diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..9a2f5ec --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/.gitignore b/.gitignore index b4989b0..83596d5 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ pnpm-lock.yaml .env .env.* !.env.example +!.env.staging # ===================== # 日志 / Logs diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..7256230 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.pyc +.env +.env.* +!.env.example +.git/ +.gitignore +.vscode/ +.DS_Store +*.md +*.log diff --git a/backend/.env.example b/backend/.env.example index 62bafa9..b2ba984 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,10 +24,9 @@ CUSTOM_INTERRUPT_MODE=0 CUSTOM_LLM_THINKING_TYPE=disabled CUSTOM_LLM_VISION_ENABLE=false -# 本地调试时,可先保持默认本地回调地址。 -# 等 ngrok 跑起来后,再把 CUSTOM_LLM_URL 改成公网 https 地址,例如: -# https://your-ngrok-domain.ngrok-free.app/api/chat_callback -CUSTOM_LLM_URL= https://postvarioloid-leeann-didynamous.ngrok-free.dev +# 填写后端服务的公网 HTTPS 地址,火山引擎 RTC 平台会回调此地址 +# 例如:https://api.yourdomain.com/v1/api/chat_callback +CUSTOM_LLM_URL= # 火山调用当前 backend 的 /api/chat_callback 时使用的 Bearer Token,可留空 CUSTOM_LLM_API_KEY= CUSTOM_LLM_MODEL_NAME= @@ -69,7 +68,7 @@ VOLC_KB_ENABLED=false VOLC_KB_NAME=your_collection_name # 知识库名称(与 VOLC_KB_RESOURCE_ID 二选一) VOLC_KB_RESOURCE_ID= # 知识库唯一 ID(优先级高于 NAME) VOLC_KB_PROJECT=default # 知识库所属项目 -VOLC_KB_ENDPOINT=https://postvarioloid-leeann-didynamous.ngrok-free.dev +VOLC_KB_ENDPOINT= VOLC_KB_TOP_K=3 # 检索返回条数 VOLC_KB_RERANK=false # 是否开启 rerank 重排 VOLC_KB_ATTACHMENT_LINK=false # 是否返回图片临时链接(图文混合场景开启,链接有效期 10 分钟) diff --git a/backend/.env.staging b/backend/.env.staging new file mode 100644 index 0000000..014396d --- /dev/null +++ b/backend/.env.staging @@ -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 diff --git a/backend/API.md b/backend/API.md new file mode 100644 index 0000000..f0a1401 --- /dev/null +++ b/backend/API.md @@ -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=&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= +``` + +**鉴权:** `Authorization: Bearer `(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 | ✅ | 检索问题 | + +**成功响应 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`) | diff --git a/backend/DEPLOYMENT.md b/backend/DEPLOYMENT.md new file mode 100644 index 0000000..aca6abf --- /dev/null +++ b/backend/DEPLOYMENT.md @@ -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 +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c3cc195 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/README.md b/backend/README.md index 78393b5..e0798d8 100644 --- a/backend/README.md +++ b/backend/README.md @@ -75,11 +75,10 @@ CUSTOM_LLM_API_KEY=your-callback-token 推荐调试流程: 1. 先启动当前 `backend` -2. 用 `ngrok` 暴露 `3001` 端口 -3. 把 `CUSTOM_LLM_URL` 改成公网地址,例如: +2. 把 `CUSTOM_LLM_URL` 改成后端服务的公网 HTTPS 地址,例如: ```dotenv -CUSTOM_LLM_URL=https://your-ngrok-domain.ngrok-free.app/api/chat_callback +CUSTOM_LLM_URL=https://api.yourdomain.com/v1/api/chat_callback ``` `CUSTOM_LLM_API_KEY` 是火山调用你这个本地回调接口时带上的 Bearer Token;如果你不需要这层鉴权,可以留空。 diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml new file mode 100644 index 0000000..113f277 --- /dev/null +++ b/backend/docker-compose.dev.yml @@ -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" # 开发时不自动重启,报错立即停止方便排查 diff --git a/backend/docker-compose.staging.yml b/backend/docker-compose.staging.yml new file mode 100644 index 0000000..4eaf254 --- /dev/null +++ b/backend/docker-compose.staging.yml @@ -0,0 +1,7 @@ +services: + backend: + env_file: + - .env.staging # 覆盖基础文件的 .env,指向测试资源 + environment: + - LOG_LEVEL=info # 测试环境用 info,比生产详细,比开发简洁 + restart: unless-stopped diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..18c1404 --- /dev/null +++ b/backend/docker-compose.yml @@ -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 diff --git a/backend/routes/v1/__init__.py b/backend/routes/v1/__init__.py new file mode 100644 index 0000000..82fa974 --- /dev/null +++ b/backend/routes/v1/__init__.py @@ -0,0 +1 @@ +"""v1 路由模块""" diff --git a/backend/routes/chat_callback.py b/backend/routes/v1/chat_callback.py similarity index 66% rename from backend/routes/chat_callback.py rename to backend/routes/v1/chat_callback.py index 65f159e..8ca3edd 100644 --- a/backend/routes/chat_callback.py +++ b/backend/routes/v1/chat_callback.py @@ -10,12 +10,30 @@ from fastapi.responses import StreamingResponse from schemas.chat import ChatCallbackRequest from services.local_llm_service import local_llm_service from services.scene_service import ensure_custom_llm_authorized, get_custom_llm_callback_settings +from services.session_store import get_room_history from utils.responses import custom_llm_error_response -router = APIRouter() +router = APIRouter(tags=["LLM 回调"]) -@router.post("/api/chat_callback") +@router.post( + "/api/chat_callback", + summary="自定义 LLM 回调(SSE 流式)", + description=( + "由**火山引擎 RTC 平台**在用户发言后自动回调,返回 OpenAI 兼容格式的 SSE 流。\n\n" + "处理逻辑:\n" + "1. 校验 `Authorization: Bearer `\n" + "2. 过滤掉 RTC 平台发送的 `欢迎语` 触发词(非真实用户输入)\n" + "3. 若携带 `room_id` Query 参数,自动从缓存取历史并 prepend 到 messages 前\n" + "4. 调用本地 LLM(工具调用 / RAG 按需触发),以 SSE 流返回结果\n\n" + "**鉴权**:`Authorization: Bearer `" + ), + responses={ + 401: {"description": "API Key 无效"}, + 400: {"description": "messages 为空或最后一条不是 user 角色"}, + 500: {"description": "LLM 初始化失败"}, + }, +) async def chat_callback(request: Request, body: ChatCallbackRequest): try: settings = get_custom_llm_callback_settings() @@ -43,6 +61,23 @@ async def chat_callback(request: Request, body: ChatCallbackRequest): status_code=400, ) + # 过滤 RTC 平台的"欢迎语"触发词(不是真实用户输入) + messages = [m for m in messages if not (m["role"] == "user" and m["content"] == "欢迎语")] + + # 注入历史对话上下文(prepend 到当前会话消息前) + room_id = request.query_params.get("room_id", "") + if room_id: + history = get_room_history(room_id) + if history: + messages = history + messages + + if not messages: + return custom_llm_error_response( + "messages 不能为空", + code="BadRequest", + status_code=400, + ) + last_message = messages[-1] if last_message.get("role") != "user": return custom_llm_error_response( diff --git a/backend/routes/debug.py b/backend/routes/v1/debug.py similarity index 76% rename from backend/routes/debug.py rename to backend/routes/v1/debug.py index b1b3ede..975311a 100644 --- a/backend/routes/debug.py +++ b/backend/routes/v1/debug.py @@ -5,17 +5,26 @@ import json import time -from fastapi import APIRouter +from fastapi import APIRouter, Query from fastapi.responses import StreamingResponse from schemas.chat import DebugChatRequest from services.local_llm_service import local_llm_service from services.rag_service import rag_service -router = APIRouter(prefix="/debug") +router = APIRouter(prefix="/debug", tags=["调试"]) -@router.post("/chat") +@router.post( + "/chat", + summary="调试 LLM 对话", + description=( + "直接向 LLM 发送消息,响应为纯文本流(非 SSE)。\n\n" + "完成后会在**服务端终端**输出本次对话完整的 `history` JSON," + "可复制后粘贴到下次请求的 `history` 字段继续对话。\n\n" + "⚠️ 仅用于本地开发调试,无鉴权。" + ), +) async def debug_chat(request: DebugChatRequest): current_messages = [ {"role": message.role, "content": message.content} for message in request.history @@ -72,8 +81,15 @@ async def debug_chat(request: DebugChatRequest): return StreamingResponse(generate_text(), media_type="text/plain") -@router.get("/rag") -async def debug_rag(query: str): +@router.get( + "/rag", + summary="调试 RAG 知识库检索", + description=( + "对知识库执行一次检索,返回原始检索上下文内容,用于验证 RAG 效果。\n\n" + "⚠️ 仅用于本地开发调试,无鉴权。" + ), +) +async def debug_rag(query: str = Query(..., description="检索问题")): if not query: return {"error": "请提供 query 参数"} diff --git a/backend/routes/v1/history.py b/backend/routes/v1/history.py new file mode 100644 index 0000000..a79ab3d --- /dev/null +++ b/backend/routes/v1/history.py @@ -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}) diff --git a/backend/routes/proxy.py b/backend/routes/v1/proxy.py similarity index 61% rename from backend/routes/proxy.py rename to backend/routes/v1/proxy.py index ac0941b..54ae545 100644 --- a/backend/routes/proxy.py +++ b/backend/routes/v1/proxy.py @@ -3,30 +3,55 @@ POST /proxy — RTC OpenAPI 代理(含请求签名) """ import httpx -from fastapi import APIRouter, Request +from fastapi import APIRouter, Query, Request from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field from config.custom_scene import get_rtc_openapi_version +from security.internal_auth import verify_internal_request from security.signer import Signer from services.scene_service import Scenes, prepare_scene_runtime -from services.session_store import load_session +from services.session_store import clear_room_history, get_room_history, load_session from utils.responses import error_response from utils.validation import assert_scene_value, assert_value -router = APIRouter() +router = APIRouter(tags=["RTC 代理"]) -@router.post("/proxy") -async def proxy(request: Request): - action = request.query_params.get("Action", "") - version = request.query_params.get("Version") or get_rtc_openapi_version() +class ProxyRequest(BaseModel): + SceneID: str = Field(..., description="场景 ID(从 getScenes 返回的 scene.id 获取)") + + +@router.post( + "/proxy", + summary="开始 / 停止语音对话", + description=( + "带 SigV4 签名转发到火山引擎 RTC OpenAPI。\n\n" + "- `Action=StartVoiceChat`:从 Session 取回 getScenes 分配的 RoomId/TaskId," + "自动注入后启动对话。\n" + "- `Action=StopVoiceChat`:停止对话并清除该房间的历史上下文缓存。\n\n" + "**鉴权**:需附加内部服务签名 Header(由 java-mock 自动添加)。" + ), + responses={ + 401: {"description": "内部签名校验失败"}, + 400: {"description": "参数缺失或场景配置不存在"}, + }, +) +async def proxy( + request: Request, + body: ProxyRequest, + action: str = Query(..., alias="Action", description="操作类型:`StartVoiceChat` 或 `StopVoiceChat`"), + version: str | None = Query(None, alias="Version", description="火山引擎 OpenAPI 版本,不传时取配置文件默认值"), +): + if not verify_internal_request(request.headers): + return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401) + + version = version or get_rtc_openapi_version() + scene_id = body.SceneID try: assert_value(action, "Action 不能为空") assert_value(version, "Version 不能为空") - - body = await request.json() - scene_id = body.get("SceneID", "") assert_value(scene_id, "SceneID 不能为空,SceneID 用于指定场景配置") json_data = Scenes.get(scene_id) @@ -56,6 +81,14 @@ async def proxy(request: Request): target_user_ids[0] = sess["UserId"] else: agent_config["TargetUserId"] = [sess["UserId"]] + # 将 room_id 追加到 LLM 回调 URL,使 chat_callback 能关联到历史 + room_id = voice_chat.get("RoomId", "") + if room_id: + llm_config = voice_chat.get("Config", {}).get("LLMConfig", {}) + llm_url = llm_config.get("Url", "") + if llm_url: + sep = "&" if "?" in llm_url else "?" + llm_config["Url"] = f"{llm_url}{sep}room_id={room_id}" req_body = voice_chat elif action == "StopVoiceChat": app_id = voice_chat.get("AppId", "") @@ -65,6 +98,8 @@ async def proxy(request: Request): assert_scene_value(scene_id, "VoiceChat.AppId", app_id) assert_scene_value(scene_id, "VoiceChat.RoomId", room_id) assert_scene_value(scene_id, "VoiceChat.TaskId", task_id) + # 清除该房间的历史上下文 + clear_room_history(room_id) req_body = {"AppId": app_id, "RoomId": room_id, "TaskId": task_id} else: req_body = {} diff --git a/backend/routes/scenes.py b/backend/routes/v1/scenes.py similarity index 76% rename from backend/routes/scenes.py rename to backend/routes/v1/scenes.py index a527ff2..c7e4c42 100644 --- a/backend/routes/scenes.py +++ b/backend/routes/v1/scenes.py @@ -5,14 +5,30 @@ POST /getScenes — 场景列表 from fastapi import APIRouter, Request from fastapi.responses import JSONResponse +from security.internal_auth import verify_internal_request from services.scene_service import Scenes, prepare_scene_runtime from services.session_store import save_session -router = APIRouter() +router = APIRouter(tags=["场景"]) -@router.post("/getScenes") +@router.post( + "/getScenes", + summary="获取场景列表", + description=( + "返回所有已配置场景的信息及对应的 RTC 房间参数(AppId / RoomId / Token 等)。\n\n" + "调用后会将本次生成的 `RoomId / UserId / TaskId` 写入 Session," + "供后续 `/proxy?Action=StartVoiceChat` 自动取用,无需客户端传递。\n\n" + "**鉴权**:需附加内部服务签名 Header(由 java-mock 自动添加)。" + ), + responses={ + 401: {"description": "内部签名校验失败"}, + }, +) async def get_scenes(request: Request): + if not verify_internal_request(request.headers): + return JSONResponse({"code": 401, "message": "鉴权失败"}, status_code=401) + try: scenes_list = [] for scene_name, data in Scenes.items(): diff --git a/backend/security/internal_auth.py b/backend/security/internal_auth.py new file mode 100644 index 0000000..e73e5d8 --- /dev/null +++ b/backend/security/internal_auth.py @@ -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) diff --git a/backend/server.py b/backend/server.py index 274c115..d5f5256 100644 --- a/backend/server.py +++ b/backend/server.py @@ -1,4 +1,5 @@ import logging +import os from pathlib import Path from dotenv import load_dotenv @@ -6,8 +7,10 @@ from dotenv import load_dotenv BASE_DIR = Path(__file__).parent load_dotenv(BASE_DIR / ".env", override=False) +LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper() + logging.basicConfig( - level=logging.DEBUG, + level=getattr(logging, LOG_LEVEL, logging.DEBUG), format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%H:%M:%S", ) @@ -18,15 +21,28 @@ logging.getLogger("openai").setLevel(logging.WARNING) logging.getLogger("uvicorn").setLevel(logging.INFO) # 路由必须在 load_dotenv 之后导入,因为模块级代码会读取环境变量 -from routes.chat_callback import router as chat_callback_router # noqa: E402 -from routes.debug import router as debug_router # noqa: E402 -from routes.proxy import router as proxy_router # noqa: E402 -from routes.scenes import router as scenes_router # noqa: E402 +from routes.v1.chat_callback import router as chat_callback_router # noqa: E402 +from routes.v1.debug import router as debug_router # noqa: E402 +from routes.v1.history import router as history_router # noqa: E402 +from routes.v1.proxy import router as proxy_router # noqa: E402 +from routes.v1.scenes import router as scenes_router # noqa: E402 from fastapi import FastAPI # noqa: E402 from fastapi.middleware.cors import CORSMiddleware # noqa: E402 -app = FastAPI() +app = FastAPI( + title="RTC AIGC 后端", + description=( + "火山引擎 RTC AI 语音对话后端服务。\n\n" + "## 鉴权说明\n" + "- **内部接口**(`/getScenes` `/proxy` `/api/session/history`):由 java-mock 网关调用," + "需附加 `X-Internal-Signature` HMAC 签名 Header。\n" + "- **LLM 回调**(`/api/chat_callback`):由火山引擎 RTC 平台回调," + "需在 `Authorization: Bearer ` 中携带 `CUSTOM_LLM_API_KEY`。\n" + "- **调试接口**(`/debug/*`):无鉴权,仅用于本地开发。" + ), + version="1.0.0", +) app.add_middleware( CORSMiddleware, @@ -35,10 +51,17 @@ app.add_middleware( allow_headers=["*"], ) -app.include_router(proxy_router) -app.include_router(scenes_router) -app.include_router(chat_callback_router) -app.include_router(debug_router) + +@app.get("/health", include_in_schema=False) +async def health(): + return {"status": "ok"} + + +app.include_router(proxy_router, prefix="/v1") +app.include_router(scenes_router, prefix="/v1") +app.include_router(chat_callback_router, prefix="/v1") +app.include_router(debug_router, prefix="/v1") +app.include_router(history_router, prefix="/v1") if __name__ == "__main__": import uvicorn diff --git a/backend/services/session_store.py b/backend/services/session_store.py index 96052b6..f2dcfd9 100644 --- a/backend/services/session_store.py +++ b/backend/services/session_store.py @@ -12,6 +12,9 @@ from fastapi import Request # { session_key: { scene_id: { RoomId, UserId, TaskId } } } _store: dict[str, dict[str, dict]] = {} +# { room_id: [{ role, content }] } +_history_store: dict[str, list[dict]] = {} + def _key(request: Request) -> str: forwarded = request.headers.get("X-Forwarded-For") @@ -33,3 +36,15 @@ def save_session(request: Request, scene_id: str, data: dict) -> None: def load_session(request: Request, scene_id: str) -> dict: return _store.get(_key(request), {}).get(scene_id, {}) + + +def save_room_history(room_id: str, messages: list[dict]) -> None: + _history_store[room_id] = messages + + +def get_room_history(room_id: str) -> list[dict]: + return _history_store.get(room_id, []) + + +def clear_room_history(room_id: str) -> None: + _history_store.pop(room_id, None) diff --git a/java-mock/.env.example b/java-mock/.env.example new file mode 100644 index 0000000..14e42ba --- /dev/null +++ b/java-mock/.env.example @@ -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 diff --git a/java-mock/API-FULL.md b/java-mock/API-FULL.md new file mode 100644 index 0000000..fabfba6 --- /dev/null +++ b/java-mock/API-FULL.md @@ -0,0 +1,755 @@ +# java-mock 接口文档 & 数据设计 + +> Base URL: `http://localhost:8080` +> +> 认证方式: JWT Bearer Token(除登录/注册外,所有接口均需在请求头附加 `Authorization: Bearer `) + +--- + +## 目录 + +- [接口总览](#接口总览) +- [一、认证接口](#一认证接口) + - [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-` 或 `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": , + "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` | — | 内部签名密钥(**必填**) | diff --git a/java-mock/data/conversations.json b/java-mock/data/conversations.json new file mode 100644 index 0000000..8583d90 --- /dev/null +++ b/java-mock/data/conversations.json @@ -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" + } + ] + } +] \ No newline at end of file diff --git a/java-mock/data/users.json b/java-mock/data/users.json new file mode 100644 index 0000000..b836bd5 --- /dev/null +++ b/java-mock/data/users.json @@ -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" + } +] + diff --git a/java-mock/middleware/auth.js b/java-mock/middleware/auth.js new file mode 100644 index 0000000..0274972 --- /dev/null +++ b/java-mock/middleware/auth.js @@ -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; diff --git a/java-mock/middleware/internalSign.js b/java-mock/middleware/internalSign.js new file mode 100644 index 0000000..612ca26 --- /dev/null +++ b/java-mock/middleware/internalSign.js @@ -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 }; diff --git a/java-mock/package-lock.json b/java-mock/package-lock.json new file mode 100644 index 0000000..fa485e7 --- /dev/null +++ b/java-mock/package-lock.json @@ -0,0 +1,1485 @@ +{ + "name": "java-mock", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "java-mock", + "version": "1.0.0", + "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" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^10.2.1", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/java-mock/package.json b/java-mock/package.json new file mode 100644 index 0000000..095b2c3 --- /dev/null +++ b/java-mock/package.json @@ -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" + } +} diff --git a/java-mock/routes/aiProxyRoutes.js b/java-mock/routes/aiProxyRoutes.js new file mode 100644 index 0000000..a9c99f7 --- /dev/null +++ b/java-mock/routes/aiProxyRoutes.js @@ -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; diff --git a/java-mock/routes/authRoutes.js b/java-mock/routes/authRoutes.js new file mode 100644 index 0000000..d497363 --- /dev/null +++ b/java-mock/routes/authRoutes.js @@ -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; diff --git a/java-mock/routes/conversationRoutes.js b/java-mock/routes/conversationRoutes.js new file mode 100644 index 0000000..fccb9eb --- /dev/null +++ b/java-mock/routes/conversationRoutes.js @@ -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; diff --git a/java-mock/server.js b/java-mock/server.js new file mode 100644 index 0000000..7fe520d --- /dev/null +++ b/java-mock/server.js @@ -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'}`); +}); diff --git a/java-mock/utils/db.js b/java-mock/utils/db.js new file mode 100644 index 0000000..a8e5dcc --- /dev/null +++ b/java-mock/utils/db.js @@ -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 }; diff --git a/java-mock/utils/hmac.js b/java-mock/utils/hmac.js new file mode 100644 index 0000000..3e2d9a9 --- /dev/null +++ b/java-mock/utils/hmac.js @@ -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 }; diff --git a/plan/integration-plan.md b/plan/integration-plan.md new file mode 100644 index 0000000..233bd4b --- /dev/null +++ b/plan/integration-plan.md @@ -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 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 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 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 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` 的最终文本,不保存中间态 | diff --git a/simple-frontend/aigc-voice-client.js b/simple-frontend/aigc-voice-client.js index 44ffcf8..8cee886 100644 --- a/simple-frontend/aigc-voice-client.js +++ b/simple-frontend/aigc-voice-client.js @@ -83,12 +83,14 @@ const AgentStage = { class AigcVoiceClient { /** * @param {Object} options - * @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:3001' + * @param {string} options.serverUrl - 后端地址, 例如 'http://localhost:8080' * @param {string} [options.sceneId] - 指定场景 ID,不传则使用第一个 + * @param {string} [options.authToken] - JWT Token(通过 java-mock /api/auth/login 获取) */ constructor(options = {}) { - this.serverUrl = options.serverUrl || 'http://localhost:3001'; + this.serverUrl = options.serverUrl || 'http://localhost:8080'; this.preferredSceneId = options.sceneId || null; + this.authToken = options.authToken || null; // RTC 相关 this.engine = null; @@ -111,6 +113,10 @@ class AigcVoiceClient { // 对话历史 this.msgHistory = []; + // 继续上次对话时缓存的历史消息,start() 时会 POST 到后端 + this._historyMessages = null; + // 继续的原始对话 ID(用于 stop 时追加到原记录而非新建) + this._continueFromId = null; // 事件回调(使用者可覆写) this.onAIThinking = null; // () => void @@ -132,9 +138,11 @@ class AigcVoiceClient { async _post(path, action, body = {}) { const url = `${this.serverUrl}${path}?Action=${action}`; + const headers = { 'Content-Type': 'application/json' }; + if (this.authToken) headers['Authorization'] = `Bearer ${this.authToken}`; const res = await fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(body), }); const json = await res.json(); @@ -155,15 +163,15 @@ class AigcVoiceClient { } async _getScenes() { - return this._post('/getScenes', 'getScenes'); + return this._post('/api/ai/getScenes', 'getScenes'); } async _startVoiceChat(sceneId) { - return this._post('/proxy', 'StartVoiceChat', { SceneID: sceneId }); + return this._post('/api/ai/proxy', 'StartVoiceChat', { SceneID: sceneId }); } async _stopVoiceChat(sceneId) { - return this._post('/proxy', 'StopVoiceChat', { SceneID: sceneId }); + return this._post('/api/ai/proxy', 'StopVoiceChat', { SceneID: sceneId }); } // ---------------------------------------------------------- @@ -197,6 +205,26 @@ class AigcVoiceClient { return { sceneId: this.sceneId, scenes: scenesArr }; } + /** + * 加载历史对话作为上下文(在 init() 之后、start() 之前调用) + * @param {Array} messages - 来自 /api/ai/conversations/:id 的 messages 数组 + * @param {string} [conversationId] - 原始对话 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() 之后调用) */ @@ -282,7 +310,26 @@ class AigcVoiceClient { // 6. 开启麦克风 await this.enableMic(); - // 7. 启动 AI Bot + // 7. 如果有历史上下文,先 POST 到后端 + if (this._historyMessages && this._historyMessages.length > 0) { + try { + await fetch(`${this.serverUrl}/api/ai/session/history`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ + room_id: this.roomId, + messages: this._historyMessages, + }), + }); + } catch (e) { + console.warn('[AigcVoiceClient] 上传历史失败:', e); + } + } + + // 8. 启动 AI Bot await this._startAgent(); console.log('[AigcVoiceClient] 通话已开始'); @@ -301,7 +348,12 @@ class AigcVoiceClient { // 2. 停止 AI Bot await this._stopAgent(); - // 3. 离房 & 销毁 + // 3. 保存对话历史 + if (this.msgHistory.length > 0) { + await this._saveConversation(); + } + + // 4. 离房 & 销毁 try { await this.engine?.leaveRoom(); window.VERTC?.destroyEngine(this.engine); @@ -311,11 +363,52 @@ class AigcVoiceClient { this.isJoined = false; this.isMicOn = false; this.msgHistory = []; + this._historyMessages = null; + this._continueFromId = null; this._emitStateChange(); console.log('[AigcVoiceClient] 通话已结束'); } + async _saveConversation() { + if (!this.authToken) return; + try { + const newMessages = this.msgHistory + .filter((m) => !m._historical && m.content) + .map((m) => ({ role: m.role, content: m.content, time: m.time })); + if (newMessages.length === 0) return; + + if (this._continueFromId) { + // 继续对话:追加到原记录 + await fetch(`${this.serverUrl}/api/ai/conversations/${this._continueFromId}/append`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ messages: newMessages }), + }); + } else { + // 全新对话:新建记录 + await fetch(`${this.serverUrl}/api/ai/conversations`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.authToken}`, + }, + body: JSON.stringify({ + sceneId: this.sceneId, + roomId: this.roomId, + messages: newMessages, + }), + }); + } + console.log('[AigcVoiceClient] 对话历史已保存'); + } catch (e) { + console.warn('[AigcVoiceClient] 保存对话历史失败:', e); + } + } + // ---------------------------------------------------------- // 麦克风控制 // ---------------------------------------------------------- @@ -527,6 +620,7 @@ class AigcVoiceClient { if (!data || !this.audioBotEnabled) return; const { text, definite, userId, paragraph } = data; + if (!text) return; // 跳过空文本帧 // 更新消息历史 this._appendMessage({ text, userId, definite, paragraph }); @@ -566,20 +660,24 @@ class AigcVoiceClient { // ---------------------------------------------------------- _appendMessage({ text, userId, definite, paragraph }) { + const role = userId === this.userId ? 'user' : 'assistant'; + + // 过滤 RTC 平台触发欢迎语的系统字符串 + if (text === '欢迎语') return; const lastMsg = this.msgHistory[this.msgHistory.length - 1]; const isNewSentence = !lastMsg || lastMsg.definite || lastMsg.paragraph; if (isNewSentence) { this.msgHistory.push({ - text, - userId, + role, + content: text, definite: !!definite, paragraph: !!paragraph, time: new Date().toISOString(), }); } else { // 话未说完,更新内容 - lastMsg.text = text; + lastMsg.content = text; lastMsg.definite = !!definite; lastMsg.paragraph = !!paragraph; lastMsg.time = new Date().toISOString(); diff --git a/simple-frontend/example.html b/simple-frontend/example.html index 2f98a96..b34597f 100644 --- a/simple-frontend/example.html +++ b/simple-frontend/example.html @@ -250,13 +250,236 @@ .text-overlay button svg { width: 20px; height: 20px; fill: #fff; } .hidden { display: none !important; } + + /* ============ 历史面板 ============ */ + .history-panel { + position: absolute; top: 0; right: 0; bottom: 0; width: 340px; z-index: 40; + background: rgba(7,11,20,0.96); border-left: 1px solid rgba(255,255,255,0.07); + backdrop-filter: blur(24px); display: flex; flex-direction: column; + transform: translateX(100%); transition: transform 0.35s cubic-bezier(0.4,0,0.2,1); + } + .history-panel.open { transform: translateX(0); } + + .history-header { + display: flex; align-items: center; justify-content: space-between; + padding: 20px 20px 16px; border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; + } + .history-title { font-size: 15px; font-weight: 400; color: rgba(255,255,255,0.7); } + .history-close { + width: 28px; height: 28px; border-radius: 50%; border: none; + background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.4); + cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; + transition: background 0.2s; + } + .history-close:hover { background: rgba(255,255,255,0.12); } + + .history-list { + flex: 1; overflow-y: auto; padding: 8px 0; + scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; + } + + .history-item { + padding: 12px 20px; cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,0.04); + transition: background 0.15s; + } + .history-item:hover { background: rgba(255,255,255,0.04); } + .history-item.active { background: rgba(16,185,129,0.06); } + + .history-item-date { + font-size: 11px; color: rgba(255,255,255,0.25); margin-bottom: 4px; + } + .history-item-preview { + font-size: 13px; color: rgba(255,255,255,0.55); font-weight: 300; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .history-item-meta { + font-size: 11px; color: rgba(255,255,255,0.2); margin-top: 4px; + } + + /* 对话详情 */ + .history-detail { + position: absolute; inset: 0; + background: rgba(7,11,20,0.98); + display: flex; flex-direction: column; + transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.4,0,0.2,1); + } + .history-detail.open { transform: translateX(0); } + + .history-detail-header { + display: flex; align-items: center; gap: 12px; + padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; + } + .history-back { + width: 28px; height: 28px; border-radius: 50%; border: none; + background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.5); + cursor: pointer; font-size: 14px; display: flex; align-items: center; justify-content: center; + transition: background 0.2s; flex-shrink: 0; + } + .history-back:hover { background: rgba(255,255,255,0.12); } + .history-detail-title { font-size: 13px; color: rgba(255,255,255,0.5); flex: 1; } + + .history-messages { + flex: 1; overflow-y: auto; padding: 16px; + scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; + display: flex; flex-direction: column; gap: 10px; + } + .history-msg { + max-width: 85%; padding: 8px 12px; border-radius: 12px; + font-size: 13px; line-height: 1.6; font-weight: 300; + } + .history-msg.user { + align-self: flex-end; + background: rgba(37,99,235,0.2); color: rgba(255,255,255,0.65); + border-bottom-right-radius: 4px; + } + .history-msg.bot { + align-self: flex-start; + background: rgba(255,255,255,0.06); color: rgba(255,255,255,0.7); + border-bottom-left-radius: 4px; + } + + .history-continue-btn { + margin: 16px; padding: 12px; + background: #10b981; border: none; border-radius: 10px; + color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px; + transition: background 0.2s; flex-shrink: 0; + } + .history-continue-btn:hover { background: #059669; } + + .history-empty { + flex: 1; display: flex; align-items: center; justify-content: center; + font-size: 13px; color: rgba(255,255,255,0.2); + } + + /* 历史按钮(启动页和通话页) */ + .hist-btn { + position: absolute; top: 20px; left: 20px; z-index: 22; + width: 36px; height: 36px; border-radius: 50%; border: 1px solid rgba(255,255,255,0.1); + background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4); + cursor: pointer; display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(10px); transition: all 0.2s; + } + .hist-btn:hover { background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7); } + .hist-btn svg { width: 16px; height: 16px; fill: currentColor; } + + /* 历史消息在字幕区的样式 */ + .caption-msg.historical { opacity: 0.35; font-size: 13px; } + .caption-divider { + text-align: center; font-size: 11px; color: rgba(255,255,255,0.2); + padding: 8px 0; letter-spacing: 1px; + } + + /* ============ 登录页 ============ */ + .login-view { + position: absolute; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; z-index: 30; + transition: opacity 0.5s ease; + } + .login-view.fade-out { opacity: 0; pointer-events: none; } + + .login-box { + width: 320px; padding: 36px 32px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 20px; backdrop-filter: blur(20px); + display: flex; flex-direction: column; gap: 16px; + } + .login-title { + font-size: 18px; font-weight: 400; color: rgba(255,255,255,0.85); + text-align: center; margin-bottom: 4px; letter-spacing: 0.5px; + } + .login-sub { + font-size: 12px; color: rgba(255,255,255,0.25); + text-align: center; margin-top: -8px; margin-bottom: 4px; + } + .login-input { + width: 100%; padding: 11px 16px; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.08); + border-radius: 10px; color: #fff; font-size: 14px; outline: none; + transition: border-color 0.2s; font-weight: 300; + } + .login-input::placeholder { color: rgba(255,255,255,0.2); } + .login-input:focus { border-color: rgba(16,185,129,0.5); } + .login-btn { + width: 100%; padding: 12px; + background: #10b981; border: none; border-radius: 10px; + color: #fff; font-size: 14px; cursor: pointer; letter-spacing: 0.5px; + transition: background 0.2s; margin-top: 4px; + } + .login-btn:hover { background: #059669; } + .login-btn:disabled { opacity: 0.4; cursor: wait; } + .login-error { + font-size: 12px; color: #f87171; text-align: center; + min-height: 16px; margin-top: -4px; + } + .login-user { + position: absolute; top: 20px; right: 20px; z-index: 25; + font-size: 12px; color: rgba(255,255,255,0.3); + display: flex; align-items: center; gap: 8px; + } + .login-user span { color: rgba(255,255,255,0.5); } + .logout-btn { + background: none; border: 1px solid rgba(255,255,255,0.1); + border-radius: 6px; padding: 3px 10px; color: rgba(255,255,255,0.3); + font-size: 11px; cursor: pointer; transition: all 0.2s; + } + .logout-btn:hover { border-color: rgba(255,255,255,0.25); color: rgba(255,255,255,0.6); }
+ + + + + + + + + + +
+
+ 对话历史 + +
+
+ + +
+
+ + +
+
+ +
+
+ -
+