rtc-voice-chat/plan/integration-plan.md
2026-04-02 20:15:15 +08:00

796 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<String> 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<String> 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()
```
**问题**:同一网络下不同用户可能拥有相同 IPUA 也可能相同,导致 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 已有数据库和 ORMSpring 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<String> 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<String> 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 1Python 内部鉴权 + 身份传递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 2Java 代理层2-3 天)
| 步骤 | 内容 |
|------|------|
| 1 | 添加 Maven 依赖(如 commons-codec |
| 2 | 创建 AiProxyServiceHMAC 签名 + HTTP 转发) |
| 3 | 创建 AiProxyController3 个代理端点) |
| 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` 的最终文本,不保存中间态 |