Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aee4a3cf7a | ||
|
|
824d2b4837 | ||
|
|
4140981a8a | ||
|
|
65e18712e6 | ||
|
|
63f3860f29 | ||
|
|
34cba2f6a5 |
No files matched your search
@@ -4,6 +4,22 @@
|
||||
|
||||
天气插件 `get_weather` 是小智ESP32语音助手的核心功能之一,支持通过语音查询全国各地的天气信息。插件基于和风天气API,提供实时天气和7天天气预报功能。
|
||||
|
||||
## 仓库不再预置凭据
|
||||
|
||||
单模块部署的时候,天气的凭据从服务器配置里的 `plugins.get_weather` 读。仓库里的模板 `main/xiaozhi-server/config.yaml` 这一节是空的,得自己申请一套填进去。插件读不到 `api_key` 或 `api_host` 的时候会直接提示还没配置,不会拿公共账号去请求。
|
||||
|
||||
仓库历史的数据库变更脚本(manager-api 的 changelog 目录里)还残留着一把**已经失效**的旧 key。那把 key 早就失效了,不要使用它,也不要照着它去填。那几个脚本已经被现网库执行过,改内容会让存量库启动时报校验不一致,所以原样留着。
|
||||
|
||||
创建凭据时要勾选三项权限,少勾哪一项都取不到对应的数据:
|
||||
|
||||
| 权限 | 用来做什么 |
|
||||
| --- | --- |
|
||||
| GeoAPI 城市查询 | 把地名换成城市编号 |
|
||||
| 实时天气 | 当前的温度、风力、湿度这些 |
|
||||
| 7 天天气预报 | 未来几天的预报 |
|
||||
|
||||
三项里少勾一两个,JSON 接口就取不到完整的数据,天气只能退到网页上抓,显示的内容比接口给的少。
|
||||
|
||||
## API Key 申请指南
|
||||
|
||||
### 1. 注册和风天气账号
|
||||
|
||||
@@ -324,7 +324,7 @@ public interface Constant {
|
||||
/**
|
||||
* 版本号
|
||||
*/
|
||||
public static final String VERSION = "0.9.8";
|
||||
public static final String VERSION = "0.9.7";
|
||||
|
||||
/**
|
||||
* 无效固件URL
|
||||
|
||||
@@ -19,6 +19,8 @@ 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;
|
||||
@@ -42,6 +44,7 @@ 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;
|
||||
@@ -49,6 +52,7 @@ 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;
|
||||
@@ -61,6 +65,7 @@ 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;
|
||||
@@ -187,6 +192,15 @@ 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,17 +41,6 @@ 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")
|
||||
|
||||
+6
-5
@@ -174,9 +174,6 @@ 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));
|
||||
}
|
||||
|
||||
@@ -195,7 +192,9 @@ public class DeviceController {
|
||||
@Operation(summary = "更新设备通讯录别名")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updateAlias(@Valid @RequestBody DeviceAddressBookAliasDTO dto) {
|
||||
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
|
||||
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
|
||||
return new Result<Void>().error("无权限操作该设备");
|
||||
}
|
||||
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), dto.getAlias(), null);
|
||||
@@ -206,7 +205,9 @@ public class DeviceController {
|
||||
@Operation(summary = "更新设备通讯录权限")
|
||||
@RequiresPermissions("sys:role:normal")
|
||||
public Result<Void> updatePermission(@Valid @RequestBody DeviceAddressBookPermissionDTO dto) {
|
||||
if (deviceService.getOwnedDeviceByMacAddress(dto.getMacAddress()) == null) {
|
||||
UserDetail user = SecurityUser.getUser();
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(dto.getMacAddress());
|
||||
if (callerDevice == null || !callerDevice.getUserId().equals(user.getId())) {
|
||||
return new Result<Void>().error("无权限操作该设备");
|
||||
}
|
||||
deviceAddressBookService.saveOrUpdate(dto.getMacAddress(), dto.getTargetMac(), null, dto.getHasPermission());
|
||||
|
||||
@@ -83,16 +83,6 @@ public interface DeviceService extends BaseService<DeviceEntity> {
|
||||
*/
|
||||
DeviceEntity getDeviceByMacAddress(String macAddress);
|
||||
|
||||
/**
|
||||
* 根据MAC地址获取当前登录用户名下的设备信息
|
||||
* <p>
|
||||
* 设备不存在或不属于当前用户时返回null,调用方无需再自行比对归属。
|
||||
*
|
||||
* @param macAddress MAC地址
|
||||
* @return 当前用户名下的设备信息,不存在或无权限时返回null
|
||||
*/
|
||||
DeviceEntity getOwnedDeviceByMacAddress(String macAddress);
|
||||
|
||||
/**
|
||||
* 根据设备ID获取激活码
|
||||
*
|
||||
|
||||
+25
-103
@@ -1,13 +1,9 @@
|
||||
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;
|
||||
@@ -22,7 +18,6 @@ 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;
|
||||
@@ -45,11 +40,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
|
||||
@Override
|
||||
public List<DeviceAddressBookEntity> getAddressBookList(String macAddress) {
|
||||
List<DeviceAddressBookEntity> rows = assignUniqueAliases(deviceAddressBookDao.getAddressBookList(macAddress));
|
||||
// 取名按固定顺序算完了,前端要看的是最近改过的排前面,放回去
|
||||
rows.sort(Comparator
|
||||
.comparing(DeviceAddressBookEntity::getUpdateDate, Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return rows;
|
||||
return deviceAddressBookDao.getAddressBookList(macAddress);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,7 +63,7 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
|
||||
// 主动呼叫模式
|
||||
Map<String, String> callerBook = allBooks.get(bookKey(callerMac));
|
||||
Map<String, String> callerBook = allBooks.get(callerMac.toLowerCase());
|
||||
if (callerBook == null) {
|
||||
return errorResult("未找到备注为'" + nickname + "'的设备");
|
||||
}
|
||||
@@ -89,16 +80,13 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
}
|
||||
|
||||
// 获取目标设备如何称呼主叫方
|
||||
Map<String, String> targetBook = allBooks.get(bookKey(targetMac));
|
||||
Map<String, String> targetBook = allBooks.get(targetMac.toLowerCase());
|
||||
String callerNickname = null;
|
||||
if (targetBook != null) {
|
||||
callerNickname = targetBook.get(bookKey(callerMac));
|
||||
callerNickname = targetBook.get(callerMac.toLowerCase());
|
||||
}
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
DeviceEntity callerDevice = deviceService.getDeviceByMacAddress(callerMac);
|
||||
if (callerDevice != null) {
|
||||
callerNickname = callerDevice.getAlias();
|
||||
}
|
||||
callerNickname = deviceService.getDeviceByMacAddress(callerMac).getAlias();
|
||||
if (StringUtils.isBlank(callerNickname)) {
|
||||
callerNickname = formatMacAsDeviceName(callerMac);
|
||||
}
|
||||
@@ -112,26 +100,32 @@ public class DeviceAddressBookServiceImpl implements DeviceAddressBookService {
|
||||
@Override
|
||||
public void refreshCache() {
|
||||
Map<String, Map<String, String>> result = new HashMap<>();
|
||||
List<DeviceAddressBookEntity> allRecords = assignUniqueAliases(deviceAddressBookDao.selectList(null));
|
||||
List<DeviceAddressBookEntity> allRecords = deviceAddressBookDao.selectList(null);
|
||||
Map<String, String> reverseMap = new HashMap<>();
|
||||
|
||||
for (DeviceAddressBookEntity entity : allRecords) {
|
||||
String macA = bookKey(entity.getMacAddress());
|
||||
String macB = bookKey(entity.getTargetMac());
|
||||
String macA = entity.getMacAddress().toLowerCase();
|
||||
String macB = entity.getTargetMac().toLowerCase();
|
||||
String alias = entity.getAlias();
|
||||
Boolean hasPermission = entity.getHasPermission();
|
||||
|
||||
// 构建正向映射: A对B的映射 nickname -> macB|permission
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
// 构建反向记录用于查找B对A的称呼,用同一个去重后的名字,两边才对得上
|
||||
reverseMap.put(macB + ":" + macA, entity.getAlias());
|
||||
// 构建反向记录用于查找B对A的称呼
|
||||
if (alias != null && !alias.isEmpty()) {
|
||||
reverseMap.put(macB + ":" + macA, alias);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建反向映射: B对A的映射 macA -> nickname
|
||||
for (DeviceAddressBookEntity entity : allRecords) {
|
||||
String macA = bookKey(entity.getMacAddress());
|
||||
String macB = bookKey(entity.getTargetMac());
|
||||
String macA = entity.getMacAddress().toLowerCase();
|
||||
String macB = entity.getTargetMac().toLowerCase();
|
||||
String aliasBtoA = reverseMap.get(macA + ":" + macB);
|
||||
if (aliasBtoA != null && !aliasBtoA.isEmpty()) {
|
||||
result.computeIfAbsent(macB, k -> new HashMap<>());
|
||||
@@ -142,54 +136,6 @@ 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);
|
||||
@@ -208,22 +154,14 @@ 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)) {
|
||||
DeviceEntity targetDevice = deviceService.getDeviceByMacAddress(targetMac);
|
||||
if (targetDevice != null) {
|
||||
alias = targetDevice.getAlias();
|
||||
}
|
||||
// 设备登记了但用户没起过名,alias 建表就是 null,这是默认状态
|
||||
// 不是异常。这时候落个 null 进去,缓存刷新会把整条跳过,
|
||||
// 接口照样返回成功,对方却永远呼不过来
|
||||
if (StringUtils.isBlank(alias)) {
|
||||
alias = formatMacAsDeviceName(targetMac);
|
||||
}
|
||||
alias = deviceService.getDeviceByMacAddress(targetMac).getAlias();
|
||||
}
|
||||
// 检查重名
|
||||
alias = generateUniqueAlias(macAddress, targetMac, alias);
|
||||
@@ -291,27 +229,11 @@ 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;
|
||||
}
|
||||
// MAC 本身不区分大小写,落库、查库走的都是小写化的值。
|
||||
// 这里跟着小写化,不跟着的话同一个地址在两条路径上会算出两个称呼,
|
||||
// 数据搬到智控台上看到的名字就叫不到人了
|
||||
String lastTwo = mac.substring(mac.length() - 2).toLowerCase();
|
||||
String lastTwo = mac.substring(mac.length() - 2);
|
||||
return "尾号为" + lastTwo + "的设备";
|
||||
}
|
||||
|
||||
|
||||
-29
@@ -316,12 +316,6 @@ 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())) {
|
||||
// 清除智能体设备数量缓存
|
||||
@@ -406,29 +400,6 @@ 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();
|
||||
|
||||
+7
-8
@@ -110,8 +110,8 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
// 1. 参数验证
|
||||
validateEditParameters(modelType, provideCode, id, modelConfigBodyDTO);
|
||||
|
||||
// 2. 验证模型提供者,使用注册类型的大小写
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
// 2. 验证模型提供者
|
||||
validateModelProvider(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, registeredModelType, id);
|
||||
ModelConfigEntity modelConfigEntity = prepareUpdateEntity(modelConfigBodyDTO, originalEntity, modelType, 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);
|
||||
|
||||
String registeredModelType = resolveModelType(modelType, provideCode);
|
||||
validateModelProvider(modelType, provideCode);
|
||||
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, registeredModelType);
|
||||
ModelConfigEntity modelConfigEntity = prepareAddEntity(modelConfigBodyDTO, modelType);
|
||||
|
||||
modelConfigDao.insert(modelConfigEntity);
|
||||
|
||||
@@ -283,14 +283,13 @@ public class ModelConfigServiceImpl extends BaseServiceImpl<ModelConfigDao, Mode
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证模型提供者并获取注册类型,保留 TTS、Memory、Intent 等类型的大小写
|
||||
* 验证模型提供者
|
||||
*/
|
||||
private String resolveModelType(String modelType, String provideCode) {
|
||||
private void validateModelProvider(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,7 +86,6 @@ 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,7 +16,6 @@ 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;
|
||||
@@ -111,17 +110,6 @@ 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,14 +21,4 @@ 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,13 +21,6 @@ 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,9 +2,7 @@ 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;
|
||||
@@ -83,36 +81,6 @@ 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);
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
-- 添加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,10 +753,3 @@ 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,12 +14,4 @@
|
||||
<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("e123"));
|
||||
assertTrue(masked.endsWith("3123"));
|
||||
assertTrue(masked.contains("****"));
|
||||
assertEquals("verylongapikeyvalue123".length(), masked.length());
|
||||
}
|
||||
|
||||
+3
-86
@@ -1,15 +1,11 @@
|
||||
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;
|
||||
@@ -19,10 +15,7 @@ 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;
|
||||
@@ -33,8 +26,6 @@ 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
|
||||
@@ -44,7 +35,7 @@ class DeviceControllerTest {
|
||||
DeviceEntity entity = ownedDevice();
|
||||
when(deviceService.selectById(DEVICE_ID)).thenReturn(entity);
|
||||
when(deviceService.updateById(entity)).thenReturn(false);
|
||||
DeviceController controller = controller(deviceService, mock(DeviceAddressBookService.class));
|
||||
DeviceController controller = controller(deviceService);
|
||||
|
||||
DeviceUpdateDTO update = new DeviceUpdateDTO();
|
||||
update.setAutoUpdate(0);
|
||||
@@ -63,84 +54,10 @@ class DeviceControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
private DeviceController controller(DeviceService deviceService) {
|
||||
return new DeviceController(
|
||||
deviceService,
|
||||
addressBookService,
|
||||
mock(DeviceAddressBookService.class),
|
||||
mock(RedisUtils.class),
|
||||
mock(SysParamsService.class));
|
||||
}
|
||||
|
||||
+3
-372
@@ -1,47 +1,26 @@
|
||||
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);
|
||||
@@ -53,363 +32,15 @@ class DeviceAddressBookServiceImplTest {
|
||||
when(addressBookDao.selectOne(any())).thenReturn(null);
|
||||
when(addressBookDao.selectList(null)).thenReturn(List.of());
|
||||
|
||||
service.saveOrUpdate(MAC, OTHER_MAC, "living-room", true);
|
||||
service.saveOrUpdate("00:11:22:33:44:55", "00:11:22:33:44:66", "living-room", true);
|
||||
|
||||
ArgumentCaptor<DeviceAddressBookEntity> entityCaptor = ArgumentCaptor.forClass(DeviceAddressBookEntity.class);
|
||||
verify(addressBookDao).insertAddressBook(entityCaptor.capture());
|
||||
DeviceAddressBookEntity entity = entityCaptor.getValue();
|
||||
assertEquals(MAC, entity.getMacAddress());
|
||||
assertEquals(OTHER_MAC, entity.getTargetMac());
|
||||
assertEquals("00:11:22:33:44:55", entity.getMacAddress());
|
||||
assertEquals("00:11:22:33:44:66", 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
@@ -1,184 +0,0 @@
|
||||
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
@@ -1,75 +0,0 @@
|
||||
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
@@ -1,120 +0,0 @@
|
||||
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.8',
|
||||
version: '0.9.7',
|
||||
}),
|
||||
showCancel: false,
|
||||
confirmText: t('common.confirm'),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@@ -9,98 +8,13 @@ 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,
|
||||
triggerSilentRefresh
|
||||
clearRequestTime
|
||||
}
|
||||
|
||||
function sendRequest() {
|
||||
@@ -123,41 +37,30 @@ 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
|
||||
}
|
||||
|
||||
// 打印请求信息
|
||||
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()
|
||||
}
|
||||
if (this._sucCallback) {
|
||||
this._sucCallback(res)
|
||||
}
|
||||
}).catch((res) => {
|
||||
// 打印失败响应
|
||||
console.log('catch', res)
|
||||
httpHandlerError(res, this._failCallback, this._networkFailCallback)
|
||||
})
|
||||
return this
|
||||
},
|
||||
'success'(callback) {
|
||||
|
||||
@@ -77,10 +77,7 @@
|
||||
<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-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"
|
||||
<el-input 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>
|
||||
@@ -164,7 +161,7 @@ export default {
|
||||
fields: JSON.parse(item.fields || '[]').map(f => ({
|
||||
label: f.label,
|
||||
prop: f.key,
|
||||
type: f.type === 'dict' ? 'json-textarea' : f.type === 'password' ? 'password' : 'text',
|
||||
type: f.type === 'password' ? 'password' : 'text',
|
||||
placeholder: `请输入${f.key}`
|
||||
}))
|
||||
}))
|
||||
@@ -207,24 +204,6 @@ 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,17 +330,6 @@ 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,13 +22,6 @@ 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,8 +613,6 @@ 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,
|
||||
@@ -631,7 +629,6 @@ 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,10 +14,6 @@ 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(
|
||||
@@ -70,74 +66,6 @@ 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,
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
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,10 +119,6 @@ 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
|
||||
|
||||
# 上下文源配置
|
||||
# 用于在系统提示词中注入动态数据,如健康数据、股票信息等
|
||||
@@ -134,14 +130,15 @@ context_providers:
|
||||
|
||||
# 插件的基础配置
|
||||
plugins:
|
||||
# 获取天气插件的配置,这里填写你的api_key
|
||||
# 这个密钥是项目共用的key,用多了可能会被限制
|
||||
# 想稳定一点就自行申请替换,每天有1000次免费调用
|
||||
# 获取天气插件的配置,这里填写你自己的api_key和apihost
|
||||
# 仓库里不再预置凭据,申请流程写在 docs/weather-integration.md
|
||||
# 申请地址:https://console.qweather.com/#/apps/create-key/over
|
||||
# 申请后通过这个链接可以找到自己的apihost:https://console.qweather.com/setting?lang=zh
|
||||
# 建key时要勾选三项权限:GeoAPI城市查询、实时天气、7天天气预报
|
||||
# 只勾一两个的话JSON接口取不到数据,天气只能退到网页上抓
|
||||
get_weather:
|
||||
api_host: "mj7p3y7naa.re.qweatherapi.com"
|
||||
api_key: "a861d0d5e7bf4ee1a83d9a9e4f96d4da"
|
||||
api_host: ""
|
||||
api_key: ""
|
||||
default_location: "广州"
|
||||
# 获取新闻插件的配置,这里根据需要的新闻类型传入对应的url链接,默认支持社会、科技、财经新闻
|
||||
# 更多类型的新闻列表查看 https://www.chinanews.com.cn/rss/
|
||||
|
||||
@@ -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.8"
|
||||
SERVER_VERSION = "0.9.7"
|
||||
_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, UNKNOWN_SPEAKER_NAME
|
||||
from core.utils.voiceprint_provider import VoiceprintProvider
|
||||
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,8 +1052,7 @@ class ConnectionHandler:
|
||||
# 更新系统prompt至上下文
|
||||
self.dialogue.update_system_message(self.prompt)
|
||||
|
||||
def chat(self, query, depth=0, speaker=None):
|
||||
# speaker: 本轮声纹身份快照,随工具调用下传
|
||||
def chat(self, query, depth=0):
|
||||
# 保存当前任务的sentence_id到局部变量,避免被新任务覆盖
|
||||
current_sentence_id = None
|
||||
|
||||
@@ -1123,7 +1122,7 @@ class ConnectionHandler:
|
||||
# 避免每轮在 system 重复出现名字诱导模型反复称呼
|
||||
speaker_for_system = None
|
||||
cs = (self.current_speaker or "").strip()
|
||||
if cs and cs != UNKNOWN_SPEAKER_NAME and cs not in self.system_introduced_speakers:
|
||||
if cs and cs != "未知说话人" and cs not in self.system_introduced_speakers:
|
||||
self.system_introduced_speakers.add(cs)
|
||||
speaker_for_system = cs
|
||||
|
||||
@@ -1338,7 +1337,7 @@ class ConnectionHandler:
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
self.func_handler.handle_llm_function_call(
|
||||
self, tool_call_data, speaker=speaker
|
||||
self, tool_call_data
|
||||
),
|
||||
self.loop,
|
||||
)
|
||||
@@ -1501,7 +1500,7 @@ class ConnectionHandler:
|
||||
)
|
||||
)
|
||||
|
||||
self.chat(None, depth=depth + 1, speaker=speaker)
|
||||
self.chat(None, depth=depth + 1)
|
||||
|
||||
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, audio_file_to_wav_bytes
|
||||
from core.utils.util import audio_to_data
|
||||
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,13 +148,8 @@ async def wakeupWordsResponse(conn: "ConnectionHandler"):
|
||||
# 获取当前音色
|
||||
voice = getattr(conn.tts, "voice", "default")
|
||||
|
||||
# 保留的 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)
|
||||
# 使用链接的sample_rate
|
||||
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, speaker=None):
|
||||
async def handle_user_intent(conn: "ConnectionHandler", text):
|
||||
# 预处理输入文本,处理可能的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, speaker=speaker
|
||||
conn, function_call_data
|
||||
),
|
||||
conn.loop,
|
||||
).result(timeout=tool_call_timeout)
|
||||
|
||||
@@ -40,8 +40,7 @@ async def resume_vad_detection(conn: "ConnectionHandler"):
|
||||
conn.just_woken_up = False
|
||||
|
||||
|
||||
async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
"""speaker: 本轮声纹身份快照,随调用链传递。"""
|
||||
async def startToChat(conn: "ConnectionHandler", text):
|
||||
# 检查输入是否是JSON格式(包含说话人信息)
|
||||
speaker_name = None
|
||||
actual_text = text
|
||||
@@ -70,8 +69,11 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
# 如果解析失败,继续使用原始文本
|
||||
pass
|
||||
|
||||
# 说话人名供提示词注入;声纹身份走 speaker 快照,不挂连接
|
||||
conn.current_speaker = speaker_name
|
||||
# 保存说话人信息到连接对象
|
||||
if speaker_name:
|
||||
conn.current_speaker = speaker_name
|
||||
else:
|
||||
conn.current_speaker = None
|
||||
|
||||
if conn.need_bind:
|
||||
await check_bind_device(conn)
|
||||
@@ -90,7 +92,7 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
await handleAbortMessage(conn)
|
||||
|
||||
# 首先进行意图分析,使用实际文本内容
|
||||
intent_handled = await handle_user_intent(conn, actual_text, speaker=speaker)
|
||||
intent_handled = await handle_user_intent(conn, actual_text)
|
||||
|
||||
if intent_handled:
|
||||
# 如果意图已被处理,不再进行聊天
|
||||
@@ -102,7 +104,7 @@ async def startToChat(conn: "ConnectionHandler", text, speaker=None):
|
||||
# 准备开始新会话
|
||||
conn.client_abort = False
|
||||
|
||||
conn.executor.submit(conn.chat, actual_text, depth=0, speaker=speaker)
|
||||
conn.executor.submit(conn.chat, actual_text)
|
||||
|
||||
|
||||
async def no_voice_close_connect(conn: "ConnectionHandler", have_voice):
|
||||
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
|
||||
@@ -102,7 +101,7 @@ class ASRProviderBase(ABC):
|
||||
)
|
||||
|
||||
if conn.voiceprint_provider and wav_data:
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker_detail(
|
||||
voiceprint_task = conn.voiceprint_provider.identify_speaker(
|
||||
wav_data, conn.session_id
|
||||
)
|
||||
# 并发等待两个结果
|
||||
@@ -120,11 +119,11 @@ class ASRProviderBase(ABC):
|
||||
else:
|
||||
raw_text, _ = asr_result
|
||||
|
||||
speaker_name = ""
|
||||
if isinstance(voiceprint_result, Exception):
|
||||
logger.bind(tag=TAG).error(f"声纹识别失败: {voiceprint_result}")
|
||||
elif voiceprint_result is not None:
|
||||
speaker_name = voiceprint_result.name or ""
|
||||
speaker_name = ""
|
||||
else:
|
||||
speaker_name = voiceprint_result
|
||||
|
||||
# 判断 ASR 结果类型
|
||||
if isinstance(raw_text, dict):
|
||||
@@ -165,16 +164,10 @@ 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, speaker=speaker)
|
||||
await startToChat(conn, enhanced_text)
|
||||
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], speaker=None
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
) -> 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], speaker=None
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
) -> 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], speaker=None
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
) -> ActionResponse:
|
||||
"""执行设备端MCP工具"""
|
||||
if not hasattr(conn, "mcp_client") or not conn.mcp_client:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
"""MCP接入点工具执行器"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any
|
||||
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):
|
||||
@@ -15,9 +13,9 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
self.conn = conn
|
||||
|
||||
async def execute(
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any], speaker: Optional[SpeakerIdentification] = None
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
) -> ActionResponse:
|
||||
"""执行MCP接入点工具。speaker: 本轮说话人快照。"""
|
||||
"""执行MCP接入点工具"""
|
||||
if not hasattr(conn, "mcp_endpoint_client") or not conn.mcp_endpoint_client:
|
||||
return ActionResponse(
|
||||
action=Action.ERROR,
|
||||
@@ -31,20 +29,14 @@ class MCPEndpointExecutor(ToolExecutor):
|
||||
)
|
||||
|
||||
try:
|
||||
# 转换参数为JSON字符串。说话人身份不写入参数,只放在 _meta。
|
||||
# 转换参数为JSON字符串
|
||||
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, meta=meta
|
||||
conn.mcp_endpoint_client, tool_name, args_str
|
||||
)
|
||||
|
||||
resultJson = None
|
||||
|
||||
@@ -6,7 +6,6 @@ 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()
|
||||
@@ -285,11 +284,7 @@ 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,
|
||||
meta: dict = None,
|
||||
mcp_client: MCPEndpointClient, tool_name: str, args: str = "{}", timeout: int = 30
|
||||
):
|
||||
"""
|
||||
调用指定的MCP接入点工具,并等待响应
|
||||
@@ -355,16 +350,16 @@ async def call_mcp_endpoint_tool(
|
||||
raise e
|
||||
|
||||
actual_name = mcp_client.name_mapping.get(tool_name, tool_name)
|
||||
payload = build_mcp_endpoint_tool_payload(
|
||||
tool_call_id, actual_name, arguments, meta
|
||||
)
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": tool_call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": actual_name, "arguments": arguments},
|
||||
}
|
||||
|
||||
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)}{log_suffix}"
|
||||
f"发送MCP接入点工具调用请求: {actual_name},参数: {json.dumps(arguments, ensure_ascii=False)}"
|
||||
)
|
||||
await mcp_client.send_message(message)
|
||||
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
"""把已识别的说话人附到 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,13 +10,12 @@ 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 streamable_http_client
|
||||
from mcp.shared.dispatcher import ProgressFnT # mcp 2.x:从 mcp.shared.session 移来
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
from mcp.shared.session import ProgressFnT
|
||||
|
||||
from config.logger import setup_logging
|
||||
from core.utils.util import sanitize_tool_name
|
||||
@@ -204,21 +203,13 @@ class ServerMCPClient:
|
||||
|
||||
if transport_type == "streamable-http" or transport_type == "http":
|
||||
# 使用 Streamable HTTP 传输
|
||||
# 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(
|
||||
http_r, http_w, get_session_id = await stack.enter_async_context(
|
||||
streamablehttp_client(
|
||||
url=self.config["url"],
|
||||
http_client=http_client,
|
||||
terminate_on_close=self.config.get("terminate_on_close", True),
|
||||
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)
|
||||
)
|
||||
)
|
||||
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], speaker=None
|
||||
self, conn, tool_name: str, arguments: Dict[str, Any]
|
||||
) -> 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], speaker=None
|
||||
self, conn: "ConnectionHandler", tool_name: str, arguments: Dict[str, Any]
|
||||
) -> 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], speaker=None
|
||||
self, conn, function_call_data: Dict[str, Any]
|
||||
) -> Optional[ActionResponse]:
|
||||
"""处理LLM函数调用。speaker: 本轮说话人快照。"""
|
||||
"""处理LLM函数调用"""
|
||||
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", {}), speaker=speaker
|
||||
call["name"], call.get("arguments", {})
|
||||
)
|
||||
responses.append(result)
|
||||
return self._combine_responses(responses)
|
||||
@@ -175,9 +175,7 @@ class UnifiedToolHandler:
|
||||
self.logger.warning(f"发送工具调用显示消息失败: {e}")
|
||||
|
||||
# 执行工具调用
|
||||
result = await self.tool_manager.execute_tool(
|
||||
function_name, arguments, speaker=speaker
|
||||
)
|
||||
result = await self.tool_manager.execute_tool(function_name, arguments)
|
||||
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], speaker=None
|
||||
self, tool_name: str, arguments: Dict[str, Any]
|
||||
) -> 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, speaker=speaker)
|
||||
result = await executor.execute(self.conn, tool_name, arguments)
|
||||
self.logger.debug(f"工具执行结果: {result}")
|
||||
return result
|
||||
|
||||
|
||||
@@ -144,7 +144,6 @@ 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")
|
||||
@@ -307,9 +306,6 @@ 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):
|
||||
@@ -437,9 +433,6 @@ 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,
|
||||
@@ -497,6 +490,7 @@ 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:")
|
||||
@@ -506,18 +500,17 @@ 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", "")
|
||||
@@ -545,11 +538,6 @@ 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
|
||||
@@ -564,14 +552,16 @@ class TTSProvider(TTSProviderBase):
|
||||
)
|
||||
traceback.print_exc()
|
||||
break
|
||||
finally:
|
||||
# 退出时关闭连接并清理引用
|
||||
# 连接异常时关闭WebSocket
|
||||
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(
|
||||
@@ -629,23 +619,21 @@ class TTSProvider(TTSProviderBase):
|
||||
# 解析结果
|
||||
# header
|
||||
header = response.header
|
||||
mask = 0b00001111
|
||||
header.protocol_version = res[0] >> 4 & mask
|
||||
num = 0b00001111
|
||||
header.protocol_version = res[0] >> 4 & num
|
||||
header.header_size = res[0] & 0x0F
|
||||
header.message_type = (res[1] >> 4) & mask
|
||||
header.message_type = (res[1] >> 4) & num
|
||||
header.message_type_specific_flags = res[1] & 0x0F
|
||||
header.serialization_method = res[2] >> 4
|
||||
header.serialization_method = res[2] >> num
|
||||
header.message_compression = res[2] & 0x0F
|
||||
header.reserved = res[3]
|
||||
#
|
||||
offset = header.header_size * 4
|
||||
offset = 4
|
||||
optional = response.optional
|
||||
if header.message_type in (FULL_SERVER_RESPONSE, AUDIO_ONLY_RESPONSE):
|
||||
if header.message_type == FULL_SERVER_RESPONSE or AUDIO_ONLY_RESPONSE:
|
||||
# read event
|
||||
if header.message_type_specific_flags == MsgTypeFlagWithEvent:
|
||||
optional.event = int.from_bytes(
|
||||
res[offset : offset + 4], "big", signed=True
|
||||
)
|
||||
optional.event = int.from_bytes(res[offset:8], "big", signed=True)
|
||||
offset += 4
|
||||
if optional.event == EVENT_NONE:
|
||||
return response
|
||||
@@ -656,11 +644,10 @@ class TTSProvider(TTSProviderBase):
|
||||
optional.response_meta_json, offset = self.read_res_content(
|
||||
res, offset
|
||||
)
|
||||
elif optional.event in (
|
||||
EVENT_SessionStarted,
|
||||
EVENT_SessionFailed,
|
||||
EVENT_SessionFinished,
|
||||
EVENT_SessionCanceled,
|
||||
elif (
|
||||
optional.event == EVENT_SessionStarted
|
||||
or optional.event == EVENT_SessionFailed
|
||||
or optional.event == EVENT_SessionFinished
|
||||
):
|
||||
optional.sessionId, offset = self.read_res_content(res, offset)
|
||||
optional.response_meta_json, offset = self.read_res_content(
|
||||
@@ -833,13 +820,6 @@ 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,8 +3,6 @@ import re
|
||||
from typing import List, Dict
|
||||
from datetime import datetime
|
||||
|
||||
from core.utils.voiceprint_provider import UNKNOWN_SPEAKER_NAME
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(
|
||||
@@ -127,7 +125,7 @@ class Dialogue:
|
||||
current_speaker_name = (current_speaker or "").strip()
|
||||
# 仅在本轮注入了有效身份时才输出 speakers_info,避免列表里的名字每轮
|
||||
# 重复出现诱导模型反复称呼;后续轮不再注入身份,靠对话历史首轮保留
|
||||
if current_speaker_name and current_speaker_name != UNKNOWN_SPEAKER_NAME:
|
||||
if current_speaker_name and current_speaker_name != "未知说话人":
|
||||
speakers = voiceprint_config.get("speakers", [])
|
||||
speakers_info = "\n<speakers_info>"
|
||||
speakers_info += f"\n当前说话人:{current_speaker_name}"
|
||||
|
||||
@@ -475,21 +475,6 @@ 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, NamedTuple
|
||||
from typing import Optional, Dict
|
||||
from config.logger import setup_logging
|
||||
from core.utils.cache.manager import cache_manager
|
||||
from core.utils.cache.config import CacheType
|
||||
@@ -11,30 +11,6 @@ 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:
|
||||
"""声纹识别服务提供者"""
|
||||
@@ -162,16 +138,7 @@ 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
|
||||
@@ -204,19 +171,18 @@ class VoiceprintProvider:
|
||||
|
||||
logger.bind(tag=TAG).info(f"声纹识别耗时: {total_elapsed_time:.3f}s")
|
||||
|
||||
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:
|
||||
# 相似度阈值检查
|
||||
if 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 identified
|
||||
return "未知说话人"
|
||||
else:
|
||||
logger.bind(tag=TAG).error(f"声纹识别API错误: HTTP {response.status}")
|
||||
return None
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import random
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote, urlsplit, urlunsplit, parse_qsl
|
||||
from markitdown import MarkItDown, StreamInfo
|
||||
from config.logger import setup_logging
|
||||
from plugins_func.register import register_function, ToolType, ActionResponse, Action
|
||||
@@ -54,6 +55,27 @@ CHANNEL_MAP = {
|
||||
# 默认新闻来源字典,当配置中没有指定时使用
|
||||
DEFAULT_NEWS_SOURCES = "澎湃新闻;百度热搜;财联社"
|
||||
|
||||
DEFAULT_NEWS_API_URL = "https://newsnow.busiyi.world/api/s?id="
|
||||
|
||||
|
||||
def _build_news_api_url(source, configured_url=None):
|
||||
"""拼出新闻接口的请求地址和查询参数。
|
||||
|
||||
配置里带 {source} 占位符就替换占位符;否则把 id 设成新闻源 id,
|
||||
这样配置里已经写了 id 时也不会拼出脏串。
|
||||
"""
|
||||
api_url = str(configured_url).strip() if configured_url else DEFAULT_NEWS_API_URL
|
||||
if "{source}" in api_url:
|
||||
return api_url.replace("{source}", quote(source, safe="")), None
|
||||
parts = urlsplit(api_url)
|
||||
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
||||
query["id"] = source
|
||||
return (
|
||||
urlunsplit((parts.scheme, parts.netloc, parts.path, "", parts.fragment)),
|
||||
query,
|
||||
)
|
||||
|
||||
|
||||
def _get_newsnow_config(conn):
|
||||
# 从连接配置获取
|
||||
plugins = conn.config.get("plugins", {})
|
||||
@@ -61,6 +83,8 @@ def _get_newsnow_config(conn):
|
||||
sources = newsnow.get("news_sources", "")
|
||||
if isinstance(sources, str) and sources.strip():
|
||||
return sources
|
||||
if isinstance(sources, (list, tuple)):
|
||||
return ";".join(str(item).strip() for item in sources if str(item).strip())
|
||||
|
||||
return ""
|
||||
|
||||
@@ -113,23 +137,26 @@ GET_NEWS_FROM_NEWSNOW_FUNCTION_DESC = {
|
||||
async def fetch_news_from_api(conn: "ConnectionHandler", source="thepaper"):
|
||||
"""从API获取新闻列表"""
|
||||
try:
|
||||
api_url = f"https://newsnow.busiyi.world/api/s?id={source}"
|
||||
|
||||
news_config = conn.config.get("plugins", {}).get("get_news_from_newsnow", {})
|
||||
if news_config.get("url"):
|
||||
api_url = news_config["url"] + source
|
||||
configured_url = news_config.get("url") if isinstance(news_config, dict) else None
|
||||
api_url, request_params = _build_news_api_url(source, configured_url)
|
||||
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
response = await client.get(api_url, headers=headers)
|
||||
response = await client.get(api_url, params=request_params, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取新闻API失败:HTTP {response.status_code},source={source}"
|
||||
)
|
||||
return []
|
||||
data = response.json()
|
||||
|
||||
if "items" in data:
|
||||
return data["items"]
|
||||
else:
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if not isinstance(items, list):
|
||||
logger.bind(tag=TAG).error(f"获取新闻API响应格式错误: {data}")
|
||||
return []
|
||||
return [item for item in items if isinstance(item, dict) and item.get("title")]
|
||||
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).error(f"获取新闻API失败: {e}")
|
||||
@@ -239,18 +266,24 @@ async def get_news_from_newsnow(
|
||||
english_source_id = None
|
||||
|
||||
# 检查输入的中文名称是否在配置的新闻源中
|
||||
news_sources_list = [
|
||||
name.strip() for name in news_sources.split(";") if name.strip()
|
||||
]
|
||||
news_sources_list = [name.strip() for name in news_sources.split(";") if name.strip()]
|
||||
if source in news_sources_list:
|
||||
# 如果输入的中文名称在配置的新闻源中,在 CHANNEL_MAP 中查找对应的英文ID
|
||||
english_source_id = CHANNEL_MAP.get(source)
|
||||
elif not source and news_sources_list:
|
||||
source = news_sources_list[0]
|
||||
english_source_id = CHANNEL_MAP.get(source)
|
||||
|
||||
# 如果找不到对应的英文ID,使用默认源
|
||||
if not english_source_id:
|
||||
logger.bind(tag=TAG).warning(f"无效的新闻源: {source},使用默认源澎湃新闻")
|
||||
english_source_id = "thepaper"
|
||||
source = "澎湃新闻"
|
||||
fallback_source = next(
|
||||
(name for name in news_sources_list if CHANNEL_MAP.get(name)),
|
||||
"澎湃新闻",
|
||||
)
|
||||
logger.bind(tag=TAG).warning(
|
||||
f"无效的新闻源: {source},使用配置中的默认源{fallback_source}"
|
||||
)
|
||||
english_source_id = CHANNEL_MAP[fallback_source]
|
||||
source = fallback_source
|
||||
|
||||
logger.bind(tag=TAG).info(f"获取新闻: 新闻源={source}({english_source_id})")
|
||||
|
||||
@@ -266,20 +299,21 @@ async def get_news_from_newsnow(
|
||||
|
||||
# 随机选择一条新闻
|
||||
selected_news = random.choice(news_items)
|
||||
title = selected_news.get("title", "未知标题")
|
||||
|
||||
# 保存当前新闻链接到连接对象,以便后续查询详情
|
||||
if not hasattr(conn, "last_newsnow_link"):
|
||||
conn.last_newsnow_link = {}
|
||||
conn.last_newsnow_link = {
|
||||
"url": selected_news.get("url", "#"),
|
||||
"title": selected_news.get("title", "未知标题"),
|
||||
"title": title,
|
||||
"source_id": english_source_id,
|
||||
}
|
||||
|
||||
# 构建新闻报告
|
||||
news_report = (
|
||||
f"根据下列数据,用{lang}回应用户的新闻查询请求:\n\n"
|
||||
f"新闻标题: {selected_news['title']}\n"
|
||||
f"新闻标题: {title}\n"
|
||||
# f"新闻来源: {source}\n"
|
||||
f"(请以自然、流畅的方式向用户播报这条新闻标题,"
|
||||
f"提示用户可以要求获取详细内容,此时会获取新闻的详细内容。)"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import asyncio
|
||||
import datetime
|
||||
import re
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from config.logger import setup_logging
|
||||
@@ -44,6 +47,41 @@ HEADERS = {
|
||||
)
|
||||
}
|
||||
|
||||
# 仓库里不再预置和风天气的凭据,自己部署的人得自己去申请一个
|
||||
WEATHER_CREDENTIAL_HINT = (
|
||||
"尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入"
|
||||
)
|
||||
WEATHER_HOST_HINT = (
|
||||
"尚未配置和风天气 api_host,请参考 docs/weather-integration.md 申请后填入"
|
||||
)
|
||||
|
||||
# 上游挂着的时候每次提问都要把整条链路重走一遍,用户要等二十几秒。
|
||||
# 失败结果短时间内不会变,先记下来直接回
|
||||
WEATHER_FAILURE_TTL = 300
|
||||
|
||||
WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
||||
# 报告里的「今天」按北京时间算。默认部署的服务器时区是 UTC,国内用户北京时间
|
||||
# 凌晨到早上八点这段时间服务器还在昨天,「今天」会说错一整天。
|
||||
# 时区自己造,不依赖 zoneinfo:容器和 Windows 上不一定有 tzdata
|
||||
BEIJING_TZ = datetime.timezone(datetime.timedelta(hours=8))
|
||||
|
||||
# 网页上写的是符号单位,报告里统一用中文,两条路径读出来的东西才对得上
|
||||
UNIT_NAMES = {
|
||||
"km/h": "公里/小时",
|
||||
"km": "公里",
|
||||
"mm": "毫米",
|
||||
"hpa": "百帕",
|
||||
"°": "℃",
|
||||
"°c": "℃",
|
||||
}
|
||||
|
||||
# 网页上的参数名和接口返回的不一样,报告里用接口那套
|
||||
ITEM_NAMES = {
|
||||
"大气压": "气压",
|
||||
"相对湿度": "湿度",
|
||||
}
|
||||
|
||||
# 天气代码 https://dev.qweather.com/docs/resource/icons/#weather-icons
|
||||
WEATHER_CODE_MAP = {
|
||||
"100": "晴",
|
||||
@@ -111,27 +149,264 @@ WEATHER_CODE_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def format_observation_time(raw_time):
|
||||
"""观测时间接口给的是带 T 的写法,报告里换成空格好看点"""
|
||||
if not raw_time:
|
||||
return None
|
||||
return str(raw_time).replace("T", " ", 1)
|
||||
|
||||
|
||||
def beijing_today(now=None):
|
||||
"""
|
||||
北京时间今天是哪天
|
||||
|
||||
<p>
|
||||
服务器本地时区不能拿来当准:默认部署写的是 UTC,国内用户北京时间凌晨到
|
||||
早上八点这段时间,服务器还在昨天,「今天」会说错一整天。
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=datetime.timezone.utc)
|
||||
return now.astimezone(BEIJING_TZ).date()
|
||||
|
||||
|
||||
def format_forecast_date(raw_date, today=None):
|
||||
"""
|
||||
预报日期统一成 今天/明天/后天 或者 星期加月日
|
||||
|
||||
<p>
|
||||
接口给的是 2026-10-24 这种写法,网页上写的是 今天10月24日,
|
||||
两条路径各写各的,用户听不出来是同一份数据。统一成一种写法。
|
||||
"""
|
||||
if not raw_date:
|
||||
return raw_date
|
||||
text = str(raw_date).strip()
|
||||
today = today or beijing_today()
|
||||
|
||||
iso = re.fullmatch(r"(\d{4})-(\d{1,2})-(\d{1,2})", text)
|
||||
if iso:
|
||||
year = int(iso.group(1))
|
||||
month = int(iso.group(2))
|
||||
day = int(iso.group(3))
|
||||
else:
|
||||
found = re.search(r"(\d{1,2})月(\d{1,2})日", text)
|
||||
if not found:
|
||||
return text
|
||||
year = today.year
|
||||
month = int(found.group(1))
|
||||
day = int(found.group(2))
|
||||
|
||||
try:
|
||||
date_value = datetime.date(year, month, day)
|
||||
except ValueError:
|
||||
return text
|
||||
|
||||
delta = (date_value - today).days
|
||||
if delta == 0:
|
||||
prefix = "今天"
|
||||
elif delta == 1:
|
||||
prefix = "明天"
|
||||
elif delta == 2:
|
||||
prefix = "后天"
|
||||
else:
|
||||
prefix = WEEKDAY_NAMES[date_value.weekday()]
|
||||
return f"{prefix}{month}月{day}日"
|
||||
|
||||
|
||||
def normalize_unit(value):
|
||||
"""把单位符号换成中文写法,换不动就原样留着"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return text
|
||||
matched = re.fullmatch(r"(.+?)([a-zA-Z°℃/]+)", text)
|
||||
if not matched:
|
||||
return text
|
||||
unit = matched.group(2).lower()
|
||||
if unit not in UNIT_NAMES:
|
||||
return text
|
||||
return f"{matched.group(1)}{UNIT_NAMES[unit]}"
|
||||
|
||||
|
||||
def normalize_temperature(value):
|
||||
"""温度只留数值,单位在报告里统一写一次"""
|
||||
if value is None:
|
||||
return None
|
||||
matched = re.match(r"-?\d+(?:\.\d+)?", str(value).strip())
|
||||
return matched.group(0) if matched else None
|
||||
|
||||
|
||||
def normalize_wind_scale(value):
|
||||
"""风力统一成 3级 这种带单位的写法"""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text or text.endswith("级"):
|
||||
return text
|
||||
return f"{text}级"
|
||||
|
||||
|
||||
async def fetch_city_info(location, api_key, api_host):
|
||||
url = f"https://{api_host}/geo/v2/city/lookup?key={api_key}&location={location}&lang=zh"
|
||||
url = f"https://{api_host}/geo/v2/city/lookup"
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(5.0, connect=3.0)) as client:
|
||||
response = await client.get(url, headers=HEADERS)
|
||||
data = response.json()
|
||||
if data.get("error") is not None:
|
||||
response = await client.get(
|
||||
url,
|
||||
params={
|
||||
"key": api_key,
|
||||
"location": location,
|
||||
"lang": "zh",
|
||||
"number": 1,
|
||||
},
|
||||
headers=HEADERS,
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气失败,原因:{data.get('error', {}).get('detail')}"
|
||||
f"获取天气城市失败:HTTP {response.status_code} 返回了非 JSON 内容"
|
||||
)
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
logger.bind(tag=TAG).error("获取天气城市失败:响应不是 JSON 对象")
|
||||
return None
|
||||
if response.status_code != 200 or data.get("code") != "200":
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气城市失败:HTTP {response.status_code},代码 {data.get('code')}"
|
||||
)
|
||||
return None
|
||||
return data.get("location", [])[0] if data.get("location") else None
|
||||
|
||||
|
||||
async def fetch_weather_data(city_info, api_key, api_host):
|
||||
location_id = city_info.get("id")
|
||||
if not location_id:
|
||||
return None
|
||||
|
||||
base_url = f"https://{api_host}/v7/weather"
|
||||
params = {"key": api_key, "location": location_id, "lang": "zh"}
|
||||
|
||||
async def request(client, endpoint):
|
||||
response = await client.get(f"{base_url}/{endpoint}", params=params, headers=HEADERS)
|
||||
try:
|
||||
data = response.json()
|
||||
except ValueError:
|
||||
return None
|
||||
if response.status_code != 200 or data.get("code") != "200":
|
||||
logger.bind(tag=TAG).error(
|
||||
f"获取天气数据失败:接口={endpoint} HTTP {response.status_code},代码 {data.get('code')}"
|
||||
)
|
||||
return None
|
||||
return data
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
|
||||
now, daily = await asyncio.gather(
|
||||
request(client, "now"), request(client, "7d")
|
||||
)
|
||||
except Exception as e:
|
||||
logger.bind(tag=TAG).warning(f"获取天气 JSON 接口失败,将尝试网页接口: {e}")
|
||||
return None
|
||||
if now is None or daily is None:
|
||||
return None
|
||||
return now, daily
|
||||
|
||||
|
||||
def format_weather_report(
|
||||
city_name,
|
||||
current_text,
|
||||
current_basic,
|
||||
temps_list,
|
||||
observed_at=None,
|
||||
data_updated_at=None,
|
||||
):
|
||||
"""两条路径的报告走同一个模板,日期和单位的写法才对得上"""
|
||||
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_text}\n"
|
||||
if observed_at:
|
||||
weather_report += f"观测时间:{observed_at}\n"
|
||||
elif data_updated_at:
|
||||
# 顶层那个是接口的数据更新时间,不是观测时间,标签得说清楚
|
||||
weather_report += f"数据更新时间:{data_updated_at}\n"
|
||||
|
||||
if current_basic:
|
||||
weather_report += "详细参数:\n"
|
||||
weather_report += "".join(
|
||||
f" · {key}: {value}\n" for key, value in current_basic.items()
|
||||
)
|
||||
|
||||
weather_report += "\n未来7天预报:\n"
|
||||
weather_report += "".join(
|
||||
f"{date}: {weather},气温 {low}~{high}℃\n"
|
||||
for date, weather, high, low in temps_list
|
||||
)
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
return weather_report
|
||||
|
||||
|
||||
def build_weather_report(city_info, now_data, daily_data):
|
||||
city_name = city_info.get("name") or city_info.get("adm2") or "未知位置"
|
||||
now = now_data.get("now", {})
|
||||
|
||||
current_basic = {
|
||||
"温度": f"{now['temp']}℃" if now.get("temp") is not None else None,
|
||||
"体感温度": f"{now['feelsLike']}℃" if now.get("feelsLike") is not None else None,
|
||||
"湿度": f"{now['humidity']}%" if now.get("humidity") is not None else None,
|
||||
"风向": now.get("windDir"),
|
||||
"风力": normalize_wind_scale(now.get("windScale")),
|
||||
"能见度": f"{now['vis']}公里" if now.get("vis") is not None else None,
|
||||
"气压": f"{now['pressure']}百帕" if now.get("pressure") is not None else None,
|
||||
"降水量": f"{now['precip']}毫米" if now.get("precip") is not None else None,
|
||||
}
|
||||
current_basic = {key: value for key, value in current_basic.items() if value}
|
||||
|
||||
temps_list = []
|
||||
for item in daily_data.get("daily", [])[:7]:
|
||||
date = format_forecast_date(item.get("fxDate") or "未知日期")
|
||||
weather = item.get("textDay") or WEATHER_CODE_MAP.get(item.get("iconDay"), "未知")
|
||||
high = normalize_temperature(item.get("tempMax")) or "未知"
|
||||
low = normalize_temperature(item.get("tempMin")) or "未知"
|
||||
temps_list.append((date, weather, high, low))
|
||||
|
||||
# 观测时间在 now.obsTime 里,没有的时候才用响应顶层那个数据更新时间,
|
||||
# 两个不是一个意思,标签得分开写
|
||||
observed_at = format_observation_time(now.get("obsTime"))
|
||||
data_updated_at = format_observation_time(now_data.get("updateTime"))
|
||||
return format_weather_report(
|
||||
city_name,
|
||||
now.get("text", "未知"),
|
||||
current_basic,
|
||||
temps_list,
|
||||
observed_at,
|
||||
data_updated_at,
|
||||
)
|
||||
|
||||
|
||||
async def fetch_weather_page(url):
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=3.0)) as client:
|
||||
# 网页只是兜底,等太久不值得,接口那边不通的话很快就该告诉用户
|
||||
async with httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(5.0, connect=3.0), follow_redirects=True
|
||||
) as client:
|
||||
response = await client.get(url, headers=HEADERS)
|
||||
return BeautifulSoup(response.text, "html.parser") if response.status_code == 200 else None
|
||||
|
||||
|
||||
def parse_weather_info(soup):
|
||||
city_name = soup.select_one("h1.c-submenu__location").get_text(strip=True)
|
||||
"""
|
||||
解析和风天气网页上的天气
|
||||
|
||||
<p>
|
||||
页面的结构一变,取不到元素就返回 None。抛异常的话 Python 的原文
|
||||
会被当成回复内容念给用户,听着像系统在报错。
|
||||
"""
|
||||
if soup is None:
|
||||
return None
|
||||
|
||||
city_element = soup.select_one("h1.c-submenu__location")
|
||||
if city_element is None:
|
||||
logger.bind(tag=TAG).warning("网页天气解析失败:取不到城市名")
|
||||
return None
|
||||
city_name = city_element.get_text(strip=True)
|
||||
|
||||
current_abstract = soup.select_one(".c-city-weather-current .current-abstract")
|
||||
current_abstract = (
|
||||
@@ -145,20 +420,48 @@ def parse_weather_info(soup):
|
||||
parts = item.get_text(strip=True, separator=" ").split(" ")
|
||||
if len(parts) == 2:
|
||||
key, value = parts[1], parts[0]
|
||||
# 风向和风力挤在一项里,拆成两项跟接口那套对上
|
||||
if key.endswith("风") and value.endswith("级"):
|
||||
current_basic["风向"] = key
|
||||
current_basic["风力"] = value
|
||||
continue
|
||||
key = ITEM_NAMES.get(key, key)
|
||||
value = normalize_unit(value)
|
||||
if not value or value == "0":
|
||||
continue
|
||||
current_basic[key] = value
|
||||
|
||||
# 网页上标的当前时间,取不到就当作没有。页面结构变了不该抛异常
|
||||
observed_at = None
|
||||
time_element = soup.select_one(".current-weather__bg .current-time")
|
||||
if time_element is not None:
|
||||
observed_at = time_element.get_text(strip=True) or None
|
||||
|
||||
temps_list = []
|
||||
for row in soup.select(".city-forecast-tabs__row")[:7]: # 取前7天的数据
|
||||
date = row.select_one(".date-bg .date").get_text(strip=True)
|
||||
weather_code = (
|
||||
row.select_one(".date-bg .icon")["src"].split("/")[-1].split(".")[0]
|
||||
)
|
||||
date_element = row.select_one(".date-bg .date")
|
||||
icon_element = row.select_one(".date-bg .icon")
|
||||
if date_element is None or icon_element is None:
|
||||
logger.bind(tag=TAG).warning("网页天气解析失败:预报行缺日期或图标")
|
||||
return None
|
||||
icon_src = icon_element.get("src")
|
||||
if not icon_src:
|
||||
logger.bind(tag=TAG).warning("网页天气解析失败:预报行图标没有地址")
|
||||
return None
|
||||
weather_code = icon_src.split("/")[-1].split(".")[0]
|
||||
weather = WEATHER_CODE_MAP.get(weather_code, "未知")
|
||||
temps = [span.get_text(strip=True) for span in row.select(".tmp-cont .temp")]
|
||||
high_temp, low_temp = (temps[0], temps[-1]) if len(temps) >= 2 else (None, None)
|
||||
temps_list.append((date, weather, high_temp, low_temp))
|
||||
temps_list.append(
|
||||
(
|
||||
format_forecast_date(date_element.get_text(strip=True)),
|
||||
weather,
|
||||
normalize_temperature(high_temp) or "未知",
|
||||
normalize_temperature(low_temp) or "未知",
|
||||
)
|
||||
)
|
||||
|
||||
return city_name, current_abstract, current_basic, temps_list
|
||||
return city_name, current_abstract, current_basic, temps_list, observed_at
|
||||
|
||||
|
||||
@register_function("get_weather", GET_WEATHER_FUNCTION_DESC, ToolType.SYSTEM_CTL)
|
||||
@@ -166,11 +469,26 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
weather_config = conn.config.get("plugins", {}).get("get_weather", {})
|
||||
api_host = weather_config.get("api_host", "mj7p3y7naa.re.qweatherapi.com")
|
||||
api_key = weather_config.get("api_key", "a861d0d5e7bf4ee1a83d9a9e4f96d4da")
|
||||
api_key = str(weather_config.get("api_key") or "").strip()
|
||||
api_host = str(weather_config.get("api_host") or "").strip()
|
||||
default_location = weather_config.get("default_location", "广州")
|
||||
client_ip = conn.client_ip
|
||||
|
||||
# 凭据没配就没法取数据,直接告诉用户去申请,别拿空 key 去撞接口。
|
||||
# 这句话要让开发者原样听到:走 LLM 转述的话,文档路径会被转述掉
|
||||
if not api_key:
|
||||
logger.bind(tag=TAG).warning(
|
||||
"未配置和风天气 api_key,天气插件没法取数据。"
|
||||
"申请和填写步骤见 docs/weather-integration.md 的「API Key 申请指南」"
|
||||
)
|
||||
return ActionResponse(Action.RESPONSE, None, WEATHER_CREDENTIAL_HINT)
|
||||
if not api_host:
|
||||
logger.bind(tag=TAG).warning(
|
||||
"未配置和风天气 api_host,天气插件没法取数据。"
|
||||
"申请和填写步骤见 docs/weather-integration.md 的「API Key 申请指南」"
|
||||
)
|
||||
return ActionResponse(Action.RESPONSE, None, WEATHER_HOST_HINT)
|
||||
|
||||
# 优先使用用户提供的location参数
|
||||
if not location:
|
||||
# 通过客户端IP解析城市
|
||||
@@ -193,37 +511,40 @@ async def get_weather(conn: "ConnectionHandler", location: str = None, lang: str
|
||||
location = default_location
|
||||
# 尝试从缓存获取完整天气报告
|
||||
weather_cache_key = f"full_weather_{location}_{lang}"
|
||||
failure_cache_key = f"failed_weather_{location}_{lang}"
|
||||
cached_weather_report = cache_manager.get(CacheType.WEATHER, weather_cache_key)
|
||||
if cached_weather_report:
|
||||
return ActionResponse(Action.REQLLM, cached_weather_report, None)
|
||||
# 刚失败过就不要再走一遍链路,用户等不起
|
||||
cached_failure = cache_manager.get(CacheType.WEATHER, failure_cache_key)
|
||||
if cached_failure:
|
||||
return ActionResponse(Action.REQLLM, cached_failure, None)
|
||||
|
||||
# 缓存未命中,获取实时天气数据
|
||||
city_info = await fetch_city_info(location, api_key, api_host)
|
||||
if not city_info:
|
||||
return ActionResponse(
|
||||
Action.REQLLM, f"未找到相关的城市: {location},请确认地点是否正确", None
|
||||
message = f"暂时查不到 {location} 的天气(城市未找到或天气服务不可用),请稍后再试"
|
||||
cache_manager.set(
|
||||
CacheType.WEATHER, failure_cache_key, message, ttl=WEATHER_FAILURE_TTL
|
||||
)
|
||||
return ActionResponse(Action.REQLLM, message)
|
||||
weather_data = await fetch_weather_data(city_info, api_key, api_host)
|
||||
if weather_data:
|
||||
weather_report = build_weather_report(city_info, *weather_data)
|
||||
else:
|
||||
fx_link = city_info.get("fxLink")
|
||||
soup = await fetch_weather_page(fx_link) if fx_link else None
|
||||
parsed = parse_weather_info(soup)
|
||||
if not parsed:
|
||||
message = "暂时查不到天气数据,请稍后再试"
|
||||
cache_manager.set(
|
||||
CacheType.WEATHER, failure_cache_key, message, ttl=WEATHER_FAILURE_TTL
|
||||
)
|
||||
return ActionResponse(Action.REQLLM, message)
|
||||
city_name, current_abstract, current_basic, temps_list, observed_at = parsed
|
||||
weather_report = format_weather_report(
|
||||
city_name, current_abstract, current_basic, temps_list, observed_at
|
||||
)
|
||||
soup = await fetch_weather_page(city_info["fxLink"])
|
||||
if not soup:
|
||||
return ActionResponse(Action.REQLLM, None, "请求失败")
|
||||
city_name, current_abstract, current_basic, temps_list = parse_weather_info(soup)
|
||||
|
||||
weather_report = f"您查询的位置是:{city_name}\n\n当前天气: {current_abstract}\n"
|
||||
|
||||
# 添加有效的当前天气参数
|
||||
if current_basic:
|
||||
weather_report += "详细参数:\n"
|
||||
for key, value in current_basic.items():
|
||||
if value != "0": # 过滤无效值
|
||||
weather_report += f" · {key}: {value}\n"
|
||||
|
||||
# 添加7天预报
|
||||
weather_report += "\n未来7天预报:\n"
|
||||
for date, weather, high, low in temps_list:
|
||||
weather_report += f"{date}: {weather},气温 {low}~{high}\n"
|
||||
|
||||
# 提示语
|
||||
weather_report += "\n(如需某一天的具体天气,请告诉我日期)"
|
||||
|
||||
# 缓存完整的天气报告
|
||||
cache_manager.set(CacheType.WEATHER, weather_cache_key, weather_report)
|
||||
|
||||
@@ -7,38 +7,36 @@ numpy==1.26.4
|
||||
websockets==14.2
|
||||
#--------- 以下是可升级的依赖
|
||||
pyyml==0.0.2
|
||||
silero_vad==6.2.3
|
||||
silero_vad==6.2.1
|
||||
opuslib_next==1.3.1
|
||||
pydub==0.25.1
|
||||
funasr==1.4.16
|
||||
# transformers>=5 要求 torch>=2.6,与本项目 torch 2.2.2 冲突(会禁用 PyTorch,fun_local 加载不了模型)
|
||||
transformers<5
|
||||
funasr==1.2.7
|
||||
openai==2.8.1
|
||||
google-generativeai==0.8.6
|
||||
google-generativeai==0.8.5
|
||||
edge_tts==7.3.1
|
||||
httpx==0.28.1
|
||||
aiohttp==3.14.5
|
||||
aiohttp==3.13.2
|
||||
aiohttp_cors==0.8.1
|
||||
ormsgpack==1.12.2
|
||||
ormsgpack==1.12.0
|
||||
ruamel.yaml==0.18.16
|
||||
loguru==0.7.3
|
||||
requests==2.34.2
|
||||
requests==2.32.5
|
||||
cozepy==0.20.0
|
||||
mem0ai==2.2.1
|
||||
mem0ai==1.0.0
|
||||
powermem>=0.3.1
|
||||
bs4==0.0.2
|
||||
modelscope==1.40.2
|
||||
sherpa_onnx==1.13.8
|
||||
mcp==2.3.0
|
||||
cnlunar==0.2.5
|
||||
modelscope==1.39.1
|
||||
sherpa_onnx==1.12.29
|
||||
mcp==1.22.0
|
||||
cnlunar==0.2.4
|
||||
PySocks==1.7.1
|
||||
dashscope==1.27.7
|
||||
dashscope==1.25.2
|
||||
baidu-aip==4.16.13
|
||||
aioconsole==0.8.2
|
||||
markitdown==0.1.8
|
||||
markitdown==0.1.3
|
||||
mcp-proxy==0.12.0
|
||||
PyJWT==2.15.1
|
||||
psutil==7.2.2
|
||||
PyJWT==2.10.1
|
||||
psutil==7.1.3
|
||||
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
|
||||
@@ -1,136 +0,0 @@
|
||||
"""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.
@@ -1,162 +0,0 @@
|
||||
"""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": "张三"}
|
||||
@@ -1,98 +0,0 @@
|
||||
"""声纹识别结果要同时保留 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
|
||||
@@ -0,0 +1,507 @@
|
||||
"""天气插件的凭据、健壮性和报告格式测试。
|
||||
|
||||
接口一律换成假客户端,不碰网络。
|
||||
"""
|
||||
import datetime
|
||||
import os
|
||||
import time
|
||||
from datetime import date as _date
|
||||
from datetime import timedelta as _timedelta
|
||||
from datetime import timezone as _timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from plugins_func.functions import get_weather as weather_module
|
||||
from plugins_func.register import Action
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text=""):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeAsyncClient:
|
||||
"""顶替 httpx.AsyncClient,把建客户端时的参数记下来"""
|
||||
|
||||
requests = []
|
||||
responder = None
|
||||
init_kwargs = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
FakeAsyncClient.init_kwargs = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
FakeAsyncClient.requests.append({"url": url, "params": params})
|
||||
return FakeAsyncClient.responder(url, params)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http(monkeypatch):
|
||||
FakeAsyncClient.requests = []
|
||||
FakeAsyncClient.responder = lambda url, params: FakeResponse(payload={"code": "200"})
|
||||
FakeAsyncClient.init_kwargs = {}
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return FakeAsyncClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_weather_cache():
|
||||
"""天气缓存是全局的,清一下免得上一条用例的结果串到下一条"""
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
cache_manager.clear(CacheType.WEATHER)
|
||||
yield
|
||||
cache_manager.clear(CacheType.WEATHER)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, plugins=None):
|
||||
self.config = {"plugins": plugins or {}}
|
||||
self.client_ip = None
|
||||
|
||||
|
||||
def _today():
|
||||
return datetime.date.today()
|
||||
|
||||
|
||||
def _web_page(date_text, temp_high="13°", temp_low="9°", current_time=None):
|
||||
"""照着和风天气网页的样子拼一段,解析要的就是这些结构和写法"""
|
||||
time_block = (
|
||||
f'<div class="current-weather__bg"><span class="current-time">{current_time}</span></div>'
|
||||
if current_time
|
||||
else ""
|
||||
)
|
||||
return f"""
|
||||
<html>
|
||||
<h1 class="c-submenu__location">杭州市</h1>
|
||||
<div class="c-city-weather-current">
|
||||
<div class="current-abstract">多云</div>
|
||||
<div class="current-basic">
|
||||
<div class="current-basic___item">4级 西北风</div>
|
||||
<div class="current-basic___item">97% 相对湿度</div>
|
||||
<div class="current-basic___item">13° 体感温度</div>
|
||||
<div class="current-basic___item">9km 能见度</div>
|
||||
<div class="current-basic___item">0.0mm 降水量</div>
|
||||
<div class="current-basic___item">1009hPa 大气压</div>
|
||||
</div>
|
||||
</div>
|
||||
{time_block}
|
||||
<div class="city-forecast-tabs">
|
||||
<div class="city-forecast-tabs__row">
|
||||
<div class="date-bg">
|
||||
<span class="date">{date_text}</span>
|
||||
<img class="icon" src="https://static.qweather.com/img/common/icon/202106d/306.png">
|
||||
</div>
|
||||
<div class="tmp-cont">
|
||||
<span class="temp">{temp_high}</span>
|
||||
<span class="temp">{temp_low}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 凭据
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecordingLogger:
|
||||
"""把日志收下来,好断言告警里带着文档路径"""
|
||||
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
def bind(self, **kwargs):
|
||||
return self
|
||||
|
||||
def _record(self, message, *args, **kwargs):
|
||||
self.messages.append(str(message))
|
||||
|
||||
warning = _record
|
||||
error = _record
|
||||
info = _record
|
||||
debug = _record
|
||||
|
||||
|
||||
async def test_missing_api_key_points_at_the_application_doc():
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
|
||||
)
|
||||
|
||||
assert response.action == Action.RESPONSE
|
||||
assert response.response == (
|
||||
"尚未配置和风天气 api_key,请参考 docs/weather-integration.md 申请后填入"
|
||||
)
|
||||
|
||||
|
||||
async def test_missing_api_host_points_at_the_application_doc():
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k"}}), location="没配主机市"
|
||||
)
|
||||
|
||||
assert response.action == Action.RESPONSE
|
||||
assert response.response == (
|
||||
"尚未配置和风天气 api_host,请参考 docs/weather-integration.md 申请后填入"
|
||||
)
|
||||
|
||||
|
||||
async def test_blank_api_key_counts_as_missing():
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": " ", "api_host": "h"}}), location="空密钥市"
|
||||
)
|
||||
|
||||
assert "尚未配置和风天气 api_key" in response.response
|
||||
|
||||
|
||||
async def test_missing_credentials_are_logged_with_the_doc_path(monkeypatch):
|
||||
"""日志里得写清楚缺哪一项、去哪儿申请,开发看日志就能定位"""
|
||||
recorder = RecordingLogger()
|
||||
monkeypatch.setattr(weather_module, "logger", recorder)
|
||||
|
||||
await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
|
||||
)
|
||||
|
||||
assert recorder.messages, "凭据缺失时一条日志都没有"
|
||||
assert any(
|
||||
"api_key" in message and "docs/weather-integration.md" in message
|
||||
for message in recorder.messages
|
||||
), f"告警里没写清楚缺哪一项、文档在哪儿:{recorder.messages}"
|
||||
|
||||
|
||||
async def test_missing_credentials_are_not_cached():
|
||||
"""凭据缺失的提示不能进缓存,配好之后还得重新走一遍链路"""
|
||||
from core.utils.cache.manager import cache_manager, CacheType
|
||||
|
||||
await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_host": "h"}}), location="没配密钥市"
|
||||
)
|
||||
|
||||
assert cache_manager.get(CacheType.WEATHER, "failed_weather_没配密钥市_zh_CN") is None
|
||||
|
||||
|
||||
async def test_no_credential_leaves_the_request_out(monkeypatch):
|
||||
"""没配凭据就别去撞接口,调用次数得是零"""
|
||||
called = []
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
called.append(1)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
|
||||
await weather_module.get_weather(FakeConn(), location="无凭据市")
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 网页解析
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_parse_weather_info_returns_none_when_city_name_missing():
|
||||
"""页面结构变了要返回 None,抛异常的话 Python 原文会被念给用户"""
|
||||
soup = BeautifulSoup("<html><div>没有城市名</div></html>", "html.parser")
|
||||
|
||||
assert weather_module.parse_weather_info(soup) is None
|
||||
|
||||
|
||||
def test_parse_weather_info_returns_none_when_forecast_icon_missing():
|
||||
html = """
|
||||
<h1 class="c-submenu__location">杭州市</h1>
|
||||
<div class="city-forecast-tabs__row">
|
||||
<div class="date-bg"><span class="date">今天10月24日</span></div>
|
||||
</div>
|
||||
"""
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
|
||||
assert weather_module.parse_weather_info(soup) is None
|
||||
|
||||
|
||||
def test_parse_weather_info_returns_none_when_soup_is_missing():
|
||||
assert weather_module.parse_weather_info(None) is None
|
||||
|
||||
|
||||
async def test_broken_web_page_gives_a_neutral_message(monkeypatch):
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
return {"id": "1", "name": "杭州", "fxLink": "https://example.com/x.html"}
|
||||
|
||||
async def fake_weather_data(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def fake_page(url):
|
||||
return BeautifulSoup("<html></html>", "html.parser")
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_data", fake_weather_data)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_page", fake_page)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="页面变了市"
|
||||
)
|
||||
|
||||
assert response.result == "暂时查不到天气数据,请稍后再试"
|
||||
assert "AttributeError" not in str(response.result)
|
||||
assert "NoneType" not in str(response.result)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 报告内容
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_report_carries_the_observation_time():
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"},
|
||||
{"now": {"text": "多云", "obsTime": "2026-10-24T09:52+08:00"}},
|
||||
{"daily": []},
|
||||
)
|
||||
|
||||
assert "观测时间:2026-10-24 09:52+08:00" in report
|
||||
|
||||
|
||||
def test_report_falls_back_to_the_top_level_update_time():
|
||||
"""没有观测时间的时候,顶层那个是数据更新时间,标签不能写成观测时间"""
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"},
|
||||
{"updateTime": "2026-10-24T10:00+08:00", "now": {"text": "多云"}},
|
||||
{"daily": []},
|
||||
)
|
||||
|
||||
assert "数据更新时间:2026-10-24 10:00+08:00" in report
|
||||
assert "观测时间" not in report
|
||||
|
||||
|
||||
def test_report_prefers_the_observation_time_over_the_update_time():
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"},
|
||||
{
|
||||
"updateTime": "2026-10-24T10:00+08:00",
|
||||
"now": {"text": "多云", "obsTime": "2026-10-24T09:52+08:00"},
|
||||
},
|
||||
{"daily": []},
|
||||
)
|
||||
|
||||
assert "观测时间:2026-10-24 09:52+08:00" in report
|
||||
assert "数据更新时间" not in report
|
||||
|
||||
|
||||
def test_report_without_any_time_has_no_empty_line():
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"}, {"now": {"text": "多云"}}, {"daily": []}
|
||||
)
|
||||
|
||||
assert "观测时间" not in report
|
||||
assert "数据更新时间" not in report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 日期按北京时间
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 时间停在 UTC 的 10 月 23 日晚上 16:30,北京时间已经是 24 号了
|
||||
FROZEN_UTC = datetime.datetime(2026, 10, 23, 16, 30, tzinfo=datetime.timezone.utc)
|
||||
|
||||
|
||||
class FrozenDatetime(datetime.datetime):
|
||||
@classmethod
|
||||
def now(cls, tz=None):
|
||||
return FROZEN_UTC.astimezone(tz) if tz else FROZEN_UTC
|
||||
|
||||
|
||||
class FrozenDate(datetime.date):
|
||||
"""服务器本地时区是 UTC,这个时刻服务器那边还是 23 号"""
|
||||
|
||||
@classmethod
|
||||
def today(cls):
|
||||
return cls(2026, 10, 23)
|
||||
|
||||
|
||||
class FrozenDatetimeModule:
|
||||
datetime = FrozenDatetime
|
||||
date = FrozenDate
|
||||
timezone = _timezone
|
||||
timedelta = _timedelta
|
||||
|
||||
|
||||
def test_today_follows_beijing_time_not_the_server_clock(monkeypatch):
|
||||
"""服务器时区是 UTC 时,「今天」得按北京时间算,否则凌晨八点前会说错一整天"""
|
||||
original_tz = os.environ.get("TZ")
|
||||
os.environ["TZ"] = "UTC"
|
||||
time.tzset()
|
||||
try:
|
||||
monkeypatch.setattr(weather_module, "datetime", FrozenDatetimeModule)
|
||||
|
||||
assert FROZEN_UTC.astimezone().date() == datetime.date(2026, 10, 23), "服务器本地还是 23 号"
|
||||
assert weather_module.beijing_today() == datetime.date(2026, 10, 24)
|
||||
assert (
|
||||
weather_module.format_forecast_date("2026-10-24") == "今天10月24日"
|
||||
), "北京已经跨天了,「今天」还停在 23 号"
|
||||
assert weather_module.format_forecast_date("2026-10-25") == "明天10月25日"
|
||||
finally:
|
||||
if original_tz is None:
|
||||
os.environ.pop("TZ", None)
|
||||
else:
|
||||
os.environ["TZ"] = original_tz
|
||||
time.tzset()
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 两条路径的写法
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_forecast_date_writing_is_the_same_on_both_paths():
|
||||
today = datetime.date(2026, 10, 24)
|
||||
|
||||
assert weather_module.format_forecast_date("2026-10-24", today) == "今天10月24日"
|
||||
assert weather_module.format_forecast_date("今天10月24日", today) == "今天10月24日"
|
||||
assert weather_module.format_forecast_date("2026-10-25", today) == "明天10月25日"
|
||||
assert weather_module.format_forecast_date("周日10月25日", today) == "明天10月25日"
|
||||
assert weather_module.format_forecast_date("2026-10-27", today) == "周二10月27日"
|
||||
assert weather_module.format_forecast_date("周二10月27日", today) == "周二10月27日"
|
||||
|
||||
|
||||
def test_both_paths_write_the_same_date_and_units():
|
||||
today = _today()
|
||||
iso_date = today.isoformat()
|
||||
web_date = f"今天{today.month}月{today.day}日"
|
||||
|
||||
json_report = weather_module.build_weather_report(
|
||||
{"name": "杭州市"},
|
||||
{
|
||||
"now": {
|
||||
"text": "多云",
|
||||
"temp": "13",
|
||||
"feelsLike": "13",
|
||||
"humidity": "97",
|
||||
"windDir": "西北风",
|
||||
"windScale": "4",
|
||||
"vis": "9",
|
||||
"pressure": "1009",
|
||||
"precip": "0.0",
|
||||
}
|
||||
},
|
||||
{
|
||||
"daily": [
|
||||
{
|
||||
"fxDate": iso_date,
|
||||
"textDay": "中雨",
|
||||
"iconDay": "306",
|
||||
"tempMax": "13",
|
||||
"tempMin": "9",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
parsed = weather_module.parse_weather_info(
|
||||
BeautifulSoup(_web_page(web_date), "html.parser")
|
||||
)
|
||||
web_report = weather_module.format_weather_report(*parsed)
|
||||
|
||||
for report in (json_report, web_report):
|
||||
assert f"{web_date}" in report
|
||||
assert "气温 9~13℃" in report
|
||||
assert "能见度: 9公里" in report
|
||||
assert "气压: 1009百帕" in report
|
||||
assert "降水量: 0.0毫米" in report
|
||||
assert "湿度: 97%" in report
|
||||
assert "体感温度: 13℃" in report
|
||||
assert "风力: 4级" in report
|
||||
|
||||
|
||||
def test_web_page_units_are_written_in_chinese():
|
||||
parsed = weather_module.parse_weather_info(
|
||||
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
|
||||
)
|
||||
|
||||
_, _, current_basic, _, _ = parsed
|
||||
assert current_basic["能见度"] == "9公里"
|
||||
assert current_basic["气压"] == "1009百帕"
|
||||
assert current_basic["降水量"] == "0.0毫米"
|
||||
assert current_basic["湿度"] == "97%"
|
||||
assert current_basic["体感温度"] == "13℃"
|
||||
assert current_basic["风向"] == "西北风"
|
||||
assert current_basic["风力"] == "4级"
|
||||
|
||||
|
||||
def test_web_page_temperature_uses_the_same_unit_as_the_api_path():
|
||||
parsed = weather_module.parse_weather_info(
|
||||
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
|
||||
)
|
||||
|
||||
_, _, _, temps_list, _ = parsed
|
||||
assert temps_list[0][2] == "13"
|
||||
assert temps_list[0][3] == "9"
|
||||
|
||||
|
||||
def test_web_page_carries_the_observation_time():
|
||||
"""网页这条路也得有观测时间,两条路径的报告才对得齐"""
|
||||
parsed = weather_module.parse_weather_info(
|
||||
BeautifulSoup(
|
||||
_web_page("今天10月24日", current_time="10月24日 09:52"), "html.parser"
|
||||
)
|
||||
)
|
||||
|
||||
assert parsed[4] == "10月24日 09:52"
|
||||
assert "观测时间:10月24日 09:52" in weather_module.format_weather_report(*parsed)
|
||||
|
||||
|
||||
def test_web_page_without_a_time_element_is_not_an_error():
|
||||
"""页面结构变了取不到时间,就当作没有,不能抛异常"""
|
||||
parsed = weather_module.parse_weather_info(
|
||||
BeautifulSoup(_web_page("今天10月24日"), "html.parser")
|
||||
)
|
||||
|
||||
assert parsed[4] is None
|
||||
assert "观测时间" not in weather_module.format_weather_report(*parsed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 等待时间
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_web_fallback_does_not_wait_long(fake_http):
|
||||
"""网页只是兜底,等太久不值得"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(text="<html></html>")
|
||||
|
||||
await weather_module.fetch_weather_page("https://example.com/x.html")
|
||||
|
||||
assert fake_http.init_kwargs["timeout"].read == 5.0
|
||||
|
||||
|
||||
async def test_failure_is_remembered_so_the_next_ask_does_not_wait(monkeypatch):
|
||||
"""上游挂着的时候别每次都把整条链路重走一遍"""
|
||||
calls = []
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
conn = FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}})
|
||||
|
||||
first = await weather_module.get_weather(conn, location="失败标记市")
|
||||
second = await weather_module.get_weather(conn, location="失败标记市")
|
||||
|
||||
assert calls == [1]
|
||||
assert first.result == second.result
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for the weather and newsnow plugin helpers.
|
||||
|
||||
The two plugins talk to external APIs, so the HTTP layer is replaced with a
|
||||
fake client and every request is recorded. Nothing here touches the network.
|
||||
"""
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from plugins_func.functions import get_news_from_newsnow as news_module
|
||||
from plugins_func.functions import get_weather as weather_module
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code=200, payload=None, text="", json_error=None):
|
||||
self.status_code = status_code
|
||||
self.payload = payload
|
||||
self.text = text
|
||||
self.json_error = json_error
|
||||
|
||||
def json(self):
|
||||
if self.json_error is not None:
|
||||
raise self.json_error
|
||||
return self.payload
|
||||
|
||||
|
||||
class FakeAsyncClient:
|
||||
"""Stands in for httpx.AsyncClient and records what was requested."""
|
||||
|
||||
requests = []
|
||||
responder = None
|
||||
init_kwargs = {}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
FakeAsyncClient.init_kwargs = kwargs
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
async def get(self, url, params=None, headers=None):
|
||||
FakeAsyncClient.requests.append({"url": url, "params": params})
|
||||
return FakeAsyncClient.responder(url, params)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_http(monkeypatch):
|
||||
"""Replace httpx.AsyncClient with the recording fake."""
|
||||
FakeAsyncClient.requests = []
|
||||
FakeAsyncClient.responder = lambda url, params: FakeResponse(payload={"code": "200"})
|
||||
FakeAsyncClient.init_kwargs = {}
|
||||
monkeypatch.setattr(httpx, "AsyncClient", FakeAsyncClient)
|
||||
return FakeAsyncClient
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self, plugins=None):
|
||||
self.config = {"plugins": plugins or {}}
|
||||
self.client_ip = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 城市查询
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_error_code(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload={"code": "402"})
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_non_json_body(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
text="<html>oops</html>", json_error=ValueError("not json")
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_non_dict_body(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload=["not", "an", "object"])
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_returns_none_on_empty_location(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"code": "200", "location": []}
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
async def test_city_lookup_searches_all_cities(fake_http):
|
||||
"""不带 range 参数就是全球检索,写死 cn 会查不到境外城市。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"code": "200", "location": [{"id": "101210101", "name": "杭州"}]}
|
||||
)
|
||||
|
||||
result = await weather_module.fetch_city_info("杭州", "key", "host")
|
||||
|
||||
assert result == {"id": "101210101", "name": "杭州"}
|
||||
sent = fake_http.requests[0]["params"]
|
||||
assert "range" not in sent
|
||||
assert sent["location"] == "杭州"
|
||||
assert sent["number"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 天气数据
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _weather_responder(now_payload, daily_payload):
|
||||
def responder(url, params):
|
||||
if url.endswith("/now"):
|
||||
return now_payload if isinstance(now_payload, FakeResponse) else FakeResponse(
|
||||
payload=now_payload
|
||||
)
|
||||
return daily_payload if isinstance(daily_payload, FakeResponse) else FakeResponse(
|
||||
payload=daily_payload
|
||||
)
|
||||
|
||||
return responder
|
||||
|
||||
|
||||
async def test_weather_data_needs_both_now_and_daily(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
{"code": "200", "now": {"text": "晴"}},
|
||||
FakeResponse(status_code=401, payload={"code": "401"}),
|
||||
)
|
||||
|
||||
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
|
||||
|
||||
|
||||
async def test_weather_data_needs_daily_when_now_failed(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
FakeResponse(status_code=401, payload={"code": "401"}),
|
||||
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
|
||||
)
|
||||
|
||||
assert await weather_module.fetch_weather_data({"id": "1"}, "key", "host") is None
|
||||
|
||||
|
||||
async def test_weather_data_returns_both_when_all_succeed(fake_http):
|
||||
fake_http.responder = _weather_responder(
|
||||
{"code": "200", "now": {"text": "晴"}},
|
||||
{"code": "200", "daily": [{"fxDate": "2026-10-24"}]},
|
||||
)
|
||||
|
||||
now, daily = await weather_module.fetch_weather_data({"id": "1"}, "key", "host")
|
||||
|
||||
assert now["now"]["text"] == "晴"
|
||||
assert daily["daily"][0]["fxDate"] == "2026-10-24"
|
||||
|
||||
|
||||
async def test_weather_page_follows_redirects(fake_http):
|
||||
"""旧形态 fxLink 会 302,不跟随重定向就拿不到内容。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(text="<html></html>")
|
||||
|
||||
await weather_module.fetch_weather_page("https://www.qweather.com/weather/101210101.html")
|
||||
|
||||
assert fake_http.init_kwargs.get("follow_redirects") is True
|
||||
|
||||
|
||||
def test_weather_report_keeps_pressure_and_precipitation():
|
||||
report = weather_module.build_weather_report(
|
||||
{"name": "杭州"},
|
||||
{"now": {"text": "多云", "temp": "24", "pressure": "1003", "precip": "0.0"}},
|
||||
{"daily": []},
|
||||
)
|
||||
|
||||
assert "气压: 1003百帕" in report
|
||||
assert "降水量: 0.0毫米" in report
|
||||
|
||||
|
||||
async def test_weather_report_keeps_pressure_and_precipitation_in_flow(monkeypatch):
|
||||
"""走完整条链路时报告里也要有气压和降水量。"""
|
||||
monkeypatch.setattr(
|
||||
weather_module,
|
||||
"fetch_city_info",
|
||||
_async_value({"id": "1", "name": "杭州"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
weather_module,
|
||||
"fetch_weather_data",
|
||||
_async_value(
|
||||
({"now": {"text": "多云", "pressure": "1003", "precip": "0.0"}}, {"daily": []})
|
||||
),
|
||||
)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="气压测试市"
|
||||
)
|
||||
|
||||
assert "气压: 1003百帕" in response.result
|
||||
assert "降水量: 0.0毫米" in response.result
|
||||
|
||||
|
||||
async def test_missing_fxlink_does_not_call_web_fallback(monkeypatch):
|
||||
"""城市信息里没有 fxLink 时不该去抓网页,直接报失败。"""
|
||||
called = []
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
return {"id": "1", "name": "杭州"}
|
||||
|
||||
async def fake_weather_data(*args, **kwargs):
|
||||
return None
|
||||
|
||||
async def fake_page(url):
|
||||
called.append(url)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_data", fake_weather_data)
|
||||
monkeypatch.setattr(weather_module, "fetch_weather_page", fake_page)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="无链接市"
|
||||
)
|
||||
|
||||
assert called == []
|
||||
assert response.result == "暂时查不到天气数据,请稍后再试"
|
||||
assert response.response is None
|
||||
|
||||
|
||||
async def test_city_not_found_message_does_not_blame_the_place_name(monkeypatch):
|
||||
"""key 失效和城市找不到都落到这句话,不能让用户去核对地名。"""
|
||||
|
||||
async def fake_city_info(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(weather_module, "fetch_city_info", fake_city_info)
|
||||
|
||||
response = await weather_module.get_weather(
|
||||
FakeConn({"get_weather": {"api_key": "k", "api_host": "h"}}), location="随便什么市"
|
||||
)
|
||||
|
||||
assert "请确认地点是否正确" not in response.result
|
||||
assert "天气服务不可用" in response.result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 新闻接口
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configured_url, expected_url, expected_params",
|
||||
[
|
||||
(None, "https://newsnow.busiyi.world/api/s", {"id": "thepaper"}),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id={source}",
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper",
|
||||
None,
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper"},
|
||||
),
|
||||
(
|
||||
"https://newsnow.busiyi.world/api/s?id=thepaper&limit=5",
|
||||
"https://newsnow.busiyi.world/api/s",
|
||||
{"id": "thepaper", "limit": "5"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_news_api_url_forms(configured_url, expected_url, expected_params):
|
||||
url, params = news_module._build_news_api_url("thepaper", configured_url)
|
||||
|
||||
assert url == expected_url
|
||||
assert params == expected_params
|
||||
|
||||
|
||||
async def test_news_api_url_forms_are_actually_requested(fake_http):
|
||||
"""配置里写了 id 也不能拼出脏串。"""
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1", "title": "标题"}]}
|
||||
)
|
||||
conn = FakeConn({"get_news_from_newsnow": {"url": "https://newsnow.busiyi.world/api/s?id=thepaper"}})
|
||||
|
||||
await news_module.fetch_news_from_api(conn, "thepaper")
|
||||
|
||||
assert fake_http.requests[0]["url"] == "https://newsnow.busiyi.world/api/s"
|
||||
assert fake_http.requests[0]["params"] == {"id": "thepaper"}
|
||||
|
||||
|
||||
async def test_news_items_must_be_a_list(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(payload={"items": "oops"})
|
||||
|
||||
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
|
||||
|
||||
|
||||
async def test_news_items_without_title_are_filtered_out(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1"}, {"id": "2", "title": ""}]}
|
||||
)
|
||||
|
||||
assert await news_module.fetch_news_from_api(FakeConn(), "thepaper") == []
|
||||
|
||||
|
||||
async def test_news_items_keep_valid_entries(fake_http):
|
||||
fake_http.responder = lambda url, params: FakeResponse(
|
||||
payload={"items": [{"id": "1", "title": ""}, {"id": "2", "title": "有效标题"}]}
|
||||
)
|
||||
|
||||
items = await news_module.fetch_news_from_api(FakeConn(), "thepaper")
|
||||
|
||||
assert items == [{"id": "2", "title": "有效标题"}]
|
||||
|
||||
|
||||
def _async_value(value):
|
||||
async def _inner(*args, **kwargs):
|
||||
return value
|
||||
|
||||
return _inner
|
||||
Reference in new issue
Block a user