Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3bf619e2dd | ||
|
|
1120be2f60 | ||
|
|
9880ce998f | ||
|
|
9bd668aebc | ||
|
|
d027432432 | ||
|
|
3972df6e4e | ||
|
|
8f2dda09cf | ||
|
|
d4ff9c5c17 | ||
|
|
3709540efd | ||
|
|
333050ae2e | ||
|
|
95ce49ffc9 | ||
|
|
be75e7bd39 | ||
|
|
24fbf7eee2 | ||
|
|
855a64cbb9 | ||
|
|
fdf280f84a | ||
|
|
498191519c | ||
|
|
f614489d81 | ||
|
|
6f958ee4a6 | ||
|
|
b226ee5430 | ||
|
|
9c30b5e035 | ||
|
|
d531d04281 | ||
|
|
d8f8a4a083 | ||
|
|
37d4330224 | ||
|
|
7873984f9f | ||
|
|
6840210ea8 | ||
|
|
0a54124d0d | ||
|
|
ddc441bc59 | ||
|
|
9b7fb3e826 | ||
|
|
a6cdbf9410 | ||
|
|
75fd5d8cc3 | ||
|
|
6fe5630346 |
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")
|
||||
|
||||
+11
@@ -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")
|
||||
|
||||
+5
-6
@@ -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获取激活码
|
||||
*
|
||||
|
||||
+103
-25
@@ -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 + "的设备";
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -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();
|
||||
|
||||
+8
-7
@@ -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");
|
||||
|
||||
+12
@@ -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);
|
||||
}
|
||||
+7
@@ -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);
|
||||
|
||||
/**
|
||||
|
||||
+32
@@ -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());
|
||||
}
|
||||
|
||||
+86
-3
@@ -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));
|
||||
}
|
||||
|
||||
+372
-3
@@ -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());
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
# 上下文源配置
|
||||
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user