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

25 KiB
Raw Blame History

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_SECRETJava 转发请求时用此密钥对关键信息做 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()

问题:同一网络下不同用户可能拥有相同 IPUA 也可能相同,导致 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 已有数据库和 ORMSpring 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.sendBeaconbeforeunload 事件中尝试保存。

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
    }
}

处理逻辑

  1. 从 JWT 中提取 userId
  2. 创建 conversation_session 记录
  3. 遍历 messages判断 userId 是否等于当前用户来区分 USER/AI
  4. 批量插入 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 JpaRepository
  • ConversationMessageRepository 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 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 的最终文本,不保存中间态