refactor
This commit is contained in:
parent
d468195db0
commit
271f7345c9
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal 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
1
.gitignore
vendored
@ -32,6 +32,7 @@ pnpm-lock.yaml
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.staging
|
||||||
|
|
||||||
# =====================
|
# =====================
|
||||||
# 日志 / Logs
|
# 日志 / Logs
|
||||||
|
|||||||
12
backend/.dockerignore
Normal file
12
backend/.dockerignore
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
*.md
|
||||||
|
*.log
|
||||||
@ -24,10 +24,9 @@ CUSTOM_INTERRUPT_MODE=0
|
|||||||
CUSTOM_LLM_THINKING_TYPE=disabled
|
CUSTOM_LLM_THINKING_TYPE=disabled
|
||||||
CUSTOM_LLM_VISION_ENABLE=false
|
CUSTOM_LLM_VISION_ENABLE=false
|
||||||
|
|
||||||
# 本地调试时,可先保持默认本地回调地址。
|
# 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址
|
||||||
# 等 ngrok 跑起来后,再把 CUSTOM_LLM_URL 改成公网 https 地址,例如:
|
# 例如:https://api.yourdomain.com/v1/api/chat_callback
|
||||||
# https://your-ngrok-domain.ngrok-free.app/api/chat_callback
|
CUSTOM_LLM_URL=
|
||||||
CUSTOM_LLM_URL= https://postvarioloid-leeann-didynamous.ngrok-free.dev
|
|
||||||
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token,可留空
|
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token,可留空
|
||||||
CUSTOM_LLM_API_KEY=
|
CUSTOM_LLM_API_KEY=
|
||||||
CUSTOM_LLM_MODEL_NAME=
|
CUSTOM_LLM_MODEL_NAME=
|
||||||
@ -69,7 +68,7 @@ VOLC_KB_ENABLED=false
|
|||||||
VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一)
|
VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一)
|
||||||
VOLC_KB_RESOURCE_ID= # 知识库唯一 ID(优先级高于 NAME)
|
VOLC_KB_RESOURCE_ID= # 知识库唯一 ID(优先级高于 NAME)
|
||||||
VOLC_KB_PROJECT=default # 知识库所属项目
|
VOLC_KB_PROJECT=default # 知识库所属项目
|
||||||
VOLC_KB_ENDPOINT=https://postvarioloid-leeann-didynamous.ngrok-free.dev
|
VOLC_KB_ENDPOINT=
|
||||||
VOLC_KB_TOP_K=3 # 检索返回条数
|
VOLC_KB_TOP_K=3 # 检索返回条数
|
||||||
VOLC_KB_RERANK=false # 是否开启 rerank 重排
|
VOLC_KB_RERANK=false # 是否开启 rerank 重排
|
||||||
VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟)
|
VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟)
|
||||||
|
|||||||
92
backend/.env.staging
Normal file
92
backend/.env.staging
Normal 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
508
backend/API.md
Normal 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 | 本次会话分配的房间 ID(UUID) |
|
||||||
|
| UserId | string | 用户在 RTC 房间中的 UserId |
|
||||||
|
| Token | string | RTC 入房 Token |
|
||||||
|
| TaskId | string | 语音任务 ID,StopVoiceChat 时需要 |
|
||||||
|
|
||||||
|
> **副作用:** 该接口会将 `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 流返回结果
|
||||||
|
|
||||||
|
**成功响应 200(text/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": "今天办公室有多少人打卡?"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**成功响应 200(text/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
295
backend/DEPLOYMENT.md
Normal 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
47
backend/Dockerfile
Normal 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"]
|
||||||
@ -75,11 +75,10 @@ CUSTOM_LLM_API_KEY=your-callback-token
|
|||||||
推荐调试流程:
|
推荐调试流程:
|
||||||
|
|
||||||
1. 先启动当前 `backend`
|
1. 先启动当前 `backend`
|
||||||
2. 用 `ngrok` 暴露 `3001` 端口
|
2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如:
|
||||||
3. 把 `CUSTOM_LLM_URL` 改成公网地址,例如:
|
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
CUSTOM_LLM_URL=https://your-ngrok-domain.ngrok-free.app/api/chat_callback
|
CUSTOM_LLM_URL=https://api.yourdomain.com/v1/api/chat_callback
|
||||||
```
|
```
|
||||||
|
|
||||||
`CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token;如果你不需要这层鉴权,可以留空。
|
`CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token;如果你不需要这层鉴权,可以留空。
|
||||||
|
|||||||
15
backend/docker-compose.dev.yml
Normal file
15
backend/docker-compose.dev.yml
Normal 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" # 开发时不自动重启,报错立即停止方便排查
|
||||||
7
backend/docker-compose.staging.yml
Normal file
7
backend/docker-compose.staging.yml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
env_file:
|
||||||
|
- .env.staging # 覆盖基础文件的 .env,指向测试资源
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=info # 测试环境用 info,比生产详细,比开发简洁
|
||||||
|
restart: unless-stopped
|
||||||
18
backend/docker-compose.yml
Normal file
18
backend/docker-compose.yml
Normal 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
|
||||||
1
backend/routes/v1/__init__.py
Normal file
1
backend/routes/v1/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""v1 路由模块"""
|
||||||
@ -10,12 +10,30 @@ from fastapi.responses import StreamingResponse
|
|||||||
from schemas.chat import ChatCallbackRequest
|
from schemas.chat import ChatCallbackRequest
|
||||||
from services.local_llm_service import local_llm_service
|
from services.local_llm_service import local_llm_service
|
||||||
from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings
|
from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings
|
||||||
|
from services.session_store import get_room_history
|
||||||
from utils.responses import custom_llm_error_response
|
from utils.responses import custom_llm_error_response
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["LLM 回调"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/chat_callback")
|
@router.post(
|
||||||
|
"/api/chat_callback",
|
||||||
|
summary="自定义 LLM 回调(SSE 流式)",
|
||||||
|
description=(
|
||||||
|
"由**火山引擎 RTC 平台**在用户发言后自动回调,返回 OpenAI 兼容格式的 SSE 流。\n\n"
|
||||||
|
"处理逻辑:\n"
|
||||||
|
"1. 校验 `Authorization: Bearer <CUSTOM_LLM_API_KEY>`\n"
|
||||||
|
"2. 过滤掉 RTC 平台发送的 `欢迎语` 触发词(非真实用户输入)\n"
|
||||||
|
"3. 若携带 `room_id` Query 参数,自动从缓存取历史并 prepend 到 messages 前\n"
|
||||||
|
"4. 调用本地 LLM(工具调用 / RAG 按需触发),以 SSE 流返回结果\n\n"
|
||||||
|
"**鉴权**:`Authorization: Bearer <CUSTOM_LLM_API_KEY>`"
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
401: {"description": "API Key 无效"},
|
||||||
|
400: {"description": "messages 为空或最后一条不是 user 角色"},
|
||||||
|
500: {"description": "LLM 初始化失败"},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def chat_callback(request: Request, body: ChatCallbackRequest):
|
async def chat_callback(request: Request, body: ChatCallbackRequest):
|
||||||
try:
|
try:
|
||||||
settings = get_custom_llm_callback_settings()
|
settings = get_custom_llm_callback_settings()
|
||||||
@ -43,6 +61,23 @@ async def chat_callback(request: Request, body: ChatCallbackRequest):
|
|||||||
status_code=400,
|
status_code=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 过滤 RTC 平台的"欢迎语"触发词(不是真实用户输入)
|
||||||
|
messages = [m for m in messages if not (m["role"] == "user" and m["content"] == "欢迎语")]
|
||||||
|
|
||||||
|
# 注入历史对话上下文(prepend 到当前会话消息前)
|
||||||
|
room_id = request.query_params.get("room_id", "")
|
||||||
|
if room_id:
|
||||||
|
history = get_room_history(room_id)
|
||||||
|
if history:
|
||||||
|
messages = history + messages
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
return custom_llm_error_response(
|
||||||
|
"messages 不能为空",
|
||||||
|
code="BadRequest",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
last_message = messages[-1]
|
last_message = messages[-1]
|
||||||
if last_message.get("role") != "user":
|
if last_message.get("role") != "user":
|
||||||
return custom_llm_error_response(
|
return custom_llm_error_response(
|
||||||
@ -5,17 +5,26 @@
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from schemas.chat import DebugChatRequest
|
from schemas.chat import DebugChatRequest
|
||||||
from services.local_llm_service import local_llm_service
|
from services.local_llm_service import local_llm_service
|
||||||
from services.rag_service import rag_service
|
from services.rag_service import rag_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/debug")
|
router = APIRouter(prefix="/debug", tags=["调试"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/chat")
|
@router.post(
|
||||||
|
"/chat",
|
||||||
|
summary="调试 LLM 对话",
|
||||||
|
description=(
|
||||||
|
"直接向 LLM 发送消息,响应为纯文本流(非 SSE)。\n\n"
|
||||||
|
"完成后会在**服务端终端**输出本次对话完整的 `history` JSON,"
|
||||||
|
"可复制后粘贴到下次请求的 `history` 字段继续对话。\n\n"
|
||||||
|
"⚠️ 仅用于本地开发调试,无鉴权。"
|
||||||
|
),
|
||||||
|
)
|
||||||
async def debug_chat(request: DebugChatRequest):
|
async def debug_chat(request: DebugChatRequest):
|
||||||
current_messages = [
|
current_messages = [
|
||||||
{"role": message.role, "content": message.content} for message in request.history
|
{"role": message.role, "content": message.content} for message in request.history
|
||||||
@ -72,8 +81,15 @@ async def debug_chat(request: DebugChatRequest):
|
|||||||
return StreamingResponse(generate_text(), media_type="text/plain")
|
return StreamingResponse(generate_text(), media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/rag")
|
@router.get(
|
||||||
async def debug_rag(query: str):
|
"/rag",
|
||||||
|
summary="调试 RAG 知识库检索",
|
||||||
|
description=(
|
||||||
|
"对知识库执行一次检索,返回原始检索上下文内容,用于验证 RAG 效果。\n\n"
|
||||||
|
"⚠️ 仅用于本地开发调试,无鉴权。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def debug_rag(query: str = Query(..., description="检索问题")):
|
||||||
if not query:
|
if not query:
|
||||||
return {"error": "请提供 query 参数"}
|
return {"error": "请提供 query 参数"}
|
||||||
|
|
||||||
42
backend/routes/v1/history.py
Normal file
42
backend/routes/v1/history.py
Normal 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})
|
||||||
@ -3,30 +3,55 @@ POST /proxy — RTC OpenAPI 代理(含请求签名)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Query, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from config.custom_scene import get_rtc_openapi_version
|
from config.custom_scene import get_rtc_openapi_version
|
||||||
|
from security.internal_auth import verify_internal_request
|
||||||
from security.signer import Signer
|
from security.signer import Signer
|
||||||
from services.scene_service import Scenes, prepare_scene_runtime
|
from services.scene_service import Scenes, prepare_scene_runtime
|
||||||
from services.session_store import load_session
|
from services.session_store import clear_room_history, get_room_history, load_session
|
||||||
from utils.responses import error_response
|
from utils.responses import error_response
|
||||||
from utils.validation import assert_scene_value, assert_value
|
from utils.validation import assert_scene_value, assert_value
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["RTC 代理"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/proxy")
|
class ProxyRequest(BaseModel):
|
||||||
async def proxy(request: Request):
|
SceneID: str = Field(..., description="场景 ID(从 getScenes 返回的 scene.id 获取)")
|
||||||
action = request.query_params.get("Action", "")
|
|
||||||
version = request.query_params.get("Version") or get_rtc_openapi_version()
|
|
||||||
|
@router.post(
|
||||||
|
"/proxy",
|
||||||
|
summary="开始 / 停止语音对话",
|
||||||
|
description=(
|
||||||
|
"带 SigV4 签名转发到火山引擎 RTC OpenAPI。\n\n"
|
||||||
|
"- `Action=StartVoiceChat`:从 Session 取回 getScenes 分配的 RoomId/TaskId,"
|
||||||
|
"自动注入后启动对话。\n"
|
||||||
|
"- `Action=StopVoiceChat`:停止对话并清除该房间的历史上下文缓存。\n\n"
|
||||||
|
"**鉴权**:需附加内部服务签名 Header(由 java-mock 自动添加)。"
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
401: {"description": "内部签名校验失败"},
|
||||||
|
400: {"description": "参数缺失或场景配置不存在"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def proxy(
|
||||||
|
request: Request,
|
||||||
|
body: ProxyRequest,
|
||||||
|
action: str = Query(..., alias="Action", description="操作类型:`StartVoiceChat` 或 `StopVoiceChat`"),
|
||||||
|
version: str | None = Query(None, alias="Version", description="火山引擎 OpenAPI 版本,不传时取配置文件默认值"),
|
||||||
|
):
|
||||||
|
if not verify_internal_request(request.headers):
|
||||||
|
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
|
||||||
|
|
||||||
|
version = version or get_rtc_openapi_version()
|
||||||
|
scene_id = body.SceneID
|
||||||
|
|
||||||
try:
|
try:
|
||||||
assert_value(action, "Action 不能为空")
|
assert_value(action, "Action 不能为空")
|
||||||
assert_value(version, "Version 不能为空")
|
assert_value(version, "Version 不能为空")
|
||||||
|
|
||||||
body = await request.json()
|
|
||||||
scene_id = body.get("SceneID", "")
|
|
||||||
assert_value(scene_id, "SceneID 不能为空,SceneID 用于指定场景配置")
|
assert_value(scene_id, "SceneID 不能为空,SceneID 用于指定场景配置")
|
||||||
|
|
||||||
json_data = Scenes.get(scene_id)
|
json_data = Scenes.get(scene_id)
|
||||||
@ -56,6 +81,14 @@ async def proxy(request: Request):
|
|||||||
target_user_ids[0] = sess["UserId"]
|
target_user_ids[0] = sess["UserId"]
|
||||||
else:
|
else:
|
||||||
agent_config["TargetUserId"] = [sess["UserId"]]
|
agent_config["TargetUserId"] = [sess["UserId"]]
|
||||||
|
# 将 room_id 追加到 LLM 回调 URL,使 chat_callback 能关联到历史
|
||||||
|
room_id = voice_chat.get("RoomId", "")
|
||||||
|
if room_id:
|
||||||
|
llm_config = voice_chat.get("Config", {}).get("LLMConfig", {})
|
||||||
|
llm_url = llm_config.get("Url", "")
|
||||||
|
if llm_url:
|
||||||
|
sep = "&" if "?" in llm_url else "?"
|
||||||
|
llm_config["Url"] = f"{llm_url}{sep}room_id={room_id}"
|
||||||
req_body = voice_chat
|
req_body = voice_chat
|
||||||
elif action == "StopVoiceChat":
|
elif action == "StopVoiceChat":
|
||||||
app_id = voice_chat.get("AppId", "")
|
app_id = voice_chat.get("AppId", "")
|
||||||
@ -65,6 +98,8 @@ async def proxy(request: Request):
|
|||||||
assert_scene_value(scene_id, "VoiceChat.AppId", app_id)
|
assert_scene_value(scene_id, "VoiceChat.AppId", app_id)
|
||||||
assert_scene_value(scene_id, "VoiceChat.RoomId", room_id)
|
assert_scene_value(scene_id, "VoiceChat.RoomId", room_id)
|
||||||
assert_scene_value(scene_id, "VoiceChat.TaskId", task_id)
|
assert_scene_value(scene_id, "VoiceChat.TaskId", task_id)
|
||||||
|
# 清除该房间的历史上下文
|
||||||
|
clear_room_history(room_id)
|
||||||
req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id}
|
req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id}
|
||||||
else:
|
else:
|
||||||
req_body = {}
|
req_body = {}
|
||||||
@ -5,14 +5,30 @@ POST /getScenes — 场景列表
|
|||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from security.internal_auth import verify_internal_request
|
||||||
from services.scene_service import Scenes, prepare_scene_runtime
|
from services.scene_service import Scenes, prepare_scene_runtime
|
||||||
from services.session_store import save_session
|
from services.session_store import save_session
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter(tags=["场景"])
|
||||||
|
|
||||||
|
|
||||||
@router.post("/getScenes")
|
@router.post(
|
||||||
|
"/getScenes",
|
||||||
|
summary="获取场景列表",
|
||||||
|
description=(
|
||||||
|
"返回所有已配置场景的信息及对应的 RTC 房间参数(AppId / RoomId / Token 等)。\n\n"
|
||||||
|
"调用后会将本次生成的 `RoomId / UserId / TaskId` 写入 Session,"
|
||||||
|
"供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。\n\n"
|
||||||
|
"**鉴权**:需附加内部服务签名 Header(由 java-mock 自动添加)。"
|
||||||
|
),
|
||||||
|
responses={
|
||||||
|
401: {"description": "内部签名校验失败"},
|
||||||
|
},
|
||||||
|
)
|
||||||
async def get_scenes(request: Request):
|
async def get_scenes(request: Request):
|
||||||
|
if not verify_internal_request(request.headers):
|
||||||
|
return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
scenes_list = []
|
scenes_list = []
|
||||||
for scene_name, data in Scenes.items():
|
for scene_name, data in Scenes.items():
|
||||||
52
backend/security/internal_auth.py
Normal file
52
backend/security/internal_auth.py
Normal 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)
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
@ -6,8 +7,10 @@ from dotenv import load_dotenv
|
|||||||
BASE_DIR = Path(__file__).parent
|
BASE_DIR = Path(__file__).parent
|
||||||
load_dotenv(BASE_DIR / ".env", override=False)
|
load_dotenv(BASE_DIR / ".env", override=False)
|
||||||
|
|
||||||
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.DEBUG,
|
level=getattr(logging, LOG_LEVEL, logging.DEBUG),
|
||||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
datefmt="%H:%M:%S",
|
datefmt="%H:%M:%S",
|
||||||
)
|
)
|
||||||
@ -18,15 +21,28 @@ logging.getLogger("openai").setLevel(logging.WARNING)
|
|||||||
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
logging.getLogger("uvicorn").setLevel(logging.INFO)
|
||||||
|
|
||||||
# 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量
|
# 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量
|
||||||
from routes.chat_callback import router as chat_callback_router # noqa: E402
|
from routes.v1.chat_callback import router as chat_callback_router # noqa: E402
|
||||||
from routes.debug import router as debug_router # noqa: E402
|
from routes.v1.debug import router as debug_router # noqa: E402
|
||||||
from routes.proxy import router as proxy_router # noqa: E402
|
from routes.v1.history import router as history_router # noqa: E402
|
||||||
from routes.scenes import router as scenes_router # noqa: E402
|
from routes.v1.proxy import router as proxy_router # noqa: E402
|
||||||
|
from routes.v1.scenes import router as scenes_router # noqa: E402
|
||||||
|
|
||||||
from fastapi import FastAPI # noqa: E402
|
from fastapi import FastAPI # noqa: E402
|
||||||
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI(
|
||||||
|
title="RTC AIGC 后端",
|
||||||
|
description=(
|
||||||
|
"火山引擎 RTC AI 语音对话后端服务。\n\n"
|
||||||
|
"## 鉴权说明\n"
|
||||||
|
"- **内部接口**(`/getScenes` `/proxy` `/api/session/history`):由 java-mock 网关调用,"
|
||||||
|
"需附加 `X-Internal-Signature` HMAC 签名 Header。\n"
|
||||||
|
"- **LLM 回调**(`/api/chat_callback`):由火山引擎 RTC 平台回调,"
|
||||||
|
"需在 `Authorization: Bearer <key>` 中携带 `CUSTOM_LLM_API_KEY`。\n"
|
||||||
|
"- **调试接口**(`/debug/*`):无鉴权,仅用于本地开发。"
|
||||||
|
),
|
||||||
|
version="1.0.0",
|
||||||
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
@ -35,10 +51,17 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(proxy_router)
|
|
||||||
app.include_router(scenes_router)
|
@app.get("/health", include_in_schema=False)
|
||||||
app.include_router(chat_callback_router)
|
async def health():
|
||||||
app.include_router(debug_router)
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(proxy_router, prefix="/v1")
|
||||||
|
app.include_router(scenes_router, prefix="/v1")
|
||||||
|
app.include_router(chat_callback_router, prefix="/v1")
|
||||||
|
app.include_router(debug_router, prefix="/v1")
|
||||||
|
app.include_router(history_router, prefix="/v1")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|||||||
@ -12,6 +12,9 @@ from fastapi import Request
|
|||||||
# { session_key: { scene_id: { RoomId, UserId, TaskId } } }
|
# { session_key: { scene_id: { RoomId, UserId, TaskId } } }
|
||||||
_store: dict[str, dict[str, dict]] = {}
|
_store: dict[str, dict[str, dict]] = {}
|
||||||
|
|
||||||
|
# { room_id: [{ role, content }] }
|
||||||
|
_history_store: dict[str, list[dict]] = {}
|
||||||
|
|
||||||
|
|
||||||
def _key(request: Request) -> str:
|
def _key(request: Request) -> str:
|
||||||
forwarded = request.headers.get("X-Forwarded-For")
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
@ -33,3 +36,15 @@ def save_session(request: Request, scene_id: str, data: dict) -> None:
|
|||||||
|
|
||||||
def load_session(request: Request, scene_id: str) -> dict:
|
def load_session(request: Request, scene_id: str) -> dict:
|
||||||
return _store.get(_key(request), {}).get(scene_id, {})
|
return _store.get(_key(request), {}).get(scene_id, {})
|
||||||
|
|
||||||
|
|
||||||
|
def save_room_history(room_id: str, messages: list[dict]) -> None:
|
||||||
|
_history_store[room_id] = messages
|
||||||
|
|
||||||
|
|
||||||
|
def get_room_history(room_id: str) -> list[dict]:
|
||||||
|
return _history_store.get(room_id, [])
|
||||||
|
|
||||||
|
|
||||||
|
def clear_room_history(room_id: str) -> None:
|
||||||
|
_history_store.pop(room_id, None)
|
||||||
|
|||||||
5
java-mock/.env.example
Normal file
5
java-mock/.env.example
Normal 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
755
java-mock/API-FULL.md
Normal 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` | — | 内部签名密钥(**必填**) |
|
||||||
579
java-mock/data/conversations.json
Normal file
579
java-mock/data/conversations.json
Normal 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
27
java-mock/data/users.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
32
java-mock/middleware/auth.js
Normal file
32
java-mock/middleware/auth.js
Normal 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;
|
||||||
28
java-mock/middleware/internalSign.js
Normal file
28
java-mock/middleware/internalSign.js
Normal 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
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
22
java-mock/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
55
java-mock/routes/aiProxyRoutes.js
Normal file
55
java-mock/routes/aiProxyRoutes.js
Normal 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 — 存储历史对话上下文到 Python(start 前调用)
|
||||||
|
router.post('/session/history', authMiddleware, async (req, res) => {
|
||||||
|
await forwardToPython('/api/session/history', '', req.body, req.user.userId, res, req.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
109
java-mock/routes/authRoutes.js
Normal file
109
java-mock/routes/authRoutes.js
Normal 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;
|
||||||
131
java-mock/routes/conversationRoutes.js
Normal file
131
java-mock/routes/conversationRoutes.js
Normal 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
46
java-mock/server.js
Normal 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
17
java-mock/utils/db.js
Normal 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
13
java-mock/utils/hmac.js
Normal 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
795
plan/integration-plan.md
Normal 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()
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:同一网络下不同用户可能拥有相同 IP,UA 也可能相同,导致 Session 冲突。
|
||||||
|
|
||||||
|
### 4.2 改造后
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _key(request: Request) -> str:
|
||||||
|
# 优先使用 Java 网关传递的真实用户 ID
|
||||||
|
user_id = request.headers.get("X-Internal-User-Id")
|
||||||
|
if user_id:
|
||||||
|
return user_id
|
||||||
|
|
||||||
|
# 降级:本地开发无网关时,仍用 IP+UA 哈希
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
ip = (forwarded.split(",")[0].strip() if forwarded
|
||||||
|
else (request.client.host if request.client else "unknown"))
|
||||||
|
ua = request.headers.get("User-Agent", "")
|
||||||
|
return hashlib.sha256(f"{ip}:{ua}".encode()).hexdigest()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 身份关联:chat_callback 场景
|
||||||
|
|
||||||
|
`/api/chat_callback` 由火山 RTC 平台调用,不经过 Java,不携带用户身份。但可通过 RoomId 关联用户:
|
||||||
|
|
||||||
|
```
|
||||||
|
getScenes 阶段:
|
||||||
|
Python 生成 RoomId → 存储 { RoomId: userId } 映射
|
||||||
|
|
||||||
|
chat_callback 阶段:
|
||||||
|
火山平台请求中包含 RoomId/TaskId 上下文
|
||||||
|
Python 可通过 RoomId 反查 userId(如需要的话)
|
||||||
|
```
|
||||||
|
|
||||||
|
**当前阶段不需要实现**:因为对话历史由前端在 stop 时批量保存到 Java,不需要 Python 侧知道 userId。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 对话历史持久化
|
||||||
|
|
||||||
|
### 5.1 存储方案
|
||||||
|
|
||||||
|
**存储在 Java 侧**,理由:
|
||||||
|
- Java 已有数据库和 ORM(Spring Data JPA)
|
||||||
|
- 对话历史是业务数据,由 Java JWT 保护访问权限
|
||||||
|
- Python 保持无状态,只做 AI 逻辑
|
||||||
|
|
||||||
|
### 5.2 数据库表设计
|
||||||
|
|
||||||
|
#### conversation_session(对话会话表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE conversation_session (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id VARCHAR(64) NOT NULL COMMENT '用户ID(来自JWT)',
|
||||||
|
scene_id VARCHAR(64) NOT NULL COMMENT '场景ID',
|
||||||
|
room_id VARCHAR(128) NOT NULL COMMENT 'RTC房间ID',
|
||||||
|
started_at DATETIME NOT NULL COMMENT '会话开始时间',
|
||||||
|
ended_at DATETIME COMMENT '会话结束时间',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_user_id (user_id),
|
||||||
|
INDEX idx_created_at (created_at)
|
||||||
|
) COMMENT='AI语音对话会话';
|
||||||
|
```
|
||||||
|
|
||||||
|
#### conversation_message(对话消息表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE conversation_message (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
session_id BIGINT NOT NULL COMMENT '关联会话ID',
|
||||||
|
sender_type VARCHAR(16) NOT NULL COMMENT 'USER / AI',
|
||||||
|
content TEXT NOT NULL COMMENT '消息文本',
|
||||||
|
is_definite TINYINT(1) DEFAULT 1 COMMENT '是否最终确认文本',
|
||||||
|
sequence_num INT NOT NULL COMMENT '消息顺序号',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_session_id (session_id),
|
||||||
|
FOREIGN KEY (session_id) REFERENCES conversation_session(id) ON DELETE CASCADE
|
||||||
|
) COMMENT='AI语音对话消息';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 保存时机与方式
|
||||||
|
|
||||||
|
**方式**:前端 `client.stop()` 时,将内存中的 `msgHistory` 数组一次性 POST 到 Java。
|
||||||
|
|
||||||
|
**理由**:
|
||||||
|
- 对话消息通过 WebRTC Binary Message 传到前端,不走 HTTP
|
||||||
|
- 逐条保存需要每收到一条字幕就发一个 HTTP 请求,开销大
|
||||||
|
- 批量保存简单可靠
|
||||||
|
|
||||||
|
**意外关闭兜底**:使用 `navigator.sendBeacon` 在 `beforeunload` 事件中尝试保存。
|
||||||
|
|
||||||
|
### 5.4 前端保存逻辑
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在 AigcVoiceClient 的 stop() 方法中,离房前保存
|
||||||
|
async stop() {
|
||||||
|
if (!this.isJoined && !this.audioBotEnabled) return;
|
||||||
|
|
||||||
|
// 保存对话历史
|
||||||
|
if (this.msgHistory.length > 0) {
|
||||||
|
await this._saveConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 原有的停止逻辑 ...
|
||||||
|
}
|
||||||
|
|
||||||
|
async _saveConversation() {
|
||||||
|
try {
|
||||||
|
await fetch(`${this.serverUrl}/api/ai/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sceneId: this.sceneId,
|
||||||
|
roomId: this.roomId,
|
||||||
|
messages: this.msgHistory,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AigcVoiceClient] 保存对话历史失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**beforeunload 兜底**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 在 start() 方法中注册
|
||||||
|
this._beforeUnloadHandler = () => {
|
||||||
|
if (this.msgHistory.length > 0) {
|
||||||
|
navigator.sendBeacon(
|
||||||
|
`${this.serverUrl}/api/ai/conversations`,
|
||||||
|
new Blob([JSON.stringify({
|
||||||
|
sceneId: this.sceneId,
|
||||||
|
roomId: this.roomId,
|
||||||
|
messages: this.msgHistory,
|
||||||
|
authToken: this.authToken, // sendBeacon 不支持自定义 Header
|
||||||
|
})], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:`sendBeacon` 不支持自定义 Header。Java 侧需要额外支持从 body 中读取 authToken 进行鉴权,或者设计一个不需要 Authorization Header 的保存端点(如通过 URL 参数传递一次性 token)。
|
||||||
|
|
||||||
|
### 5.5 Java 端 API 设计
|
||||||
|
|
||||||
|
#### 保存对话 — `POST /api/ai/conversations`
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"sceneId": "Custom",
|
||||||
|
"roomId": "room_abc123",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"text": "你好,帮我查一下今天的考勤",
|
||||||
|
"userId": "user123",
|
||||||
|
"definite": true,
|
||||||
|
"paragraph": true,
|
||||||
|
"time": "2026-04-02T10:30:00.000Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "好的,我来帮你查询今天的考勤记录。",
|
||||||
|
"userId": "bot_xiaokuai",
|
||||||
|
"definite": true,
|
||||||
|
"paragraph": true,
|
||||||
|
"time": "2026-04-02T10:30:02.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"sessionId": 42
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**处理逻辑**:
|
||||||
|
1. 从 JWT 中提取 userId
|
||||||
|
2. 创建 `conversation_session` 记录
|
||||||
|
3. 遍历 messages,判断 `userId` 是否等于当前用户来区分 USER/AI
|
||||||
|
4. 批量插入 `conversation_message`
|
||||||
|
|
||||||
|
#### 查询对话列表 — `GET /api/ai/conversations`
|
||||||
|
|
||||||
|
**请求参数**:
|
||||||
|
|
||||||
|
| 参数 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| page | int | 页码,默认 1 |
|
||||||
|
| size | int | 每页条数,默认 20 |
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"total": 15,
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"sceneId": "Custom",
|
||||||
|
"roomId": "room_abc123",
|
||||||
|
"startedAt": "2026-04-02T10:30:00",
|
||||||
|
"endedAt": "2026-04-02T10:35:00",
|
||||||
|
"messageCount": 12,
|
||||||
|
"firstMessage": "你好,帮我查一下今天的考勤"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 查询对话详情 — `GET /api/ai/conversations/{id}`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"id": 42,
|
||||||
|
"sceneId": "Custom",
|
||||||
|
"roomId": "room_abc123",
|
||||||
|
"startedAt": "2026-04-02T10:30:00",
|
||||||
|
"endedAt": "2026-04-02T10:35:00",
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"senderType": "USER",
|
||||||
|
"content": "你好,帮我查一下今天的考勤",
|
||||||
|
"sequenceNum": 1,
|
||||||
|
"createdAt": "2026-04-02T10:30:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"senderType": "AI",
|
||||||
|
"content": "好的,我来帮你查询今天的考勤记录。",
|
||||||
|
"sequenceNum": 2,
|
||||||
|
"createdAt": "2026-04-02T10:30:02"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 删除对话 — `DELETE /api/ai/conversations/{id}`
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 需验证该会话属于当前 JWT 用户,防止越权删除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 各组件具体改动清单
|
||||||
|
|
||||||
|
### 6.1 Python 后端改动
|
||||||
|
|
||||||
|
#### 6.1.1 新建 `backend/middleware/__init__.py`
|
||||||
|
|
||||||
|
空文件。
|
||||||
|
|
||||||
|
#### 6.1.2 新建 `backend/middleware/internal_auth.py`
|
||||||
|
|
||||||
|
HMAC 签名验证中间件:
|
||||||
|
- 读取 `INTERNAL_SERVICE_SECRET` 环境变量
|
||||||
|
- 对非豁免路径验证 `X-Internal-Signature`
|
||||||
|
- 豁免路径:`/api/chat_callback`、`/debug/*`
|
||||||
|
- 密钥为空时跳过验证(本地开发兼容)
|
||||||
|
|
||||||
|
#### 6.1.3 修改 `backend/services/session_store.py`
|
||||||
|
|
||||||
|
修改 `_key()` 函数:优先使用 `X-Internal-User-Id` Header,降级使用 IP+UA 哈希。
|
||||||
|
|
||||||
|
#### 6.1.4 修改 `backend/server.py`
|
||||||
|
|
||||||
|
注册 `InternalAuthMiddleware`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from middleware.internal_auth import InternalAuthMiddleware
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(InternalAuthMiddleware)
|
||||||
|
app.add_middleware(CORSMiddleware, ...) # 已有
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.1.5 修改 `backend/.env.example`
|
||||||
|
|
||||||
|
添加:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ============ 服务间鉴权 ============
|
||||||
|
# Java 网关与 Python 之间的共享密钥(生产环境必须设置!)
|
||||||
|
INTERNAL_SERVICE_SECRET=
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Java 后端新增
|
||||||
|
|
||||||
|
#### 6.2.1 配置 `application.yml`
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ai:
|
||||||
|
python-backend-url: http://localhost:3001
|
||||||
|
internal-service-secret: your-shared-secret-key
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2.2 新建 `AiProxyController`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai")
|
||||||
|
public class AiProxyController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private AiProxyService aiProxyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理 getScenes 请求到 Python
|
||||||
|
*/
|
||||||
|
@PostMapping("/getScenes")
|
||||||
|
public ResponseEntity<String> getScenes(
|
||||||
|
@RequestBody(required = false) String body,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
return aiProxyService.forwardToPython(
|
||||||
|
"/getScenes", "Action=getScenes", body, user.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理 StartVoiceChat / StopVoiceChat 请求到 Python
|
||||||
|
*/
|
||||||
|
@PostMapping("/proxy")
|
||||||
|
public ResponseEntity<String> proxy(
|
||||||
|
@RequestParam String Action,
|
||||||
|
@RequestBody String body,
|
||||||
|
@AuthenticationPrincipal UserDetails user) {
|
||||||
|
return aiProxyService.forwardToPython(
|
||||||
|
"/proxy", "Action=" + Action, body, user.getUsername());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2.3 新建 `AiProxyService`
|
||||||
|
|
||||||
|
负责构造 HMAC 签名 Header,使用 `RestTemplate` 转发请求到 Python。
|
||||||
|
|
||||||
|
(详细代码见第 3.6 节)
|
||||||
|
|
||||||
|
#### 6.2.4 新建 `ConversationController`
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/ai/conversations")
|
||||||
|
public class ConversationController {
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public Result save(@RequestBody SaveConversationDTO dto,
|
||||||
|
@AuthenticationPrincipal UserDetails user) { ... }
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public Result list(@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size,
|
||||||
|
@AuthenticationPrincipal UserDetails user) { ... }
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public Result detail(@PathVariable Long id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) { ... }
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public Result delete(@PathVariable Long id,
|
||||||
|
@AuthenticationPrincipal UserDetails user) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6.2.5 新建 JPA 实体
|
||||||
|
|
||||||
|
- `ConversationSession` — 对应 `conversation_session` 表
|
||||||
|
- `ConversationMessage` — 对应 `conversation_message` 表
|
||||||
|
|
||||||
|
#### 6.2.6 新建 Repository
|
||||||
|
|
||||||
|
- `ConversationSessionRepository extends JpaRepository`
|
||||||
|
- `ConversationMessageRepository extends JpaRepository`
|
||||||
|
|
||||||
|
#### 6.2.7 新建 DTO
|
||||||
|
|
||||||
|
- `SaveConversationDTO` — 保存对话请求体
|
||||||
|
- `ConversationListVO` — 列表响应
|
||||||
|
- `ConversationDetailVO` — 详情响应
|
||||||
|
|
||||||
|
### 6.3 前端改动
|
||||||
|
|
||||||
|
#### 6.3.1 修改 `simple-frontend/aigc-voice-client.js`
|
||||||
|
|
||||||
|
**构造函数**新增 `authToken` 参数:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.serverUrl = options.serverUrl || 'http://localhost:3001';
|
||||||
|
this.authToken = options.authToken || null; // 新增:JWT Token
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`_post()` 方法**添加 Authorization Header:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async _post(path, action, body = {}) {
|
||||||
|
const url = `${this.serverUrl}${path}?Action=${action}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${this.authToken}`;
|
||||||
|
}
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**更新端点路径**(适配 Java 网关):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async _getScenes() {
|
||||||
|
return this._post('/api/ai/getScenes', 'getScenes'); // 原: /getScenes
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startVoiceChat(sceneId) {
|
||||||
|
return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: sceneId }); // 原: /proxy
|
||||||
|
}
|
||||||
|
|
||||||
|
async _stopVoiceChat(sceneId) {
|
||||||
|
return this._post('/api/ai/proxy', 'StopVoiceChat', { SceneID: sceneId }); // 原: /proxy
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:路径是否需要改取决于 Java 侧的路由设计。如果 Java 端 Controller 路径就是 `/api/ai/getScenes`,则前端需要改。如果用 Nginx/网关层做路径映射,前端可以保持不变。建议在前端用配置项控制路径前缀。
|
||||||
|
|
||||||
|
**`stop()` 方法**添加对话保存:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async stop() {
|
||||||
|
if (!this.isJoined && !this.audioBotEnabled) return;
|
||||||
|
|
||||||
|
// 新增:保存对话历史
|
||||||
|
if (this.msgHistory.length > 0) {
|
||||||
|
await this._saveConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... 原有停止逻辑 ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**新增方法**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async _saveConversation() { /* POST 到 /api/ai/conversations */ }
|
||||||
|
async getConversations(page, size) { /* GET /api/ai/conversations */ }
|
||||||
|
async getConversationDetail(id) { /* GET /api/ai/conversations/{id} */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 实施计划
|
||||||
|
|
||||||
|
### Phase 1:Python 内部鉴权 + 身份传递(1-2 天)
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | 新建 HMAC 验证中间件 | `backend/middleware/internal_auth.py` |
|
||||||
|
| 2 | 改造 session_store 使用 userId | `backend/services/session_store.py` |
|
||||||
|
| 3 | 注册中间件 | `backend/server.py` |
|
||||||
|
| 4 | 添加配置项 | `backend/.env.example`, `backend/.env` |
|
||||||
|
| 5 | 测试:无密钥时仍可直接调用 Python | 手动测试 |
|
||||||
|
|
||||||
|
### Phase 2:Java 代理层(2-3 天)
|
||||||
|
|
||||||
|
| 步骤 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 1 | 添加 Maven 依赖(如 commons-codec) |
|
||||||
|
| 2 | 创建 AiProxyService(HMAC 签名 + HTTP 转发) |
|
||||||
|
| 3 | 创建 AiProxyController(3 个代理端点) |
|
||||||
|
| 4 | 配置 application.yml |
|
||||||
|
| 5 | 测试:通过 Java 调用 Python 各端点正常 |
|
||||||
|
|
||||||
|
### Phase 3:对话历史持久化(2-3 天)
|
||||||
|
|
||||||
|
| 步骤 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| 1 | 执行建表 SQL |
|
||||||
|
| 2 | 创建 JPA 实体和 Repository |
|
||||||
|
| 3 | 创建 ConversationController + Service |
|
||||||
|
| 4 | 创建 DTO/VO |
|
||||||
|
| 5 | 测试:保存和查询 API 正常工作 |
|
||||||
|
|
||||||
|
### Phase 4:前端集成(1-2 天)
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 文件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | 添加 authToken 支持 | `aigc-voice-client.js` |
|
||||||
|
| 2 | 更新端点路径 | `aigc-voice-client.js` |
|
||||||
|
| 3 | 添加 stop 时保存对话逻辑 | `aigc-voice-client.js` |
|
||||||
|
| 4 | 添加 beforeunload 兜底 | `aigc-voice-client.js` |
|
||||||
|
| 5 | 端到端测试 | 全流程 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 验证清单
|
||||||
|
|
||||||
|
### 鉴权验证
|
||||||
|
- [ ] 无 HMAC 签名直接调 Python `/getScenes` → 返回 401
|
||||||
|
- [ ] 通过 Java 代理调 Python → 正常返回
|
||||||
|
- [ ] `INTERNAL_SERVICE_SECRET` 为空时直接调 Python → 正常返回(开发模式)
|
||||||
|
- [ ] 时间戳超过 5 分钟 → 返回 401
|
||||||
|
- [ ] 篡改 userId 但不改签名 → 返回 401
|
||||||
|
|
||||||
|
### 身份验证
|
||||||
|
- [ ] 用户 A 调 getScenes → 获得 RoomId-A
|
||||||
|
- [ ] 用户 B 调 getScenes → 获得 RoomId-B(不同于 A)
|
||||||
|
- [ ] 用户 A 调 StartVoiceChat → 使用 RoomId-A(不串到 B)
|
||||||
|
|
||||||
|
### 对话保存
|
||||||
|
- [ ] 完成语音对话 → stop → Java DB 中有对应 session + messages
|
||||||
|
- [ ] 消息的 sender_type 正确区分 USER/AI
|
||||||
|
- [ ] 消息顺序号正确
|
||||||
|
|
||||||
|
### 历史查询
|
||||||
|
- [ ] GET /api/ai/conversations → 返回当前用户的会话列表
|
||||||
|
- [ ] GET /api/ai/conversations/{id} → 返回对话详情
|
||||||
|
- [ ] 用户 A 查不到用户 B 的对话(权限隔离)
|
||||||
|
- [ ] DELETE 只能删除自己的对话
|
||||||
|
|
||||||
|
### 端到端
|
||||||
|
- [ ] 前端带 JWT → Java → Python → 获取场景 → 加入房间 → 语音对话 → stop → 历史已保存 → 查看历史
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 注意事项与风险
|
||||||
|
|
||||||
|
| 风险 | 说明 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| chat_callback 无法经 Java | 火山 RTC 平台直接调 Python | 架构已考虑,不影响 |
|
||||||
|
| sendBeacon 不支持自定义 Header | 意外关闭时兜底保存无法带 JWT | Java 侧额外支持 body 中读取 token,或使用一次性 token |
|
||||||
|
| 同用户多 Tab 打开 | Session 中的 RoomId 会被覆盖 | 可在前端生成 clientSessionId 附加到请求,Session key 变为 `userId:clientSessionId` |
|
||||||
|
| Python 重启丢 Session | 内存 Session 在 Python 重启后丢失 | 如需高可用,可改用 Redis。当前阶段可接受(重启后用户重新 init 即可) |
|
||||||
|
| 大量历史消息 | 长时间对话可能产生大量消息 | 前端 `msgHistory` 只保留 `definite=true` 的最终文本,不保存中间态 |
|
||||||
@ -83,12 +83,14 @@ const AgentStage = {
|
|||||||
class AigcVoiceClient {
|
class AigcVoiceClient {
|
||||||
/**
|
/**
|
||||||
* @param {Object} options
|
* @param {Object} options
|
||||||
* @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:3001'
|
* @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:8080'
|
||||||
* @param {string} [options.sceneId] - 指定场景 ID,不传则使用第一个
|
* @param {string} [options.sceneId] - 指定场景 ID,不传则使用第一个
|
||||||
|
* @param {string} [options.authToken] - JWT Token(通过 java-mock /api/auth/login 获取)
|
||||||
*/
|
*/
|
||||||
constructor(options = {}) {
|
constructor(options = {}) {
|
||||||
this.serverUrl = options.serverUrl || 'http://localhost:3001';
|
this.serverUrl = options.serverUrl || 'http://localhost:8080';
|
||||||
this.preferredSceneId = options.sceneId || null;
|
this.preferredSceneId = options.sceneId || null;
|
||||||
|
this.authToken = options.authToken || null;
|
||||||
|
|
||||||
// RTC 相关
|
// RTC 相关
|
||||||
this.engine = null;
|
this.engine = null;
|
||||||
@ -111,6 +113,10 @@ class AigcVoiceClient {
|
|||||||
|
|
||||||
// 对话历史
|
// 对话历史
|
||||||
this.msgHistory = [];
|
this.msgHistory = [];
|
||||||
|
// 继续上次对话时缓存的历史消息,start() 时会 POST 到后端
|
||||||
|
this._historyMessages = null;
|
||||||
|
// 继续的原始对话 ID(用于 stop 时追加到原记录而非新建)
|
||||||
|
this._continueFromId = null;
|
||||||
|
|
||||||
// 事件回调(使用者可覆写)
|
// 事件回调(使用者可覆写)
|
||||||
this.onAIThinking = null; // () => void
|
this.onAIThinking = null; // () => void
|
||||||
@ -132,9 +138,11 @@ class AigcVoiceClient {
|
|||||||
|
|
||||||
async _post(path, action, body = {}) {
|
async _post(path, action, body = {}) {
|
||||||
const url = `${this.serverUrl}${path}?Action=${action}`;
|
const url = `${this.serverUrl}${path}?Action=${action}`;
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (this.authToken) headers['Authorization'] = `Bearer ${this.authToken}`;
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers,
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
@ -155,15 +163,15 @@ class AigcVoiceClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _getScenes() {
|
async _getScenes() {
|
||||||
return this._post('/getScenes', 'getScenes');
|
return this._post('/api/ai/getScenes', 'getScenes');
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startVoiceChat(sceneId) {
|
async _startVoiceChat(sceneId) {
|
||||||
return this._post('/proxy', 'StartVoiceChat', { SceneID: sceneId });
|
return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: sceneId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async _stopVoiceChat(sceneId) {
|
async _stopVoiceChat(sceneId) {
|
||||||
return this._post('/proxy', 'StopVoiceChat', { SceneID: sceneId });
|
return this._post('/api/ai/proxy', 'StopVoiceChat', { SceneID: sceneId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
@ -197,6 +205,26 @@ class AigcVoiceClient {
|
|||||||
return { sceneId: this.sceneId, scenes: scenesArr };
|
return { sceneId: this.sceneId, scenes: scenesArr };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载历史对话作为上下文(在 init() 之后、start() 之前调用)
|
||||||
|
* @param {Array} messages - 来自 /api/ai/conversations/:id 的 messages 数组
|
||||||
|
* @param {string} [conversationId] - 原始对话 ID,stop 时会追加到该记录而非新建
|
||||||
|
*/
|
||||||
|
loadHistory(messages, conversationId) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
this._historyMessages = messages.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
this._continueFromId = conversationId || null;
|
||||||
|
// 展示历史展示(标记为 _historical 不保存到新记录)
|
||||||
|
this.msgHistory = messages.map((m) => ({
|
||||||
|
role: m.role,
|
||||||
|
content: m.content,
|
||||||
|
definite: true,
|
||||||
|
paragraph: true,
|
||||||
|
time: m.createdAt,
|
||||||
|
_historical: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换场景(需在 start() 之前或 stop() 之后调用)
|
* 切换场景(需在 start() 之前或 stop() 之后调用)
|
||||||
*/
|
*/
|
||||||
@ -282,7 +310,26 @@ class AigcVoiceClient {
|
|||||||
// 6. 开启麦克风
|
// 6. 开启麦克风
|
||||||
await this.enableMic();
|
await this.enableMic();
|
||||||
|
|
||||||
// 7. 启动 AI Bot
|
// 7. 如果有历史上下文,先 POST 到后端
|
||||||
|
if (this._historyMessages && this._historyMessages.length > 0) {
|
||||||
|
try {
|
||||||
|
await fetch(`${this.serverUrl}/api/ai/session/history`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
room_id: this.roomId,
|
||||||
|
messages: this._historyMessages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AigcVoiceClient] 上传历史失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 启动 AI Bot
|
||||||
await this._startAgent();
|
await this._startAgent();
|
||||||
|
|
||||||
console.log('[AigcVoiceClient] 通话已开始');
|
console.log('[AigcVoiceClient] 通话已开始');
|
||||||
@ -301,7 +348,12 @@ class AigcVoiceClient {
|
|||||||
// 2. 停止 AI Bot
|
// 2. 停止 AI Bot
|
||||||
await this._stopAgent();
|
await this._stopAgent();
|
||||||
|
|
||||||
// 3. 离房 & 销毁
|
// 3. 保存对话历史
|
||||||
|
if (this.msgHistory.length > 0) {
|
||||||
|
await this._saveConversation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 离房 & 销毁
|
||||||
try {
|
try {
|
||||||
await this.engine?.leaveRoom();
|
await this.engine?.leaveRoom();
|
||||||
window.VERTC?.destroyEngine(this.engine);
|
window.VERTC?.destroyEngine(this.engine);
|
||||||
@ -311,11 +363,52 @@ class AigcVoiceClient {
|
|||||||
this.isJoined = false;
|
this.isJoined = false;
|
||||||
this.isMicOn = false;
|
this.isMicOn = false;
|
||||||
this.msgHistory = [];
|
this.msgHistory = [];
|
||||||
|
this._historyMessages = null;
|
||||||
|
this._continueFromId = null;
|
||||||
this._emitStateChange();
|
this._emitStateChange();
|
||||||
|
|
||||||
console.log('[AigcVoiceClient] 通话已结束');
|
console.log('[AigcVoiceClient] 通话已结束');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _saveConversation() {
|
||||||
|
if (!this.authToken) return;
|
||||||
|
try {
|
||||||
|
const newMessages = this.msgHistory
|
||||||
|
.filter((m) => !m._historical && m.content)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content, time: m.time }));
|
||||||
|
if (newMessages.length === 0) return;
|
||||||
|
|
||||||
|
if (this._continueFromId) {
|
||||||
|
// 继续对话:追加到原记录
|
||||||
|
await fetch(`${this.serverUrl}/api/ai/conversations/${this._continueFromId}/append`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ messages: newMessages }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 全新对话:新建记录
|
||||||
|
await fetch(`${this.serverUrl}/api/ai/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.authToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sceneId: this.sceneId,
|
||||||
|
roomId: this.roomId,
|
||||||
|
messages: newMessages,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log('[AigcVoiceClient] 对话历史已保存');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[AigcVoiceClient] 保存对话历史失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// 麦克风控制
|
// 麦克风控制
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
@ -527,6 +620,7 @@ class AigcVoiceClient {
|
|||||||
if (!data || !this.audioBotEnabled) return;
|
if (!data || !this.audioBotEnabled) return;
|
||||||
|
|
||||||
const { text, definite, userId, paragraph } = data;
|
const { text, definite, userId, paragraph } = data;
|
||||||
|
if (!text) return; // 跳过空文本帧
|
||||||
|
|
||||||
// 更新消息历史
|
// 更新消息历史
|
||||||
this._appendMessage({ text, userId, definite, paragraph });
|
this._appendMessage({ text, userId, definite, paragraph });
|
||||||
@ -566,20 +660,24 @@ class AigcVoiceClient {
|
|||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
_appendMessage({ text, userId, definite, paragraph }) {
|
_appendMessage({ text, userId, definite, paragraph }) {
|
||||||
|
const role = userId === this.userId ? 'user' : 'assistant';
|
||||||
|
|
||||||
|
// 过滤 RTC 平台触发欢迎语的系统字符串
|
||||||
|
if (text === '欢迎语') return;
|
||||||
const lastMsg = this.msgHistory[this.msgHistory.length - 1];
|
const lastMsg = this.msgHistory[this.msgHistory.length - 1];
|
||||||
const isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph;
|
const isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph;
|
||||||
|
|
||||||
if (isNewSentence) {
|
if (isNewSentence) {
|
||||||
this.msgHistory.push({
|
this.msgHistory.push({
|
||||||
text,
|
role,
|
||||||
userId,
|
content: text,
|
||||||
definite: !!definite,
|
definite: !!definite,
|
||||||
paragraph: !!paragraph,
|
paragraph: !!paragraph,
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 话未说完,更新内容
|
// 话未说完,更新内容
|
||||||
lastMsg.text = text;
|
lastMsg.content = text;
|
||||||
lastMsg.definite = !!definite;
|
lastMsg.definite = !!definite;
|
||||||
lastMsg.paragraph = !!paragraph;
|
lastMsg.paragraph = !!paragraph;
|
||||||
lastMsg.time = new Date().toISOString();
|
lastMsg.time = new Date().toISOString();
|
||||||
|
|||||||
@ -250,13 +250,236 @@
|
|||||||
.text-overlay button svg { width: 20px; height: 20px; fill: #fff; }
|
.text-overlay button svg { width: 20px; height: 20px; fill: #fff; }
|
||||||
|
|
||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ============ 历史面板 ============ */
|
||||||
|
.history-panel {
|
||||||
|
position: absolute; top: 0; right: 0; bottom: 0; width: 340px; z-index: 40;
|
||||||
|
background: rgba(7,11,20,0.96); border-left: 1px solid rgba(255,255,255,0.07);
|
||||||
|
backdrop-filter: blur(24px); display: flex; flex-direction: column;
|
||||||
|
transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
}
|
||||||
|
.history-panel.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.history-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history-title { font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.7); }
|
||||||
|
.history-close {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: none;
|
||||||
|
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4);
|
||||||
|
cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.history-close:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
|
||||||
|
.history-list {
|
||||||
|
flex: 1; overflow-y: auto; padding: 8px 0;
|
||||||
|
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
padding: 12px 20px; cursor: pointer;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.history-item:hover { background: rgba(255,255,255,0.04); }
|
||||||
|
.history-item.active { background: rgba(16,185,129,0.06); }
|
||||||
|
|
||||||
|
.history-item-date {
|
||||||
|
font-size: 11px; color: rgba(255,255,255,0.25); margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.history-item-preview {
|
||||||
|
font-size: 13px; color: rgba(255,255,255,0.55); font-weight: 300;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.history-item-meta {
|
||||||
|
font-size: 11px; color: rgba(255,255,255,0.2); margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 对话详情 */
|
||||||
|
.history-detail {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(7,11,20,0.98);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1);
|
||||||
|
}
|
||||||
|
.history-detail.open { transform: translateX(0); }
|
||||||
|
|
||||||
|
.history-detail-header {
|
||||||
|
display: flex; align-items: center; gap: 12px;
|
||||||
|
padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history-back {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%; border: none;
|
||||||
|
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5);
|
||||||
|
cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history-back:hover { background: rgba(255,255,255,0.12); }
|
||||||
|
.history-detail-title { font-size: 13px; color: rgba(255,255,255,0.5); flex: 1; }
|
||||||
|
|
||||||
|
.history-messages {
|
||||||
|
flex: 1; overflow-y: auto; padding: 16px;
|
||||||
|
scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.history-msg {
|
||||||
|
max-width: 85%; padding: 8px 12px; border-radius: 12px;
|
||||||
|
font-size: 13px; line-height: 1.6; font-weight: 300;
|
||||||
|
}
|
||||||
|
.history-msg.user {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: rgba(37,99,235,0.2); color: rgba(255,255,255,0.65);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
.history-msg.bot {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.7);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-continue-btn {
|
||||||
|
margin: 16px; padding: 12px;
|
||||||
|
background: #10b981; border: none; border-radius: 10px;
|
||||||
|
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
|
||||||
|
transition: background 0.2s; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.history-continue-btn:hover { background: #059669; }
|
||||||
|
|
||||||
|
.history-empty {
|
||||||
|
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 13px; color: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 历史按钮(启动页和通话页) */
|
||||||
|
.hist-btn {
|
||||||
|
position: absolute; top: 20px; left: 20px; z-index: 22;
|
||||||
|
width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4);
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
backdrop-filter: blur(10px); transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.hist-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); }
|
||||||
|
.hist-btn svg { width: 16px; height: 16px; fill: currentColor; }
|
||||||
|
|
||||||
|
/* 历史消息在字幕区的样式 */
|
||||||
|
.caption-msg.historical { opacity: 0.35; font-size: 13px; }
|
||||||
|
.caption-divider {
|
||||||
|
text-align: center; font-size: 11px; color: rgba(255,255,255,0.2);
|
||||||
|
padding: 8px 0; letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 登录页 ============ */
|
||||||
|
.login-view {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; z-index: 30;
|
||||||
|
transition: opacity 0.5s ease;
|
||||||
|
}
|
||||||
|
.login-view.fade-out { opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
width: 320px; padding: 36px 32px;
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 20px; backdrop-filter: blur(20px);
|
||||||
|
display: flex; flex-direction: column; gap: 16px;
|
||||||
|
}
|
||||||
|
.login-title {
|
||||||
|
font-size: 18px; font-weight: 400; color: rgba(255,255,255,0.85);
|
||||||
|
text-align: center; margin-bottom: 4px; letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.login-sub {
|
||||||
|
font-size: 12px; color: rgba(255,255,255,0.25);
|
||||||
|
text-align: center; margin-top: -8px; margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.login-input {
|
||||||
|
width: 100%; padding: 11px 16px;
|
||||||
|
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 10px; color: #fff; font-size: 14px; outline: none;
|
||||||
|
transition: border-color 0.2s; font-weight: 300;
|
||||||
|
}
|
||||||
|
.login-input::placeholder { color: rgba(255,255,255,0.2); }
|
||||||
|
.login-input:focus { border-color: rgba(16,185,129,0.5); }
|
||||||
|
.login-btn {
|
||||||
|
width: 100%; padding: 12px;
|
||||||
|
background: #10b981; border: none; border-radius: 10px;
|
||||||
|
color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px;
|
||||||
|
transition: background 0.2s; margin-top: 4px;
|
||||||
|
}
|
||||||
|
.login-btn:hover { background: #059669; }
|
||||||
|
.login-btn:disabled { opacity: 0.4; cursor: wait; }
|
||||||
|
.login-error {
|
||||||
|
font-size: 12px; color: #f87171; text-align: center;
|
||||||
|
min-height: 16px; margin-top: -4px;
|
||||||
|
}
|
||||||
|
.login-user {
|
||||||
|
position: absolute; top: 20px; right: 20px; z-index: 25;
|
||||||
|
font-size: 12px; color: rgba(255,255,255,0.3);
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.login-user span { color: rgba(255,255,255,0.5); }
|
||||||
|
.logout-btn {
|
||||||
|
background: none; border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 6px; padding: 3px 10px; color: rgba(255,255,255,0.3);
|
||||||
|
font-size: 11px; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.logout-btn:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.6); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
|
|
||||||
|
<!-- ======== 登录页 ======== -->
|
||||||
|
<div class="login-view" id="loginView">
|
||||||
|
<div class="login-box">
|
||||||
|
<div class="login-title">AI 语音助手</div>
|
||||||
|
<div class="login-sub">admin / admin123 · user1 / user123</div>
|
||||||
|
<input class="login-input" id="loginUsername" placeholder="用户名" autocomplete="username"
|
||||||
|
onkeydown="if(event.key==='Enter')document.getElementById('loginPassword').focus()" />
|
||||||
|
<input class="login-input" id="loginPassword" type="password" placeholder="密码" autocomplete="current-password"
|
||||||
|
onkeydown="if(event.key==='Enter')handleLogin()" />
|
||||||
|
<div class="login-error" id="loginError"></div>
|
||||||
|
<button class="login-btn" id="loginBtn" onclick="handleLogin()">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已登录用户信息(右上角) -->
|
||||||
|
<div class="login-user hidden" id="userInfo">
|
||||||
|
<span id="userNickname"></span>
|
||||||
|
<button class="logout-btn" onclick="handleLogout()">退出</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 历史按钮(登录后显示,浮在左上角) -->
|
||||||
|
<button class="hist-btn hidden" id="histBtn" onclick="openHistory()" title="历史记录">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- ======== 历史面板 ======== -->
|
||||||
|
<div class="history-panel" id="historyPanel">
|
||||||
|
<div class="history-header">
|
||||||
|
<span class="history-title">对话历史</span>
|
||||||
|
<button class="history-close" onclick="closeHistory()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="history-list" id="historyList"></div>
|
||||||
|
|
||||||
|
<!-- 详情子面板(叠加在历史面板上) -->
|
||||||
|
<div class="history-detail" id="historyDetail">
|
||||||
|
<div class="history-detail-header">
|
||||||
|
<button class="history-back" onclick="closeDetail()">‹</button>
|
||||||
|
<span class="history-detail-title" id="detailTitle"></span>
|
||||||
|
</div>
|
||||||
|
<div class="history-messages" id="historyMessages"></div>
|
||||||
|
<button class="history-continue-btn" id="continueBtn" onclick="handleContinue()">
|
||||||
|
接着这次记录继续对话
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ======== 启动页 ======== -->
|
<!-- ======== 启动页 ======== -->
|
||||||
<div class="landing" id="landing">
|
<div class="landing hidden" id="landing">
|
||||||
<div class="landing-orb" onclick="handleStart()"></div>
|
<div class="landing-orb" onclick="handleStart()"></div>
|
||||||
<div class="landing-text">AI 语音助手</div>
|
<div class="landing-text">AI 语音助手</div>
|
||||||
<div class="landing-sub">点击光球或按钮开始对话</div>
|
<div class="landing-sub">点击光球或按钮开始对话</div>
|
||||||
@ -312,8 +535,110 @@
|
|||||||
<script src="./aigc-voice-client.js"></script>
|
<script src="./aigc-voice-client.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const client = new AigcVoiceClient({ serverUrl: `http://${location.hostname}:3001` });
|
const JAVA_MOCK_URL = `http://${location.hostname}:8080`;
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
// ============ 登录 ============
|
||||||
|
async function handleLogin() {
|
||||||
|
const username = $('loginUsername').value.trim();
|
||||||
|
const password = $('loginPassword').value.trim();
|
||||||
|
if (!username || !password) {
|
||||||
|
$('loginError').textContent = '请输入用户名和密码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('loginBtn').disabled = true;
|
||||||
|
$('loginBtn').textContent = '登录中...';
|
||||||
|
$('loginError').textContent = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${JAVA_MOCK_URL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json.message || '登录失败');
|
||||||
|
|
||||||
|
currentUser = { name: json.data.name, deptName: json.data.deptName, roleList: json.data.roleList };
|
||||||
|
const token = json.data.token;
|
||||||
|
|
||||||
|
// 初始化 AigcVoiceClient,指向 java-mock,带上 JWT
|
||||||
|
client = new AigcVoiceClient({ serverUrl: JAVA_MOCK_URL, authToken: token });
|
||||||
|
setupClientCallbacks();
|
||||||
|
|
||||||
|
// 注册 beforeunload 兜底保存
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if (client && client.msgHistory.length > 0) {
|
||||||
|
navigator.sendBeacon(
|
||||||
|
`${JAVA_MOCK_URL}/api/ai/conversations`,
|
||||||
|
new Blob([JSON.stringify({
|
||||||
|
sceneId: client.sceneId,
|
||||||
|
roomId: client.roomId,
|
||||||
|
messages: client.msgHistory
|
||||||
|
.filter((m) => !m._historical)
|
||||||
|
.map((m) => ({ role: m.role, content: m.content, time: m.time })),
|
||||||
|
})], { type: 'application/json' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('loginView').classList.add('fade-out');
|
||||||
|
$('userNickname').textContent = currentUser.name;
|
||||||
|
$('userInfo').classList.remove('hidden');
|
||||||
|
$('histBtn').classList.remove('hidden');
|
||||||
|
$('landing').classList.remove('hidden');
|
||||||
|
} catch (e) {
|
||||||
|
$('loginError').textContent = e.message;
|
||||||
|
$('loginBtn').disabled = false;
|
||||||
|
$('loginBtn').textContent = '登录';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
client = null;
|
||||||
|
currentUser = null;
|
||||||
|
$('landing').classList.add('hidden');
|
||||||
|
$('callView').classList.remove('active');
|
||||||
|
$('userInfo').classList.add('hidden');
|
||||||
|
$('histBtn').classList.add('hidden');
|
||||||
|
closeHistory();
|
||||||
|
$('loginView').classList.remove('fade-out');
|
||||||
|
$('loginUsername').value = '';
|
||||||
|
$('loginPassword').value = '';
|
||||||
|
$('loginBtn').disabled = false;
|
||||||
|
$('loginBtn').textContent = '登录';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupClientCallbacks() {
|
||||||
|
client.onStateChange = (state) => {
|
||||||
|
const mic = $('btnMic');
|
||||||
|
if (state.isMicOn) {
|
||||||
|
mic.classList.remove('muted');
|
||||||
|
$('micOn').classList.remove('hidden');
|
||||||
|
$('micOff').classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
mic.classList.add('muted');
|
||||||
|
$('micOn').classList.add('hidden');
|
||||||
|
$('micOff').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
client.onAIThinking = () => setOrbState('thinking', '思考中');
|
||||||
|
client.onAISpeaking = () => setOrbState('speaking', '');
|
||||||
|
client.onAIFinished = () => setOrbState('listening', '聆听中');
|
||||||
|
client.onAIInterrupted = () => {
|
||||||
|
setOrbState('listening', '已打断');
|
||||||
|
const msgs = client.msgHistory;
|
||||||
|
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||||
|
if (msgs[i].role === 'assistant') { msgs[i].isInterrupted = true; break; }
|
||||||
|
}
|
||||||
|
renderCaptions(msgs);
|
||||||
|
};
|
||||||
|
client.onSubtitle = () => renderCaptions(client.msgHistory);
|
||||||
|
client.onError = (err) => { console.error(err); setOrbState('idle', '出错了'); };
|
||||||
|
}
|
||||||
let textInputVisible = false;
|
let textInputVisible = false;
|
||||||
|
|
||||||
// ============ 光球状态 ============
|
// ============ 光球状态 ============
|
||||||
@ -326,11 +651,27 @@
|
|||||||
function renderCaptions(msgs) {
|
function renderCaptions(msgs) {
|
||||||
const area = $('captionArea');
|
const area = $('captionArea');
|
||||||
area.innerHTML = '';
|
area.innerHTML = '';
|
||||||
|
let hadHistory = false;
|
||||||
msgs.forEach((msg) => {
|
msgs.forEach((msg) => {
|
||||||
const isUser = msg.userId === client.userId;
|
if (msg._historical) {
|
||||||
|
hadHistory = true;
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `caption-msg historical ${msg.role === 'user' ? 'user' : 'bot'}`;
|
||||||
|
div.textContent = msg.content;
|
||||||
|
area.appendChild(div);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hadHistory) {
|
||||||
|
const divider = document.createElement('div');
|
||||||
|
divider.className = 'caption-divider';
|
||||||
|
divider.textContent = '— 本次对话 —';
|
||||||
|
area.appendChild(divider);
|
||||||
|
hadHistory = false;
|
||||||
|
}
|
||||||
|
const isUser = msg.role === 'user';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
|
div.className = `caption-msg ${isUser ? 'user' : 'bot'}`;
|
||||||
let html = escapeHtml(msg.text);
|
let html = escapeHtml(msg.content);
|
||||||
if (!isUser && msg.isInterrupted) {
|
if (!isUser && msg.isInterrupted) {
|
||||||
html += '<span class="interrupted-tag">已打断</span>';
|
html += '<span class="interrupted-tag">已打断</span>';
|
||||||
}
|
}
|
||||||
@ -341,11 +682,10 @@
|
|||||||
|
|
||||||
// 实时字幕:显示最后一条
|
// 实时字幕:显示最后一条
|
||||||
const last = msgs[msgs.length - 1];
|
const last = msgs[msgs.length - 1];
|
||||||
if (last) {
|
if (last && !last._historical) {
|
||||||
const isUser = last.userId === client.userId;
|
$('liveCaption').innerHTML = last.role === 'user'
|
||||||
$('liveCaption').innerHTML = isUser
|
? `<span class="user-text">${escapeHtml(last.content)}</span>`
|
||||||
? `<span class="user-text">${escapeHtml(last.text)}</span>`
|
: escapeHtml(last.content);
|
||||||
: escapeHtml(last.text);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -355,38 +695,6 @@
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ 回调 ============
|
|
||||||
client.onStateChange = (state) => {
|
|
||||||
const mic = $('btnMic');
|
|
||||||
if (state.isMicOn) {
|
|
||||||
mic.classList.remove('muted');
|
|
||||||
$('micOn').classList.remove('hidden');
|
|
||||||
$('micOff').classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
mic.classList.add('muted');
|
|
||||||
$('micOn').classList.add('hidden');
|
|
||||||
$('micOff').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
client.onAIThinking = () => setOrbState('thinking', '思考中');
|
|
||||||
client.onAISpeaking = () => setOrbState('speaking', '');
|
|
||||||
client.onAIFinished = () => setOrbState('listening', '聆听中');
|
|
||||||
client.onAIInterrupted = () => {
|
|
||||||
setOrbState('listening', '已打断');
|
|
||||||
const msgs = client.msgHistory;
|
|
||||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
|
||||||
if (msgs[i].userId !== client.userId) { msgs[i].isInterrupted = true; break; }
|
|
||||||
}
|
|
||||||
renderCaptions(msgs);
|
|
||||||
};
|
|
||||||
|
|
||||||
client.onSubtitle = () => renderCaptions(client.msgHistory);
|
|
||||||
client.onError = (err) => {
|
|
||||||
console.error(err);
|
|
||||||
setOrbState('idle', '出错了');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============ 操作 ============
|
// ============ 操作 ============
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
const btn = $('btnStart');
|
const btn = $('btnStart');
|
||||||
@ -420,6 +728,10 @@
|
|||||||
$('captionArea').innerHTML = '';
|
$('captionArea').innerHTML = '';
|
||||||
$('liveCaption').innerHTML = '';
|
$('liveCaption').innerHTML = '';
|
||||||
setOrbState('idle', '');
|
setOrbState('idle', '');
|
||||||
|
// 重置启动页提示文字
|
||||||
|
const sub = document.querySelector('.landing-sub');
|
||||||
|
sub.textContent = '点击光球或按钮开始对话';
|
||||||
|
sub.style.color = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleMic() { client.toggleMic(); }
|
function handleToggleMic() { client.toggleMic(); }
|
||||||
@ -437,12 +749,108 @@
|
|||||||
input.value = '';
|
input.value = '';
|
||||||
client.sendTextToLLM(text);
|
client.sendTextToLLM(text);
|
||||||
client.msgHistory.push({
|
client.msgHistory.push({
|
||||||
text, userId: client.userId,
|
role: 'user', content: text,
|
||||||
definite: true, paragraph: true, time: new Date().toISOString(),
|
definite: true, paragraph: true, time: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
renderCaptions(client.msgHistory);
|
renderCaptions(client.msgHistory);
|
||||||
toggleTextInput();
|
toggleTextInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 历史面板 ============
|
||||||
|
let selectedConversation = null;
|
||||||
|
|
||||||
|
async function openHistory() {
|
||||||
|
$('historyPanel').classList.add('open');
|
||||||
|
await loadHistoryList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeHistory() {
|
||||||
|
$('historyPanel').classList.remove('open');
|
||||||
|
closeDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHistoryList() {
|
||||||
|
const list = $('historyList');
|
||||||
|
list.innerHTML = '<div class="history-empty">加载中...</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations?size=50`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${client.authToken}` },
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
const items = json.data?.list || [];
|
||||||
|
if (items.length === 0) {
|
||||||
|
list.innerHTML = '<div class="history-empty">暂无历史记录</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = '';
|
||||||
|
items.forEach((item) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'history-item';
|
||||||
|
div.dataset.id = item.id;
|
||||||
|
const date = new Date(item.startedAt).toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="history-item-date">${date}</div>
|
||||||
|
<div class="history-item-preview">${escapeHtml(item.firstMessage || '(无文字记录)')}</div>
|
||||||
|
<div class="history-item-meta">${item.messageCount} 条消息 · ${item.sceneId}</div>
|
||||||
|
`;
|
||||||
|
div.addEventListener('click', () => openDetail(item.id));
|
||||||
|
list.appendChild(div);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
list.innerHTML = '<div class="history-empty">加载失败</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id) {
|
||||||
|
$('historyDetail').classList.add('open');
|
||||||
|
$('historyMessages').innerHTML = '<div style="color:rgba(255,255,255,0.2);padding:20px;font-size:13px">加载中...</div>';
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${JAVA_MOCK_URL}/api/ai/conversations/${id}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${client.authToken}` },
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
const conv = json.data;
|
||||||
|
selectedConversation = conv;
|
||||||
|
|
||||||
|
const date = new Date(conv.startedAt).toLocaleString('zh-CN', {
|
||||||
|
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
$('detailTitle').textContent = date + ' · ' + conv.sceneId;
|
||||||
|
|
||||||
|
const msgsEl = $('historyMessages');
|
||||||
|
msgsEl.innerHTML = '';
|
||||||
|
conv.messages.forEach((m) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `history-msg ${m.role === 'user' ? 'user' : 'bot'}`;
|
||||||
|
div.textContent = m.content;
|
||||||
|
msgsEl.appendChild(div);
|
||||||
|
});
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
$('historyMessages').innerHTML = '<div style="color:#f87171;padding:20px;font-size:13px">加载失败</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
$('historyDetail').classList.remove('open');
|
||||||
|
selectedConversation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleContinue() {
|
||||||
|
if (!selectedConversation) return;
|
||||||
|
const conv = selectedConversation; // closeHistory 会把 selectedConversation 置 null,先保存
|
||||||
|
client.loadHistory(conv.messages, conv.id);
|
||||||
|
closeHistory();
|
||||||
|
// 跳转到启动页,准备开始新对话
|
||||||
|
$('landing').classList.remove('fade-out', 'hidden');
|
||||||
|
$('callView').classList.remove('active');
|
||||||
|
// 在启动页显示提示
|
||||||
|
const sub = document.querySelector('.landing-sub');
|
||||||
|
sub.textContent = `将接续 ${new Date(conv.startedAt).toLocaleDateString('zh-CN')} 的对话`;
|
||||||
|
sub.style.color = 'rgba(16,185,129,0.6)';
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user