Compare commits

..
Author SHA1 Message Date
hrz aee4a3cf7a 文档改成只讲单模块这侧的凭据来源
上一版把两个来源混成一句,说智控台那边的默认值也是空的,那要等数据库变更脚本跑完才成立,容易被当成现状。这版只讲单模块部署:凭据从服务器配置的 plugins.get_weather 读,仓库模板是空的;历史脚本里那把旧 key 已失效,不要用也不要照着填。
2026-10-24 06:06:28 +00:00
hrz 824d2b4837 文档讲清历史脚本里那把失效的旧 key
仓库历史的数据库变更脚本里还留着一把已经失效的旧 key,不能用也不该照着填,但那些脚本已经被现网库执行过,改内容会让存量库启动报错,所以原样留着。文档里把这件事说清楚,也说明当前读凭据的两个地方都是空的,那把旧 key 不在代码路径上。
2026-10-24 05:50:23 +00:00
hrz 4140981a8a 凭据提示原样念出来,日期按北京时间算
评审提的几条一起改:观测时间和数据更新时间分开标注,网页那条路也把观测时间解析出来;「今天」按北京时间算,不跟服务器时区走;失败路径不再传那个读不到的参数。

凭据缺失时改成直接回复,服务端日志也留一条带文档路径的告警,开发者看日志能定位。
2026-10-24 01:19:06 +00:00
hrz 65e18712e6 天气插件不再内置和风天气凭据
仓库里那个和风天气的密钥不再预置,配置文件和代码里的默认值都清空了,没配凭据时直接提示用户自己去申请。

顺带修了几处:网页结构变了取不到内容时给中性提示,不再把Python的报错原文念给用户;接口和网页两条路的日期、单位统一成一种写法;报告加上观测时间;网页兜底只等5秒,失败结果记5分钟,不用每次都等二十几秒。

文档补了申请流程和要勾的三项权限。
2026-10-24 00:30:22 +00:00
hrz 63f3860f29 修掉天气新闻插件的三处鲁棒性缺陷
城市查询不再写死国内范围,境外城市也能查到。实时天气和七天预报要都拿到才走 JSON,缺一个就整体退回网页路径,不产出残缺报告。网页抓取加上跟随跳转,旧链接的 302 也能拿到内容。城市查不到时不再让用户去核对地名,多数情况是密钥失效。报告补上气压和降水量。新闻 URL 拼接抽成纯函数,配了 23 项单测。
2026-10-23 23:32:30 +08:00
Jacob 34cba2f6a5 fix: harden weather and news plugins 2026-10-23 15:35:19 +08:00
63 changed files with 1468 additions and 2225 deletions

No files matched your search

+16
View File
@@ -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")
@@ -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")
@@ -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获取激活码
*
@@ -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 + "的设备";
}
@@ -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();
@@ -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");
@@ -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);
}
@@ -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);
/**
@@ -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());
}
@@ -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));
}
@@ -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());
}
}
@@ -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;
}
}
@@ -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);
}
}
}
@@ -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'),
+22 -119
View File
@@ -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,
-7
View File
@@ -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();
},
);
});
+6 -9
View File
@@ -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/
+1 -1
View File
@@ -6,7 +6,7 @@ from config.config_loader import load_config
from config.settings import check_config_file
from core.utils.cache.manager import cache_manager, CacheType
SERVER_VERSION = "0.9.8"
SERVER_VERSION = "0.9.7"
_logger_initialized = False
+6 -7
View File
@@ -42,7 +42,7 @@ from core.providers.tts.dto.dto import ContentType, TTSMessageDTO, SentenceType
from config.logger import setup_logging, build_module_string, create_connection_logger
from config.manage_api_client import DeviceNotFoundException, DeviceBindException, generate_and_save_chat_title
from core.utils.prompt_manager import PromptManager
from core.utils.voiceprint_provider import VoiceprintProvider, 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):
+5 -12
View File
@@ -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:
+1 -3
View File
@@ -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}"
-15
View File
@@ -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)
+15 -17
View File
@@ -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