# RTC-AIGC 集成方案:Java 网关代理 + 用户鉴权 + 对话历史持久化 ## 1. 背景与目标 ### 现状 - **Python AI 后端**(FastAPI, port 3001):处理 RTC 语音对话、LLM 回调、RTC OpenAPI 代理 - **前端**(vanilla JS, `AigcVoiceClient`):已测试通过,即将集成到另一个项目 - **Java 后端**(Spring Boot + JWT):已有项目,拥有完整的用户鉴权体系 ### 问题 1. Python 后端无用户鉴权,使用 `IP + User-Agent` 哈希做简易 Session 2. 对话历史仅在前端内存中,刷新即丢失 3. 前端集成后需统一走 Java 后端,不能直连 Python ### 目标 1. **用户鉴权**:利用 Java 已有的 JWT 体系,统一用户身份 2. **数据存储**:持久化对话历史,支持用户回看 3. **架构整合**:Java 做 API 网关,前端只与 Java 通信 --- ## 2. 架构设计 ### 2.1 整体架构 ``` ┌──────────┐ JWT ┌───────────────┐ HMAC签名 ┌────────────────┐ │ Frontend │ ──────────────→ │ Java Backend │ ────────────→ │ Python Backend │ │ (JS) │ │ (Spring Boot) │ │ (FastAPI:3001) │ └──────────┘ └───────────────┘ └────────────────┘ │ ↑ ↓ 火山RTC平台 ┌──────────┐ 直接调用 │ MySQL/PG │ /api/chat_callback │ 对话历史 │ └──────────┘ ``` ### 2.2 关键设计决策 | 决策 | 选择 | 理由 | |------|------|------| | 网关模式 | Java 代理转发 | 前端只需维护一个后端地址,鉴权集中管理 | | 服务间鉴权 | HMAC-SHA256 签名 | 无状态,不需引入额外组件(如 Redis) | | 对话存储位置 | Java 侧数据库 | Java 已有 DB,对话历史是业务数据 | | 保存时机 | 会话结束时批量保存 | 消息通过 WebRTC 传输,实时保存开销大 | | chat_callback | 不经过 Java | 由火山 RTC 平台直接调用 Python,无法代理 | ### 2.3 数据流详解 #### 完整通话流程 ``` [1] 用户登录(已有流程) Frontend → Java: POST /api/auth/login → 获得 JWT [2] 获取场景配置 Frontend → Java: POST /api/ai/getScenes (带 JWT) Java: 验证 JWT → 提取 userId → 添加 HMAC 签名 Header Java → Python: POST /getScenes (带 X-Internal-* Headers) Python: 验证签名 → 用 userId 做 Session Key → 返回场景配置 Java → Frontend: 透传响应 [3] 启动语音对话 Frontend → Java: POST /api/ai/proxy?Action=StartVoiceChat (带 JWT) Java: 验证 JWT → 转发到 Python (带签名) Python: 从 Session 取出 RoomId/UserId → 调用火山 RTC OpenAPI → 返回结果 Java → Frontend: 透传响应 [4] 语音对话进行中 ┌─ 语音数据: Frontend ←→ 火山 RTC 服务器 (WebRTC, 不走 HTTP) ─┐ │ 字幕/状态: 火山 RTC → Frontend (RTC Binary Message, TLV) │ │ LLM 回调: 火山 RTC → Python /api/chat_callback (SSE) │ └─────── 这三条通道都不经过 Java ──────────────────────────────┘ [5] 结束通话 Frontend → Java: POST /api/ai/proxy?Action=StopVoiceChat (带 JWT) Java → Python: 转发 → 停止 AI Bot [6] 保存对话历史 Frontend → Java: POST /api/ai/conversations (带 JWT + msgHistory) Java: 验证 JWT → 存入数据库 ``` #### 哪些走 Java 网关,哪些不走 | 通道 | 是否走 Java | 说明 | |------|------------|------| | `POST /getScenes` | 走 | 需要用户身份 | | `POST /proxy?Action=StartVoiceChat` | 走 | 需要用户身份 | | `POST /proxy?Action=StopVoiceChat` | 走 | 需要用户身份 | | `POST /api/chat_callback` | **不走** | 火山 RTC 平台直接调用 Python | | 语音音频流 | **不走** | WebRTC P2P / 媒体服务器 | | 字幕/状态消息 | **不走** | RTC Binary Message (TLV) | | 对话历史保存 | 直接到 Java | Java 自己处理,不转发 Python | --- ## 3. 服务间鉴权设计(Java → Python) ### 3.1 方案:HMAC-SHA256 共享密钥签名 **原理**:Java 和 Python 共享一个密钥(`INTERNAL_SERVICE_SECRET`),Java 转发请求时用此密钥对关键信息做 HMAC 签名,Python 验证签名合法性。 ### 3.2 Header 规范 Java 转发请求时附加以下 Header: | Header | 值 | 说明 | |--------|------|------| | `X-Internal-Service` | `java-gateway` | 标识请求来源 | | `X-Internal-User-Id` | JWT 中的 userId | 当前登录用户 ID | | `X-Internal-Timestamp` | 毫秒时间戳 | 用于防重放 | | `X-Internal-Signature` | HMAC-SHA256 值 | 签名校验 | **签名算法**: ``` message = "{service}:{userId}:{timestamp}" = "java-gateway:user123:1711958400000" signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) ``` ### 3.3 Python 侧验证逻辑 ```python # 伪代码 def verify_internal_auth(request): service = header("X-Internal-Service") # "java-gateway" user_id = header("X-Internal-User-Id") # "user123" timestamp = header("X-Internal-Timestamp") # "1711958400000" signature = header("X-Internal-Signature") # HMAC hex # 1. 检查必要 Header 是否齐全 if not all([service, timestamp, signature]): return 401 # 2. 检查时间戳偏差(防重放),允许 ±5 分钟 if abs(now_ms - int(timestamp)) > 300_000: return 401 # 3. 验证 HMAC 签名 expected = hmac_sha256(SECRET, f"{service}:{user_id}:{timestamp}") if not constant_time_equal(signature, expected): return 401 # 4. 验证通过,userId 可信 return OK ``` ### 3.4 跳过验证的路径 | 路径 | 原因 | |------|------| | `/api/chat_callback` | 火山 RTC 平台调用,有自己的 Bearer Token 鉴权 | | `/debug/*` | 开发调试用,生产环境应在网络层禁止访问 | ### 3.5 本地开发兼容 当 `INTERNAL_SERVICE_SECRET` 未配置(为空)时,跳过签名验证,保持向后兼容: ``` INTERNAL_SERVICE_SECRET= # 为空 → 跳过验证(仅限开发环境!) INTERNAL_SERVICE_SECRET=your-secret-key # 有值 → 强制验证 ``` ### 3.6 Java 侧签名代码参考 ```java @Service public class AiProxyService { @Value("${ai.internal-service-secret}") private String internalSecret; @Value("${ai.python-backend-url}") private String pythonUrl; // e.g. "http://localhost:3001" /** * 转发请求到 Python 后端,附加 HMAC 签名 */ public ResponseEntity forwardToPython( String path, String queryString, String body, String userId) { String timestamp = String.valueOf(System.currentTimeMillis()); String service = "java-gateway"; String message = service + ":" + userId + ":" + timestamp; String signature = hmacSha256(internalSecret, message); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("X-Internal-Service", service); headers.set("X-Internal-User-Id", userId); headers.set("X-Internal-Timestamp", timestamp); headers.set("X-Internal-Signature", signature); String url = pythonUrl + path + (queryString != null ? "?" + queryString : ""); HttpEntity entity = new HttpEntity<>(body, headers); return restTemplate.exchange(url, HttpMethod.POST, entity, String.class); } private String hmacSha256(String secret, String message) { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] hash = mac.doFinal(message.getBytes(StandardCharsets.UTF_8)); return Hex.encodeHexString(hash); // Apache Commons Codec } } ``` --- ## 4. 用户身份传递 ### 4.1 当前实现 **文件**:`backend/services/session_store.py` ```python # 当前:用 IP + User-Agent 哈希做 session key def _key(request: Request) -> str: forwarded = request.headers.get("X-Forwarded-For") ip = (forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown")) ua = request.headers.get("User-Agent", "") return hashlib.sha256(f"{ip}:{ua}".encode()).hexdigest() ``` **问题**:同一网络下不同用户可能拥有相同 IP,UA 也可能相同,导致 Session 冲突。 ### 4.2 改造后 ```python def _key(request: Request) -> str: # 优先使用 Java 网关传递的真实用户 ID user_id = request.headers.get("X-Internal-User-Id") if user_id: return user_id # 降级:本地开发无网关时,仍用 IP+UA 哈希 forwarded = request.headers.get("X-Forwarded-For") ip = (forwarded.split(",")[0].strip() if forwarded else (request.client.host if request.client else "unknown")) ua = request.headers.get("User-Agent", "") return hashlib.sha256(f"{ip}:{ua}".encode()).hexdigest() ``` ### 4.3 身份关联:chat_callback 场景 `/api/chat_callback` 由火山 RTC 平台调用,不经过 Java,不携带用户身份。但可通过 RoomId 关联用户: ``` getScenes 阶段: Python 生成 RoomId → 存储 { RoomId: userId } 映射 chat_callback 阶段: 火山平台请求中包含 RoomId/TaskId 上下文 Python 可通过 RoomId 反查 userId(如需要的话) ``` **当前阶段不需要实现**:因为对话历史由前端在 stop 时批量保存到 Java,不需要 Python 侧知道 userId。 --- ## 5. 对话历史持久化 ### 5.1 存储方案 **存储在 Java 侧**,理由: - Java 已有数据库和 ORM(Spring Data JPA) - 对话历史是业务数据,由 Java JWT 保护访问权限 - Python 保持无状态,只做 AI 逻辑 ### 5.2 数据库表设计 #### conversation_session(对话会话表) ```sql CREATE TABLE conversation_session ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id VARCHAR(64) NOT NULL COMMENT '用户ID(来自JWT)', scene_id VARCHAR(64) NOT NULL COMMENT '场景ID', room_id VARCHAR(128) NOT NULL COMMENT 'RTC房间ID', started_at DATETIME NOT NULL COMMENT '会话开始时间', ended_at DATETIME COMMENT '会话结束时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_id (user_id), INDEX idx_created_at (created_at) ) COMMENT='AI语音对话会话'; ``` #### conversation_message(对话消息表) ```sql CREATE TABLE conversation_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, session_id BIGINT NOT NULL COMMENT '关联会话ID', sender_type VARCHAR(16) NOT NULL COMMENT 'USER / AI', content TEXT NOT NULL COMMENT '消息文本', is_definite TINYINT(1) DEFAULT 1 COMMENT '是否最终确认文本', sequence_num INT NOT NULL COMMENT '消息顺序号', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_session_id (session_id), FOREIGN KEY (session_id) REFERENCES conversation_session(id) ON DELETE CASCADE ) COMMENT='AI语音对话消息'; ``` ### 5.3 保存时机与方式 **方式**:前端 `client.stop()` 时,将内存中的 `msgHistory` 数组一次性 POST 到 Java。 **理由**: - 对话消息通过 WebRTC Binary Message 传到前端,不走 HTTP - 逐条保存需要每收到一条字幕就发一个 HTTP 请求,开销大 - 批量保存简单可靠 **意外关闭兜底**:使用 `navigator.sendBeacon` 在 `beforeunload` 事件中尝试保存。 ### 5.4 前端保存逻辑 ```javascript // 在 AigcVoiceClient 的 stop() 方法中,离房前保存 async stop() { if (!this.isJoined && !this.audioBotEnabled) return; // 保存对话历史 if (this.msgHistory.length > 0) { await this._saveConversation(); } // ... 原有的停止逻辑 ... } async _saveConversation() { try { await fetch(`${this.serverUrl}/api/ai/conversations`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.authToken}`, }, body: JSON.stringify({ sceneId: this.sceneId, roomId: this.roomId, messages: this.msgHistory, }), }); } catch (e) { console.warn('[AigcVoiceClient] 保存对话历史失败:', e); } } ``` **beforeunload 兜底**: ```javascript // 在 start() 方法中注册 this._beforeUnloadHandler = () => { if (this.msgHistory.length > 0) { navigator.sendBeacon( `${this.serverUrl}/api/ai/conversations`, new Blob([JSON.stringify({ sceneId: this.sceneId, roomId: this.roomId, messages: this.msgHistory, authToken: this.authToken, // sendBeacon 不支持自定义 Header })], { type: 'application/json' }) ); } }; window.addEventListener('beforeunload', this._beforeUnloadHandler); ``` > **注意**:`sendBeacon` 不支持自定义 Header。Java 侧需要额外支持从 body 中读取 authToken 进行鉴权,或者设计一个不需要 Authorization Header 的保存端点(如通过 URL 参数传递一次性 token)。 ### 5.5 Java 端 API 设计 #### 保存对话 — `POST /api/ai/conversations` **请求体**: ```json { "sceneId": "Custom", "roomId": "room_abc123", "messages": [ { "text": "你好,帮我查一下今天的考勤", "userId": "user123", "definite": true, "paragraph": true, "time": "2026-04-02T10:30:00.000Z" }, { "text": "好的,我来帮你查询今天的考勤记录。", "userId": "bot_xiaokuai", "definite": true, "paragraph": true, "time": "2026-04-02T10:30:02.000Z" } ] } ``` **响应**: ```json { "code": 200, "data": { "sessionId": 42 } } ``` **处理逻辑**: 1. 从 JWT 中提取 userId 2. 创建 `conversation_session` 记录 3. 遍历 messages,判断 `userId` 是否等于当前用户来区分 USER/AI 4. 批量插入 `conversation_message` #### 查询对话列表 — `GET /api/ai/conversations` **请求参数**: | 参数 | 类型 | 说明 | |------|------|------| | page | int | 页码,默认 1 | | size | int | 每页条数,默认 20 | **响应**: ```json { "code": 200, "data": { "total": 15, "list": [ { "id": 42, "sceneId": "Custom", "roomId": "room_abc123", "startedAt": "2026-04-02T10:30:00", "endedAt": "2026-04-02T10:35:00", "messageCount": 12, "firstMessage": "你好,帮我查一下今天的考勤" } ] } } ``` #### 查询对话详情 — `GET /api/ai/conversations/{id}` **响应**: ```json { "code": 200, "data": { "id": 42, "sceneId": "Custom", "roomId": "room_abc123", "startedAt": "2026-04-02T10:30:00", "endedAt": "2026-04-02T10:35:00", "messages": [ { "senderType": "USER", "content": "你好,帮我查一下今天的考勤", "sequenceNum": 1, "createdAt": "2026-04-02T10:30:00" }, { "senderType": "AI", "content": "好的,我来帮你查询今天的考勤记录。", "sequenceNum": 2, "createdAt": "2026-04-02T10:30:02" } ] } } ``` #### 删除对话 — `DELETE /api/ai/conversations/{id}` **响应**: ```json { "code": 200, "data": null } ``` > 需验证该会话属于当前 JWT 用户,防止越权删除。 --- ## 6. 各组件具体改动清单 ### 6.1 Python 后端改动 #### 6.1.1 新建 `backend/middleware/__init__.py` 空文件。 #### 6.1.2 新建 `backend/middleware/internal_auth.py` HMAC 签名验证中间件: - 读取 `INTERNAL_SERVICE_SECRET` 环境变量 - 对非豁免路径验证 `X-Internal-Signature` - 豁免路径:`/api/chat_callback`、`/debug/*` - 密钥为空时跳过验证(本地开发兼容) #### 6.1.3 修改 `backend/services/session_store.py` 修改 `_key()` 函数:优先使用 `X-Internal-User-Id` Header,降级使用 IP+UA 哈希。 #### 6.1.4 修改 `backend/server.py` 注册 `InternalAuthMiddleware`: ```python from middleware.internal_auth import InternalAuthMiddleware app = FastAPI() app.add_middleware(InternalAuthMiddleware) app.add_middleware(CORSMiddleware, ...) # 已有 ``` #### 6.1.5 修改 `backend/.env.example` 添加: ```env # ============ 服务间鉴权 ============ # Java 网关与 Python 之间的共享密钥(生产环境必须设置!) INTERNAL_SERVICE_SECRET= ``` ### 6.2 Java 后端新增 #### 6.2.1 配置 `application.yml` ```yaml ai: python-backend-url: http://localhost:3001 internal-service-secret: your-shared-secret-key ``` #### 6.2.2 新建 `AiProxyController` ```java @RestController @RequestMapping("/api/ai") public class AiProxyController { @Autowired private AiProxyService aiProxyService; /** * 代理 getScenes 请求到 Python */ @PostMapping("/getScenes") public ResponseEntity getScenes( @RequestBody(required = false) String body, @AuthenticationPrincipal UserDetails user) { return aiProxyService.forwardToPython( "/getScenes", "Action=getScenes", body, user.getUsername()); } /** * 代理 StartVoiceChat / StopVoiceChat 请求到 Python */ @PostMapping("/proxy") public ResponseEntity proxy( @RequestParam String Action, @RequestBody String body, @AuthenticationPrincipal UserDetails user) { return aiProxyService.forwardToPython( "/proxy", "Action=" + Action, body, user.getUsername()); } } ``` #### 6.2.3 新建 `AiProxyService` 负责构造 HMAC 签名 Header,使用 `RestTemplate` 转发请求到 Python。 (详细代码见第 3.6 节) #### 6.2.4 新建 `ConversationController` ```java @RestController @RequestMapping("/api/ai/conversations") public class ConversationController { @PostMapping public Result save(@RequestBody SaveConversationDTO dto, @AuthenticationPrincipal UserDetails user) { ... } @GetMapping public Result list(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @AuthenticationPrincipal UserDetails user) { ... } @GetMapping("/{id}") public Result detail(@PathVariable Long id, @AuthenticationPrincipal UserDetails user) { ... } @DeleteMapping("/{id}") public Result delete(@PathVariable Long id, @AuthenticationPrincipal UserDetails user) { ... } } ``` #### 6.2.5 新建 JPA 实体 - `ConversationSession` — 对应 `conversation_session` 表 - `ConversationMessage` — 对应 `conversation_message` 表 #### 6.2.6 新建 Repository - `ConversationSessionRepository extends JpaRepository` - `ConversationMessageRepository extends JpaRepository` #### 6.2.7 新建 DTO - `SaveConversationDTO` — 保存对话请求体 - `ConversationListVO` — 列表响应 - `ConversationDetailVO` — 详情响应 ### 6.3 前端改动 #### 6.3.1 修改 `simple-frontend/aigc-voice-client.js` **构造函数**新增 `authToken` 参数: ```javascript constructor(options = {}) { this.serverUrl = options.serverUrl || 'http://localhost:3001'; this.authToken = options.authToken || null; // 新增:JWT Token // ... } ``` **`_post()` 方法**添加 Authorization Header: ```javascript async _post(path, action, body = {}) { const url = `${this.serverUrl}${path}?Action=${action}`; const headers = { 'Content-Type': 'application/json' }; if (this.authToken) { headers['Authorization'] = `Bearer ${this.authToken}`; } const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), }); // ... } ``` **更新端点路径**(适配 Java 网关): ```javascript async _getScenes() { return this._post('/api/ai/getScenes', 'getScenes'); // 原: /getScenes } async _startVoiceChat(sceneId) { return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: sceneId }); // 原: /proxy } async _stopVoiceChat(sceneId) { return this._post('/api/ai/proxy', 'StopVoiceChat', { SceneID: sceneId }); // 原: /proxy } ``` > **注意**:路径是否需要改取决于 Java 侧的路由设计。如果 Java 端 Controller 路径就是 `/api/ai/getScenes`,则前端需要改。如果用 Nginx/网关层做路径映射,前端可以保持不变。建议在前端用配置项控制路径前缀。 **`stop()` 方法**添加对话保存: ```javascript async stop() { if (!this.isJoined && !this.audioBotEnabled) return; // 新增:保存对话历史 if (this.msgHistory.length > 0) { await this._saveConversation(); } // ... 原有停止逻辑 ... } ``` **新增方法**: ```javascript async _saveConversation() { /* POST 到 /api/ai/conversations */ } async getConversations(page, size) { /* GET /api/ai/conversations */ } async getConversationDetail(id) { /* GET /api/ai/conversations/{id} */ } ``` --- ## 7. 实施计划 ### Phase 1:Python 内部鉴权 + 身份传递(1-2 天) | 步骤 | 内容 | 文件 | |------|------|------| | 1 | 新建 HMAC 验证中间件 | `backend/middleware/internal_auth.py` | | 2 | 改造 session_store 使用 userId | `backend/services/session_store.py` | | 3 | 注册中间件 | `backend/server.py` | | 4 | 添加配置项 | `backend/.env.example`, `backend/.env` | | 5 | 测试:无密钥时仍可直接调用 Python | 手动测试 | ### Phase 2:Java 代理层(2-3 天) | 步骤 | 内容 | |------|------| | 1 | 添加 Maven 依赖(如 commons-codec) | | 2 | 创建 AiProxyService(HMAC 签名 + HTTP 转发) | | 3 | 创建 AiProxyController(3 个代理端点) | | 4 | 配置 application.yml | | 5 | 测试:通过 Java 调用 Python 各端点正常 | ### Phase 3:对话历史持久化(2-3 天) | 步骤 | 内容 | |------|------| | 1 | 执行建表 SQL | | 2 | 创建 JPA 实体和 Repository | | 3 | 创建 ConversationController + Service | | 4 | 创建 DTO/VO | | 5 | 测试:保存和查询 API 正常工作 | ### Phase 4:前端集成(1-2 天) | 步骤 | 内容 | 文件 | |------|------|------| | 1 | 添加 authToken 支持 | `aigc-voice-client.js` | | 2 | 更新端点路径 | `aigc-voice-client.js` | | 3 | 添加 stop 时保存对话逻辑 | `aigc-voice-client.js` | | 4 | 添加 beforeunload 兜底 | `aigc-voice-client.js` | | 5 | 端到端测试 | 全流程 | --- ## 8. 验证清单 ### 鉴权验证 - [ ] 无 HMAC 签名直接调 Python `/getScenes` → 返回 401 - [ ] 通过 Java 代理调 Python → 正常返回 - [ ] `INTERNAL_SERVICE_SECRET` 为空时直接调 Python → 正常返回(开发模式) - [ ] 时间戳超过 5 分钟 → 返回 401 - [ ] 篡改 userId 但不改签名 → 返回 401 ### 身份验证 - [ ] 用户 A 调 getScenes → 获得 RoomId-A - [ ] 用户 B 调 getScenes → 获得 RoomId-B(不同于 A) - [ ] 用户 A 调 StartVoiceChat → 使用 RoomId-A(不串到 B) ### 对话保存 - [ ] 完成语音对话 → stop → Java DB 中有对应 session + messages - [ ] 消息的 sender_type 正确区分 USER/AI - [ ] 消息顺序号正确 ### 历史查询 - [ ] GET /api/ai/conversations → 返回当前用户的会话列表 - [ ] GET /api/ai/conversations/{id} → 返回对话详情 - [ ] 用户 A 查不到用户 B 的对话(权限隔离) - [ ] DELETE 只能删除自己的对话 ### 端到端 - [ ] 前端带 JWT → Java → Python → 获取场景 → 加入房间 → 语音对话 → stop → 历史已保存 → 查看历史 --- ## 9. 注意事项与风险 | 风险 | 说明 | 缓解措施 | |------|------|----------| | chat_callback 无法经 Java | 火山 RTC 平台直接调 Python | 架构已考虑,不影响 | | sendBeacon 不支持自定义 Header | 意外关闭时兜底保存无法带 JWT | Java 侧额外支持 body 中读取 token,或使用一次性 token | | 同用户多 Tab 打开 | Session 中的 RoomId 会被覆盖 | 可在前端生成 clientSessionId 附加到请求,Session key 变为 `userId:clientSessionId` | | Python 重启丢 Session | 内存 Session 在 Python 重启后丢失 | 如需高可用,可改用 Redis。当前阶段可接受(重启后用户重新 init 即可) | | 大量历史消息 | 长时间对话可能产生大量消息 | 前端 `msgHistory` 只保留 `definite=true` 的最终文本,不保存中间态 |