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.example
!.env.staging
# =====================
# 日志 / 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_VISION_ENABLE=false
# 本地调试时,可先保持默认本地回调地址。
# 等 ngrok 跑起来后,再把 CUSTOM_LLM_URL 改成公网 https 地址,例如:
# https://your-ngrok-domain.ngrok-free.app/api/chat_callback
CUSTOM_LLM_URL= https://postvarioloid-leeann-didynamous.ngrok-free.dev
# 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址
# 例如https://api.yourdomain.com/v1/api/chat_callback
CUSTOM_LLM_URL=
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token可留空
CUSTOM_LLM_API_KEY=
CUSTOM_LLM_MODEL_NAME=
@ -69,7 +68,7 @@ VOLC_KB_ENABLED=false
VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一)
VOLC_KB_RESOURCE_ID= # 知识库唯一 ID优先级高于 NAME
VOLC_KB_PROJECT=default # 知识库所属项目
VOLC_KB_ENDPOINT=https://postvarioloid-leeann-didynamous.ngrok-free.dev
VOLC_KB_ENDPOINT=
VOLC_KB_TOP_K=3 # 检索返回条数
VOLC_KB_RERANK=false # 是否开启 rerank 重排
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`
2. 用 `ngrok` 暴露 `3001` 端口
3. 把 `CUSTOM_LLM_URL` 改成公网地址,例如:
2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如:
```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如果你不需要这层鉴权可以留空。

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 services.local_llm_service import local_llm_service
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
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):
try:
settings = get_custom_llm_callback_settings()
@ -43,6 +61,23 @@ async def chat_callback(request: Request, body: ChatCallbackRequest):
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]
if last_message.get("role") != "user":
return custom_llm_error_response(

View File

@ -5,17 +5,26 @@
import json
import time
from fastapi import APIRouter
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from schemas.chat import DebugChatRequest
from services.local_llm_service import local_llm_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):
current_messages = [
{"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")
@router.get("/rag")
async def debug_rag(query: str):
@router.get(
"/rag",
summary="调试 RAG 知识库检索",
description=(
"对知识库执行一次检索,返回原始检索上下文内容,用于验证 RAG 效果。\n\n"
"⚠️ 仅用于本地开发调试,无鉴权。"
),
)
async def debug_rag(query: str = Query(..., description="检索问题")):
if not 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
from fastapi import APIRouter, Request
from fastapi import APIRouter, Query, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from config.custom_scene import get_rtc_openapi_version
from security.internal_auth import verify_internal_request
from security.signer import Signer
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.validation import assert_scene_value, assert_value
router = APIRouter()
router = APIRouter(tags=["RTC 代理"])
@router.post("/proxy")
async def proxy(request: Request):
action = request.query_params.get("Action", "")
version = request.query_params.get("Version") or get_rtc_openapi_version()
class ProxyRequest(BaseModel):
SceneID: str = Field(..., description="场景 ID从 getScenes 返回的 scene.id 获取)")
@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:
assert_value(action, "Action 不能为空")
assert_value(version, "Version 不能为空")
body = await request.json()
scene_id = body.get("SceneID", "")
assert_value(scene_id, "SceneID 不能为空SceneID 用于指定场景配置")
json_data = Scenes.get(scene_id)
@ -56,6 +81,14 @@ async def proxy(request: Request):
target_user_ids[0] = sess["UserId"]
else:
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
elif action == "StopVoiceChat":
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.RoomId", room_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}
else:
req_body = {}

View File

@ -5,14 +5,30 @@ POST /getScenes — 场景列表
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from security.internal_auth import verify_internal_request
from services.scene_service import Scenes, prepare_scene_runtime
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):
if not verify_internal_request(request.headers):
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
try:
scenes_list = []
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 os
from pathlib import Path
from dotenv import load_dotenv
@ -6,8 +7,10 @@ from dotenv import load_dotenv
BASE_DIR = Path(__file__).parent
load_dotenv(BASE_DIR / ".env", override=False)
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
logging.basicConfig(
level=logging.DEBUG,
level=getattr(logging, LOG_LEVEL, logging.DEBUG),
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
@ -18,15 +21,28 @@ logging.getLogger("openai").setLevel(logging.WARNING)
logging.getLogger("uvicorn").setLevel(logging.INFO)
# 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量
from routes.chat_callback import router as chat_callback_router # noqa: E402
from routes.debug import router as debug_router # noqa: E402
from routes.proxy import router as proxy_router # noqa: E402
from routes.scenes import router as scenes_router # noqa: E402
from routes.v1.chat_callback import router as chat_callback_router # noqa: E402
from routes.v1.debug import router as debug_router # noqa: E402
from routes.v1.history import router as history_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.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(
CORSMiddleware,
@ -35,10 +51,17 @@ app.add_middleware(
allow_headers=["*"],
)
app.include_router(proxy_router)
app.include_router(scenes_router)
app.include_router(chat_callback_router)
app.include_router(debug_router)
@app.get("/health", include_in_schema=False)
async def health():
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__":
import uvicorn

View File

@ -12,6 +12,9 @@ from fastapi import Request
# { session_key: { scene_id: { RoomId, UserId, TaskId } } }
_store: dict[str, dict[str, dict]] = {}
# { room_id: [{ role, content }] }
_history_store: dict[str, list[dict]] = {}
def _key(request: Request) -> str:
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:
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 {
/**
* @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.authToken] - JWT Token通过 java-mock /api/auth/login 获取
*/
constructor(options = {}) {
this.serverUrl = options.serverUrl || 'http://localhost:3001';
this.serverUrl = options.serverUrl || 'http://localhost:8080';
this.preferredSceneId = options.sceneId || null;
this.authToken = options.authToken || null;
// RTC 相关
this.engine = null;
@ -111,6 +113,10 @@ class AigcVoiceClient {
// 对话历史
this.msgHistory = [];
// 继续上次对话时缓存的历史消息start() 时会 POST 到后端
this._historyMessages = null;
// 继续的原始对话 ID用于 stop 时追加到原记录而非新建)
this._continueFromId = null;
// 事件回调(使用者可覆写)
this.onAIThinking = null; // () => void
@ -132,9 +138,11 @@ class AigcVoiceClient {
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: { 'Content-Type': 'application/json' },
headers,
body: JSON.stringify(body),
});
const json = await res.json();
@ -155,15 +163,15 @@ class AigcVoiceClient {
}
async _getScenes() {
return this._post('/getScenes', 'getScenes');
return this._post('/api/ai/getScenes', 'getScenes');
}
async _startVoiceChat(sceneId) {
return this._post('/proxy', 'StartVoiceChat', { SceneID: sceneId });
return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: 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 };
}
/**
* 加载历史对话作为上下文 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() 之后调用
*/
@ -282,7 +310,26 @@ class AigcVoiceClient {
// 6. 开启麦克风
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();
console.log('[AigcVoiceClient] 通话已开始');
@ -301,7 +348,12 @@ class AigcVoiceClient {
// 2. 停止 AI Bot
await this._stopAgent();
// 3. 离房 & 销毁
// 3. 保存对话历史
if (this.msgHistory.length > 0) {
await this._saveConversation();
}
// 4. 离房 & 销毁
try {
await this.engine?.leaveRoom();
window.VERTC?.destroyEngine(this.engine);
@ -311,11 +363,52 @@ class AigcVoiceClient {
this.isJoined = false;
this.isMicOn = false;
this.msgHistory = [];
this._historyMessages = null;
this._continueFromId = null;
this._emitStateChange();
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;
const { text, definite, userId, paragraph } = data;
if (!text) return; // 跳过空文本帧
// 更新消息历史
this._appendMessage({ text, userId, definite, paragraph });
@ -566,20 +660,24 @@ class AigcVoiceClient {
// ----------------------------------------------------------
_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 isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph;
if (isNewSentence) {
this.msgHistory.push({
text,
userId,
role,
content: text,
definite: !!definite,
paragraph: !!paragraph,
time: new Date().toISOString(),
});
} else {
// 话未说完,更新内容
lastMsg.text = text;
lastMsg.content = text;
lastMsg.definite = !!definite;
lastMsg.paragraph = !!paragraph;
lastMsg.time = new Date().toISOString();

View File

@ -250,13 +250,236 @@
.text-overlay button svg { width: 20px; height: 20px; fill: #fff; }
.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>
</head>
<body>
<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-text">AI 语音助手</div>
<div class="landing-sub">点击光球或按钮开始对话</div>
@ -312,50 +535,84 @@
<script src="./aigc-voice-client.js"></script>
<script>
const client = new AigcVoiceClient({ serverUrl: `http://${location.hostname}:3001` });
const JAVA_MOCK_URL = `http://${location.hostname}:8080`;
const $ = (id) => document.getElementById(id);
let textInputVisible = false;
// ============ 光球状态 ============
function setOrbState(state, label) {
$('orbContainer').dataset.state = state;
$('orbLabel').textContent = label;
}
let client = null;
let currentUser = null;
// ============ 字幕渲染 ============
function renderCaptions(msgs) {
const area = $('captionArea');
area.innerHTML = '';
msgs.forEach((msg) => {
const isUser = msg.userId === client.userId;
const div = document.createElement('div');
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
let html = escapeHtml(msg.text);
if (!isUser && msg.isInterrupted) {
html += '<span class="interrupted-tag">已打断</span>';
// ============ 登录 ============
async function handleLogin() {
const username = $('loginUsername').value.trim();
const password = $('loginPassword').value.trim();
if (!username || !password) {
$('loginError').textContent = '请输入用户名和密码';
return;
}
div.innerHTML = html;
area.appendChild(div);
$('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 }),
});
area.scrollTop = area.scrollHeight;
const json = await res.json();
if (!res.ok) throw new Error(json.message || '登录失败');
// 实时字幕:显示最后一条
const last = msgs[msgs.length - 1];
if (last) {
const isUser = last.userId === client.userId;
$('liveCaption').innerHTML = isUser
? `<span class="user-text">${escapeHtml(last.text)}</span>`
: escapeHtml(last.text);
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 escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
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) {
@ -368,7 +625,6 @@
$('micOff').classList.remove('hidden');
}
};
client.onAIThinking = () => setOrbState('thinking', '思考中');
client.onAISpeaking = () => setOrbState('speaking', '');
client.onAIFinished = () => setOrbState('listening', '聆听中');
@ -376,16 +632,68 @@
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; }
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', '出错了');
};
client.onError = (err) => { console.error(err); setOrbState('idle', '出错了'); };
}
let textInputVisible = false;
// ============ 光球状态 ============
function setOrbState(state, label) {
$('orbContainer').dataset.state = state;
$('orbLabel').textContent = label;
}
// ============ 字幕渲染 ============
function renderCaptions(msgs) {
const area = $('captionArea');
area.innerHTML = '';
let hadHistory = false;
msgs.forEach((msg) => {
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');
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
let html = escapeHtml(msg.content);
if (!isUser && msg.isInterrupted) {
html += '<span class="interrupted-tag">已打断</span>';
}
div.innerHTML = html;
area.appendChild(div);
});
area.scrollTop = area.scrollHeight;
// 实时字幕:显示最后一条
const last = msgs[msgs.length - 1];
if (last && !last._historical) {
$('liveCaption').innerHTML = last.role === 'user'
? `<span class="user-text">${escapeHtml(last.content)}</span>`
: escapeHtml(last.content);
}
}
function escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ============ 操作 ============
async function handleStart() {
@ -420,6 +728,10 @@
$('captionArea').innerHTML = '';
$('liveCaption').innerHTML = '';
setOrbState('idle', '');
// 重置启动页提示文字
const sub = document.querySelector('.landing-sub');
sub.textContent = '点击光球或按钮开始对话';
sub.style.color = '';
}
function handleToggleMic() { client.toggleMic(); }
@ -437,12 +749,108 @@
input.value = '';
client.sendTextToLLM(text);
client.msgHistory.push({
text, userId: client.userId,
role: 'user', content: text,
definite: true, paragraph: true, time: new Date().toISOString(),
});
renderCaptions(client.msgHistory);
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>
</body>
</html>