refactor: update API endpoints to use consistent path structure, changing /api/chat_callback to /chat_callback and adjusting related documentation and configurations accordingly.

This commit is contained in:
lengbone 2026-04-08 11:00:29 +08:00
parent 271f7345c9
commit 6daaae00d3
20 changed files with 544 additions and 397 deletions

View File

@ -9,4 +9,5 @@ __pycache__/
.vscode/ .vscode/
.DS_Store .DS_Store
*.md *.md
!prompts/*.md
*.log *.log

View File

@ -20,14 +20,14 @@ CUSTOM_AGENT_WELCOME_MESSAGE=你好,我是小块,有什么需要帮忙的吗
CUSTOM_INTERRUPT_MODE=0 CUSTOM_INTERRUPT_MODE=0
# ============ LLM 配置 (RTC OpenAPI 侧) ============ # ============ LLM 配置 (RTC OpenAPI 侧) ============
# RTC 会回调 CUSTOM_LLM_URL 指定的地址(通常是本后端的 /api/chat_callback # RTC 会回调 CUSTOM_LLM_URL 指定的地址(通常是本后端的 /v1/chat_callback
CUSTOM_LLM_THINKING_TYPE=disabled CUSTOM_LLM_THINKING_TYPE=disabled
CUSTOM_LLM_VISION_ENABLE=false CUSTOM_LLM_VISION_ENABLE=false
# 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址 # 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址
# 例如https://api.yourdomain.com/v1/api/chat_callback # 例如https://api.yourdomain.com/v1/chat_callback
CUSTOM_LLM_URL= CUSTOM_LLM_URL=
# 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token可留空 # 火山调用当前 backend 的 /v1/chat_callback 时使用的 Bearer Token可留空
CUSTOM_LLM_API_KEY= CUSTOM_LLM_API_KEY=
CUSTOM_LLM_MODEL_NAME= CUSTOM_LLM_MODEL_NAME=
CUSTOM_LLM_HISTORY_LENGTH= CUSTOM_LLM_HISTORY_LENGTH=
@ -39,8 +39,8 @@ CUSTOM_LLM_TEMPERATURE=
CUSTOM_LLM_TOP_P= CUSTOM_LLM_TOP_P=
CUSTOM_LLM_MAX_TOKENS= CUSTOM_LLM_MAX_TOKENS=
# ============ 本地 LLM 回调配置 (/api/chat_callback) ============ # ============ 本地 LLM 回调配置 (/v1/chat_callback) ============
# RTC 回调本后端的 /api/chat_callback 后, # RTC 回调本后端的 /v1/chat_callback 后,
# 本后端再用以下配置调用方舟(通过 OpenAI SDK # 本后端再用以下配置调用方舟(通过 OpenAI SDK
LOCAL_LLM_API_KEY= LOCAL_LLM_API_KEY=
LOCAL_LLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 LOCAL_LLM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
@ -90,3 +90,8 @@ CUSTOM_AVATAR_TOKEN=
# 启用后 LLM 可主动调用已注册的工具函数查询真实数据 # 启用后 LLM 可主动调用已注册的工具函数查询真实数据
TOOLS_ENABLED=true TOOLS_ENABLED=true
TOOLS_MAX_ROUNDS=5 # 单次对话最大工具调用轮数(防无限循环) TOOLS_MAX_ROUNDS=5 # 单次对话最大工具调用轮数(防无限循环)
# ============ API Tools业务接口工具 ============
API_TOOLS_BASE_URL=https://hskuaikuai.com:9000/test
API_TOOLS_TOKEN=
API_TOOLS_TIMEOUT=10

View File

@ -25,7 +25,7 @@ CUSTOM_INTERRUPT_MODE=0
# ============ LLM 配置 (RTC OpenAPI 侧) ============ # ============ LLM 配置 (RTC OpenAPI 侧) ============
# 测试环境回调地址 —— 改成 staging 服务器的公网地址 # 测试环境回调地址 —— 改成 staging 服务器的公网地址
CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/api/chat_callback CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/chat_callback
CUSTOM_LLM_API_KEY=your-staging-llm-api-key CUSTOM_LLM_API_KEY=your-staging-llm-api-key
CUSTOM_LLM_THINKING_TYPE=disabled CUSTOM_LLM_THINKING_TYPE=disabled
CUSTOM_LLM_VISION_ENABLE=false CUSTOM_LLM_VISION_ENABLE=false

View File

@ -1,59 +1,62 @@
# Python 后端 API 文档 # Python 后端 API 文档
> Base URL: `http://localhost:3001` > Base URL: `http://localhost:3001/v1`
> >
> 所有来自 **java-mock** 的请求须附加内部服务签名 Header见[内部鉴权协议](#内部鉴权协议) > 所有路径均挂载在 `/v1` 前缀下,完整路径如 `POST http://localhost:3001/v1/getScenes`
> >
> `/api/chat_callback` 由**火山引擎 RTC 平台**直接回调,走独立 API Key 鉴权。 > 来自 **java-mock** 的请求须附加 [内部签名 Header](#内部鉴权协议)。
>
--- > `/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 | `/getScenes` | java-mock | 内部签名 | 获取场景列表 & RTC 入房参数 |
| POST | `/proxy` | java-mock | 内部签名 | 转发 StartVoiceChat / StopVoiceChat 到火山引擎 | | POST | `/proxy?Action=xxx` | java-mock | 内部签名 | 开始/停止语音对话 |
| POST | `/api/session/history` | java-mock | 内部签名 | 写入房间历史上下文 | | POST | `/session/history` | java-mock | 内部签名 | 写入房间历史上下文 |
| POST | `/api/chat_callback` | 火山引擎 RTC 平台 | API Key | 自定义 LLM 回调,返回 SSE 流 | | POST | `/chat_callback` | 火山引擎 RTC | Bearer Token | 自定义 LLM 回调SSE 流式) |
| POST | `/debug/chat` | 开发调试 | 无 | 直接测试 LLM 对话 | | POST | `/debug/chat` | 开发调试 | 无 | 调试 LLM 对话 |
| GET | `/debug/rag` | 开发调试 | 无 | 测试 RAG 知识库检索 | | GET | `/debug/rag` | 开发调试 | 无 | 调试 RAG 检索 |
### 调用时序
```
前端 → java-mock → Python 后端
1. POST /getScenes ← 获取场景 + RTC 入房参数
2. POST /session/history ← (可选) 注入历史上下文
3. POST /proxy?Action=StartVoiceChat ← 启动语音对话
4. 火山引擎 RTC → POST /chat_callback ← 平台回调(自动,非 java-mock
5. POST /proxy?Action=StopVoiceChat ← 结束语音对话
```
--- ---
## 一、场景接口 ## 一、获取场景列表
### 1.1 获取场景列表
``` ```
POST /getScenes POST /v1/getScenes
``` ```
**鉴权:** 内部签名java-mock 调用) 返回所有已配置场景的信息和对应的 RTC 入房参数。每次调用会**重新生成** RoomId/UserId/Token/TaskId 并写入服务端 Session供后续 `StartVoiceChat` 自动取用。
**请求体:** `{}` 或空 ### 请求
**成功响应 200** **Headers内部签名**
| Header | 必填 | 说明 |
|--------|------|------|
| `X-Internal-Service` | ✅ | 固定 `java-gateway` |
| `X-Internal-User-Id` | ✅ | 当前登录用户 ID |
| `X-Internal-Timestamp` | ✅ | 毫秒时间戳(字符串) |
| `X-Internal-Signature` | ✅ | HMAC-SHA256 签名hex算法见[内部鉴权协议](#内部鉴权协议) |
**Body** 无(`{}` 或空均可)
### 成功响应 `200`
```json ```json
{ {
@ -65,19 +68,21 @@ POST /getScenes
{ {
"scene": { "scene": {
"id": "Custom", "id": "Custom",
"botName": "BotUser001", "name": "小块",
"icon": "https://lf3-rtc-demo.volccdn.com/obj/rtc-aigc-assets/DoubaoAvatar.png",
"botName": "agent-user-001",
"isInterruptMode": true, "isInterruptMode": true,
"isVision": false, "isVision": false,
"isScreenMode": false, "isScreenMode": false,
"isAvatarScene": false, "isAvatarScene": false,
"avatarBgUrl": null "avatarBgUrl": ""
}, },
"rtc": { "rtc": {
"AppId": "6xxxxxxx", "AppId": "6xxxxxxx",
"RoomId": "550e8400-e29b-41d4-a716-446655440000", "RoomId": "550e8400-e29b-41d4-a716-446655440000",
"UserId": "user-xyz", "UserId": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"Token": "AQBhMGI3Zm...", "Token": "001xxxxxxAQBhMGI3Zm...",
"TaskId": "task-001" "TaskId": "e5f6a7b8-1234-5678-9abc-def012345678"
} }
} }
] ]
@ -85,199 +90,35 @@ POST /getScenes
} }
``` ```
**场景字段说明:** **`Result.scenes[*].scene` 字段说明:**
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |------|------|------|
| id | string | 场景唯一标识,后续接口的 `SceneID` 取此值 | | `id` | string | 场景唯一标识,后续 `/proxy` 接口的 `SceneID` 取此值 |
| botName | string | AI Bot 在 RTC 房间中的 UserId | | `name` | string | 场景显示名称 |
| isInterruptMode | boolean | 是否开启打断模式InterruptMode === 0 | | `icon` | string | 场景头像 URL |
| isVision | boolean | 是否开启视觉能力 | | `botName` | string | AI Bot 在 RTC 房间中的 UserId |
| isScreenMode | boolean | 是否为屏幕共享模式 | | `isInterruptMode` | boolean | 是否开启用户打断模式 |
| isAvatarScene | boolean | 是否为数字人场景 | | `isVision` | boolean | 是否开启视觉理解能力 |
| avatarBgUrl | string\|null | 数字人背景图 URL | | `isScreenMode` | boolean | 是否为屏幕共享模式(视觉输入来自屏幕流) |
| `isAvatarScene` | boolean | 是否为数字人场景 |
| `avatarBgUrl` | string\|null | 数字人背景图 URL非数字人场景为空 |
**RTC 字段说明:** **`Result.scenes[*].rtc` 字段说明:**
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|---|---|---| |------|------|------|
| AppId | string | 火山引擎 RTC AppId | | `AppId` | string | 火山引擎 RTC AppId |
| RoomId | string | 本次会话分配的房间 IDUUID | | `RoomId` | string | 本次生成的房间 IDUUID前端入房和后续接口都需要 |
| UserId | string | 用户在 RTC 房间中的 UserId | | `UserId` | string | 本次生成的用户 IDUUID前端入房使用 |
| Token | string | RTC 入房 Token | | `Token` | string | RTC 入房 Token24 小时有效 |
| TaskId | string | 语音任务 IDStopVoiceChat 时需要 | | `TaskId` | string | 语音任务 ID`StopVoiceChat` 时需要 |
> **副作用:** 该接口会将 `RoomId / UserId / TaskId` 写入服务端 Session供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递 > **注意:** 每次调用 `getScenes` 都会重新生成 `RoomId`/`UserId`/`Token`/`TaskId`。必须在 `getScenes` 之后、`StartVoiceChat` 之前使用同一套参数
**失败响应:** ### 失败响应
```json **鉴权失败 `401`**
{
"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 ```json
{ {
@ -286,82 +127,253 @@ POST /api/session/history
} }
``` ```
**配置错误(缺少环境变量等):**
```json
{
"ResponseMetadata": {
"Action": "getScenes",
"Error": {
"Code": -1,
"Message": "Custom 场景缺少以下环境变量: CUSTOM_ACCESS_KEY_ID, CUSTOM_SECRET_KEY"
}
}
}
```
--- ---
## 四、LLM 回调接口 ## 二、开始/停止语音对话
### 4.1 自定义 LLM 回调SSE
> 由**火山引擎 RTC 平台**在用户发言后自动回调。返回 OpenAI 兼容格式的 SSE 流。
``` ```
POST /api/chat_callback?room_id=<room_id> POST /v1/proxy?Action={Action}&Version={Version}
``` ```
**鉴权:** `Authorization: Bearer <CUSTOM_LLM_API_KEY>`Header 或 Query 参数) 带 SigV4 签名转发到火山引擎 RTC OpenAPI。内部会自动从 Session 取回 `getScenes` 分配的房间参数。
### 请求
**Headers内部签名** 同 [getScenes](#请求)
**Query 参数:** **Query 参数:**
| 字段 | 必填 | 说明 | | 参数 | 类型 | 必填 | 默认值 | 说明 |
|---|---|---| |------|------|------|--------|------|
| room_id | ❌ | 房间 ID。传入时自动从缓存取历史上下文并 prepend 到 messages | | `Action` | string | ✅ | — | `StartVoiceChat``StopVoiceChat` |
| `Version` | string | ❌ | 环境变量 `RTC_OPENAPI_VERSION`,兜底 `2025-06-01` | 火山引擎 OpenAPI 版本 |
**请求体JSON** **BodyJSON**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |------|------|------|------|
| messages | array | ✅ | 对话消息列表,最后一条必须是 `user` 角色 | | `SceneID` | string | ✅ | 场景 ID`getScenes` 返回的 `scene.id`(当前固定为 `"Custom"` |
| temperature | float | ❌ | 采样温度 |
| max_tokens | int | ❌ | 最大生成 token 数 |
| top_p | float | ❌ | Top-P 采样 |
`messages` 中每条消息:
| 字段 | 类型 | 说明 |
|---|---|---|
| role | string | `"user"` / `"assistant"` / `"system"` |
| content | string | 消息文本 |
**请求示例:** **请求示例:**
```json ```json
{ {
"messages": [ "SceneID": "Custom"
{ "role": "user", "content": "今天办公室出勤情况咋样" }
],
"temperature": 0.7,
"max_tokens": 1024
} }
``` ```
**处理逻辑:** ### StartVoiceChat 处理逻辑
1. 验证 API Key 1. 从 Session 取回 `getScenes` 时生成的 `RoomId`、`UserId`、`TaskId`
2. 过滤掉 `content === "欢迎语"` 的触发词消息RTC 平台自动发送,非真实用户输入) 2. 将 `room_id` 追加到 LLM 回调 URL`?room_id={RoomId}`),使 `/chat_callback` 能关联历史上下文
3. 若有 `room_id`,从缓存取历史并 prepend 到 messages 前 3. 使用 AK/SK 对请求做 SigV4 签名
4. 调用本地 LLM 服务(工具调用 / RAG 按需触发) 4. 转发到 `https://rtc.volcengineapi.com?Action=StartVoiceChat`
5. 以 SSE 流返回结果
**成功响应 200text/event-stream** ### StartVoiceChat 成功响应 `200`
```json
{
"ResponseMetadata": {
"RequestId": "2025070100000000000000000000abcd",
"Action": "StartVoiceChat",
"Version": "2025-06-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
### StopVoiceChat 处理逻辑
1. 从 Session 取回 `RoomId`、`TaskId`
2. **清除该房间的历史上下文缓存**`session/history` 写入的数据)
3. 使用 AK/SK 签名后转发到火山引擎
### StopVoiceChat 成功响应 `200`
```json
{
"ResponseMetadata": {
"RequestId": "2025070100000000000000000000efgh",
"Action": "StopVoiceChat",
"Version": "2025-06-01",
"Service": "rtc"
},
"Result": {
"Message": "success"
}
}
```
### 失败响应
**鉴权失败 `401`**
```json
{
"code": 401,
"message": "鉴权失败"
}
```
**参数/配置错误HTTP 200但包含 Error**
```json
{
"ResponseMetadata": {
"Action": "StartVoiceChat",
"Error": {
"Code": -1,
"Message": "SceneID 不能为空SceneID 用于指定场景配置"
}
}
}
```
| 错误场景 | Error.Message |
|----------|---------------|
| Action 为空 | `Action 不能为空` |
| SceneID 为空 | `SceneID 不能为空SceneID 用于指定场景配置` |
| 场景不存在 | `{SceneID} 不存在,请先配置对应场景。` |
| AK/SK 缺失 | `Custom 场景的 AccountConfig.accessKeyId 不能为空` |
| 火山引擎接口报错 | 透传火山引擎原始错误信息 |
---
## 三、写入历史上下文
``` ```
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"今"},...}]} POST /v1/session/history
```
data: {"id":"chatcmpl-xxx","object":"chat.completion.chunk","choices":[{"delta":{"content":"天"},...}]} `StartVoiceChat` **之前**调用,将上一次的对话历史注入该房间的上下文缓存。后续火山引擎 RTC 回调 `/chat_callback` 时,会自动将这些历史消息 prepend 到每次 LLM 请求的 messages 前面,实现跨会话的上下文延续。
### 请求
**Headers内部签名** 同 [getScenes](#请求)
**BodyJSON**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `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": "今天全部门出勤率百分之九十五,共二十三人到岗。" }
]
}
```
### 成功响应 `200`
```json
{
"code": 200
}
```
### 失败响应
**鉴权失败 `401`**
```json
{
"code": 401,
"message": "鉴权失败"
}
```
**Body 校验失败 `422`FastAPI 自动校验):**
```json
{
"detail": [
{
"type": "missing",
"loc": ["body", "room_id"],
"msg": "Field required"
}
]
}
```
> **注意:**
> - 每次调用会**覆盖**该 `room_id` 下已有的历史,不是追加
> - `StopVoiceChat` 时会自动清除该房间的历史缓存
> - 如果不需要上下文延续(新对话),可以跳过此接口
---
## 四、自定义 LLM 回调SSE
> 此接口由**火山引擎 RTC 平台**自动回调,不经过 java-mock。
```
POST /v1/chat_callback?room_id={room_id}
```
**鉴权:** `Authorization: Bearer <CUSTOM_LLM_API_KEY>`
**Query 参数:**
| 参数 | 必填 | 说明 |
|------|------|------|
| `room_id` | ❌ | 房间 ID`StartVoiceChat` 自动追加到回调 URL 中 |
**BodyJSON**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `messages` | array | ✅ | 对话消息列表,最后一条必须是 `user` 角色 |
| `temperature` | float | ❌ | 采样温度 |
| `max_tokens` | int | ❌ | 最大生成 token 数 |
| `top_p` | float | ❌ | Top-P 采样 |
**成功响应 `200``text/event-stream`**
```
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"今"}}]}
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"天"}}]}
data: [DONE] data: [DONE]
``` ```
**失败响应SSE 格式HTTP 状态码对应):** **失败响应:**
``` | HTTP 状态码 | Error.Code | 触发场景 |
data: {"error":{"code":"AuthenticationError","message":"API Key 无效"}} |-------------|------------|----------|
data: [DONE]
```
| HTTP 状态码 | code | 触发场景 |
|---|---|---|
| 401 | `AuthenticationError` | API Key 无效 | | 401 | `AuthenticationError` | API Key 无效 |
| 400 | `BadRequest` | messages 为空 / 最后一条不是 user | | 400 | `BadRequest` | messages 为空 / 最后一条不是 user |
| 500 | `InternalError` | LLM 初始化失败 / 请求解析失败 | | 500 | `InternalError` | LLM 初始化失败 / 请求解析失败 |
@ -370,128 +382,101 @@ data: [DONE]
## 五、调试接口 ## 五、调试接口
> 仅供本地开发使用,无鉴权。 > 仅供本地开发,无鉴权。
### 5.1 调试聊天 ### 5.1 调试聊天
``` ```
POST /debug/chat POST /v1/debug/chat
``` ```
直接发消息给 LLM响应为纯文本流非 SSE。完成后在服务端终端输出可复用的 `history` JSON 结构。
**请求体JSON**
| 字段 | 类型 | 必填 | 说明 | | 字段 | 类型 | 必填 | 说明 |
|---|---|---|---| |------|------|------|------|
| history | array | ❌ | 历史消息列表,格式同 `messages`,默认空 | | `history` | array | ❌ | 历史消息列表(`role` + `content`),默认空 |
| question | string | ✅ | 本次用户提问 | | `question` | string | ✅ | 本次用户提问 |
**请求示例:** **响应:** `200 text/plain` 流式文本
```json
{
"history": [
{ "role": "assistant", "content": "你好,有什么可以帮你?" }
],
"question": "今天办公室有多少人打卡?"
}
```
**成功响应 200text/plain 流式):**
```
今天办公室一共九人,目前出勤率为 100%。
```
---
### 5.2 调试 RAG 检索 ### 5.2 调试 RAG 检索
``` ```
GET /debug/rag?query=<query> GET /v1/debug/rag?query={query}
``` ```
测试知识库检索,返回检索到的原始上下文内容。 | 参数 | 必填 | 说明 |
|------|------|------|
| `query` | ✅ | 检索问题 |
**Query 参数:** **响应 `200`**
| 字段 | 必填 | 说明 |
|---|---|---|
| query | ✅ | 检索问题 |
**成功响应 200**
```json ```json
{ {
"query": "今天出勤情况", "query": "今天出勤情况",
"retrieved_context": "#### 今天出勤数据\n办公室共 9 人...", "retrieved_context": "检索到的知识文本...",
"length": 128, "length": 128,
"status": "success" "status": "success"
} }
``` ```
无结果时 `status``"no_results_or_error"``retrieved_context` 为 `null`
--- ---
## 内部鉴权协议 ## 内部鉴权协议
> `/getScenes`、`/proxy`、`/api/session/history` 均启用此鉴权。 > `/getScenes`、`/proxy`、`/session/history` 均启用此鉴权。
**java-mock 发送方**在请求头附加: **java-mock 发送方**在请求头附加:
| Header | 说明 | | Header | 必填 | 说明 |
|---|---| |--------|------|------|
| `X-Internal-Service` | 固定值 `java-gateway` | | `X-Internal-Service` | ✅ | 固定值 `java-gateway` |
| `X-Internal-User-Id` | 当前登录用户 ID | | `X-Internal-User-Id` | ✅ | 当前登录用户 ID |
| `X-Internal-Timestamp` | 毫秒级 Unix 时间戳(字符串) | | `X-Internal-Timestamp` | ✅ | 毫秒级 Unix 时间戳(字符串) |
| `X-Internal-Signature` | HMAC-SHA256 签名hex | | `X-Internal-Signature` | ✅ | HMAC-SHA256 签名hex |
| `X-User-Name` | URL 编码的用户显示名 | | `X-User-Name` | ❌ | URL 编码的用户显示名透传Python 侧暂未使用) |
| `X-User-Sex` | 性别 | | `X-User-Sex` | ❌ | 性别 |
| `X-User-Is-Driver` | `"true"` / `"false"` | | `X-User-Dept-Name` | ❌ | URL 编码的部门名称 |
| `X-User-Dept-Id` | 部门 ID |
| `X-User-Dept-Name` | URL 编码的部门名称 |
| `X-User-Role-List` | URL 编码的角色列表 JSON |
**签名算法:** **签名算法:**
``` ```
message = "java-gateway:{userId}:{毫秒时间戳}" message = "java-gateway:{userId}:{毫秒时间戳}"
signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) // hex signature = HMAC-SHA256(INTERNAL_SERVICE_SECRET, message) → hex 编码
``` ```
**Python 接收方验证逻辑`security/internal_auth.py`** **Python 接收方验证逻辑:**
1. `INTERNAL_SERVICE_SECRET` 未配置 → 直接放行(开发环境兼容) 1. `INTERNAL_SERVICE_SECRET` 未配置 → **直接放行**(开发环境兼容)
2. 校验 4 个必要字段是否存在,`X-Internal-Service` 必须为 `java-gateway` 2. 校验 `X-Internal-Service` 必须为 `java-gateway`,且 4 个必要 Header 非空
3. 时间窗口`abs(now_ms - timestamp) ≤ 5分钟`(防重放) 3. 时间窗口校验:`|当前时间 - timestamp| ≤ 5 分钟`(防重放)
4. 重新计算 HMAC`hmac.compare_digest` 常量时间比较(防时序攻击) 4. 重新计算 HMAC`hmac.compare_digest` 常量时间比较(防时序攻击)
--- ---
## 通用错误结构 ## 通用错误结构
**内部接口getScenes / proxy / session 系列** 返回火山引擎风格: **内部接口getScenes / proxy / session/history** 返回火山引擎风格:
```json ```json
{ {
"ResponseMetadata": { "ResponseMetadata": {
"Action": "xxx", "Action": "操作名",
"Error": { "Error": {
"Code": "InvalidParameter", "Code": -1,
"Message": "SceneID 不能为空" "Message": "错误描述"
} }
} }
} }
``` ```
**chat_callback** 返回 SSE 格式错误 **chat_callback** 返回 JSON 格式错误(非 SSE
``` ```json
data: {"error":{"code":"BadRequest","message":"messages 不能为空"}} {
"Error": {
data: [DONE] "Code": "BadRequest",
"Message": "messages 不能为空"
}
}
``` ```
--- ---
@ -499,10 +484,11 @@ data: [DONE]
## 环境变量 ## 环境变量
| 变量名 | 必填 | 说明 | | 变量名 | 必填 | 说明 |
|---|---|---| |--------|------|------|
| `INTERNAL_SERVICE_SECRET` | ✅ | 内部服务签名密钥,与 java-mock 侧保持一致 | | `INTERNAL_SERVICE_SECRET` | ✅ | 内部服务签名密钥,需与 java-mock 侧一致 |
| `CUSTOM_LLM_API_KEY` | ✅ | `chat_callback` 接口的鉴权 Key配置到火山引擎 RTC 场景中 | | `CUSTOM_LLM_API_KEY` | ❌ | `/chat_callback` 的 Bearer Token留空则跳过鉴权 |
| `OPENAI_API_KEY` | ✅(或同类) | 接入 LLM 所需的 API Key | | `LOCAL_LLM_API_KEY` | ✅ | 方舟 LLM API Key |
| `OPENAI_BASE_URL` | ❌ | 自定义 LLM Base URL接入本地模型时使用 | | `LOCAL_LLM_MODEL` | ✅ | 方舟端点 ID |
| `LLM_MODEL` | ❌ | 指定模型名称 | | `LOCAL_LLM_BASE_URL` | ✅ | 方舟 API 地址 |
| `RAG_*` | ❌ | 知识库相关配置(详见 `utils/env.py` |
完整变量列表见 `.env.example`

View File

@ -128,9 +128,9 @@ LOCAL_LLM_API_KEY=你的Ark API Key
LOCAL_LLM_MODEL=你的Ark端点ID LOCAL_LLM_MODEL=你的Ark端点ID
# 关键:填服务器公网 IP 或域名 # 关键:填服务器公网 IP 或域名
CUSTOM_LLM_URL=http://你的公网IP:3001/v1/api/chat_callback CUSTOM_LLM_URL=http://你的公网IP:3001/v1/chat_callback
# 有域名 + HTTPS 则改为: # 有域名 + HTTPS 则改为:
# CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/api/chat_callback # CUSTOM_LLM_URL=https://your-staging-domain.example.com/v1/chat_callback
``` ```
> 火山引擎 RTC 平台需要能主动回调 `CUSTOM_LLM_URL`,所以这里必须填**公网地址**localhost 无效。 > 火山引擎 RTC 平台需要能主动回调 `CUSTOM_LLM_URL`,所以这里必须填**公网地址**localhost 无效。
@ -238,14 +238,14 @@ docker run -d --env-file /secure/prod.env -p 3001:3001 aigc-backend:${IMAGE_TAG}
| 要求 | 原因 | | 要求 | 原因 |
|------|------| |------|------|
| idle timeout ≥ 75s | 容器 `--timeout-keep-alive 75`LB 超时需更长 | | idle timeout ≥ 75s | 容器 `--timeout-keep-alive 75`LB 超时需更长 |
| 关闭响应缓冲 | `/api/chat_callback` 是 SSE 流式响应Nginx 需设 `proxy_buffering off` | | 关闭响应缓冲 | `/v1/chat_callback` 是 SSE 流式响应Nginx 需设 `proxy_buffering off` |
| TLS 在 LB 层终止 | 容器内不处理 HTTPS | | TLS 在 LB 层终止 | 容器内不处理 HTTPS |
| 健康检查路径 `GET /health` | 返回 `{"status":"ok"}`HTTP 200 | | 健康检查路径 `GET /health` | 返回 `{"status":"ok"}`HTTP 200 |
| sticky session如需水平扩展| 会话存储在内存中,同一客户端需路由到同一容器 | | sticky session如需水平扩展| 会话存储在内存中,同一客户端需路由到同一容器 |
Nginx 反向代理最小配置片段: Nginx 反向代理最小配置片段:
```nginx ```nginx
location /v1/api/chat_callback { location /v1/chat_callback {
proxy_pass http://backend:3001; proxy_pass http://backend:3001;
proxy_buffering off; proxy_buffering off;
proxy_cache off; proxy_cache off;

View File

@ -4,7 +4,7 @@
- 根据 `backend/.env``backend/config/custom_scene.py` 构建 `Custom` 场景配置 - 根据 `backend/.env``backend/config/custom_scene.py` 构建 `Custom` 场景配置
- 代理调用火山 RTC OpenAPI - 代理调用火山 RTC OpenAPI
- 在同一个 FastAPI 进程里提供本地 `CustomLLM` 回调接口 `/api/chat_callback` - 在同一个 FastAPI 进程里提供本地 `CustomLLM` 回调接口 `/chat_callback`
## 环境要求 ## 环境要求
@ -68,7 +68,7 @@ cp .env.example .env
当前 `backend` 自己对外提供回调接口,火山 RTC 会回调 `CUSTOM_LLM_URL` 指定的地址: 当前 `backend` 自己对外提供回调接口,火山 RTC 会回调 `CUSTOM_LLM_URL` 指定的地址:
```dotenv ```dotenv
CUSTOM_LLM_URL=http://127.0.0.1:3001/api/chat_callback CUSTOM_LLM_URL=http://127.0.0.1:3001/v1/chat_callback
CUSTOM_LLM_API_KEY=your-callback-token CUSTOM_LLM_API_KEY=your-callback-token
``` ```
@ -78,14 +78,14 @@ CUSTOM_LLM_API_KEY=your-callback-token
2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如: 2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如:
```dotenv ```dotenv
CUSTOM_LLM_URL=https://api.yourdomain.com/v1/api/chat_callback CUSTOM_LLM_URL=https://api.yourdomain.com/v1/chat_callback
``` ```
`CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token如果你不需要这层鉴权可以留空。 `CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token如果你不需要这层鉴权可以留空。
### 当前 backend 内置的本地 LLM 回调配置 ### 当前 backend 内置的本地 LLM 回调配置
`/api/chat_callback` 内部会通过 OpenAI SDK 调用方舟,因此还需要: `/chat_callback` 内部会通过 OpenAI SDK 调用方舟,因此还需要:
```dotenv ```dotenv
LOCAL_LLM_API_KEY=your-ark-api-key LOCAL_LLM_API_KEY=your-ark-api-key
@ -153,7 +153,7 @@ RAG_CONTEXT_FILE=
2. 环境变量 `RTC_OPENAPI_VERSION` 2. 环境变量 `RTC_OPENAPI_VERSION`
3. 默认值 `2025-06-01` 3. 默认值 `2025-06-01`
### `POST /api/chat_callback` ### `POST /chat_callback`
这是当前 backend 内置的 `CustomLLM` 回调接口,也是你配置给火山 `LLMConfig.Url` 的目标地址。 这是当前 backend 内置的 `CustomLLM` 回调接口,也是你配置给火山 `LLMConfig.Url` 的目标地址。

View File

@ -1,5 +1,5 @@
""" """
POST /api/chat_callback 自定义 LLM 回调SSE 流式响应 POST /chat_callback 自定义 LLM 回调SSE 流式响应
""" """
import json import json
@ -17,7 +17,7 @@ router = APIRouter(tags=["LLM 回调"])
@router.post( @router.post(
"/api/chat_callback", "/chat_callback",
summary="自定义 LLM 回调SSE 流式)", summary="自定义 LLM 回调SSE 流式)",
description=( description=(
"由**火山引擎 RTC 平台**在用户发言后自动回调,返回 OpenAI 兼容格式的 SSE 流。\n\n" "由**火山引擎 RTC 平台**在用户发言后自动回调,返回 OpenAI 兼容格式的 SSE 流。\n\n"
@ -120,7 +120,7 @@ async def chat_callback(request: Request, body: ChatCallbackRequest):
raise raise
except Exception as exc: except Exception as exc:
has_error = True has_error = True
print(f"❌ /api/chat_callback 流式输出失败: {exc}") print(f"❌ /chat_callback 流式输出失败: {exc}")
if has_error: if has_error:
print("⚠️ 已提前结束当前 SSE 流") print("⚠️ 已提前结束当前 SSE 流")

View File

@ -32,10 +32,8 @@ async def debug_chat(request: DebugChatRequest):
current_messages.append({"role": "user", "content": request.question}) current_messages.append({"role": "user", "content": request.question})
start_time = time.time() start_time = time.time()
rag_context = await rag_service.retrieve(request.question)
stream_iterator = local_llm_service.chat_stream( stream_iterator = local_llm_service.chat_stream(
history_messages=current_messages, history_messages=current_messages,
rag_context=rag_context,
) )
def generate_text(): def generate_text():

View File

@ -1,10 +1,11 @@
""" """
POST /api/session/history 存储历史对话上下文 java-mock 内部调用 POST /session/history 存储历史对话上下文 java-mock 内部调用
""" """
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from schemas.responses import AuthFailResponse, SessionHistoryResponse
from security.internal_auth import verify_internal_request from security.internal_auth import verify_internal_request
from services.session_store import save_room_history from services.session_store import save_room_history
@ -22,16 +23,17 @@ class SetHistoryRequest(BaseModel):
@router.post( @router.post(
"/api/session/history", "/session/history",
summary="写入房间历史上下文", summary="写入房间历史上下文",
description=( description=(
"在 `StartVoiceChat` 之前调用,将历史对话注入该房间的上下文缓存。\n\n" "在 `StartVoiceChat` 之前调用,将历史对话注入该房间的上下文缓存。\n\n"
"后续 `/api/chat_callback` 调用时会自动将缓存的历史 prepend 到每次 LLM 请求的 messages 前," "后续 `/chat_callback` 调用时会自动将缓存的历史 prepend 到每次 LLM 请求的 messages 前,"
"实现多轮对话的上下文延续。\n\n" "实现多轮对话的上下文延续。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。" "**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
), ),
response_model=SessionHistoryResponse,
responses={ responses={
401: {"description": "内部签名校验失败"}, 401: {"description": "内部签名校验失败", "model": AuthFailResponse},
}, },
) )
async def set_history(request: Request, body: SetHistoryRequest): async def set_history(request: Request, body: SetHistoryRequest):

View File

@ -8,6 +8,7 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from config.custom_scene import get_rtc_openapi_version from config.custom_scene import get_rtc_openapi_version
from schemas.responses import AuthFailResponse, ProxyResponse
from security.internal_auth import verify_internal_request 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
@ -32,8 +33,9 @@ class ProxyRequest(BaseModel):
"- `Action=StopVoiceChat`:停止对话并清除该房间的历史上下文缓存。\n\n" "- `Action=StopVoiceChat`:停止对话并清除该房间的历史上下文缓存。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。" "**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
), ),
response_model=ProxyResponse,
responses={ responses={
401: {"description": "内部签名校验失败"}, 401: {"description": "内部签名校验失败", "model": AuthFailResponse},
400: {"description": "参数缺失或场景配置不存在"}, 400: {"description": "参数缺失或场景配置不存在"},
}, },
) )

View File

@ -5,6 +5,7 @@ POST /getScenes — 场景列表
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from schemas.responses import AuthFailResponse, GetScenesResponse
from security.internal_auth import verify_internal_request 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
@ -21,8 +22,9 @@ router = APIRouter(tags=["场景"])
"供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。\n\n" "供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。\n\n"
"**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。" "**鉴权**:需附加内部服务签名 Header由 java-mock 自动添加)。"
), ),
response_model=GetScenesResponse,
responses={ responses={
401: {"description": "内部签名校验失败"}, 401: {"description": "内部签名校验失败", "model": AuthFailResponse},
}, },
) )
async def get_scenes(request: Request): async def get_scenes(request: Request):

View File

@ -0,0 +1,93 @@
"""
API 响应模型 用于 Swagger 文档展示响应体结构
"""
from __future__ import annotations
from pydantic import BaseModel, Field
# ─── 通用 ────────────────────────────────────────────────
class ErrorDetail(BaseModel):
Code: int | str = Field(..., description="错误码", examples=[-1])
Message: str = Field(..., description="错误描述", examples=["参数缺失"])
class ResponseMetadata(BaseModel):
Action: str = Field(..., description="接口名称", examples=["getScenes"])
Error: ErrorDetail | None = Field(default=None, description="仅失败时存在")
class AuthFailResponse(BaseModel):
"""鉴权失败"""
code: int = Field(401, description="状态码")
message: str = Field("鉴权失败", description="错误信息")
# ─── getScenes ───────────────────────────────────────────
class SceneInfo(BaseModel):
id: str = Field(..., description="场景唯一标识,后续接口的 SceneID 取此值", examples=["Custom"])
name: str = Field(..., description="场景显示名称", examples=["小块"])
icon: str = Field(..., description="场景头像 URL")
botName: str | None = Field(None, description="AI Bot 在 RTC 房间中的 UserId")
isInterruptMode: bool = Field(..., description="是否开启用户打断模式")
isVision: bool | None = Field(None, description="是否开启视觉理解能力")
isScreenMode: bool = Field(False, description="是否为屏幕共享模式")
isAvatarScene: bool | None = Field(None, description="是否为数字人场景")
avatarBgUrl: str | None = Field(None, description="数字人背景图 URL")
class RTCInfo(BaseModel):
AppId: str = Field(..., description="火山引擎 RTC AppId")
RoomId: str = Field(..., description="本次生成的房间 IDUUID")
UserId: str = Field(..., description="本次生成的用户 IDUUID")
Token: str = Field(..., description="RTC 入房 Token24 小时有效")
TaskId: str = Field(..., description="语音任务 IDStopVoiceChat 时需要")
class SceneItem(BaseModel):
scene: SceneInfo
rtc: RTCInfo
class GetScenesResult(BaseModel):
scenes: list[SceneItem] = Field(..., description="场景列表")
class GetScenesResponse(BaseModel):
"""获取场景列表成功响应"""
ResponseMetadata: ResponseMetadata
Result: GetScenesResult
# ─── proxy ───────────────────────────────────────────────
class ProxyResultData(BaseModel):
Message: str = Field(..., description="结果信息", examples=["success"])
class ProxyResponseMetadata(BaseModel):
RequestId: str | None = Field(None, description="火山引擎请求 ID")
Action: str = Field(..., description="操作类型", examples=["StartVoiceChat"])
Version: str = Field(..., description="OpenAPI 版本", examples=["2025-06-01"])
Service: str = Field("rtc", description="服务名")
Error: ErrorDetail | None = Field(default=None, description="仅失败时存在")
class ProxyResponse(BaseModel):
"""代理接口成功响应(透传火山引擎返回)"""
ResponseMetadata: ProxyResponseMetadata
Result: ProxyResultData | None = Field(None, description="成功时的结果数据")
# ─── session/history ─────────────────────────────────────
class SessionHistoryResponse(BaseModel):
"""写入历史上下文成功响应"""
code: int = Field(200, description="状态码", examples=[200])

View File

@ -20,6 +20,9 @@ def verify_internal_request(headers) -> bool:
验证内部服务请求签名 验证内部服务请求签名
未配置 INTERNAL_SERVICE_SECRET 时直接放行开发/测试环境兼容 未配置 INTERNAL_SERVICE_SECRET 时直接放行开发/测试环境兼容
""" """
# TODO: 开发阶段临时关闭鉴权,上线前需恢复
return True
secret = os.environ.get("INTERNAL_SERVICE_SECRET", "") secret = os.environ.get("INTERNAL_SERVICE_SECRET", "")
if not secret: if not secret:
return True return True

View File

@ -35,9 +35,9 @@ app = FastAPI(
description=( description=(
"火山引擎 RTC AI 语音对话后端服务。\n\n" "火山引擎 RTC AI 语音对话后端服务。\n\n"
"## 鉴权说明\n" "## 鉴权说明\n"
"- **内部接口**`/getScenes` `/proxy` `/api/session/history`):由 java-mock 网关调用," "- **内部接口**`/getScenes` `/proxy` `/session/history`):由 java-mock 网关调用,"
"需附加 `X-Internal-Signature` HMAC 签名 Header。\n" "需附加 `X-Internal-Signature` HMAC 签名 Header。\n"
"- **LLM 回调**`/api/chat_callback`):由火山引擎 RTC 平台回调," "- **LLM 回调**`/chat_callback`):由火山引擎 RTC 平台回调,"
"需在 `Authorization: Bearer <key>` 中携带 `CUSTOM_LLM_API_KEY`。\n" "需在 `Authorization: Bearer <key>` 中携带 `CUSTOM_LLM_API_KEY`。\n"
"- **调试接口**`/debug/*`):无鉴权,仅用于本地开发。" "- **调试接口**`/debug/*`):无鉴权,仅用于本地开发。"
), ),

View File

@ -27,6 +27,7 @@ def load_scenes() -> dict:
scenes = { scenes = {
CUSTOM_SCENE_ID: build_custom_scene_from_env(), CUSTOM_SCENE_ID: build_custom_scene_from_env(),
} }
if SCENES_DIR.is_dir():
for p in sorted(SCENES_DIR.glob("*.json")): for p in sorted(SCENES_DIR.glob("*.json")):
if p.stem == CUSTOM_SCENE_ID: if p.stem == CUSTOM_SCENE_ID:
continue continue

View File

@ -11,17 +11,24 @@ import re
import httpx import httpx
from tools.registry import tool_registry from tools.registry import tool_registry
from utils.env import env_int, env_str
# ── 公共默认值(所有接口共享) ───────────────────────────── # ── 公共默认值(所有接口共享) ─────────────────────────────
DEFAULT_BASE_URL = "https://hskuaikuai.com:9000/test" # TODO: 替换为实际服务地址
DEFAULT_HEADERS = { def _get_base_url() -> str:
"Content-Type": "application/json", return env_str("API_TOOLS_BASE_URL", "https://hskuaikuai.com:9000/test")
"token": "265a068eb7af455ca97f1a5063561a5d", # TODO: 后续由前端传入
}
DEFAULT_TIMEOUT = 10 # 秒
def _get_default_headers() -> dict[str, str]:
headers = {"Content-Type": "application/json"}
token = env_str("API_TOOLS_TOKEN")
if token:
headers["token"] = token
return headers
DEFAULT_TIMEOUT = env_int("API_TOOLS_TIMEOUT", 10)
# ── API 端点声明 ───────────────────────────────────────── # ── API 端点声明 ─────────────────────────────────────────
@ -139,7 +146,8 @@ _PATH_PARAM_RE = re.compile(r"\{(\w+)\}")
def _call_api(endpoint: dict, **kwargs) -> str: def _call_api(endpoint: dict, **kwargs) -> str:
method = endpoint["method"].upper() method = endpoint["method"].upper()
headers = {**DEFAULT_HEADERS, **endpoint.get("headers", {})} base_url = _get_base_url()
headers = {**_get_default_headers(), **endpoint.get("headers", {})}
# "全部"部门:批量查询所有部门,合并摘要后返回 # "全部"部门:批量查询所有部门,合并摘要后返回
if endpoint["name"] == "get_checkin_app_dept" and kwargs.get("deptName") == "全部": if endpoint["name"] == "get_checkin_app_dept" and kwargs.get("deptName") == "全部":
@ -147,7 +155,7 @@ def _call_api(endpoint: dict, **kwargs) -> str:
combined = {} combined = {}
for dept in all_depts: for dept in all_depts:
try: try:
url = DEFAULT_BASE_URL.rstrip("/") + endpoint["path"] url = base_url.rstrip("/") + endpoint["path"]
resp = httpx.request(method, url, headers=headers, json={"deptName": dept}, timeout=DEFAULT_TIMEOUT) resp = httpx.request(method, url, headers=headers, json={"deptName": dept}, timeout=DEFAULT_TIMEOUT)
resp.raise_for_status() resp.raise_for_status()
summary = _summarize_checkin_dept(resp.text) summary = _summarize_checkin_dept(resp.text)
@ -158,7 +166,7 @@ def _call_api(endpoint: dict, **kwargs) -> str:
print(f"全部部门摘要: {result[:300]}...") print(f"全部部门摘要: {result[:300]}...")
return result return result
url = DEFAULT_BASE_URL.rstrip("/") + endpoint["path"] url = base_url.rstrip("/") + endpoint["path"]
# 1) 路径参数替换:{user_id} → kwargs["user_id"] # 1) 路径参数替换:{user_id} → kwargs["user_id"]
path_params = set(_PATH_PARAM_RE.findall(url)) path_params = set(_PATH_PARAM_RE.findall(url))
for p in path_params: for p in path_params:

View File

@ -326,7 +326,7 @@ POST /api/ai/session/history
**请求体JSON** **请求体JSON**
转发到 Python `/api/session/history`,具体字段由 Python 接口定义,典型示例: 转发到 Python `/session/history`,具体字段由 Python 接口定义,典型示例:
```json ```json
{ {

View File

@ -575,5 +575,51 @@
"createdAt": "2026-04-02T09:31:39.293Z" "createdAt": "2026-04-02T09:31:39.293Z"
} }
] ]
},
{
"id": "29119c56-6e65-42a8-81c9-bb4574895940",
"userId": "user-admin-001",
"sceneId": "Custom",
"roomId": "de26efa0-6d90-4b37-96ce-741af9b23bd5",
"startedAt": "2026-04-03T07:25:21.765Z",
"endedAt": "2026-04-03T07:25:58.942Z",
"createdAt": "2026-04-03T07:26:37.884Z",
"messages": [
{
"role": "assistant",
"content": "你好,我是小块,有什么需要帮忙的吗?",
"createdAt": "2026-04-03T07:25:21.765Z"
},
{
"role": "user",
"content": "啊! 你返回的应该是这种这才是啥意思?我看你返回的你做包做做包了一层。",
"createdAt": "2026-04-03T07:25:28.458Z"
},
{
"role": "user",
"content": "这个。",
"createdAt": "2026-04-03T07:25:30.538Z"
},
{
"role": "user",
"content": "这个才是他要的。",
"createdAt": "2026-04-03T07:25:36.686Z"
},
{
"role": "user",
"content": "你那个逮他,你那个就是有一个这个东西是吧。对,因为他拿的不是这东西,默认返回的。",
"createdAt": "2026-04-03T07:25:46.945Z"
},
{
"role": "user",
"content": "取消你要不就给这,要不就全能改,要不就后来取消。",
"createdAt": "2026-04-03T07:25:54.080Z"
},
{
"role": "user",
"content": "你在干嘛?",
"createdAt": "2026-04-03T07:25:58.942Z"
}
]
} }
] ]

View File

@ -49,7 +49,7 @@ router.post('/proxy', authMiddleware, async (req, res) => {
// POST /api/ai/session/history — 存储历史对话上下文到 Pythonstart 前调用) // POST /api/ai/session/history — 存储历史对话上下文到 Pythonstart 前调用)
router.post('/session/history', authMiddleware, async (req, res) => { router.post('/session/history', authMiddleware, async (req, res) => {
await forwardToPython('/api/session/history', '', req.body, req.user.userId, res, req.user); await forwardToPython('/session/history', '', req.body, req.user.userId, res, req.user);
}); });
module.exports = router; module.exports = router;

View File

@ -31,7 +31,7 @@
│ ↑ │ ↑
↓ 火山RTC平台 ↓ 火山RTC平台
┌──────────┐ 直接调用 ┌──────────┐ 直接调用
│ MySQL/PG │ /api/chat_callback │ MySQL/PG │ /chat_callback
│ 对话历史 │ │ 对话历史 │
└──────────┘ └──────────┘
``` ```
@ -70,7 +70,7 @@
[4] 语音对话进行中 [4] 语音对话进行中
┌─ 语音数据: Frontend ←→ 火山 RTC 服务器 (WebRTC, 不走 HTTP) ─┐ ┌─ 语音数据: Frontend ←→ 火山 RTC 服务器 (WebRTC, 不走 HTTP) ─┐
│ 字幕/状态: 火山 RTC → Frontend (RTC Binary Message, TLV) │ │ 字幕/状态: 火山 RTC → Frontend (RTC Binary Message, TLV) │
│ LLM 回调: 火山 RTC → Python /api/chat_callback (SSE) │ │ LLM 回调: 火山 RTC → Python /chat_callback (SSE) │
└─────── 这三条通道都不经过 Java ──────────────────────────────┘ └─────── 这三条通道都不经过 Java ──────────────────────────────┘
[5] 结束通话 [5] 结束通话
@ -89,7 +89,7 @@
| `POST /getScenes` | 走 | 需要用户身份 | | `POST /getScenes` | 走 | 需要用户身份 |
| `POST /proxy?Action=StartVoiceChat` | 走 | 需要用户身份 | | `POST /proxy?Action=StartVoiceChat` | 走 | 需要用户身份 |
| `POST /proxy?Action=StopVoiceChat` | 走 | 需要用户身份 | | `POST /proxy?Action=StopVoiceChat` | 走 | 需要用户身份 |
| `POST /api/chat_callback` | **不走** | 火山 RTC 平台直接调用 Python | | `POST /chat_callback` | **不走** | 火山 RTC 平台直接调用 Python |
| 语音音频流 | **不走** | WebRTC P2P / 媒体服务器 | | 语音音频流 | **不走** | WebRTC P2P / 媒体服务器 |
| 字幕/状态消息 | **不走** | RTC Binary Message (TLV) | | 字幕/状态消息 | **不走** | RTC Binary Message (TLV) |
| 对话历史保存 | 直接到 Java | Java 自己处理,不转发 Python | | 对话历史保存 | 直接到 Java | Java 自己处理,不转发 Python |
@ -153,7 +153,7 @@ def verify_internal_auth(request):
| 路径 | 原因 | | 路径 | 原因 |
|------|------| |------|------|
| `/api/chat_callback` | 火山 RTC 平台调用,有自己的 Bearer Token 鉴权 | | `/chat_callback` | 火山 RTC 平台调用,有自己的 Bearer Token 鉴权 |
| `/debug/*` | 开发调试用,生产环境应在网络层禁止访问 | | `/debug/*` | 开发调试用,生产环境应在网络层禁止访问 |
### 3.5 本地开发兼容 ### 3.5 本地开发兼容
@ -249,7 +249,7 @@ def _key(request: Request) -> str:
### 4.3 身份关联chat_callback 场景 ### 4.3 身份关联chat_callback 场景
`/api/chat_callback` 由火山 RTC 平台调用,不经过 Java不携带用户身份。但可通过 RoomId 关联用户: `/chat_callback` 由火山 RTC 平台调用,不经过 Java不携带用户身份。但可通过 RoomId 关联用户:
``` ```
getScenes 阶段: getScenes 阶段:
@ -513,7 +513,7 @@ window.addEventListener('beforeunload', this._beforeUnloadHandler);
HMAC 签名验证中间件: HMAC 签名验证中间件:
- 读取 `INTERNAL_SERVICE_SECRET` 环境变量 - 读取 `INTERNAL_SERVICE_SECRET` 环境变量
- 对非豁免路径验证 `X-Internal-Signature` - 对非豁免路径验证 `X-Internal-Signature`
- 豁免路径:`/api/chat_callback`、`/debug/*` - 豁免路径:`/chat_callback`、`/debug/*`
- 密钥为空时跳过验证(本地开发兼容) - 密钥为空时跳过验证(本地开发兼容)
#### 6.1.3 修改 `backend/services/session_store.py` #### 6.1.3 修改 `backend/services/session_store.py`