This commit is contained in:
lengbone 2026-04-02 20:15:15 +08:00
parent d468195db0
commit 271f7345c9
38 changed files with 5933 additions and 90 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Bash(curl -s http://localhost:8080/health)",
"Bash(curl -s http://localhost:3001/health)",
"Bash(curl -s -X POST http://localhost:8080/api/auth/login -H 'Content-Type: application/json' -d '{\"username\":\"admin\",\"password\":\"admin123\"}')",
"Bash(python3 -m json.tool)"
]
}
}

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ pnpm-lock.yaml
.env .env
.env.* .env.*
!.env.example !.env.example
!.env.staging
# ===================== # =====================
# 日志 / Logs # 日志 / Logs

12
backend/.dockerignore Normal file
View File

@ -0,0 +1,12 @@
.venv/
__pycache__/
*.pyc
.env
.env.*
!.env.example
.git/
.gitignore
.vscode/
.DS_Store
*.md
*.log

View File

@ -24,10 +24,9 @@ CUSTOM_INTERRUPT_MODE=0
CUSTOM_LLM_THINKING_TYPE=disabled CUSTOM_LLM_THINKING_TYPE=disabled
CUSTOM_LLM_VISION_ENABLE=false CUSTOM_LLM_VISION_ENABLE=false
# 本地调试时,可先保持默认本地回调地址。 # 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址
# 等 ngrok 跑起来后,再把 CUSTOM_LLM_URL 改成公网 https 地址,例如: # 例如https://api.yourdomain.com/v1/api/chat_callback
# https://your-ngrok-domain.ngrok-free.app/api/chat_callback CUSTOM_LLM_URL=
CUSTOM_LLM_URL= https://postvarioloid-leeann-didynamous.ngrok-free.dev
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token可留空 # 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token可留空
CUSTOM_LLM_API_KEY= CUSTOM_LLM_API_KEY=
CUSTOM_LLM_MODEL_NAME= CUSTOM_LLM_MODEL_NAME=
@ -69,7 +68,7 @@ VOLC_KB_ENABLED=false
VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一) VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一)
VOLC_KB_RESOURCE_ID= # 知识库唯一 ID优先级高于 NAME VOLC_KB_RESOURCE_ID= # 知识库唯一 ID优先级高于 NAME
VOLC_KB_PROJECT=default # 知识库所属项目 VOLC_KB_PROJECT=default # 知识库所属项目
VOLC_KB_ENDPOINT=https://postvarioloid-leeann-didynamous.ngrok-free.dev VOLC_KB_ENDPOINT=
VOLC_KB_TOP_K=3 # 检索返回条数 VOLC_KB_TOP_K=3 # 检索返回条数
VOLC_KB_RERANK=false # 是否开启 rerank 重排 VOLC_KB_RERANK=false # 是否开启 rerank 重排
VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟) VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟)

92
backend/.env.staging Normal file
View File

@ -0,0 +1,92 @@
# ============ 测试环境配置 ============
# 此文件用于 staging 测试环境,指向测试资源而非生产资源
# 不要提交真实密钥到 git建议通过 CI/CD secret 管理
# ============ 火山引擎账号凭证 ============
CUSTOM_ACCESS_KEY_ID=your-staging-access-key-id
CUSTOM_SECRET_KEY=your-staging-secret-key
# ============ RTC 配置 ============
CUSTOM_RTC_APP_ID=your-staging-rtc-app-id
CUSTOM_RTC_APP_KEY=
CUSTOM_RTC_ROOM_ID=
CUSTOM_RTC_USER_ID=
CUSTOM_RTC_TOKEN=
RTC_OPENAPI_VERSION=2025-06-01
# ============ 场景配置 ============
CUSTOM_SCENE_NAME=测试助手
CUSTOM_SCENE_ICON=https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png
CUSTOM_TASK_ID=your-staging-task-id
CUSTOM_AGENT_USER_ID=your-staging-agent-user-id
CUSTOM_AGENT_TARGET_USER_ID=
CUSTOM_AGENT_WELCOME_MESSAGE=你好,我是测试助手。
CUSTOM_INTERRUPT_MODE=0
# ============ LLM 配置 (RTC OpenAPI 侧) ============
# 测试环境回调地址 —— 改成 staging 服务器的公网地址
CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/api/chat_callback
CUSTOM_LLM_API_KEY=your-staging-llm-api-key
CUSTOM_LLM_THINKING_TYPE=disabled
CUSTOM_LLM_VISION_ENABLE=false
CUSTOM_LLM_MODEL_NAME=
CUSTOM_LLM_HISTORY_LENGTH=
CUSTOM_LLM_PREFILL=
CUSTOM_LLM_CUSTOM=
CUSTOM_LLM_EXTRA_HEADER_JSON=
CUSTOM_LLM_ENABLE_PARALLEL_TOOL_CALLS=
CUSTOM_LLM_TEMPERATURE=
CUSTOM_LLM_TOP_P=
CUSTOM_LLM_MAX_TOKENS=
# ============ 本地 LLM 回调配置 ============
LOCAL_LLM_API_KEY=your-staging-ark-api-key
LOCAL_LLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
LOCAL_LLM_MODEL=your-staging-ark-endpoint-id
LOCAL_LLM_TIMEOUT_SECONDS=1800
LOCAL_LLM_TEMPERATURE=0.3
# ============ ASR / TTS ============
CUSTOM_ASR_APP_ID=your-staging-asr-app-id
CUSTOM_TTS_APP_ID=your-staging-tts-app-id
CUSTOM_ASR_PROVIDER=volcano
CUSTOM_ASR_MODE=smallmodel
CUSTOM_ASR_CLUSTER=volcengine_streaming_common
CUSTOM_TTS_PROVIDER=volcano
CUSTOM_TTS_CLUSTER=volcano_tts
CUSTOM_TTS_VOICE_TYPE=BV001_streaming
CUSTOM_TTS_SPEED_RATIO=1
CUSTOM_TTS_PITCH_RATIO=1
CUSTOM_TTS_VOLUME_RATIO=1
# ============ RAG 配置 ============
VOLC_KB_ENABLED=false
VOLC_KB_NAME=
VOLC_KB_RESOURCE_ID=
VOLC_KB_PROJECT=default
VOLC_KB_ENDPOINT=
VOLC_KB_TOP_K=3
VOLC_KB_RERANK=false
VOLC_KB_ATTACHMENT_LINK=false
RAG_STATIC_CONTEXT=
RAG_CONTEXT_FILE=
# ============ 数字人 ============
CUSTOM_AVATAR_ENABLED=false
CUSTOM_AVATAR_TYPE=3min
CUSTOM_AVATAR_ROLE=
CUSTOM_AVATAR_BACKGROUND_URL=
CUSTOM_AVATAR_VIDEO_BITRATE=2000
CUSTOM_AVATAR_APP_ID=
CUSTOM_AVATAR_TOKEN=
# ============ Tools ============
TOOLS_ENABLED=true
TOOLS_MAX_ROUNDS=5
# ============ 内部鉴权 ============
INTERNAL_SERVICE_SECRET=your-staging-internal-secret
# ============ 测试环境专属 ============
LOG_LEVEL=info
PORT=3001

508
backend/API.md Normal file
View File

@ -0,0 +1,508 @@
# Python 后端 API 文档
> Base URL: `http://localhost:3001`
>
> 所有来自 **java-mock** 的请求须附加内部服务签名 Header见[内部鉴权协议](#内部鉴权协议))。
>
> `/api/chat_callback` 由**火山引擎 RTC 平台**直接回调,走独立 API Key 鉴权。
---
## 目录
- [接口总览](#接口总览)
- [一、场景接口](#一场景接口)
- [1.1 获取场景列表](#11-获取场景列表)
- [二、RTC 代理接口](#二rtc-代理接口)
- [2.1 开始 / 停止语音对话](#21-开始--停止语音对话)
- [三、会话历史接口](#三会话历史接口)
- [3.1 写入历史上下文](#31-写入历史上下文)
- [四、LLM 回调接口](#四llm-回调接口)
- [4.1 自定义 LLM 回调SSE](#41-自定义-llm-回调sse)
- [五、调试接口](#五调试接口)
- [5.1 调试聊天](#51-调试聊天)
- [5.2 调试 RAG 检索](#52-调试-rag-检索)
- [内部鉴权协议](#内部鉴权协议)
- [通用错误结构](#通用错误结构)
- [环境变量](#环境变量)
---
## 接口总览
| 方法 | 路径 | 调用方 | 鉴权方式 | 说明 |
|---|---|---|---|---|
| POST | `/getScenes` | java-mock | 内部签名 | 获取场景列表 & RTC 配置 |
| POST | `/proxy` | java-mock | 内部签名 | 转发 StartVoiceChat / StopVoiceChat 到火山引擎 |
| POST | `/api/session/history` | java-mock | 内部签名 | 写入房间历史上下文 |
| POST | `/api/chat_callback` | 火山引擎 RTC 平台 | API Key | 自定义 LLM 回调,返回 SSE 流 |
| POST | `/debug/chat` | 开发调试 | 无 | 直接测试 LLM 对话 |
| GET | `/debug/rag` | 开发调试 | 无 | 测试 RAG 知识库检索 |
---
## 一、场景接口
### 1.1 获取场景列表
```
POST /getScenes
```
**鉴权:** 内部签名java-mock 调用)
**请求体:** `{}` 或空
**成功响应 200**
```json
{
"ResponseMetadata": {
"Action": "getScenes"
},
"Result": {
"scenes": [
{
"scene": {
"id": "Custom",
"botName": "BotUser001",
"isInterruptMode": true,
"isVision": false,
"isScreenMode": false,
"isAvatarScene": false,
"avatarBgUrl": null
},
"rtc": {
"AppId": "6xxxxxxx",
"RoomId": "550e8400-e29b-41d4-a716-446655440000",
"UserId": "user-xyz",
"Token": "AQBhMGI3Zm...",
"TaskId": "task-001"
}
}
]
}
}
```
**场景字段说明:**
| 字段 | 类型 | 说明 |
|---|---|---|
| id | string | 场景唯一标识,后续接口的 `SceneID` 取此值 |
| botName | string | AI Bot 在 RTC 房间中的 UserId |
| isInterruptMode | boolean | 是否开启打断模式InterruptMode === 0 |
| isVision | boolean | 是否开启视觉能力 |
| isScreenMode | boolean | 是否为屏幕共享模式 |
| isAvatarScene | boolean | 是否为数字人场景 |
| avatarBgUrl | string\|null | 数字人背景图 URL |
**RTC 字段说明:**
| 字段 | 类型 | 说明 |
|---|---|---|
| AppId | string | 火山引擎 RTC AppId |
| RoomId | string | 本次会话分配的房间 IDUUID |
| UserId | string | 用户在 RTC 房间中的 UserId |
| Token | string | RTC 入房 Token |
| TaskId | string | 语音任务 IDStopVoiceChat 时需要 |
> **副作用:** 该接口会将 `RoomId / UserId / TaskId` 写入服务端 Session供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。
**失败响应:**
```json
{
"ResponseMetadata": {
"Action": "getScenes",
"Error": {
"Code": -1,
"Message": "错误描述"
}
}
}
```
---
## 二、RTC 代理接口
### 2.1 开始 / 停止语音对话
```
POST /proxy?Action=<action>&Version=<version>
```
**鉴权:** 内部签名java-mock 调用)
**Query 参数:**
| 字段 | 必填 | 默认值 | 说明 |
|---|---|---|---|
| Action | ✅ | — | `StartVoiceChat``StopVoiceChat` |
| Version | ❌ | 配置文件值 | 火山引擎 OpenAPI 版本,如 `2024-12-01` |
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| SceneID | string | ✅ | 场景 ID`getScenes``scene.id` 获取) |
---
#### StartVoiceChat
**请求示例:**
```json
{
"SceneID": "Custom"
}
```
**内部处理逻辑:**
1. 从 Session 取回 `getScenes` 时生成的 `RoomId / UserId / TaskId`
2. 将 `room_id` 附加到 LLM 回调 URL`?room_id=xxx`),使 `/api/chat_callback` 能关联历史上下文
3. 带 SigV4 签名转发到火山引擎 `https://rtc.volcengineapi.com`
**成功响应 200**
```json
{
"ResponseMetadata": {
"RequestId": "2024xxxxxxxxxx",
"Action": "StartVoiceChat",
"Version": "2024-12-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
---
#### StopVoiceChat
**请求示例:**
```json
{
"SceneID": "Custom"
}
```
**内部处理逻辑:**
1. 从 Session 取回 `RoomId / TaskId`
2. 清除该房间的历史上下文缓存(`room_history`
3. 带 SigV4 签名转发到火山引擎
**成功响应 200**
```json
{
"ResponseMetadata": {
"RequestId": "2024xxxxxxxxxx",
"Action": "StopVoiceChat",
"Version": "2024-12-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
**通用失败响应:**
```json
{
"ResponseMetadata": {
"Action": "StartVoiceChat",
"Error": {
"Code": "InvalidParameter",
"Message": "SceneID 不能为空SceneID 用于指定场景配置"
}
}
}
```
---
## 三、会话历史接口
### 3.1 写入历史上下文
> 在 `StartVoiceChat` 之前调用,将历史对话注入该房间的上下文缓存。后续 `/api/chat_callback` 会自动将其 prepend 到每次 LLM 请求的 messages 前。
```
POST /api/session/history
```
**鉴权:** 内部签名java-mock 调用)
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| room_id | string | ✅ | RTC 房间 ID`getScenes` 返回的 `rtc.RoomId` 一致) |
| messages | array | ✅ | 历史消息数组 |
`messages` 每条消息:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| role | string | ✅ | `"user"``"assistant"` |
| content | string | ✅ | 消息文本 |
**请求示例:**
```json
{
"room_id": "550e8400-e29b-41d4-a716-446655440000",
"messages": [
{ "role": "assistant", "content": "你好,我是小块" },
{ "role": "user", "content": "今天出勤情况咋样" },
{ "role": "assistant", "content": "今天出勤率 90%" }
]
}
```
**成功响应 200**
```json
{
"code": 200
}
```
**失败响应 401**
```json
{
"code": 401,
"message": "鉴权失败"
}
```
---
## 四、LLM 回调接口
### 4.1 自定义 LLM 回调SSE
> 由**火山引擎 RTC 平台**在用户发言后自动回调。返回 OpenAI 兼容格式的 SSE 流。
```
POST /api/chat_callback?room_id=<room_id>
```
**鉴权:** `Authorization: Bearer <CUSTOM_LLM_API_KEY>`Header 或 Query 参数)
**Query 参数:**
| 字段 | 必填 | 说明 |
|---|---|---|
| room_id | ❌ | 房间 ID。传入时自动从缓存取历史上下文并 prepend 到 messages |
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| messages | array | ✅ | 对话消息列表,最后一条必须是 `user` 角色 |
| temperature | float | ❌ | 采样温度 |
| max_tokens | int | ❌ | 最大生成 token 数 |
| top_p | float | ❌ | Top-P 采样 |
`messages` 中每条消息:
| 字段 | 类型 | 说明 |
|---|---|---|
| role | string | `"user"` / `"assistant"` / `"system"` |
| content | string | 消息文本 |
**请求示例:**
```json
{
"messages": [
{ "role": "user", "content": "今天办公室出勤情况咋样" }
],
"temperature": 0.7,
"max_tokens": 1024
}
```
**处理逻辑:**
1. 验证 API Key
2. 过滤掉 `content === "欢迎语"` 的触发词消息RTC 平台自动发送,非真实用户输入)
3. 若有 `room_id`,从缓存取历史并 prepend 到 messages 前
4. 调用本地 LLM 服务(工具调用 / RAG 按需触发)
5. 以 SSE 流返回结果
**成功响应 200text/event-stream**
```
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"今"},...}]}
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"天"},...}]}
data: [DONE]
```
**失败响应SSE 格式HTTP 状态码对应):**
```
data: {"error":{"code":"AuthenticationError","message":"API Key 无效"}}
data: [DONE]
```
| HTTP 状态码 | code | 触发场景 |
|---|---|---|
| 401 | `AuthenticationError` | API Key 无效 |
| 400 | `BadRequest` | messages 为空 / 最后一条不是 user |
| 500 | `InternalError` | LLM 初始化失败 / 请求解析失败 |
---
## 五、调试接口
> 仅供本地开发使用,无鉴权。
### 5.1 调试聊天
```
POST /debug/chat
```
直接发消息给 LLM响应为纯文本流非 SSE。完成后在服务端终端输出可复用的 `history` JSON 结构。
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| history | array | ❌ | 历史消息列表,格式同 `messages`,默认空 |
| question | string | ✅ | 本次用户提问 |
**请求示例:**
```json
{
"history": [
{ "role": "assistant", "content": "你好,有什么可以帮你?" }
],
"question": "今天办公室有多少人打卡?"
}
```
**成功响应 200text/plain 流式):**
```
今天办公室一共九人,目前出勤率为 100%。
```
---
### 5.2 调试 RAG 检索
```
GET /debug/rag?query=<query>
```
测试知识库检索,返回检索到的原始上下文内容。
**Query 参数:**
| 字段 | 必填 | 说明 |
|---|---|---|
| query | ✅ | 检索问题 |
**成功响应 200**
```json
{
"query": "今天出勤情况",
"retrieved_context": "#### 今天出勤数据\n办公室共 9 人...",
"length": 128,
"status": "success"
}
```
无结果时 `status``"no_results_or_error"``retrieved_context` 为 `null`
---
## 内部鉴权协议
> `/getScenes`、`/proxy`、`/api/session/history` 均启用此鉴权。
**java-mock 发送方**在请求头附加:
| Header | 说明 |
|---|---|
| `X-Internal-Service` | 固定值 `java-gateway` |
| `X-Internal-User-Id` | 当前登录用户 ID |
| `X-Internal-Timestamp` | 毫秒级 Unix 时间戳(字符串) |
| `X-Internal-Signature` | HMAC-SHA256 签名hex |
| `X-User-Name` | URL 编码的用户显示名 |
| `X-User-Sex` | 性别 |
| `X-User-Is-Driver` | `"true"` / `"false"` |
| `X-User-Dept-Id` | 部门 ID |
| `X-User-Dept-Name` | URL 编码的部门名称 |
| `X-User-Role-List` | URL 编码的角色列表 JSON |
**签名算法:**
```
message = "java-gateway:{userId}:{毫秒时间戳}"
signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) // hex
```
**Python 接收方验证逻辑(`security/internal_auth.py`**
1. `INTERNAL_SERVICE_SECRET` 未配置 → 直接放行(开发环境兼容)
2. 校验 4 个必要字段是否存在,`X-Internal-Service` 必须为 `java-gateway`
3. 时间窗口:`abs(now_ms - timestamp) ≤ 5分钟`(防重放)
4. 重新计算 HMAC`hmac.compare_digest` 常量时间比较(防时序攻击)
---
## 通用错误结构
**内部接口getScenes / proxy / session 系列)** 返回火山引擎风格:
```json
{
"ResponseMetadata": {
"Action": "xxx",
"Error": {
"Code": "InvalidParameter",
"Message": "SceneID 不能为空"
}
}
}
```
**chat_callback** 返回 SSE 格式错误:
```
data: {"error":{"code":"BadRequest","message":"messages 不能为空"}}
data: [DONE]
```
---
## 环境变量
| 变量名 | 必填 | 说明 |
|---|---|---|
| `INTERNAL_SERVICE_SECRET` | ✅ | 内部服务签名密钥,与 java-mock 侧保持一致 |
| `CUSTOM_LLM_API_KEY` | ✅ | `chat_callback` 接口的鉴权 Key配置到火山引擎 RTC 场景中 |
| `OPENAI_API_KEY` | ✅(或同类) | 接入 LLM 所需的 API Key |
| `OPENAI_BASE_URL` | ❌ | 自定义 LLM Base URL接入本地模型时使用 |
| `LLM_MODEL` | ❌ | 指定模型名称 |
| `RAG_*` | ❌ | 知识库相关配置(详见 `utils/env.py` |

295
backend/DEPLOYMENT.md Normal file
View File

@ -0,0 +1,295 @@
# 部署指南
本项目使用 Docker + docker-compose 管理多环境部署。所有配置通过环境变量注入,`load_dotenv(override=False)` 保证**真实环境变量(容器/CI/CD 注入)永远优先于 `.env` 文件**。
---
## 文件结构
```
backend/
├── Dockerfile # 多阶段构建镜像(共用)
├── docker-compose.yml # 基础配置(生产默认)
├── docker-compose.dev.yml # 开发环境 override
├── docker-compose.staging.yml # 测试环境 override
├── .env # 本地开发配置(不提交 git
├── .env.staging # 测试环境配置模板(不提交真实密钥)
└── .env.example # 全量变量说明(提交 git
```
---
## 三个环境对比
| 项目 | 开发 (dev) | 测试 (staging) | 生产 (production) |
|------|-----------|---------------|------------------|
| 启动方式 | uv 直接运行 或 Docker | docker-compose + override | Docker 镜像 + CI/CD |
| 配置来源 | `.env` | `.env.staging` | 平台注入环境变量 |
| `LOG_LEVEL` | `DEBUG` | `INFO` | `WARNING` |
| `--reload` | 是 | 否 | 否 |
| `restart` | no | unless-stopped | unless-stopped |
| `.env` 文件 | 挂载 / 读取 | 挂载 `.env.staging` | **不挂载任何文件** |
---
## 开发环境
### 方式一:直接用 uv推荐最快
```bash
cd backend
uv run uvicorn server:app --host 0.0.0.0 --port 3001 --reload
```
自动读取 `.env`,支持热重载。
### 方式二Docker + dev override
```bash
cd backend
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
源码目录挂载进容器,修改文件自动重载,行为与方式一一致,但运行在容器内。
---
## 测试环境 (Staging)
### 服务器目录约定
项目统一放在 `/opt` 下,这是 Linux 存放第三方应用的标准位置,权限独立、路径固定:
```bash
/opt/aigc-demo/ # 项目根目录
└── backend/ # Python 后端(本文档所描述的部分)
```
初次登录服务器后执行:
```bash
sudo mkdir -p /opt/aigc-demo
sudo chown $USER:$USER /opt/aigc-demo
```
### 第一步:服务器安装 Docker
```bash
# Ubuntu / Debian
curl -fsSL https://get.docker.com | sh
sudo systemctl enable --now docker
# CentOS / AlmaLinux
sudo yum install -y docker
sudo systemctl enable --now docker
# 验证
docker --version
docker compose version
```
### 第二步:拉取代码
```bash
cd /opt/aigc-demo
git clone https://github.com/your-repo/rtc-aigc-demo.git .
# 或者已有仓库时更新
git pull
```
如果服务器没有配置 git也可以从本机传
```bash
# 本机执行
scp -r ./backend user@your-server-ip:/opt/aigc-demo/
```
### 第三步:配置 staging 密钥
```bash
cd /opt/aigc-demo/backend
cp .env.staging .env.staging.local
vim .env.staging.local
```
**必填项:**
```bash
CUSTOM_ACCESS_KEY_ID=你的火山引擎AK
CUSTOM_SECRET_KEY=你的火山引擎SK
CUSTOM_RTC_APP_ID=你的RTC AppId
CUSTOM_RTC_APP_KEY=你的RTC AppKey
CUSTOM_TASK_ID=你的TaskId
CUSTOM_AGENT_USER_ID=你的AgentUserId
LOCAL_LLM_API_KEY=你的Ark API Key
LOCAL_LLM_MODEL=你的Ark端点ID
# 关键:填服务器公网 IP 或域名
CUSTOM_LLM_URL=http://你的公网IP:3001/v1/api/chat_callback
# 有域名 + HTTPS 则改为:
# CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/api/chat_callback
```
> 火山引擎 RTC 平台需要能主动回调 `CUSTOM_LLM_URL`,所以这里必须填**公网地址**localhost 无效。
### 第四步:开放防火墙端口
在云平台控制台的**安全组**放行 `3001` 端口入方向TCP
- **阿里云**`ECS 控制台 → 安全组 → 配置规则 → 添加入方向规则 → 端口 3001`
- **腾讯云**`CVM 控制台 → 安全组 → 添加规则 → 端口 3001`
### 第五步:启动服务
```bash
cd /opt/aigc-demo/backend
docker compose -f docker-compose.yml -f docker-compose.staging.yml \
--env-file .env.staging.local up -d --build
```
`-d` 后台运行,`--build` 每次重新构建镜像。
### 第六步:验证
```bash
# 容器内健康检查
curl http://localhost:3001/health
# 预期:{"status":"ok"}
# 从外网验证(换成你的公网 IP
curl http://你的公网IP:3001/health
# 查看实时日志
docker compose logs -f backend
```
### 日常运维命令
```bash
# 更新代码后重新部署
git pull
docker compose -f docker-compose.yml -f docker-compose.staging.yml \
--env-file .env.staging.local up -d --build
# 重启服务
docker compose -f docker-compose.yml -f docker-compose.staging.yml restart
# 停止服务
docker compose -f docker-compose.yml -f docker-compose.staging.yml down
# 查看最近 100 行日志
docker compose logs --tail=100 backend
```
---
## 生产环境
### 构建镜像
```bash
cd backend
# 打带版本号的 tag用 git commit hash
IMAGE_TAG=$(git rev-parse --short HEAD)
docker build -t aigc-backend:${IMAGE_TAG} .
docker build -t aigc-backend:latest .
```
### 推送到镜像仓库
```bash
# 以阿里云 ACR 为例
docker tag aigc-backend:${IMAGE_TAG} registry.cn-beijing.aliyuncs.com/your-ns/aigc-backend:${IMAGE_TAG}
docker push registry.cn-beijing.aliyuncs.com/your-ns/aigc-backend:${IMAGE_TAG}
```
### 运行
生产环境**不使用任何 `.env` 文件**所有密钥由平台ECS 环境变量、K8s Secret、Cloud Run注入
```bash
docker run -d \
--name aigc-backend \
--restart unless-stopped \
-p 3001:3001 \
-e CUSTOM_ACCESS_KEY_ID=xxx \
-e CUSTOM_SECRET_KEY=xxx \
-e LOCAL_LLM_API_KEY=xxx \
-e LOG_LEVEL=warning \
# ... 其余变量 ...
aigc-backend:${IMAGE_TAG}
```
或通过 `--env-file` 传入一个**不在代码仓库中**的生产密钥文件:
```bash
docker run -d --env-file /secure/prod.env -p 3001:3001 aigc-backend:${IMAGE_TAG}
```
---
## 负载均衡 / 反向代理注意事项
| 要求 | 原因 |
|------|------|
| idle timeout ≥ 75s | 容器 `--timeout-keep-alive 75`LB 超时需更长 |
| 关闭响应缓冲 | `/api/chat_callback` 是 SSE 流式响应Nginx 需设 `proxy_buffering off` |
| TLS 在 LB 层终止 | 容器内不处理 HTTPS |
| 健康检查路径 `GET /health` | 返回 `{"status":"ok"}`HTTP 200 |
| sticky session如需水平扩展| 会话存储在内存中,同一客户端需路由到同一容器 |
Nginx 反向代理最小配置片段:
```nginx
location /v1/api/chat_callback {
proxy_pass http://backend:3001;
proxy_buffering off;
proxy_cache off;
proxy_set_header X-Forwarded-For $remote_addr;
}
location / {
proxy_pass http://backend:3001;
proxy_set_header X-Forwarded-For $remote_addr;
}
```
---
## .gitignore 建议
确保以下文件不进入版本库:
```gitignore
.env
.env.staging.local
.env.production
*.local
```
`.env.staging`(仅含占位符的模板)可以提交,便于团队成员了解需要配置哪些变量。
---
## 快速参考
```bash
# 开发
uv run uvicorn server:app --reload
# 测试环境Docker
docker compose -f docker-compose.yml -f docker-compose.staging.yml up --build
# 生产镜像构建
docker build -t aigc-backend:$(git rev-parse --short HEAD) .
# 查看健康状态
curl http://localhost:3001/health
# 查看容器日志
docker compose logs -f backend
```

47
backend/Dockerfile Normal file
View File

@ -0,0 +1,47 @@
# === Stage 1: build dependencies ===
FROM python:3.13-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy only dependency files first (cache layer)
COPY pyproject.toml uv.lock ./
# Install dependencies into a self-contained venv
RUN uv sync --frozen --no-dev --no-install-project
# Copy application code
COPY . .
# Install the project itself
RUN uv sync --frozen --no-dev
# === Stage 2: runtime ===
FROM python:3.13-slim AS runtime
RUN groupadd --gid 1000 app && \
useradd --uid 1000 --gid app --shell /bin/bash --create-home app
WORKDIR /app
# Copy the entire venv and application from builder
COPY --from=builder /app /app
# Remove files that must not be in the image
RUN rm -f .env .env.* && rm -rf __pycache__
ENV PATH="/app/.venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
USER app
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"]
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "3001", \
"--log-level", "warning", "--timeout-keep-alive", "75"]

View File

@ -75,11 +75,10 @@ CUSTOM_LLM_API_KEY=your-callback-token
推荐调试流程: 推荐调试流程:
1. 先启动当前 `backend` 1. 先启动当前 `backend`
2. 用 `ngrok` 暴露 `3001` 端口 2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如:
3. 把 `CUSTOM_LLM_URL` 改成公网地址,例如:
```dotenv ```dotenv
CUSTOM_LLM_URL=https://your-ngrok-domain.ngrok-free.app/api/chat_callback CUSTOM_LLM_URL=https://api.yourdomain.com/v1/api/chat_callback
``` ```
`CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token如果你不需要这层鉴权可以留空。 `CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token如果你不需要这层鉴权可以留空。

View File

@ -0,0 +1,15 @@
services:
backend:
build:
target: builder # 使用 builder 阶段,保留完整工具链
volumes:
- .:/app # 挂载源码目录,支持热重载
- /app/.venv # 防止宿主机 .venv 覆盖容器内的 .venv
command: uvicorn server:app --host 0.0.0.0 --port 3001 --reload
env_file:
- .env
environment:
- LOG_LEVEL=debug
ports:
- "${PORT:-3001}:3001"
restart: "no" # 开发时不自动重启,报错立即停止方便排查

View File

@ -0,0 +1,7 @@
services:
backend:
env_file:
- .env.staging # 覆盖基础文件的 .env指向测试资源
environment:
- LOG_LEVEL=info # 测试环境用 info比生产详细比开发简洁
restart: unless-stopped

View File

@ -0,0 +1,18 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
ports:
- "${PORT:-3001}:3001"
env_file:
- .env
environment:
- LOG_LEVEL=${LOG_LEVEL:-warning}
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:3001/health')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

View File

@ -0,0 +1 @@
"""v1 路由模块"""

View File

@ -10,12 +10,30 @@ from fastapi.responses import StreamingResponse
from schemas.chat import ChatCallbackRequest from schemas.chat import ChatCallbackRequest
from services.local_llm_service import local_llm_service from services.local_llm_service import local_llm_service
from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings
from services.session_store import get_room_history
from utils.responses import custom_llm_error_response from utils.responses import custom_llm_error_response
router = APIRouter() router = APIRouter(tags=["LLM 回调"])
@router.post("/api/chat_callback") @router.post(
"/api/chat_callback",
summary="自定义 LLM 回调SSE 流式)",
description=(
"由**火山引擎 RTC 平台**在用户发言后自动回调,返回 OpenAI 兼容格式的 SSE 流。\n\n"
"处理逻辑:\n"
"1. 校验 `Authorization: Bearer <CUSTOM_LLM_API_KEY>`\n"
"2. 过滤掉 RTC 平台发送的 `欢迎语` 触发词(非真实用户输入)\n"
"3. 若携带 `room_id` Query 参数,自动从缓存取历史并 prepend 到 messages 前\n"
"4. 调用本地 LLM工具调用 / RAG 按需触发),以 SSE 流返回结果\n\n"
"**鉴权**`Authorization: Bearer <CUSTOM_LLM_API_KEY>`"
),
responses={
401: {"description": "API Key 无效"},
400: {"description": "messages 为空或最后一条不是 user 角色"},
500: {"description": "LLM 初始化失败"},
},
)
async def chat_callback(request: Request, body: ChatCallbackRequest): async def chat_callback(request: Request, body: ChatCallbackRequest):
try: try:
settings = get_custom_llm_callback_settings() settings = get_custom_llm_callback_settings()
@ -43,6 +61,23 @@ async def chat_callback(request: Request, body: ChatCallbackRequest):
status_code=400, status_code=400,
) )
# 过滤 RTC 平台的"欢迎语"触发词(不是真实用户输入)
messages = [m for m in messages if not (m["role"] == "user" and m["content"] == "欢迎语")]
# 注入历史对话上下文prepend 到当前会话消息前)
room_id = request.query_params.get("room_id", "")
if room_id:
history = get_room_history(room_id)
if history:
messages = history + messages
if not messages:
return custom_llm_error_response(
"messages 不能为空",
code="BadRequest",
status_code=400,
)
last_message = messages[-1] last_message = messages[-1]
if last_message.get("role") != "user": if last_message.get("role") != "user":
return custom_llm_error_response( return custom_llm_error_response(

View File

@ -5,17 +5,26 @@
import json import json
import time import time
from fastapi import APIRouter from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from schemas.chat import DebugChatRequest from schemas.chat import DebugChatRequest
from services.local_llm_service import local_llm_service from services.local_llm_service import local_llm_service
from services.rag_service import rag_service from services.rag_service import rag_service
router = APIRouter(prefix="/debug") router = APIRouter(prefix="/debug", tags=["调试"])
@router.post("/chat") @router.post(
"/chat",
summary="调试 LLM 对话",
description=(
"直接向 LLM 发送消息,响应为纯文本流(非 SSE\n\n"
"完成后会在**服务端终端**输出本次对话完整的 `history` JSON"
"可复制后粘贴到下次请求的 `history` 字段继续对话。\n\n"
"⚠️ 仅用于本地开发调试,无鉴权。"
),
)
async def debug_chat(request: DebugChatRequest): async def debug_chat(request: DebugChatRequest):
current_messages = [ current_messages = [
{"role": message.role, "content": message.content} for message in request.history {"role": message.role, "content": message.content} for message in request.history
@ -72,8 +81,15 @@ async def debug_chat(request: DebugChatRequest):
return StreamingResponse(generate_text(), media_type="text/plain") return StreamingResponse(generate_text(), media_type="text/plain")
@router.get("/rag") @router.get(
async def debug_rag(query: str): "/rag",
summary="调试 RAG 知识库检索",
description=(
"对知识库执行一次检索,返回原始检索上下文内容,用于验证 RAG 效果。\n\n"
"⚠️ 仅用于本地开发调试,无鉴权。"
),
)
async def debug_rag(query: str = Query(..., description="检索问题")):
if not query: if not query:
return {"error": "请提供 query 参数"} return {"error": "请提供 query 参数"}

View File

@ -0,0 +1,42 @@
"""
POST /api/session/history 存储历史对话上下文 java-mock 内部调用
"""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from security.internal_auth import verify_internal_request
from services.session_store import save_room_history
router = APIRouter(tags=["会话历史"])
class HistoryMessage(BaseModel):
role: str = Field(..., description="消息角色:`user` 或 `assistant`")
content: str = Field(..., description="消息内容")
class SetHistoryRequest(BaseModel):
room_id: str = Field(..., description="RTC 房间 ID与 getScenes 返回的 rtc.RoomId 一致)")
messages: list[HistoryMessage] = Field(..., description="历史消息列表")
@router.post(
"/api/session/history",
summary="写入房间历史上下文",
description=(
"在 `StartVoiceChat` 之前调用,将历史对话注入该房间的上下文缓存。\n\n"
"后续 `/api/chat_callback` 调用时会自动将缓存的历史 prepend 到每次 LLM 请求的 messages 前,"
"实现多轮对话的上下文延续。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
),
responses={
401: {"description": "内部签名校验失败"},
},
)
async def set_history(request: Request, body: SetHistoryRequest):
if not verify_internal_request(request.headers):
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
save_room_history(body.room_id, [m.model_dump() for m in body.messages])
return JSONResponse({"code": 200})

View File

@ -3,30 +3,55 @@ POST /proxy — RTC OpenAPI 代理(含请求签名)
""" """
import httpx import httpx
from fastapi import APIRouter, Request from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from config.custom_scene import get_rtc_openapi_version from config.custom_scene import get_rtc_openapi_version
from security.internal_auth import verify_internal_request
from security.signer import Signer from security.signer import Signer
from services.scene_service import Scenes, prepare_scene_runtime from services.scene_service import Scenes, prepare_scene_runtime
from services.session_store import load_session from services.session_store import clear_room_history, get_room_history, load_session
from utils.responses import error_response from utils.responses import error_response
from utils.validation import assert_scene_value, assert_value from utils.validation import assert_scene_value, assert_value
router = APIRouter() router = APIRouter(tags=["RTC 代理"])
@router.post("/proxy") class ProxyRequest(BaseModel):
async def proxy(request: Request): SceneID: str = Field(..., description="场景 ID从 getScenes 返回的 scene.id 获取)")
action = request.query_params.get("Action", "")
version = request.query_params.get("Version") or get_rtc_openapi_version()
@router.post(
"/proxy",
summary="开始 / 停止语音对话",
description=(
"带 SigV4 签名转发到火山引擎 RTC OpenAPI。\n\n"
"- `Action=StartVoiceChat`:从 Session 取回 getScenes 分配的 RoomId/TaskId"
"自动注入后启动对话。\n"
"- `Action=StopVoiceChat`:停止对话并清除该房间的历史上下文缓存。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
),
responses={
401: {"description": "内部签名校验失败"},
400: {"description": "参数缺失或场景配置不存在"},
},
)
async def proxy(
request: Request,
body: ProxyRequest,
action: str = Query(..., alias="Action", description="操作类型:`StartVoiceChat` 或 `StopVoiceChat`"),
version: str | None = Query(None, alias="Version", description="火山引擎 OpenAPI 版本,不传时取配置文件默认值"),
):
if not verify_internal_request(request.headers):
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
version = version or get_rtc_openapi_version()
scene_id = body.SceneID
try: try:
assert_value(action, "Action 不能为空") assert_value(action, "Action 不能为空")
assert_value(version, "Version 不能为空") assert_value(version, "Version 不能为空")
body = await request.json()
scene_id = body.get("SceneID", "")
assert_value(scene_id, "SceneID 不能为空SceneID 用于指定场景配置") assert_value(scene_id, "SceneID 不能为空SceneID 用于指定场景配置")
json_data = Scenes.get(scene_id) json_data = Scenes.get(scene_id)
@ -56,6 +81,14 @@ async def proxy(request: Request):
target_user_ids[0] = sess["UserId"] target_user_ids[0] = sess["UserId"]
else: else:
agent_config["TargetUserId"] = [sess["UserId"]] agent_config["TargetUserId"] = [sess["UserId"]]
# 将 room_id 追加到 LLM 回调 URL使 chat_callback 能关联到历史
room_id = voice_chat.get("RoomId", "")
if room_id:
llm_config = voice_chat.get("Config", {}).get("LLMConfig", {})
llm_url = llm_config.get("Url", "")
if llm_url:
sep = "&" if "?" in llm_url else "?"
llm_config["Url"] = f"{llm_url}{sep}room_id={room_id}"
req_body = voice_chat req_body = voice_chat
elif action == "StopVoiceChat": elif action == "StopVoiceChat":
app_id = voice_chat.get("AppId", "") app_id = voice_chat.get("AppId", "")
@ -65,6 +98,8 @@ async def proxy(request: Request):
assert_scene_value(scene_id, "VoiceChat.AppId", app_id) assert_scene_value(scene_id, "VoiceChat.AppId", app_id)
assert_scene_value(scene_id, "VoiceChat.RoomId", room_id) assert_scene_value(scene_id, "VoiceChat.RoomId", room_id)
assert_scene_value(scene_id, "VoiceChat.TaskId", task_id) assert_scene_value(scene_id, "VoiceChat.TaskId", task_id)
# 清除该房间的历史上下文
clear_room_history(room_id)
req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id} req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id}
else: else:
req_body = {} req_body = {}

View File

@ -5,14 +5,30 @@ POST /getScenes — 场景列表
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from security.internal_auth import verify_internal_request
from services.scene_service import Scenes, prepare_scene_runtime from services.scene_service import Scenes, prepare_scene_runtime
from services.session_store import save_session from services.session_store import save_session
router = APIRouter() router = APIRouter(tags=["场景"])
@router.post("/getScenes") @router.post(
"/getScenes",
summary="获取场景列表",
description=(
"返回所有已配置场景的信息及对应的 RTC 房间参数AppId / RoomId / Token 等)。\n\n"
"调用后会将本次生成的 `RoomId / UserId / TaskId` 写入 Session"
"供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
),
responses={
401: {"description": "内部签名校验失败"},
},
)
async def get_scenes(request: Request): async def get_scenes(request: Request):
if not verify_internal_request(request.headers):
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
try: try:
scenes_list = [] scenes_list = []
for scene_name, data in Scenes.items(): for scene_name, data in Scenes.items():

View File

@ -0,0 +1,52 @@
"""
内部服务鉴权验证来自 java-mock 的请求签名
签名算法 java-mock/middleware/internalSign.js 保持一致
message = "java-gateway:{userId}:{毫秒时间戳}"
signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) # hex
"""
import hashlib
import hmac
import os
import time
# 允许的时钟偏差(毫秒),防重放攻击
_ALLOWED_SKEW_MS = 5 * 60 * 1000 # 5 分钟
def verify_internal_request(headers) -> bool:
"""
验证内部服务请求签名
未配置 INTERNAL_SERVICE_SECRET 时直接放行开发/测试环境兼容
"""
secret = os.environ.get("INTERNAL_SERVICE_SECRET", "")
if not secret:
return True
service = headers.get("X-Internal-Service", "")
user_id = headers.get("X-Internal-User-Id", "")
timestamp = headers.get("X-Internal-Timestamp", "")
signature = headers.get("X-Internal-Signature", "")
# 基础字段校验
if service != "java-gateway" or not user_id or not timestamp or not signature:
return False
# 时间窗口校验(防重放)
try:
ts_ms = int(timestamp)
if abs(int(time.time() * 1000) - ts_ms) > _ALLOWED_SKEW_MS:
return False
except ValueError:
return False
# 重新计算签名,使用常量时间比较防时序攻击
message = f"java-gateway:{user_id}:{timestamp}"
expected = hmac.new(
secret.encode("utf-8"),
message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)

View File

@ -1,4 +1,5 @@
import logging import logging
import os
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@ -6,8 +7,10 @@ from dotenv import load_dotenv
BASE_DIR = Path(__file__).parent BASE_DIR = Path(__file__).parent
load_dotenv(BASE_DIR / ".env", override=False) load_dotenv(BASE_DIR / ".env", override=False)
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=getattr(logging, LOG_LEVEL, logging.DEBUG),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S", datefmt="%H:%M:%S",
) )
@ -18,15 +21,28 @@ logging.getLogger("openai").setLevel(logging.WARNING)
logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn").setLevel(logging.INFO)
# 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量 # 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量
from routes.chat_callback import router as chat_callback_router # noqa: E402 from routes.v1.chat_callback import router as chat_callback_router # noqa: E402
from routes.debug import router as debug_router # noqa: E402 from routes.v1.debug import router as debug_router # noqa: E402
from routes.proxy import router as proxy_router # noqa: E402 from routes.v1.history import router as history_router # noqa: E402
from routes.scenes import router as scenes_router # noqa: E402 from routes.v1.proxy import router as proxy_router # noqa: E402
from routes.v1.scenes import router as scenes_router # noqa: E402
from fastapi import FastAPI # noqa: E402 from fastapi import FastAPI # noqa: E402
from fastapi.middleware.cors import CORSMiddleware # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402
app = FastAPI() app = FastAPI(
title="RTC AIGC 后端",
description=(
"火山引擎 RTC AI 语音对话后端服务。\n\n"
"## 鉴权说明\n"
"- **内部接口**`/getScenes` `/proxy` `/api/session/history`):由 java-mock 网关调用,"
"需附加 `X-Internal-Signature` HMAC 签名 Header。\n"
"- **LLM 回调**`/api/chat_callback`):由火山引擎 RTC 平台回调,"
"需在 `Authorization: Bearer <key>` 中携带 `CUSTOM_LLM_API_KEY`。\n"
"- **调试接口**`/debug/*`):无鉴权,仅用于本地开发。"
),
version="1.0.0",
)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@ -35,10 +51,17 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
app.include_router(proxy_router)
app.include_router(scenes_router) @app.get("/health", include_in_schema=False)
app.include_router(chat_callback_router) async def health():
app.include_router(debug_router) return {"status": "ok"}
app.include_router(proxy_router, prefix="/v1")
app.include_router(scenes_router, prefix="/v1")
app.include_router(chat_callback_router, prefix="/v1")
app.include_router(debug_router, prefix="/v1")
app.include_router(history_router, prefix="/v1")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn

View File

@ -12,6 +12,9 @@ from fastapi import Request
# { session_key: { scene_id: { RoomId, UserId, TaskId } } } # { session_key: { scene_id: { RoomId, UserId, TaskId } } }
_store: dict[str, dict[str, dict]] = {} _store: dict[str, dict[str, dict]] = {}
# { room_id: [{ role, content }] }
_history_store: dict[str, list[dict]] = {}
def _key(request: Request) -> str: def _key(request: Request) -> str:
forwarded = request.headers.get("X-Forwarded-For") forwarded = request.headers.get("X-Forwarded-For")
@ -33,3 +36,15 @@ def save_session(request: Request, scene_id: str, data: dict) -> None:
def load_session(request: Request, scene_id: str) -> dict: def load_session(request: Request, scene_id: str) -> dict:
return _store.get(_key(request), {}).get(scene_id, {}) return _store.get(_key(request), {}).get(scene_id, {})
def save_room_history(room_id: str, messages: list[dict]) -> None:
_history_store[room_id] = messages
def get_room_history(room_id: str) -> list[dict]:
return _history_store.get(room_id, [])
def clear_room_history(room_id: str) -> None:
_history_store.pop(room_id, None)

5
java-mock/.env.example Normal file
View File

@ -0,0 +1,5 @@
PORT=8080
JWT_SECRET=mock-jwt-secret
JWT_EXPIRES_IN=7d
PYTHON_BACKEND_URL=http://localhost:3001/v1
INTERNAL_SERVICE_SECRET=mock-internal-secret

755
java-mock/API-FULL.md Normal file
View File

@ -0,0 +1,755 @@
# java-mock 接口文档 & 数据设计
> Base URL: `http://localhost:8080`
>
> 认证方式: JWT Bearer Token除登录/注册外,所有接口均需在请求头附加 `Authorization: Bearer <token>`
---
## 目录
- [接口总览](#接口总览)
- [一、认证接口](#一认证接口)
- [1.1 登录](#11-登录)
- [1.2 注册](#12-注册)
- [1.3 获取当前用户信息](#13-获取当前用户信息)
- [二、AI 代理接口(转发 Python](#二ai-代理接口转发-python)
- [2.1 获取场景列表](#21-获取场景列表)
- [2.2 开始语音对话](#22-开始语音对话)
- [2.3 停止语音对话](#23-停止语音对话)
- [2.4 写入历史上下文](#24-写入历史上下文)
- [三、对话记录接口](#三对话记录接口)
- [3.1 保存对话](#31-保存对话)
- [3.2 对话列表(分页)](#32-对话列表分页)
- [3.3 对话详情](#33-对话详情)
- [3.4 追加消息](#34-追加消息)
- [3.5 删除对话](#35-删除对话)
- [四、其他](#四其他)
- [4.1 健康检查](#41-健康检查)
- [数据结构设计](#数据结构设计)
- [User用户表](#user用户表)
- [Conversation对话表](#conversation对话表)
- [Message消息子文档](#message消息子文档)
- [内部转发签名协议](#内部转发签名协议)
- [通用错误响应](#通用错误响应)
- [环境变量](#环境变量)
---
## 接口总览
| 方法 | 路径 | 是否需要 Token | 说明 |
|---|---|---|---|
| GET | `/health` | 否 | 健康检查 |
| POST | `/api/auth/login` | 否 | 登录 |
| POST | `/api/auth/register` | 否 | 注册 |
| GET | `/api/auth/me` | ✅ | 获取当前用户信息 |
| POST | `/api/ai/getScenes` | ✅ | 获取场景列表(转发 Python |
| POST | `/api/ai/proxy?Action=StartVoiceChat` | ✅ | 开始语音对话(转发 Python |
| POST | `/api/ai/proxy?Action=StopVoiceChat` | ✅ | 停止语音对话(转发 Python |
| POST | `/api/ai/session/history` | ✅ | 写入历史上下文(转发 Python |
| POST | `/api/ai/conversations` | ✅ | 保存对话记录 |
| GET | `/api/ai/conversations` | ✅ | 对话列表(分页) |
| GET | `/api/ai/conversations/:id` | ✅ | 对话详情 |
| POST | `/api/ai/conversations/:id/append` | ✅ | 追加消息到对话 |
| DELETE | `/api/ai/conversations/:id` | ✅ | 删除对话 |
---
## 一、认证接口
### 1.1 登录
```
POST /api/auth/login
```
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| username | string | ✅ | 用户名 |
| password | string | ✅ | 密码(明文) |
**请求示例:**
```json
{
"username": "admin",
"password": "admin123"
}
```
**成功响应 200**
```json
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"name": "管理员",
"sex": "male",
"isDriver": false,
"deptId": 1,
"deptName": "办公室",
"roleList": ["admin", "user"]
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 400 | 400 | 用户名和密码不能为空 |
| 401 | 401 | 用户名或密码错误 |
---
### 1.2 注册
```
POST /api/auth/register
```
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| username | string | ✅ | 用户名(唯一) |
| password | string | ✅ | 密码(明文) |
| nickname | string | ❌ | 昵称,不传时默认等于 username |
**请求示例:**
```json
{
"username": "newuser",
"password": "pass123",
"nickname": "新用户"
}
```
**成功响应 200**
```json
{
"code": 200,
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"name": "新用户",
"sex": "unknown",
"isDriver": false,
"deptId": 0,
"deptName": "",
"roleList": ["user"]
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 400 | 400 | 用户名和密码不能为空 |
| 409 | 409 | 用户名已存在 |
> 注册成功后新用户默认属性:`sex=unknown`、`isDriver=false`、`deptId=0`、`roleList=["user"]`
---
### 1.3 获取当前用户信息
```
GET /api/auth/me
```
**成功响应 200**
```json
{
"code": 200,
"data": {
"name": "管理员",
"sex": "male",
"isDriver": false,
"deptId": 1,
"deptName": "办公室",
"roleList": ["admin", "user"]
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 401 | 401 | 未提供 Authorization Token |
| 401 | 401 | Token 无效或已过期 |
| 404 | 404 | 用户不存在 |
---
## 二、AI 代理接口(转发 Python
> 所有 AI 代理接口均带 HMAC-SHA256 内部签名后转发至 Python 后端(`PYTHON_BACKEND_URL`),响应内容原样透传。
>
> 详细签名规则见 [内部转发签名协议](#内部转发签名协议)。
---
### 2.1 获取场景列表
```
POST /api/ai/getScenes
```
**请求体:** `{}` 或空
**成功响应Python 原样返回):**
```json
{
"ResponseMetadata": {
"Action": "getScenes"
},
"Result": {
"scenes": [
{
"scene": {
"id": "Custom",
"botName": "BotUser001",
"isInterruptMode": true,
"isVision": false,
"isScreenMode": false,
"isAvatarScene": false,
"avatarBgUrl": null
},
"rtc": {
"AppId": "6xxxxxxx",
"RoomId": "room-abc123",
"UserId": "user-xyz",
"Token": "AQBhMGI3Zm...",
"TaskId": "task-001"
}
}
]
}
}
```
---
### 2.2 开始语音对话
```
POST /api/ai/proxy?Action=StartVoiceChat
```
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| SceneID | string | ✅ | 场景 ID从 getScenes 获取) |
**请求示例:**
```json
{
"SceneID": "Custom"
}
```
**成功响应Python 原样返回):**
```json
{
"ResponseMetadata": {
"RequestId": "2024xxxxxxxxxx",
"Action": "StartVoiceChat",
"Version": "2024-12-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
---
### 2.3 停止语音对话
```
POST /api/ai/proxy?Action=StopVoiceChat
```
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| SceneID | string | ✅ | 场景 ID |
**请求示例:**
```json
{
"SceneID": "Custom"
}
```
**成功响应Python 原样返回):**
```json
{
"ResponseMetadata": {
"RequestId": "2024xxxxxxxxxx",
"Action": "StopVoiceChat",
"Version": "2024-12-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
---
### 2.4 写入历史上下文
> 在 StartVoiceChat 之前调用,将历史对话上下文注入 Python Session以便 AI 延续上下文。
```
POST /api/ai/session/history
```
**请求体JSON**
转发到 Python `/api/session/history`,具体字段由 Python 接口定义,典型示例:
```json
{
"roomId": "room-abc123",
"history": [
{ "role": "user", "content": "你好" },
{ "role": "assistant", "content": "你好!有什么可以帮你?" }
]
}
```
**成功响应:** Python 原样返回
---
## 三、对话记录接口
### 3.1 保存对话
```
POST /api/ai/conversations
```
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| sceneId | string | ✅ | 场景 ID |
| roomId | string | ❌ | RTC 房间 ID |
| messages | array | ✅ | 消息数组(见下表) |
**messages 数组每条消息字段:**
| 字段 | 类型 | 说明 |
|---|---|---|
| role | string | `"user"``"assistant"`(优先使用) |
| content | string | 消息文本 |
| time | string | ISO 时间戳,不传取服务器当前时间 |
| userId | string | 兼容旧格式。与当前登录用户 ID 相同则视为 `user`,否则视为 `assistant` |
| text | string | 兼容旧格式,与 `content` 二选一 |
**请求示例:**
```json
{
"sceneId": "Custom",
"roomId": "room-abc123",
"messages": [
{ "role": "assistant", "content": "你好,我是小块", "time": "2026-04-02T10:00:00Z" },
{ "role": "user", "content": "今天出勤情况咋样", "time": "2026-04-02T10:00:05Z" },
{ "role": "assistant", "content": "今天出勤率 90%", "time": "2026-04-02T10:00:08Z" }
]
}
```
**成功响应 200**
```json
{
"code": 200,
"data": {
"sessionId": "9864c5ef-cdee-4c35-8f6a-81a9a4f6d323"
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 400 | 400 | sceneId 和 messages 不能为空 |
---
### 3.2 对话列表(分页)
```
GET /api/ai/conversations?page=1&size=20
```
**Query 参数:**
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| page | number | 1 | 页码(从 1 开始) |
| size | number | 20 | 每页条数(上限 100 |
**成功响应 200**
```json
{
"code": 200,
"data": {
"total": 5,
"page": 1,
"size": 20,
"list": [
{
"id": "9864c5ef-cdee-4c35-8f6a-81a9a4f6d323",
"sceneId": "Custom",
"roomId": "room-abc123",
"startedAt": "2026-04-02T10:00:00Z",
"endedAt": "2026-04-02T10:00:08Z",
"messageCount": 3,
"firstMessage": "今天出勤情况咋样"
}
]
}
}
```
> - 列表按 `createdAt` **倒序**排列
> - `firstMessage` 取第一条 `role === "user"` 的消息内容,无用户消息时为空串
> - 仅返回**当前登录用户**的对话记录
---
### 3.3 对话详情
```
GET /api/ai/conversations/:id
```
**Path 参数:**
| 字段 | 说明 |
|---|---|
| id | 对话 UUID |
**成功响应 200**
```json
{
"code": 200,
"data": {
"id": "9864c5ef-cdee-4c35-8f6a-81a9a4f6d323",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "room-abc123",
"startedAt": "2026-04-02T10:00:00Z",
"endedAt": "2026-04-02T10:00:08Z",
"createdAt": "2026-04-02T10:00:10Z",
"messages": [
{ "role": "assistant", "content": "你好,我是小块", "createdAt": "2026-04-02T10:00:00Z" },
{ "role": "user", "content": "今天出勤情况咋样", "createdAt": "2026-04-02T10:00:05Z" },
{ "role": "assistant", "content": "今天出勤率 90%", "createdAt": "2026-04-02T10:00:08Z" }
]
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 404 | 404 | 对话记录不存在 |
| 403 | 403 | 无权访问该对话 |
---
### 3.4 追加消息
> 对话保存后如需继续追加消息(例如语音会话延续),调用此接口。
```
POST /api/ai/conversations/:id/append
```
**Path 参数:**
| 字段 | 说明 |
|---|---|
| id | 对话 UUID |
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| messages | array | ✅ | 追加的消息数组,格式同 3.1 |
**请求示例:**
```json
{
"messages": [
{ "role": "user", "content": "那明天呢", "time": "2026-04-02T10:01:00Z" },
{ "role": "assistant", "content": "明天是周末,无需考勤", "time": "2026-04-02T10:01:03Z" }
]
}
```
**成功响应 200**
```json
{
"code": 200,
"data": {
"sessionId": "9864c5ef-cdee-4c35-8f6a-81a9a4f6d323"
}
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 400 | 400 | messages 不能为空 |
| 404 | 404 | 对话记录不存在 |
| 403 | 403 | 无权操作该对话 |
---
### 3.5 删除对话
```
DELETE /api/ai/conversations/:id
```
**Path 参数:**
| 字段 | 说明 |
|---|---|
| id | 对话 UUID |
**成功响应 200**
```json
{
"code": 200,
"data": null
}
```
**失败响应:**
| HTTP 状态码 | code | message |
|---|---|---|
| 404 | 404 | 对话记录不存在 |
| 403 | 403 | 无权删除该对话 |
---
## 四、其他
### 4.1 健康检查
```
GET /health
```
**响应(无需 Token**
```json
{
"status": "ok",
"service": "java-mock",
"timestamp": "2026-04-02T10:00:00.000Z"
}
```
---
## 数据结构设计
### User用户表
> 文件存储路径:`data/users.json`(数组)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | ✅ | 唯一 ID格式 `user-<uuid>``user-admin-001`(内置账号) |
| username | string | ✅ | 登录用户名,全局唯一 |
| password | string | ✅ | 明文密码Mock 环境,生产请改用 bcrypt 哈希) |
| name | string | ✅ | 显示名 / 昵称 |
| sex | string | ✅ | 性别:`"male"` / `"female"` / `"unknown"` |
| isDriver | boolean | ✅ | 是否为司机角色 |
| deptId | number | ✅ | 部门 ID`0` 表示无部门 |
| deptName | string | ✅ | 部门名称 |
| roleList | string[] | ✅ | 角色列表,如 `["admin","user"]` / `["user"]` |
| createdAt | string | ✅ | 创建时间 ISO8601 |
**示例数据:**
```json
[
{
"id": "user-admin-001",
"username": "admin",
"password": "admin123",
"name": "管理员",
"sex": "male",
"isDriver": false,
"deptId": 1,
"deptName": "办公室",
"roleList": ["admin", "user"],
"createdAt": "2026-01-01T00:00:00.000Z"
},
{
"id": "user-001",
"username": "user1",
"password": "user123",
"name": "测试用户",
"sex": "female",
"isDriver": false,
"deptId": 2,
"deptName": "工程部",
"roleList": ["user"],
"createdAt": "2026-01-01T00:00:00.000Z"
}
]
```
---
### Conversation对话表
> 文件存储路径:`data/conversations.json`(数组)
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| id | string | ✅ | UUID v4全局唯一 |
| userId | string | ✅ | 所属用户 ID关联 User.id |
| sceneId | string | ✅ | 场景 ID`"Custom"` |
| roomId | string | ✅ | RTC 房间 ID无则为空串 |
| startedAt | string | ✅ | 对话开始时间(取第一条消息 `createdAt` |
| endedAt | string | ✅ | 对话结束时间(取最后一条消息 `createdAt` |
| createdAt | string | ✅ | 记录入库时间 |
| messages | Message[] | ✅ | 消息数组(见 Message 结构) |
**示例数据:**
```json
{
"id": "85ce1273-7279-44fb-b018-3e49295c89f7",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "4fea87af-5b23-445d-9754-d4d878a1c705",
"startedAt": "2026-04-02T05:06:57.828Z",
"endedAt": "2026-04-02T05:07:18.750Z",
"createdAt": "2026-04-02T05:07:28.729Z",
"messages": [...]
}
```
---
### Message消息子文档
> 嵌套在 Conversation.messages 数组中
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| role | string | ✅ | `"user"`(用户说)或 `"assistant"`AI 说) |
| content | string | ✅ | 消息文本内容 |
| createdAt | string | ✅ | 消息时间 ISO8601 |
**示例:**
```json
[
{ "role": "assistant", "content": "你好,我是小块,有什么需要帮忙的吗?", "createdAt": "2026-04-02T05:06:57.828Z" },
{ "role": "user", "content": "今天办公室出勤情况咋样", "createdAt": "2026-04-02T05:07:12.023Z" },
{ "role": "assistant", "content": "今天办公室一共九个人,出勤率 90%。", "createdAt": "2026-04-02T05:07:18.750Z" }
]
```
---
## 内部转发签名协议
> 转发到 Python 时java-mock 会附加以下请求头Python 侧需验证签名合法性。
| Header | 说明 |
|---|---|
| `X-Internal-Service` | 固定值 `java-gateway` |
| `X-Internal-User-Id` | 当前登录用户 ID |
| `X-Internal-Timestamp` | 毫秒级 Unix 时间戳(字符串) |
| `X-Internal-Signature` | HMAC-SHA256 签名,见下方算法 |
| `X-User-Name` | URL 编码的用户显示名 |
| `X-User-Sex` | 性别字符串 |
| `X-User-Is-Driver` | `"true"``"false"` |
| `X-User-Dept-Id` | 部门 ID 字符串 |
| `X-User-Dept-Name` | URL 编码的部门名称 |
| `X-User-Role-List` | URL 编码的 JSON 数组字符串,如 `%5B%22admin%22%5D` |
**签名算法:**
```
message = "java-gateway:{userId}:{timestamp}"
signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) // hex 输出
```
---
## 通用错误响应
所有接口在出错时均返回如下结构:
```json
{
"code": <HTTP状态码>,
"message": "错误描述"
}
```
| code | 含义 |
|---|---|
| 400 | 请求参数缺失或格式错误 |
| 401 | 未登录或 Token 无效 / 过期 |
| 403 | 无权限访问该资源 |
| 404 | 资源不存在 |
| 409 | 资源冲突(如用户名重复) |
| 502 | 无法连接 Python 后端 |
| 500 | 服务器内部错误 |
---
## 环境变量
| 变量名 | 默认值 | 说明 |
|---|---|---|
| `PORT` | `8080` | 服务监听端口 |
| `JWT_SECRET` | — | JWT 签名密钥(**必填** |
| `JWT_EXPIRES_IN` | `7d` | JWT 过期时间 |
| `PYTHON_BACKEND_URL` | `http://localhost:3001` | Python 后端地址 |
| `INTERNAL_SERVICE_SECRET` | — | 内部签名密钥(**必填** |

View File

@ -0,0 +1,579 @@
[
{
"id": "85ce1273-7279-44fb-b018-3e49295c89f7",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "4fea87af-5b23-445d-9754-d4d878a1c705",
"startedAt": "2026-04-02T05:06:57.828Z",
"endedAt": "2026-04-02T05:07:18.750Z",
"createdAt": "2026-04-02T05:07:28.729Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:06:57.828Z"
},
{
"role": "user",
"content": "你在干嘛呢",
"createdAt": "2026-04-02T05:07:12.023Z"
},
{
"role": "assistant",
"content": "我在待命呢,你有什么考勤相关的问题要问我吗?",
"createdAt": "2026-04-02T05:07:18.750Z"
}
]
},
{
"id": "82591451-4cb7-49da-8a6b-3a778e5a53ac",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "b9a98b65-dba8-42a5-8bab-10c6e54e6026",
"startedAt": "2026-04-02T05:07:48.276Z",
"endedAt": "2026-04-02T05:07:48.276Z",
"createdAt": "2026-04-02T05:17:22.669Z",
"messages": [
{
"role": "assistant",
"content": "那你现在有什么考勤相关的问题需要我帮忙吗?",
"createdAt": "2026-04-02T05:07:48.276Z"
}
]
},
{
"id": "9a9d19aa-8513-428b-aa79-b5078b57f10f",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "d71d9111-351d-4c13-ab67-c05c7afadcd6",
"startedAt": "2026-04-02T05:18:39.702Z",
"endedAt": "2026-04-02T05:19:40.002Z",
"createdAt": "2026-04-02T05:19:43.601Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:18:39.702Z"
},
{
"role": "user",
"content": "你在干嘛呢",
"createdAt": "2026-04-02T05:18:46.045Z"
},
{
"role": "assistant",
"content": "我正等着帮你解决考勤相关的问题呢,你有什么想问的吗?",
"createdAt": "2026-04-02T05:18:54.562Z"
},
{
"role": "user",
"content": "今天办公室出勤情况咋样",
"createdAt": "2026-04-02T05:19:27.710Z"
},
{
"role": "assistant",
"content": "今天办公室一共九个人,目前还没有人打卡,出勤率是百分之零哦。",
"createdAt": "2026-04-02T05:19:40.002Z"
}
]
},
{
"id": "3b9e5bcf-1fc5-4c96-87e1-4bbdc360ba6d",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "001aa745-16bc-4655-88db-ef4c89c5a46c",
"startedAt": "2026-04-02T05:19:54.043Z",
"endedAt": "2026-04-02T05:32:48.820Z",
"createdAt": "2026-04-02T05:21:06.517Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:19:54.043Z"
},
{
"role": "user",
"content": "今天办公室出勤情况咋样",
"createdAt": "2026-04-02T05:20:11.495Z"
},
{
"role": "assistant",
"content": "今天办公室总共有九个人,目前还没有人完成打卡,出勤率是百分之零,大家都还没到岗呢。",
"createdAt": "2026-04-02T05:20:27.844Z"
},
{
"role": "user",
"content": "我问了你几次出勤问题",
"createdAt": "2026-04-02T05:20:47.562Z"
},
{
"role": "assistant",
"content": "你一共问了我两次今天办公室出勤的问题,现在还需要帮你查询别的考勤信息吗。",
"createdAt": "2026-04-02T05:21:05.174Z"
},
{
"role": "assistant",
"content": "你好,我是小块,有什么",
"createdAt": "2026-04-02T05:32:05.314Z"
},
{
"role": "user",
"content": "你好",
"createdAt": "2026-04-02T05:32:05.320Z"
},
{
"role": "assistant",
"content": "你好,我是小块,有什么需要",
"createdAt": "2026-04-02T05:32:05.494Z"
},
{
"role": "assistant",
"content": "你好呀,请问你有什么考勤相关",
"createdAt": "2026-04-02T05:32:11.112Z"
},
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:32:21.385Z"
},
{
"role": "user",
"content": "我一共问了你几个问题",
"createdAt": "2026-04-02T05:32:23.307Z"
},
{
"role": "assistant",
"content": "你到现在一共问了我三个问题哦,其中两个都是和出勤相关的。",
"createdAt": "2026-04-02T05:32:46.712Z"
},
{
"role": "assistant",
"content": "还有别的什么需要我帮忙的吗?",
"createdAt": "2026-04-02T05:32:48.820Z"
}
]
},
{
"id": "eaae0d2f-315f-4be5-bcf8-bdda985c8639",
"userId": "user-001",
"sceneId": "Custom",
"roomId": "cef3cea7-0019-46cd-ae08-a617e369dfc3",
"startedAt": "2026-04-02T05:24:29.794Z",
"endedAt": "2026-04-02T05:24:50.055Z",
"createdAt": "2026-04-02T05:24:51.357Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:24:29.794Z"
},
{
"role": "user",
"content": "今天天气怎么样子",
"createdAt": "2026-04-02T05:24:39.212Z"
},
{
"role": "assistant",
"content": "请问你想查询哪个城市的天气呢?",
"createdAt": "2026-04-02T05:24:47.455Z"
},
{
"role": "assistant",
"content": "告诉我城市名称我就能帮你查啦。",
"createdAt": "2026-04-02T05:24:50.055Z"
}
]
},
{
"id": "120c1027-0c39-4c4c-8005-795ba474224c",
"userId": "user-001",
"sceneId": "Custom",
"roomId": "b8ae0dab-a6ee-4441-a11b-1e91a934a41c",
"startedAt": "2026-04-02T05:25:03.034Z",
"endedAt": "2026-04-02T05:25:19.794Z",
"createdAt": "2026-04-02T05:25:30.389Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:25:03.034Z"
},
{
"role": "user",
"content": "深圳",
"createdAt": "2026-04-02T05:25:09.560Z"
},
{
"role": "assistant",
"content": "深圳今天是多云天气,气温二十六摄氏度哦。",
"createdAt": "2026-04-02T05:25:19.794Z"
}
]
},
{
"id": "b01017b1-27af-41cb-9010-cbcd7fcd19b3",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "edc6cfb5-044e-436a-aafe-a90483cfe76e",
"startedAt": "2026-04-02T05:53:50.483Z",
"endedAt": "2026-04-02T05:54:41.168Z",
"createdAt": "2026-04-02T05:54:32.075Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:53:50.483Z"
},
{
"role": "user",
"content": "你的主人是谁",
"createdAt": "2026-04-02T05:54:00.792Z"
},
{
"role": "assistant",
"content": "这个我帮不上忙。",
"createdAt": "2026-04-02T05:54:06.444Z"
},
{
"role": "user",
"content": "今天上海天气怎么样",
"createdAt": "2026-04-02T05:54:19.920Z"
},
{
"role": "assistant",
"content": "今天上海是晴天,气温二十四度,天气很不错哦。",
"createdAt": "2026-04-02T05:54:31.724Z"
},
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T05:54:41.168Z"
}
]
},
{
"id": "800f3111-3bc3-488b-bf51-4a88d641924f",
"userId": "user-001",
"sceneId": "Custom",
"roomId": "66a87c72-4a29-4de3-b64c-13cc4958bf7d",
"startedAt": "2026-04-02T06:14:52.039Z",
"endedAt": "2026-04-02T06:15:06.919Z",
"createdAt": "2026-04-02T06:15:07.157Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T06:14:52.039Z"
},
{
"role": "user",
"content": "1qeqwe",
"createdAt": "2026-04-02T06:14:57.806Z"
},
{
"role": "assistant",
"content": "暂时查不到,稍后再",
"createdAt": "2026-04-02T06:15:06.919Z"
}
]
},
{
"id": "22dca127-069b-43bd-a20c-712279fbada5",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "97f31b2c-e0c2-4c07-97ed-66314b3c65bb",
"startedAt": "2026-04-02T06:33:49.533Z",
"endedAt": "2026-04-02T06:34:01.632Z",
"createdAt": "2026-04-02T06:34:01.820Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T06:33:49.533Z"
},
{
"role": "user",
"content": "你好!",
"createdAt": "2026-04-02T06:33:52.163Z"
},
{
"role": "assistant",
"content": "你好呀,请问你有什么关于考勤或者系统使",
"createdAt": "2026-04-02T06:34:01.632Z"
}
]
},
{
"id": "fdff20fd-f699-4d19-b1a7-a2f6b8ed70c4",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "5d747800-57fd-437f-ba1f-5522b8d662c8",
"startedAt": "2026-04-02T06:45:08.979Z",
"endedAt": "2026-04-02T06:45:31.819Z",
"createdAt": "2026-04-02T06:47:15.994Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T06:45:08.979Z"
},
{
"role": "user",
"content": "嗯",
"createdAt": "2026-04-02T06:45:09.217Z"
},
{
"role": "assistant",
"content": "请问你具体想问什么问题呀,是查询考勤信息还是了解系统操作呢?",
"createdAt": "2026-04-02T06:45:19.127Z"
},
{
"role": "user",
"content": "你在干嘛",
"createdAt": "2026-04-02T06:45:23.199Z"
},
{
"role": "assistant",
"content": "这个我帮不上忙。",
"createdAt": "2026-04-02T06:45:31.819Z"
}
]
},
{
"id": "ad54db6d-ccd4-4a55-b844-6bf3f7a6705e",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "76f14a25-9208-4263-92f6-b82b482dde2f",
"startedAt": "2026-04-02T07:51:40.354Z",
"endedAt": "2026-04-02T07:52:07.481Z",
"createdAt": "2026-04-02T07:52:07.742Z",
"messages": [
{
"role": "user",
"content": "你好你好!",
"createdAt": "2026-04-02T07:51:40.354Z"
},
{
"role": "user",
"content": "你是谁?",
"createdAt": "2026-04-02T07:51:42.598Z"
},
{
"role": "user",
"content": "坏事?",
"createdAt": "2026-04-02T07:51:57.697Z"
},
{
"role": "user",
"content": "炸了呀。",
"createdAt": "2026-04-02T07:52:01.398Z"
},
{
"role": "user",
"content": "读文字没问题那他",
"createdAt": "2026-04-02T07:52:07.481Z"
}
]
},
{
"id": "7ab9ee57-c948-475a-be0a-d8a841e20741",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "4fc26b1c-8961-4242-a19f-0db6e4c39f88",
"startedAt": "2026-04-02T07:56:26.158Z",
"endedAt": "2026-04-02T07:56:30.894Z",
"createdAt": "2026-04-02T07:56:55.182Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T07:56:26.158Z"
},
{
"role": "user",
"content": "你好!",
"createdAt": "2026-04-02T07:56:30.894Z"
}
]
},
{
"id": "7fc21d3e-99f5-4106-b865-129d232cf74a",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "e785ceb1-c43f-422b-95e8-93eb91600eb9",
"startedAt": "2026-04-02T07:59:23.514Z",
"endedAt": "2026-04-02T07:59:25.522Z",
"createdAt": "2026-04-02T07:59:36.289Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T07:59:23.514Z"
},
{
"role": "user",
"content": "停!你不要说话!",
"createdAt": "2026-04-02T07:59:25.522Z"
}
]
},
{
"id": "7c52e10a-1b49-451d-b6f6-dacb2392c214",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "d285c07d-a07b-4870-ae0a-5ca302a2d54f",
"startedAt": "2026-04-02T08:20:54.530Z",
"endedAt": "2026-04-02T08:21:15.652Z",
"createdAt": "2026-04-02T08:21:21.415Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T08:20:54.530Z"
},
{
"role": "user",
"content": "罚你了!",
"createdAt": "2026-04-02T08:20:58.517Z"
},
{
"role": "user",
"content": "不行,我操!",
"createdAt": "2026-04-02T08:21:05.761Z"
},
{
"role": "user",
"content": "这边是后端的接口。",
"createdAt": "2026-04-02T08:21:08.449Z"
},
{
"role": "user",
"content": "哎,这个不行这个是这个,这裙子是后端的。",
"createdAt": "2026-04-02T08:21:15.652Z"
}
]
},
{
"id": "0f2f2e0f-4aa5-409c-8c70-766ee933e2ef",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "e4f360c4-f322-49cb-9c20-1c388a588e62",
"startedAt": "2026-04-02T08:35:55.433Z",
"endedAt": "2026-04-02T08:35:58.081Z",
"createdAt": "2026-04-02T08:36:31.087Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T08:35:55.433Z"
},
{
"role": "user",
"content": "你好你好,你是谁?",
"createdAt": "2026-04-02T08:35:58.081Z"
}
]
},
{
"id": "ea9fee06-f873-40a4-b618-c700c3fa71da",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "5478ee14-8166-4c0d-baa3-9f2bfd10606b",
"startedAt": "2026-04-02T08:41:02.558Z",
"endedAt": "2026-04-02T08:41:48.109Z",
"createdAt": "2026-04-02T08:41:48.705Z",
"messages": [
{
"role": "user",
"content": "操这个网!",
"createdAt": "2026-04-02T08:41:02.558Z"
},
{
"role": "assistant",
"content": "这个我帮不上忙。",
"createdAt": "2026-04-02T08:41:08.829Z"
},
{
"role": "user",
"content": "啊,可以了。",
"createdAt": "2026-04-02T08:41:13.365Z"
},
{
"role": "user",
"content": "终于行了,改了一名就行了!",
"createdAt": "2026-04-02T08:41:17.297Z"
},
{
"role": "user",
"content": "吃什么?",
"createdAt": "2026-04-02T08:41:19.485Z"
},
{
"role": "assistant",
"content": "这个我帮不上忙。",
"createdAt": "2026-04-02T08:41:23.866Z"
},
{
"role": "user",
"content": "你能帮的上什么忙?",
"createdAt": "2026-04-02T08:41:28.290Z"
},
{
"role": "assistant",
"content": "我专门帮你解答考勤打卡相关的问题,还有这个平台的系统使用说明哦。",
"createdAt": "2026-04-02T08:41:46.408Z"
},
{
"role": "assistant",
"content": "你要是问各部门的出",
"createdAt": "2026-04-02T08:41:48.109Z"
}
]
},
{
"id": "61c5749e-429a-4492-9fbc-9836b560e643",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "016af142-1327-4d7e-8245-45adc4f8c241",
"startedAt": "2026-04-02T09:31:17.053Z",
"endedAt": "2026-04-02T09:31:39.293Z",
"createdAt": "2026-04-02T09:31:43.377Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-02T09:31:17.053Z"
},
{
"role": "user",
"content": "yes。",
"createdAt": "2026-04-02T09:31:17.616Z"
},
{
"role": "user",
"content": "婷,你是谁?",
"createdAt": "2026-04-02T09:31:19.336Z"
},
{
"role": "assistant",
"content": "我是语音助手小块,专门帮你",
"createdAt": "2026-04-02T09:31:26.642Z"
},
{
"role": "user",
"content": "嗯,可以了,画完域名之后速度也变快了。",
"createdAt": "2026-04-02T09:31:29.819Z"
},
{
"role": "user",
"content": "我刚才就用那个,一会就卡死。",
"createdAt": "2026-04-02T09:31:33.130Z"
},
{
"role": "assistant",
"content": "这个我帮不上忙。",
"createdAt": "2026-04-02T09:31:39.293Z"
}
]
}
]

27
java-mock/data/users.json Normal file
View File

@ -0,0 +1,27 @@
[
{
"id": "user-admin-001",
"username": "admin",
"password": "admin123",
"name": "管理员",
"sex": "male",
"isDriver": false,
"deptId": 1,
"deptName": "办公室",
"roleList": ["admin", "user"],
"createdAt": "2026-01-01T00:00:00.000Z"
},
{
"id": "user-001",
"username": "user1",
"password": "user123",
"name": "测试用户",
"sex": "female",
"isDriver": false,
"deptId": 2,
"deptName": "工程部",
"roleList": ["user"],
"createdAt": "2026-01-01T00:00:00.000Z"
}
]

View File

@ -0,0 +1,32 @@
const jwt = require('jsonwebtoken');
/**
* JWT 验证中间件
* 验证通过后将 { userId, username } 挂载到 req.user
*/
function authMiddleware(req, res, next) {
const authHeader = req.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ code: 401, message: '未提供 Authorization Token' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
req.user = {
userId: payload.userId,
username: payload.username,
name: payload.name || '',
sex: payload.sex || '',
isDriver: payload.isDriver || false,
deptId: payload.deptId || 0,
deptName: payload.deptName || '',
roleList: payload.roleList || [],
};
next();
} catch (err) {
return res.status(401).json({ code: 401, message: 'Token 无效或已过期' });
}
}
module.exports = authMiddleware;

View File

@ -0,0 +1,28 @@
const { hmacSha256 } = require('../utils/hmac');
/**
* 生成转发到 Python 后端时所需的内部服务鉴权 Header
* @param {string} userId - 当前登录用户 ID
* @returns {Object} 需要附加到请求上的 Headers
*/
function buildInternalHeaders(userId, userInfo = {}) {
const service = 'java-gateway';
const timestamp = String(Date.now());
const message = `${service}:${userId}:${timestamp}`;
const signature = hmacSha256(process.env.INTERNAL_SERVICE_SECRET, message);
return {
'X-Internal-Service': service,
'X-Internal-User-Id': userId,
'X-Internal-Timestamp': timestamp,
'X-Internal-Signature': signature,
'X-User-Name': encodeURIComponent(userInfo.name || ''),
'X-User-Sex': userInfo.sex || '',
'X-User-Is-Driver': String(userInfo.isDriver || false),
'X-User-Dept-Id': String(userInfo.deptId || 0),
'X-User-Dept-Name': encodeURIComponent(userInfo.deptName || ''),
'X-User-Role-List': encodeURIComponent(JSON.stringify(userInfo.roleList || [])),
};
}
module.exports = { buildInternalHeaders };

1485
java-mock/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
java-mock/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "java-mock",
"version": "1.0.0",
"description": "Mock Java backend using Express — simulates JWT auth, API gateway to Python, and conversation history storage",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"axios": "^1.6.8",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.18.3",
"jsonwebtoken": "^9.0.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}

View File

@ -0,0 +1,55 @@
const express = require('express');
const axios = require('axios');
const authMiddleware = require('../middleware/auth');
const { buildInternalHeaders } = require('../middleware/internalSign');
const router = express.Router();
const PYTHON_URL = () => process.env.PYTHON_BACKEND_URL || 'http://localhost:3001';
/**
* 通用代理函数 HMAC 签名转发到 Python
*/
async function forwardToPython(pythonPath, queryString, body, userId, res, userInfo = {}) {
const url = `${PYTHON_URL()}${pythonPath}${queryString ? `?${queryString}` : ''}`;
const headers = {
'Content-Type': 'application/json',
...buildInternalHeaders(userId, userInfo),
};
console.log('[→ Python]', url);
console.log('[→ Headers]', JSON.stringify(headers, null, 2));
console.log('[→ Body]', JSON.stringify(body, null, 2));
try {
const resp = await axios.post(url, body, { headers, timeout: 30000 });
return res.status(resp.status).json(resp.data);
} catch (err) {
if (err.response) {
return res.status(err.response.status).json(err.response.data);
}
console.error('[aiProxy] 转发失败:', err.message);
return res.status(502).json({ code: 502, message: `无法连接 Python 后端: ${err.message}` });
}
}
// POST /api/ai/getScenes
router.post('/getScenes', authMiddleware, async (req, res) => {
await forwardToPython('/getScenes', 'Action=getScenes', req.body, req.user.userId, res, req.user);
});
// POST /api/ai/proxy?Action=StartVoiceChat|StopVoiceChat
router.post('/proxy', authMiddleware, async (req, res) => {
const action = req.query.Action;
if (!action) {
return res.status(400).json({ code: 400, message: 'Action 参数不能为空' });
}
await forwardToPython('/proxy', `Action=${action}`, req.body, req.user.userId, res, req.user);
});
// POST /api/ai/session/history — 存储历史对话上下文到 Pythonstart 前调用)
router.post('/session/history', authMiddleware, async (req, res) => {
await forwardToPython('/api/session/history', '', req.body, req.user.userId, res, req.user);
});
module.exports = router;

View File

@ -0,0 +1,109 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { readTable, writeTable } = require('../utils/db');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
// POST /api/auth/login
router.post('/login', (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ code: 400, message: '用户名和密码不能为空' });
}
const users = readTable('users');
const user = users.find((u) => u.username === username && u.password === password);
if (!user) {
return res.status(401).json({ code: 401, message: '用户名或密码错误' });
}
const token = jwt.sign(
{ userId: user.id, username: user.username, name: user.name, sex: user.sex, isDriver: user.isDriver, deptId: user.deptId, deptName: user.deptName, roleList: user.roleList },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
return res.json({
code: 200,
data: {
token,
name: user.name,
sex: user.sex,
isDriver: user.isDriver,
deptId: user.deptId,
deptName: user.deptName,
roleList: user.roleList,
},
});
});
// POST /api/auth/register
router.post('/register', (req, res) => {
const { username, password, nickname } = req.body || {};
if (!username || !password) {
return res.status(400).json({ code: 400, message: '用户名和密码不能为空' });
}
const users = readTable('users');
if (users.find((u) => u.username === username)) {
return res.status(409).json({ code: 409, message: '用户名已存在' });
}
const newUser = {
id: `user-${uuidv4()}`,
username,
password,
name: nickname || username,
sex: 'unknown',
isDriver: false,
deptId: 0,
deptName: '',
roleList: ['user'],
createdAt: new Date().toISOString(),
};
users.push(newUser);
writeTable('users', users);
const token = jwt.sign(
{ userId: newUser.id, username: newUser.username, name: newUser.name, sex: newUser.sex, isDriver: newUser.isDriver, deptId: newUser.deptId, deptName: newUser.deptName, roleList: newUser.roleList },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN || '7d' }
);
return res.json({
code: 200,
data: {
token,
name: newUser.name,
sex: newUser.sex,
isDriver: newUser.isDriver,
deptId: newUser.deptId,
deptName: newUser.deptName,
roleList: newUser.roleList,
},
});
});
// GET /api/auth/me
router.get('/me', authMiddleware, (req, res) => {
const users = readTable('users');
const user = users.find((u) => u.id === req.user.userId);
if (!user) {
return res.status(404).json({ code: 404, message: '用户不存在' });
}
return res.json({
code: 200,
data: {
name: user.name,
sex: user.sex,
isDriver: user.isDriver,
deptId: user.deptId,
deptName: user.deptName,
roleList: user.roleList,
},
});
});
module.exports = router;

View File

@ -0,0 +1,131 @@
const express = require('express');
const { v4: uuidv4 } = require('uuid');
const { readTable, writeTable } = require('../utils/db');
const authMiddleware = require('../middleware/auth');
const router = express.Router();
// POST /api/ai/conversations — 保存一次对话
router.post('/', authMiddleware, (req, res) => {
const { sceneId, roomId, messages } = req.body || {};
if (!sceneId || !Array.isArray(messages)) {
return res.status(400).json({ code: 400, message: 'sceneId 和 messages 不能为空' });
}
const conversations = readTable('conversations');
const now = new Date().toISOString();
// 统一存为 role/content 格式,兼容旧的 { userId, text } 格式
const formattedMessages = messages.map((m) => ({
role: (m.role === 'user' || m.role === 'assistant')
? m.role
: (m.userId === req.user.userId ? 'user' : 'assistant'),
content: m.content || m.text || '',
createdAt: m.time || now,
}));
// 推断会话开始/结束时间
const startedAt = formattedMessages[0]?.createdAt || now;
const endedAt = formattedMessages[formattedMessages.length - 1]?.createdAt || now;
const session = {
id: uuidv4(),
userId: req.user.userId,
sceneId,
roomId: roomId || '',
startedAt,
endedAt,
createdAt: now,
messages: formattedMessages,
};
conversations.push(session);
writeTable('conversations', conversations);
return res.json({ code: 200, data: { sessionId: session.id } });
});
// GET /api/ai/conversations — 分页列表(仅当前用户)
router.get('/', authMiddleware, (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const size = Math.max(1, Math.min(100, parseInt(req.query.size) || 20));
const all = readTable('conversations').filter((c) => c.userId === req.user.userId);
// 按时间倒序
all.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
const total = all.length;
const list = all.slice((page - 1) * size, page * size).map((c) => ({
id: c.id,
sceneId: c.sceneId,
roomId: c.roomId,
startedAt: c.startedAt,
endedAt: c.endedAt,
messageCount: c.messages.length,
firstMessage: c.messages.find((m) => m.role === 'user')?.content || '',
}));
return res.json({ code: 200, data: { total, page, size, list } });
});
// GET /api/ai/conversations/:id — 详情(含消息)
router.get('/:id', authMiddleware, (req, res) => {
const conversations = readTable('conversations');
const conv = conversations.find((c) => c.id === req.params.id);
if (!conv) {
return res.status(404).json({ code: 404, message: '对话记录不存在' });
}
if (conv.userId !== req.user.userId) {
return res.status(403).json({ code: 403, message: '无权访问该对话' });
}
return res.json({ code: 200, data: conv });
});
// POST /api/ai/conversations/:id/append — 继续对话追加消息
router.post('/:id/append', authMiddleware, (req, res) => {
const { messages } = req.body || {};
if (!Array.isArray(messages)) {
return res.status(400).json({ code: 400, message: 'messages 不能为空' });
}
const conversations = readTable('conversations');
const conv = conversations.find((c) => c.id === req.params.id);
if (!conv) return res.status(404).json({ code: 404, message: '对话记录不存在' });
if (conv.userId !== req.user.userId) return res.status(403).json({ code: 403, message: '无权操作该对话' });
const now = new Date().toISOString();
const newFormatted = messages.map((m) => ({
role: m.role === 'user' || m.role === 'assistant' ? m.role : 'assistant',
content: m.content || '',
createdAt: m.time || now,
}));
conv.messages.push(...newFormatted);
conv.endedAt = newFormatted[newFormatted.length - 1]?.createdAt || now;
writeTable('conversations', conversations);
return res.json({ code: 200, data: { sessionId: conv.id } });
});
// DELETE /api/ai/conversations/:id — 删除(仅本人)
router.delete('/:id', authMiddleware, (req, res) => {
const conversations = readTable('conversations');
const idx = conversations.findIndex((c) => c.id === req.params.id);
if (idx === -1) {
return res.status(404).json({ code: 404, message: '对话记录不存在' });
}
if (conversations[idx].userId !== req.user.userId) {
return res.status(403).json({ code: 403, message: '无权删除该对话' });
}
conversations.splice(idx, 1);
writeTable('conversations', conversations);
return res.json({ code: 200, data: null });
});
module.exports = router;

46
java-mock/server.js Normal file
View File

@ -0,0 +1,46 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const authRoutes = require('./routes/authRoutes');
const aiProxyRoutes = require('./routes/aiProxyRoutes');
const conversationRoutes = require('./routes/conversationRoutes');
const app = express();
const PORT = process.env.PORT || 8080;
app.use(cors());
app.use(express.json());
// 打印请求头
app.use((req, _res, next) => {
console.log(`\n[${new Date().toISOString()}] ${req.method} ${req.path}`);
console.log('[Headers]', JSON.stringify(req.headers, null, 2));
next();
});
// 健康检查
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'java-mock', timestamp: new Date().toISOString() });
});
// 路由
app.use('/api/auth', authRoutes);
app.use('/api/ai', aiProxyRoutes);
app.use('/api/ai/conversations', conversationRoutes);
// 404
app.use((req, res) => {
res.status(404).json({ code: 404, message: `Not Found: ${req.method} ${req.path}` });
});
// 全局错误处理
app.use((err, req, res, _next) => {
console.error('[Error]', err);
res.status(500).json({ code: 500, message: err.message || '服务器内部错误' });
});
app.listen(PORT, () => {
console.log(`java-mock 启动成功 → http://localhost:${PORT}`);
console.log(`Python 后端地址: ${process.env.PYTHON_BACKEND_URL || 'http://localhost:3001'}`);
});

17
java-mock/utils/db.js Normal file
View File

@ -0,0 +1,17 @@
const fs = require('fs');
const path = require('path');
const DATA_DIR = path.join(__dirname, '../data');
function readTable(name) {
const file = path.join(DATA_DIR, `${name}.json`);
if (!fs.existsSync(file)) return [];
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function writeTable(name, data) {
const file = path.join(DATA_DIR, `${name}.json`);
fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf8');
}
module.exports = { readTable, writeTable };

13
java-mock/utils/hmac.js Normal file
View File

@ -0,0 +1,13 @@
const crypto = require('crypto');
/**
* 生成 HMAC-SHA256 十六进制签名
* @param {string} secret
* @param {string} message
* @returns {string}
*/
function hmacSha256(secret, message) {
return crypto.createHmac('sha256', secret).update(message).digest('hex');
}
module.exports = { hmacSha256 };

795
plan/integration-plan.md Normal file
View File

@ -0,0 +1,795 @@
# 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` 的最终文本,不保存中间态 |

View File

@ -83,12 +83,14 @@ const AgentStage = {
class AigcVoiceClient { class AigcVoiceClient {
/** /**
* @param {Object} options * @param {Object} options
* @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:3001' * @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:8080'
* @param {string} [options.sceneId] - 指定场景 ID不传则使用第一个 * @param {string} [options.sceneId] - 指定场景 ID不传则使用第一个
* @param {string} [options.authToken] - JWT Token通过 java-mock /api/auth/login 获取
*/ */
constructor(options = {}) { constructor(options = {}) {
this.serverUrl = options.serverUrl || 'http://localhost:3001'; this.serverUrl = options.serverUrl || 'http://localhost:8080';
this.preferredSceneId = options.sceneId || null; this.preferredSceneId = options.sceneId || null;
this.authToken = options.authToken || null;
// RTC 相关 // RTC 相关
this.engine = null; this.engine = null;
@ -111,6 +113,10 @@ class AigcVoiceClient {
// 对话历史 // 对话历史
this.msgHistory = []; this.msgHistory = [];
// 继续上次对话时缓存的历史消息start() 时会 POST 到后端
this._historyMessages = null;
// 继续的原始对话 ID用于 stop 时追加到原记录而非新建)
this._continueFromId = null;
// 事件回调(使用者可覆写) // 事件回调(使用者可覆写)
this.onAIThinking = null; // () => void this.onAIThinking = null; // () => void
@ -132,9 +138,11 @@ class AigcVoiceClient {
async _post(path, action, body = {}) { async _post(path, action, body = {}) {
const url = `${this.serverUrl}${path}?Action=${action}`; 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, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
const json = await res.json(); const json = await res.json();
@ -155,15 +163,15 @@ class AigcVoiceClient {
} }
async _getScenes() { async _getScenes() {
return this._post('/getScenes', 'getScenes'); return this._post('/api/ai/getScenes', 'getScenes');
} }
async _startVoiceChat(sceneId) { async _startVoiceChat(sceneId) {
return this._post('/proxy', 'StartVoiceChat', { SceneID: sceneId }); return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: sceneId });
} }
async _stopVoiceChat(sceneId) { async _stopVoiceChat(sceneId) {
return this._post('/proxy', 'StopVoiceChat', { SceneID: sceneId }); return this._post('/api/ai/proxy', 'StopVoiceChat', { SceneID: sceneId });
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -197,6 +205,26 @@ class AigcVoiceClient {
return { sceneId: this.sceneId, scenes: scenesArr }; return { sceneId: this.sceneId, scenes: scenesArr };
} }
/**
* 加载历史对话作为上下文 init() 之后start() 之前调用
* @param {Array} messages - 来自 /api/ai/conversations/:id messages 数组
* @param {string} [conversationId] - 原始对话 IDstop 时会追加到该记录而非新建
*/
loadHistory(messages, conversationId) {
if (!Array.isArray(messages) || messages.length === 0) return;
this._historyMessages = messages.map((m) => ({ role: m.role, content: m.content }));
this._continueFromId = conversationId || null;
// 展示历史展示(标记为 _historical 不保存到新记录)
this.msgHistory = messages.map((m) => ({
role: m.role,
content: m.content,
definite: true,
paragraph: true,
time: m.createdAt,
_historical: true,
}));
}
/** /**
* 切换场景需在 start() 之前或 stop() 之后调用 * 切换场景需在 start() 之前或 stop() 之后调用
*/ */
@ -282,7 +310,26 @@ class AigcVoiceClient {
// 6. 开启麦克风 // 6. 开启麦克风
await this.enableMic(); await this.enableMic();
// 7. 启动 AI Bot // 7. 如果有历史上下文,先 POST 到后端
if (this._historyMessages && this._historyMessages.length > 0) {
try {
await fetch(`${this.serverUrl}/api/ai/session/history`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.authToken}`,
},
body: JSON.stringify({
room_id: this.roomId,
messages: this._historyMessages,
}),
});
} catch (e) {
console.warn('[AigcVoiceClient] 上传历史失败:', e);
}
}
// 8. 启动 AI Bot
await this._startAgent(); await this._startAgent();
console.log('[AigcVoiceClient] 通话已开始'); console.log('[AigcVoiceClient] 通话已开始');
@ -301,7 +348,12 @@ class AigcVoiceClient {
// 2. 停止 AI Bot // 2. 停止 AI Bot
await this._stopAgent(); await this._stopAgent();
// 3. 离房 & 销毁 // 3. 保存对话历史
if (this.msgHistory.length > 0) {
await this._saveConversation();
}
// 4. 离房 & 销毁
try { try {
await this.engine?.leaveRoom(); await this.engine?.leaveRoom();
window.VERTC?.destroyEngine(this.engine); window.VERTC?.destroyEngine(this.engine);
@ -311,11 +363,52 @@ class AigcVoiceClient {
this.isJoined = false; this.isJoined = false;
this.isMicOn = false; this.isMicOn = false;
this.msgHistory = []; this.msgHistory = [];
this._historyMessages = null;
this._continueFromId = null;
this._emitStateChange(); this._emitStateChange();
console.log('[AigcVoiceClient] 通话已结束'); console.log('[AigcVoiceClient] 通话已结束');
} }
async _saveConversation() {
if (!this.authToken) return;
try {
const newMessages = this.msgHistory
.filter((m) => !m._historical && m.content)
.map((m) => ({ role: m.role, content: m.content, time: m.time }));
if (newMessages.length === 0) return;
if (this._continueFromId) {
// 继续对话:追加到原记录
await fetch(`${this.serverUrl}/api/ai/conversations/${this._continueFromId}/append`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.authToken}`,
},
body: JSON.stringify({ messages: newMessages }),
});
} else {
// 全新对话:新建记录
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: newMessages,
}),
});
}
console.log('[AigcVoiceClient] 对话历史已保存');
} catch (e) {
console.warn('[AigcVoiceClient] 保存对话历史失败:', e);
}
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// 麦克风控制 // 麦克风控制
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -527,6 +620,7 @@ class AigcVoiceClient {
if (!data || !this.audioBotEnabled) return; if (!data || !this.audioBotEnabled) return;
const { text, definite, userId, paragraph } = data; const { text, definite, userId, paragraph } = data;
if (!text) return; // 跳过空文本帧
// 更新消息历史 // 更新消息历史
this._appendMessage({ text, userId, definite, paragraph }); this._appendMessage({ text, userId, definite, paragraph });
@ -566,20 +660,24 @@ class AigcVoiceClient {
// ---------------------------------------------------------- // ----------------------------------------------------------
_appendMessage({ text, userId, definite, paragraph }) { _appendMessage({ text, userId, definite, paragraph }) {
const role = userId === this.userId ? 'user' : 'assistant';
// 过滤 RTC 平台触发欢迎语的系统字符串
if (text === '欢迎语') return;
const lastMsg = this.msgHistory[this.msgHistory.length - 1]; const lastMsg = this.msgHistory[this.msgHistory.length - 1];
const isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph; const isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph;
if (isNewSentence) { if (isNewSentence) {
this.msgHistory.push({ this.msgHistory.push({
text, role,
userId, content: text,
definite: !!definite, definite: !!definite,
paragraph: !!paragraph, paragraph: !!paragraph,
time: new Date().toISOString(), time: new Date().toISOString(),
}); });
} else { } else {
// 话未说完,更新内容 // 话未说完,更新内容
lastMsg.text = text; lastMsg.content = text;
lastMsg.definite = !!definite; lastMsg.definite = !!definite;
lastMsg.paragraph = !!paragraph; lastMsg.paragraph = !!paragraph;
lastMsg.time = new Date().toISOString(); lastMsg.time = new Date().toISOString();

View File

@ -250,13 +250,236 @@
.text-overlay button svg { width: 20px; height: 20px; fill: #fff; } .text-overlay button svg { width: 20px; height: 20px; fill: #fff; }
.hidden { display: none !important; } .hidden { display: none !important; }
/* ============ 历史面板 ============ */
.history-panel {
position: absolute; top: 0; right: 0; bottom: 0; width: 340px; z-index: 40;
background: rgba(7,11,20,0.96); border-left: 1px solid rgba(255,255,255,0.07);
backdrop-filter: blur(24px); display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.4,0,0.2,1);
}
.history-panel.open { transform: translateX(0); }
.history-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.history-title { font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.7); }
.history-close {
width: 28px; height: 28px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4);
cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center;
transition: background 0.2s;
}
.history-close:hover { background: rgba(255,255,255,0.12); }
.history-list {
flex: 1; overflow-y: auto; padding: 8px 0;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
}
.history-item {
padding: 12px 20px; cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.04);
transition: background 0.15s;
}
.history-item:hover { background: rgba(255,255,255,0.04); }
.history-item.active { background: rgba(16,185,129,0.06); }
.history-item-date {
font-size: 11px; color: rgba(255,255,255,0.25); margin-bottom: 4px;
}
.history-item-preview {
font-size: 13px; color: rgba(255,255,255,0.55); font-weight: 300;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-item-meta {
font-size: 11px; color: rgba(255,255,255,0.2); margin-top: 4px;
}
/* 对话详情 */
.history-detail {
position: absolute; inset: 0;
background: rgba(7,11,20,0.98);
display: flex; flex-direction: column;
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1);
}
.history-detail.open { transform: translateX(0); }
.history-detail-header {
display: flex; align-items: center; gap: 12px;
padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.history-back {
width: 28px; height: 28px; border-radius: 50%; border: none;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
transition: background 0.2s; flex-shrink: 0;
}
.history-back:hover { background: rgba(255,255,255,0.12); }
.history-detail-title { font-size: 13px; color: rgba(255,255,255,0.5); flex: 1; }
.history-messages {
flex: 1; overflow-y: auto; padding: 16px;
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
display: flex; flex-direction: column; gap: 10px;
}
.history-msg {
max-width: 85%; padding: 8px 12px; border-radius: 12px;
font-size: 13px; line-height: 1.6; font-weight: 300;
}
.history-msg.user {
align-self: flex-end;
background: rgba(37,99,235,0.2); color: rgba(255,255,255,0.65);
border-bottom-right-radius: 4px;
}
.history-msg.bot {
align-self: flex-start;
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.7);
border-bottom-left-radius: 4px;
}
.history-continue-btn {
margin: 16px; padding: 12px;
background: #10b981; border: none; border-radius: 10px;
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
transition: background 0.2s; flex-shrink: 0;
}
.history-continue-btn:hover { background: #059669; }
.history-empty {
flex: 1; display: flex; align-items: center; justify-content: center;
font-size: 13px; color: rgba(255,255,255,0.2);
}
/* 历史按钮(启动页和通话页) */
.hist-btn {
position: absolute; top: 20px; left: 20px; z-index: 22;
width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.1);
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4);
cursor: pointer; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(10px); transition: all 0.2s;
}
.hist-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); }
.hist-btn svg { width: 16px; height: 16px; fill: currentColor; }
/* 历史消息在字幕区的样式 */
.caption-msg.historical { opacity: 0.35; font-size: 13px; }
.caption-divider {
text-align: center; font-size: 11px; color: rgba(255,255,255,0.2);
padding: 8px 0; letter-spacing: 1px;
}
/* ============ 登录页 ============ */
.login-view {
position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; z-index: 30;
transition: opacity 0.5s ease;
}
.login-view.fade-out { opacity: 0; pointer-events: none; }
.login-box {
width: 320px; padding: 36px 32px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px; backdrop-filter: blur(20px);
display: flex; flex-direction: column; gap: 16px;
}
.login-title {
font-size: 18px; font-weight: 400; color: rgba(255,255,255,0.85);
text-align: center; margin-bottom: 4px; letter-spacing: 0.5px;
}
.login-sub {
font-size: 12px; color: rgba(255,255,255,0.25);
text-align: center; margin-top: -8px; margin-bottom: 4px;
}
.login-input {
width: 100%; padding: 11px 16px;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #fff; font-size: 14px; outline: none;
transition: border-color 0.2s; font-weight: 300;
}
.login-input::placeholder { color: rgba(255,255,255,0.2); }
.login-input:focus { border-color: rgba(16,185,129,0.5); }
.login-btn {
width: 100%; padding: 12px;
background: #10b981; border: none; border-radius: 10px;
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
transition: background 0.2s; margin-top: 4px;
}
.login-btn:hover { background: #059669; }
.login-btn:disabled { opacity: 0.4; cursor: wait; }
.login-error {
font-size: 12px; color: #f87171; text-align: center;
min-height: 16px; margin-top: -4px;
}
.login-user {
position: absolute; top: 20px; right: 20px; z-index: 25;
font-size: 12px; color: rgba(255,255,255,0.3);
display: flex; align-items: center; gap: 8px;
}
.login-user span { color: rgba(255,255,255,0.5); }
.logout-btn {
background: none; border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; padding: 3px 10px; color: rgba(255,255,255,0.3);
font-size: 11px; cursor: pointer; transition: all 0.2s;
}
.logout-btn:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.6); }
</style> </style>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
<!-- ======== 登录页 ======== -->
<div class="login-view" id="loginView">
<div class="login-box">
<div class="login-title">AI 语音助手</div>
<div class="login-sub">admin / admin123 &nbsp;·&nbsp; user1 / user123</div>
<input class="login-input" id="loginUsername" placeholder="用户名" autocomplete="username"
onkeydown="if(event.key==='Enter')document.getElementById('loginPassword').focus()" />
<input class="login-input" id="loginPassword" type="password" placeholder="密码" autocomplete="current-password"
onkeydown="if(event.key==='Enter')handleLogin()" />
<div class="login-error" id="loginError"></div>
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
</div>
</div>
<!-- 已登录用户信息(右上角) -->
<div class="login-user hidden" id="userInfo">
<span id="userNickname"></span>
<button class="logout-btn" onclick="handleLogout()">退出</button>
</div>
<!-- 历史按钮(登录后显示,浮在左上角) -->
<button class="hist-btn hidden" id="histBtn" onclick="openHistory()" title="历史记录">
<svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>
</button>
<!-- ======== 历史面板 ======== -->
<div class="history-panel" id="historyPanel">
<div class="history-header">
<span class="history-title">对话历史</span>
<button class="history-close" onclick="closeHistory()">×</button>
</div>
<div class="history-list" id="historyList"></div>
<!-- 详情子面板(叠加在历史面板上) -->
<div class="history-detail" id="historyDetail">
<div class="history-detail-header">
<button class="history-back" onclick="closeDetail()"></button>
<span class="history-detail-title" id="detailTitle"></span>
</div>
<div class="history-messages" id="historyMessages"></div>
<button class="history-continue-btn" id="continueBtn" onclick="handleContinue()">
接着这次记录继续对话
</button>
</div>
</div>
<!-- ======== 启动页 ======== --> <!-- ======== 启动页 ======== -->
<div class="landing" id="landing"> <div class="landing hidden" id="landing">
<div class="landing-orb" onclick="handleStart()"></div> <div class="landing-orb" onclick="handleStart()"></div>
<div class="landing-text">AI 语音助手</div> <div class="landing-text">AI 语音助手</div>
<div class="landing-sub">点击光球或按钮开始对话</div> <div class="landing-sub">点击光球或按钮开始对话</div>
@ -312,8 +535,110 @@
<script src="./aigc-voice-client.js"></script> <script src="./aigc-voice-client.js"></script>
<script> <script>
const client = new AigcVoiceClient({ serverUrl: `http://${location.hostname}:3001` }); const JAVA_MOCK_URL = `http://${location.hostname}:8080`;
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let client = null;
let currentUser = null;
// ============ 登录 ============
async function handleLogin() {
const username = $('loginUsername').value.trim();
const password = $('loginPassword').value.trim();
if (!username || !password) {
$('loginError').textContent = '请输入用户名和密码';
return;
}
$('loginBtn').disabled = true;
$('loginBtn').textContent = '登录中...';
$('loginError').textContent = '';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.message || '登录失败');
currentUser = { name: json.data.name, deptName: json.data.deptName, roleList: json.data.roleList };
const token = json.data.token;
// 初始化 AigcVoiceClient指向 java-mock带上 JWT
client = new AigcVoiceClient({ serverUrl: JAVA_MOCK_URL, authToken: token });
setupClientCallbacks();
// 注册 beforeunload 兜底保存
window.addEventListener('beforeunload', () => {
if (client && client.msgHistory.length > 0) {
navigator.sendBeacon(
`${JAVA_MOCK_URL}/api/ai/conversations`,
new Blob([JSON.stringify({
sceneId: client.sceneId,
roomId: client.roomId,
messages: client.msgHistory
.filter((m) => !m._historical)
.map((m) => ({ role: m.role, content: m.content, time: m.time })),
})], { type: 'application/json' })
);
}
});
$('loginView').classList.add('fade-out');
$('userNickname').textContent = currentUser.name;
$('userInfo').classList.remove('hidden');
$('histBtn').classList.remove('hidden');
$('landing').classList.remove('hidden');
} catch (e) {
$('loginError').textContent = e.message;
$('loginBtn').disabled = false;
$('loginBtn').textContent = '登录';
}
}
function handleLogout() {
client = null;
currentUser = null;
$('landing').classList.add('hidden');
$('callView').classList.remove('active');
$('userInfo').classList.add('hidden');
$('histBtn').classList.add('hidden');
closeHistory();
$('loginView').classList.remove('fade-out');
$('loginUsername').value = '';
$('loginPassword').value = '';
$('loginBtn').disabled = false;
$('loginBtn').textContent = '登录';
}
function setupClientCallbacks() {
client.onStateChange = (state) => {
const mic = $('btnMic');
if (state.isMicOn) {
mic.classList.remove('muted');
$('micOn').classList.remove('hidden');
$('micOff').classList.add('hidden');
} else {
mic.classList.add('muted');
$('micOn').classList.add('hidden');
$('micOff').classList.remove('hidden');
}
};
client.onAIThinking = () => setOrbState('thinking', '思考中');
client.onAISpeaking = () => setOrbState('speaking', '');
client.onAIFinished = () => setOrbState('listening', '聆听中');
client.onAIInterrupted = () => {
setOrbState('listening', '已打断');
const msgs = client.msgHistory;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'assistant') { msgs[i].isInterrupted = true; break; }
}
renderCaptions(msgs);
};
client.onSubtitle = () => renderCaptions(client.msgHistory);
client.onError = (err) => { console.error(err); setOrbState('idle', '出错了'); };
}
let textInputVisible = false; let textInputVisible = false;
// ============ 光球状态 ============ // ============ 光球状态 ============
@ -326,11 +651,27 @@
function renderCaptions(msgs) { function renderCaptions(msgs) {
const area = $('captionArea'); const area = $('captionArea');
area.innerHTML = ''; area.innerHTML = '';
let hadHistory = false;
msgs.forEach((msg) => { msgs.forEach((msg) => {
const isUser = msg.userId === client.userId; if (msg._historical) {
hadHistory = true;
const div = document.createElement('div');
div.className = `caption-msg historical ${msg.role === 'user' ? 'user' : 'bot'}`;
div.textContent = msg.content;
area.appendChild(div);
return;
}
if (hadHistory) {
const divider = document.createElement('div');
divider.className = 'caption-divider';
divider.textContent = '— 本次对话 —';
area.appendChild(divider);
hadHistory = false;
}
const isUser = msg.role === 'user';
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`; div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
let html = escapeHtml(msg.text); let html = escapeHtml(msg.content);
if (!isUser && msg.isInterrupted) { if (!isUser && msg.isInterrupted) {
html += '<span class="interrupted-tag">已打断</span>'; html += '<span class="interrupted-tag">已打断</span>';
} }
@ -341,11 +682,10 @@
// 实时字幕:显示最后一条 // 实时字幕:显示最后一条
const last = msgs[msgs.length - 1]; const last = msgs[msgs.length - 1];
if (last) { if (last && !last._historical) {
const isUser = last.userId === client.userId; $('liveCaption').innerHTML = last.role === 'user'
$('liveCaption').innerHTML = isUser ? `<span class="user-text">${escapeHtml(last.content)}</span>`
? `<span class="user-text">${escapeHtml(last.text)}</span>` : escapeHtml(last.content);
: escapeHtml(last.text);
} }
} }
@ -355,38 +695,6 @@
return d.innerHTML; return d.innerHTML;
} }
// ============ 回调 ============
client.onStateChange = (state) => {
const mic = $('btnMic');
if (state.isMicOn) {
mic.classList.remove('muted');
$('micOn').classList.remove('hidden');
$('micOff').classList.add('hidden');
} else {
mic.classList.add('muted');
$('micOn').classList.add('hidden');
$('micOff').classList.remove('hidden');
}
};
client.onAIThinking = () => setOrbState('thinking', '思考中');
client.onAISpeaking = () => setOrbState('speaking', '');
client.onAIFinished = () => setOrbState('listening', '聆听中');
client.onAIInterrupted = () => {
setOrbState('listening', '已打断');
const msgs = client.msgHistory;
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].userId !== client.userId) { msgs[i].isInterrupted = true; break; }
}
renderCaptions(msgs);
};
client.onSubtitle = () => renderCaptions(client.msgHistory);
client.onError = (err) => {
console.error(err);
setOrbState('idle', '出错了');
};
// ============ 操作 ============ // ============ 操作 ============
async function handleStart() { async function handleStart() {
const btn = $('btnStart'); const btn = $('btnStart');
@ -420,6 +728,10 @@
$('captionArea').innerHTML = ''; $('captionArea').innerHTML = '';
$('liveCaption').innerHTML = ''; $('liveCaption').innerHTML = '';
setOrbState('idle', ''); setOrbState('idle', '');
// 重置启动页提示文字
const sub = document.querySelector('.landing-sub');
sub.textContent = '点击光球或按钮开始对话';
sub.style.color = '';
} }
function handleToggleMic() { client.toggleMic(); } function handleToggleMic() { client.toggleMic(); }
@ -437,12 +749,108 @@
input.value = ''; input.value = '';
client.sendTextToLLM(text); client.sendTextToLLM(text);
client.msgHistory.push({ client.msgHistory.push({
text, userId: client.userId, role: 'user', content: text,
definite: true, paragraph: true, time: new Date().toISOString(), definite: true, paragraph: true, time: new Date().toISOString(),
}); });
renderCaptions(client.msgHistory); renderCaptions(client.msgHistory);
toggleTextInput(); toggleTextInput();
} }
// ============ 历史面板 ============
let selectedConversation = null;
async function openHistory() {
$('historyPanel').classList.add('open');
await loadHistoryList();
}
function closeHistory() {
$('historyPanel').classList.remove('open');
closeDetail();
}
async function loadHistoryList() {
const list = $('historyList');
list.innerHTML = '<div class="history-empty">加载中...</div>';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations?size=50`, {
headers: { 'Authorization': `Bearer ${client.authToken}` },
});
const json = await res.json();
const items = json.data?.list || [];
if (items.length === 0) {
list.innerHTML = '<div class="history-empty">暂无历史记录</div>';
return;
}
list.innerHTML = '';
items.forEach((item) => {
const div = document.createElement('div');
div.className = 'history-item';
div.dataset.id = item.id;
const date = new Date(item.startedAt).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
div.innerHTML = `
<div class="history-item-date">${date}</div>
<div class="history-item-preview">${escapeHtml(item.firstMessage || '(无文字记录)')}</div>
<div class="history-item-meta">${item.messageCount} 条消息 · ${item.sceneId}</div>
`;
div.addEventListener('click', () => openDetail(item.id));
list.appendChild(div);
});
} catch (e) {
list.innerHTML = '<div class="history-empty">加载失败</div>';
}
}
async function openDetail(id) {
$('historyDetail').classList.add('open');
$('historyMessages').innerHTML = '<div style="color:rgba(255,255,255,0.2);padding:20px;font-size:13px">加载中...</div>';
try {
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations/${id}`, {
headers: { 'Authorization': `Bearer ${client.authToken}` },
});
const json = await res.json();
const conv = json.data;
selectedConversation = conv;
const date = new Date(conv.startedAt).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
});
$('detailTitle').textContent = date + ' · ' + conv.sceneId;
const msgsEl = $('historyMessages');
msgsEl.innerHTML = '';
conv.messages.forEach((m) => {
const div = document.createElement('div');
div.className = `history-msg ${m.role === 'user' ? 'user' : 'bot'}`;
div.textContent = m.content;
msgsEl.appendChild(div);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
} catch (e) {
$('historyMessages').innerHTML = '<div style="color:#f87171;padding:20px;font-size:13px">加载失败</div>';
}
}
function closeDetail() {
$('historyDetail').classList.remove('open');
selectedConversation = null;
}
function handleContinue() {
if (!selectedConversation) return;
const conv = selectedConversation; // closeHistory 会把 selectedConversation 置 null先保存
client.loadHistory(conv.messages, conv.id);
closeHistory();
// 跳转到启动页,准备开始新对话
$('landing').classList.remove('fade-out', 'hidden');
$('callView').classList.remove('active');
// 在启动页显示提示
const sub = document.querySelector('.landing-sub');
sub.textContent = `将接续 ${new Date(conv.startedAt).toLocaleDateString('zh-CN')} 的对话`;
sub.style.color = 'rgba(16,185,129,0.6)';
}
</script> </script>
</body> </body>
</html> </html>