796 lines
25 KiB
Markdown
796 lines
25 KiB
Markdown
# 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()
|
||
```
|
||
|
||
**问题**:同一网络下不同用户可能拥有相同 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<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 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` 的最终文本,不保存中间态 |
|