25 KiB
RTC-AIGC 集成方案:Java 网关代理 + 用户鉴权 + 对话历史持久化
1. 背景与目标
现状
- Python AI 后端(FastAPI, port 3001):处理 RTC 语音对话、LLM 回调、RTC OpenAPI 代理
- 前端(vanilla JS,
AigcVoiceClient):已测试通过,即将集成到另一个项目 - Java 后端(Spring Boot + JWT):已有项目,拥有完整的用户鉴权体系
问题
- Python 后端无用户鉴权,使用
IP + User-Agent哈希做简易 Session - 对话历史仅在前端内存中,刷新即丢失
- 前端集成后需统一走 Java 后端,不能直连 Python
目标
- 用户鉴权:利用 Java 已有的 JWT 体系,统一用户身份
- 数据存储:持久化对话历史,支持用户回看
- 架构整合: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 侧验证逻辑
# 伪代码
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 侧签名代码参考
@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
# 当前:用 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 改造后
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(对话会话表)
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(对话消息表)
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 前端保存逻辑
// 在 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 兜底:
// 在 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
请求体:
{
"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"
}
]
}
响应:
{
"code": 200,
"data": {
"sessionId": 42
}
}
处理逻辑:
- 从 JWT 中提取 userId
- 创建
conversation_session记录 - 遍历 messages,判断
userId是否等于当前用户来区分 USER/AI - 批量插入
conversation_message
查询对话列表 — GET /api/ai/conversations
请求参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| page | int | 页码,默认 1 |
| size | int | 每页条数,默认 20 |
响应:
{
"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}
响应:
{
"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}
响应:
{
"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:
from middleware.internal_auth import InternalAuthMiddleware
app = FastAPI()
app.add_middleware(InternalAuthMiddleware)
app.add_middleware(CORSMiddleware, ...) # 已有
6.1.5 修改 backend/.env.example
添加:
# ============ 服务间鉴权 ============
# Java 网关与 Python 之间的共享密钥(生产环境必须设置!)
INTERNAL_SERVICE_SECRET=
6.2 Java 后端新增
6.2.1 配置 application.yml
ai:
python-backend-url: http://localhost:3001
internal-service-secret: your-shared-secret-key
6.2.2 新建 AiProxyController
@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
@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 JpaRepositoryConversationMessageRepository extends JpaRepository
6.2.7 新建 DTO
SaveConversationDTO— 保存对话请求体ConversationListVO— 列表响应ConversationDetailVO— 详情响应
6.3 前端改动
6.3.1 修改 simple-frontend/aigc-voice-client.js
构造函数新增 authToken 参数:
constructor(options = {}) {
this.serverUrl = options.serverUrl || 'http://localhost:3001';
this.authToken = options.authToken || null; // 新增:JWT Token
// ...
}
_post() 方法添加 Authorization Header:
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 网关):
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() 方法添加对话保存:
async stop() {
if (!this.isJoined && !this.audioBotEnabled) return;
// 新增:保存对话历史
if (this.msgHistory.length > 0) {
await this._saveConversation();
}
// ... 原有停止逻辑 ...
}
新增方法:
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 的最终文本,不保存中间态 |