diff --git a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java index 917f1e50..c3f498d4 100644 --- a/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java +++ b/main/manager-api/src/main/java/xiaozhi/common/constant/Constant.java @@ -141,6 +141,11 @@ public interface Constant { */ String SERVER_MQTT_SECRET = "server.mqtt_signature_key"; + /** + * WebSocket认证开关 + */ + String SERVER_AUTH_ENABLED = "server.auth.enabled"; + /** * 无记忆 */ diff --git a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java index 5c3f854d..44977730 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/device/service/impl/DeviceServiceImpl.java @@ -1,6 +1,8 @@ package xiaozhi.modules.device.service.impl; import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Base64; import java.util.Date; @@ -169,7 +171,22 @@ public class DeviceServiceImpl extends BaseServiceImpl DeviceReportRespDTO.Websocket websocket = new DeviceReportRespDTO.Websocket(); // 从系统参数获取WebSocket URL,如果未配置则使用默认值 String wsUrl = sysParamsService.getValue(Constant.SERVER_WEBSOCKET, true); - websocket.setToken(""); + + // 检查是否启用认证并生成token + String authEnabled = sysParamsService.getValue(Constant.SERVER_AUTH_ENABLED, true); + if ("true".equalsIgnoreCase(authEnabled)) { + try { + // 生成token + String token = generateWebSocketToken(clientId, macAddress); + websocket.setToken(token); + } catch (Exception e) { + log.error("生成WebSocket token失败: {}", e.getMessage()); + websocket.setToken(""); + } + } else { + websocket.setToken(""); + } + if (StringUtils.isBlank(wsUrl) || wsUrl.equals("null")) { log.error("WebSocket地址未配置,请登录智控台,在参数管理找到【server.websocket】配置"); wsUrl = "ws://xiaozhi.server.com:8000/xiaozhi/v1/"; @@ -189,7 +206,7 @@ public class DeviceServiceImpl extends BaseServiceImpl // 添加MQTT UDP配置 // 从系统参数获取MQTT Gateway地址,仅在配置有效时使用 - String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, false); + String mqttUdpConfig = sysParamsService.getValue(Constant.SERVER_MQTT_GATEWAY, true); if (mqttUdpConfig != null && !mqttUdpConfig.equals("null") && !mqttUdpConfig.isEmpty()) { try { String groupId = deviceById != null && deviceById.getBoard() != null ? deviceById.getBoard() @@ -494,6 +511,40 @@ public class DeviceServiceImpl extends BaseServiceImpl return Base64.getEncoder().encodeToString(signature); } + /** + * 生成WebSocket认证token 遵循Python端AuthManager的实现逻辑:token = signature.timestamp + * + * @param clientId 客户端ID + * @param username 用户名 (通常为deviceId/macAddress) + * @return 认证token字符串 + */ + private String generateWebSocketToken(String clientId, String username) + throws NoSuchAlgorithmException, InvalidKeyException { + // 从系统参数获取密钥 + String secretKey = sysParamsService.getValue(Constant.SERVER_SECRET, false); + if (StringUtils.isBlank(secretKey)) { + throw new IllegalStateException("WebSocket认证密钥未配置(server.secret)"); + } + + // 获取当前时间戳(秒) + long timestamp = System.currentTimeMillis() / 1000; + + // 构建签名内容: clientId|username|timestamp + String content = String.format("%s|%s|%d", clientId, username, timestamp); + + // 生成HMAC-SHA256签名 + Mac hmac = Mac.getInstance("HmacSHA256"); + SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + hmac.init(keySpec); + byte[] signature = hmac.doFinal(content.getBytes(StandardCharsets.UTF_8)); + + // Base64 URL-safe编码签名(去除填充符=) + String signatureBase64 = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); + + // 返回格式: signature.timestamp + return String.format("%s.%d", signatureBase64, timestamp); + } + /** * 构建MQTT配置信息 * @@ -504,7 +555,7 @@ public class DeviceServiceImpl extends BaseServiceImpl private DeviceReportRespDTO.MQTT buildMqttConfig(String macAddress, String groupId) throws Exception { // 从环境变量或系统参数获取签名密钥 - String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", false); + String signatureKey = sysParamsService.getValue("server.mqtt_signature_key", true); if (StringUtils.isBlank(signatureKey)) { log.warn("缺少MQTT_SIGNATURE_KEY,跳过MQTT配置生成"); return null; diff --git a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java index 2c7bab4a..1ab9809d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/security/controller/LoginController.java @@ -6,6 +6,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.commons.lang3.StringUtils; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -23,7 +24,9 @@ import xiaozhi.common.exception.ErrorCode; import xiaozhi.common.exception.RenException; import xiaozhi.common.page.TokenDTO; import xiaozhi.common.user.UserDetail; +import xiaozhi.common.utils.JsonUtils; import xiaozhi.common.utils.Result; +import xiaozhi.common.utils.Sm2DecryptUtil; import xiaozhi.common.validator.AssertUtils; import xiaozhi.common.validator.ValidatorUtils; import xiaozhi.modules.security.dto.LoginDTO; @@ -32,8 +35,6 @@ import xiaozhi.modules.security.password.PasswordUtils; import xiaozhi.modules.security.service.CaptchaService; import xiaozhi.modules.security.service.SysUserTokenService; import xiaozhi.modules.security.user.SecurityUser; -import xiaozhi.common.utils.Sm2DecryptUtil; -import org.apache.commons.lang3.StringUtils; import xiaozhi.modules.sys.dto.PasswordDTO; import xiaozhi.modules.sys.dto.RetrievePasswordDTO; import xiaozhi.modules.sys.dto.SysUserDTO; @@ -41,7 +42,6 @@ import xiaozhi.modules.sys.service.SysDictDataService; import xiaozhi.modules.sys.service.SysParamsService; import xiaozhi.modules.sys.service.SysUserService; import xiaozhi.modules.sys.vo.SysDictDataItem; -import xiaozhi.common.utils.JsonUtils; /** * 登录控制层 @@ -237,7 +237,7 @@ public class LoginController { config.put("sm2PublicKey", publicKey); // 获取system-web.menu参数配置 - String menuConfig = sysParamsService.getValue("system-web.menu", false); + String menuConfig = sysParamsService.getValue("system-web.menu", true); if (StringUtils.isNotBlank(menuConfig)) { config.put("systemWebMenu", JsonUtils.parseObject(menuConfig, Object.class)); } diff --git a/main/manager-api/src/main/resources/db/changelog/202512131453.sql b/main/manager-api/src/main/resources/db/changelog/202512131453.sql new file mode 100644 index 00000000..27ea4a1b --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202512131453.sql @@ -0,0 +1,6 @@ +-- 删除server模块是否开启token认证参数 +delete from `sys_params` where param_code = 'server.auth.enabled'; + +-- 添加server模块是否开启token认证参数 +INSERT INTO `sys_params` (id, param_code, param_value, value_type, param_type, remark) VALUES +(122, 'server.auth.enabled', 'true', 'boolean', 1, 'server模块是否开启token认证'); \ No newline at end of file diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index b0cdbc63..dd6dff55 100755 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -438,3 +438,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202512041515.sql + - changeSet: + id: 202512131453 + author: hrz + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202512131453.sql diff --git a/main/manager-web/src/i18n/de.js b/main/manager-web/src/i18n/de.js index 4a9ab40c..1d8704be 100644 --- a/main/manager-web/src/i18n/de.js +++ b/main/manager-web/src/i18n/de.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Löschen fehlgeschlagen, bitte versuchen Sie es erneut', 'paramManagement.operationCancelled': 'Löschen abgebrochen', 'paramManagement.operationClosed': 'Operation geschlossen', - 'paramManagement.updateSuccess': 'Aktualisierung erfolgreich', + 'paramManagement.updateSuccess': 'Aktualisierung erfolgreich. Einige Konfigurationen werden erst nach Neustart des xiaozhi-server-Moduls wirksam.', 'paramManagement.addSuccess': 'Hinzufügen erfolgreich', 'paramManagement.updateFailed': 'Aktualisierung fehlgeschlagen', 'paramManagement.addFailed': 'Hinzufügen fehlgeschlagen', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Aktivieren erfolgreich', 'modelConfig.disableSuccess': 'Deaktivieren erfolgreich', 'modelConfig.operationFailed': 'Operation fehlgeschlagen', - 'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt', + 'modelConfig.setDefaultSuccess': 'Standardmodell erfolgreich gesetzt, bitte starten Sie das xiaozhi-server-Modul zeitnah manuell neu', 'modelConfig.itemsPerPage': '{items} Einträge/Seite', 'modelConfig.firstPage': 'Erste Seite', 'modelConfig.prevPage': 'Vorherige Seite', diff --git a/main/manager-web/src/i18n/en.js b/main/manager-web/src/i18n/en.js index 298d1e17..f57f8ab3 100644 --- a/main/manager-web/src/i18n/en.js +++ b/main/manager-web/src/i18n/en.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Deletion failed, please try again', 'paramManagement.operationCancelled': 'Deletion cancelled', 'paramManagement.operationClosed': 'Operation closed', - 'paramManagement.updateSuccess': 'Update successful', + 'paramManagement.updateSuccess': 'Update successful. Some configurations will take effect only after restarting the xiaozhi-server module.', 'paramManagement.addSuccess': 'Add successful', 'paramManagement.updateFailed': 'Update failed', 'paramManagement.addFailed': 'Add failed', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Enable successful', 'modelConfig.disableSuccess': 'Disable successful', 'modelConfig.operationFailed': 'Operation failed', - 'modelConfig.setDefaultSuccess': 'Set default model successful', + 'modelConfig.setDefaultSuccess': 'Set default model successful, please restart the xiaozhi-server module manually in time', 'modelConfig.itemsPerPage': '{items} items/page', 'modelConfig.firstPage': 'First Page', 'modelConfig.prevPage': 'Previous Page', diff --git a/main/manager-web/src/i18n/vi.js b/main/manager-web/src/i18n/vi.js index 9ca108ef..a96ccfaa 100644 --- a/main/manager-web/src/i18n/vi.js +++ b/main/manager-web/src/i18n/vi.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': 'Xóa thất bại, vui lòng thử lại', 'paramManagement.operationCancelled': 'Đã hủy xóa', 'paramManagement.operationClosed': 'Đã đóng thao tác', - 'paramManagement.updateSuccess': 'Cập nhật thành công', + 'paramManagement.updateSuccess': 'Cập nhật thành công. Một số cấu hình chỉ có hiệu lực sau khi khởi động lại mô-đun xiaozhi-server.', 'paramManagement.addSuccess': 'Thêm thành công', 'paramManagement.updateFailed': 'Cập nhật thất bại', 'paramManagement.addFailed': 'Thêm thất bại', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': 'Bật thành công', 'modelConfig.disableSuccess': 'Tắt thành công', 'modelConfig.operationFailed': 'Thao tác thất bại', - 'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công', + 'modelConfig.setDefaultSuccess': 'Đặt mô hình mặc định thành công, vui lòng khởi động lại module xiaozhi-server thủ công kịp thời', 'modelConfig.itemsPerPage': '{items} mục/trang', 'modelConfig.firstPage': 'Trang đầu', 'modelConfig.prevPage': 'Trang trước', diff --git a/main/manager-web/src/i18n/zh_CN.js b/main/manager-web/src/i18n/zh_CN.js index 392b9a8b..6a3c6d80 100644 --- a/main/manager-web/src/i18n/zh_CN.js +++ b/main/manager-web/src/i18n/zh_CN.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': '删除失败,请重试', 'paramManagement.operationCancelled': '已取消删除操作', 'paramManagement.operationClosed': '操作已关闭', - 'paramManagement.updateSuccess': '修改成功', + 'paramManagement.updateSuccess': '修改成功,部分配置需重启xiaozhi-server模块才生效', 'paramManagement.addSuccess': '新增成功', 'paramManagement.updateFailed': '更新失败', 'paramManagement.addFailed': '新增失败', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': '启用成功', 'modelConfig.disableSuccess': '禁用成功', 'modelConfig.operationFailed': '操作失败', - 'modelConfig.setDefaultSuccess': '设置默认模型成功', + 'modelConfig.setDefaultSuccess': '设置默认模型成功,请及时手动重启xiaozhi-server模块', 'modelConfig.itemsPerPage': '{items}条/页', 'modelConfig.firstPage': '首页', 'modelConfig.prevPage': '上一页', diff --git a/main/manager-web/src/i18n/zh_TW.js b/main/manager-web/src/i18n/zh_TW.js index 69bacd67..ff5fdb47 100644 --- a/main/manager-web/src/i18n/zh_TW.js +++ b/main/manager-web/src/i18n/zh_TW.js @@ -713,7 +713,7 @@ export default { 'paramManagement.deleteFailed': '刪除失敗,請重試', 'paramManagement.operationCancelled': '已取消刪除操作', 'paramManagement.operationClosed': '操作已關閉', - 'paramManagement.updateSuccess': '修改成功', + 'paramManagement.updateSuccess': '修改成功,部分配置需重啟xiaozhi-server模組才生效', 'paramManagement.addSuccess': '新增成功', 'paramManagement.updateFailed': '更新失敗', 'paramManagement.addFailed': '新增失敗', @@ -852,7 +852,7 @@ export default { 'modelConfig.enableSuccess': '啟用成功', 'modelConfig.disableSuccess': '禁用成功', 'modelConfig.operationFailed': '操作失敗', - 'modelConfig.setDefaultSuccess': '設置默認模型成功', + 'modelConfig.setDefaultSuccess': '設置默認模型成功,請及時手動重啟xiaozhi-server模組', 'modelConfig.itemsPerPage': '{items}條/頁', 'modelConfig.firstPage': '首頁', 'modelConfig.prevPage': '上一頁', diff --git a/main/xiaozhi-server/config/config_loader.py b/main/xiaozhi-server/config/config_loader.py index c85510b8..e0220e33 100644 --- a/main/xiaozhi-server/config/config_loader.py +++ b/main/xiaozhi-server/config/config_loader.py @@ -68,6 +68,7 @@ async def get_config_from_api_async(config): "url": config["manager-api"].get("url", ""), "secret": config["manager-api"].get("secret", ""), } + auth_enabled = config_data.get("server", {}).get("auth", {}).get("enabled", False) # server的配置以本地为准 if config.get("server"): config_data["server"] = { @@ -77,6 +78,7 @@ async def get_config_from_api_async(config): "vision_explain": config["server"].get("vision_explain", ""), "auth_key": config["server"].get("auth_key", ""), } + config_data["server"]["auth"] = {"enabled": auth_enabled} # 如果服务器没有prompt_template,则从本地配置读取 if not config_data.get("prompt_template"): config_data["prompt_template"] = config.get("prompt_template") diff --git a/main/xiaozhi-server/core/connection.py b/main/xiaozhi-server/core/connection.py index dbb2bf7a..70489036 100644 --- a/main/xiaozhi-server/core/connection.py +++ b/main/xiaozhi-server/core/connection.py @@ -498,7 +498,7 @@ class ConnectionHandler: def _initialize_asr(self): """初始化ASR""" - if self._asr.interface_type == InterfaceType.LOCAL: + if self._asr is not None and hasattr(self._asr, "interface_type") and self._asr.interface_type == InterfaceType.LOCAL: # 如果公共ASR是本地服务,则直接返回 # 因为本地一个实例ASR,可以被多个连接共享 asr = self._asr diff --git a/main/xiaozhi-server/requirements.txt b/main/xiaozhi-server/requirements.txt index 3f62ef4c..919c0f6d 100644 --- a/main/xiaozhi-server/requirements.txt +++ b/main/xiaozhi-server/requirements.txt @@ -13,7 +13,7 @@ pydub==0.25.1 funasr==1.2.7 openai==2.8.1 google-generativeai==0.8.5 -edge_tts==7.2.3 +edge_tts==7.2.6 httpx==0.28.1 aiohttp==3.13.2 aiohttp_cors==0.8.1 diff --git a/main/xiaozhi-server/test/js/core/audio/recorder.js b/main/xiaozhi-server/test/js/core/audio/recorder.js index 9f94f895..3fe785f0 100644 --- a/main/xiaozhi-server/test/js/core/audio/recorder.js +++ b/main/xiaozhi-server/test/js/core/audio/recorder.js @@ -240,6 +240,24 @@ export class AudioRecorder { if (this.isRecording) return false; try { + // 检查是否有WebSocketHandler实例 + const { getWebSocketHandler } = await import('../network/websocket.js'); + const wsHandler = getWebSocketHandler(); + + // 如果机器正在说话,发送打断消息 + if (wsHandler && wsHandler.isRemoteSpeaking && wsHandler.currentSessionId) { + const abortMessage = { + session_id: wsHandler.currentSessionId, + type: 'abort', + reason: 'wake_word_detected' + }; + + if (this.websocket && this.websocket.readyState === WebSocket.OPEN) { + this.websocket.send(JSON.stringify(abortMessage)); + log('发送打断消息', 'info'); + } + } + if (!this.initEncoder()) { log('无法启动录音: Opus编码器初始化失败', 'error'); return false;