3 Commits

Author SHA1 Message Date
e159d18f01 Polish generic NAS deployment template
- Rework docker-compose-Nas.yaml as a QNAP/NAS-friendly generic template with placeholders for NAS IP, public domain, proxy, and secrets.

- Preserve QNAP-specific fixes including relative build context, api network alias, NAS persistent paths, and 127.0.0.1 health checks.

- Keep optional frpc profile while avoiding real server addresses or tokens in the reusable template.

- Replace residual deployment examples with generic your-domain.example, NAS_IP, PROXY_IP, and LAN_CIDR placeholders in README and Docker docs.
2026-05-09 03:46:56 +08:00
95a4678340 Handle overloaded AI provider responses
- Preserve upstream AI provider HTTP status codes and expose AI-specific error codes for overload, rate limit, unavailable, and generic provider failures.

- Add short retries for transient AI provider 429/5xx chat completion responses, with configurable retry delays.

- Show friendly AI busy/unavailable messages in the report editor instead of raw provider JSON.

- Preserve custom backend error codes in the shared API exception filter.

- Add AI service tests for retry behavior and overload error mapping.

- Update API, feature, and testing documentation for AI proxy retry and error handling.
2026-05-09 03:35:55 +08:00
1faaa05032 Refine reusable compose deployment template
- Make the default Docker Compose CORS_ORIGIN configurable so public and NAS deployments can override allowed origins without editing the compose file.

- Update the default frpc profile command to call frpc explicitly, matching the snowdreamtech/frpc entrypoint behavior observed in the deployed copy.

- Apply the same explicit frpc command to the NAS compose template.

- Keep real frpc server address and token from the deployed copy out of the reusable template.
2026-05-08 17:13:18 +08:00
12 changed files with 215 additions and 51 deletions

View File

@@ -278,15 +278,15 @@ docker compose -f docker-compose-Nas.yaml ps
# XXX 必改:生产环境不要继续使用默认 Session 密钥。
SESSION_SECRET=替换为足够长的随机字符串
# XXX 如果公网入口是 https://sstwbg.example.com,建议开启这两项。
# XXX 如果公网入口是 https://your-domain.example建议开启这两项。
SESSION_COOKIE_SECURE=true
TRUST_PROXY=true
CORS_ORIGIN=http://192.168.31.5:4002,https://sstwbg.example.com
CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
# XXX 如需代理构建或让后端访问外网,可按 NAS 网络环境配置。
HTTP_PROXY=http://192.168.31.7:7893
HTTPS_PROXY=http://192.168.31.7:7893
NO_PROXY=localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web
HTTP_PROXY=http://PROXY_IP:PROXY_PORT
HTTPS_PROXY=http://PROXY_IP:PROXY_PORT
NO_PROXY=localhost,127.0.0.1,LAN_CIDR,tuwen_db,tuwen_api,tuwen_web,api,db,web
```
启用 NAS 上的 frpc 隧道:
@@ -297,16 +297,16 @@ docker compose -f docker-compose-Nas.yaml --profile frpc up -d --build
docker compose -f docker-compose-Nas.yaml logs -f frpc
```
`# XXX` NAS 公网访问仍然只需要把 `4002` 通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 给 `sstwbg.example.com` 提供 HTTPS。`4443` 仍只是本机自签名 HTTPS 演示入口,不建议映射到公网域名。
`# XXX` NAS 公网访问仍然只需要把 `4002` 通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 给 `your-domain.example` 提供 HTTPS。`4443` 仍只是本机自签名 HTTPS 演示入口,不建议映射到公网域名。
## 公网反向代理部署
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 sstwbg.example.com 的部署链路。 -->
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口通过 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 your-domain.example 的部署链路。 -->
推荐公网链路:
```text
浏览器 https://sstwbg.example.com
浏览器 https://your-domain.example
-> 公网服务器 Nginx Proxy Manager
-> frps/frpc 映射端口
-> 本机 Docker web:4002
@@ -363,17 +363,17 @@ docker-compose logs -f frpc
docker-compose up -d --build
```
`# XXX` 公网正式访问只需要映射 `4002` 到公网服务器,再由公网 Nginx Proxy Manager 提供 `https://sstwbg.example.com`。不建议把 `4443` 映射到公网域名;`4443` 是本机自签名 HTTPS 演示入口,主要用于 `https://localhost:4443` 测试麦克风。
`# XXX` 公网正式访问只需要映射 `4002` 到公网服务器,再由公网 Nginx Proxy Manager 提供 `https://your-domain.example`。不建议把 `4443` 映射到公网域名;`4443` 是本机自签名 HTTPS 演示入口,主要用于 `https://localhost:4443` 测试麦克风。
Nginx Proxy Manager 中为 `sstwbg.example.com` 新建 Proxy Host
Nginx Proxy Manager 中为 `your-domain.example` 新建 Proxy Host
- `Domain Names``sstwbg.example.com`
- `Domain Names``your-domain.example`
- `Scheme``http`
- `Forward Hostname / IP`frps 可访问到的映射地址,通常是 `127.0.0.1`、公网服务器内网 IP 或 frps 指定监听地址。
- `Forward Port`frpc 暴露出来的 `remotePort`,例如 `4002`
- `Websockets Support`:开启。`# XXX` 语音识别使用 `/api/speech/iat` WebSocket必须透传 Upgrade。
- `Block Common Exploits`:开启。
- `SSL`:申请或绑定 `sstwbg.example.com` 证书,开启 `Force SSL``# XXX` 浏览器麦克风权限要求 HTTPS普通公网 HTTP 下语音识别不可用。
- `SSL`:申请或绑定 `your-domain.example` 证书,开启 `Force SSL``# XXX` 浏览器麦克风权限要求 HTTPS普通公网 HTTP 下语音识别不可用。
Nginx Proxy Manager 的 `Advanced` 可加入:
@@ -389,8 +389,8 @@ proxy_send_timeout 3600s;
公网验收顺序:
```text
1. 打开 https://sstwbg.example.com/api/health确认 API 健康检查可访问。
2. 打开 https://sstwbg.example.com 登录 admin / 123456。
1. 打开 https://your-domain.example/api/health确认 API 健康检查可访问。
2. 打开 https://your-domain.example 登录 admin / 123456。
3. 进入系统设置,确认 AI Provider 和讯飞语音配置有效。
4. 进入报告编辑页测试上传视频、自动抽帧、报告保存、AI 对话和语音听写。
5. 浏览器控制台执行 window.isSecureContext应返回 true否则语音麦克风权限不会开放。

View File

@@ -1,5 +1,5 @@
# XXX QNAP/NAS deployment compose. Designed for Container Station or docker compose on a NAS.
# XXX Edit /share paths, proxy variables, SESSION_SECRET, and frpc/frpc.toml before public deployment.
# XXX 威联通/QNAP NAS 部署模板。
# XXX 使用前建议复制一份 .env按实际 NAS IP、域名、代理和密钥修改变量。
services:
tuwen_db:
@@ -11,7 +11,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-surclaw}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-surclaw_dev_password}
volumes:
# XXX NAS persistent database directory. Change this path if your QNAP share is different.
# XXX 默认适配威联通 Container 共享目录;如目录不同,修改 NAS_DATA_ROOT。
- ${NAS_DATA_ROOT:-/share/Container/tuwen_system_v2/data}/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-surclaw} -d $${POSTGRES_DB:-surclaw}"]
@@ -21,38 +21,38 @@ services:
tuwen_api:
build:
# XXX Keep relative context for QNAP, otherwise Container Station may fail to resolve absolute paths.
# XXX 威联通 Container Station 对绝对路径解析不稳定,保持相对 context。
context: .
dockerfile: Dockerfile.server
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
container_name: tuwen_api
restart: unless-stopped
networks:
default:
aliases:
# XXX nginx.conf proxies /api to http://api:3100, so NAS service keeps this alias.
# XXX nginx.conf /api 固定代理到 http://api:3100NAS 服务需要保留该别名。
- api
depends_on:
tuwen_db:
condition: service_healthy
ports:
# XXX Optional host API port for NAS diagnostics; public users should enter through web:4002/NPM.
# XXX API 诊断端口;公网访问应走 web:4002/Nginx Proxy Manager。
- "${NAS_API_PORT:-4102}:3100"
volumes:
# XXX NAS persistent upload directory for signatures, videos, keyframes, and template images.
# XXX 持久化签名、视频、关键帧和模板图片等上传文件。
- ${NAS_DATA_ROOT:-/share/Container/tuwen_system_v2/data}/uploads:/app/uploads
environment:
NODE_ENV: production
API_PORT: 3100
API_BODY_LIMIT: ${API_BODY_LIMIT:-100mb}
DATABASE_URL: postgresql://${POSTGRES_USER:-surclaw}:${POSTGRES_PASSWORD:-surclaw_dev_password}@tuwen_db:5432/${POSTGRES_DB:-surclaw}?schema=public
CORS_ORIGIN: ${CORS_ORIGIN:-http://192.168.31.5:4002,https://sstwbg.example.com}
# XXX Replace in NAS .env or Container Station variables before real deployment.
# XXX 示例:CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4002,https://localhost:4443}
# XXX 生产环境必须替换;公网 HTTPS 入口建议同时设置 SESSION_COOKIE_SECURE=true 和 TRUST_PROXY=true。
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
# XXX For public HTTPS through Nginx Proxy Manager, set SESSION_COOKIE_SECURE=true and TRUST_PROXY=true.
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
TRUST_PROXY: ${TRUST_PROXY:-false}
FILE_STORAGE_DIR: /app/uploads
@@ -64,9 +64,9 @@ services:
HTTPS_PROXY: ${HTTPS_PROXY:-}
http_proxy: ${HTTP_PROXY:-}
https_proxy: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
healthcheck:
# XXX Use 127.0.0.1 instead of localhost to avoid IPv6 resolution issues on some NAS firmware.
# XXX 使用 127.0.0.1,规避部分 NAS 固件中 localhost 优先解析 IPv6 的问题。
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3100/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 10s
timeout: 5s
@@ -75,13 +75,12 @@ services:
tuwen_web:
build:
# XXX Keep relative context for QNAP.
context: .
dockerfile: Dockerfile
args:
HTTP_PROXY: ${HTTP_PROXY:-}
HTTPS_PROXY: ${HTTPS_PROXY:-}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,192.168.31.0/24,tuwen_db,tuwen_api,tuwen_web,api,db,web}
NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,tuwen_db,tuwen_api,tuwen_web,api,db,web}
container_name: tuwen_web
restart: unless-stopped
depends_on:
@@ -89,10 +88,10 @@ services:
condition: service_healthy
ports:
- "${NAS_WEB_PORT:-4002}:80"
# XXX Local self-signed HTTPS demo only. Public HTTPS should be handled by Nginx Proxy Manager.
# XXX 仅用于本机自签名 HTTPS 演示;公网 HTTPS 应由 Nginx Proxy Manager 提供。
- "${NAS_HTTPS_DEMO_PORT:-4443}:443"
environment:
APP_URL: ${APP_URL:-http://192.168.31.5:4002}
APP_URL: ${APP_URL:-http://localhost:4002}
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 10s
@@ -100,12 +99,12 @@ services:
retries: 6
frpc:
# XXX Optional public tunnel client. Edit frpc/frpc.toml before enabling this profile.
# XXX 可选公网隧道客户端。启用前先编辑 frpc/frpc.toml。
image: snowdreamtech/frpc:latest
container_name: tuwen_frpc
restart: unless-stopped
network_mode: host
command: ["-c", "/etc/frp/frpc.toml"]
command: ["frpc", "-c", "/etc/frp/frpc.toml"]
volumes:
- ./frpc/frpc.toml:/etc/frp/frpc.toml:ro
depends_on:

View File

@@ -26,7 +26,7 @@ services:
environment:
API_PORT: 3100
API_BODY_LIMIT: 100mb
CORS_ORIGIN: http://localhost:4002,https://localhost:4443,http://localhost:3001
CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4002,https://localhost:4443,http://localhost:3001}
DATABASE_URL: postgresql://surclaw:surclaw_dev_password@db:5432/surclaw?schema=public
# XXX Public HTTPS reverse-proxy deployments can override these from the shell or .env.
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
@@ -73,7 +73,7 @@ services:
container_name: tuwen_frpc
restart: unless-stopped
network_mode: host
command: ["-c", "/etc/frp/frpc.toml"]
command: ["frpc", "-c", "/etc/frp/frpc.toml"]
volumes:
- ./frpc/frpc.toml:/etc/frp/frpc.toml:ro
depends_on:

View File

@@ -541,6 +541,7 @@ pageSize?: number
- 请求上下文只能包含当前报告内容和当前报告内用户有权访问的图片/关键帧。
- 不允许跨部门检索报告作为上下文。
- 当前实现接收 OpenAI 兼容 `messages`、温度等参数,后端会用全局 Provider 的 `modelName` 覆盖请求中的 `model`,所有用户共用同一套 key。
- 上游模型返回 `429/5xx` 等临时错误时,后端会对 `/chat/completions` 做短暂重试;重试后仍失败时保留上游 HTTP 状态码,并通过错误码区分 `AI_PROVIDER_OVERLOADED``AI_PROVIDER_RATE_LIMITED``AI_PROVIDER_UNAVAILABLE``AI_PROVIDER_ERROR`
## Speech API

View File

@@ -106,7 +106,7 @@ docker-compose up -d --build
推荐链路:
```text
浏览器 https://sstwbg.example.com
浏览器 https://your-domain.example
-> 公网服务器 Nginx Proxy Manager
-> frps/frpc 映射端口
-> 本机 Docker web:4002
@@ -127,7 +127,7 @@ docker-compose --profile frpc up -d --build
docker-compose logs -f frpc
```
Nginx Proxy Manager 代理 `sstwbg.example.com` 时:
Nginx Proxy Manager 代理 `your-domain.example` 时:
- 代理目标指向 frpc 暴露的 `4002` 映射端口。
- 开启 `Websockets Support`,否则 `/api/speech/iat` 语音 WebSocket 会失败。

View File

@@ -85,7 +85,7 @@ curl http://127.0.0.1:4002/api/health
SESSION_SECRET=替换为足够长的随机字符串
SESSION_COOKIE_SECURE=true
TRUST_PROXY=true
CORS_ORIGIN=http://192.168.31.5:4002,https://sstwbg.example.com
CORS_ORIGIN=http://NAS_IP:4002,https://your-domain.example
```
启用 NAS 上的 frpc 隧道:
@@ -149,9 +149,9 @@ https://localhost:4443
## 公网域名反向代理
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口经 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 sstwbg.example.com。 -->
<!-- # XXX 公网部署新增:适用于本机 Docker 4002 端口经 frpc 映射到公网服务器,再由 Nginx Proxy Manager 绑定 your-domain.example。 -->
如果使用 `本机 Docker 4002 -> frpc -> 公网服务器 Nginx Proxy Manager -> sstwbg.example.com`,推荐流程:
如果使用 `本机 Docker 4002 -> frpc -> 公网服务器 Nginx Proxy Manager -> your-domain.example`,推荐流程:
```bash
# XXX 使用公网 HTTPS 入口时compose 变量可从 shell 或 .env 覆盖。
@@ -166,7 +166,7 @@ docker-compose logs -f frpc
Nginx Proxy Manager 配置要点:
- `Domain Names` 使用 `sstwbg.example.com`
- `Domain Names` 使用 `your-domain.example`
- `Forward Hostname / IP` 指向 frpc 在公网服务器暴露的地址。
- `Forward Port` 填 frpc 暴露的端口,例如 `4002`
- 开启 `Websockets Support`,保证 `/api/speech/iat` 语音听写 WebSocket 可升级。
@@ -175,7 +175,7 @@ Nginx Proxy Manager 配置要点:
`# XXX` 公网正式访问只映射 `4002` 即可;不要把本机自签名 HTTPS 演示入口 `4443` 直接映射为公网域名入口。公网 HTTPS 应由 Nginx Proxy Manager 负责。
公网验收先访问 `https://sstwbg.example.com/api/health`再登录并测试报告保存、视频抽帧、AI 对话和语音听写。
公网验收先访问 `https://your-domain.example/api/health`再登录并测试报告保存、视频抽帧、AI 对话和语音听写。
## 生产部署前必须修改

View File

@@ -41,7 +41,7 @@
| 视频上传 | 真实集成 | 使用浏览器 File API 和对象 URL即时预览并优先通过 `/api/files``kind = VIDEO` 写入后端文件资源。 |
| 自动抽帧 | 真实集成 | 使用 `<video>` + `<canvas>` 按百分比截取 JPEG关键帧优先通过 `/api/files``kind = FRAME` 写入后端文件资源;百分比支持两位小数并保留配置顺序,实际截图按时间顺序执行,自动插入按配置顺序执行。 |
| 关键帧插入 | 真实集成 | 关键帧可点击插入或拖入图片占位符;上传成功后编辑器会把插入图片从 Data URL 替换为受控文件 URL。 |
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
| AI 辅助撰写 | 真实集成 | 前端调用 `/api/ai/chat`,后端使用全局共用 Provider Key 代理 OpenAI 兼容 `/chat/completions`上游临时过载时会短暂重试并保留 429/5xx 状态供前端友好提示;AI 可编辑区域插入后会立即同步到目标下拉栏;需要有效 Provider 配置、模型和网络。 |
| AI 差异确认 | 真实可用 | 使用 `diff` 生成左右差异,确认后写入 AI 区域。 |
| 讯飞语音听写 | 真实集成 | 前端使用麦克风采集音频并连接 `/api/speech/iat`;后端读取讯飞配置、生成鉴权 URL、补齐首帧 APPID/业务参数并转发 IAT 结果。需要浏览器权限、安全上下文(`localhost` 或 HTTPS、有效配置和网络Docker 提供 `https://localhost:4443` 演示入口。 |
| AI/语音密钥管理 | 真实集成 | AI Key 和讯飞 APIKey/APISecret 均由后端代理读取和使用;普通用户读取设置时不返回真实密钥。 |

View File

@@ -32,7 +32,7 @@ npm run build
| 后端模板映射 | Template API 返回前端可消费的 `Template` 结构,并生成权限策略资源。 |
| 后端用户映射 | Users API 返回前端可消费的 `User` 结构,并把部门模板授权映射成 `visibleTemplates/manageableTemplates`。 |
| 后端设置校验 | Settings API 使用 schema 校验抽帧、AI Provider 和语音配置。 |
| 后端 AI 代理入参 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容。 |
| 后端 AI 代理入参和错误处理 | AI Proxy 使用 schema 校验 OpenAI 兼容消息和多模态内容;上游 429/5xx 会短暂重试,并保留上游状态与 AI 专用错误码。 |
| 后端语音代理帧处理 | Speech Proxy 对首个讯飞 IAT 音频帧补齐 APPID 和默认业务参数,后续帧保持兼容,并把上游文本消息按字符串转发给浏览器。 |
| 后端字段库和文件 schema | Library/Files API 校验字段库和通用文件上传 payload。 |
| 后端 HTTP 集成 | Nest HTTP 层覆盖 API prefix、登录会话、未登录保护、受保护接口 actor 传递。 |
@@ -104,6 +104,7 @@ AI 第三方接口、讯飞语音上游 WebSocket、麦克风权限和真实视
| 后端系统设置 schema | 已覆盖 | `server/src/settings/settings.schemas.test.ts` |
| 演示模式默认值 | 已覆盖 | `server/src/demo/demo-defaults.test.ts` 覆盖后端默认模板与前端报告编辑器默认内容一致,并校验语音演示配置完整。 |
| 后端 AI 代理 schema | 已覆盖 | `server/src/ai/ai.schemas.test.ts` |
| 后端 AI 代理重试和错误码 | 已覆盖 | `server/src/ai/ai.service.test.ts` |
| 后端语音代理首帧处理 | 已覆盖 | `server/src/speech/xf-frame.test.ts` |
| 后端字段库 schema | 已覆盖 | `server/src/library/library.schemas.test.ts` |
| 后端文件 schema | 已覆盖 | `server/src/files/files.schemas.test.ts` |

View File

@@ -0,0 +1,82 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { SafeUser } from '../auth/auth.types';
import { AiService } from './ai.service';
const actor: SafeUser = {
id: 'user-1',
username: 'admin',
role: 'super',
name: '管理员',
tenantId: 'tenant-1',
departmentId: 'dept-1',
departmentName: '外科',
status: 'active',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const createService = () => {
const settingsService = {
getSystemSettings: vi.fn().mockResolvedValue({
activeAiProvider: 'kimi',
aiProviders: {
kimi: {
endpoint: 'https://provider.example/v1',
apiKey: 'test-key',
modelName: 'moonshot-v1',
},
},
}),
};
return new AiService(settingsService as never);
};
describe('AiService', () => {
const originalRetryDelays = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
beforeEach(() => {
process.env.AI_PROVIDER_RETRY_DELAYS_MS = '0,0';
});
afterEach(() => {
vi.unstubAllGlobals();
if (originalRetryDelays === undefined) {
delete process.env.AI_PROVIDER_RETRY_DELAYS_MS;
} else {
process.env.AI_PROVIDER_RETRY_DELAYS_MS = originalRetryDelays;
}
});
it('retries transient provider overloads and then returns a completion', async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ choices: [{ message: { content: '{"reply":"已完善"}' } }] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
const response = await createService().chat(actor, {
messages: [{ role: 'user', content: '请完善报告内容' }],
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(response).toEqual({ choices: [{ message: { content: '{"reply":"已完善"}' } }] });
});
it('preserves provider overload status and exposes an AI-specific error code', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message: 'The engine is currently overloaded', type: 'engine_overloaded_error' }), { status: 429 }),
);
vi.stubGlobal('fetch', fetchMock);
await expect(createService().chat(actor, {
messages: [{ role: 'user', content: '请完善报告内容' }],
})).rejects.toMatchObject({
response: expect.objectContaining({
code: 'AI_PROVIDER_OVERLOADED',
message: expect.stringContaining('AI 服务请求失败429'),
}),
status: 429,
});
expect(fetchMock).toHaveBeenCalledTimes(3);
});
});

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { BadRequestException, HttpException, Injectable } from '@nestjs/common';
import type { SafeUser } from '../auth/auth.types.js';
import { SettingsService } from '../settings/settings.service.js';
import { aiChatSchema } from './ai.schemas.js';
@@ -9,6 +9,9 @@ interface AiProvider {
modelName: string;
}
const RETRYABLE_PROVIDER_STATUSES = new Set([429, 500, 502, 503, 504]);
const DEFAULT_RETRY_DELAYS_MS = [600, 1200];
@Injectable()
export class AiService {
constructor(private readonly settingsService: SettingsService) {}
@@ -22,7 +25,7 @@ export class AiService {
const payload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, payload));
throw this.createProviderException(response.status, payload);
}
const models = Array.isArray((payload as { data?: unknown[] }).data)
@@ -54,7 +57,7 @@ export class AiService {
model: provider.modelName || input.model,
};
const response = await this.fetchProvider(`${provider.endpoint}/chat/completions`, {
const response = await this.fetchProviderWithRetry(`${provider.endpoint}/chat/completions`, {
method: 'POST',
headers: this.headers(provider),
body: JSON.stringify(payload),
@@ -62,7 +65,7 @@ export class AiService {
const responsePayload = await this.parseProviderResponse(response);
if (!response.ok) {
throw new BadRequestException(this.formatProviderError(response.status, responsePayload));
throw this.createProviderException(response.status, responsePayload);
}
return responsePayload;
@@ -114,6 +117,17 @@ export class AiService {
}
}
private async fetchProviderWithRetry(url: string, init: RequestInit) {
const retryDelays = this.retryDelays();
let response = await this.fetchProvider(url, init);
for (const delayMs of retryDelays) {
if (!RETRYABLE_PROVIDER_STATUSES.has(response.status)) break;
await this.sleep(delayMs);
response = await this.fetchProvider(url, init);
}
return response;
}
private formatProviderError(status: number, payload: unknown) {
const message =
typeof payload === 'object' && payload !== null && 'error' in payload
@@ -123,4 +137,47 @@ export class AiService {
: JSON.stringify(payload);
return `AI 服务请求失败:${status}${message ? ` - ${message}` : ''}`;
}
private createProviderException(status: number, payload: unknown) {
return new HttpException(
{
code: this.providerErrorCode(status, payload),
message: this.formatProviderError(status, payload),
},
status,
);
}
private providerErrorCode(status: number, payload: unknown) {
const providerType =
typeof payload === 'object' && payload !== null && 'type' in payload
? String((payload as { type: unknown }).type)
: typeof payload === 'object' && payload !== null && 'error' in payload
? this.extractProviderErrorType((payload as { error: unknown }).error)
: '';
if (status === 429 && /overloaded/i.test(providerType)) return 'AI_PROVIDER_OVERLOADED';
if (status === 429) return 'AI_PROVIDER_RATE_LIMITED';
if (status >= 500) return 'AI_PROVIDER_UNAVAILABLE';
return 'AI_PROVIDER_ERROR';
}
private extractProviderErrorType(error: unknown) {
return typeof error === 'object' && error !== null && 'type' in error
? String((error as { type: unknown }).type)
: '';
}
private retryDelays() {
const raw = process.env.AI_PROVIDER_RETRY_DELAYS_MS;
if (!raw) return DEFAULT_RETRY_DELAYS_MS;
return raw
.split(',')
.map((value) => Number(value.trim()))
.filter((value) => Number.isFinite(value) && value >= 0);
}
private sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}

View File

@@ -26,14 +26,19 @@ export class ApiExceptionFilter implements ExceptionFilter {
response.status(status).json({
error: {
code: this.resolveCode(status),
code: this.resolveCode(status, payload),
message,
},
requestId: response.getHeader('x-request-id') ?? undefined,
});
}
private resolveCode(status: number) {
private resolveCode(status: number, payload?: unknown) {
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
const code = (payload as { code?: unknown }).code;
if (typeof code === 'string' && code) return code;
}
switch (status) {
case HttpStatus.BAD_REQUEST:
return 'BAD_REQUEST';
@@ -41,10 +46,14 @@ export class ApiExceptionFilter implements ExceptionFilter {
return 'UNAUTHORIZED';
case HttpStatus.FORBIDDEN:
return 'FORBIDDEN';
case HttpStatus.TOO_MANY_REQUESTS:
return 'TOO_MANY_REQUESTS';
case HttpStatus.NOT_FOUND:
return 'NOT_FOUND';
case HttpStatus.CONFLICT:
return 'CONFLICT';
case HttpStatus.SERVICE_UNAVAILABLE:
return 'SERVICE_UNAVAILABLE';
case HttpStatus.UNPROCESSABLE_ENTITY:
return 'VALIDATION_ERROR';
default:

View File

@@ -47,6 +47,21 @@ const getApiErrorMessage = (error: unknown, fallback: string) => {
return fallback;
};
const getAiErrorMessage = (error: unknown) => {
if (error instanceof ApiError) {
if (error.status === 401) return '登录状态已失效,请重新登录后再使用 AI。';
if (error.status === 429 || error.code === 'AI_PROVIDER_OVERLOADED' || error.code === 'AI_PROVIDER_RATE_LIMITED') {
return 'AI 服务当前繁忙或请求过多,请稍后重试。';
}
if (error.status >= 500 || error.code === 'AI_PROVIDER_UNAVAILABLE') {
return 'AI 服务暂时不可用,请稍后重试或切换其他模型。';
}
return error.message || 'AI 服务请求失败,请稍后重试。';
}
if (error instanceof Error) return error.message || 'AI 服务请求失败,请稍后重试。';
return 'AI 服务请求失败,请稍后重试。';
};
export default function ReportEditor() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -1428,7 +1443,7 @@ export default function ReportEditor() {
setAiSelectedEditorImages([]);
} catch (error: any) {
console.error(error);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统错误】: ${error.message}` }]);
setChatMessages(prev => [...prev, { id: Date.now().toString(), role: 'model', content: `【系统提示】: ${getAiErrorMessage(error)}` }]);
} finally {
setIsGenerating(false);
}