Compare commits

...
Author SHA1 Message Date
Sakura-RanChen 3bf619e2dd 调整相关逻辑 2026-10-28 11:45:13 +08:00
Sakura-RanChen 1120be2f60 增加控制台配置,调整部分逻辑 2026-10-26 11:40:00 +08:00
9880ce998f fix: 把已识别说话人附到 MCP 接入点 tools/call (#3443)
声纹接口返回的 speaker_id 此前只换成展示名交给 LLM,接入点转发工具参数时不会带上说话人。识别成功后把 speakerId / speakerName 写入 tools/call 的 params._meta,工具参数保持原样;可用 mcp_forward_speaker 关闭。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: shinjiyu <shinjiyu@users.noreply.github.com>
2026-10-26 10:04:51 +08:00
hrz 9bd668aebc Merge pull request #3449 from TXyy2023/codex/issue-3050-preserve-model-type
fix(manager-api): 保存模型时保留供应器登记的类型大小写
2026-10-23 22:17:36 +08:00
hrz d027432432 Merge pull request #3458 from monsterlag/fix/issue-3064-b736
fix(manager-web): 修复短屏下模型配置知识库入口被裁剪
2026-10-23 21:53:42 +08:00
hrz 3972df6e4e 加固模型配置导航的契约测试
原来的用例只取第一条样式规则,后面谁再补一条同名规则就能把滚动改回去,测试照样是绿的。这次把导航用到的样式选择器逐条列出来核对,外层容器也要求只能有一条规则,这样有人悄悄覆盖时测试会当场报错。
2026-10-23 13:39:19 +00:00
hrz 8f2dda09cf Bump to 0.9.8 (#3469) 2026-10-23 18:54:05 +08:00
hrz d4ff9c5c17 修掉通讯录的两处越权(读 + 写),并补上三处空指针 (#3460)
* 读取设备通讯录先验设备归属

登录用户传任意 MAC 就能读到别人设备通讯录里的备注和称呼。归属判断收进一个方法,读取通讯录、改别名、改权限都走它,不属于当前用户的设备一律按查不到处理。

* 通讯录存别名不再空指针

对方设备没登记时取备注会直接崩,整条权限授予请求跟着挂掉。取不到就退回尾号称呼,呼叫时主叫没登记也不再崩,顺手删掉一行调试打印。

* 解绑先验归属,通讯录别名不再落空

解绑接口原来不核对设备归属,谁知道对方的设备 id 就能把那台设备的通讯录清空,设备本身倒是因为带了用户条件没删掉。现在先看归属,不是自己的直接不动。

设备登记了但用户没起名时,别名会落成空,缓存刷新把整条跳过,对方再也呼不过来。改成和呼叫那处一样,两级判空都取不到才退回尾号称呼。顺带加了一条回填,把写进去的空值补上。

呼叫那条测试原来会真发请求也没验到东西,改成桩掉网关直接看发出去的称呼。

* 通讯录空别名兜底,撞名加后缀

缓存拼表之前,空别名先用尾号称呼顶上,不再整条跳过,用户没起名的设备也能被呼到。

昵称在同一个人的通讯录里是索引,两台撞名时后写的会盖掉先写的,按昵称呼会呼错设备。加后缀区分,规则和存表时那套一致。取名靠先后,而查询不带排序,所以先按地址排一遍,同样的数据每次刷新结果一样。

数据库没有排序保证,去重全靠顺序,所以这里先排再拼。迁移脚本不用动,运行时的兜底能管住它没覆盖到的行。

* 通讯录取名两条路径对上了

后缀原来只写进缓存,接口直查库返回原值,同一份数据给的是两个名字。
用户照控制台看到的名字呼叫,命中的不是同一台设备。

把取名抽成一个方法,两边都走一遍。顺带把权限位排到地址前面,
撞名时能呼的那台先拿原名,反过来用户怎么点都只会说没权限。

迁移取末两位时改成跟着小写化,和运行期算出的是同一个称呼。

* 同一个人被拆成两本通讯录,一台设备消失

两条记录的归属地址只差大小写时,会被算成两个人,各拿一个同名,
写进同一本时后者盖掉前者,一台设备就从缓存里消失了。

把归属地址归一抽成一个方法,算称呼、写缓存、查缓存都走它。

* 通讯录接口按最近改过的排前,这个约定有用例钉住

智控台页面是按对方地址把备注合进去的,接口返回顺序它并不关心,
所以现在这么排对界面没有效果。

用例留着是防它被当无用代码删掉,删了会有用例报出来。

* 迁移串到别人的私名,撞车的组改走尾号称呼

通讯录回填名字时,设备表里同一个 MAC 有两行(同一台被两个用户各登记一次),
MySQL 自己挑一行填进去,跟这行属于谁无关。改成只有一台时才取智控台名字,
多台就退回按 MAC 末两位算的称呼。名字照样填上,缓存照样刷新,对方还是呼得到。

补了 12 条在真库上跑的用例,另配一条排查重复 MAC 的语句。

* 去掉走不到的分支和没人用的桩,补上末尾换行

getUser() 取不到 subject 或 principal 时返回的是空对象,不是 null,
user == null 走不到,删掉;用例改成桩空对象,测真正的匿名那条路。
归属判断挪进 service 之后,controller 层四处 SecurityUser 的桩没人用了,删掉。
四个文件末尾少了换行,补上。另加一条用例钉住判重分组的比较规则:
只折叠大小写,不折叠分隔符。

* 移除回填通讯录别名的迁移,运行时已经兜住了

这个迁移是本 PR 带进来的。它要把空别名刷成称呼,可运行时的兜底已经做了一样的事,
而且覆盖得更宽:那边连纯空格、地址为空和不足两位的都管。删掉它,功能不受影响。

删它有两条理由。一是重复。二是它按 MAC 去设备表取名字,
而一个 MAC 在设备表里可能有多行——同一台设备被两个用户各登记一次就能造出来,
这时候取到的是别人给自己设备起的名字。写在这里,是怕后来的人以为这个坑不存在。

* 归属校验撞到重复 MAC 不再报错

库里同一 MAC 可能有两行只差大小写,selectOne 会抛 TooManyResultsException,把读通讯录接口整个带崩。改成 selectList 按 id 取第一条做归属校验,撞行时正常返回,不再报错。
2026-10-23 17:43:05 +08:00
wengzh 3709540efd Merge pull request #3445 from xinnan-tech/fix/huoshan-double-stream-4-bugs-y15
huoshan_double_stream 修 4 个会话协议与监听竞态 bug
2026-10-23 17:37:05 +08:00
wengzh 333050ae2e Merge pull request #3464 from xinnan-tech/chore/bump-deps-20261023
chore(deps): 批量升级 15 个依赖,适配 mcp 2.x
2026-10-23 17:05:05 +08:00
Sakura-RanChen 95ce49ffc9 删除 benchmark/ 下 3438~3441 回归验证脚本
四处修复均已验证收敛,脚本不进 CI、裸跑依赖 manager-api,保留仅为仓库噪声;后续若再动 huoshan_double_stream 时以 pytest 覆盖
2026-10-23 17:04:52 +08:00
Sakura-RanChen be75e7bd39 chore(deps): 批量升级 15 个依赖,适配 mcp 2.x
源 PR: #3426 #3427 #3428 #3429

剔除 4 项:
- edge-tts 7.3.1->7.2.8 是降级
- powermem >=1.1.7 要求 Python>=3.11
- openai 3.29.0 与 powermem 冲突(powermem 要求 openai<3.0.0)
- ruamel.yaml 0.19.1 与 hyperpyyaml 冲突

mcp 2.x 适配: streamablehttp_client 更名为 streamable_http_client,headers/timeout
改由 http_client 承载,产出从 3 元组变 2 元组,ProgressFnT 移到 mcp.shared.dispatcher
2026-10-23 15:15:50 +08:00
hrz 24fbf7eee2 登录 token 12h 静默失效补 silent refresh 接口 #3392 (#3402)
* 登录 token 12h 静默失效补 silent refresh 接口 #3392

方案 A:后端 POST /xiaozhi/user/refresh(anon),旧 token 校验通过后强制覆盖为新 token 字符串,旧 token 在 DB 查不到即失效。前端 httpRequest 在 sendRequest 入口检查 token 剩余有效期,剩 1h 内触发 silent refresh(独立 fly 实例 + 单飞锁),成功后 setToken 替换并自动继续发送当前请求,失败走 clearAuth 跳登录。

* refreshToken 改 CAS 防并发 race + 拆前端副作用 #3392

P1:SysUserTokenDao 新增 refreshTokenCAS,条件 UPDATE(oldToken + 未过期 + userId),受影响 0 行抛 TOKEN_INVALID;refreshToken 改调它,避免读-改-写 race。P2:refreshToken 加 @Transactional;httpRequest.getTokenExpireAt 内的 localStorage.setItem 挪到独立 persistTokenExpireAt,由 shouldRefresh 入口调用。

* getTokenExpireAt 改读 localStorage 与持久化同源 #3392

pr01 三改:原代码 getTokenExpireAt 读 store(state.token 缓存),与 persistTokenExpireAt 写 localStorage 不同源,导致 _localStoredAt 缺失 → NaN → shouldRefresh 永 false → silent refresh 永不触发。修复:getTokenExpireAt 改读 localStorage,persistTokenExpireAt 同步刷 store,保证两端一致。e2e 已验证:注入 expire=1800 token → reload → role-config mount 触发 sendRequest → Network 抓到 POST /user/refresh 200 → localStorage token 被替换为新 12h token(含 _localStoredAt)。

* 应用启动同步 localStorage token 到 store #3392

PR #3402 扩展:守卫放行后首次 API 请求时,vuex state.token 还是 null,httpRequest 读 store 取不到 Authorization 头,会被后端 401 → clearAuth 跳登录。在 main.js 应用启动阶段就把 localStorage 里的 token commit 进 store,避免 race。不动 refresh 链路与其它业务文件。
2026-10-23 09:47:41 +08:00
monsterlag 855a64cbb9 fix(manager-web): keep model navigation scrollable on short screens 2026-10-22 04:06:52 +00:00
hrzandSakura-RanChen fdf280f84a 模板 GET 入口迁回 AgentTemplateController #3395 (#3401)
* 模板 GET 入口迁回 AgentTemplateController #3395

根因:AgentController 已存在 GET /agent/template/templateList,AgentTemplateController 缺显式 GET,于是路由虽通却跨 controller 暴露模板数据,且职责错位。修复:删 AgentController.templateList,同名 @GetMapping(无 path)落到 AgentTemplateController,权限 normal、orderByAsc sort、复用现有 service.list。验证:spring-boot 启动期无 Ambiguous;/agent/template、/page、/{id} 三路由均被 Shiro 一致拦截,权限/路径正确。

* 补 P2 注释说明 normal 权限反直觉是有意保留 #3395

在 getAgentTemplates 上方加 2 行注释,说明单独 normal 权限是为保持原行为契约(普通用户也能查可用模板),与类级 superAdmin 故意分离;非疏忽。无逻辑变更。

* 去除未使用引用

---------

Co-authored-by: Sakura-RanChen <1908198662@qq.com>
2026-10-22 11:42:48 +08:00
wengzh 498191519c Merge pull request #3452 from xinnan-tech/py-test-wakeup
Py test wakeup
2026-10-22 11:08:38 +08:00
wengzh f614489d81 Merge pull request #3454 from xinnan-tech/web-test-json
Web test json
2026-10-20 15:44:16 +08:00
Sakura-RanChen 6f958ee4a6 fix: 新增模型页面dict字段错误解析 2026-10-20 15:34:40 +08:00
TXyy2023 b226ee5430 fix(manager-web): decode stored dictionaries before editing (#3450) 2026-10-20 11:53:02 +08:00
Sakura-RanChen 9c30b5e035 文件直接写入wav,无需经过二次编解码 2026-10-20 10:45:31 +08:00
TXyy2023 d531d04281 fix(tts): handle retained audio files in wakeup cache (#3451) 2026-10-20 10:00:20 +08:00
Sakura-RanChen d8f8a4a083 - 取消屏障保证:新连接创建前 _cancel_monitor_task 已等旧监听任务完全退出,
旧任务不可能读新连接或误关新连接(check_huoshan_3440 动态断言覆盖)
- mismatch 分支仅保留纯跳过,删除旧会话结束事件的复位遗留,
  activate_session 写入点收敛到会话生命周期(start / matched 结束 / close)
- 删除 _session_start_future 启动确认机制:start_session 发送即返回,
  SessionFailed 由 monitor 记 error 并复位 activate_session,无挂起风险
- 回归脚本同步调整:3438 重写为新行为断言,3440 用屏障断言替换 snapshot 断言
2026-10-19 17:52:25 +08:00
TXyy2023 37d4330224 test(model): verify registered types with MySQL and Redis 2026-10-18 23:13:39 +08:00
TXyy2023 7873984f9f fix(manager-api): preserve registered model type casing 2026-10-18 17:10:47 +08:00
hrz 6840210ea8 check_huoshan_3439 改 FIRST 分支行为断言
原 test 用正则 grep 源码验存在性,pr02 复评指出这样测不到实际调用。
改成起后台线程跑真 tts_text_priority_thread、构造 FIRST 消息入队、
run_coroutine_threadsafe patch 成立即完成的 future,最后断言
reset_stream_state 与 opus_encoder.reset_state 各被调一次。
2026-10-17 07:57:30 +08:00
hrz 0a54124d0d huoshan_double_stream 修 3 处复评反馈
init 漏写 _pending_prefix 字段、start_session 用已弃用的
get_event_loop、finish_session 留着多余的 getattr 兜底。补字段、
换 get_running_loop、去兜底,3 处都修了。
2026-10-17 07:57:26 +08:00
hrz ddc441bc59 补 huoshan_double_stream 4 bug 复现脚本
四个独立脚本各覆盖一个 issue:start_session 等 future 确认、句尾残留与编码器
冲刷、monitor snapshot 隔离、parser_response 四处协议解析。共享 _huoshan_mock
构造一个绕开父类 init 的假 provider 实例。
2026-10-17 07:39:51 +08:00
hrz 9b7fb3e826 parser_response 四处二进制协议解析修正
serialization_method 错用掩码当移位数恒 0,改成 >>4;message_type 写
== FULL or AUDIO_ONLY_RESPONSE 优先级下恒真,改 in (...);offset 硬编码 4 在
header_size>1 全错位,改成 header_size * 4;SessionCanceled 漏进协议分支被
else 误读,补进统一解析。
2026-10-17 07:39:51 +08:00
hrz a6cdbf9410 监听任务用 snapshot 隔离跨连接
旧 monitor 循环直接 self.ws.recv(),连接重建后旧任务读新下行;finally 无条件
关 self.ws 误关新连接。改成入口绑 monitor_ws / monitor_task,recv 用 snapshot;
旧 session 事件 continue 不再 reset 当前;finally 判 id 相同才关 ws / 清引用。
2026-10-17 07:39:51 +08:00
hrz 75fd5d8cc3 句尾替换词残留先发 + 会话结束冲刷编码器
旧 finish_session 不发 _pending_prefix 残留,句尾替换词被扣时整段丢;monitor / to_tts
收 SessionFinished 不调 is_end=True 冲刷 opus 编码器,每句固定丢尾。改成
finish_session 开头先发残留,monitor / to_tts 调 is_end=True 冲刷,FIRST 同步重置。
2026-10-17 07:39:51 +08:00
hrz 6fe5630346 TTS 会话等 SessionStarted 确认
旧逻辑 start_session 发完 StartSession 即返回;服务端鉴权失败或 14s 内 26005 时
文本线程继续给死会话发文本,整轮无声。改成末尾 await 一个 future:monitor 读到
SessionStarted set True、读到 SessionFailed set False,超时报错让文本线程感知。
2026-10-17 07:39:50 +08:00
58 changed files with 2166 additions and 193 deletions

No files matched your search

@@ -324,7 +324,7 @@ public interface Constant {
/**
* 版本号
*/
public static final String VERSION = "0.9.7";
public static final String VERSION = "0.9.8";
/**
* 无效固件URL
@@ -19,8 +19,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
@@ -44,7 +42,6 @@ import xiaozhi.modules.agent.dto.AgentDTO;
import xiaozhi.modules.agent.dto.AgentMemoryDTO;
import xiaozhi.modules.agent.dto.AgentUpdateDTO;
import xiaozhi.modules.agent.entity.AgentEntity;
import xiaozhi.modules.agent.entity.AgentTemplateEntity;
import xiaozhi.modules.agent.dto.AgentTagDTO;
import xiaozhi.modules.agent.entity.AgentTagEntity;
import xiaozhi.modules.agent.service.AgentTagService;
@@ -52,7 +49,6 @@ import xiaozhi.modules.agent.service.AgentChatAudioService;
import xiaozhi.modules.agent.service.AgentChatHistoryService;
import xiaozhi.modules.agent.service.AgentChatSummaryService;
import xiaozhi.modules.agent.service.AgentService;
import xiaozhi.modules.agent.service.AgentTemplateService;
import xiaozhi.modules.agent.vo.AgentChatHistoryUserVO;
import xiaozhi.modules.agent.vo.AgentInfoVO;
import xiaozhi.modules.security.user.SecurityUser;
@@ -65,7 +61,6 @@ public class AgentController {
private static final long AUDIO_PLAY_TOKEN_EXPIRE_SECONDS = 300L;
private final AgentService agentService;
private final AgentTemplateService agentTemplateService;
private final AgentChatHistoryService agentChatHistoryService;
private final AgentChatAudioService agentChatAudioService;
private final AgentChatSummaryService agentChatSummaryService;
@@ -192,15 +187,6 @@ public class AgentController {
return new Result<>();
}
@GetMapping("/template")
@Operation(summary = "智能体模板模板列表")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTemplateEntity>> templateList() {
List<AgentTemplateEntity> list = agentTemplateService
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
return new Result<List<AgentTemplateEntity>>().ok(list);
}
@GetMapping("/{id}/sessions")
@Operation(summary = "获取智能体会话列表")
@RequiresPermissions("sys:role:normal")
@@ -41,6 +41,17 @@ public class AgentTemplateController {
private final AgentTemplateService agentTemplateService;
// 单独用 normal 权限(而非类级 superAdmin)是有意保留:
// 普通用户也需查询可用模板,与 /page 的管理入口分离,保持原行为契约。
@GetMapping
@Operation(summary = "智能体模板列表")
@RequiresPermissions("sys:role:normal")
public Result<List<AgentTemplateEntity>> getAgentTemplates() {
List<AgentTemplateEntity> list = agentTemplateService
.list(new QueryWrapper<AgentTemplateEntity>().orderByAsc("sort"));
return new Result<List<AgentTemplateEntity>>().ok(list);
}
@GetMapping("/page")
@Operation(summary = "获取模板分页列表")
@RequiresPermissions("sys:role:superAdmin")
@@ -174,6 +174,9 @@ public class DeviceController {
@Operation(summary = "获取设备通讯录")
@RequiresPermissions("sys:role:normal")
public Result<Object> getAddressBook(@PathVariable String macAddress) {
if (deviceService.getOwnedDeviceByMacAddress(macAddress) == null) {
return new Result<Object>().error(ErrorCode.DEVICE_NOT_EXIST);
}
return new Result<Object>().ok(deviceAddressBookService.getAddressBookList(macAddress));
}
@@ -192,9 +195,7 @@ public class DeviceController {
@Operation(summary = "更新设备通讯录别名")
@RequiresPermissions("sys:role:normal")
public Result<Void> updateAlias(@Valid @RequestBody DeviceAddressBookAliasDTO dto) {
UserDetail user = SecurityUser.getUser();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
return new Result<Void>().error("无权限操作该设备");
}
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), dto.getAlias(), null);
@@ -205,9 +206,7 @@ public class DeviceController {
@Operation(summary = "更新设备通讯录权限")
@RequiresPermissions("sys:role:normal")
public Result<Void> updatePermission(@Valid @RequestBody DeviceAddressBookPermissionDTO dto) {
UserDetail user = SecurityUser.getUser();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
return new Result<Void>().error("无权限操作该设备");
}
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
@@ -83,6 +83,16 @@ public interface DeviceService extends BaseService<DeviceEntity> {
*/
DeviceEntity getDeviceByMacAddress(String macAddress);
/**
* 根据MAC地址获取当前登录用户名下的设备信息
* <p>
* 设备不存在或不属于当前用户时返回null,调用方无需再自行比对归属。
*
* @param macAddress MAC地址
* @return 当前用户名下的设备信息,不存在或无权限时返回null
*/
DeviceEntity getOwnedDeviceByMacAddress(String macAddress);
/**
* 根据设备ID获取激活码
*
@@ -1,9 +1,13 @@
package xiaozhi.modules.device.service.impl;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
@@ -18,6 +22,7 @@ import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
@@ -40,7 +45,11 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
@Override
public List<DeviceAddressBookEntity> getAddressBookList(String macAddress) {
return deviceAddressBookDao.getAddressBookList(macAddress);
List<DeviceAddressBookEntity> rows = assignUniqueAliases(deviceAddressBookDao.getAddressBookList(macAddress));
// 取名按固定顺序算完了,前端要看的是最近改过的排前面,放回去
rows.sort(Comparator
.comparing(DeviceAddressBookEntity::getUpdateDate, Comparator.nullsLast(Comparator.reverseOrder())));
return rows;
}
@Override
@@ -63,7 +72,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
// 主动呼叫模式
Map<String, String> callerBook = allBooks.get(callerMac.toLowerCase());
Map<String, String> callerBook = allBooks.get(bookKey(callerMac));
if (callerBook == null) {
return errorResult("未找到备注为'" + nickname + "'的设备");
}
@@ -80,13 +89,16 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
// 获取目标设备如何称呼主叫方
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
Map<String, String> targetBook = allBooks.get(bookKey(targetMac));
String callerNickname = null;
if (targetBook != null) {
callerNickname = targetBook.get(callerMac.toLowerCase());
callerNickname = targetBook.get(bookKey(callerMac));
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
if (callerDevice != null) {
callerNickname = callerDevice.getAlias();
}
if (StringUtils.isBlank(callerNickname)) {
callerNickname = formatMacAsDeviceName(callerMac);
}
@@ -100,32 +112,26 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
@Override
public void refreshCache() {
Map<String, Map<String, String>> result = new HashMap<>();
List<DeviceAddressBookEntity> allRecords = deviceAddressBookDao.selectList(null);
List<DeviceAddressBookEntity> allRecords = assignUniqueAliases(deviceAddressBookDao.selectList(null));
Map<String, String> reverseMap = new HashMap<>();
for (DeviceAddressBookEntity entity : allRecords) {
String macA = entity.getMacAddress().toLowerCase();
String macB = entity.getTargetMac().toLowerCase();
String alias = entity.getAlias();
Boolean hasPermission = entity.getHasPermission();
String macA = bookKey(entity.getMacAddress());
String macB = bookKey(entity.getTargetMac());
// 构建正向映射: A对B的映射 nickname -> macB|permission
if (alias != null && !alias.isEmpty()) {
result.computeIfAbsent(macA, k -> new HashMap<>());
String permStr = (hasPermission != null && hasPermission) ? "1" : "0";
result.get(macA).put(alias, macB + "|" + permStr);
}
Map<String, String> book = result.computeIfAbsent(macA, k -> new HashMap<>());
String permStr = (Boolean.TRUE.equals(entity.getHasPermission())) ? "1" : "0";
book.put(entity.getAlias(), macB + "|" + permStr);
// 构建反向记录用于查找B对A的称呼
if (alias != null && !alias.isEmpty()) {
reverseMap.put(macB + ":" + macA, alias);
}
// 构建反向记录用于查找B对A的称呼,用同一个去重后的名字,两边才对得上
reverseMap.put(macB + ":" + macA, entity.getAlias());
}
// 构建反向映射: B对A的映射 macA -> nickname
for (DeviceAddressBookEntity entity : allRecords) {
String macA = entity.getMacAddress().toLowerCase();
String macB = entity.getTargetMac().toLowerCase();
String macA = bookKey(entity.getMacAddress());
String macB = bookKey(entity.getTargetMac());
String aliasBtoA = reverseMap.get(macA + ":" + macB);
if (aliasBtoA != null && !aliasBtoA.isEmpty()) {
result.computeIfAbsent(macB, k -> new HashMap<>());
@@ -136,6 +142,54 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
redisUtils.set(RedisKeys.getAddressBookKey(), result);
}
/**
* 给每行算出一个不重名的称呼,返回排好序、alias 已写好的新列表
*
* 昵称在同一个人的通讯录里是索引,同名后写的会盖掉先写的,按昵称呼会呼错设备。
* 库里本来就有重名:空别名补出来的尾号称呼只有 256 种取值,一本通讯录十几条
* 没起名就撞上了。所以显示和呼叫都得走这一份,
* 不然前端看到的字和呼叫时用的名字对不上,用户照着点的名字呼不到人。
* 不改传入的那个列表:它是 DAO 刚查出来的,调用方还要按自己的顺序用
*/
private List<DeviceAddressBookEntity> assignUniqueAliases(List<DeviceAddressBookEntity> rows) {
// 取名要靠先后定谁拿原名,而 selectList 不带 order by,返回顺序不保证。
// 排一次,同样的数据每次算出来的结果一样,缓存不会来回变。
// 按归一后的地址排:同一个人不管库里是哪种写法都排到一起,
// 后面两级比较器再分先后。同一本里 target_mac 不会重复(主键摆着),
// 所以顺序还是由这三个键定死的,不会掉回"跟着数据库返回顺序走"
List<DeviceAddressBookEntity> ordered = new ArrayList<>(rows);
ordered.sort(Comparator
.comparing((DeviceAddressBookEntity row) -> bookKey(row.getMacAddress()),
Comparator.nullsFirst(Comparator.naturalOrder()))
// 权限排在地址前面:撞名时让对方准许呼我的那台先拿原名。
// 排在后面等于不起作用,两条地址不同时谁先谁后是地址定的,
// 没权限那条照样占住原名,用户照前端看到的名字呼叫命中的就是它,
// 界面上只会说没权限,怎么点都不通
.thenComparing(DeviceAddressBookEntity::getHasPermission,
Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(DeviceAddressBookEntity::getTargetMac, Comparator.nullsFirst(Comparator.naturalOrder())));
Map<String, Set<String>> takenByOwner = new HashMap<>();
for (DeviceAddressBookEntity row : ordered) {
// 建表时 alias 默认是空的,用户没起名就是空的,这是默认状态不是异常。
// 补个尾号称呼顶上,整条跳过的话接口照样返回成功,对方却再也呼不过来
String alias = StringUtils.isBlank(row.getAlias())
? formatMacAsDeviceName(row.getTargetMac())
: row.getAlias();
// 加后缀的写法跟 generateUniqueAlias 保持一致,不搞两套命名规则
Set<String> taken = takenByOwner.computeIfAbsent(bookKey(row.getMacAddress()), k -> new HashSet<>());
String uniqueAlias = alias;
int suffix = 1;
while (taken.contains(uniqueAlias)) {
uniqueAlias = alias + suffix++;
}
taken.add(uniqueAlias);
row.setAlias(uniqueAlias);
}
return ordered;
}
@Override
public void updateAlias(String macAddress, String targetMac, String alias) {
String finalAlias = generateUniqueAlias(macAddress, targetMac, alias);
@@ -154,14 +208,22 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
QueryWrapper<DeviceAddressBookEntity> wrapper = new QueryWrapper<>();
wrapper.eq("mac_address", macAddress).eq("target_mac", targetMac);
DeviceAddressBookEntity record = deviceAddressBookDao.selectOne(wrapper);
System.out.println("saveOrUpdate - mac=" + macAddress + ", targetMac=" + targetMac + ", alias=" + alias + ", record=" + (record == null ? "null" : "not null"));
if (record == null) {
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
entity.setMacAddress(macAddress);
entity.setTargetMac(targetMac);
// 如果 alias 为空,就默认使用设备名称
// 如果 alias 为空,就默认使用设备名称;对方设备尚未登记时退回尾号称呼
if (StringUtils.isBlank(alias)) {
alias = deviceService.getDeviceByMacAddress(targetMac).getAlias();
DeviceEntity targetDevice = deviceService.getDeviceByMacAddress(targetMac);
if (targetDevice != null) {
alias = targetDevice.getAlias();
}
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
// 接口照样返回成功,对方却永远呼不过来
if (StringUtils.isBlank(alias)) {
alias = formatMacAsDeviceName(targetMac);
}
}
// 检查重名
alias = generateUniqueAlias(macAddress, targetMac, alias);
@@ -229,11 +291,27 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
}
}
/**
* 通讯录里当主人用的地址键
*
* <p>
* 写缓存、查缓存、算称呼都靠它分组,三处必须是同一个值。少归一一次,同一个人的两行
* 会被当成两个人:各自拿到同名,写进同一本时后者盖掉前者,一台设备直接从缓存里消失。
* 库里确实能出现这种数据——表是 utf8mb4_0900_ai_ci,主键 (mac_address, target_mac)
* 大小写不敏感,owner 只差大小写而 target 不同的话两行不撞
*/
private static String bookKey(String macAddress) {
return macAddress == null ? null : macAddress.toLowerCase();
}
private String formatMacAsDeviceName(String mac) {
if (StringUtils.isBlank(mac) || mac.length() < 2) {
return mac;
}
String lastTwo = mac.substring(mac.length() - 2);
// MAC 本身不区分大小写,落库、查库走的都是小写化的值。
// 这里跟着小写化,不跟着的话同一个地址在两条路径上会算出两个称呼,
// 数据搬到智控台上看到的名字就叫不到人了
String lastTwo = mac.substring(mac.length() - 2).toLowerCase();
return "尾号为" + lastTwo + "的设备";
}
@@ -316,6 +316,12 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
if (device == null) {
return;
}
// 后面删通讯录那步是按 mac_address 删的,不看 user_id。
// 设备本身因为带了 user_id 条件删不掉,通讯录却会照删不误,
// 任意登录用户知道对方的设备 id 就能把对方整本通讯录清空
if (userId == null || device.getUserId() == null || !device.getUserId().equals(userId)) {
return;
}
String macAddress = device.getMacAddress();
if (StringUtils.isNotBlank(device.getAgentId())) {
// 清除智能体设备数量缓存
@@ -400,6 +406,29 @@ public class DeviceServiceImpl extends BaseServiceImpl<DeviceDao, DeviceEntity>
return baseDao.selectOne(wrapper);
}
@Override
public DeviceEntity getOwnedDeviceByMacAddress(String macAddress) {
if (StringUtils.isBlank(macAddress)) {
return null;
}
// 同一 MAC 可能有两行只差大小写,selectOne 会抛 TooManyResultsException
// 把整个读通讯录接口带崩。按 id 取第一条做归属校验,撞行不报错
QueryWrapper<DeviceEntity> wrapper = new QueryWrapper<>();
wrapper.eq("mac_address", macAddress).orderByAsc("id");
List<DeviceEntity> devices = baseDao.selectList(wrapper);
if (devices == null || devices.isEmpty()) {
return null;
}
DeviceEntity device = devices.get(0);
UserDetail user = SecurityUser.getUser();
// getUser() 在取不到 subject、principal 时返回的是空对象,不是 null,
// 所以这里不会拿到 null。匿名调用走的就是空对象那一条,id 是空的,比不上
if (device.getUserId() == null || !device.getUserId().equals(user.getId())) {
return null;
}
return device;
}
private DeviceReportRespDTO.ServerTime buildServerTime() {
DeviceReportRespDTO.ServerTime serverTime = new DeviceReportRespDTO.ServerTime();
TimeZone tz = TimeZone.getDefault();
@@ -110,8 +110,8 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
// 1. 参数验证
validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
// 2. 验证模型提供者
validateModelProvider(modelType, provideCode);
// 2. 验证模型提供者,使用注册类型的大小写
String registeredModelType = resolveModelType(modelType, provideCode);
// 3. 获取原始配置(不经过敏感数据处理)
ModelConfigEntity originalEntity = getOriginalConfigFromDb(id);
@@ -120,7 +120,7 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
validateLlmConfiguration(modelConfigBodyDTO);
// 5. 准备更新实体并处理敏感数据
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, id);
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, registeredModelType, id);
// 6. 执行数据库更新
modelConfigDao.updateById(modelConfigEntity);
@@ -136,9 +136,9 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
public ModelConfigDTO add(String modelType, String provideCode, ModelConfigBodyDTO modelConfigBodyDTO) {
validateAddParameters(modelType, provideCode, modelConfigBodyDTO);
validateModelProvider(modelType, provideCode);
String registeredModelType = resolveModelType(modelType, provideCode);
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, registeredModelType);
modelConfigDao.insert(modelConfigEntity);
@@ -283,13 +283,14 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
}
/**
* 验证模型提供者
* 验证模型提供者并获取注册类型,保留 TTS、Memory、Intent 等类型的大小写
*/
private void validateModelProvider(String modelType, String provideCode) {
private String resolveModelType(String modelType, String provideCode) {
List<ModelProviderDTO> providerList = modelProviderService.getList(modelType, provideCode);
if (CollectionUtil.isEmpty(providerList)) {
throw new RenException(ErrorCode.MODEL_PROVIDER_NOT_EXIST);
}
return providerList.get(0).getModelType();
}
/**
@@ -86,6 +86,7 @@ public class ShiroConfig {
filterMap.put("/user/captcha", "anon");
filterMap.put("/user/smsVerification", "anon");
filterMap.put("/user/login", "anon");
filterMap.put("/user/refresh", "anon");
filterMap.put("/user/pub-config", "anon");
filterMap.put("/user/register", "anon");
filterMap.put("/user/retrieve-password", "anon");
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -110,6 +111,17 @@ public class LoginController {
return sysUserTokenService.createToken(userDTO.getId());
}
@PostMapping("/refresh")
@Operation(summary = "刷新token:用旧token换新token")
public Result<TokenDTO> refresh(HttpServletRequest request) {
String authorization = request.getHeader(Constant.AUTHORIZATION);
if (StringUtils.isBlank(authorization) || !authorization.startsWith("Bearer ")) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
String oldToken = authorization.replace("Bearer ", "");
return sysUserTokenService.refreshToken(oldToken);
}
@PostMapping("/register")
@Operation(summary = "注册")
public Result<Void> register(@RequestBody LoginDTO login) {
@@ -21,4 +21,14 @@ public interface SysUserTokenDao extends BaseDao<SysUserTokenEntity> {
SysUserTokenEntity getByUserId(Long userId);
void logout(@Param("userId") Long userId, @Param("expireDate") Date expireDate);
/**
* 用旧 token 换新 token 的 CAS 更新:仅当旧 token 未过期且属于指定 user 时才覆盖。
* 返回受影响行数(0 表示条件不满足 = token 已被并发刷新或过期)。
*/
int refreshTokenCAS(@Param("userId") Long userId,
@Param("oldToken") String oldToken,
@Param("newToken") String newToken,
@Param("newExpire") Date newExpire,
@Param("updateDate") Date updateDate);
}
@@ -21,6 +21,13 @@ public interface SysUserTokenService extends BaseService<SysUserTokenEntity> {
*/
Result<TokenDTO> createToken(Long userId);
/**
* 用旧token换新token,旧token立即失效
*
* @param oldToken 旧token字符串
*/
Result<TokenDTO> refreshToken(String oldToken);
SysUserDTO getUserByToken(String token);
/**
@@ -2,7 +2,9 @@ package xiaozhi.modules.security.service.impl;
import java.util.Date;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import cn.hutool.core.date.DateUtil;
import lombok.AllArgsConstructor;
@@ -81,6 +83,36 @@ public class SysUserTokenServiceImpl extends BaseServiceImpl<SysUserTokenDao, Sy
return new Result<TokenDTO>().ok(tokenDTO);
}
@Override
@Transactional
public Result<TokenDTO> refreshToken(String oldToken) {
if (StringUtils.isBlank(oldToken)) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
SysUserTokenEntity tokenEntity = baseDao.getByToken(oldToken);
if (tokenEntity == null) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
if (tokenEntity.getExpireDate().before(new Date())) {
throw new RenException(ErrorCode.UNAUTHORIZED);
}
// CAS 更新:并发场景下只有一个请求能匹配到该 oldToken,另一个返 0 行
String newToken = TokenGenerator.generateValue();
Date now = new Date();
Date expireTime = new Date(now.getTime() + EXPIRE * 1000L);
int affected = baseDao.refreshTokenCAS(tokenEntity.getUserId(), oldToken, newToken, expireTime, now);
if (affected == 0) {
throw new RenException(ErrorCode.TOKEN_INVALID);
}
TokenDTO dto = new TokenDTO();
dto.setToken(newToken);
dto.setExpire(EXPIRE);
dto.setClientHash(HttpContextUtils.getClientCode());
return new Result<TokenDTO>().ok(dto);
}
@Override
public SysUserDTO getUserByToken(String token) {
SysUserTokenEntity userToken = baseDao.getByToken(token);
@@ -0,0 +1,4 @@
-- 添加MCP接入点附带说话人身份的开关参数配置
delete from `sys_params` where param_code = 'mcp_forward_speaker';
INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark)
VALUES (313, 'mcp_forward_speaker', 'true', 'boolean', 1, '是否随MCP调用附带说话人身份');
@@ -753,3 +753,10 @@ databaseChangeLog:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202609172100.sql
- changeSet:
id: 202610261030
author: RanChen
changes:
- sqlFile:
encoding: utf8
path: classpath:db/changelog/202610261030.sql
@@ -14,4 +14,12 @@
<update id="logout">
update sys_user_token set expire_date = #{expireDate} where user_id = #{userId}
</update>
<update id="refreshTokenCAS">
update sys_user_token
set token = #{newToken}, update_date = #{updateDate}, expire_date = #{newExpire}
where token = #{oldToken}
and expire_date > NOW()
and user_id = #{userId}
</update>
</mapper>
@@ -44,7 +44,7 @@ class SensitiveDataUtilsTest {
void maskMiddle_keeps_four_at_each_end_for_long_strings() {
String masked = SensitiveDataUtils.maskMiddle("verylongapikeyvalue123");
assertTrue(masked.startsWith("very"));
assertTrue(masked.endsWith("3123"));
assertTrue(masked.endsWith("e123"));
assertTrue(masked.contains("****"));
assertEquals("verylongapikeyvalue123".length(), masked.length());
}
@@ -1,11 +1,15 @@
package xiaozhi.modules.device.controller;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
@@ -15,7 +19,10 @@ import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.common.utils.Result;
import xiaozhi.modules.device.dto.DeviceAddressBookAliasDTO;
import xiaozhi.modules.device.dto.DeviceAddressBookPermissionDTO;
import xiaozhi.modules.device.dto.DeviceUpdateDTO;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.DeviceService;
@@ -26,6 +33,8 @@ import xiaozhi.modules.sys.service.SysParamsService;
class DeviceControllerTest {
private static final String DEVICE_ID = "device-id";
private static final String MAC = "00:11:22:33:44:55";
private static final String OTHER_MAC = "00:11:22:33:44:66";
private static final long USER_ID = 1L;
@Test
@@ -35,7 +44,7 @@ class DeviceControllerTest {
DeviceEntity entity = ownedDevice();
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
when(deviceService.updateById(entity)).thenReturn(false);
DeviceController controller = controller(deviceService);
DeviceController controller = controller(deviceService, mock(DeviceAddressBookService.class));
DeviceUpdateDTO update = new DeviceUpdateDTO();
update.setAutoUpdate(0);
@@ -54,10 +63,84 @@ class DeviceControllerTest {
}
}
private DeviceController controller(DeviceService deviceService) {
@Test
@DisplayName("读取他人设备通讯录时一律按设备不存在处理")
void addressBookOfDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
try (MockedStatic<MessageUtils> messageUtils = mockStatic(MessageUtils.class)) {
messageUtils.when(() -> MessageUtils.getMessage(ErrorCode.DEVICE_NOT_EXIST))
.thenReturn("Device not exist");
Result<Object> result = controller.getAddressBook(OTHER_MAC);
assertEquals(ErrorCode.DEVICE_NOT_EXIST, result.getCode());
assertNull(result.getData());
verify(addressBookService, never()).getAddressBookList(OTHER_MAC);
}
}
@Test
@DisplayName("读取本机设备通讯录照常返回")
void addressBookOfOwnDeviceIsReturned() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
List<DeviceAddressBookEntity> entries = List.of(new DeviceAddressBookEntity());
when(deviceService.getOwnedDeviceByMacAddress(MAC)).thenReturn(ownedDevice());
when(addressBookService.getAddressBookList(MAC)).thenReturn(entries);
DeviceController controller = controller(deviceService, addressBookService);
Result<Object> result = controller.getAddressBook(MAC);
assertEquals(0, result.getCode());
assertEquals(entries, result.getData());
}
@Test
@DisplayName("以他人设备名义改别名被拒")
void updateAliasOnDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
DeviceAddressBookAliasDTO dto = new DeviceAddressBookAliasDTO();
dto.setMacAddress(OTHER_MAC);
dto.setTargetMac(MAC);
dto.setAlias("office");
Result<Void> result = controller.updateAlias(dto);
assertEquals("无权限操作该设备", result.getMsg());
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, "office", null);
}
@Test
@DisplayName("以他人设备名义改权限被拒")
void updatePermissionOnDeviceOwnedByAnotherUserIsRejected() {
DeviceService deviceService = mock(DeviceService.class);
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
when(deviceService.getOwnedDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
DeviceController controller = controller(deviceService, addressBookService);
DeviceAddressBookPermissionDTO dto = new DeviceAddressBookPermissionDTO();
dto.setMacAddress(OTHER_MAC);
dto.setTargetMac(MAC);
dto.setHasPermission(true);
Result<Void> result = controller.updatePermission(dto);
assertEquals("无权限操作该设备", result.getMsg());
verify(addressBookService, never()).saveOrUpdate(OTHER_MAC, MAC, null, true);
}
private DeviceController controller(DeviceService deviceService, DeviceAddressBookService addressBookService) {
return new DeviceController(
deviceService,
mock(DeviceAddressBookService.class),
addressBookService,
mock(RedisUtils.class),
mock(SysParamsService.class));
}
@@ -1,26 +1,47 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Date;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import xiaozhi.common.constant.Constant;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.device.dao.DeviceAddressBookDao;
import xiaozhi.modules.device.entity.DeviceAddressBookEntity;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceService;
import xiaozhi.modules.sys.service.SysParamsService;
class DeviceAddressBookServiceImplTest {
private static final String MAC = "00:11:22:33:44:55";
private static final String OTHER_MAC = "00:11:22:33:44:66";
private static final String THIRD_MAC = "00:11:22:33:44:77";
// 纯数字的 MAC 大小写化后跟原来一模一样,拿它造不出"只差大小写"的两行
private static final String HEX_MAC = "00:11:22:33:44:ab";
@Test
void newEntryUsesTheDedicatedAddressBookInsertMapping() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
@@ -32,15 +53,363 @@ class DeviceAddressBookServiceImplTest {
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
service.saveOrUpdate(MAC, OTHER_MAC, "living-room", true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
assertEquals("00:11:22:33:44:66", entity.getTargetMac());
assertEquals(MAC, entity.getMacAddress());
assertEquals(OTHER_MAC, entity.getTargetMac());
assertEquals("living-room", entity.getAlias());
assertTrue(entity.getHasPermission());
verify(addressBookDao, never()).insert(any(DeviceAddressBookEntity.class));
}
@Test
void blankAliasOnUnregisteredTargetFallsBackToMacSuffix() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(null);
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
DeviceAddressBookEntity entity = entityCaptor.getValue();
assertNotNull(entity.getAlias());
assertEquals("尾号为66的设备", entity.getAlias());
assertTrue(entity.getHasPermission());
}
@Test
void blankAliasOnRegisteredTargetWithoutNicknameFallsBackToMacSuffix() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
DeviceEntity registered = new DeviceEntity();
registered.setMacAddress(OTHER_MAC);
registered.setAlias(null);
when(deviceService.getDeviceByMacAddress(OTHER_MAC)).thenReturn(registered);
service.saveOrUpdate(MAC, OTHER_MAC, null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
assertEquals("尾号为66的设备", entityCaptor.getValue().getAlias(),
"登记了但没起名的设备落个空名,缓存刷新会跳过这条,对方永远呼不过来");
}
@Test
void refreshCacheKeepsEntriesWhoseAliasWasNeverFilledIn() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(List.of(row(MAC, OTHER_MAC, null)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertNotNull(book, "空别名整条被跳过,这条设备就再也呼不过来了");
assertEquals(OTHER_MAC + "|1", book.get("尾号为66的设备"));
}
@Test
void refreshCacheDisambiguatesTwoEntriesSharingAnAlias() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅"),
row(MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertEquals(2, book.size(), "撞名的两台设备只留得下一台:" + book);
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
}
@Test
void refreshCacheGivesTheSameResultWhateverOrderRowsComeBackIn() {
DeviceAddressBookEntity first = row(MAC, OTHER_MAC, "客厅");
DeviceAddressBookEntity second = row(MAC, THIRD_MAC, "客厅");
Map<String, String> inOrder = refreshAndRead(List.of(first, second));
Map<String, String> reversed = refreshAndRead(List.of(second, first));
assertEquals(inOrder, reversed, "同样的数据两次刷新结果不一样,缓存里的称呼会来回变");
}
/**
* 库里能同时存在两条 owner 只差大小写的行:表是 utf8mb4_0900_ai_ci,
* 主键 (mac_address, target_mac) 大小写不敏感,target 不同就不会撞。
* 这是同一个人,算称呼和写缓存都得按小写归成一本
*/
@Test
void ownersDifferingOnlyInCaseShareOneBook() {
String upper = HEX_MAC.toUpperCase();
assertNotEquals(HEX_MAC, upper, "这个用例得真的造出大小写差异才有效");
Map<String, Map<String, String>> books = refreshAndReadAll(List.of(
row(upper, OTHER_MAC, "客厅"),
row(HEX_MAC, THIRD_MAC, "客厅")));
Map<String, String> book = books.get(HEX_MAC);
assertNotNull(book, "同一个人被算成了两个人:" + books.keySet());
assertEquals(2, book.size(), "两台设备只剩一台,另一台从缓存里消失了:" + book);
assertEquals(OTHER_MAC + "|1", book.get("客厅"));
assertEquals(THIRD_MAC + "|1", book.get("客厅1"));
assertEquals(1, books.size(), "两种写法的地址被拆成了两本:" + books.keySet());
}
@Test
void listAndCacheAgreeWhenOwnersDifferOnlyInCase() {
String upper = HEX_MAC.toUpperCase();
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.getAddressBookList(upper)).thenReturn(new ArrayList<>(List.of(
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅"))));
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(upper, OTHER_MAC, "客厅"), row(HEX_MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(upper));
assertEquals(2, shownToConsole.size(),
"接口按原值分组,同一个人的两台设备被算成一个,前端只能看到一台:" + shownToConsole);
service.refreshCache();
Map<String, String> callableNames = new HashMap<>();
cachedBook(service, redisUtils).get(HEX_MAC)
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
assertEquals(shownToConsole, callableNames,
"接口按原值分组、缓存按小写分组,用户照前端看到的名字呼,命中的不是同一台设备");
}
@Test
void nameAssignmentDoesNotDependOnWhichSpellingOfTheOwnerCameBack() {
String upper = HEX_MAC.toUpperCase();
Map<String, String> expected = Map.of("客厅", OTHER_MAC + "|1", "客厅1", THIRD_MAC + "|1");
Map<String, String> lowerFirst = refreshAndRead(HEX_MAC, List.of(
row(HEX_MAC, OTHER_MAC, "客厅"), row(upper, THIRD_MAC, "客厅")));
Map<String, String> upperFirst = refreshAndRead(HEX_MAC, List.of(
row(upper, THIRD_MAC, "客厅"), row(HEX_MAC, OTHER_MAC, "客厅")));
assertEquals(expected, lowerFirst,
"同一个人的称呼取决于库里哪种写法先返回,刷新一次就换一次名字");
assertEquals(expected, upperFirst, "顺序反过来结果就该不一样:" + upperFirst);
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, String>> refreshAndReadAll(List<DeviceAddressBookEntity> rows) {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.selectList(null)).thenReturn(rows);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
return cachedBook(service, redisUtils);
}
private Map<String, String> refreshAndRead(List<DeviceAddressBookEntity> rows) {
return refreshAndRead(MAC, rows);
}
private Map<String, String> refreshAndRead(String owner, List<DeviceAddressBookEntity> rows) {
return refreshAndReadAll(rows).get(owner);
}
@SuppressWarnings("unchecked")
private Map<String, Map<String, String>> cachedBook(DeviceAddressBookServiceImpl service, RedisUtils redisUtils) {
ArgumentCaptor<Object> captor = ArgumentCaptor.forClass(Object.class);
verify(redisUtils).set(eq(RedisKeys.getAddressBookKey()), captor.capture());
return (Map<String, Map<String, String>>) captor.getValue();
}
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias) {
return row(macAddress, targetMac, alias, Boolean.TRUE);
}
private DeviceAddressBookEntity row(String macAddress, String targetMac, String alias, Boolean hasPermission) {
DeviceAddressBookEntity entity = new DeviceAddressBookEntity();
entity.setMacAddress(macAddress);
entity.setTargetMac(targetMac);
entity.setAlias(alias);
entity.setHasPermission(hasPermission);
return entity;
}
@Test
void listAndCacheHandOutTheSameNameForCollidingEntries() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
// 两次 DAO 调用各给一份新行:取名会写回实体,共用同一份的话第二遍读到的是算过的名字
// 用可变列表,跟 MyBatis 实际返回的一致,免得排序那步先炸掉、断言根本没跑到
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
new ArrayList<>(List.of(row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅"))));
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅"), row(MAC, THIRD_MAC, "客厅")));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
Map<String, String> shownToConsole = namesByTargetMac(service.getAddressBookList(MAC));
service.refreshCache();
Map<String, String> callableNames = new HashMap<>();
cachedBook(service, redisUtils).get(MAC)
.forEach((name, macAndPerm) -> callableNames.put(name, macAndPerm.split("\\|")[0]));
assertEquals(callableNames, shownToConsole,
"接口和缓存给的是两个名字,用户照前端看到的那个名字呼叫,命中的不是同一台设备");
}
@Test
void theCallableEntryKeepsThePlainNameWhenTwoCollide() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
// OTHER_MAC 的 target_mac 排在前面,没权限的那条会先占住原名
when(addressBookDao.selectList(null)).thenReturn(List.of(
row(MAC, OTHER_MAC, "客厅", Boolean.FALSE),
row(MAC, THIRD_MAC, "客厅", Boolean.TRUE)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
service.refreshCache();
Map<String, String> book = cachedBook(service, redisUtils).get(MAC);
assertEquals(THIRD_MAC + "|1", book.get("客厅"),
"能呼的那台被挤到后缀名上了,用户照前端看到的名字点,界面只会说没权限");
assertEquals(OTHER_MAC + "|0", book.get("客厅1"));
}
@Test
void listFillsInBlankAliasesSoTheConsoleShowsWhatCanBeCalled() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
when(addressBookDao.getAddressBookList(MAC)).thenReturn(new ArrayList<>(List.of(row(MAC, OTHER_MAC, null))));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
assertEquals("尾号为66的设备", rows.get(0).getAlias(),
"空别名原样返回的话前端回落显示设备名,用户照着那个名字呼叫却只会说找不到备注");
}
@Test
void anUppercaseMacGetsTheSameNameWhicheverPathAddsItToTheBook() {
// 兜底称呼只有这一处算法:存进去的时候算一次,之后接口和缓存里显示的
// 都是同一个字符串。不跟着小写化的话同一个地址在两条路径上算出两个称呼,
// 用户照着看到的名字呼过去,命中的不是自己刚存的那台
//
// 运行期这一侧:saveOrUpdate 传的是 DTO 原样过来的地址,大小写没归一
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
when(addressBookDao.selectOne(any())).thenReturn(null);
when(addressBookDao.selectList(null)).thenReturn(List.of());
when(deviceService.getDeviceByMacAddress(anyString())).thenReturn(null);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, mock(SysParamsService.class));
service.saveOrUpdate(MAC, "ff:ee:dd:cc:bb:AB", null, true);
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
assertEquals("尾号为ab的设备", entityCaptor.getValue().getAlias(),
"MAC 大小写不区分,末尾两位要跟着小写化,否则接口和呼叫那边算出来的称呼对不上");
}
/**
* 接口按最近改过的排前面
*
* <p>
* 智控台那条 Vue 是按 targetMac 把 alias 合进 allDevices 的,接口顺序被丢掉了,
* 这条现在对 console 没有效果。用例留着是把这个契约钉住:哪天当无用代码删了,
* 会有用例报出来,而不是静悄悄换掉接口的返回顺序
*/
@Test
void listPutsTheMostRecentlyTouchedEntryFirst() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceAddressBookEntity older = row(MAC, OTHER_MAC, "客厅");
older.setUpdateDate(new Date(1767321840000L));
DeviceAddressBookEntity newer = row(MAC, THIRD_MAC, "书房");
newer.setUpdateDate(new Date(1778000000000L));
DeviceAddressBookEntity neverTouched = row(MAC, "00:11:22:33:44:88", "阳台");
when(addressBookDao.getAddressBookList(MAC)).thenReturn(
new ArrayList<>(List.of(older, neverTouched, newer)));
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, mock(DeviceService.class), mock(SysParamsService.class));
List<DeviceAddressBookEntity> rows = service.getAddressBookList(MAC);
assertEquals(List.of("书房", "客厅", "阳台"), rows.stream().map(DeviceAddressBookEntity::getAlias).toList(),
"最近改过的应该排前面,没改过的排最后");
}
private Map<String, String> namesByTargetMac(List<DeviceAddressBookEntity> rows) {
Map<String, String> byAlias = new HashMap<>();
for (DeviceAddressBookEntity row : rows) {
byAlias.put(row.getAlias(), row.getTargetMac());
}
return byAlias;
}
@Test
void callByNicknameFallsBackToMacSuffixWhenCallerHasNoAlias() {
DeviceAddressBookDao addressBookDao = mock(DeviceAddressBookDao.class);
RedisUtils redisUtils = mock(RedisUtils.class);
DeviceService deviceService = mock(DeviceService.class);
SysParamsService sysParamsService = mock(SysParamsService.class);
DeviceAddressBookServiceImpl service = new DeviceAddressBookServiceImpl(
addressBookDao, redisUtils, deviceService, sysParamsService);
Map<String, Map<String, String>> books = new HashMap<>();
Map<String, String> callerBook = new HashMap<>();
callerBook.put("office", OTHER_MAC + "|1");
books.put(MAC, callerBook);
when(redisUtils.get(RedisKeys.getAddressBookKey())).thenReturn(books);
when(deviceService.getDeviceByMacAddress(MAC)).thenReturn(null);
when(sysParamsService.getValue("server.mqtt_manager_api", true)).thenReturn("127.0.0.1:1883");
when(sysParamsService.getValue(Constant.SERVER_MQTT_SECRET, true)).thenReturn("gateway-key");
// 网关是真的 http 调用,桩掉它,否则测试会往 127.0.0.1:1883 真发请求
AtomicReference<String> sentUrl = new AtomicReference<>();
AtomicReference<String> sentBody = new AtomicReference<>();
Map<String, Object> result;
try (MockedStatic<MqttGatewayAuthorization> gateway = mockStatic(MqttGatewayAuthorization.class)) {
gateway.when(() -> MqttGatewayAuthorization.postJson(
anyString(), anyString(), anyString(), any(), anyInt()))
.thenAnswer(invocation -> {
sentUrl.set(invocation.getArgument(0));
sentBody.set(invocation.getArgument(1));
return "{\"status\":\"success\",\"message\":\"ok\"}";
});
result = service.callByNickname(MAC, "office", false);
}
assertEquals("success", result.get("status"));
assertEquals("http://127.0.0.1:1883/api/call/request", sentUrl.get());
assertTrue(sentBody.get().contains("尾号为55的设备"),
"主叫方没登记也没起名时该退回尾号称呼:" + sentBody.get());
}
}
@@ -0,0 +1,184 @@
package xiaozhi.modules.device.service.impl;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.user.UserDetail;
import xiaozhi.modules.device.dao.DeviceDao;
import xiaozhi.modules.device.entity.DeviceEntity;
import xiaozhi.modules.device.service.DeviceAddressBookService;
import xiaozhi.modules.device.service.OtaService;
import xiaozhi.modules.security.user.SecurityUser;
import xiaozhi.modules.sys.service.SysParamsService;
import xiaozhi.modules.sys.service.SysUserUtilService;
@DisplayName("设备归属校验回归测试")
class DeviceServiceImplTest {
private static final String MAC = "00:11:22:33:44:55";
private static final long USER_ID = 1L;
private static final long OTHER_USER_ID = 2L;
@Test
@DisplayName("设备属于当前用户时返回设备")
void deviceOwnedByCurrentUserIsReturned() {
DeviceEntity device = device(USER_ID);
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertSame(device, service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备属于他人时按查不到处理")
void deviceOwnedByAnotherUserIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(OTHER_USER_ID)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备未登记时按查不到处理")
void unregisteredDeviceIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of()));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("设备没有归属人时按查不到处理")
void unboundDeviceIsHidden() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(null)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("没有登录用户时按查不到处理")
void anonymousCallerGetsNothing() {
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(device(USER_ID)));
// 匿名调用的实际形态:getUser() 返回空对象,id 是空的,不是返回 null
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(new UserDetail());
assertNull(service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("库里同一 MAC 有两行只差大小写时,取第一条做归属校验,不再抛异常")
void duplicateMacDifferingOnlyInCaseStillReturnsTheDevice() {
DeviceEntity first = device(USER_ID, "device-1");
DeviceEntity second = device(USER_ID, "device-2");
DeviceServiceImpl service = serviceWithDeviceDao(daoReturning(List.of(first, second)));
try (MockedStatic<SecurityUser> securityUser = mockStatic(SecurityUser.class)) {
securityUser.when(SecurityUser::getUser).thenReturn(user(USER_ID));
assertSame(first, service.getOwnedDeviceByMacAddress(MAC));
}
}
@Test
@DisplayName("解绑自己的设备时,通讯录跟着一起清")
void unbindOwnDeviceClearsItsAddressBook() {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectById("device-1")).thenReturn(device(USER_ID, "device-1"));
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
service.unbindDevice(USER_ID, "device-1");
verify(dao).delete(any(UpdateWrapper.class));
verify(addressBookService).deleteByMacAddresses(List.of(MAC));
}
@Test
@DisplayName("解绑别人的设备时什么都不删,通讯录不能跟着没")
void unbindAnotherUsersDeviceDeletesNothing() {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectById("device-1")).thenReturn(device(OTHER_USER_ID, "device-1"));
DeviceAddressBookService addressBookService = mock(DeviceAddressBookService.class);
DeviceServiceImpl service = serviceWithDao(dao, addressBookService);
service.unbindDevice(USER_ID, "device-1");
verify(dao, never()).delete(any(UpdateWrapper.class));
verify(addressBookService, never()).deleteByMacAddresses(anyList());
}
private DeviceServiceImpl serviceWithDao(DeviceDao dao, DeviceAddressBookService addressBookService) {
// baseDao 由父类持有,子类内部赋给 this,测试类自己碰不到它
return new DeviceServiceImpl(
dao, mock(SysUserUtilService.class), mock(SysParamsService.class),
mock(RedisUtils.class), mock(OtaService.class), addressBookService) {
{
this.baseDao = dao;
}
};
}
private DeviceServiceImpl serviceWithDeviceDao(DeviceDao dao) {
return serviceWithDao(dao, mock(DeviceAddressBookService.class));
}
private DeviceDao daoReturning(DeviceEntity device) {
return daoReturning(List.of(device));
}
private DeviceDao daoReturning(List<DeviceEntity> rows) {
DeviceDao dao = mock(DeviceDao.class);
when(dao.selectList(any())).thenReturn(rows);
return dao;
}
private DeviceEntity device(Long userId) {
return device(userId, null);
}
private DeviceEntity device(Long userId, String id) {
DeviceEntity entity = new DeviceEntity();
entity.setId(id);
entity.setMacAddress(MAC);
entity.setUserId(userId);
return entity;
}
private UserDetail user(long id) {
UserDetail user = new UserDetail();
user.setId(id);
return user;
}
}
@@ -0,0 +1,75 @@
package xiaozhi.modules.model.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import java.util.UUID;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import cn.hutool.json.JSONObject;
import xiaozhi.common.redis.RedisKeys;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
import xiaozhi.modules.model.dto.ModelConfigDTO;
import xiaozhi.modules.model.service.ModelConfigService;
@SpringBootTest
@ActiveProfiles("dev")
@Transactional
class ModelConfigPersistenceTest {
@Autowired
private ModelConfigService service;
@Autowired
private JdbcTemplate jdbc;
@Autowired
private RedisUtils redis;
@ParameterizedTest
@CsvSource({"tts,TTS", "memory,Memory", "intent,Intent"})
void preservesRegisteredTypeWhenAddingAndEditing(String requestedType, String registeredType) {
String id = UUID.randomUUID().toString().replace("-", "");
String providerCode = "test-" + id;
jdbc.update("INSERT INTO ai_model_provider (id, model_type, provider_code, name, fields) VALUES (?, ?, ?, ?, ?)",
id, registeredType, providerCode, "Persistence test provider", "[]");
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
body.setId(id);
body.setModelCode(providerCode);
body.setModelName("Persistence test model");
body.setIsDefault(0);
body.setIsEnabled(1);
body.setSort(1);
body.setConfigJson(new JSONObject().set("type", providerCode));
ModelConfigDTO added = service.add(requestedType, providerCode, body);
assertEquals(registeredType, added.getModelType());
assertEquals(registeredType,
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
String cacheKey = RedisKeys.getModelConfigById(id);
try {
redis.set(cacheKey, "cached model");
body.setModelName("Updated persistence test model");
ModelConfigDTO edited = service.edit(requestedType, providerCode, id, body);
assertEquals(registeredType, edited.getModelType());
assertEquals(registeredType,
jdbc.queryForObject("SELECT model_type FROM ai_model_config WHERE id = ?", String.class, id));
assertEquals(body.getModelName(),
jdbc.queryForObject("SELECT model_name FROM ai_model_config WHERE id = ?", String.class, id));
assertNull(redis.get(cacheKey));
} finally {
redis.delete(cacheKey);
}
}
}
@@ -0,0 +1,120 @@
package xiaozhi.modules.model.service.impl;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
import xiaozhi.common.exception.ErrorCode;
import xiaozhi.common.exception.RenException;
import xiaozhi.common.redis.RedisUtils;
import xiaozhi.common.utils.MessageUtils;
import xiaozhi.modules.agent.dao.AgentDao;
import xiaozhi.modules.model.dao.ModelConfigDao;
import xiaozhi.modules.model.dto.ModelConfigBodyDTO;
import xiaozhi.modules.model.dto.ModelConfigDTO;
import xiaozhi.modules.model.dto.ModelProviderDTO;
import xiaozhi.modules.model.entity.ModelConfigEntity;
import xiaozhi.modules.model.service.ModelProviderService;
class ModelConfigServiceImplTest {
private ModelConfigDao modelConfigDao;
private ModelProviderService modelProviderService;
private ModelConfigServiceImpl service;
@BeforeEach
void setUp() {
modelConfigDao = mock(ModelConfigDao.class);
modelProviderService = mock(ModelProviderService.class);
service = new ModelConfigServiceImpl(
modelConfigDao, modelProviderService, mock(RedisUtils.class), mock(AgentDao.class));
}
@ParameterizedTest
@CsvSource({
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
})
void addPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
registerProvider(requestedType, registeredType);
ModelConfigDTO result = service.add(requestedType, "test-provider", modelBody());
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
verify(modelConfigDao).insert(saved.capture());
assertEquals(registeredType, saved.getValue().getModelType());
assertEquals(registeredType, result.getModelType());
}
@ParameterizedTest
@CsvSource({
"vad, VAD", "asr, ASR", "tts, TTS", "llm, LLM", "vllm, VLLM",
"memory, Memory", "intent, Intent", "rag, RAG", "LLM, LLM"
})
void editPreservesRegisteredProviderModelType(String requestedType, String registeredType) {
registerProvider(requestedType, registeredType);
ModelConfigEntity original = new ModelConfigEntity();
original.setId("test-model");
original.setModelType(registeredType);
when(modelConfigDao.selectById("test-model")).thenReturn(original);
ModelConfigDTO result = service.edit(requestedType, "test-provider", "test-model", modelBody());
ArgumentCaptor<ModelConfigEntity> saved = ArgumentCaptor.forClass(ModelConfigEntity.class);
verify(modelConfigDao).updateById(saved.capture());
assertEquals(registeredType, saved.getValue().getModelType());
assertEquals(registeredType, result.getModelType());
}
@ParameterizedTest
@ValueSource(booleans = {false, true})
void rejectsUnknownProviderBeforeWriting(boolean editing) {
when(modelProviderService.getList("tts", "missing")).thenReturn(List.of());
try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
messages.when(() -> MessageUtils.getMessage(ErrorCode.MODEL_PROVIDER_NOT_EXIST))
.thenReturn("Model provider does not exist");
RenException error = assertThrows(RenException.class, () -> {
if (editing) {
service.edit("tts", "missing", "test-model", modelBody());
} else {
service.add("tts", "missing", modelBody());
}
});
assertEquals(ErrorCode.MODEL_PROVIDER_NOT_EXIST, error.getCode());
}
verify(modelConfigDao, never()).insert(any(ModelConfigEntity.class));
verify(modelConfigDao, never()).updateById(any(ModelConfigEntity.class));
}
private void registerProvider(String requestedType, String registeredType) {
ModelProviderDTO provider = new ModelProviderDTO();
provider.setModelType(registeredType);
provider.setProviderCode("test-provider");
when(modelProviderService.getList(requestedType, "test-provider")).thenReturn(List.of(provider));
}
private ModelConfigBodyDTO modelBody() {
ModelConfigBodyDTO body = new ModelConfigBodyDTO();
body.setId("test-model");
body.setModelName("Test model");
body.setIsEnabled(1);
body.setSort(1);
return body;
}
}
@@ -265,7 +265,7 @@ function showAbout() {
title: t('settings.aboutApp', { appName: import.meta.env.VITE_APP_TITLE }),
content: t('settings.aboutContent', {
appName: import.meta.env.VITE_APP_TITLE,
version: '0.9.7',
version: '0.9.8',
}),
showCancel: false,
confirmText: t('common.confirm'),
+120 -23
View File
@@ -1,6 +1,7 @@
import Fly from 'flyio/dist/npm/fly';
import store from '../store/index';
import Constant from '../utils/constant';
import { getServiceUrl } from '../apis/api';
import { goToPage, isNotNull, showDanger, showWarning } from '../utils/index';
import i18n from '../i18n/index';
@@ -8,13 +9,98 @@ const fly = new Fly()
// 设置超时
fly.config.timeout = 30000
// 独立 fly 实例给 silent refresh 用,避免任何回调链回到 sendRequest
const refreshFly = new Fly()
refreshFly.config.timeout = 30000
// silent refresh:剩 1h 触发换 token,单飞锁避免并发
const REFRESH_AHEAD_MS = 60 * 60 * 1000
let refreshPromise = null
function getTokenExpireAt() {
// 与 persistTokenExpireAt 同源读 localStorage(store 走的是 state.token 缓存,
// 刚写入 localStorage 后再走 store 拿不到补写的 _localStoredAt)
const raw = localStorage.getItem('token')
if (!raw) return 0
try {
const parsed = JSON.parse(raw)
if (!parsed || !parsed.expire) return 0
// login.vue 写入 store 时是 setToken(now),token 对象里没有 createdAt;
// 用 _localStoredAt 作为本地落地时间基准
return (parsed._localStoredAt || 0) + parsed.expire * 1000
} catch (e) {
return 0
}
}
// 首次见到该 token 时记下落地时间;同步刷新 localStorage 与 store,保证两端一致
function persistTokenExpireAt() {
const raw = localStorage.getItem('token')
if (!raw) return
try {
const parsed = JSON.parse(raw)
if (parsed && parsed.expire && !parsed._localStoredAt) {
parsed._localStoredAt = Date.now()
const next = JSON.stringify(parsed)
localStorage.setItem('token', next)
store.commit('setToken', next)
}
} catch (e) { /* ignore */ }
}
function shouldRefresh() {
persistTokenExpireAt()
const exp = getTokenExpireAt()
if (!exp) return false
return exp - Date.now() < REFRESH_AHEAD_MS
}
function doRefresh() {
// 直接走 fly.request,绕过 sendRequest,避免死循环触发 silent refresh
const raw = localStorage.getItem('token')
if (!raw) return Promise.reject(new Error('no token'))
let bearer
try {
bearer = JSON.parse(raw).token
} catch (e) {
return Promise.reject(e)
}
return refreshFly.request(getServiceUrl() + '/user/refresh', {}, {
method: 'POST',
headers: { Authorization: 'Bearer ' + bearer }
}).then((res) => {
const body = res && res.data
if (!body || (body.code !== 0 && body.code !== 'success')) {
throw new Error('refresh failed: ' + (body && body.msg))
}
const newDto = body.data
newDto._localStoredAt = Date.now()
store.commit('setToken', JSON.stringify(newDto))
return newDto
})
}
function triggerSilentRefresh() {
if (!refreshPromise) {
refreshPromise = doRefresh().catch((err) => {
store.commit('clearAuth')
goToPage(Constant.PAGE.LOGIN, true)
throw err
}).finally(() => {
refreshPromise = null
})
}
return refreshPromise
}
/**
* Request服务封装
*/
export default {
sendRequest,
reAjaxFun,
clearRequestTime
clearRequestTime,
triggerSilentRefresh
}
function sendRequest() {
@@ -37,30 +123,41 @@ function sendRequest() {
acceptLanguage = 'en-US';
}
this._header['Accept-Language'] = acceptLanguage;
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
const doSend = () => {
if (isNotNull(store.getters.getToken)) {
this._header.Authorization = 'Bearer ' + (JSON.parse(store.getters.getToken)).token
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
}
// 打印请求信息
fly.request(this._url, this._data, {
method: this._method,
headers: this._header,
responseType: this._responseType
}).then((res) => {
const error = httpHandlerError(res, this._failCallback, this._networkFailCallback);
if (error) {
return
}
if (this._sucCallback) {
this._sucCallback(res)
}
}).catch((res) => {
// 打印失败响应
console.log('catch', res)
httpHandlerError(res, this._failCallback, this._networkFailCallback)
})
// 剩 1h 内过期就先 silent refresh 再发,确保请求带新 token
if (shouldRefresh()) {
triggerSilentRefresh().then(doSend).catch(() => {
// 触发 refresh 后被拦截跳登录,这里什么都不用做
})
} else {
doSend()
}
return this
},
'success'(callback) {
@@ -77,7 +77,10 @@
<el-form :model="formData.configJson" label-width="auto" label-position="left" class="custom-form">
<div v-for="(row, rowIndex) in chunkedCallInfoFields" :key="rowIndex" class="form-row">
<el-form-item v-for="field in row" :key="field.prop" :label="field.label" :prop="field.prop" style="flex: 1;">
<el-input v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
<el-input v-if="field.type === 'json-textarea'" v-model="formData.configJson[field.prop]" type="textarea"
:rows="3" :placeholder="field.placeholder" class="custom-input-bg">
</el-input>
<el-input v-else v-model="formData.configJson[field.prop]" :placeholder="field.placeholder"
:type="field.type || 'text'" class="custom-input-bg" :show-password="field.type === 'password'">
</el-input>
</el-form-item>
@@ -161,7 +164,7 @@ export default {
fields: JSON.parse(item.fields || '[]').map(f => ({
label: f.label,
prop: f.key,
type: f.type === 'password' ? 'password' : 'text',
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : 'text',
placeholder: `请输入${f.key}`
}))
}))
@@ -204,6 +207,24 @@ export default {
return;
}
// dict 字段必须解析为 JSON 对象再提交,否则会以字符串入库
for (const field of this.providerFields) {
if (field.type !== 'json-textarea') continue;
const raw = this.formData.configJson[field.prop];
if (!raw || typeof raw === 'object') continue;
try {
const parsed = JSON.parse(raw);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('not object');
}
this.formData.configJson[field.prop] = parsed;
} catch (e) {
this.$message.error('必须输入字典格式(如 {"key":"value"})');
this.saving = false;
return;
}
}
const submitData = {
id: this.formData.id || '',
modelName: this.formData.modelName || '',
@@ -330,6 +330,17 @@ export default {
if (!configJson.hasOwnProperty(field.prop)) {
configJson[field.prop] = "";
} else if (field.type === "json-textarea") {
// 兼容已保存为 JSON 字符串的字典,避免再次序列化后无法编辑。
if (typeof configJson[field.prop] === "string") {
try {
const parsed = JSON.parse(configJson[field.prop]);
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
configJson[field.prop] = parsed;
}
} catch {
// 保留非法值的显示,由现有校验提示用户修正。
}
}
this.$set(
this.fieldJsonMap,
field.prop,
+7
View File
@@ -22,6 +22,13 @@ Vue.config.productionTip = false
// 注册Service Worker
registerServiceWorker();
// 应用启动时把 localStorage 里的 token 同步进 store,避免守卫放行后首次请求时
// httpRequest 读到 state.token=null 而漏带 Authorization 头触发 401 跳登录
const cachedToken = localStorage.getItem('token')
if (cachedToken) {
store.commit('setToken', cachedToken)
}
// 创建Vue实例
new Vue({
router,
@@ -613,6 +613,8 @@ export default {
.nav-panel {
min-width: 242px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
border-right: 1px solid #ebeef5;
background: linear-gradient(
120deg,
@@ -629,6 +631,7 @@ export default {
.nav-panel .el-menu-item {
height: 50px;
flex-shrink: 0;
background: #e9f0ff;
line-height: 50px;
border-radius: 4px 0 0 4px !important;
@@ -14,6 +14,10 @@ const functionDialogSource = await readFile(
new URL('../src/components/FunctionDialog.vue', import.meta.url),
'utf8',
);
const modelConfigSource = await readFile(
new URL('../src/views/ModelConfig.vue', import.meta.url),
'utf8',
);
test('address-book permission state consistently uses the target device MAC', () => {
assert.match(
@@ -66,6 +70,74 @@ test('correct-word pagination maps the UI page size to the backend limit query',
assert.doesNotMatch(correctWordApiSource, /pageSize: params\.pageSize/);
});
test('model navigation keeps its scrollable layout declarations', () => {
const navStyles = modelConfigSource.match(/\.nav-panel\s*\{([^}]*)\}/)?.[1];
const itemStyles = modelConfigSource.match(
/\.nav-panel\s+\.el-menu-item\s*\{([^}]*)\}/,
)?.[1];
assert.ok(navStyles, 'model navigation should define its layout');
assert.ok(itemStyles, 'model menu items should define their layout');
const wrapperStyles = modelConfigSource.match(/\.main-wrapper\s*\{([^}]*)\}/)?.[1];
assert.ok(wrapperStyles, 'model page wrapper should define its height');
assert.match(navStyles, /box-sizing:\s*border-box;/);
assert.match(navStyles, /overflow-y:\s*auto;/);
assert.match(navStyles, /display:\s*flex;/);
assert.match(navStyles, /flex-direction:\s*column;/);
assert.match(itemStyles, /flex-shrink:\s*0;/);
assert.match(wrapperStyles, /height:\s*calc\([^)]*vh[^)]*\)/);
// 断言只取第一条同名规则,后面再补一条就能盖掉上面三条,必须挡住
assert.equal(
(modelConfigSource.match(/\.nav-panel\s*\{/g) || []).length,
1,
'model navigation should keep a single rule',
);
assert.equal(
(modelConfigSource.match(/\.nav-panel\s+\.el-menu-item\s*\{/g) || []).length,
1,
'model menu items should keep a single rule',
);
// 复合选择器特异性更高,能直接盖掉可滚属性
assert.equal(
(modelConfigSource.match(/\.nav-panel[.:\[#][^{}]*\{/g) || []).length,
0,
'model navigation should not use composite selectors',
);
const styleSource = modelConfigSource.match(/<style[^>]*>([\s\S]*?)<\/style>/)?.[1];
assert.ok(styleSource, 'model config should define its styles');
const ruleSelectors = styleSource
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/[^\n]*/g, '')
.split('}')
.map((chunk) => {
const open = chunk.indexOf('{');
return open < 0 ? null : chunk.slice(0, open).trim();
})
.filter((selector) => selector && !selector.startsWith('@'));
// 上面几条挡不住带伪类的后代选择器(.nav-panel .el-menu-item:hover),
// 它特异性一样更高,照样能盖掉可滚属性,所以把 nav 相关的选择器逐条列出来核对
assert.deepEqual(
ruleSelectors.filter((selector) => selector.includes('.nav-panel')).sort(),
[
'.nav-panel',
'.nav-panel .el-menu-item',
'.nav-panel .el-menu-item.is-active',
'.nav-panel .el-menu-item.is-active::before',
'::v-deep .nav-panel .el-menu-item.is-active .menu-text',
].sort(),
'model navigation should keep its declared rules',
);
assert.deepEqual(
ruleSelectors.filter((selector) => selector.includes('.main-wrapper')),
['.main-wrapper'],
'model page wrapper should keep a single rule',
);
});
test('function dialog footer stays above the expanding MCP tools section', () => {
const mcpLayer = functionDialogSource.match(
/\.mcp-access-point\s*\{[^}]*z-index:\s*(\d+);/s,
@@ -0,0 +1,79 @@
import Vue from 'vue';
import { parseComponent } from 'vue-template-compiler';
import { describe, expect, it, vi } from 'vitest';
import source from '../src/components/AddModelDialog.vue?raw';
// The unit-test runner has no Vue SFC plugin. Load the real component's script
// with its API and dialog dependencies stubbed; keep Vue's reactivity.
const script = parseComponent(source).script.content
.replace(/^import .*;$/gm, '')
.replace('export default', 'return');
const component = new Function('CustomDialog', 'Api', script)({}, {});
function makeVm() {
const vm = new Vue({
...component,
propsData: { visible: false, modelType: 'TTS' },
});
vm.$message = { error: vi.fn() };
vm.$t = (key) => key;
vm.providerFields = [
{ label: '音量', prop: 'volume', type: 'json-textarea', placeholder: '请输入volume' },
{ label: '密钥', prop: 'api_key', type: 'password', placeholder: '请输入api_key' },
];
vm.formData.supplier = 'test-provider';
return vm;
}
function submitted(vm) {
let captured;
vm.$on('confirm', (data) => {
captured = data;
});
vm.confirm();
return captured;
}
describe('add dialog dict fields', () => {
it('parses valid JSON dict input into an object before submit', () => {
const vm = makeVm();
vm.formData.configJson = { volume: '{"speed":1,"volume":10}', api_key: 'sk-xxx' };
const data = submitted(vm);
expect(data.configJson.volume).toEqual({ speed: 1, volume: 10 });
expect(data.configJson.api_key).toBe('sk-xxx');
expect(vm.$message.error).not.toHaveBeenCalled();
});
it('blocks invalid JSON input and does not emit confirm', () => {
const vm = makeVm();
vm.formData.configJson = { volume: '{bad json', api_key: 'sk-xxx' };
expect(submitted(vm)).toBeUndefined();
expect(vm.$message.error).toHaveBeenCalled();
});
it.each(['[1,2]', 'null', '"text"', '1'])(
'blocks non-object JSON %s',
(value) => {
const vm = makeVm();
vm.formData.configJson = { volume: value, api_key: 'sk-xxx' };
expect(submitted(vm)).toBeUndefined();
expect(vm.$message.error).toHaveBeenCalled();
},
);
it('leaves empty strings and existing object values untouched', () => {
const vm = makeVm();
const obj = { speed: 2 };
vm.formData.configJson = { volume: obj, api_key: '' };
const data = submitted(vm);
expect(data.configJson.volume).toBe(obj);
expect(data.configJson.api_key).toBe('');
expect(vm.$message.error).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,98 @@
import Vue from 'vue';
import { parseComponent } from 'vue-template-compiler';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import source from '../src/components/ModelEditDialog.vue?raw';
// The unit-test runner has no Vue SFC plugin. Load the real component's script
// with its API and child-dialog dependencies stubbed; keep Vue's reactivity.
const script = parseComponent(source).script.content
.replace(/^import .*;$/gm, '')
.replace('export default', 'return');
const component = new Function('CustomDialog', 'Api', script)({}, {});
let vm;
let saved;
function openModel(value) {
vm.processModelData({
id: 'test-model',
modelName: 'Test model',
configJson: { type: 'test-provider', prosody: value },
});
}
beforeEach(() => {
vi.useFakeTimers();
vm = new Vue({
...component,
propsData: { modelType: 'TTS', modelData: { id: 'test-model' } },
});
vm.$message = { error: vi.fn() };
vm.dynamicCallInfoFields = [{ prop: 'prosody', type: 'json-textarea' }];
saved = vi.fn();
vm.$on('save', saved);
});
afterEach(() => {
vm.$destroy();
vi.clearAllTimers();
vi.useRealTimers();
});
describe('stored dictionary fields', () => {
it('opens a JSON string as an editable dictionary instead of a quoted string', () => {
openModel('{"speed":1,"volume":10}');
expect(JSON.parse(vm.fieldJsonMap.prosody)).toEqual({ speed: 1, volume: 10 });
expect(vm.form.configJson.prosody).toEqual({ speed: 1, volume: 10 });
});
it('preserves a stored JSON string when saving without changes', () => {
openModel('{"speed":1,"volume":10}');
vm.handleSave();
expect(vm.$message.error).not.toHaveBeenCalled();
expect(saved.mock.calls[0][0].formData.configJson.prosody).toEqual({
speed: 1,
volume: 10,
});
});
it('keeps edits after saving and reopening the returned object', () => {
openModel('{"speed":1,"volume":10}');
vm.fieldJsonMap.prosody = '{"speed":2,"volume":5}';
vm.handleSave();
const config = saved.mock.calls[0][0].formData.configJson;
vm.resetForm();
openModel(JSON.parse(JSON.stringify(config.prosody)));
vm.handleSave();
expect(JSON.parse(vm.fieldJsonMap.prosody)).toEqual({ speed: 2, volume: 5 });
expect(saved.mock.calls[1][0].formData.configJson.prosody).toEqual({
speed: 2,
volume: 5,
});
expect(vm.$message.error).not.toHaveBeenCalled();
});
it('preserves nested values and masked secrets in existing dictionary objects', () => {
const value = { speed: 0, enabled: false, nested: { api_key: 'ab****yz' }, tags: ['a'] };
openModel(value);
vm.handleSave();
expect(saved.mock.calls[0][0].formData.configJson.prosody).toEqual(value);
expect(vm.$message.error).not.toHaveBeenCalled();
});
it.each(['not json', 'null', '[]', '1', 'true', '"text"'])(
'does not reinterpret %s as a dictionary',
(value) => {
openModel(value);
expect(JSON.parse(vm.fieldJsonMap.prosody)).toBe(value);
expect(vm.validateJson(vm.fieldJsonMap.prosody)).toBeNull();
expect(vm.$message.error).toHaveBeenCalled();
},
);
});
+4
View File
@@ -119,6 +119,10 @@ wakeup_words:
# MCP接入点地址,地址格式为:ws://你的mcp接入点ip或者域名:端口号/mcp/?token=你的token
# 详细教程 https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/mcp-endpoint-integration.md
mcp_endpoint: 你的接入点 websocket地址
# 声纹识别到说话人后,是否把身份附在 MCP 接入点 tools/call 的 params._meta 上。
# true:写入 speakerId、speakerName。未识别到说话人时不附带,请求与原来一致。
# 工具参数本身不改。false:完全不附带。
mcp_forward_speaker: true
# 上下文源配置
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
+1 -1
View File
@@ -6,7 +6,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from core.utils.cache.manager import cache_manager, CacheType
SERVER_VERSION = "0.9.7"
SERVER_VERSION = "0.9.8"
_logger_initialized = False
+7 -6
View File
@@ -42,7 +42,7 @@ from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from config.logger import setup_logging, build_module_string, create_connection_logger
from config.manage_api_client import DeviceNotFoundException, DeviceBindException, generate_and_save_chat_title
from core.utils.prompt_manager import PromptManager
from core.utils.voiceprint_provider import VoiceprintProvider
from core.utils.voiceprint_provider import VoiceprintProvider, UNKNOWN_SPEAKER_NAME
from core.utils.util import get_system_error_response
from core.utils import textUtils
@@ -158,7 +158,7 @@ class ConnectionHandler:
# 所以涉及到ASR的变量,需要在这里定义,属于connection的私有变量
self.asr_audio = [] # 存储PCM帧列表,供VAD和ASR共享
self.asr_audio_queue = queue.Queue()
self.current_speaker = None # 存储当前说话人
self.current_speaker = None # 当前说话人展示名
self.introduced_speakers = set() # 已"首次引入"的说话人,控制只在首轮带名字
self.system_introduced_speakers = set() # 已在 system 注入过身份的说话人,控制 system 身份只首轮出现
@@ -1052,7 +1052,8 @@ class ConnectionHandler:
# 更新系统prompt至上下文
self.dialogue.update_system_message(self.prompt)
def chat(self, query, depth=0):
def chat(self, query, depth=0, speaker=None):
# speaker: 本轮声纹身份快照,随工具调用下传
# 保存当前任务的sentence_id到局部变量,避免被新任务覆盖
current_sentence_id = None
@@ -1122,7 +1123,7 @@ class ConnectionHandler:
# 避免每轮在 system 重复出现名字诱导模型反复称呼
speaker_for_system = None
cs = (self.current_speaker or "").strip()
if cs and cs != "未知说话人" and cs not in self.system_introduced_speakers:
if cs and cs != UNKNOWN_SPEAKER_NAME and cs not in self.system_introduced_speakers:
self.system_introduced_speakers.add(cs)
speaker_for_system = cs
@@ -1337,7 +1338,7 @@ class ConnectionHandler:
future = asyncio.run_coroutine_threadsafe(
self.func_handler.handle_llm_function_call(
self, tool_call_data
self, tool_call_data, speaker=speaker
),
self.loop,
)
@@ -1500,7 +1501,7 @@ class ConnectionHandler:
)
)
self.chat(None, depth=depth + 1)
self.chat(None, depth=depth + 1, speaker=speaker)
def _report_worker(self):
"""聊天记录上报工作线程"""
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from core.connection import ConnectionHandler
from core.utils.dialogue import Message
from core.utils.util import audio_to_data
from core.utils.util import audio_to_data, audio_file_to_wav_bytes
from core.providers.tts.dto.dto import SentenceType
from core.utils.wakeup_word import WakeupWordsConfig
from core.handle.sendAudioHandle import sendAudioMessage, send_tts_message
@@ -148,8 +148,13 @@ async def wakeupWordsResponse(conn: "ConnectionHandler"):
# 获取当前音色
voice = getattr(conn.tts, "voice", "default")
# 使用链接的sample_rate
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=conn.sample_rate)
# 保留的 TTS 音频是文件路径而非 Opus 帧:直接转目标采样率 wav,跳过有损 Opus 往返
if isinstance(tts_result, str):
wav_bytes = await asyncio.to_thread(
audio_file_to_wav_bytes, tts_result, conn.sample_rate
)
else:
wav_bytes = opus_datas_to_wav_bytes(tts_result, sample_rate=conn.sample_rate)
file_path = wakeup_words_config.generate_file_path(voice)
with open(file_path, "wb") as f:
f.write(wav_bytes)
@@ -17,7 +17,7 @@ from core.providers.tts.dto.dto import TTSMessageDTO, SentenceType
TAG = __name__
async def handle_user_intent(conn: "ConnectionHandler", text):
async def handle_user_intent(conn: "ConnectionHandler", text, speaker=None):
# 预处理输入文本,处理可能的JSON格式
try:
if text.strip().startswith("{") and text.strip().endswith("}"):
@@ -172,7 +172,7 @@ async def process_intent_result(
try:
result = asyncio.run_coroutine_threadsafe(
conn.func_handler.handle_llm_function_call(
conn, function_call_data
conn, function_call_data, speaker=speaker
),
conn.loop,
).result(timeout=tool_call_timeout)
@@ -40,7 +40,8 @@ async def resume_vad_detection(conn: "ConnectionHandler"):
conn.just_woken_up = False
async def startToChat(conn: "ConnectionHandler", text):
async def startToChat(conn: "ConnectionHandler", text, speaker=None):
"""speaker: 本轮声纹身份快照,随调用链传递。"""
# 检查输入是否是JSON格式(包含说话人信息)
speaker_name = None
actual_text = text
@@ -69,11 +70,8 @@ async def startToChat(conn: "ConnectionHandler", text):
# 如果解析失败,继续使用原始文本
pass
# 保存说话人信息到连接对象
if speaker_name:
conn.current_speaker = speaker_name
else:
conn.current_speaker = None
# 说话人名供提示词注入;声纹身份走 speaker 快照,不挂连接
conn.current_speaker = speaker_name
if conn.need_bind:
await check_bind_device(conn)
@@ -92,7 +90,7 @@ async def startToChat(conn: "ConnectionHandler", text):
await handleAbortMessage(conn)
# 首先进行意图分析,使用实际文本内容
intent_handled = await handle_user_intent(conn, actual_text)
intent_handled = await handle_user_intent(conn, actual_text, speaker=speaker)
if intent_handled:
# 如果意图已被处理,不再进行聊天
@@ -104,7 +102,7 @@ async def startToChat(conn: "ConnectionHandler", text):
# 准备开始新会话
conn.client_abort = False
conn.executor.submit(conn.chat, actual_text)
conn.executor.submit(conn.chat, actual_text, depth=0, speaker=speaker)
async def no_voice_close_connect(conn: "ConnectionHandler", have_voice):
+12 -5
View File
@@ -17,6 +17,7 @@ from core.providers.asr.dto.dto import InterfaceType
from core.handle.receiveAudioHandle import startToChat
from core.handle.reportHandle import enqueue_asr_report
from core.utils.util import remove_punctuation_and_length
from core.utils.voiceprint_provider import SpeakerIdentification
from core.handle.receiveAudioHandle import handleAudioMessage
from typing import Optional, Tuple, List, NamedTuple, TYPE_CHECKING
@@ -101,7 +102,7 @@ class ASRProviderBase(ABC):
)
if conn.voiceprint_provider and wav_data:
voiceprint_task = conn.voiceprint_provider.identify_speaker(
voiceprint_task = conn.voiceprint_provider.identify_speaker_detail(
wav_data, conn.session_id
)
# 并发等待两个结果
@@ -119,11 +120,11 @@ class ASRProviderBase(ABC):
else:
raw_text, _ = asr_result
speaker_name = ""
if isinstance(voiceprint_result, Exception):
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
speaker_name = ""
else:
speaker_name = voiceprint_result
elif voiceprint_result is not None:
speaker_name = voiceprint_result.name or ""
# 判断 ASR 结果类型
if isinstance(raw_text, dict):
@@ -164,10 +165,16 @@ class ASRProviderBase(ABC):
self.stop_ws_connection()
if text_len > 0:
# 本轮声纹身份快照,随调用链供 MCP 接入点工具使用
speaker = (
voiceprint_result
if isinstance(voiceprint_result, SpeakerIdentification)
else None
)
audio_snapshot = asr_audio_task.copy()
enqueue_asr_report(conn, enhanced_text, audio_snapshot)
# 使用自定义模块进行上报
await startToChat(conn, enhanced_text)
await startToChat(conn, enhanced_text, speaker=speaker)
except Exception as e:
logger.bind(tag=TAG).error(f"处理语音停止失败: {e}")
import traceback
@@ -11,9 +11,9 @@ class ToolExecutor(ABC):
@abstractmethod
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行工具调用"""
"""执行工具调用。speaker: 可选的说话人身份快照。"""
pass
@abstractmethod
@@ -15,7 +15,7 @@ class DeviceIoTExecutor(ToolExecutor):
self.iot_tools: Dict[str, ToolDefinition] = {}
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行设备端IoT工具"""
if not self.has_tool(tool_name):
@@ -16,7 +16,7 @@ class DeviceMCPExecutor(ToolExecutor):
self.conn = conn
async def execute(
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行设备端MCP工具"""
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
@@ -1,9 +1,11 @@
"""MCP接入点工具执行器"""
from typing import Dict, Any
from typing import Dict, Any, Optional
from ..base import ToolType, ToolDefinition, ToolExecutor
from plugins_func.register import Action, ActionResponse
from core.utils.voiceprint_provider import SpeakerIdentification
from .mcp_endpoint_handler import call_mcp_endpoint_tool
from .mcp_speaker import should_forward_speaker, speaker_call_meta
class MCPEndpointExecutor(ToolExecutor):
@@ -13,9 +15,9 @@ class MCPEndpointExecutor(ToolExecutor):
self.conn = conn
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
self, conn, tool_name: str, arguments: Dict[str, Any], speaker: Optional[SpeakerIdentification] = None
) -> ActionResponse:
"""执行MCP接入点工具"""
"""执行MCP接入点工具。speaker: 本轮说话人快照。"""
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
return ActionResponse(
action=Action.ERROR,
@@ -29,14 +31,20 @@ class MCPEndpointExecutor(ToolExecutor):
)
try:
# 转换参数为JSON字符串
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
import json
meta = None
if speaker is not None and should_forward_speaker(
getattr(conn, "config", None)
):
meta = speaker_call_meta(speaker.speaker_id, speaker.name)
args_str = json.dumps(arguments) if arguments else "{}"
# 调用MCP接入点工具
result = await call_mcp_endpoint_tool(
conn.mcp_endpoint_client, tool_name, args_str
conn.mcp_endpoint_client, tool_name, args_str, meta=meta
)
resultJson = None
@@ -6,6 +6,7 @@ import re
import websockets
from config.logger import setup_logging
from .mcp_endpoint_client import MCPEndpointClient
from .mcp_speaker import build_mcp_endpoint_tool_payload
TAG = __name__
logger = setup_logging()
@@ -284,7 +285,11 @@ async def send_mcp_endpoint_tools_list_continue(
async def call_mcp_endpoint_tool(
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
mcp_client: MCPEndpointClient,
tool_name: str,
args: str = "{}",
timeout: int = 30,
meta: dict = None,
):
"""
调用指定的MCP接入点工具,并等待响应
@@ -350,16 +355,16 @@ async def call_mcp_endpoint_tool(
raise e
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
payload = {
"jsonrpc": "2.0",
"id": tool_call_id,
"method": "tools/call",
"params": {"name": actual_name, "arguments": arguments},
}
payload = build_mcp_endpoint_tool_payload(
tool_call_id, actual_name, arguments, meta
)
message = json.dumps(payload)
log_suffix = ""
if meta:
log_suffix = f",_meta: {json.dumps(meta, ensure_ascii=False)}"
logger.bind(tag=TAG).info(
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}{log_suffix}"
)
await mcp_client.send_message(message)
@@ -0,0 +1,51 @@
"""把已识别的说话人附到 MCP 接入点 tools/call 上。
工具参数保持模型给出的原样。身份放在 MCP 的 params._meta 里,
接入点上的服务可以读取,不读的服务按协议忽略。
"""
from typing import Any, Dict, Optional
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
def should_forward_speaker(config: Optional[dict]) -> bool:
"""mcp_forward_speaker 缺省为开启。显式 false 时不附带身份。"""
if not isinstance(config, dict):
return True
value = config.get("mcp_forward_speaker", True)
if value is None:
return True
return str(value).strip().lower() in ("true", "1", "yes")
def speaker_call_meta(speaker_id: Any, speaker_name: Any) -> Optional[Dict[str, str]]:
"""有真实身份时返回 _meta;没有则返回 None,调用方不写这个字段。"""
meta: Dict[str, str] = {}
if isinstance(speaker_id, str):
speaker_id = speaker_id.strip()
if speaker_id:
meta["speakerId"] = speaker_id
if isinstance(speaker_name, str):
speaker_name = speaker_name.strip()
if speaker_name and speaker_name != UNKNOWN_SPEAKER_NAME:
meta["speakerName"] = speaker_name
return meta or None
def build_mcp_endpoint_tool_payload(
tool_call_id: int,
name: str,
arguments: Dict[str, Any],
meta: Optional[Dict[str, str]] = None,
) -> dict:
"""组装 tools/call。meta 为空时不出现 _meta,参数字典原样放进 arguments。"""
params: Dict[str, Any] = {"name": name, "arguments": arguments}
if meta:
params["_meta"] = meta
return {
"jsonrpc": "2.0",
"id": tool_call_id,
"method": "tools/call",
"params": params,
}
@@ -10,12 +10,13 @@ import concurrent.futures
from contextlib import AsyncExitStack
from typing import Optional, List, Dict, Any
import httpx2
from mcp import ClientSession, StdioServerParameters, Implementation
from mcp.client.session import SamplingFnT, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.shared.session import ProgressFnT
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.dispatcher import ProgressFnT # mcp 2.x:从 mcp.shared.session 移来
from config.logger import setup_logging
from core.utils.util import sanitize_tool_name
@@ -203,13 +204,21 @@ class ServerMCPClient:
if transport_type == "streamable-http" or transport_type == "http":
# 使用 Streamable HTTP 传输
http_r, http_w, get_session_id = await stack.enter_async_context(
streamablehttp_client(
# mcp 2.x:headers/timeout 改由 http_client 承载,产出从 3 元组变为 2 元组
http_client = httpx2.AsyncClient(
headers=headers,
timeout=httpx2.Timeout(
self.config.get("timeout", 30),
read=self.config.get("sse_read_timeout", 60 * 5),
),
)
stack.push_async_callback(http_client.aclose)
http_r, http_w = await stack.enter_async_context(
streamable_http_client(
url=self.config["url"],
headers=headers,
timeout=self.config.get("timeout", 30),
sse_read_timeout=self.config.get("sse_read_timeout", 60 * 5),
terminate_on_close=self.config.get("terminate_on_close", True)
http_client=http_client,
terminate_on_close=self.config.get("terminate_on_close", True),
)
)
read_stream, write_stream = http_r, http_w
@@ -22,7 +22,7 @@ class ServerMCPExecutor(ToolExecutor):
await self.mcp_manager.initialize_servers()
async def execute(
self, conn, tool_name: str, arguments: Dict[str, Any]
self, conn, tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行服务端MCP工具"""
if not self._initialized or not self.mcp_manager:
@@ -17,7 +17,7 @@ class ServerPluginExecutor(ToolExecutor):
self.config = conn.config
async def execute(
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行服务端插件工具"""
func_item = all_function_registry.get(tool_name)
@@ -137,16 +137,16 @@ class UnifiedToolHandler:
return self.tool_manager.has_tool(tool_name)
async def handle_llm_function_call(
self, conn, function_call_data: Dict[str, Any]
self, conn, function_call_data: Dict[str, Any], speaker=None
) -> Optional[ActionResponse]:
"""处理LLM函数调用"""
"""处理LLM函数调用。speaker: 本轮说话人快照。"""
try:
# 处理多函数调用
if "function_calls" in function_call_data:
responses = []
for call in function_call_data["function_calls"]:
result = await self.tool_manager.execute_tool(
call["name"], call.get("arguments", {})
call["name"], call.get("arguments", {}), speaker=speaker
)
responses.append(result)
return self._combine_responses(responses)
@@ -175,7 +175,9 @@ class UnifiedToolHandler:
self.logger.warning(f"发送工具调用显示消息失败: {e}")
# 执行工具调用
result = await self.tool_manager.execute_tool(function_name, arguments)
result = await self.tool_manager.execute_tool(
function_name, arguments, speaker=speaker
)
return result
except Exception as e:
@@ -71,9 +71,9 @@ class ToolManager:
return tool_def.tool_type if tool_def else None
async def execute_tool(
self, tool_name: str, arguments: Dict[str, Any]
self, tool_name: str, arguments: Dict[str, Any], speaker=None
) -> ActionResponse:
"""执行工具调用"""
"""执行工具调用。speaker: 本轮说话人快照。"""
try:
# 查找工具类型
tool_type = self.get_tool_type(tool_name)
@@ -93,7 +93,7 @@ class ToolManager:
# 执行工具
self.logger.info(f"执行工具: {tool_name},参数: {arguments}")
result = await executor.execute(self.conn, tool_name, arguments)
result = await executor.execute(self.conn, tool_name, arguments, speaker=speaker)
self.logger.debug(f"工具执行结果: {result}")
return result
@@ -144,6 +144,7 @@ class TTSProvider(TTSProviderBase):
self.ws = None
self.interface_type = InterfaceType.DUAL_STREAM
self._monitor_task = None # 监听任务引用
self._pending_prefix = ""
self.appId = config.get("appid")
self.access_token = config.get("access_token")
self.cluster = config.get("cluster")
@@ -306,6 +307,9 @@ class TTSProvider(TTSProviderBase):
if message.sentence_type == SentenceType.FIRST:
# 重置流式处理状态
self.reset_stream_state()
# 重置 opus 流式编码器跨轮残留
if hasattr(self, "opus_encoder") and self.opus_encoder:
self.opus_encoder.reset_state()
# 初始化参数
try:
if not getattr(self.conn, "sentence_id", None):
@@ -433,6 +437,9 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).debug(f"关闭会话~~{session_id}")
try:
if self.ws:
# 句尾替换词滑动窗口残留先发出去
if self._pending_prefix:
await self.text_to_speak(self._pending_prefix, None)
header = Header(
message_type=FULL_CLIENT_REQUEST,
message_type_specific_flags=MsgTypeFlagWithEvent,
@@ -490,7 +497,6 @@ class TTSProvider(TTSProviderBase):
try:
while not self.conn.stop_event.is_set():
try:
# 确保 `recv()` 运行在同一个 event loop
msg = await self.ws.recv()
res = self.parser_response(msg)
self.print_response(res, "send_text res:")
@@ -500,17 +506,18 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).debug(f"链接关闭成功~~")
break
# 只处理当前活跃会话的响应
# 只处理当前活跃会话的响应;旧会话事件不影响当前 activate_session
if res.optional.sessionId and self.conn.sentence_id != res.optional.sessionId:
# 如果是会话结束相关事件,即使会话ID不匹配也要重置状态
if res.optional.event in [EVENT_SessionCanceled, EVENT_SessionFailed, EVENT_SessionFinished]:
logger.bind(tag=TAG).debug(f"收到残余下行结束响应重置会话状态~~")
self.activate_session = False
continue
if res.optional.event == EVENT_SessionCanceled:
logger.bind(tag=TAG).debug(f"释放服务端资源成功~~")
self.activate_session = False
elif res.optional.event == EVENT_SessionFailed:
logger.bind(tag=TAG).error(
f"TTS 会话被服务端拒绝: {self.conn.sentence_id}"
)
self.activate_session = False
elif not self.resource_type and res.optional.event == EVENT_TTSSentenceStart:
json_data = json.loads(res.payload.decode("utf-8"))
self.tts_text = json_data.get("text", "")
@@ -538,6 +545,11 @@ class TTSProvider(TTSProviderBase):
logger.bind(tag=TAG).info(f"句子语音生成成功:{self.tts_text}")
elif res.optional.event == EVENT_SessionFinished:
logger.bind(tag=TAG).debug(f"会话结束~~")
# 句尾冲刷流式 opus 编码器不足一帧的 PCM 残量
if hasattr(self, "opus_encoder") and self.opus_encoder:
self.wav_to_opus_data_audio_raw_stream(
b"", is_end=True, callback=self.handle_opus
)
self.activate_session = False
self._process_before_stop_play_files()
# 非复用模式下,会话结束后发送 FinishConnection
@@ -552,16 +564,14 @@ class TTSProvider(TTSProviderBase):
)
traceback.print_exc()
break
# 连接异常时关闭WebSocket
finally:
# 退出时关闭连接并清理引用
if self.ws:
try:
await self.ws.close()
except:
pass
self.ws = None
# 监听任务退出时清理引用
finally:
self.activate_session = False
self._monitor_task = None
async def send_event(
@@ -619,21 +629,23 @@ class TTSProvider(TTSProviderBase):
# 解析结果
# header
header = response.header
num = 0b00001111
header.protocol_version = res[0] >> 4 & num
mask = 0b00001111
header.protocol_version = res[0] >> 4 & mask
header.header_size = res[0] & 0x0F
header.message_type = (res[1] >> 4) & num
header.message_type = (res[1] >> 4) & mask
header.message_type_specific_flags = res[1] & 0x0F
header.serialization_method = res[2] >> num
header.serialization_method = res[2] >> 4
header.message_compression = res[2] & 0x0F
header.reserved = res[3]
#
offset = 4
offset = header.header_size * 4
optional = response.optional
if header.message_type == FULL_SERVER_RESPONSE or AUDIO_ONLY_RESPONSE:
if header.message_type in (FULL_SERVER_RESPONSE, AUDIO_ONLY_RESPONSE):
# read event
if header.message_type_specific_flags == MsgTypeFlagWithEvent:
optional.event = int.from_bytes(res[offset:8], "big", signed=True)
optional.event = int.from_bytes(
res[offset : offset + 4], "big", signed=True
)
offset += 4
if optional.event == EVENT_NONE:
return response
@@ -644,10 +656,11 @@ class TTSProvider(TTSProviderBase):
optional.response_meta_json, offset = self.read_res_content(
res, offset
)
elif (
optional.event == EVENT_SessionStarted
or optional.event == EVENT_SessionFailed
or optional.event == EVENT_SessionFinished
elif optional.event in (
EVENT_SessionStarted,
EVENT_SessionFailed,
EVENT_SessionFinished,
EVENT_SessionCanceled,
):
optional.sessionId, offset = self.read_res_content(res, offset)
optional.response_meta_json, offset = self.read_res_content(
@@ -820,6 +833,13 @@ class TTSProvider(TTSProviderBase):
):
self.wav_to_opus_data_audio_raw_stream(res.payload, callback=lambda opus_frame: audio_data.append(opus_frame))
elif res.optional.event == EVENT_SessionFinished:
# 句尾冲刷流式 opus 编码器不足一帧的 PCM 残量
if hasattr(self, "opus_encoder") and self.opus_encoder:
self.wav_to_opus_data_audio_raw_stream(
b"",
is_end=True,
callback=lambda opus_frame: audio_data.append(opus_frame),
)
break
finally:
+3 -1
View File
@@ -3,6 +3,8 @@ import re
from typing import List, Dict
from datetime import datetime
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
class Message:
def __init__(
@@ -125,7 +127,7 @@ class Dialogue:
current_speaker_name = (current_speaker or "").strip()
# 仅在本轮注入了有效身份时才输出 speakers_info,避免列表里的名字每轮
# 重复出现诱导模型反复称呼;后续轮不再注入身份,靠对话历史首轮保留
if current_speaker_name and current_speaker_name != "未知说话人":
if current_speaker_name and current_speaker_name != UNKNOWN_SPEAKER_NAME:
speakers = voiceprint_config.get("speakers", [])
speakers_info = "\n<speakers_info>"
speakers_info += f"\n当前说话人:{current_speaker_name}"
+15
View File
@@ -475,6 +475,21 @@ def opus_datas_to_wav_bytes(opus_datas, sample_rate=16000, channels=1):
pass
def audio_file_to_wav_bytes(audio_file_path, sample_rate=16000):
"""音频文件直接转目标采样率的 wav 字节流,不经 Opus 编解码,避免有损"""
file_type = os.path.splitext(audio_file_path)[1]
if file_type:
file_type = file_type.lstrip(".")
with _ffmpeg_path_override():
audio = AudioSegment.from_file(
audio_file_path, format=file_type, parameters=["-nostdin"]
)
audio = audio.set_channels(1).set_frame_rate(sample_rate).set_sample_width(2)
buffer = BytesIO()
audio.export(buffer, format="wav")
return buffer.getvalue()
def check_vad_update(before_config, new_config):
if (
new_config.get("selected_module") is None
@@ -3,7 +3,7 @@ import time
import aiohttp
import requests
from urllib.parse import urlparse, parse_qs
from typing import Optional, Dict
from typing import Optional, Dict, NamedTuple
from config.logger import setup_logging
from core.utils.cache.manager import cache_manager
from core.utils.cache.config import CacheType
@@ -11,6 +11,30 @@ from core.utils.cache.config import CacheType
TAG = __name__
logger = setup_logging()
# 未命中声纹时沿用的展示名。下游只把这个名字交给 LLM,不把它当成真实身份。
UNKNOWN_SPEAKER_NAME = "未知说话人"
class SpeakerIdentification(NamedTuple):
"""一次声纹识别结果。speaker_id 仅在命中已配置说话人时有值。"""
speaker_id: Optional[str]
name: str
def resolve_speaker_identification(
speaker_id: Optional[str],
score: float,
speaker_map: Dict[str, Dict[str, str]],
similarity_threshold: float,
) -> SpeakerIdentification:
"""把声纹接口返回的 speaker_id / score 解析成展示名,并保留命中的 id。"""
if score < similarity_threshold:
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
if speaker_id and speaker_id in speaker_map:
return SpeakerIdentification(speaker_id, speaker_map[speaker_id]["name"])
return SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME)
class VoiceprintProvider:
"""声纹识别服务提供者"""
@@ -138,7 +162,16 @@ class VoiceprintProvider:
return is_healthy
async def identify_speaker(self, audio_data: bytes, session_id: str) -> Optional[str]:
"""识别说话人"""
"""识别说话人,返回展示名。需要 speaker_id 时用 identify_speaker_detail。"""
identified = await self.identify_speaker_detail(audio_data, session_id)
if identified is None:
return None
return identified.name
async def identify_speaker_detail(
self, audio_data: bytes, session_id: str
) -> Optional[SpeakerIdentification]:
"""识别说话人,同时返回声纹 id 和展示名。"""
if not self.enabled or not self.api_url or not self.api_key:
logger.bind(tag=TAG).debug("声纹识别功能已禁用或未配置,跳过识别")
return None
@@ -171,18 +204,19 @@ class VoiceprintProvider:
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
# 相似度阈值检查
if score < self.similarity_threshold:
identified = resolve_speaker_identification(
speaker_id, score, self.speaker_map, self.similarity_threshold
)
# 三种结果:命中 / 低于阈值 / id不在配置中
if identified.speaker_id:
logger.bind(tag=TAG).info(
f"声纹识别成功: {identified.name} (id: {identified.speaker_id}, 相似度: {score:.3f})"
)
elif score < self.similarity_threshold:
logger.bind(tag=TAG).warning(f"声纹识别相似度{score:.3f}低于阈值{self.similarity_threshold}")
return "未知说话人"
if speaker_id and speaker_id in self.speaker_map:
result_name = self.speaker_map[speaker_id]["name"]
logger.bind(tag=TAG).info(f"声纹识别成功: {result_name} (相似度: {score:.3f})")
return result_name
else:
logger.bind(tag=TAG).warning(f"未识别的说话人ID: {speaker_id}")
return "未知说话人"
return identified
else:
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
return None
+17 -15
View File
@@ -7,36 +7,38 @@ numpy==1.26.4
websockets==14.2
#--------- 以下是可升级的依赖
pyyml==0.0.2
silero_vad==6.2.1
silero_vad==6.2.3
opuslib_next==1.3.1
pydub==0.25.1
funasr==1.2.7
funasr==1.4.16
# transformers>=5 要求 torch>=2.6,与本项目 torch 2.2.2 冲突(会禁用 PyTorch,fun_local 加载不了模型)
transformers<5
openai==2.8.1
google-generativeai==0.8.5
google-generativeai==0.8.6
edge_tts==7.3.1
httpx==0.28.1
aiohttp==3.13.2
aiohttp==3.14.5
aiohttp_cors==0.8.1
ormsgpack==1.12.0
ormsgpack==1.12.2
ruamel.yaml==0.18.16
loguru==0.7.3
requests==2.32.5
requests==2.34.2
cozepy==0.20.0
mem0ai==1.0.0
mem0ai==2.2.1
powermem>=0.3.1
bs4==0.0.2
modelscope==1.39.1
sherpa_onnx==1.12.29
mcp==1.22.0
cnlunar==0.2.4
modelscope==1.40.2
sherpa_onnx==1.13.8
mcp==2.3.0
cnlunar==0.2.5
PySocks==1.7.1
dashscope==1.25.2
dashscope==1.27.7
baidu-aip==4.16.13
aioconsole==0.8.2
markitdown==0.1.3
markitdown==0.1.8
mcp-proxy==0.12.0
PyJWT==2.10.1
psutil==7.1.3
PyJWT==2.15.1
psutil==7.2.2
portalocker==3.2.0 # 暂留 3.x:4.x 与 qdrant-client<1.20 的 portalocker<4.0 约束冲突 (PR #3360)
Jinja2==3.1.6
vosk==0.3.45
@@ -0,0 +1,136 @@
"""Wakeup caching accepts both return forms of TTSProviderBase.to_tts."""
import asyncio
import math
import struct
import wave
from io import BytesIO
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
@pytest.fixture
def wakeup_runtime(tmp_path, monkeypatch):
# conftest supplies local configuration; no deployment config is required.
from config import settings
monkeypatch.chdir(tmp_path)
monkeypatch.setattr(settings, "config_file_valid", True)
from core.handle import helloHandle
from core.providers.tts.base import TTSProviderBase
from core.utils.wakeup_word import WakeupWordsConfig
class LocalTTS(TTSProviderBase):
"""Replace only remote synthesis; keep the real file and Opus conversion."""
def __init__(self, output_dir, delete_audio_file):
super().__init__({"output_dir": str(output_dir)}, delete_audio_file)
self.voice = "test-voice"
self.filename = output_dir / "retained.wav"
buffer = BytesIO()
with wave.open(buffer, "wb") as audio:
audio.setnchannels(1)
audio.setsampwidth(2)
audio.setframerate(22050)
audio.writeframes(
b"".join(
struct.pack(
"<h", int(8000 * math.sin(2 * math.pi * 440 * i / 22050))
)
# 1.1s 源音频:给重采样留裕量,规避重采样器 off-by-one
for i in range(int(22050 * 1.1))
)
)
self.audio_bytes = buffer.getvalue()
def generate_filename(self):
return str(self.filename)
async def text_to_speak(self, text, output_file):
if output_file is not None:
Path(output_file).write_bytes(self.audio_bytes)
return self.audio_bytes
cache = WakeupWordsConfig()
monkeypatch.setattr(helloHandle, "wakeup_words_config", cache)
monkeypatch.setattr(helloHandle, "_wakeup_response_lock", asyncio.Lock())
return helloHandle, cache, LocalTTS
@pytest.mark.parametrize("sample_rate", [16000, 24000])
@pytest.mark.parametrize("delete_audio_file", [False, True])
def test_wakeup_cache_accepts_retained_files_and_opus_frames(
tmp_path, wakeup_runtime, sample_rate, delete_audio_file
):
helloHandle, wakeup_cache, LocalTTS = wakeup_runtime
tts = LocalTTS(tmp_path, delete_audio_file)
conn = SimpleNamespace(tts=tts, sample_rate=sample_rate)
tts.conn = conn
asyncio.run(helloHandle.wakeupWordsResponse(conn))
response = wakeup_cache.get_wakeup_response(tts.voice)
assert response is not None
assert response["text"] in helloHandle.WAKEUP_CONFIG["responses"]
with wave.open(response["file_path"], "rb") as audio:
assert audio.getnchannels() == 1
assert audio.getsampwidth() == 2
assert audio.getframerate() == sample_rate
assert audio.getnframes() >= sample_rate
assert any(audio.readframes(audio.getnframes()))
if not delete_audio_file:
assert tts.filename.read_bytes() == tts.audio_bytes
assert not helloHandle._wakeup_response_lock.locked()
@pytest.mark.parametrize("tts_result", [None, []])
def test_empty_synthesis_does_not_replace_cached_response(wakeup_runtime, tts_result):
helloHandle, wakeup_cache, _ = wakeup_runtime
wakeup_cache.generate_file_path = Mock()
wakeup_cache.update_wakeup_response = Mock()
conn = SimpleNamespace(tts=SimpleNamespace(to_tts=lambda _: tts_result))
asyncio.run(helloHandle.wakeupWordsResponse(conn))
wakeup_cache.generate_file_path.assert_not_called()
wakeup_cache.update_wakeup_response.assert_not_called()
assert not helloHandle._wakeup_response_lock.locked()
def test_retained_file_skips_opus_roundtrip(wakeup_runtime, tmp_path, monkeypatch):
"""保留文件路径必须直转 wav,不得经过 Opus 编解码。"""
from core.utils import util
helloHandle, wakeup_cache, LocalTTS = wakeup_runtime
def _forbid_opus(*_args, **_kwargs):
raise AssertionError("retained file path must not run Opus encoding")
monkeypatch.setattr(util, "pcm_to_data_stream", _forbid_opus)
tts = LocalTTS(tmp_path, False)
conn = SimpleNamespace(tts=tts, sample_rate=16000)
tts.conn = conn
asyncio.run(helloHandle.wakeupWordsResponse(conn))
assert wakeup_cache.get_wakeup_response(tts.voice) is not None
def test_missing_retained_file_preserves_cache_and_releases_lock(wakeup_runtime):
helloHandle, wakeup_cache, _ = wakeup_runtime
wakeup_cache.generate_file_path = Mock()
wakeup_cache.update_wakeup_response = Mock()
conn = SimpleNamespace(
tts=SimpleNamespace(voice="test-voice", to_tts=lambda _: "missing.wav"),
sample_rate=16000,
)
with pytest.raises(FileNotFoundError):
asyncio.run(helloHandle.wakeupWordsResponse(conn))
wakeup_cache.generate_file_path.assert_not_called()
wakeup_cache.update_wakeup_response.assert_not_called()
assert not helloHandle._wakeup_response_lock.locked()
Whitespace-only changes.
@@ -0,0 +1,162 @@
"""MCP 接入点 tools/call 通过 _meta 携带说话人,不改工具参数。"""
import json
import pytest
from core.providers.tools.mcp_endpoint.mcp_speaker import (
build_mcp_endpoint_tool_payload,
should_forward_speaker,
speaker_call_meta,
)
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME, SpeakerIdentification
def test_meta_includes_id_and_name():
assert speaker_call_meta(" test1 ", " 张三 ") == {
"speakerId": "test1",
"speakerName": "张三",
}
def test_meta_omits_unknown_speaker_and_blank_id():
assert speaker_call_meta(None, UNKNOWN_SPEAKER_NAME) is None
assert speaker_call_meta(" ", "张三") == {"speakerName": "张三"}
assert speaker_call_meta("test1", UNKNOWN_SPEAKER_NAME) == {"speakerId": "test1"}
def test_forwarding_defaults_on_and_can_be_disabled():
assert should_forward_speaker(None) is True
assert should_forward_speaker({}) is True
assert should_forward_speaker({"mcp_forward_speaker": True}) is True
assert should_forward_speaker({"mcp_forward_speaker": False}) is False
# 字符串 false 可关闭;空值视为缺省开启
assert should_forward_speaker({"mcp_forward_speaker": "false"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "False"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "0"}) is False
assert should_forward_speaker({"mcp_forward_speaker": "true"}) is True
assert should_forward_speaker({"mcp_forward_speaker": None}) is True
# 空串按关闭处理,与 manager-api 的 Boolean.parseBoolean("") 一致
assert should_forward_speaker({"mcp_forward_speaker": ""}) is False
def test_payload_adds_meta_without_touching_arguments():
arguments = {"name": "灯"}
payload = build_mcp_endpoint_tool_payload(
7,
"HassTurnOn",
arguments,
{"speakerId": "test1", "speakerName": "张三"},
)
assert payload["method"] == "tools/call"
assert payload["params"]["arguments"] is arguments
assert "speakerId" not in arguments
assert payload["params"]["_meta"]["speakerId"] == "test1"
def test_payload_without_meta_matches_previous_shape():
payload = build_mcp_endpoint_tool_payload(7, "calculator", {"a": 1})
assert set(payload["params"]) == {"name", "arguments"}
assert "_meta" not in payload["params"]
class _Client:
async def is_ready(self):
return True
class _Conn:
def __init__(self, config=None):
self.mcp_endpoint_client = _Client()
self.config = {} if config is None else config
@pytest.mark.asyncio
async def test_executor_forwards_speaker_in_meta_only(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["tool_name"] = tool_name
captured["args"] = args_str
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
arguments = {"area": "客厅"}
conn = _Conn()
result = await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
arguments,
speaker=SpeakerIdentification("test1", "张三"),
)
assert json.loads(captured["args"]) == {"area": "客厅"}
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
assert arguments == {"area": "客厅"}
assert result.result == "done"
@pytest.mark.asyncio
async def test_executor_skips_meta_when_disabled(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn(config={"mcp_forward_speaker": False})
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{"area": "客厅"},
speaker=SpeakerIdentification("test1", "张三"),
)
assert captured["meta"] is None
@pytest.mark.asyncio
async def test_executor_omits_meta_for_unknown_speaker(monkeypatch):
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn()
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{},
speaker=SpeakerIdentification(None, UNKNOWN_SPEAKER_NAME),
)
assert captured["meta"] is None
@pytest.mark.asyncio
async def test_executor_reads_snapshot_not_conn_state(monkeypatch):
"""meta 取自快照,不受连接态影响。"""
from core.providers.tools.mcp_endpoint import mcp_endpoint_executor as executor_mod
captured = {}
async def fake_call(client, tool_name, args_str="{}", timeout=30, meta=None):
captured["meta"] = meta
return "done"
monkeypatch.setattr(executor_mod, "call_mcp_endpoint_tool", fake_call)
conn = _Conn()
await executor_mod.MCPEndpointExecutor(conn).execute(
conn,
"HassTurnOn",
{},
speaker=SpeakerIdentification("test1", "张三"),
)
assert captured["meta"] == {"speakerId": "test1", "speakerName": "张三"}
@@ -0,0 +1,98 @@
"""声纹识别结果要同时保留 speaker_id 和展示名。"""
import aiohttp
import pytest
from core.utils.voiceprint_provider import (
UNKNOWN_SPEAKER_NAME,
VoiceprintProvider,
resolve_speaker_identification,
)
SPEAKERS = {"test1": {"name": "张三", "description": "程序员"}}
def test_matched_speaker_keeps_id_and_name():
identified = resolve_speaker_identification("test1", 0.91, SPEAKERS, 0.4)
assert identified.speaker_id == "test1"
assert identified.name == "张三"
def test_score_equal_to_threshold_still_matches():
identified = resolve_speaker_identification("test1", 0.4, SPEAKERS, 0.4)
assert identified.speaker_id == "test1"
def test_low_score_hides_id_and_uses_unknown_name():
identified = resolve_speaker_identification("test1", 0.39, SPEAKERS, 0.4)
assert identified.speaker_id is None
assert identified.name == UNKNOWN_SPEAKER_NAME
def test_unconfigured_speaker_id_is_unknown():
identified = resolve_speaker_identification("other", 0.99, SPEAKERS, 0.4)
assert identified.speaker_id is None
assert identified.name == UNKNOWN_SPEAKER_NAME
class _Response:
def __init__(self, status, payload):
self.status = status
self._payload = payload
async def json(self):
return self._payload
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
class _Session:
def __init__(self, response):
self._response = response
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
return False
def post(self, *args, **kwargs):
return self._response
def _enabled_provider():
provider = VoiceprintProvider({})
provider.enabled = True
provider.api_url = "http://voiceprint.local/voiceprint/identify"
provider.api_key = "k"
provider.speaker_ids = ["test1"]
provider.speaker_map = SPEAKERS
provider.similarity_threshold = 0.4
return provider
@pytest.mark.asyncio
async def test_identify_speaker_still_returns_display_name(monkeypatch):
provider = _enabled_provider()
monkeypatch.setattr(
aiohttp,
"ClientSession",
lambda *args, **kwargs: _Session(
_Response(200, {"speaker_id": "test1", "score": 0.88})
),
)
assert await provider.identify_speaker(b"wav", "session") == "张三"
detail = await provider.identify_speaker_detail(b"wav", "session")
assert detail.speaker_id == "test1"
assert detail.name == "张三"
@pytest.mark.asyncio
async def test_identify_speaker_disabled_returns_none():
provider = VoiceprintProvider({})
assert await provider.identify_speaker(b"wav", "session") is None
assert await provider.identify_speaker_detail(b"wav", "session") is None