提交 17e4823d authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增AI网关

上级 6a6e6197
......@@ -32,3 +32,10 @@ PERMISSION_APP_ID=
PERMISSION_APP_SECRET=
SSO_USER_CACHE_TTL_SECONDS=180
PERMISSION_CACHE_TTL_SECONDS=180
# AI 网关上游(密钥只放环境变量,不进数据库)
VOLCANO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
VOLCANO_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_API_KEY=
AI_RATE_LIMIT_PER_MINUTE=120
......@@ -25,3 +25,12 @@ PERMISSION_APP_ID=ezijing_82b452761ada1b79fd12b9fcbd73629e
PERMISSION_APP_SECRET=f19d1e788ac9ea0e3c72ef2e3caee8f0
SSO_USER_CACHE_TTL_SECONDS=180
PERMISSION_CACHE_TTL_SECONDS=180
# AI 网关上游(密钥只放环境变量,不进数据库)
VOLCANO_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
VOLCANO_API_KEY=10c95d49-0368-4fb1-b87c-7668eb0ce67d
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_API_KEY=sk-f1a6f0a7013241de8393cb2cb108e777
AI_RATE_LIMIT_PER_MINUTE=120
......@@ -27,3 +27,6 @@ Thumbs.db
# Build
build/
dist/
# 本地数据备份(清理/迁移前的快照),不入库
backup/
......@@ -76,11 +76,107 @@ test/
## 已知取舍
- **权限规则目前有三处表达**:路由 hooks(粗粒度角色拦截)、service 断言(状态校验)、
`projects/crud.js` 的 `getProjectAccess`(前端能力标志)。**已知漂移**:
`can_edit_project` 少了阶段判断——项目负责人在非「方案/立项」阶段会看到可点、
但提交返回 409 的「编辑」按钮。收敛思路:抽 `projects/policy.js` 纯函数 + 矩阵一致性测试,
详见 2026-09-08 的讨论(暂缓实施)。
- **AI 网关(v2 简化)**:模型 = 别名(映射, 一行含 provider/upstream_model) + 版本化价格(alias+version)。
上游每个 provider 一个 key,读环境变量(`VOLCANO_API_KEY`/`DEEPSEEK_API_KEY`),baseUrl 支持 `VOLCANO_BASE_URL` 覆盖(测试 mock 用)。
2026-09-09 用户明确不要「上游账号 + 多路由」的复杂结构(一个火山 key 先用着;将来按学校分 key 时再扩展路由表)。
计量:key 级周期预算(非钱包);用量按 (应用,key,用户) 归因;`ai_usage` 记 price_version 供对账。
AI 网关首次上线仅执行 `migrations/002_init_ai_gateway_schema.sql`,全部 AI 表和索引放在此文件。
不维护旧预扣数据迁移或价格版本缺失时的兼容分支。
落地约定(2026-09-09 补):
- 2026-09-11 用户决定移除预扣与估算计费。请求前只检查已用额度(软上限),
完成后按上游真实 usage 累计;并发和在途任务可能超预算。
文本/图片共用 `runCall()`;视频提交记零成本 pending,完成后在事务内幂等计费。
缺失用量或中断时不估算,记 `usage_source=missing`,成本 0 表示尚未确认,非免费。
`usage_missing` 标记用于后续核对。保留提交时价格版本和视频提交账期。
实测样本(2026-09-10,`doubao-seedream-4-5-251128`):
`{ model, created, data: [{ url, size: "2048x2048" }], usage: { generated_images: 1, output_tokens: 16384, total_tokens: 16384 } }`
—— 图片按 `usage.generated_images` 计费,`output_tokens` 只作对账,契约锁在
`test/unit/ai-image-usage.test.js`。
- **定价口径(2026-09-10 定,不做档位)**:一个模型一个价,全部在「模型」里维护
(`ai_model_prices`,alias + version,改价 = 关旧版本开新版本):
| 能力 | 计费单位 | 价格字段 |
|---|---|---|
| 文本 | token | `input_price` / `cached_input_price` / `output_price`(元/1M token) |
| 图片 | 张 | `unit_price`(元/张) |
| 视频 | 秒 | `unit_price`(元/秒) |
上游响应里的 `size` / `resolution` / `service_tier` / `draft` 等字段**不参与计价**,
只把 `completion_tokens` 记进 `ai_usage` 供对账。
- **待校准(口径已定,数值未核)**:上游成本与我们的对外单价并非同一维度——
图片上游按 token 计(实测 `output_tokens` 恒为 16384/张),视频上游按 token 计
(实测 720p/24fps 5 秒 = 108900 token ≈ 21780 token/秒),我们对外分别按张、按秒。
所以「元/张」「元/秒」这两个单价本质上是由 token 单价和分辨率折算出来的派生值:
分辨率或 `service_tier`(`flex` 是 `default` 的 50%)一变,同一单价就可能收错钱。
**上账单核对 `unit_price` 的数值**,必要时再讨论是否引入档位;
在此之前不要假定所有分辨率/服务等级的成本都一样。
实测样本(2026-09-10):
- 图片 `doubao-seedream-4-5-251128`:
`{ data: [{ url, size: "2048x2048" }], usage: { generated_images: 1, output_tokens: 16384 } }`
—— 契约见 `test/unit/ai-image-usage.test.js`;
- 视频 `doubao-seedance-2-0-260128`:
`{ status: "succeeded", content: { video_url }, usage: { completion_tokens: 108900 },
duration: 5, resolution: "720p", framespersecond: 24, service_tier: "default" }`
—— 时长取**顶层** `duration`(不是 `usage.duration`,代码两者都兼容)。
- 视频 `duration=-1` 支持智能时长,按任务完成后的真实时长计费。
- 流式超时用**空闲超时**(`streamChatCompletion({ idleTimeoutMs })`,两条 chunk 之间的最大间隔),
**不要**用 `AbortSignal.timeout` 当流式总时限——它是绝对超时,会把超过时限的健康长回答整条掐断。
- 数据面每个请求由 `v1/autohooks.js` 挂 `request.abortSignal`(监听 `reply.raw` 的 close,
**不能**监听 `request.raw` 的 close,请求体读完就会触发),客户端断开时取消上游;
路由把它和各自的 `AbortSignal.timeout` 用 `AbortSignal.any` 组合。
- `ai_quota_usage.used_micro` 仅累计真实成本,不再有负数退补。
- 价格「当前生效」条件只有 `pricing.service.js#activePriceCondition()` 一份,管理面取价复用它。
- **DMS 权限分三层,但能力标志只有一份判定**(分层是有意的,重复不是):
① 路由 hooks:粗粒度角色拦截(`services/dms/hooks.js`);
② 写接口断言:状态/阶段校验(`projects/helpers.js` 的 `assert*`);
③ 前端能力标志:`services/dms/projects/policy.js#buildAccessFlags()`。
③ 是 `can_edit_project` / `can_manage_team` / `can_move_stage` / `can_rollback_stage` /
`editable_stages` 的**唯一来源**(列表 `listProjects` 与详情 `getProjectAccess` 都调它),
且与 ② 共用同一份阶段常量 `EDITABLE_STAGES`。
**admin 短路只绕过角色,不绕过归档/阶段**——原来列表侧写成 `admin || (...)` 无条件 true,
管理员对归档项目看到「可编辑」、点进去 PUT 却 400,已修。
历史漂移(供借鉴):先是详情侧补了阶段判断而列表侧没有,随后列表侧管理员分支又漏了。
判据:**同一个字段在两处算出不同结论**就是 bug——新增能力标志先看 policy,不要在 service 里就地拼条件。
- **DMS 写路径读项目必须加行锁**:`getProjectByCodeWithDb(db, code, { forUpdate: true })`
(`FOR UPDATE`)。事务内不加锁时两个并发请求会读到同一份旧状态、各自通过校验,
实测并发两次阶段流转会写出**两条**审计流水(终态正确但流水重复)。
所有写路径(crud / phases / stages)都先锁 `project_list` 同一行,锁顺序一致,不会死锁。
只读路径(`getProjectByCode`)不加锁。
- **`project_list.product_name` / `case_list.product_name` 已删除**(2026-09-10)。
这两列只在写路径被赋值,读路径全部用 `leftJoin(product_list)` 取 `products.name` 覆盖,
全仓库无人读(已 grep 核对),客户端传 `product_name` 会「看起来改成功、实际无效」。
产品名一律走 join,`resolveProductFields` 现在只校验并返回 `product_id`。
- **用量页(center-dms `modules/ai/views/UsageSummary.tsx`)的信息架构**:只有一拨人看(运营/管理),
按「问题」分块,不再用一张表 + `group_by` 下拉回答所有问题:
⓪ **作用域**(应用 / 密钥下拉,决定下面所有模块口径,并显示「当前口径」提示条);
① KPI(总成本/调用次数/单次均价/失败次数,**每个都带环比**);② 按天趋势;
③ **成本排名**(应用/密钥/用户三选一,横向对比谁花得多——与筛选互补);
④ 成本构成(模型/能力,两个环形);⑤ 请求构成(按状态,失败率决定上面几块可不可信)。
「按应用/按 Key 看用量」= 作用域筛选,**不要**再退回用 group_by 切换视图:
`app_id` / `api_key_id` / `user_id` 三个过滤后端一直支持(`report.service.buildWhere`),
前端必须把它们接到筛选器上;换应用时要清空密钥选择,避免「B 应用 + A 密钥」错配。
首屏固定 5 个 `summarize` 请求(本期汇总 / 上期汇总算环比 / day / model / status),
所以 `USAGE_GROUP_BYS` 里有 `status` 维度——**不要退回去用「按 status 过滤两次」的数量差**。
图表直接用 `echarts-for-react`(自带 echarts 实例),**不要手写图表、也不要自己包 ECharts 生命周期**。
避坑:该包的 `lib/` 是 CJS 产物,在 Vite 里 `default` 为 `undefined`,会报
"Element type is invalid ... but got: object"——只用主入口 `import ReactECharts from 'echarts-for-react'`。
代价是 ECharts 会进这个懒加载路由的 chunk(约 1.1MB / gzip 380KB),可接受;
若将来要压体积,再换 `echarts/core` 按需注册 + `esm/core.js`。
单次均价是主指标之一:**成本涨了要能区分「用得多」还是「每次都贵」**。
- **测试必须自己清理数据,否则会污染开发库**(2026-09-10 踩到):
`test/routes/ai-gateway.test.js` 原来 18 处 `t.after` 清理里漏了 6 处
(5 个删了 key/app 却没删 `ai_usage`,1 个连 `t.after` 都没有),
结果测试库攒下 **186 行 app_id 已失效的孤儿用量行**,用量页上凭空多出 178 个「模型」。
现在的做法:文件级 `afterEach` 按命名约定兜底清理(测试应用 code 一律 `test-` 前缀,
测试模型 alias 同前缀),用例自己的 `t.after` 保留但必须**幂等**——
`afterEach` 在 `t.after` **之前**执行(node:test 顺序),兜底先删,用例再删就是 not found。
新增用例时不需要写清理,但创建的 app/model 必须用 `test-` 前缀。
- **logs 域已停用**(2026-09-10 业务确认「现在用不到了」):
`routes/api/logs.js` + `services/logs.service.js` + `models/log.model.js` 保留代码但不再投入,
`/api/logs` 的无鉴权、keyword 正则、Mongo 缓冲超时等问题**已知且暂不处理**——不要重复评估,也不要顺手删(是否下线由业务决定)。
- `src/config.js` 里的微信 app secret 目前硬编码(P0 未处理,历史遗留)。
- `.env.prod` 已被 git 跟踪且随镜像发布(`Dockerfile` 的 `COPY ./` + deploy 脚本指向它)。
......
# AI 网关 API 标准
版本:v1.0,2026-09-14。状态:设计稿,尚未完成真实供应商联调。
## 1. 设计结论
不再建设独立 AI SDK。调用链固定为:
业务页面 → 业务项目内的轻量 AI 请求层 → AI 网关 → 供应商
| 层次 | 职责 |
| --- | --- |
| 业务页面 | 选择公共模型,构造请求,消费网关标准响应 |
| 业务请求层 | 统一 Base URL、鉴权、JSON 请求、SSE 读取和视频轮询 |
| AI 网关 | 模型路由、供应商参数转换、响应统一、用量记录和真实错误返回 |
| 供应商适配器 | 只存在于网关内部,业务不得感知供应商 |
业务请求层不是 SDK,不定义第二套协议:
- 请求字段与网关协议同名,不兼容历史 SDK 字段。
- 文本原样返回 `choices`,不转换为顶层 `content`。
- 图片原样返回 `data`,不转换为 `images`。
- 视频原样返回任务结构,不转换为顶层 `url`。
- 错误保留网关完整 `error` 对象和 HTTP 状态。
- 不解析模型名,不包含供应商 Key 或供应商分支。
浏览器不能持有长期网关 Key。前端调用同源业务服务,由业务服务完成用户鉴权并转发到网关;供应商 Key 只保存在网关。
## 2. 公共入口
Base URL:`/ai`
| HTTP 接口 | 用途 |
| --- | --- |
| `GET /models?type=text\|image\|video` | 查询当前应用可用模型 |
| `POST /chat/completions` | 文本生成,`stream` 区分普通与流式 |
| `POST /images/generations` | 文生图、参考图生图和组图 |
| `POST /videos` | 创建文生视频或图生视频任务 |
| `GET /videos/{id}` | 查询视频任务 |
| `GET /usage` | 查询当前应用的调用明细 |
| `GET /usage/summary` | 查询当前应用的用量汇总 |
业务只使用公共模型 ID。请求转发时网关将其替换为上游模型;供应商响应由适配器整理成本文定义的结构。上游已经符合标准时直接返回,只把 `model` 改回公共模型 ID。
模型目录只返回页面所需的最小字段:
```json
{
"data": [
{ "id": "text-default", "name": "通用文本", "type": "text" }
]
}
```
网关只校验路由所需的必填项和基本类型,不建设逐模型参数规则库。文本和图片的兼容参数直接透传,上游不支持的值由上游按自身规范返回。
## 3. 文本生成
`POST /chat/completions` 遵循 OpenAI Chat Completions。网关校验 `model`、`messages` 和 `stream`,其余 OpenAI 兼容字段直接透传:
```json
{
"model": "text-default",
"messages": [
{ "role": "system", "content": "你是一名课程助教" },
{ "role": "user", "content": "总结这段内容" }
],
"temperature": 0.7,
"max_completion_tokens": 2000,
"stream": false
}
```
| 字段 | 要求 |
| --- | --- |
| `model` | 必填,公共文本模型 ID |
| `messages` | 必填;支持 `system`、`user`、`assistant` 的字符串内容 |
| `temperature` | 可选,直接透传 |
| `max_completion_tokens` / `max_tokens` | 可选,按上游兼容协议直接透传 |
| `stream` | 可选,默认 `false` |
网关不解析提示词、不读取业务文件、不执行工具。兼容协议已有的参数由供应商负责校验,网关不重复维护字段白名单。
搜索不属于 AI 网关范围。网关不提供搜索接口、不配置搜索模型、不接收 `search` 或 `web_search` 参数,也不记录搜索用量;业务如需搜索,应在业务系统中单独调用搜索服务。
非流式成功响应:
```json
{
"id": "chat_123",
"object": "chat.completion",
"created": 1789084800,
"model": "text-default",
"choices": [{
"index": 0,
"message": { "role": "assistant", "content": "总结内容" },
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 21,
"completion_tokens": 50,
"total_tokens": 71
}
}
```
业务读取 `response.choices[0].message.content`,请求层不得再生成 `response.content`。
流式响应使用 `text/event-stream`,增量位于 `choices[].delta`,最后发送 `data: [DONE]`。流中错误发送 `event: error`,其 `data` 使用第 6 节结构,随后关闭连接。
## 4. 图片生成
`POST /images/generations` 校验 `model` 和 `prompt`,其余火山图片兼容参数直接透传:
```json
{
"model": "image-default",
"prompt": "产品宣传海报",
"image": ["https://example.com/reference.png"],
"size": "2048x2048",
"watermark": true
}
```
| 字段 | 要求 |
| --- | --- |
| `model` | 必填,公共图片模型 ID |
| `prompt` | 必填,提示词 |
| `image` | 可选,参考图 URL、data URI 或数组;省略即文生图 |
| `size` | 可选,`WIDTHxHEIGHT`;省略时使用上游默认值 |
| `watermark` | 可选,是否添加水印 |
| `sequential_image_generation` | 可选,`disabled` 或 `auto` |
| `sequential_image_generation_options.max_images` | 组图最大张数,仅在 `auto` 时使用 |
网关不把 `width`、`height`、`count` 等业务字段转换成供应商字段;业务应直接提交目标模型支持的标准参数。供应商新增兼容参数时,网关无需同步增加字段。
成功响应:
```json
{
"created": 1789084800,
"model": "image-default",
"data": [{ "url": "https://example.com/output.png" }],
"usage": { "generated_images": 1 }
}
```
业务读取 `response.data`,请求层不得转换为 `response.images`。只有上游实际返回的值才写入响应;未知尺寸、有效期或用量不得伪造为 `0`。
## 5. 视频生成
视频使用异步任务,因为供应商创建和生成是两个阶段。
创建任务:`POST /videos`
```json
{
"model": "video-default",
"prompt": "镜头缓慢推进",
"image": "https://example.com/start.png",
"duration": 5,
"aspect_ratio": "16:9",
"resolution": "720p",
"fps": 24,
"watermark": true
}
```
| 字段 | 要求 |
| --- | --- |
| `model` | 必填,公共视频模型 ID |
| `prompt` | 必填,提示词 |
| `image` | 可选,首帧 URL 或 data URI;省略即文生视频 |
| `duration` | 可选,正数秒 |
| `aspect_ratio` | 可选,如 `16:9` |
| `resolution` | 可选,如 `720p`、`1080p` |
| `fps` | 可选,帧率 |
| `watermark` | 可选,是否添加水印 |
视频不是 OpenAI 兼容协议,网关只转换已经定义的公共字段;未定义的供应商专属参数暂不透传。
创建成功返回 `202`:
```json
{
"id": "vid_123",
"model": "video-default",
"status": "queued",
"video": null,
"usage": null,
"error": null
}
```
查询任务:`GET /videos/vid_123`
```json
{
"id": "vid_123",
"model": "video-default",
"status": "succeeded",
"video": {
"url": "https://example.com/result.mp4",
"duration": 5,
"expires_at": null
},
"usage": { "seconds": 5 },
"error": null
}
```
状态为 `queued`、`processing`、`succeeded`、`failed`、`expired`。查询接口即使生成失败也返回 `200`,并在任务的 `error` 中给出第 6 节结构。业务请求层可以提供 `waitVideo(id)`,但最终仍返回标准任务对象。
第一版不额外建设通用任务中心、Redis、MQ、幂等层或复杂 worker。网关保存上游任务 ID 和计费状态,查询时刷新状态并防止重复结算。
## 6. 错误
所有错误都使用同一个外层结构。供应商错误不翻译、不覆盖真实 `code` 和 `message`,完整原始响应放在 `details` 中:
```json
{
"error": {
"source": "provider",
"provider": "volcano",
"code": "InvalidParameter",
"type": "provider_error",
"message": "invalid image",
"request_id": "req_123",
"provider_request_id": "provider_req_123",
"details": {
"error": {
"code": "InvalidParameter",
"message": "invalid image"
}
}
}
}
```
鉴权、模型路由、配额和内部配置等网关错误使用:
```json
{
"error": {
"source": "gateway",
"code": "invalid_request",
"type": "invalid_request_error",
"message": "请求参数不正确",
"request_id": "req_123"
}
}
```
- 网关生成 `X-Request-Id` 便于日志定位。
- 上游网络错误和超时使用 `source=provider`,同时带上供应商名称和稳定错误码。
- 日志不得输出供应商 Key、Authorization 或完整敏感请求。
- 业务请求层应保留 HTTP 状态和完整响应体,不能只留下“生成失败”。
## 7. 火山引擎适配
| 公共操作 | 火山目标 | 核心转换 |
| --- | --- | --- |
| 文本生成 | `/api/v3/chat/completions` | 映射模型;规范化普通响应、SSE 和 usage |
| 图片生成 | `/api/v3/images/generations` | 保持参考图语义;明确映射组图;响应统一为 `data[]` |
| 视频提交 | `/api/v3/contents/generations/tasks` | `prompt`/`image` 转 content;`aspect_ratio` → `ratio`;`fps` → `framespersecond` |
| 视频查询 | `/api/v3/contents/generations/tasks/{id}` | 映射状态;`content.video_url` → `video.url`;时长 → `usage.seconds` |
适配器必须用脱敏后的真实成功、失败、流式和任务样本验证。尚未核实的供应商字段不能凭字段名相似直接转换。
官方核验入口:
- [OpenAI Chat Completions](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
- [OpenAI Images](https://developers.openai.com/api/reference/resources/images/methods/generate)
- [火山文本 API](https://www.volcengine.com/docs/82379/1494384)
- [火山图片 API](https://www.volcengine.com/docs/82379/1541523)
- [火山创建视频任务](https://www.volcengine.com/docs/82379/1520757)
- [火山查询视频任务](https://www.volcengine.com/docs/82379/1521309)
## 8. 用量与实施顺序
不做预扣和估算。文本按实际 token、图片按实际成功张数、视频按实际秒数记账;缺少真实用量时标记待补充,不写成 `0`。
实施顺序:
1. 按本文收口网关路由、适配器、响应和错误协议。
2. 用真实火山请求验证文本普通/流式、图片和视频创建/查询,保存脱敏契约样本。
3. 在 `saas-ai` 内新增一个轻量请求文件,直接调用以上五个接口。
4. 修改页面:文本读取 `choices`,图片读取 `data`,视频读取任务的 `video.url`;删除供应商注册与历史 SDK 字段。
5. 移除 `@ezijing/ai-vue` 和浏览器侧供应商配置;已有前端供应商 Key 应废止并轮换。
6. 用一个首次部署 SQL 完成 AI 网关表结构;若开发库已执行过旧 SQL,只额外提供一个明确的修复 SQL。
验收重点:业务代码中没有供应商名称和模型解析分支;不同供应商通过同一请求得到同一响应结构;第三方错误真实可见;没有用估算值结算。
本文定义目标协议,不代表当前代码已经实现或完成线上验证。
<title>AI 网关设计</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;600;700&family=IBM+Plex+Serif:wght@400;600&display=swap">
<style>
:root {
--ground: #F3F5F2;
--surface: #FFFFFF;
--surface-sunk: #E9ECE7;
--ink: #1A1E24;
--ink-muted: #5C6672;
--ink-faint: #8A929C;
--rule: #D6DBD3;
--rule-strong: #B9C1B7;
--accent: #A85F0B;
--accent-ink: #7A4406;
--accent-soft: #F4E6CE;
--ok: #2C6B4F;
--ok-soft: #DCEBE1;
--code-bg: #EBEEE8;
--shadow: 0 1px 2px rgba(26, 30, 36, .06), 0 8px 24px -16px rgba(26, 30, 36, .28);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ground: #11141A;
--surface: #181C23;
--surface-sunk: #1F242C;
--ink: #E7EAE5;
--ink-muted: #9BA4AF;
--ink-faint: #6F7885;
--rule: #2A313A;
--rule-strong: #3B444F;
--accent: #E8A33D;
--accent-ink: #F2BE6E;
--accent-soft: #33280F;
--ok: #6FBF95;
--ok-soft: #16301F;
--code-bg: #1D232B;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -16px rgba(0, 0, 0, .8);
}
}
:root[data-theme="dark"] {
--ground: #11141A;
--surface: #181C23;
--surface-sunk: #1F242C;
--ink: #E7EAE5;
--ink-muted: #9BA4AF;
--ink-faint: #6F7885;
--rule: #2A313A;
--rule-strong: #3B444F;
--accent: #E8A33D;
--accent-ink: #F2BE6E;
--accent-soft: #33280F;
--ok: #6FBF95;
--ok-soft: #16301F;
--code-bg: #1D232B;
--shadow: 0 1px 2px rgba(0, 0, 0, .4), 0 8px 24px -16px rgba(0, 0, 0, .8);
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ground);
color: var(--ink);
font-family: "IBM Plex Serif", Georgia, "Songti SC", serif;
font-size: 16px;
line-height: 1.68;
-webkit-font-smoothing: antialiased;
}
.shell {
max-width: 1180px;
margin: 0 auto;
padding: 0 28px 96px;
display: grid;
grid-template-columns: 1fr;
gap: 0 56px;
}
@media (min-width: 1040px) {
.shell { grid-template-columns: 220px minmax(0, 1fr); }
}
.masthead {
grid-column: 1 / -1;
padding: 56px 0 30px;
border-bottom: 2px solid var(--ink);
}
.eyebrow {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 11px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent-ink);
display: block;
margin-bottom: 14px;
}
h1 {
font-family: "IBM Plex Sans", "PingFang SC", system-ui, sans-serif;
font-size: clamp(30px, 4.4vw, 46px);
line-height: 1.1;
font-weight: 700;
letter-spacing: -.02em;
margin: 0 0 14px;
text-wrap: balance;
}
.standfirst {
font-size: 18px;
color: var(--ink-muted);
max-width: 62ch;
margin: 0 0 22px;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 8px 28px;
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--ink-faint);
}
.meta-row b { color: var(--ink-muted); font-weight: 500; }
.rail { display: none; padding-top: 44px; }
@media (min-width: 1040px) {
.rail {
display: block;
position: sticky;
top: 24px;
align-self: start;
max-height: calc(100vh - 48px);
overflow-y: auto;
}
}
.rail-title {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: 10px;
letter-spacing: .18em;
text-transform: uppercase;
color: var(--ink-faint);
padding-bottom: 10px;
border-bottom: 1px solid var(--rule);
margin-bottom: 12px;
}
.rail ol {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 7px;
}
.rail a {
font-family: "IBM Plex Sans", system-ui, sans-serif;
font-size: 13px;
color: var(--ink-muted);
text-decoration: none;
display: flex;
gap: 10px;
line-height: 1.35;
}
.rail a:hover { color: var(--accent-ink); }
.rail a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 2px; }
.rail .num {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
color: var(--ink-faint);
padding-top: 2px;
font-variant-numeric: tabular-nums;
}
main { padding-top: 40px; min-width: 0; }
section { margin-bottom: 58px; scroll-margin-top: 24px; }
h2 {
font-family: "IBM Plex Sans", "PingFang SC", system-ui, sans-serif;
font-size: 23px;
font-weight: 600;
letter-spacing: -.01em;
margin: 0 0 6px;
display: flex;
align-items: baseline;
gap: 12px;
text-wrap: balance;
}
h2 .sec-num {
font-family: "IBM Plex Mono", monospace;
font-size: 13px;
color: var(--accent);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
h2 + .lede { color: var(--ink-muted); margin: 0 0 22px; max-width: 68ch; }
h3 {
font-family: "IBM Plex Sans", system-ui, sans-serif;
font-size: 15px;
font-weight: 600;
letter-spacing: .01em;
margin: 30px 0 10px;
}
p { margin: 0 0 16px; max-width: 68ch; }
a { color: var(--accent-ink); }
strong { font-weight: 600; }
code {
font-family: "IBM Plex Mono", ui-monospace, monospace;
font-size: .875em;
background: var(--code-bg);
padding: 1px 5px;
border-radius: 3px;
word-break: break-word;
}
ul, ol { max-width: 68ch; padding-left: 22px; margin: 0 0 16px; }
li { margin-bottom: 7px; }
li::marker { color: var(--ink-faint); }
pre {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 6px;
padding: 16px 18px;
overflow-x: auto;
margin: 0 0 18px;
font-size: 13px;
line-height: 1.62;
}
pre code { background: none; padding: 0; font-size: inherit; word-break: normal; }
.mermaid-wrap {
background: var(--surface);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 22px 18px;
overflow-x: auto;
margin: 0 0 20px;
}
pre.mermaid {
background: none;
border: 0;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--rule);
border-radius: 8px;
background: var(--surface);
margin: 0 0 20px;
}
table {
border-collapse: collapse;
width: 100%;
font-family: "IBM Plex Sans", system-ui, sans-serif;
font-size: 13.5px;
font-variant-numeric: tabular-nums;
}
th, td {
text-align: left;
padding: 11px 16px;
border-bottom: 1px solid var(--rule);
vertical-align: top;
}
th {
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--ink-muted);
font-weight: 500;
background: var(--surface-sunk);
white-space: nowrap;
}
tbody tr:last-child td { border-bottom: 0; }
td code { font-size: 12.5px; }
.col-name {
font-family: "IBM Plex Mono", monospace;
font-size: 12.5px;
white-space: nowrap;
}
.callout {
border: 1px solid var(--rule);
border-left: 3px solid var(--accent);
background: var(--surface);
border-radius: 0 8px 8px 0;
padding: 16px 20px;
margin: 0 0 20px;
}
.callout .label {
font-family: "IBM Plex Mono", monospace;
font-size: 10px;
letter-spacing: .16em;
text-transform: uppercase;
color: var(--accent-ink);
display: block;
margin-bottom: 6px;
}
.callout p:last-child { margin-bottom: 0; }
.callout.ok { border-left-color: var(--ok); }
.callout.ok .label { color: var(--ok); }
.decisions {
display: grid;
gap: 1px;
background: var(--rule);
border: 1px solid var(--rule);
border-radius: 8px;
overflow: hidden;
margin: 0 0 20px;
}
@media (min-width: 720px) { .decisions { grid-template-columns: 1fr 1fr; } }
.decision { background: var(--surface); padding: 16px 18px; }
.decision dt {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
letter-spacing: .12em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 6px;
}
.decision dd {
margin: 0;
font-family: "IBM Plex Sans", system-ui, sans-serif;
font-size: 14.5px;
line-height: 1.5;
}
.decision dd b { color: var(--accent-ink); font-weight: 600; }
.phases {
display: flex;
flex-direction: column;
border-left: 2px solid var(--rule-strong);
margin: 0 0 20px 6px;
}
.phase { position: relative; padding: 0 0 22px 26px; }
.phase:last-child { padding-bottom: 0; }
.phase::before {
content: "";
position: absolute;
left: -7px;
top: 7px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--ground);
border: 2px solid var(--accent);
}
.phase h4 {
font-family: "IBM Plex Sans", system-ui, sans-serif;
font-size: 15px;
font-weight: 600;
margin: 0 0 4px;
}
.phase p { margin: 0; font-size: 14.5px; color: var(--ink-muted); max-width: 62ch; }
.phase .gate {
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
color: var(--ok);
display: block;
margin-top: 5px;
}
footer {
grid-column: 1 / -1;
border-top: 1px solid var(--rule);
padding-top: 20px;
margin-top: 10px;
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
color: var(--ink-faint);
display: flex;
flex-wrap: wrap;
gap: 6px 24px;
}
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
</style>
<div class="shell">
<header class="masthead">
<span class="eyebrow">ezijing-node-server · 设计方案</span>
<h1>AI 网关设计</h1>
<p class="standfirst">
面向多应用接入的中转站:火山 + DeepSeek 双上游,覆盖文本、图片、视频三种生成模型类型。
数据面走 API key,管理面(应用配置、调用日志、用量报表)落在 center-dms。
</p>
<div class="meta-row">
<span><b>版本</b> v6 草案</span>
<span><b>日期</b> 2026-09-08</span>
<span><b>状态</b> 待评审</span>
<span><b>接入方</b> 多应用 · 每应用多 key · 管理面在 center-dms</span>
</div>
</header>
<aside class="rail">
<div class="rail-title">目录</div>
<ol>
<li><a href="#s1"><span class="num">01</span><span>目标与边界</span></a></li>
<li><a href="#s2"><span class="num">02</span><span>关键决策</span></a></li>
<li><a href="#s3"><span class="num">03</span><span>三种调用形态</span></a></li>
<li><a href="#s3b"><span class="num">03b</span><span>业务系统接入</span></a></li>
<li><a href="#s4"><span class="num">04</span><span>目录落位</span></a></li>
<li><a href="#s5"><span class="num">05</span><span>数据模型</span></a></li>
<li><a href="#s6"><span class="num">06</span><span>上游适配</span></a></li>
<li><a href="#s7"><span class="num">07</span><span>用量提取</span></a></li>
<li><a href="#s8"><span class="num">08</span><span>配额与账本</span></a></li>
<li><a href="#s9"><span class="num">09</span><span>报表接口</span></a></li>
<li><a href="#s10"><span class="num">10</span><span>分阶段实施</span></a></li>
<li><a href="#s11"><span class="num">11</span><span>测试策略</span></a></li>
<li><a href="#s12"><span class="num">12</span><span>待定问题</span></a></li>
</ol>
</aside>
<main>
<section id="s1">
<h2><span class="sec-num">01</span>目标与边界</h2>
<p class="lede">对外暴露 OpenAI 兼容的接口,对内路由到火山或 DeepSeek,把每一次调用归因到「哪个应用、哪个 key」,并记录用量与成本。</p>
<h3>要做</h3>
<ul>
<li><strong>多应用接入</strong>:每个应用一个或多个 API key,用量按应用和 key 查看,配额在 key 上配置。</li>
<li><strong>三种模型类型</strong>:文本(token 计费、流式)、图片(按张)、视频(按秒、异步任务)。</li>
<li><strong>OpenAI 兼容入口</strong>:文本走 <code>POST /api/ai/chat/completions</code>,接入方无需改 SDK。</li>
<li><strong>模型映射</strong>:客户端请求别名,网关翻译成上游真实模型(火山用 endpoint id)。</li>
<li><strong>用量统计</strong>:每次调用一行明细,含应用、key、用量、成本、耗时、状态。</li>
<li><strong>配额控制</strong>:key 级预算,超额返回 429。</li>
<li><strong>管理面</strong>:应用 / key / 模型映射 / 价格的配置接口,以及调用日志与报表查询——全部由 center-dms 调用与展示。</li>
</ul>
<h3>暂不做</h3>
<ul>
<li>按用户配额(账本结构已预留,见 §08)。</li>
<li>接入方自带上游密钥(统一用网关的密钥,便于归因与结算)。</li>
<li>多租户账单、充值、发票。</li>
<li>Prompt / 生成内容留存(只存计量元数据)。</li>
<li>Agent 编排与工具调用——网关只负责转发与计量。</li>
</ul>
</section>
<section id="s2">
<h2><span class="sec-num">02</span>关键决策</h2>
<p class="lede">以下七条是后续所有设计的约束。</p>
<dl class="decisions">
<div class="decision">
<dt>上游</dt>
<dd><b>火山方舟 + DeepSeek</b>,文本接口均为 OpenAI 兼容,可共用一个适配器。</dd>
</div>
<div class="decision">
<dt>模型类型范围</dt>
<dd><b>文本 / 图片 / 视频</b>三种;图片按张、视频按秒。</dd>
</div>
<div class="decision">
<dt>接入方</dt>
<dd><b>多应用,每应用多 key</b>(例如同一产品下每所学校一个 key);用量按应用 + key + 用户三维归因。</dd>
</div>
<div class="decision">
<dt>配额粒度</dt>
<dd><b>key 级</b>;<b>用户级配额由业务系统自己控制</b>(网关只记录用量)。</dd>
</div>
<div class="decision">
<dt>传输方式</dt>
<dd>文本<b>必须支持流式</b>(前端默认 <code>stream: true</code>)。</dd>
</div>
<div class="decision">
<dt>管理面</dt>
<dd>应用/key/映射/价格的配置与日志、报表查看,<b>都在 center-dms</b>;网关只提供接口。</dd>
</div>
<div class="decision">
<dt>计量统一</dt>
<dd>三种模型类型按<b>成本(金额)</b>记账,避免 token / 张 / 秒 无法比较。</dd>
</div>
</dl>
<div class="callout">
<span class="label">贯穿约束</span>
<p>沿用仓库既有约定:接口字段 <code>snake_case</code>、校验用路由 <code>schema:{}</code>、共享资源用 <code>fastify-plugin</code> 装饰、跨目录引用 <code>#src/*</code>。网关是新增的一个域,不改动现有任何一层。</p>
</div>
</section>
<section id="s3">
<h2><span class="sec-num">03</span>三种调用形态</h2>
<p class="lede">文本同步流式、图片同步一次性、视频异步任务——三条路径的结算时机完全不同。</p>
<h3>文本:同步 + 流式</h3>
<div class="mermaid-wrap">
<pre class="mermaid">
flowchart LR
A["客户端<br/>stream: true"] --> B{"鉴权<br/>key → app"}
B -- 无效 --> E1["401"]
B --> C["模型映射"]
C --> D{"检查已用成本<br/>key 日/月软限额"}
D -- 超额 --> E2["429"]
D --> F["转发上游<br/>注入 include_usage"]
F --> G["SSE 透传<br/>旁路解析 usage"]
G --> H["计费<br/>真实用量"]
H --> I[("ai_usage")]
</pre>
</div>
<h3>图片:同步一次性</h3>
<p>响应直接带生成结果,没有流。按响应中的实际张数计费并落库。</p>
<h3>视频:异步任务</h3>
<div class="mermaid-wrap">
<pre class="mermaid">
flowchart LR
A["POST 提交任务"] --> B["检查已用成本<br/>不占额度"]
B --> C["上游返回 task_id"]
C --> D["落 pending"]
D --> E{"客户端查询状态"}
E -- 进行中 --> E
E -- 完成 --> F["结算<br/>真实时长 × 单价"]
F --> G["更新 success"]
E -- 失败 --> H["不计费<br/>更新 error"]
D -.-> I["惰性兜底<br/>该 key 下次请求时结算"]
</pre>
</div>
<div class="callout">
<span class="label">按需查询 + 惰性兜底</span>
<p>不跑后台定时器:结算发生在客户端查询任务状态时(主路径)。为防止客户端从此不再查询、账目长期悬挂,在该 key <strong>下一次发起任意请求</strong>时顺带结算其过期的 <code>pending</code> 任务——查询驱动,不引入常驻轮询。</p>
</div>
<h3>四个边界情况</h3>
<div class="table-wrap">
<table>
<thead><tr><th>情况</th><th>处理</th><th>落库状态</th></tr></thead>
<tbody>
<tr><td>正常结束</td><td>按真实用量累计成本</td><td><code>success</code></td></tr>
<tr><td>客户端中途断开(文本)</td><td>标记缺失用量待核对,不估算费用</td><td><code>aborted</code></td></tr>
<tr><td>上游返回错误</td><td>不扣额度,只记录</td><td><code>error</code></td></tr>
<tr><td>视频任务失败</td><td>不计费</td><td><code>error</code></td></tr>
</tbody>
</table>
</div>
</section>
<section id="s3b">
<h2><span class="sec-num">03b</span>业务系统接入</h2>
<p class="lede">调用方(业务系统)用自己应用的 key 调网关,并在请求头里带上「谁在用」。</p>
<pre><code>POST /api/ai/v1/chat/completions
Authorization: Bearer sk-ezj-xxxxxxxx # 应用/学校的 key(放服务端环境变量)
X-User-Id: 6602032005293015040 # 调用用户(业务系统从自己的登录态取)
X-User-Name: %E7%8E%8B%E9%B9%8F%E9%A3%9E # 可选,percent-encode,用于展示
{ "model": "deepseek-chat", "messages": [...], "stream": true }</code></pre>
<div class="callout">
<span class="label">用户级配额在业务系统</span>
<p>网关不维护使用人名单,也不做用户级限额——每个业务系统的人员模型不同。业务系统在调用前先查自己的额度,用完就自己拒绝;网关只负责把用量准确记到 <code>(应用, key, 用户)</code> 上。</p>
</div>
<h3>业务系统查询用量</h3>
<p>用同一个 key 调网关的用量接口,<strong>结果自动限定在本应用内</strong>,业务系统拿回去自己拼装页面:</p>
<pre><code>GET /api/ai/v1/usage/summary?group_by=user|model|type|day&amp;from=&amp;to=
GET /api/ai/v1/usage?user_id=&amp;model=&amp;page=&amp;limit=
</code></pre>
<div class="callout">
<span class="label">key 不能进浏览器</span>
<p>前端能拿到的 key 等于公开的。若 AI 功能在前端直接发起,应由业务系统后端转发(key 留在服务端,用户标识从 session 取,归因才可信),或由后端签发短期令牌后再直连。</p>
</div>
</section>
<section id="s4">
<h2><span class="sec-num">04</span>目录落位</h2>
<p class="lede">完全套用现有结构,新域与 dms / logs 同级。数据面与管理面用子目录分开,各自挂 autohooks,互不影响。</p>
<pre><code>src/
├── routes/api/ai/
│ ├── v1/ # 数据面(OpenAI 兼容,API key 鉴权)
│ │ ├── autohooks.js # 校验 key → 解析出 app + key
│ │ ├── chat.js # POST /chat/completions
│ │ ├── images.js # POST /images/generations
│ │ ├── videos.js # POST /videos + GET /videos/:task_id
│ │ └── models.js # GET /models
│ └── admin/ # 管理面(DMS 登录态鉴权,center-dms 调用)
│ ├── autohooks.js # authenticate + requireRouteAccess
│ ├── apps.js # 应用增删改查
│ ├── keys.js # key 增删改查(创建时返回明文一次)
│ ├── mappings.js # 模型映射维护
│ ├── prices.js # 价格维护
│ └── usage.js # 调用日志 + 用量报表
├── services/ai/
│ ├── gateway.service.js # 选上游、注入参数、流式透传
│ ├── quota.service.js # 软限额检查 / 真实成本累计
│ ├── usage.service.js # 落库 + 报表聚合
│ ├── pricing.service.js # 价格表 + 成本计算
│ ├── usage-parser.js # 从 SSE 提取 usage(纯函数)
│ └── providers/ # 供应商适配器
├── schemas/ai/{chat,image,video,admin,usage}.js
└── db/schema/ai/
├── apps.js
├── api-keys.js
├── model-mappings.js
├── model-prices.js
├── quota-usage.js
└── ai-usage.js</code></code></pre>
</section>
<section id="s5">
<h2><span class="sec-num">05</span>数据模型</h2>
<p class="lede">六张表:四张配置、两张计量。全部 MySQL + Drizzle,复用 <code>columns.js</code> 的时间戳。</p>
<h3>apps — 接入应用</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">id</td><td>bigint PK</td><td></td></tr>
<tr><td class="col-name">code</td><td>varchar(64) unique</td><td>应用标识,如 <code>center-dms</code></td></tr>
<tr><td class="col-name">name</td><td>varchar(120)</td><td>展示名</td></tr>
<tr><td class="col-name">billing_mode</td><td>varchar(16)</td><td><code>internal</code> 仅记录用量;<code>quota</code> 使用应用额度</td></tr>
<tr><td class="col-name">status</td><td>tinyint</td><td>1 启用 / 0 停用(停用后该应用所有 key 失效)</td></tr>
<tr><td class="col-name">operator_user_id / operator_name</td><td>varchar</td><td>最后操作人(与业务表 <code>operator_*</code> 命名一致)</td></tr>
</tbody>
</table>
</div>
<h3>api_keys — 密钥</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">id</td><td>bigint PK</td><td></td></tr>
<tr><td class="col-name">app_id</td><td>bigint</td><td>归属应用</td></tr>
<tr><td class="col-name">name</td><td>varchar(120)</td><td>备注名</td></tr>
<tr><td class="col-name">key_hash</td><td>char(64) unique</td><td>sha256(明文);明文只在创建时返回一次</td></tr>
<tr><td class="col-name">key_prefix</td><td>char(8)</td><td>前 8 位,列表展示与排查用</td></tr>
<tr><td class="col-name">status</td><td>tinyint</td><td>1 启用 / 0 停用</td></tr>
<tr><td class="col-name">operator_user_id / operator_name</td><td>varchar</td><td>最后操作人(与业务表 <code>operator_*</code> 命名一致)</td></tr>
</tbody>
</table>
</div>
<h3>model_mappings — 别名到上游模型</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">alias</td><td>varchar(80) PK</td><td>客户端请求的 model 名</td></tr>
<tr><td class="col-name">type</td><td>varchar(16)</td><td><code>text</code> / <code>image</code> / <code>video</code></td></tr>
<tr><td class="col-name">provider</td><td>varchar(32)</td><td><code>volcano</code> / <code>deepseek</code></td></tr>
<tr><td class="col-name">upstream_model</td><td>varchar(120)</td><td>火山为 endpoint id(<code>ep-...</code>)</td></tr>
<tr><td class="col-name">enabled</td><td>tinyint</td><td>下线别名置 0,保留历史归因</td></tr>
<tr><td class="col-name">operator_user_id / operator_name</td><td>varchar</td><td>最后操作人(与业务表 <code>operator_*</code> 命名一致)</td></tr>
</tbody>
</table>
</div>
<h3>model_prices — 单价(按模型类型区分计价单位)</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">provider + upstream_model</td><td>联合 PK</td><td></td></tr>
<tr><td class="col-name">pricing_unit</td><td>varchar(24)</td><td><code>per_1m_tokens</code> / <code>per_image</code> / <code>per_second</code></td></tr>
<tr><td class="col-name">input_price</td><td>decimal(12,6)</td><td>文本按输入 token</td></tr>
<tr><td class="col-name">output_price</td><td>decimal(12,6)</td><td>文本按输出 token</td></tr>
<tr><td class="col-name">unit_price</td><td>decimal(12,6)</td><td>图片每张 / 视频每秒</td></tr>
<tr><td class="col-name">currency</td><td>char(3)</td><td>暂定 <code>CNY</code></td></tr>
<tr><td class="col-name">effective_from</td><td>datetime</td><td>调价不改历史</td></tr>
<tr><td class="col-name">operator_user_id / operator_name</td><td>varchar</td><td>最后操作人(与业务表 <code>operator_*</code> 命名一致)</td></tr>
</tbody>
</table>
</div>
<h3>quota_usage — 账本(统一按成本计)</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">scope_type</td><td>varchar(16)</td><td><code>api_key</code></td></tr>
<tr><td class="col-name">scope_id</td><td>varchar(64)</td><td>api_key_id</td></tr>
<tr><td class="col-name">unit</td><td>varchar(8)</td><td><code>DAY</code> / <code>MONTH</code></td></tr>
<tr><td class="col-name">period</td><td>varchar(16)</td><td><code>2026-09-08</code> / <code>2026-09</code></td></tr>
<tr><td class="col-name">used_micro</td><td>bigint</td><td>已占用金额,单位 <b>1e-6 元</b>(整数避免浮点漂移)</td></tr>
</tbody>
</table>
</div>
<p>前四列联合主键。当前账本只记录 API key 的日/月配额占用。</p>
<h3>ai_usage — 调用明细(用量统计的核心)</h3>
<div class="table-wrap">
<table>
<thead><tr><th>列</th><th>类型</th><th>说明</th></tr></thead>
<tbody>
<tr><td class="col-name">request_id</td><td>varchar(64)</td><td>关联 <code>x-request-id</code>,直接对上服务端日志</td></tr>
<tr><td class="col-name">app_id / api_key_id / user_id</td><td>bigint / varchar</td><td>三级归因,前两级必有</td></tr>
<tr><td class="col-name">type</td><td>varchar(16)</td><td><code>text</code> / <code>image</code> / <code>video</code></td></tr>
<tr><td class="col-name">provider / model / upstream_model</td><td>varchar</td><td>model 是客户端别名</td></tr>
<tr><td class="col-name">unit</td><td>varchar(16)</td><td><code>token</code> / <code>image</code> / <code>second</code></td></tr>
<tr><td class="col-name">quantity</td><td>bigint</td><td>计费数量:token 数 / 张数 / 秒数</td></tr>
<tr><td class="col-name">prompt_tokens / completion_tokens</td><td>bigint</td><td>仅文本有值</td></tr>
<tr><td class="col-name">usage_source</td><td>varchar(16)</td><td><code>reported</code> / <code>estimated</code></td></tr>
<tr><td class="col-name">cost</td><td>decimal(12,6)</td><td>按调用当时价格计算</td></tr>
<tr><td class="col-name">status</td><td>varchar(16)</td><td><code>pending</code> / <code>success</code> / <code>error</code> / <code>aborted</code></td></tr>
<tr><td class="col-name">upstream_task_id</td><td>varchar(128)</td><td>视频异步任务才有</td></tr>
<tr><td class="col-name">http_status / error_code</td><td>int / varchar</td><td></td></tr>
<tr><td class="col-name">latency_ms / first_token_ms</td><td>int</td><td>首 token 延迟仅流式文本有</td></tr>
<tr><td class="col-name">stream</td><td>tinyint</td><td></td></tr>
</tbody>
</table>
</div>
<p>索引:<code>(app_id, created_at)</code>、<code>(api_key_id, created_at)</code>、<code>(provider, model, created_at)</code>、<code>(type, created_at)</code>、<code>(status)</code>(惰性兜底扫 <code>pending</code>)。</p>
</section>
<section id="s6">
<h2><span class="sec-num">06</span>上游适配</h2>
<p class="lede">文本接口两家都是 OpenAI 兼容;图片和视频目前只有火山方舟提供。</p>
<div class="table-wrap">
<table>
<thead><tr><th>模型类型</th><th>上游</th><th>端点</th><th>计量单位</th></tr></thead>
<tbody>
<tr><td>文本</td><td>火山 / DeepSeek</td><td class="col-name">/chat/completions</td><td>token</td></tr>
<tr><td>图片</td><td>火山</td><td class="col-name">/images/generations</td><td>张</td></tr>
<tr><td>视频</td><td>火山</td><td class="col-name">/contents/generations/tasks</td><td>秒(异步任务)</td></tr>
</tbody>
</table>
</div>
<div class="table-wrap">
<table>
<thead><tr><th></th><th>火山方舟</th><th>DeepSeek</th></tr></thead>
<tbody>
<tr><td>Base URL</td><td class="col-name">https://ark.cn-beijing.volces.com/api/v3</td><td class="col-name">https://api.deepseek.com/v1</td></tr>
<tr><td>鉴权</td><td><code>Authorization: Bearer &lt;ark key&gt;</code></td><td><code>Authorization: Bearer &lt;ds key&gt;</code></td></tr>
<tr><td>模型名</td><td>endpoint id,如 <code>ep-20250901xxxx</code></td><td><code>deepseek-chat</code> / <code>deepseek-reasoner</code></td></tr>
<tr><td>include_usage</td><td>支持</td><td>支持</td></tr>
</tbody>
</table>
</div>
<div class="callout ok">
<span class="label">已确认</span>
<p>两家都支持 <code>stream_options.include_usage</code>,所以流式文本的用量可以统一走 <code>reported</code>;上游没返回 usage 时不估算费用,明细标成 <code>missing</code> 待核对。</p>
</div>
<div class="callout">
<span class="label">密钥存放</span>
<p>上游密钥走环境变量(<code>VOLCANO_API_KEY</code> / <code>DEEPSEEK_API_KEY</code>),不进数据库。上游清单放 <code>services/ai/providers.js</code>,一个 provider 一条配置,按模型类型声明可用端点。<strong>接入方不需要也不允许自带上游 key</strong>,统一用网关密钥——归因和结算才说得清。</p>
</div>
<p>现有 <code>logs.service.js</code> 已经在跟踪这三个端点,说明调用量真实存在——网关上线后这批流量可以直接迁过来。</p>
</section>
<section id="s7">
<h2><span class="sec-num">07</span>用量提取</h2>
<p class="lede">文本要从 SSE 分片里捞 usage;图片和视频的用量在响应体里,直接读。</p>
<div class="table-wrap">
<table>
<thead><tr><th>模型类型</th><th>用量来源</th><th>拿不到时</th></tr></thead>
<tbody>
<tr><td>文本(流式)</td><td>注入 <code>stream_options.include_usage</code>,最后一条 chunk(<code>choices: []</code>)带 usage</td><td>不估算,标 <code>missing</code> 待核对</td></tr>
<tr><td>文本(非流式)</td><td>响应体 <code>usage</code> 字段</td><td>同上</td></tr>
<tr><td>图片</td><td>响应里的实际张数(<code>usage.generated_images</code> 或结果数组长度)</td><td>标 <code>missing</code> 待核对</td></tr>
<tr><td>视频</td><td>任务完成后返回的时长</td><td>标 <code>missing</code> 待核对</td></tr>
</tbody>
</table>
</div>
<pre><code>// services/ai/usage-parser.js —— 纯函数,输入分片字符串,输出累计用量
export const createUsageParser = (provider) =&gt; {
let usage = null
return {
feed(chunk) { /* 解析 SSE data 行,捕获最后一条 usage */ },
result() { return usage }, // { prompt_tokens, completion_tokens } | null
}
}</code></pre>
<h3>流式透传写法</h3>
<pre><code>const upstream = await fetch(url, { method, headers, body, signal })
reply.raw.writeHead(upstream.status, {
'content-type': upstream.headers.get('content-type'),
'x-request-id': request.id,
})
for await (const chunk of upstream.body) {
reply.raw.write(chunk) // 原样透传,不缓冲
parser.feed(chunk) // 旁路解析,不阻塞
}
reply.raw.end()</code></pre>
<p>关键点:<strong>用 <code>reply.raw</code> 直写</strong>,不走 Fastify 序列化——SSE 是流,任何缓冲都会破坏前端的实时性。</p>
</section>
<section id="s8">
<h2><span class="sec-num">08</span>配额与账本</h2>
<p class="lede">三种模型类型计量单位不同,统一按实际成本记账;只有额度结算应用才校验应用额度包。</p>
<div class="callout">
<span class="label">为什么不用 token 配额</span>
<p>一张图、一秒视频折算成多少 token 没有客观标准,因此额度包按金额管理。网关不管理合同,只记录应用可使用的总额度、有效期和实际已用金额。</p>
</div>
<h3>应用额度与真实计费</h3>
<p>内部应用只记录成本,不限制额度。额度结算应用在请求前确认存在生效且未用完的额度包;同一应用的生效日期范围不能重叠。</p>
<p>不预扣、不估算。文本按真实输入/输出 Token,图片按实际张数,视频完成后按真实时长累计到额度包;视频状态更新和计费在同一事务内,只执行一次。</p>
<p>缺失用量或客户端断开时不估算费用,记录 <code>usage_source=missing</code> 供核对,成本 0 表示尚未确认。保留提交时价格版本及视频提交账期。</p>
</section>
<section id="s9">
<h2><span class="sec-num">09</span>报表接口</h2>
<p class="lede">直接聚合明细表,不做预聚合——数据量到百万级再考虑。</p>
<pre><code>GET /api/ai/usage/summary
?group_by=app | api_key | model | provider | type | day
&amp;from=2026-09-01&amp;to=2026-09-30
&amp;app_id=3
→ { success: true, data: {
rows: [{
key: "center-dms", calls: 1284,
quantity: 4820193, unit: "token",
prompt_tokens: ..., completion_tokens: ..., cost: "12.340000"
}],
total: { calls: 1284, cost: "12.340000" }
} }</code></pre>
<p>管理面(center-dms 用,DMS 登录态鉴权):<code>GET /api/ai/admin/usage/summary</code>、<code>/usage/recent</code>、<code>/quota</code>。</p>
<p>数据面(业务系统用,API key 鉴权、自动限定本应用):<code>GET /api/ai/v1/usage/summary</code>、<code>/usage</code>。</p>
</section>
<section id="s10">
<h2><span class="sec-num">10</span>分阶段实施</h2>
<p class="lede">每阶段独立可验证,且都能用本地已有的 MySQL 跑真实数据。</p>
<div class="phases">
<div class="phase">
<h4>阶段一 · 骨架与文本</h4>
<p>六张表(建表 SQL)+ 应用与 key 管理;鉴权钩子;模型映射;非流式文本透传;<code>ai_usage</code> 落库。</p>
<span class="gate">验收:本地 MySQL 能查到一次真实调用的明细行(带 app 与 key)</span>
</div>
<div class="phase">
<h4>阶段二 · 流式与用量提取</h4>
<p>SSE 透传;<code>usage-parser</code> 纯函数 + 单测;首 token 延迟。</p>
<span class="gate">验收:mock 上游跑通流式,usage 正确落库</span>
</div>
<div class="phase">
<h4>阶段三 · 配额与账本</h4>
<p>成本口径的 key 级软限额 / 真实计费 / 429;断开与报错分支。</p>
<span class="gate">验收:并发测试验证真实成本累计和视频幂等性</span>
</div>
<div class="phase">
<h4>阶段四 · 图片</h4>
<p>图片端点、按实际张数计价。</p>
<span class="gate">验收:一次真实生成落库,张数与成本正确</span>
</div>
<div class="phase">
<h4>阶段五 · 视频(异步)</h4>
<p>任务提交、<code>pending</code> 落库、查询时结算、惰性兜底。</p>
<span class="gate">验收:客户端只提交不查询,账目也能在下一次请求时收敛</span>
</div>
<div class="phase">
<h4>阶段六 · 报表与限流</h4>
<p>summary / recent / models / quota 接口;接入 <code>@fastify/rate-limit</code>。</p>
<span class="gate">验收:报表数字与 ai_usage 明细能对上</span>
</div>
</div>
</section>
<section id="s11">
<h2><span class="sec-num">11</span>测试策略</h2>
<div class="table-wrap">
<table>
<thead><tr><th>对象</th><th>方式</th><th>为什么</th></tr></thead>
<tbody>
<tr><td><code>usage-parser</code></td><td>纯函数单测,喂各种 SSE 分片</td><td>最容易出 bug,且完全可离线验证</td></tr>
<tr><td><code>quota.service</code></td><td>打本地 MySQL 跑真实多 scope 事务</td><td>并发与原子性只有真库能验</td></tr>
<tr><td>网关透传</td><td>起 mock 上游,覆盖流式 / 报错 / 断开</td><td>不依赖真实上游配额与网络</td></tr>
<tr><td>视频按需结算</td><td>mock 任务状态流转,验证 pending → success / error 与惰性兜底</td><td>异步路径分支多</td></tr>
<tr><td>报表聚合</td><td>插入固定数据后断言聚合结果</td><td>数字必须可复现</td></tr>
</tbody>
</table>
</div>
<p>沿用现有 <code>test/unit</code> 与 <code>test/routes</code> 的划分,网关测试同样不需要 <code>.env</code>。</p>
</section>
<section id="s12">
<h2><span class="sec-num">12</span>待定问题</h2>
<ul>
<li><strong>图片/视频的真实计费口径</strong>——图片已确认按张;视频按秒还是按次、是否区分分辨率档位,需要以火山账单为准校准价格表。</li>
<li><strong>惰性兜底的有效期</strong>——超过 30 分钟的 <code>pending</code> 在下一次请求时查询上游,按实际终态处理,不自动判失败。</li>
<li><strong>应用与 key 的创建方式</strong>——后台页面、还是先用脚本初始化。</li>
</ul>
<div class="callout ok">
<span class="label">下一步</span>
<p>确认上面几条后即可开始阶段一:六张表建表 + 应用/key 初始化 + 文本非流式透传骨架。代码按 <code>routes/api/ai/</code> · <code>services/ai/</code> · <code>db/schema/ai/</code> 落位,不触碰现有模块。</p>
</div>
</section>
</main>
<footer>
<span>ezijing-node-server · AI 网关设计 v6</span>
<span>2026-09-08</span>
<span>火山方舟 / DeepSeek · 文本 / 图片 / 视频 · 多应用</span>
</footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mermaid/10.9.1/mermaid.min.js"></script>
<script>
(function () {
if (typeof mermaid === 'undefined') return;
var dark = document.documentElement.getAttribute('data-theme') === 'dark' ||
(!document.documentElement.hasAttribute('data-theme') &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
mermaid.initialize({
startOnLoad: true,
theme: dark ? 'dark' : 'neutral',
fontFamily: '"IBM Plex Sans", system-ui, sans-serif',
flowchart: { curve: 'basis', padding: 12 }
});
})();
</script>
-- DMS schema baseline (MySQL 8+)
-- Execute this SQL manually after reviewing the target database.
-- 已有旧版 DMS 表使用 fix_dms_schema.sql;新建库无需再执行 fix。
CREATE TABLE IF NOT EXISTS `product_list` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
......@@ -13,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `product_list` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_name` (`name`),
KEY `idx_products_status` (`status`),
KEY `idx_products_updated_at` (`updated_at`),
KEY `idx_products_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品信息';
......@@ -25,11 +27,11 @@ CREATE TABLE IF NOT EXISTS `project_list` (
`school_name` varchar(255),
`department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255),
`contact_name` varchar(120),
`contact_title` varchar(120),
`contact_phone` varchar(64),
`solution` text,
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`stage` int NOT NULL DEFAULT 10,
`status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 20已归档',
`description` text,
......@@ -40,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `project_list` (
PRIMARY KEY (`id`),
UNIQUE KEY `uk_projects_project_code` (`project_code`),
KEY `idx_projects_stage_status` (`stage`, `status`),
KEY `idx_projects_created_at` (`created_at`),
KEY `idx_projects_product_id` (`product_id`),
KEY `idx_projects_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目主表';
......@@ -49,7 +52,6 @@ CREATE TABLE IF NOT EXISTS `case_list` (
`name` varchar(255) NOT NULL COMMENT '案例名称',
`description` text COMMENT '案例简介',
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255) COMMENT '产品名称',
`files` longtext COMMENT '案例附件JSON',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
......@@ -57,14 +59,14 @@ CREATE TABLE IF NOT EXISTS `case_list` (
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_cases_product_id` (`product_id`),
KEY `idx_cases_updated_at` (`updated_at`),
KEY `idx_cases_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='案例信息';
CREATE TABLE IF NOT EXISTS `project_initiations` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`application_file_url` text COMMENT '附件JSON数组',
`argument_file_url` text COMMENT '附件JSON数组',
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
......@@ -86,8 +88,7 @@ CREATE TABLE IF NOT EXISTS `project_procurements` (
`main_bid_owner` varchar(120),
`companion_bidders` text,
`formal_bid_status` varchar(120),
`winning_notice_file_url` text COMMENT '附件JSON数组',
`bid_archive_file_url` text COMMENT '附件JSON数组',
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
......@@ -102,7 +103,7 @@ CREATE TABLE IF NOT EXISTS `project_contracts` (
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` text COMMENT '附件JSON数组',
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
......@@ -117,6 +118,7 @@ CREATE TABLE IF NOT EXISTS `project_deliveries` (
`delivery_contact` varchar(120),
`delivery_contact_phone` varchar(64),
`delivery_note` text,
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`completed_at` datetime,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
......@@ -128,8 +130,8 @@ CREATE TABLE IF NOT EXISTS `project_deliveries` (
CREATE TABLE IF NOT EXISTS `project_acceptances` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` text COMMENT '附件JSON数组',
`acceptance_note` text,
`attachment_file_url` text COMMENT '阶段附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
......
-- AI 网关建表(MySQL 8+)
-- 六张表:应用与密钥、模型映射与价格、应用额度、调用明细
-- 均为新表,无存量数据迁移。审阅后执行。
-- 接入应用
CREATE TABLE IF NOT EXISTS `ai_apps` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` varchar(64) NOT NULL COMMENT '应用标识,如 center-dms',
`name` varchar(120) NOT NULL COMMENT '应用名称',
`billing_mode` varchar(16) NOT NULL DEFAULT 'internal' COMMENT '结算模式: internal内部使用/quota额度控制',
`status` int NOT NULL DEFAULT 1 COMMENT '状态: 0停用 1启用',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ai_apps_code` (`code`),
KEY `idx_ai_apps_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-接入应用';
-- 应用持有的密钥
CREATE TABLE IF NOT EXISTS `ai_api_keys` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` bigint unsigned NOT NULL COMMENT '归属应用',
`name` varchar(120) NOT NULL COMMENT '备注名',
`key_hash` char(64) NOT NULL COMMENT 'sha256(明文),明文只在创建时返回一次',
`key_prefix` varchar(12) NOT NULL COMMENT '前 8 位,列表展示与排查用',
`status` int NOT NULL DEFAULT 1 COMMENT '状态: 0停用 1启用',
`last_used_at` datetime DEFAULT NULL COMMENT '最近一次调用时间',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ai_api_keys_hash` (`key_hash`),
KEY `idx_ai_api_keys_app` (`app_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-API 密钥';
-- 平台模型别名及上游映射
CREATE TABLE IF NOT EXISTS `ai_model_mappings` (
`alias` varchar(80) NOT NULL COMMENT '平台对外暴露的模型别名',
`name` varchar(120) NOT NULL COMMENT '模型展示名称',
`type` varchar(16) NOT NULL COMMENT '模型类型: text/image/video',
`provider` varchar(32) NOT NULL COMMENT '上游: volcano/deepseek',
`upstream_model` varchar(120) NOT NULL COMMENT '上游真实模型,火山为 endpoint id',
`enabled` int NOT NULL DEFAULT 1 COMMENT '是否启用',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`alias`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-模型映射';
-- 模型单价(按别名版本化:改价 = 关闭旧版本 + 开新版本)
CREATE TABLE IF NOT EXISTS `ai_model_prices` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`alias` varchar(80) NOT NULL COMMENT '模型别名',
`version` int NOT NULL DEFAULT 1 COMMENT '版本号,改价=新版本',
`pricing_unit` varchar(24) NOT NULL COMMENT '计价单位: per_1m_tokens/per_image/per_second',
`input_price` decimal(12,6) NOT NULL DEFAULT 0 COMMENT '输入 token 单价',
`cached_input_price` decimal(12,6) NOT NULL DEFAULT 0 COMMENT '缓存命中输入单价(0=按 input_price 计)',
`output_price` decimal(12,6) NOT NULL DEFAULT 0 COMMENT '输出 token 单价',
`unit_price` decimal(12,6) NOT NULL DEFAULT 0 COMMENT '每张/每秒单价',
`currency` varchar(3) NOT NULL DEFAULT 'CNY' COMMENT '币种',
`effective_from` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '生效时间',
`effective_to` datetime DEFAULT NULL COMMENT 'NULL=生效中',
`status` int NOT NULL DEFAULT 1 COMMENT '1生效 0停用',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_ai_model_prices_alias_version` (`alias`, `version`),
KEY `idx_ai_model_prices_alias` (`alias`, `status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-模型价格(按别名版本化)';
-- 应用额度包。合同在商务系统管理,网关只记录可调用额度。
CREATE TABLE IF NOT EXISTS `ai_quota_packages` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` bigint unsigned NOT NULL COMMENT '所属应用',
`name` varchar(120) NOT NULL COMMENT '额度名称',
`amount_micro` bigint unsigned NOT NULL COMMENT '总额度(微元)',
`used_micro` bigint unsigned NOT NULL DEFAULT 0 COMMENT '已使用额度(微元)',
`start_date` date NOT NULL COMMENT '生效日期',
`end_date` date NOT NULL COMMENT '失效日期',
`status` int NOT NULL DEFAULT 1 COMMENT '状态: 0停用 1启用',
`remark` text DEFAULT NULL COMMENT '备注',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_ai_quota_packages_app` (`app_id`, `status`, `start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-应用额度包';
-- 调用明细
CREATE TABLE IF NOT EXISTS `ai_usage` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`request_id` varchar(64) DEFAULT NULL COMMENT '关联 x-request-id',
`app_id` bigint unsigned DEFAULT NULL COMMENT '应用',
`api_key_id` bigint unsigned DEFAULT NULL COMMENT '密钥',
`quota_package_id` bigint unsigned DEFAULT NULL COMMENT '本次结算使用的额度包',
`user_id` varchar(64) DEFAULT NULL COMMENT '调用用户(来自请求头 X-User-Id)',
`user_name` varchar(120) DEFAULT NULL COMMENT '调用用户姓名(来自 X-User-Name)',
`type` varchar(16) NOT NULL COMMENT '模型类型: text/image/video',
`provider` varchar(32) NOT NULL COMMENT '上游',
`model` varchar(80) NOT NULL COMMENT '客户端请求的别名',
`upstream_model` varchar(120) NOT NULL COMMENT '上游真实模型',
`price_version` int DEFAULT NULL COMMENT '本次计费使用的价格版本',
`unit` varchar(16) NOT NULL COMMENT '计费单位: token/image/second',
`quantity` bigint unsigned NOT NULL DEFAULT 0 COMMENT '计费数量',
`prompt_tokens` bigint unsigned NOT NULL DEFAULT 0 COMMENT '输入 token(仅文本)',
`cached_tokens` bigint unsigned NOT NULL DEFAULT 0 COMMENT '缓存命中的输入 token',
`completion_tokens` bigint unsigned NOT NULL DEFAULT 0 COMMENT '输出 token(仅文本)',
`reasoning_tokens` bigint unsigned NOT NULL DEFAULT 0 COMMENT '思考 token(含在 completion 内)',
`usage_source` varchar(16) NOT NULL DEFAULT 'reported' COMMENT 'reported/missing,missing 表示成本待核对',
`cost` decimal(12,6) NOT NULL DEFAULT 0 COMMENT '成本',
`status` varchar(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/success/error/aborted',
`upstream_task_id` varchar(128) DEFAULT NULL COMMENT '上游异步任务 ID(视频)',
`http_status` int DEFAULT NULL COMMENT '上游 HTTP 状态',
`error_code` varchar(64) DEFAULT NULL COMMENT '错误码',
`latency_ms` int DEFAULT NULL COMMENT '总耗时',
`first_token_ms` int DEFAULT NULL COMMENT '首 token 延迟(流式)',
`stream` int NOT NULL DEFAULT 0 COMMENT '是否流式',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_ai_usage_app` (`app_id`, `created_at`),
KEY `idx_ai_usage_key` (`api_key_id`, `created_at`),
KEY `idx_ai_usage_quota_package` (`quota_package_id`, `created_at`),
KEY `idx_ai_usage_model` (`provider`, `model`, `created_at`),
KEY `idx_ai_usage_type` (`type`, `created_at`),
KEY `idx_ai_usage_status` (`status`),
KEY `idx_ai_usage_task` (`app_id`, `api_key_id`, `upstream_task_id`),
KEY `idx_ai_usage_pending` (`api_key_id`, `status`, `unit`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-调用明细';
......@@ -20,12 +20,12 @@ CREATE TABLE `project_list` (
`school_name` varchar(255),
`department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255),
`contact_name` varchar(120),
`contact_title` varchar(120),
`contact_phone` varchar(64),
`solution` text,
`attachment_file_url` text,
`stage` int NOT NULL DEFAULT 10,
`status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 20已归档',
`description` text,
......@@ -42,7 +42,6 @@ CREATE TABLE `case_list` (
`name` varchar(255) NOT NULL COMMENT '案例名称',
`description` text COMMENT '案例简介',
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255) COMMENT '产品名称',
`files` longtext COMMENT '案例附件JSON',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
......@@ -54,8 +53,7 @@ CREATE TABLE `case_list` (
CREATE TABLE `project_initiations` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`application_file_url` text,
`argument_file_url` text,
`attachment_file_url` text,
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
......@@ -76,8 +74,7 @@ CREATE TABLE `project_procurements` (
`main_bid_owner` varchar(120),
`companion_bidders` text,
`formal_bid_status` varchar(120),
`winning_notice_file_url` text,
`bid_archive_file_url` text,
`attachment_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_procurements_id` PRIMARY KEY(`id`),
......@@ -91,7 +88,7 @@ CREATE TABLE `project_contracts` (
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` text,
`attachment_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_contracts_id` PRIMARY KEY(`id`),
......@@ -105,6 +102,7 @@ CREATE TABLE `project_deliveries` (
`delivery_contact` varchar(120),
`delivery_contact_phone` varchar(64),
`delivery_note` text,
`attachment_file_url` text,
`completed_at` datetime,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
......@@ -115,8 +113,8 @@ CREATE TABLE `project_deliveries` (
CREATE TABLE `project_acceptances` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` text,
`acceptance_note` text,
`attachment_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_acceptances_id` PRIMARY KEY(`id`),
......@@ -175,16 +173,22 @@ ALTER TABLE `project_role_assignments` ADD CONSTRAINT `project_role_assignments_
--> statement-breakpoint
CREATE INDEX `idx_products_status` ON `product_list` (`status`);
--> statement-breakpoint
CREATE INDEX `idx_products_updated_at` ON `product_list` (`updated_at`);
--> statement-breakpoint
CREATE INDEX `idx_products_operator_user_id` ON `product_list` (`operator_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_projects_stage_status` ON `project_list` (`stage`,`status`);
--> statement-breakpoint
CREATE INDEX `idx_projects_created_at` ON `project_list` (`created_at`);
--> statement-breakpoint
CREATE INDEX `idx_projects_product_id` ON `project_list` (`product_id`);
--> statement-breakpoint
CREATE INDEX `idx_projects_operator_user_id` ON `project_list` (`operator_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_cases_product_id` ON `case_list` (`product_id`);
--> statement-breakpoint
CREATE INDEX `idx_cases_updated_at` ON `case_list` (`updated_at`);
--> statement-breakpoint
CREATE INDEX `idx_cases_operator_user_id` ON `case_list` (`operator_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_project_timelines_project_id` ON `project_timelines` (`project_id`);
......
-- 仅用于已经执行过旧版 002_init_ai_gateway_schema.sql 的开发库。
-- 新库直接执行最新版 002,不需要执行本文件。
ALTER TABLE `ai_model_mappings`
CHANGE COLUMN `capability` `type` varchar(16) NOT NULL COMMENT '模型类型: text/image/video',
ADD COLUMN `name` varchar(120) NULL COMMENT '模型展示名称' AFTER `alias`;
UPDATE `ai_model_mappings`
SET `name` = `alias`
WHERE `name` IS NULL OR `name` = '';
ALTER TABLE `ai_model_mappings`
MODIFY COLUMN `name` varchar(120) NOT NULL COMMENT '模型展示名称';
ALTER TABLE `ai_usage`
DROP INDEX `idx_ai_usage_capability`,
CHANGE COLUMN `capability` `type` varchar(16) NOT NULL COMMENT '模型类型: text/image/video',
ADD INDEX `idx_ai_usage_type` (`type`, `created_at`);
ALTER TABLE `ai_apps`
ADD COLUMN `billing_mode` varchar(16) NOT NULL DEFAULT 'internal' COMMENT '结算模式: internal内部使用/quota额度控制' AFTER `name`;
ALTER TABLE `ai_api_keys`
DROP COLUMN `quota_day`,
DROP COLUMN `quota_month`;
CREATE TABLE `ai_quota_packages` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`app_id` bigint unsigned NOT NULL COMMENT '所属应用',
`name` varchar(120) NOT NULL COMMENT '额度名称',
`amount_micro` bigint unsigned NOT NULL COMMENT '总额度(微元)',
`used_micro` bigint unsigned NOT NULL DEFAULT 0 COMMENT '已使用额度(微元)',
`start_date` date NOT NULL COMMENT '生效日期',
`end_date` date NOT NULL COMMENT '失效日期',
`status` int NOT NULL DEFAULT 1 COMMENT '状态: 0停用 1启用',
`remark` text DEFAULT NULL COMMENT '备注',
`operator_user_id` varchar(64) DEFAULT NULL COMMENT '最后操作人用户ID',
`operator_name` varchar(120) DEFAULT NULL COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_ai_quota_packages_app` (`app_id`, `status`, `start_date`, `end_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 网关-应用额度包';
ALTER TABLE `ai_usage`
ADD COLUMN `quota_package_id` bigint unsigned DEFAULT NULL COMMENT '本次结算使用的额度包' AFTER `api_key_id`,
ADD INDEX `idx_ai_usage_quota_package` (`quota_package_id`, `created_at`);
DROP TABLE `ai_quota_usage`;
-- 已有旧版 DMS 表的增量修复(MySQL 8+),仅执行一次。
-- 新建库执行最新版 001_init_dms_schema.sql 即可,无需执行本文件。
-- 替代原 004 / 005:若已执行过它们,不要再次执行本文件。
-- 删除 product_name 列及其数据;产品名称统一关联 product_list 获取。
ALTER TABLE `project_list`
DROP COLUMN `product_name`,
ADD INDEX `idx_projects_created_at` (`created_at`);
ALTER TABLE `case_list`
DROP COLUMN `product_name`,
ADD INDEX `idx_cases_updated_at` (`updated_at`);
ALTER TABLE `product_list`
ADD INDEX `idx_products_updated_at` (`updated_at`);
-- 已有 DMS 数据库增加各阶段附件字段(MySQL 8+),仅执行一次。
-- 新建数据库执行最新版 001_init_dms_schema.sql 即可,无需执行本文件。
ALTER TABLE `project_list`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `solution`;
ALTER TABLE `project_initiations`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `project_id`;
ALTER TABLE `project_procurements`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `formal_bid_status`;
ALTER TABLE `project_contracts`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `drafter`;
ALTER TABLE `project_deliveries`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `delivery_note`;
ALTER TABLE `project_acceptances`
ADD COLUMN `attachment_file_url` text COMMENT '阶段附件JSON数组' AFTER `acceptance_note`;
......@@ -14,6 +14,7 @@
"@fastify/cors": "^11.3.0",
"@fastify/formbody": "^9.0.0",
"@fastify/http-proxy": "^11.6.2",
"@fastify/rate-limit": "^11.2.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^6.1.1",
"ali-oss": "^6.23.0",
......@@ -1330,6 +1331,28 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.0.tgz",
"integrity": "sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^6.0.0",
"ip-address": "^10.2.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@fastify/reply-from": {
"version": "12.6.5",
"resolved": "https://registry.npmjs.org/@fastify/reply-from/-/reply-from-12.6.5.tgz",
......@@ -3327,6 +3350,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
......
......@@ -42,6 +42,7 @@
"@fastify/cors": "^11.3.0",
"@fastify/formbody": "^9.0.0",
"@fastify/http-proxy": "^11.6.2",
"@fastify/rate-limit": "^11.2.0",
"@fastify/swagger": "^9.8.1",
"@fastify/swagger-ui": "^6.1.1",
"ali-oss": "^6.23.0",
......
......@@ -139,13 +139,12 @@ async function main() {
for (const [index, item] of cases.entries()) {
const [result] = await connection.execute(
`INSERT INTO case_list
(name, description, product_id, product_name, files, operator_user_id, operator_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
(name, description, product_id, files, operator_user_id, operator_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
item.name,
item.description,
item.productId,
item.productName,
item.files,
item.operatorUserId,
item.operatorName,
......
......@@ -54,7 +54,9 @@ export default async function app(fastify, opts) {
await fastify.register(autoload, {
dir: path.join(__dirname, 'routes'),
autoHooks: true,
cascadeHooks: true,
// 不向下级联:routes/ai/autohooks.js 是数据面(API Key 鉴权),
// 级联会把它套到 routes/ai/admin/ 的管理面(TGC 鉴权)上。
cascadeHooks: false,
options: { ...opts },
})
}
import crypto from 'node:crypto'
import axios from 'axios'
import config from '#src/config.js'
import logger from '#src/lib/logger.js'
const MAX_BATCH_SIZE = 500
......
......@@ -38,6 +38,13 @@ const envSchema = z.object({
PERMISSION_APP_SECRET: z.string().default(''),
SSO_USER_CACHE_TTL_SECONDS: int(180),
PERMISSION_CACHE_TTL_SECONDS: int(180),
// AI 网关上游
VOLCANO_BASE_URL: z.string().default('https://ark.cn-beijing.volces.com/api/v3'),
VOLCANO_API_KEY: z.string().default(''),
DEEPSEEK_BASE_URL: z.string().default('https://api.deepseek.com/v1'),
DEEPSEEK_API_KEY: z.string().default(''),
AI_RATE_LIMIT_PER_MINUTE: int(120),
})
// 启动即校验环境变量,配置错误立刻失败,而不是运行到某个请求时才炸
......@@ -91,6 +98,14 @@ const config = {
userInfoCacheTtlSeconds: env.SSO_USER_CACHE_TTL_SECONDS,
permissionCacheTtlSeconds: env.PERMISSION_CACHE_TTL_SECONDS,
},
ai: {
rateLimitPerMinute: env.AI_RATE_LIMIT_PER_MINUTE,
// 上游密钥只走环境变量,不进数据库
providers: {
volcano: { baseUrl: env.VOLCANO_BASE_URL, apiKey: env.VOLCANO_API_KEY },
deepseek: { baseUrl: env.DEEPSEEK_BASE_URL, apiKey: env.DEEPSEEK_API_KEY },
},
},
wechat: {
apps: {
wxd6109d07f6396e5c: 'd80a330735fc82f3fd6aba425481e8fd',
......
import { bigint, decimal, index, int, mysqlTable, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const USAGE_STATUSES = {
PENDING: 'pending',
SUCCESS: 'success',
ERROR: 'error',
ABORTED: 'aborted',
}
export const USAGE_SOURCES = {
REPORTED: 'reported',
MISSING: 'missing',
}
/** 「上游成功但没返回用量」——此时用量来源为 missing,成本尚未确认,用它标记出来便于对账 */
export const USAGE_ERROR_CODES = {
USAGE_MISSING: 'usage_missing',
}
// 计费单位:与 ai_usage.unit 一一对应
export const USAGE_UNITS = {
TOKEN: 'token',
IMAGE: 'image',
SECOND: 'second',
}
// 调用明细:用量统计的核心
export const aiUsage = mysqlTable(
'ai_usage',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
request_id: varchar('request_id', { length: 64 }),
app_id: bigint('app_id', { mode: 'number', unsigned: true }),
api_key_id: bigint('api_key_id', { mode: 'number', unsigned: true }),
quota_package_id: bigint('quota_package_id', { mode: 'number', unsigned: true }),
user_id: varchar('user_id', { length: 64 }),
user_name: varchar('user_name', { length: 120 }),
type: varchar('type', { length: 16 }).notNull(),
provider: varchar('provider', { length: 32 }).notNull(),
model: varchar('model', { length: 80 }).notNull(),
upstream_model: varchar('upstream_model', { length: 120 }).notNull(),
// 计价用的价格版本(对账/重算用)
price_version: int('price_version'),
unit: varchar('unit', { length: 16 }).notNull(),
quantity: bigint('quantity', { mode: 'number', unsigned: true }).notNull().default(0),
prompt_tokens: bigint('prompt_tokens', { mode: 'number', unsigned: true }).notNull().default(0),
// 缓存命中的输入 token(单价更低,影响成本计算)
cached_tokens: bigint('cached_tokens', { mode: 'number', unsigned: true }).notNull().default(0),
completion_tokens: bigint('completion_tokens', { mode: 'number', unsigned: true }).notNull().default(0),
// 思考 token(已包含在 completion_tokens 内)
reasoning_tokens: bigint('reasoning_tokens', { mode: 'number', unsigned: true }).notNull().default(0),
usage_source: varchar('usage_source', { length: 16 }).notNull().default(USAGE_SOURCES.REPORTED),
cost: decimal('cost', { precision: 12, scale: 6 }).notNull().default('0'),
status: varchar('status', { length: 16 }).notNull().default(USAGE_STATUSES.PENDING),
upstream_task_id: varchar('upstream_task_id', { length: 128 }),
http_status: int('http_status'),
error_code: varchar('error_code', { length: 64 }),
latency_ms: int('latency_ms'),
first_token_ms: int('first_token_ms'),
stream: int('stream').notNull().default(0),
...timestamps,
},
(table) => ({
appIdx: index('idx_ai_usage_app').on(table.app_id, table.created_at),
keyIdx: index('idx_ai_usage_key').on(table.api_key_id, table.created_at),
quotaPackageIdx: index('idx_ai_usage_quota_package').on(table.quota_package_id, table.created_at),
modelIdx: index('idx_ai_usage_model').on(table.provider, table.model, table.created_at),
typeIdx: index('idx_ai_usage_type').on(table.type, table.created_at),
statusIdx: index('idx_ai_usage_status').on(table.status),
taskIdx: index('idx_ai_usage_task').on(table.app_id, table.api_key_id, table.upstream_task_id),
// 惰性兜底:扫描某 key 过期的 pending 视频任务
pendingIdx: index('idx_ai_usage_pending').on(table.api_key_id, table.status, table.unit, table.created_at),
})
)
import { bigint, char, datetime, index, int, mysqlTable, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const KEY_STATUSES = {
DISABLED: 0,
ENABLED: 1,
}
// 应用持有的密钥:只负责鉴权,用量控制属于应用额度
export const aiApiKeys = mysqlTable(
'ai_api_keys',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
app_id: bigint('app_id', { mode: 'number', unsigned: true }).notNull(),
name: varchar('name', { length: 120 }).notNull(),
key_hash: char('key_hash', { length: 64 }).notNull(),
key_prefix: varchar('key_prefix', { length: 12 }).notNull(),
status: int('status').notNull().default(KEY_STATUSES.ENABLED),
last_used_at: datetime('last_used_at', { mode: 'string' }),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
hashIdx: uniqueIndex('uk_ai_api_keys_hash').on(table.key_hash),
appIdx: index('idx_ai_api_keys_app').on(table.app_id),
})
)
import { bigint, index, int, mysqlTable, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const APP_STATUSES = {
DISABLED: 0,
ENABLED: 1,
}
export const BILLING_MODES = {
INTERNAL: 'internal',
QUOTA: 'quota',
}
// 接入应用:密钥和用量的业务归属
export const aiApps = mysqlTable(
'ai_apps',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
code: varchar('code', { length: 64 }).notNull(),
name: varchar('name', { length: 120 }).notNull(),
billing_mode: varchar('billing_mode', { length: 16 }).notNull().default(BILLING_MODES.INTERNAL),
status: int('status').notNull().default(APP_STATUSES.ENABLED),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
codeIdx: uniqueIndex('uk_ai_apps_code').on(table.code),
statusIdx: index('idx_ai_apps_status').on(table.status),
})
)
import { index, int, mysqlTable, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const AI_MODEL_TYPES = {
TEXT: 'text',
IMAGE: 'image',
VIDEO: 'video',
}
// 平台对外暴露的模型别名(上游路由见 ai_model_routes)
export const aiModelMappings = mysqlTable(
'ai_model_mappings',
{
alias: varchar('alias', { length: 80 }).primaryKey(),
name: varchar('name', { length: 120 }).notNull(),
type: varchar('type', { length: 16 }).notNull(),
provider: varchar('provider', { length: 32 }).notNull(),
upstream_model: varchar('upstream_model', { length: 120 }).notNull(),
enabled: int('enabled').notNull().default(1),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
providerIdx: index('idx_ai_model_mappings_provider').on(table.provider),
})
)
import { sql } from 'drizzle-orm'
import { datetime, decimal, index, int, mysqlTable, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const PRICING_UNITS = {
PER_1M_TOKENS: 'per_1m_tokens',
PER_IMAGE: 'per_image',
PER_SECOND: 'per_second',
}
export const PRICE_STATUSES = {
DISABLED: 0,
ACTIVE: 1,
}
// 单价表:每个别名一个价(一个版本一行);改价 = 关闭旧版本 + 开新版本
// 暂不做分辨率/清晰度档位(2026-09-10 决定:先固定一个价格)
export const aiModelPrices = mysqlTable(
'ai_model_prices',
{
// 定价挂在别名上(对客卖的是别名;上游成本在路由层)
alias: varchar('alias', { length: 80 }).notNull(),
// 版本号从 1 递增;改价 = 新增版本 + 关闭旧版本
version: int('version').notNull().default(1),
pricing_unit: varchar('pricing_unit', { length: 24 }).notNull(),
input_price: decimal('input_price', { precision: 12, scale: 6 }).notNull().default('0'),
// 缓存命中单价;为 0 时回退到 input_price
cached_input_price: decimal('cached_input_price', { precision: 12, scale: 6 }).notNull().default('0'),
output_price: decimal('output_price', { precision: 12, scale: 6 }).notNull().default('0'),
unit_price: decimal('unit_price', { precision: 12, scale: 6 }).notNull().default('0'),
currency: varchar('currency', { length: 3 }).notNull().default('CNY'),
effective_from: datetime('effective_from', { mode: 'string' })
.notNull()
.default(sql`CURRENT_TIMESTAMP`),
// NULL 表示仍然生效
effective_to: datetime('effective_to', { mode: 'string' }),
status: int('status').notNull().default(1),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
versionIdx: uniqueIndex('uk_ai_model_prices_alias_version').on(table.alias, table.version),
activeIdx: index('idx_ai_model_prices_alias').on(table.alias, table.status),
})
)
import { bigint, date, index, int, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../columns.js'
export const QUOTA_PACKAGE_STATUSES = {
DISABLED: 0,
ENABLED: 1,
}
export const aiQuotaPackages = mysqlTable(
'ai_quota_packages',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
app_id: bigint('app_id', { mode: 'number', unsigned: true }).notNull(),
name: varchar('name', { length: 120 }).notNull(),
amount_micro: bigint('amount_micro', { mode: 'number', unsigned: true }).notNull(),
used_micro: bigint('used_micro', { mode: 'number', unsigned: true }).notNull().default(0),
start_date: date('start_date', { mode: 'string' }).notNull(),
end_date: date('end_date', { mode: 'string' }).notNull(),
status: int('status').notNull().default(QUOTA_PACKAGE_STATUSES.ENABLED),
remark: text('remark'),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
appIdx: index('idx_ai_quota_packages_app').on(table.app_id, table.status, table.start_date, table.end_date),
}),
)
......@@ -8,7 +8,6 @@ export const cases = mysqlTable(
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
product_id: bigint('product_id', { mode: 'number', unsigned: true }),
product_name: varchar('product_name', { length: 255 }),
files: longtext('files'),
operator_user_id: varchar('operator_user_id', { length: 64 }),
operator_name: varchar('operator_name', { length: 120 }),
......@@ -16,6 +15,8 @@ export const cases = mysqlTable(
},
(table) => ({
productIdIdx: index('idx_cases_product_id').on(table.product_id),
// 列表按 updated_at 倒序分页
updatedAtIdx: index('idx_cases_updated_at').on(table.updated_at),
operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operator_user_id),
})
)
......@@ -20,6 +20,8 @@ export const products = mysqlTable(
(table) => ({
nameIdx: uniqueIndex('uk_products_name').on(table.name),
statusIdx: index('idx_products_status').on(table.status),
// 列表按 updated_at 倒序分页
updatedAtIdx: index('idx_products_updated_at').on(table.updated_at),
operatorUserIdIdx: index('idx_products_operator_user_id').on(table.operator_user_id),
})
)
......@@ -37,11 +37,11 @@ export const projects = mysqlTable(
school_name: varchar('school_name', { length: 255 }),
department_name: varchar('department_name', { length: 255 }),
product_id: bigint('product_id', { mode: 'number', unsigned: true }),
product_name: varchar('product_name', { length: 255 }),
contact_name: varchar('contact_name', { length: 120 }),
contact_title: varchar('contact_title', { length: 120 }),
contact_phone: varchar('contact_phone', { length: 64 }),
solution: text('solution'),
attachment_file_url: text('attachment_file_url'),
stage: int('stage').notNull().default(PROJECT_STAGES.SOLUTION),
status: int('status').notNull().default(PROJECT_STATUSES.ACTIVE),
description: text('description'),
......@@ -52,6 +52,8 @@ export const projects = mysqlTable(
(table) => ({
projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.project_code),
stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status),
// 列表按 created_at 倒序分页
createdAtIdx: index('idx_projects_created_at').on(table.created_at),
productIdIdx: index('idx_projects_product_id').on(table.product_id),
operatorUserIdIdx: index('idx_projects_operator_user_id').on(table.operator_user_id),
})
......@@ -64,8 +66,7 @@ export const initiations = mysqlTable(
project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
application_file_url: text('application_file_url'),
argument_file_url: text('argument_file_url'),
attachment_file_url: text('attachment_file_url'),
project_amount: decimal('project_amount', { precision: 14, scale: 2 }),
fund_source: varchar('fund_source', { length: 255 }),
execution_plan: text('execution_plan'),
......@@ -91,8 +92,7 @@ export const procurements = mysqlTable(
main_bid_owner: varchar('main_bid_owner', { length: 120 }),
companion_bidders: text('companion_bidders'),
formal_bid_status: varchar('formal_bid_status', { length: 120 }),
winning_notice_file_url: text('winning_notice_file_url'),
bid_archive_file_url: text('bid_archive_file_url'),
attachment_file_url: text('attachment_file_url'),
...timestamps,
},
(table) => ({
......@@ -111,7 +111,7 @@ export const contracts = mysqlTable(
contract_name: varchar('contract_name', { length: 255 }),
amount: decimal('amount', { precision: 14, scale: 2 }),
drafter: varchar('drafter', { length: 120 }),
archive_file_url: text('archive_file_url'),
attachment_file_url: text('attachment_file_url'),
...timestamps,
},
(table) => ({
......@@ -128,6 +128,7 @@ export const deliveries = mysqlTable('project_deliveries', {
delivery_contact: varchar('delivery_contact', { length: 120 }),
delivery_contact_phone: varchar('delivery_contact_phone', { length: 64 }),
delivery_note: text('delivery_note'),
attachment_file_url: text('attachment_file_url'),
completed_at: datetime('completed_at', { mode: 'string' }),
...timestamps,
}, (table) => ({
......@@ -139,8 +140,8 @@ export const acceptances = mysqlTable('project_acceptances', {
project_id: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
acceptance_report_url: text('acceptance_report_url'),
acceptance_note: text('acceptance_note'),
attachment_file_url: text('attachment_file_url'),
...timestamps,
}, (table) => ({
projectIdIdx: uniqueIndex('uk_acceptances_project_id').on(table.project_id),
......
......@@ -25,11 +25,20 @@ const start = async () => {
process.exit(1)
}
// Graceful shutdown
// Graceful shutdown:给 close 设上限
// SSE 流式请求最长可挂 120s,没有上限的话进程会一直不退出(pm2 reload 时请求灰掉)
const SHUTDOWN_TIMEOUT_MS = 10_000
const shutdown = async (signal) => {
logger.info(`${signal} received, shutting down...`)
await fastify.close()
try {
await Promise.race([
fastify.close(),
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS).unref()),
])
logger.info('Server closed')
} catch (error) {
logger.error({ err: error }, '关闭时出错,强制退出')
}
process.exit(0)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
......
/**
* 带错误码的业务错误。
*
* 与 lib/http-error.js 的区别:httpError 用于「网关自己拒绝请求」(401/429/503 等),
* 这里的 code 用于标注「上游/流式链路的技术性失败原因」,会被写进 ai_usage.error_code,
* 便于报表按错误类型聚合。两者都会透传 statusCode 给全局 error handler。
*/
export const codedError = (statusCode, message, code) => {
const error = new Error(message)
error.statusCode = statusCode
error.code = code
return error
}
/** 请求已被中止(客户端断开、超时、上游不可达时 fetch 会抛 AbortError/TimeoutError) */
export const isAborted = (error) =>
error?.name === 'AbortError' || error?.name === 'TimeoutError'
......@@ -93,8 +93,9 @@ const logSchema = new mongoose.Schema(
userAgent: String,
ip: String,
url: String,
createdAt: String,
updatedAt: String,
// immutable:客户端 body 里塞 createdAt 也改不动(写入时由 timestamps 填充)
createdAt: { type: String, immutable: true },
updatedAt: { type: String, immutable: true },
},
{
timestamps: {
......
import fp from 'fastify-plugin'
import rateLimit from '@fastify/rate-limit'
/**
* 限流插件:默认不全局生效(global: false),由各路由按需声明。
* AI 数据面按 API key 限流——鉴权钩子在 onRequest,限流放在 preHandler,
* 所以 keyGenerator 能拿到 request.ai。
*/
export default fp(async (fastify) => {
await fastify.register(rateLimit, {
global: false,
max: 300,
timeWindow: '1 minute',
})
}, { name: 'rate-limit' })
import fp from 'fastify-plugin'
import httpProxy from '@fastify/http-proxy'
// /api/usercenter/* 透传到用户中心
// /proxy/usercenter/* 透传到用户中心(/proxy 前缀表示这类接口是转发的上游,不是我们的业务资源)
export default fp(async (fastify) => {
await fastify.register(httpProxy, {
upstream: 'https://api-usercenter.ezijing.com',
prefix: '/api/usercenter',
prefix: '/proxy/usercenter',
})
}, { name: 'usercenter-proxy' })
import { success } from '#src/lib/response.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import * as adminService from '#src/services/ai/admin.service.js'
import { idParam } from '#src/schemas/dms/common.js'
import {
appCreateBody,
appRowSchema,
appUpdateBody,
appsListQuery,
itemResponseSchema,
listResponseSchema,
} from '#src/schemas/ai/admin.js'
const list = async (request, reply) => {
const { keyword, status, page, limit } = request.query
const result = await adminService.listApps({ keyword, status }, { page, limit })
return success(reply, result)
}
const create = async (request, reply) => {
const app = await adminService.createApp(request.body, getCurrentUser(request))
return success(reply, app, 201)
}
const update = async (request, reply) => {
const app = await adminService.updateApp(request.params.id, request.body, getCurrentUser(request))
return success(reply, app)
}
const remove = async (request, reply) => {
const app = await adminService.deleteApp(request.params.id)
return success(reply, app)
}
export default async function aiAdminAppsRoutes(fastify) {
fastify.get(
'/apps',
{ schema: { querystring: appsListQuery, response: { 200: listResponseSchema(appRowSchema) } } },
list,
)
fastify.post(
'/apps',
{ schema: { body: appCreateBody, response: { 201: itemResponseSchema(appRowSchema) } } },
create,
)
fastify.put(
'/apps/:id',
{ schema: { params: idParam, body: appUpdateBody, response: { 200: itemResponseSchema(appRowSchema) } } },
update,
)
fastify.delete(
'/apps/:id',
{ schema: { params: idParam, response: { 200: itemResponseSchema(appRowSchema) } } },
remove,
)
}
import { authenticate, requireRouteAccess } from '#src/services/dms/hooks.js'
// 本目录所有路由统一鉴权:先验登录(onRequest,先于 schema 校验),再验路由权限
export default async function aiAdminAutohooks(fastify) {
fastify.addHook('onRequest', authenticate)
fastify.addHook('preHandler', requireRouteAccess('/dms/ai'))
}
import { success } from '#src/lib/response.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import * as adminService from '#src/services/ai/admin.service.js'
import { idParam } from '#src/schemas/dms/common.js'
import {
itemResponseSchema,
keyCreateBody,
keyCreatedSchema,
keyRowSchema,
keysListQuery,
keyUpdateBody,
listResponseSchema,
} from '#src/schemas/ai/admin.js'
const list = async (request, reply) => {
const { app_id, status, page, limit } = request.query
const result = await adminService.listApiKeys({ app_id, status }, { page, limit })
return success(reply, result)
}
const create = async (request, reply) => {
const key = await adminService.createApiKey(request.body, getCurrentUser(request))
return success(reply, key, 201)
}
const update = async (request, reply) => {
const key = await adminService.updateApiKey(request.params.id, request.body, getCurrentUser(request))
return success(reply, key)
}
const remove = async (request, reply) => {
const key = await adminService.deleteApiKey(request.params.id)
return success(reply, key)
}
export default async function aiAdminKeysRoutes(fastify) {
fastify.get(
'/keys',
{ schema: { querystring: keysListQuery, response: { 200: listResponseSchema(keyRowSchema) } } },
list,
)
fastify.post(
'/keys',
{ schema: { body: keyCreateBody, response: { 201: itemResponseSchema(keyCreatedSchema) } } },
create,
)
fastify.put(
'/keys/:id',
{ schema: { params: idParam, body: keyUpdateBody, response: { 200: itemResponseSchema(keyRowSchema) } } },
update,
)
fastify.delete(
'/keys/:id',
{ schema: { params: idParam, response: { 200: itemResponseSchema(keyRowSchema) } } },
remove,
)
}
import { success } from '#src/lib/response.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import * as adminService from '#src/services/ai/admin.service.js'
import {
itemResponseSchema,
listResponseSchema,
modelAliasParam,
modelCreateBody,
modelRowSchema,
modelsListQuery,
modelUpdateBody,
} from '#src/schemas/ai/admin.js'
const list = async (request, reply) => {
const { keyword, type, provider, page, limit } = request.query
const result = await adminService.listModels({ keyword, type, provider }, { page, limit })
return success(reply, result)
}
const create = async (request, reply) => {
const model = await adminService.createModel(request.body, getCurrentUser(request))
return success(reply, model, 201)
}
const update = async (request, reply) => {
const model = await adminService.updateModel(request.params.alias, request.body, getCurrentUser(request))
return success(reply, model)
}
const remove = async (request, reply) => {
const model = await adminService.deleteModel(request.params.alias)
return success(reply, model)
}
export default async function aiAdminModelsRoutes(fastify) {
fastify.get(
'/models',
{ schema: { querystring: modelsListQuery, response: { 200: listResponseSchema(modelRowSchema) } } },
list,
)
fastify.post(
'/models',
{ schema: { body: modelCreateBody, response: { 201: itemResponseSchema(modelRowSchema) } } },
create,
)
fastify.put(
'/models/:alias',
{ schema: { params: modelAliasParam, body: modelUpdateBody, response: { 200: itemResponseSchema(modelRowSchema) } } },
update,
)
fastify.delete(
'/models/:alias',
{ schema: { params: modelAliasParam, response: { 200: itemResponseSchema(modelRowSchema) } } },
remove,
)
}
import { success } from '#src/lib/response.js'
import { getCurrentUser } from '#src/services/dms/auth.service.js'
import * as adminService from '#src/services/ai/admin.service.js'
import { idParam } from '#src/schemas/dms/common.js'
import {
itemResponseSchema,
listResponseSchema,
quotaPackageCreateBody,
quotaPackageRowSchema,
quotaPackagesListQuery,
quotaPackageUpdateBody,
} from '#src/schemas/ai/admin.js'
export default async function aiAdminQuotaPackageRoutes(fastify) {
fastify.get('/quota-packages', {
schema: { querystring: quotaPackagesListQuery, response: { 200: listResponseSchema(quotaPackageRowSchema) } },
}, async (request, reply) => {
const { app_id, status, page, limit } = request.query
return success(reply, await adminService.listQuotaPackages({ app_id, status }, { page, limit }))
})
fastify.post('/quota-packages', {
schema: { body: quotaPackageCreateBody, response: { 201: itemResponseSchema(quotaPackageRowSchema) } },
}, async (request, reply) => {
return success(reply, await adminService.createQuotaPackage(request.body, getCurrentUser(request)), 201)
})
fastify.put('/quota-packages/:id', {
schema: { params: idParam, body: quotaPackageUpdateBody, response: { 200: itemResponseSchema(quotaPackageRowSchema) } },
}, async (request, reply) => {
return success(reply, await adminService.updateQuotaPackage(request.params.id, request.body, getCurrentUser(request)))
})
fastify.delete('/quota-packages/:id', {
schema: { params: idParam, response: { 200: itemResponseSchema(quotaPackageRowSchema) } },
}, async (request, reply) => {
return success(reply, await adminService.deleteQuotaPackage(request.params.id))
})
}
import { success } from '#src/lib/response.js'
import { usageRecentQuery, usageSummaryQuery } from '#src/schemas/ai/usage.js'
import { summarize, listRecent } from '#src/services/ai/report.service.js'
export default async function aiAdminUsageRoutes(fastify) {
fastify.get('/usage/summary', { schema: { querystring: usageSummaryQuery } }, async (request, reply) => {
const { group_by: groupBy, from, to, app_id: appId, api_key_id: apiKeyId, type, usage_source: usageSource, status, model } = request.query
const result = await summarize({ groupBy, from, to, appId, apiKeyId, type, usageSource, status, model })
return success(reply, result)
})
fastify.get('/usage/recent', { schema: { querystring: usageRecentQuery } }, async (request, reply) => {
const { page, limit, app_id: appId, api_key_id: apiKeyId, type, usage_source: usageSource, billing_status: billingStatus, status, model, request_id: requestId, from, to } = request.query
const result = await listRecent({ appId, apiKeyId, type, usageSource, billingStatus, status, model, requestId, from, to }, { page, limit })
return success(reply, result)
})
}
import { createHash } from 'node:crypto'
import { hasZodFastifySchemaValidationErrors } from 'fastify-type-provider-zod'
import { eq, sql } from 'drizzle-orm'
import { LRUCache } from 'lru-cache'
import { db } from '#src/db/client.js'
import { aiApiKeys, KEY_STATUSES } from '#src/db/schema/ai/api-keys.js'
import { aiApps, APP_STATUSES } from '#src/db/schema/ai/apps.js'
import { httpError } from '#src/lib/http-error.js'
import logger from '#src/lib/logger.js'
import { getPriceByVersion } from '#src/services/ai/pricing.service.js'
import { settleOverduePending } from '#src/services/ai/video.service.js'
import { normalizeGatewayException } from '#src/services/ai/error.service.js'
const LAST_USED_THROTTLE_MS = 60_000
const LAZY_SETTLE_THROTTLE_MS = 60_000
// 每个 key 最多每分钟扫一次过期任务;LRU 防止长期运行下无限增长
const lastSweepAt = new LRUCache({ max: 5_000, ttl: LAZY_SETTLE_THROTTLE_MS })
const hashKey = (plaintext) => createHash('sha256').update(plaintext).digest('hex')
const extractToken = (request) => {
const header = request.headers.authorization || ''
if (!header.toLowerCase().startsWith('bearer ')) return ''
return header.slice(7).trim()
}
/**
* 数据面鉴权:Bearer <api key> -> 应用 + 密钥上下文
* 每个请求查一次库(key_hash 有唯一索引),保证停用/删除立即生效。
*/
export const authenticateApiKey = async (request) => {
const token = extractToken(request)
if (!token) throw httpError(401, '缺少 API key')
const rows = await db
.select({ key: aiApiKeys, app: aiApps })
.from(aiApiKeys)
.innerJoin(aiApps, eq(aiApiKeys.app_id, aiApps.id))
.where(eq(aiApiKeys.key_hash, hashKey(token)))
.limit(1)
const row = rows[0]
if (!row) throw httpError(401, 'API key 无效')
if (row.key.status !== KEY_STATUSES.ENABLED) throw httpError(403, 'API key 已停用')
if (row.app.status !== APP_STATUSES.ENABLED) throw httpError(403, '应用已停用')
// 调用用户来自请求头:一个 key 供多人使用,按 (key, user) 归因
const rawUserId = request.headers['x-user-id']
const rawUserName = request.headers['x-user-name']
const userId = rawUserId ? String(rawUserId).trim().slice(0, 64) : null
// HTTP 头不能直接放中文,调用方通常做 percent-encode,这里解回来
let headerName = rawUserName ? String(rawUserName).trim().slice(0, 360) : null
if (headerName) {
try {
headerName = decodeURIComponent(headerName).slice(0, 120)
} catch {
headerName = headerName.slice(0, 120)
}
}
request.ai = {
appId: row.app.id,
appCode: row.app.code,
keyId: row.key.id,
userId,
userName: headerName,
billingMode: row.app.billing_mode,
}
// 惰性兜底:顺带结算该 key 过期的 pending 视频任务(不阻塞本次请求)
if (!lastSweepAt.has(row.key.id)) {
lastSweepAt.set(row.key.id, true)
settleOverduePending({
apiKeyId: row.key.id,
priceLookup: getPriceByVersion,
}).catch((error) => logger.warn({ err: error }, '惰性结算视频任务失败'))
}
// last_used_at 节流更新,避免每个请求都写库
const lastUsedAt = row.key.last_used_at ? new Date(row.key.last_used_at).getTime() : 0
if (Date.now() - lastUsedAt > LAST_USED_THROTTLE_MS) {
db.update(aiApiKeys)
.set({ last_used_at: sql`CURRENT_TIMESTAMP` })
.where(eq(aiApiKeys.id, row.key.id))
.catch((error) => logger.warn({ err: error }, '更新 last_used_at 失败'))
}
}
export default async function aiV1Autohooks(fastify) {
fastify.setErrorHandler((err, request, reply) => {
const validation = hasZodFastifySchemaValidationErrors(err)
if (validation) {
err.message = err.validation.map((item) => {
const path = item.instancePath.replace(/^\//, '').replaceAll('/', '.')
return `${path ? `${path}: ` : ''}${item.message}`
}).join('; ')
}
const candidate = Number(err.statusCode ?? err.status)
const status = Number.isInteger(candidate) && candidate >= 400 && candidate < 600 ? candidate : 500
const payload = normalizeGatewayException({ error: err, requestId: request.id, validation })
if (status >= 500) request.log.error({ err }, 'AI gateway request failed')
return reply.code(status).send(payload)
})
fastify.addHook('onRequest', authenticateApiKey)
/**
* 每个请求挂一个 AbortSignal,客户端跑掉时中止上游请求。
* 否则图片(300s)/ 非流式文本(120s)会白白把上游跑完并计费。
*
* 监听 reply.raw 的 close(响应结束或客户端提前断开都会触发)。
* 不能监听 request.raw 的 close——请求体读完就会触发,会把正常请求误判成断开。
*/
fastify.addHook('onRequest', async (request, reply) => {
const controller = new AbortController()
request.abortSignal = controller.signal
if (request.raw.aborted || request.raw.destroyed) {
controller.abort()
return
}
reply.raw.once('close', () => controller.abort())
})
}
import { chatCompletionBody } from '#src/schemas/ai/chat.js'
import { resolveBillableModel } from '#src/services/ai/model.service.js'
import { textCostMicro } from '#src/services/ai/pricing.service.js'
import { runCall, upstreamErrorCode } from '#src/services/ai/call-flow.js'
import { providerFor } from '#src/services/ai/providers/index.js'
import { aiRateLimitOptions } from '#src/services/ai/rate-limit.js'
import { AI_MODEL_TYPES } from '#src/db/schema/ai/model-mappings.js'
import { USAGE_UNITS } from '#src/db/schema/ai/ai-usage.js'
import { PRICING_UNITS } from '#src/db/schema/ai/model-prices.js'
const UPSTREAM_TIMEOUT_MS = 120_000
/** 流式特有的空闲超时:两条 chunk 之间的最大间隔 */
const STREAM_IDLE_TIMEOUT_MS = 120_000
/** 取上游上报的 usage;gateway 已统一放在 result.usage(流式与非流式同形) */
const reportedUsage = (result) => result.usage ?? null
/** 客户端断开信号 + 上游总超时;AbortSignal.any 里的 timeout 只兜「上游不响应」(流式另见 idle 超时) */
const upstreamSignal = (request, timeoutMs) => {
const timeout = AbortSignal.timeout(timeoutMs)
return request.abortSignal ? AbortSignal.any([request.abortSignal, timeout]) : timeout
}
export default async function aiChatRoutes(fastify) {
fastify.post(
'/chat/completions',
{ schema: { body: chatCompletionBody }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const { model, stream } = request.body
const { mapping, price } = await resolveBillableModel(model, {
type: AI_MODEL_TYPES.TEXT,
pricingUnit: PRICING_UNITS.PER_1M_TOKENS,
})
const provider = providerFor(mapping.provider, stream ? 'streamChat' : 'chat')
const common = {
request,
mapping,
model,
price,
type: AI_MODEL_TYPES.TEXT,
unit: USAGE_UNITS.TOKEN,
// 只根据上游真实 usage 计费。
settleCostMicro: (_result, usage) => textCostMicro(
price,
usage.prompt_tokens,
usage.completion_tokens,
usage.prompt_tokens_details?.cached_tokens ?? 0,
),
quantityOf: (_result, usage) => usage.prompt_tokens + usage.completion_tokens,
onError: (result) => ({ code: upstreamErrorCode(result) }),
}
// ── 流式 ──
if (stream) {
const result = await runCall({
...common,
stream: true,
usageOf: reportedUsage,
execute: () => provider.streamChat({
mapping,
body: request.body,
reply,
signal: request.abortSignal,
requestId: request.id,
idleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
}),
})
// 已 hijack,不能再 send
if (!result.ok) return reply.code(result.status).send(result.data)
return reply
}
// ── 非流式 ──
const result = await runCall({
...common,
stream: false,
usageOf: reportedUsage,
execute: () => provider.chat({
mapping,
body: request.body,
signal: upstreamSignal(request, UPSTREAM_TIMEOUT_MS),
requestId: request.id,
}),
})
if (!result.ok) return reply.code(result.status).send(result.data)
return reply.send({ ...result.data, model })
},
)
}
import { imageGenerationBody } from '#src/schemas/ai/image.js'
import { resolveBillableModel } from '#src/services/ai/model.service.js'
import { unitCostMicro } from '#src/services/ai/pricing.service.js'
import { runCall, upstreamErrorCode } from '#src/services/ai/call-flow.js'
import { providerFor } from '#src/services/ai/providers/index.js'
import { aiRateLimitOptions } from '#src/services/ai/rate-limit.js'
import { AI_MODEL_TYPES } from '#src/db/schema/ai/model-mappings.js'
import { USAGE_UNITS } from '#src/db/schema/ai/ai-usage.js'
import { PRICING_UNITS } from '#src/db/schema/ai/model-prices.js'
const UPSTREAM_TIMEOUT_MS = 300_000
/**
* 从火山方舟的图片响应里取用量(纯函数,单独可测)。
* 真实响应形如:
* { model, created, data: [{ url, size: "2048x2048" }],
* usage: { generated_images: 1, output_tokens: 16384, total_tokens: 16384 } }
*
* 计费张数优先用 usage.generated_images;只有老版本 / 异常响应没有该字段时才退化为
* 数 data 数组长度。两者都没有就返回 null,由 runCall 标 usage_missing 待核对。
* output_tokens 是我们自己按张计价之外的上游口径,记下来便于与火山账单核对。
*/
export const parseImageUsage = (data) => {
const count = data?.usage?.generated_images
?? (Array.isArray(data?.data) ? data.data.length : null)
if (count === null) return null
return {
generated_images: count,
completion_tokens: data?.usage?.output_tokens ?? 0,
}
}
export default async function aiImageRoutes(fastify) {
fastify.post(
'/images/generations',
{ schema: { body: imageGenerationBody }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const { model } = request.body
const { mapping, price } = await resolveBillableModel(model, {
type: AI_MODEL_TYPES.IMAGE,
pricingUnit: PRICING_UNITS.PER_IMAGE,
})
const provider = providerFor(mapping.provider, 'image')
// 真实张数以响应为准。
const result = await runCall({
request,
mapping,
model,
price,
type: AI_MODEL_TYPES.IMAGE,
unit: USAGE_UNITS.IMAGE,
execute: () => provider.image({
mapping,
body: request.body,
requestId: request.id,
signal: request.abortSignal
? AbortSignal.any([request.abortSignal, AbortSignal.timeout(UPSTREAM_TIMEOUT_MS)])
: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
}),
// 计费数量 = 上游实际出图张数;连 data 数组都没有才算「没上报用量」,
// 交由 runCall 标记 missing + usage_missing,不估算。
usageOf: (upstream) => parseImageUsage(upstream.data),
quantityOf: (_upstream, usage) => usage.generated_images,
settleCostMicro: (_upstream, usage) => unitCostMicro(price, usage.generated_images),
onError: (upstream) => ({ code: upstreamErrorCode(upstream) }),
})
if (!result.ok) return reply.code(result.status).send(result.data)
return reply.send({ ...result.data, model })
},
)
}
import { listEnabledModels } from '#src/services/ai/model.service.js'
import { aiRateLimitOptions } from '#src/services/ai/rate-limit.js'
import { z } from 'zod'
const modelsQuery = z.object({
type: z.enum(['text', 'image', 'video']).optional(),
}).strict()
export default async function aiModelsRoutes(fastify) {
fastify.get('/models', {
schema: { querystring: modelsQuery },
preHandler: fastify.rateLimit(aiRateLimitOptions),
}, async (request, reply) => {
const rows = await listEnabledModels(request.query.type)
return reply.send({
data: rows.map((row) => ({
id: row.alias,
name: row.name,
type: row.type,
})),
})
})
}
import { appUsageRecentQuery, appUsageSummaryQuery } from '#src/schemas/ai/usage.js'
import { summarize, listRecent } from '#src/services/ai/report.service.js'
import { aiRateLimitOptions } from '#src/services/ai/rate-limit.js'
/**
* 业务系统的用量查询入口。
* 鉴权用的是应用自己的 key,所有查询自动限定在本应用内,查不到别的应用。
*/
export default async function aiAppUsageRoutes(fastify) {
fastify.get(
'/usage/summary',
{ schema: { querystring: appUsageSummaryQuery }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const { group_by: groupBy, from, to, user_id: userId, model, type, status } = request.query
const result = await summarize({ groupBy, from, to, userId, model, type, status, appId: request.ai.appId })
return reply.send(result)
},
)
fastify.get(
'/usage',
{ schema: { querystring: appUsageRecentQuery }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const { page, limit, user_id: userId, model, type, status, from, to } = request.query
const result = await listRecent(
{ appId: request.ai.appId, userId, model, type, status, from, to },
{ page, limit },
)
return reply.send(result)
},
)
}
import { and, eq } from 'drizzle-orm'
import dayjs from 'dayjs'
import { db } from '#src/db/client.js'
import { httpError } from '#src/lib/http-error.js'
import { aiUsage, USAGE_SOURCES, USAGE_STATUSES, USAGE_UNITS } from '#src/db/schema/ai/ai-usage.js'
import { PRICING_UNITS } from '#src/db/schema/ai/model-prices.js'
import { AI_MODEL_TYPES } from '#src/db/schema/ai/model-mappings.js'
import { videoSubmitBody, videoTaskParam } from '#src/schemas/ai/video.js'
import { resolveBillableModel } from '#src/services/ai/model.service.js'
import { getPriceByVersion } from '#src/services/ai/pricing.service.js'
import { resolveQuotaPackage } from '#src/services/ai/quota.service.js'
import { providerFor } from '#src/services/ai/providers/index.js'
import { buildUsageRecord, recordUsage } from '#src/services/ai/usage.service.js'
import { aiRateLimitOptions } from '#src/services/ai/rate-limit.js'
import { refreshVideoTask } from '#src/services/ai/video.service.js'
const SUBMIT_TIMEOUT_MS = 60_000
const QUERY_TIMEOUT_MS = 30_000
const findTask = async (appId, apiKeyId, taskId) => {
const rows = await db
.select()
.from(aiUsage)
.where(and(eq(aiUsage.app_id, appId), eq(aiUsage.api_key_id, apiKeyId), eq(aiUsage.upstream_task_id, taskId)))
.limit(1)
return rows[0] ?? null
}
const withTimeout = (request, timeoutMs) => {
const timeout = AbortSignal.timeout(timeoutMs)
return request.abortSignal ? AbortSignal.any([request.abortSignal, timeout]) : timeout
}
export default async function aiVideoRoutes(fastify) {
// 提交任务不占额度,完成后按真实时长计费。
fastify.post(
'/videos',
{ schema: { body: videoSubmitBody }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const { model, prompt, image, duration, aspect_ratio, resolution, fps, watermark } = request.body
const { mapping, price } = await resolveBillableModel(model, {
type: AI_MODEL_TYPES.VIDEO,
pricingUnit: PRICING_UNITS.PER_SECOND,
})
const provider = providerFor(mapping.provider, 'createVideo')
const submittedAt = new Date()
const quotaPackage = await resolveQuotaPackage(request.ai)
const upstream = await provider.createVideo({
mapping,
body: { model, prompt, image, duration, aspect_ratio, resolution, fps, watermark },
signal: withTimeout(request, SUBMIT_TIMEOUT_MS),
requestId: request.id,
})
const { status, data, latencyMs } = upstream
const upstreamTaskId = data?.id ?? data?.task_id ?? null
const record = (recordStatus, extra = {}) => recordUsage(buildUsageRecord({
request, mapping, model, price,
quota_package_id: quotaPackage?.id ?? null,
type: AI_MODEL_TYPES.VIDEO,
unit: USAGE_UNITS.SECOND,
quantity: 0,
usage_source: USAGE_SOURCES.MISSING,
cost: '0.000000',
status: recordStatus,
http_status: status,
latency_ms: latencyMs,
stream: 0,
created_at: dayjs(submittedAt).format('YYYY-MM-DD HH:mm:ss'),
...extra,
}))
if (!upstream.ok || !upstreamTaskId) {
await record(USAGE_STATUSES.ERROR, { error_code: data?.error?.code ?? null })
return reply.code(status).send(data)
}
await record(USAGE_STATUSES.PENDING, { upstream_task_id: upstreamTaskId })
return reply.code(202).send({
id: upstreamTaskId,
model,
status: 'queued',
video: null,
usage: null,
error: null,
})
},
)
// 查询任务:顺带结算(主路径)
fastify.get(
'/videos/:task_id',
{ schema: { params: videoTaskParam }, preHandler: fastify.rateLimit(aiRateLimitOptions) },
async (request, reply) => {
const row = await findTask(request.ai.appId, request.ai.keyId, request.params.task_id)
if (!row) throw httpError(404, '任务不存在')
// 按提交时锁定的版本取价,避免任务期间改价影响已提交的任务
const price = await getPriceByVersion(row.model, row.price_version)
if (!price) throw httpError(503, `模型未配置价格:${row.model}`)
const result = await refreshVideoTask({
row,
price,
signal: withTimeout(request, QUERY_TIMEOUT_MS),
requestId: request.id,
})
if (result.ok === false) return reply.code(result.status).send(result.data)
return reply.send(result.data ?? {
id: request.params.task_id,
model: row.model,
status: 'processing',
video: null,
usage: null,
error: null,
})
},
)
}
......@@ -31,11 +31,9 @@ const create = async (request, reply) => {
const list = async (request, reply) => {
const { keyword, stage, status, page, limit } = request.query
const result = await projectsService.listProjects(
{ keyword, stage, status },
{ page, limit },
request.user ? { userId: request.user.ssoId, roles: request.user.roles || [] } : {},
)
// 与其他路径统一传原始 request.user(ssoId + roles),
// 不再在这里另造一个 { userId, roles } 形状——同一概念两种形状正是漂移的来源
const result = await projectsService.listProjects({ keyword, stage, status }, { page, limit }, request.user || {})
return success(reply, result)
}
......
import { z } from 'zod'
import { pagination } from '#src/schemas/dms/common.js'
// ---------- 公共子结构 ----------
// 状态:0 停用 / 1 启用(用 int 而非 boolean,与仓储惯例一致)
const statusField = z.coerce
.number()
.int()
.refine((v) => v === 0 || v === 1, '状态仅支持 0/1')
export const billingModeEnum = z.enum(['internal', 'quota'])
// 网关只管理三类生成模型。
export const modelTypeEnum = z.enum(['text', 'image', 'video'])
// 上游供应商
export const providerEnum = z.enum(['volcano', 'deepseek'])
// 计价单位
export const pricingUnitEnum = z.enum(['per_1m_tokens', 'per_image', 'per_second'])
// 单价:元(小数)
const priceAmount = z.coerce.number().finite().nonnegative()
// 最后操作人(与业务表 operator_* 一致),请求里由登录态注入,不允许入参篡改
export const auditRowFields = {
operator_user_id: z.string().nullable(),
operator_name: z.string().nullable(),
}
// ---------- 应用 ----------
export const appCreateBody = z.object({
code: z.string().trim().min(1).max(64),
name: z.string().trim().min(1).max(120),
billing_mode: billingModeEnum.optional(),
status: statusField.optional(),
})
export const appUpdateBody = appCreateBody.partial()
export const appsListQuery = z.object({
keyword: z.string().trim().optional(),
status: statusField.optional(),
...pagination,
})
export const appRowSchema = z.object({
id: z.number().int(),
code: z.string(),
name: z.string(),
billing_mode: billingModeEnum,
status: z.number().int(),
...auditRowFields,
created_at: z.string(),
updated_at: z.string(),
enabled_key_count: z.number().int().optional(),
month_calls: z.number().int().optional(),
month_cost: z.string().optional(),
last_used_at: z.string().nullable().optional(),
})
// ---------- 密钥 ----------
export const keyCreateBody = z.object({
app_id: z.coerce.number().int().positive(),
name: z.string().trim().min(1).max(120),
status: statusField.optional(),
})
// app_id 不允许改(密钥换应用就等于重签)
export const keyUpdateBody = keyCreateBody.omit({ app_id: true }).partial()
export const keysListQuery = z.object({
app_id: z.coerce.number().int().positive().optional(),
status: statusField.optional(),
...pagination,
})
export const keyRowSchema = z.object({
id: z.number().int(),
app_id: z.number().int(),
name: z.string(),
key_prefix: z.string(),
key_masked: z.string(),
status: z.number().int(),
last_used_at: z.string().nullable(),
...auditRowFields,
created_at: z.string(),
updated_at: z.string(),
})
// 创建密钥响应额外返回一次明文 key(此后不可再取)
export const keyCreatedSchema = keyRowSchema.extend({ key: z.string() })
// ---------- 应用额度 ----------
const quotaPackageFields = {
app_id: z.coerce.number().int().positive(),
name: z.string().trim().min(1).max(120),
amount_micro: z.coerce.number().int().positive(),
start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
status: statusField.optional(),
remark: z.string().trim().max(1000).nullish(),
}
export const quotaPackageCreateBody = z.object(quotaPackageFields)
export const quotaPackageUpdateBody = quotaPackageCreateBody.omit({ app_id: true }).partial()
export const quotaPackagesListQuery = z.object({
app_id: z.coerce.number().int().positive().optional(),
status: statusField.optional(),
...pagination,
})
export const quotaPackageRowSchema = z.object({
id: z.number().int(),
app_id: z.number().int(),
name: z.string(),
amount_micro: z.number().int(),
used_micro: z.number().int(),
start_date: z.string(),
end_date: z.string(),
status: z.number().int(),
remark: z.string().nullable(),
...auditRowFields,
created_at: z.string(),
updated_at: z.string(),
})
// ---------- 模型单价 ----------
// ---------- 响应信封(与 src/lib/response.js 的 success() 结构一致)----------
export const itemResponseSchema = (dataSchema) => z.object({ success: z.literal(true), data: dataSchema })
export const listResponseSchema = (rowSchema) =>
z.object({
success: z.literal(true),
data: z.object({
list: z.array(rowSchema),
pagination: z.object({
page: z.number().int(),
limit: z.number().int(),
total: z.number().int(),
pages: z.number().int(),
}),
}),
})
// ---------- 模型(映射 + 价格合并)----------
export const modelCreateBody = z.object({
alias: z.string().trim().min(1).max(80),
name: z.string().trim().min(1).max(120),
type: modelTypeEnum,
provider: providerEnum,
upstream_model: z.string().trim().min(1).max(120),
enabled: statusField.optional(),
pricing_unit: pricingUnitEnum,
input_price: priceAmount.optional(),
cached_input_price: priceAmount.optional(),
output_price: priceAmount.optional(),
unit_price: priceAmount.optional(),
currency: z.string().trim().max(3).optional(),
})
// alias 是主键,不允许改
export const modelUpdateBody = modelCreateBody.omit({ alias: true }).partial()
export const modelsListQuery = z.object({
keyword: z.string().trim().optional(),
type: modelTypeEnum.optional(),
provider: providerEnum.optional(),
...pagination,
})
export const modelAliasParam = z.object({
alias: z.string().trim().min(1).max(80),
})
export const modelRowSchema = z.object({
alias: z.string(),
name: z.string(),
type: modelTypeEnum,
provider: providerEnum,
upstream_model: z.string(),
enabled: z.number().int(),
version: z.number().int().nullable(),
pricing_unit: pricingUnitEnum.nullable(),
input_price: z.string().nullable(),
cached_input_price: z.string().nullable(),
output_price: z.string().nullable(),
unit_price: z.string().nullable(),
currency: z.string().nullable(),
effective_from: z.string().nullable(),
effective_to: z.string().nullable(),
has_price: z.boolean(),
...auditRowFields,
created_at: z.string(),
updated_at: z.string(),
})
import { z } from 'zod'
const message = z
.object({
role: z.string().min(1),
content: z.unknown(),
})
.passthrough()
// 文本接口遵循 OpenAI Chat Completions,只校验网关路由需要的核心字段。
export const chatCompletionBody = z
.object({
model: z.string().min(1).max(80),
messages: z.array(message).min(1),
stream: z.boolean().optional(),
})
.passthrough()
.superRefine((body, ctx) => {
for (const field of ['search', 'web_search']) {
if (field in body) ctx.addIssue({ code: 'custom', path: [field], message: '搜索不属于 AI 网关' })
}
})
import { z } from 'zod'
export const imageGenerationBody = z
.object({
model: z.string().min(1).max(80),
prompt: z.string().min(1),
})
.passthrough()
import { z } from 'zod'
export const USAGE_GROUP_BYS = ['app', 'api_key', 'user', 'model', 'provider', 'type', 'usage_source', 'status', 'day']
export const USAGE_STATUSES = ['pending', 'success', 'error', 'aborted']
export const USAGE_SOURCES = ['reported', 'missing']
export const BILLING_STATUSES = ['pending', 'charged', 'not_charged', 'reconcile']
const dateField = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD')
const statusField = z.enum(USAGE_STATUSES)
const usageSourceField = z.enum(USAGE_SOURCES)
const billingStatusField = z.enum(BILLING_STATUSES)
const modelField = z.string().max(80)
export const usageSummaryQuery = z.object({
group_by: z.enum(USAGE_GROUP_BYS).default('model'),
from: dateField.optional(),
to: dateField.optional(),
app_id: z.coerce.number().int().positive().optional(),
api_key_id: z.coerce.number().int().positive().optional(),
type: z.enum(['text', 'image', 'video']).optional(),
usage_source: usageSourceField.optional(),
status: statusField.optional(),
model: modelField.optional(),
})
export const usageRecentQuery = z.object({
request_id: z.string().trim().max(64).optional(),
app_id: z.coerce.number().int().positive().optional(),
api_key_id: z.coerce.number().int().positive().optional(),
type: z.enum(['text', 'image', 'video']).optional(),
usage_source: usageSourceField.optional(),
billing_status: billingStatusField.optional(),
status: statusField.optional(),
model: modelField.optional(),
// 日期区间:不传时 listRecent 会补默认窗口(见 report.service.withDefaultRange),
// 所以调用方应显式带上,避免「查不到旧日志却不知道原因」
from: dateField.optional(),
to: dateField.optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
})
// ---------- 数据面(业务系统调用,自动限定在自身应用内)----------
export const appUsageSummaryQuery = z.object({
group_by: z.enum(['user', 'model', 'type', 'day']).default('user'),
from: dateField.optional(),
to: dateField.optional(),
user_id: z.string().max(64).optional(),
type: z.enum(['text', 'image', 'video']).optional(),
status: statusField.optional(),
model: modelField.optional(),
})
export const appUsageRecentQuery = z.object({
user_id: z.string().max(64).optional(),
model: modelField.optional(),
type: z.enum(['text', 'image', 'video']).optional(),
status: statusField.optional(),
from: dateField.optional(),
to: dateField.optional(),
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
})
import { z } from 'zod'
export const videoSubmitBody = z
.object({
model: z.string().min(1).max(80),
prompt: z.string().min(1),
image: z.string().min(1).optional(),
duration: z.coerce.number().int().positive().max(300).optional(),
aspect_ratio: z.string().regex(/^\d{1,2}:\d{1,2}$/).optional(),
resolution: z.string().regex(/^\d{3,4}p$/).optional(),
fps: z.coerce.number().int().positive().max(120).optional(),
watermark: z.boolean().optional(),
})
.strict()
export const videoTaskParam = z.object({
task_id: z.string().min(1).max(128),
})
......@@ -11,7 +11,6 @@ const caseBody = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().nullish(),
product_id: optionalId,
product_name: z.string().max(255).nullish(),
files: z.array(z.unknown()).nullish(),
})
......
......@@ -5,9 +5,12 @@ export const pagination = {
limit: z.coerce.number().int().min(1).max(500).optional(),
}
// null / '' 是「清空该字段」,必须解析成 null 保留下来:drizzle 的 update set 会
// 丢弃值为 undefined 的列(drizzle-orm/utils.js mapUpdateSet),解析成 undefined
// 会让清空静默失效——接口 200 但旧值还在。
export const optionalNumber = z.preprocess(
(value) => value === null || value === '' ? undefined : value,
z.coerce.number().optional(),
(value) => value === '' ? null : value,
z.coerce.number().nullish(),
)
export const optionalId = z.preprocess(
......
......@@ -19,7 +19,6 @@ export const projectCreateBody = z.object({
school_name: z.string().trim().min(1).max(255),
department_name: z.string().max(255).nullish(),
product_id: z.coerce.number().int().positive(),
product_name: z.string().max(255).nullish(),
contact_name: z.string().max(120).nullish(),
contact_title: z.string().max(120).nullish(),
contact_phone: z.string().max(64).nullish(),
......@@ -30,11 +29,7 @@ export const projectUpdateBody = projectCreateBody.partial()
export const solutionUpsertBody = z.object({
solution: z.string().nullish(),
})
export const moveStageBody = z.object({
to_stage: z.coerce.number().int(),
description: z.string().nullish(),
attachment_file_url: fileListValue,
})
export const rollbackStageBody = z.object({
......@@ -56,8 +51,7 @@ export const projectTeamBody = z.object({
// ── phase upserts ────────────────────────────────────────────────────
export const initiationUpsertBody = z.object({
application_file_url: fileListValue,
argument_file_url: fileListValue,
attachment_file_url: fileListValue,
project_amount: optionalNumber,
fund_source: z.string().max(255).nullish(),
execution_plan: z.string().nullish(),
......@@ -72,8 +66,7 @@ export const procurementUpsertBody = z.object({
main_bid_owner: z.string().max(120).nullish(),
companion_bidders: z.string().nullish(),
formal_bid_status: z.string().max(120).nullish(),
winning_notice_file_url: fileListValue,
bid_archive_file_url: fileListValue,
attachment_file_url: fileListValue,
})
export const contractUpsertBody = z.object({
......@@ -81,7 +74,7 @@ export const contractUpsertBody = z.object({
contract_name: z.string().max(255).nullish(),
amount: optionalNumber,
drafter: z.string().max(120).nullish(),
archive_file_url: fileListValue,
attachment_file_url: fileListValue,
})
export const deliveryUpsertBody = z.object({
......@@ -89,9 +82,25 @@ export const deliveryUpsertBody = z.object({
delivery_contact: z.string().max(120).nullish(),
delivery_contact_phone: z.string().max(64).nullish(),
delivery_note: z.string().nullish(),
attachment_file_url: fileListValue,
})
export const acceptanceUpsertBody = z.object({
acceptance_report_url: fileListValue,
acceptance_note: z.string().nullish(),
attachment_file_url: fileListValue,
})
const stageCompletionData = z.object({
...solutionUpsertBody.shape,
...initiationUpsertBody.shape,
...procurementUpsertBody.shape,
...contractUpsertBody.shape,
...deliveryUpsertBody.shape,
...acceptanceUpsertBody.shape,
}).partial()
export const moveStageBody = z.object({
to_stage: z.coerce.number().int(),
description: z.string().nullish(),
phase_data: stageCompletionData.optional(),
})
import { createHash, randomBytes } from 'node:crypto'
import dayjs from 'dayjs'
import { and, count, desc, eq, gte, inArray, like, lte, ne, or, sql } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import { aiApps, APP_STATUSES, BILLING_MODES } from '#src/db/schema/ai/apps.js'
import { aiApiKeys } from '#src/db/schema/ai/api-keys.js'
import { aiModelMappings } from '#src/db/schema/ai/model-mappings.js'
import { aiModelPrices, PRICE_STATUSES } from '#src/db/schema/ai/model-prices.js'
import { aiUsage } from '#src/db/schema/ai/ai-usage.js'
import { aiQuotaPackages, QUOTA_PACKAGE_STATUSES } from '#src/db/schema/ai/quota-packages.js'
import { invalidateModelCache } from './model.service.js'
import { providerFor } from './providers/index.js'
import { activePriceCondition } from './pricing.service.js'
// ---------- 工具 ----------
// 分页入参夹紧(与 products.service 一致)
const resolvePage = (page) => Math.max(parseInt(page, 10) || 1, 1)
const resolveLimit = (limit) => Math.min(Math.max(parseInt(limit, 10) || 20, 1), 100)
const buildPagination = (page, limit, total) => ({
page,
limit,
total,
pages: Math.ceil(total / limit),
})
// drop undefined(undefined 不参与写库,null 则真实写入 NULL)
const cleanUndefined = (values) =>
Object.fromEntries(Object.entries(values).filter(([, value]) => value !== undefined))
// 唯一键冲突(mysql2 errcode 1062)
const isDuplicateEntry = (error) => error?.code === 'ER_DUP_ENTRY' || error?.errno === 1062
// 最后操作人(与业务表 operator_* 保持一致)
const audit = (operator = {}) => ({
operator_user_id: operator.userId || null,
operator_name: operator.userName || null,
})
// 更新时同样只写最后操作人
const auditUpdate = audit
// ==================== 应用 ====================
const getAppById = async (id) => {
const rows = await db.select().from(aiApps).where(eq(aiApps.id, Number(id))).limit(1)
return rows[0] || null
}
const buildAppWhere = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
conditions.push(or(like(aiApps.code, keyword), like(aiApps.name, keyword)))
}
if (filters.status !== undefined && filters.status !== '') {
conditions.push(eq(aiApps.status, Number(filters.status)))
}
return conditions.length ? and(...conditions) : undefined
}
export const listApps = async (filters = {}, options = {}) => {
const page = resolvePage(options.page)
const limit = resolveLimit(options.limit)
const where = buildAppWhere(filters)
const [rows, totalRows] = await Promise.all([
db.select().from(aiApps).where(where).orderBy(desc(aiApps.updated_at)).limit(limit).offset((page - 1) * limit),
db.select({ total: count() }).from(aiApps).where(where),
])
const appIds = rows.map((row) => row.id)
if (!appIds.length) return { list: [], pagination: buildPagination(page, limit, totalRows[0]?.total || 0) }
const monthStart = dayjs().startOf('month').format('YYYY-MM-DD HH:mm:ss')
const [keyRows, usageRows] = await Promise.all([
db.select({ app_id: aiApiKeys.app_id, enabled_key_count: count() })
.from(aiApiKeys)
.where(and(inArray(aiApiKeys.app_id, appIds), eq(aiApiKeys.status, 1)))
.groupBy(aiApiKeys.app_id),
db.select({
app_id: aiUsage.app_id,
month_calls: count(),
month_cost: sql`COALESCE(SUM(${aiUsage.cost}), 0)`,
last_used_at: sql`MAX(${aiUsage.created_at})`,
})
.from(aiUsage)
.where(and(inArray(aiUsage.app_id, appIds), gte(aiUsage.created_at, monthStart)))
.groupBy(aiUsage.app_id),
])
const keysByApp = new Map(keyRows.map((row) => [row.app_id, Number(row.enabled_key_count)]))
const usageByApp = new Map(usageRows.map((row) => [row.app_id, row]))
const list = rows.map((row) => {
const usage = usageByApp.get(row.id)
return {
...row,
enabled_key_count: keysByApp.get(row.id) ?? 0,
month_calls: Number(usage?.month_calls ?? 0),
month_cost: String(usage?.month_cost ?? '0'),
last_used_at: usage?.last_used_at ?? null,
}
})
return { list, pagination: buildPagination(page, limit, totalRows[0]?.total || 0) }
}
export const createApp = async (payload = {}, operator = {}) => {
const code = String(payload.code ?? '').trim()
const name = String(payload.name ?? '').trim()
if (!code) throw httpError(400, '应用 code 不能为空')
if (!name) throw httpError(400, '应用名称不能为空')
if (payload.status !== undefined && ![APP_STATUSES.DISABLED, APP_STATUSES.ENABLED].includes(Number(payload.status))) {
throw httpError(400, '应用状态不正确')
}
try {
const [result] = await db.insert(aiApps).values(
cleanUndefined({
code,
name,
billing_mode: payload.billing_mode,
status: payload.status === undefined ? undefined : Number(payload.status),
...audit(operator),
})
)
return getAppById(Number(result.insertId))
} catch (error) {
if (isDuplicateEntry(error)) throw httpError(409, '应用 code 已存在')
throw error
}
}
export const updateApp = async (id, payload = {}, operator = {}) => {
const appId = Number(id)
const current = await getAppById(appId)
if (!current) throw httpError(404, '应用不存在')
const values = cleanUndefined({
code: payload.code === undefined ? undefined : String(payload.code).trim(),
name: payload.name === undefined ? undefined : String(payload.name).trim(),
billing_mode: payload.billing_mode,
status: payload.status === undefined ? undefined : Number(payload.status),
...auditUpdate(operator),
})
if (values.code === '') throw httpError(400, '应用 code 不能为空')
if (values.name === '') throw httpError(400, '应用名称不能为空')
if (Object.keys(values).length === 0) return current
try {
await db.update(aiApps).set(values).where(eq(aiApps.id, appId))
} catch (error) {
if (isDuplicateEntry(error)) throw httpError(409, '应用 code 已存在')
throw error
}
return getAppById(appId)
}
export const deleteApp = async (id) => {
const appId = Number(id)
const app = await getAppById(appId)
if (!app) throw httpError(404, '应用不存在')
const [keyRows] = await db.select({ total: count() }).from(aiApiKeys).where(eq(aiApiKeys.app_id, appId))
if (keyRows.total) throw httpError(400, '应用下仍有密钥,不能删除')
const [usageRows] = await db.select({ total: count() }).from(aiUsage).where(eq(aiUsage.app_id, appId))
if (usageRows.total) throw httpError(400, '应用已有请求记录,请停用而不要删除')
const [quotaRows] = await db.select({ total: count() }).from(aiQuotaPackages).where(eq(aiQuotaPackages.app_id, appId))
if (quotaRows.total) throw httpError(400, '应用已有额度记录,请停用而不要删除')
await db.delete(aiApps).where(eq(aiApps.id, appId))
return app
}
// ==================== 密钥 ====================
// 明文格式 sk-ezj- + 32 位 base64url 随机字符(24 字节经 base64url 恰好 32 字符,无补齐)
const generatePlainKey = () => {
const secret = randomBytes(24).toString('base64url')
const plaintext = `sk-ezj-${secret}`
return {
plaintext,
hash: createHash('sha256').update(plaintext).digest('hex'),
prefix: secret.slice(0, 8), // 去掉 sk-ezj- 后的前 8 位
}
}
// 列表/详情绝不返回 key_hash,只暴露 key_prefix
const safeKeyColumns = {
id: aiApiKeys.id,
app_id: aiApiKeys.app_id,
name: aiApiKeys.name,
key_prefix: aiApiKeys.key_prefix,
status: aiApiKeys.status,
last_used_at: aiApiKeys.last_used_at,
operator_user_id: aiApiKeys.operator_user_id,
operator_name: aiApiKeys.operator_name,
created_at: aiApiKeys.created_at,
updated_at: aiApiKeys.updated_at,
}
// 掩码展示串由后端拼好,前端直接显示
const withMaskedKey = (row) => (row ? { ...row, key_masked: `sk-ezj-${row.key_prefix}*****` } : null)
const getApiKeyById = async (id) => {
const rows = await db.select(safeKeyColumns).from(aiApiKeys).where(eq(aiApiKeys.id, Number(id))).limit(1)
return withMaskedKey(rows[0])
}
const buildKeyWhere = (filters = {}) => {
const conditions = []
if (filters.app_id !== undefined && filters.app_id !== '') {
conditions.push(eq(aiApiKeys.app_id, Number(filters.app_id)))
}
if (filters.status !== undefined && filters.status !== '') {
conditions.push(eq(aiApiKeys.status, Number(filters.status)))
}
return conditions.length ? and(...conditions) : undefined
}
export const listApiKeys = async (filters = {}, options = {}) => {
const page = resolvePage(options.page)
const limit = resolveLimit(options.limit)
const where = buildKeyWhere(filters)
const [rows, totalRows] = await Promise.all([
db.select(safeKeyColumns).from(aiApiKeys).where(where).orderBy(desc(aiApiKeys.id)).limit(limit).offset((page - 1) * limit),
db.select({ total: count() }).from(aiApiKeys).where(where),
])
return { list: rows.map(withMaskedKey), pagination: buildPagination(page, limit, totalRows[0]?.total || 0) }
}
export const createApiKey = async (payload = {}, operator = {}) => {
const appId = Number(payload.app_id)
if (!appId) throw httpError(400, 'app_id 不能为空')
const name = String(payload.name ?? '').trim()
if (!name) throw httpError(400, '密钥名称不能为空')
const app = await getAppById(appId)
if (!app) throw httpError(404, '应用不存在')
const { plaintext, hash, prefix } = generatePlainKey()
try {
const [result] = await db.insert(aiApiKeys).values(
cleanUndefined({
app_id: appId,
name,
key_hash: hash,
key_prefix: prefix,
status: payload.status === undefined ? undefined : Number(payload.status),
...audit(operator),
})
)
const row = await getApiKeyById(Number(result.insertId))
return { ...row, key: plaintext } // 明文只在创建响应出现一次
} catch (error) {
if (isDuplicateEntry(error)) throw httpError(409, '密钥生成冲突,请重试')
throw error
}
}
export const updateApiKey = async (id, payload = {}, operator = {}) => {
const keyId = Number(id)
const current = await getApiKeyById(keyId)
if (!current) throw httpError(404, '密钥不存在')
const values = cleanUndefined({
name: payload.name === undefined ? undefined : String(payload.name).trim(),
status: payload.status === undefined ? undefined : Number(payload.status),
...auditUpdate(operator),
})
if (values.name === '') throw httpError(400, '密钥名称不能为空')
if (Object.keys(values).length === 0) return current
await db.update(aiApiKeys).set(values).where(eq(aiApiKeys.id, keyId))
return getApiKeyById(keyId)
}
export const deleteApiKey = async (id) => {
const keyId = Number(id)
const key = await getApiKeyById(keyId)
if (!key) throw httpError(404, '密钥不存在')
const [usageRows] = await db.select({ total: count() }).from(aiUsage).where(eq(aiUsage.api_key_id, keyId))
if (usageRows.total) throw httpError(400, '密钥已有请求记录,请停用而不要删除')
await db.delete(aiApiKeys).where(eq(aiApiKeys.id, keyId))
return key
}
// ==================== 应用额度 ====================
const getQuotaPackageById = async (id) => {
const rows = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, Number(id))).limit(1)
return rows[0] || null
}
const assertQuotaDates = (startDate, endDate) => {
if (startDate > endDate) throw httpError(400, '额度结束日期不能早于开始日期')
}
const assertNoQuotaOverlap = async ({ appId, startDate, endDate, excludeId }, tx = db) => {
const conditions = [
eq(aiQuotaPackages.app_id, appId),
eq(aiQuotaPackages.status, QUOTA_PACKAGE_STATUSES.ENABLED),
lte(aiQuotaPackages.start_date, endDate),
gte(aiQuotaPackages.end_date, startDate),
]
if (excludeId) conditions.push(ne(aiQuotaPackages.id, excludeId))
const rows = await tx.select({ id: aiQuotaPackages.id }).from(aiQuotaPackages).where(and(...conditions)).limit(1)
if (rows.length) throw httpError(400, '该应用在此日期范围已有生效额度')
}
export const listQuotaPackages = async (filters = {}, options = {}) => {
const page = resolvePage(options.page)
const limit = resolveLimit(options.limit)
const conditions = []
if (filters.app_id) conditions.push(eq(aiQuotaPackages.app_id, Number(filters.app_id)))
if (filters.status !== undefined && filters.status !== '') conditions.push(eq(aiQuotaPackages.status, Number(filters.status)))
const where = conditions.length ? and(...conditions) : undefined
const [rows, [totalRow]] = await Promise.all([
db.select().from(aiQuotaPackages).where(where).orderBy(desc(aiQuotaPackages.id)).limit(limit).offset((page - 1) * limit),
db.select({ total: count() }).from(aiQuotaPackages).where(where),
])
return { list: rows, pagination: buildPagination(page, limit, Number(totalRow.total)) }
}
export const createQuotaPackage = async (payload = {}, operator = {}) => {
const appId = Number(payload.app_id)
const app = await getAppById(appId)
if (!app) throw httpError(404, '应用不存在')
if (app.billing_mode !== BILLING_MODES.QUOTA) throw httpError(400, '只有额度结算应用才能配置额度')
assertQuotaDates(payload.start_date, payload.end_date)
if (payload.status === undefined || Number(payload.status) === QUOTA_PACKAGE_STATUSES.ENABLED) {
await assertNoQuotaOverlap({ appId, startDate: payload.start_date, endDate: payload.end_date })
}
const [result] = await db.insert(aiQuotaPackages).values(cleanUndefined({
app_id: appId,
name: String(payload.name).trim(),
amount_micro: Number(payload.amount_micro),
start_date: payload.start_date,
end_date: payload.end_date,
status: payload.status === undefined ? undefined : Number(payload.status),
remark: payload.remark,
...audit(operator),
}))
return getQuotaPackageById(Number(result.insertId))
}
export const updateQuotaPackage = async (id, payload = {}, operator = {}) => {
const packageId = Number(id)
const current = await getQuotaPackageById(packageId)
if (!current) throw httpError(404, '额度不存在')
const startDate = payload.start_date ?? current.start_date
const endDate = payload.end_date ?? current.end_date
const status = payload.status === undefined ? current.status : Number(payload.status)
assertQuotaDates(startDate, endDate)
if (status === QUOTA_PACKAGE_STATUSES.ENABLED) {
await assertNoQuotaOverlap({ appId: current.app_id, startDate, endDate, excludeId: packageId })
}
const values = cleanUndefined({
name: payload.name === undefined ? undefined : String(payload.name).trim(),
amount_micro: payload.amount_micro === undefined ? undefined : Number(payload.amount_micro),
start_date: payload.start_date,
end_date: payload.end_date,
status: payload.status === undefined ? undefined : Number(payload.status),
remark: payload.remark,
...auditUpdate(operator),
})
if (values.amount_micro !== undefined && values.amount_micro < current.used_micro) {
throw httpError(400, '总额度不能小于已使用金额')
}
await db.update(aiQuotaPackages).set(values).where(eq(aiQuotaPackages.id, packageId))
return getQuotaPackageById(packageId)
}
export const deleteQuotaPackage = async (id) => {
const packageId = Number(id)
const current = await getQuotaPackageById(packageId)
if (!current) throw httpError(404, '额度不存在')
const [usageRows] = await db.select({ total: count() }).from(aiUsage).where(eq(aiUsage.quota_package_id, packageId))
if (usageRows.total) throw httpError(400, '额度已有使用记录,请停用而不要删除')
await db.delete(aiQuotaPackages).where(eq(aiQuotaPackages.id, packageId))
return current
}
// ==================== 模型管理(映射 + 版本化价格)====================
const modelBaseColumns = {
alias: aiModelMappings.alias,
name: aiModelMappings.name,
type: aiModelMappings.type,
provider: aiModelMappings.provider,
upstream_model: aiModelMappings.upstream_model,
enabled: aiModelMappings.enabled,
operator_user_id: aiModelMappings.operator_user_id,
operator_name: aiModelMappings.operator_name,
created_at: aiModelMappings.created_at,
updated_at: aiModelMappings.updated_at,
}
const priceColumns = {
version: aiModelPrices.version,
pricing_unit: aiModelPrices.pricing_unit,
input_price: aiModelPrices.input_price,
cached_input_price: aiModelPrices.cached_input_price,
output_price: aiModelPrices.output_price,
unit_price: aiModelPrices.unit_price,
currency: aiModelPrices.currency,
effective_from: aiModelPrices.effective_from,
effective_to: aiModelPrices.effective_to,
}
/**
* 当前生效价格(与网关取价同一份条件:status=1 且未失效中的最大版本),
* 避免管理面展示的价格和实际计费用的价格不一致。
*/
const getActivePrice = async (tx, alias) => {
const rows = await tx
.select(priceColumns)
.from(aiModelPrices)
.where(activePriceCondition(alias))
.orderBy(desc(aiModelPrices.version))
.limit(1)
return rows[0] ?? null
}
const withDetails = async (mapping, tx = db) => {
if (!mapping) return null
const price = await getActivePrice(tx, mapping.alias)
return { ...mapping, ...price, has_price: price !== null }
}
const buildModelWhere = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
conditions.push(or(like(aiModelMappings.alias, keyword), like(aiModelMappings.name, keyword), like(aiModelMappings.upstream_model, keyword)))
}
if (filters.type) conditions.push(eq(aiModelMappings.type, filters.type))
if (filters.provider) conditions.push(eq(aiModelMappings.provider, filters.provider))
return conditions.length ? and(...conditions) : undefined
}
const modelPriceValues = (payload) => cleanUndefined({
pricing_unit: payload.pricing_unit,
input_price: payload.input_price === undefined ? undefined : String(payload.input_price),
cached_input_price: payload.cached_input_price === undefined ? undefined : String(payload.cached_input_price),
output_price: payload.output_price === undefined ? undefined : String(payload.output_price),
unit_price: payload.unit_price === undefined ? undefined : String(payload.unit_price),
currency: payload.currency === undefined ? undefined : String(payload.currency).toUpperCase(),
})
const validateProviderType = (provider, type) => {
const operation = { text: 'chat', image: 'image', video: 'createVideo' }[type]
providerFor(provider, operation)
}
const PRICE_FIELDS = ['pricing_unit', 'input_price', 'cached_input_price', 'output_price', 'unit_price', 'currency']
/**
* 价格是否真有改动(决定要不要开新版本)。
* 不能只看「客户端有没有传字段」——前端每次保存都会带上 pricing_unit / currency,
* 那样每次改名都会作废旧价、插一条内容相同的新版本,改价时间点也无法追溯。
*/
const priceChanged = (next, active) => {
if (!active) return true
return PRICE_FIELDS.some((key) =>
// 金额列从 mysql2 回来是 '1.000000' 这样的字符串,按数值比较;其余按字符串
key === 'pricing_unit' || key === 'currency'
? String(next[key]) !== String(active[key])
: Number(next[key]) !== Number(active[key]),
)
}
export const listModels = async (filters = {}, options = {}) => {
const page = resolvePage(options.page)
const limit = resolveLimit(options.limit)
const where = buildModelWhere(filters)
const [rows, [totalRow]] = await Promise.all([
db.select(modelBaseColumns).from(aiModelMappings).where(where)
.orderBy(aiModelMappings.alias).limit(limit).offset((page - 1) * limit),
db.select({ total: count() }).from(aiModelMappings).where(where),
])
// 一次取回本页所有别名的生效价格,避免逐行查询(一页 100 行原来要 101 次查询)
const aliases = rows.map((row) => row.alias)
const priceRows = aliases.length
? await db
.select({ ...priceColumns, alias: aiModelPrices.alias })
.from(aiModelPrices)
.where(and(
inArray(aiModelPrices.alias, aliases),
eq(aiModelPrices.status, PRICE_STATUSES.ACTIVE),
sql`(${aiModelPrices.effective_to} IS NULL OR ${aiModelPrices.effective_to} > NOW())`,
))
.orderBy(aiModelPrices.version)
: []
// 每个别名取版本号最大的那条
const priceByAlias = new Map()
for (const price of priceRows) {
const current = priceByAlias.get(price.alias)
if (!current || price.version > current.version) priceByAlias.set(price.alias, price)
}
const list = rows.map((row) => {
const price = priceByAlias.get(row.alias) ?? null
return { ...row, ...price, has_price: price !== null }
})
return { list, pagination: buildPagination(page, limit, Number(totalRow.total)) }
}
export const getModelByAlias = async (alias) => {
const rows = await db.select(modelBaseColumns).from(aiModelMappings).where(eq(aiModelMappings.alias, alias)).limit(1)
return withDetails(rows[0] ?? null)
}
export const createModel = async (payload = {}, operator = {}) => {
const alias = String(payload.alias ?? '').trim()
if (!alias) throw httpError(400, '模型别名不能为空')
const name = String(payload.name ?? '').trim()
if (!name) throw httpError(400, '模型名称不能为空')
validateProviderType(payload.provider, payload.type)
try {
await db.transaction(async (tx) => {
await tx.insert(aiModelMappings).values(cleanUndefined({
alias,
name,
type: payload.type,
provider: payload.provider,
upstream_model: String(payload.upstream_model ?? '').trim(),
enabled: payload.enabled === undefined ? undefined : Number(payload.enabled),
...audit(operator),
}))
await tx.insert(aiModelPrices).values({
alias,
version: 1,
currency: 'CNY',
status: PRICE_STATUSES.ACTIVE,
...modelPriceValues(payload),
...audit(operator),
})
})
} catch (error) {
if (isDuplicateEntry(error)) throw httpError(409, `模型别名已存在:${alias}`)
throw error
}
invalidateModelCache(alias)
return getModelByAlias(alias)
}
export const updateModel = async (alias, payload = {}, operator = {}) => {
const current = await getModelByAlias(alias)
if (!current) throw httpError(404, '模型不存在')
if (payload.name !== undefined && !String(payload.name).trim()) throw httpError(400, '模型名称不能为空')
validateProviderType(payload.provider ?? current.provider, payload.type ?? current.type)
await db.transaction(async (tx) => {
await tx.update(aiModelMappings).set(cleanUndefined({
name: payload.name === undefined ? undefined : String(payload.name).trim(),
type: payload.type,
provider: payload.provider,
upstream_model: payload.upstream_model === undefined ? undefined : String(payload.upstream_model).trim(),
enabled: payload.enabled === undefined ? undefined : Number(payload.enabled),
...auditUpdate(operator),
})).where(eq(aiModelMappings.alias, alias))
const active = await getActivePrice(tx, alias)
// 旧版本没填的字段用本次提交的补齐,改价只开新版本、不动历史
const nextPrice = {
pricing_unit: payload.pricing_unit ?? active?.pricing_unit,
input_price: payload.input_price ?? active?.input_price,
cached_input_price: payload.cached_input_price ?? active?.cached_input_price,
output_price: payload.output_price ?? active?.output_price,
unit_price: payload.unit_price ?? active?.unit_price,
currency: payload.currency ?? active?.currency ?? 'CNY',
}
// pricing_unit 是 NOT NULL 且无默认值:本次没提交价格、也没有生效版本时不碰价格表
if (nextPrice.pricing_unit !== undefined && priceChanged(nextPrice, active)) {
if (active) {
await tx.update(aiModelPrices)
.set({ status: PRICE_STATUSES.DISABLED, effective_to: sql`NOW()` })
.where(and(eq(aiModelPrices.alias, alias), eq(aiModelPrices.version, active.version)))
}
const [maxRow] = await tx.select({ max: sql`COALESCE(MAX(${aiModelPrices.version}), 0)` }).from(aiModelPrices).where(eq(aiModelPrices.alias, alias))
await tx.insert(aiModelPrices).values({
alias,
version: Number(maxRow.max) + 1,
status: PRICE_STATUSES.ACTIVE,
...modelPriceValues(nextPrice),
...auditUpdate(operator),
})
}
})
invalidateModelCache(alias)
return getModelByAlias(alias)
}
export const deleteModel = async (alias) => {
const model = await getModelByAlias(alias)
if (!model) throw httpError(404, '模型不存在')
const [usageRows] = await db.select({ total: count() }).from(aiUsage).where(eq(aiUsage.model, alias))
if (usageRows.total) throw httpError(400, '模型已有请求记录,请停用而不要删除')
await db.transaction(async (tx) => {
await tx.delete(aiModelMappings).where(eq(aiModelMappings.alias, alias))
await tx.delete(aiModelPrices).where(eq(aiModelPrices.alias, alias))
})
invalidateModelCache(alias)
return model
}
import { isAborted } from '#src/lib/errors.js'
import { USAGE_ERROR_CODES, USAGE_SOURCES, USAGE_STATUSES } from '#src/db/schema/ai/ai-usage.js'
import { microToCost } from './pricing.service.js'
import { chargeQuotaPackage, resolveQuotaPackage } from './quota.service.js'
import { buildUsageRecord, recordUsage } from './usage.service.js'
import { db } from '#src/db/client.js'
/** 检查软限额 → 转发 → 按真实用量计费。缺失用量只标记待核对,不估算成本。 */
export const runCall = async ({
request, mapping, model, price, type, unit,
execute, usageOf, settleCostMicro, quantityOf, onError, stream = false,
}) => {
const quotaPackage = await resolveQuotaPackage(request.ai)
const record = (values) => recordUsage(buildUsageRecord({
request, mapping, model, price, type, unit, quota_package_id: quotaPackage?.id ?? null,
quantity: 0, prompt_tokens: 0, completion_tokens: 0, cached_tokens: 0, reasoning_tokens: 0,
cost: '0.000000', stream: stream ? 1 : 0,
...values,
}))
let result
try {
result = await execute()
} catch (error) {
await record({
status: isAborted(error) ? USAGE_STATUSES.ABORTED : USAGE_STATUSES.ERROR,
usage_source: USAGE_SOURCES.MISSING,
error_code: error?.code ?? USAGE_ERROR_CODES.USAGE_MISSING,
})
throw error
}
const metrics = { http_status: result.status, latency_ms: result.latencyMs, first_token_ms: result.firstTokenMs }
if (result.ok === false || result.status < 200 || result.status >= 300) {
await record({ ...metrics, status: USAGE_STATUSES.ERROR, error_code: onError?.(result)?.code ?? null })
return result
}
const usage = usageOf?.(result)
if (!usage) {
await record({
...metrics, status: USAGE_STATUSES.SUCCESS,
usage_source: USAGE_SOURCES.MISSING, error_code: USAGE_ERROR_CODES.USAGE_MISSING,
})
return result
}
const costMicro = settleCostMicro(result, usage)
await db.transaction(async (tx) => {
await chargeQuotaPackage(quotaPackage?.id, costMicro, { tx })
await recordUsage(buildUsageRecord({
request, mapping, model, price, type, unit, quota_package_id: quotaPackage?.id ?? null,
...metrics, status: USAGE_STATUSES.SUCCESS, usage_source: USAGE_SOURCES.REPORTED,
quantity: quantityOf?.(result, usage) ?? 0,
prompt_tokens: usage.prompt_tokens ?? 0,
completion_tokens: usage.completion_tokens ?? 0,
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
reasoning_tokens: usage.completion_tokens_details?.reasoning_tokens ?? 0,
cost: microToCost(costMicro), stream: stream ? 1 : 0,
}), { tx, throwOnError: true })
})
return result
}
export const upstreamErrorCode = (result) => result?.data?.error?.code ?? null
const TYPES = {
400: 'invalid_request_error',
401: 'authentication_error',
403: 'permission_error',
404: 'not_found_error',
429: 'rate_limit_error',
}
const gatewayError = ({ code, type, message, requestId }) => ({
error: {
source: 'gateway',
code,
type,
message,
request_id: requestId ?? null,
},
})
export const normalizeGatewayException = ({ error, requestId, validation = false }) => {
const status = Number(error.statusCode ?? error.status) || 500
return gatewayError({
code: error.code ?? (validation ? 'invalid_request' : status === 401 ? 'invalid_api_key' : status === 403 ? 'permission_denied' : status === 404 ? 'not_found' : status === 429 ? 'rate_limit_exceeded' : 'gateway_error'),
type: TYPES[status] ?? 'api_error',
message: error.message,
requestId,
})
}
import { httpError } from '#src/lib/http-error.js'
import { codedError } from '#src/lib/errors.js'
import { StringDecoder } from 'node:string_decoder'
import { createUsageParser } from './usage-parser.js'
const STREAM_IDLE_TIMEOUT_CODE = 'stream_idle_timeout'
/**
* 流式空闲超时:上游多久没吐数据就判死。
* 注意不能用 AbortSignal.timeout 做流式总时限——它是从创建那一刻起的绝对超时,
* 不会因为一直在正常收数据而重置,会把超过时限的健康长回答整条掐断。
*/
const createIdleAbort = (timeoutMs) => {
const controller = new AbortController()
let timer = null
const reset = () => {
clearTimeout(timer)
timer = setTimeout(() => {
controller.abort(codedError(504, '上游流式响应超时', STREAM_IDLE_TIMEOUT_CODE))
}, timeoutMs)
timer.unref?.()
}
return {
signal: controller.signal,
reset,
clear: () => clearTimeout(timer),
}
}
const writeChunk = async (raw, chunk, signal) => {
if (raw.write(chunk)) return
await new Promise((resolve, reject) => {
const cleanup = () => {
raw.off('drain', onDrain)
raw.off('close', onClose)
signal?.removeEventListener('abort', onAbort)
}
const onDrain = () => {
cleanup()
resolve()
}
const onClose = () => {
cleanup()
reject(signal?.reason ?? new Error('客户端连接已关闭'))
}
const onAbort = () => {
cleanup()
reject(signal.reason)
}
raw.once('drain', onDrain)
raw.once('close', onClose)
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/** SSE 结构保持不变,只把每个 JSON 事件里的模型改回公共模型 ID。 */
const createModelRewriter = (model) => {
const decoder = new StringDecoder('utf8')
let buffer = ''
const rewriteLine = (line) => {
if (!line.startsWith('data:')) return line
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') return line
try {
return `data: ${JSON.stringify({ ...JSON.parse(payload), model })}`
} catch {
return line
}
}
return {
write(chunk) {
buffer += decoder.write(chunk)
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
return lines.map(rewriteLine).join('\n') + (lines.length ? '\n' : '')
},
end() {
buffer += decoder.end()
const output = buffer ? rewriteLine(buffer) : ''
buffer = ''
return output
},
}
}
/**
* 转发请求到上游。
* POST 时把 model 替换为上游真实模型,其余原样透传;GET 不带 body。
*/
const providerError = ({ mapping, data, status, requestId, providerRequestId }) => {
if (data?.error?.source === 'provider') return data
const original = data?.error ?? data
return {
error: {
source: 'provider',
provider: mapping.provider,
code: original?.code ?? 'provider_error',
type: original?.type ?? 'provider_error',
message: original?.message ?? `供应商请求失败(HTTP ${status})`,
request_id: requestId ?? null,
provider_request_id: providerRequestId ?? null,
details: data,
},
}
}
export const normalizeProviderTaskError = ({ mapping, error, requestId }) => providerError({
mapping,
data: { error },
status: 200,
requestId,
}).error
export const forwardJson = async ({ method = 'POST', path, mapping, body, signal, requestId }) => {
const provider = mapping.account
const apiKey = provider.getApiKey()
const startedAt = Date.now()
if (!apiKey) {
return {
ok: false,
status: 503,
data: { error: { source: 'gateway', code: 'upstream_not_configured', type: 'configuration_error', message: `上游账号未配置密钥:${mapping.provider}`, request_id: requestId ?? null } },
latencyMs: 0,
}
}
let response, text
try {
response = await fetch(`${provider.getBaseUrl()}${path}`, {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
},
body: method === 'GET' ? undefined : JSON.stringify({ ...body, model: mapping.upstreamModel }),
signal,
})
// 收到响应头不代表请求完成,读响应体也可能断连或超时。
text = await response.text()
} catch (error) {
const timedOut = error?.name === 'TimeoutError'
return {
ok: false,
status: timedOut ? 504 : 502,
data: {
error: {
code: timedOut ? 'upstream_timeout' : 'upstream_unavailable',
source: 'provider',
provider: mapping.provider,
message: timedOut ? '上游服务响应超时' : '上游服务暂时不可用',
request_id: requestId ?? null,
provider_request_id: null,
details: null,
},
},
latencyMs: Date.now() - startedAt,
}
}
const latencyMs = Date.now() - startedAt
let data
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || '上游返回非 JSON' } }
}
if (!response.ok) {
data = providerError({
mapping,
data,
status: response.status,
requestId,
providerRequestId: response.headers.get('x-request-id') ?? response.headers.get('x-tt-logid'),
})
}
return { ok: response.ok, status: response.status, data, latencyMs, usage: data?.usage ?? null }
}
/**
* 转发文本补全请求(流式)。
* 透传 SSE 分片的同时旁路解析 usage,并记录首 token 延迟。
* 上游报错时不接管响应,交回调用方按普通 JSON 处理。
*
* 超时语义:
* - idleTimeoutMs 是「两条 chunk 之间的最大间隔」,每收到一片就重置,
* 所以长回答不会被总时长误杀;
* - 连接本身沿用 AbortSignal.timeout 兜住「上游一直不回响应头」的情况。
*/
export const streamChatCompletion = async ({ mapping, path, body, reply, signal, requestId, idleTimeoutMs = 120_000 }) => {
const provider = mapping.account
const apiKey = provider.getApiKey()
if (!apiKey) throw httpError(503, `上游账号未配置密钥:${mapping.provider}`)
const startedAt = Date.now()
const idle = createIdleAbort(idleTimeoutMs)
const upstreamSignal = signal ? AbortSignal.any([signal, idle.signal]) : idle.signal
const upstreamBody = {
...body,
model: mapping.upstreamModel,
// 让上游在流末尾带上 usage;这是计费依据,必须由网关强制打开,
// 不能被调用方的 stream_options 覆盖(否则不返回 usage = 不计费)
stream_options: { ...(body.stream_options ?? {}), include_usage: true },
}
let response
try {
idle.reset()
response = await fetch(`${provider.getBaseUrl()}${path}`, {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(upstreamBody),
signal: upstreamSignal,
})
} catch (error) {
idle.clear()
// 空闲超时要把原因透出去(落库 error_code),其余交给调用方按中断处理
throw idle.signal.aborted ? idle.signal.reason : error
}
if (!response.ok || !response.body) {
idle.clear()
const text = await response.text()
let data
try {
data = JSON.parse(text)
} catch {
data = { error: { message: text || '上游返回非 JSON' } }
}
return {
ok: false,
status: response.status,
data: providerError({
mapping,
data,
status: response.status,
requestId,
providerRequestId: response.headers.get('x-request-id') ?? response.headers.get('x-tt-logid'),
}),
latencyMs: Date.now() - startedAt,
}
}
// 接管响应,Fastify 不再插手
reply.hijack()
reply.raw.writeHead(response.status, {
'content-type': response.headers.get('content-type') || 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-request-id': reply.request.id,
})
const parser = createUsageParser()
const modelRewriter = createModelRewriter(mapping.alias)
let firstTokenMs = null
const iterator = response.body[Symbol.asyncIterator]()
try {
while (true) {
idle.reset()
let onAbort
const next = await Promise.race([
iterator.next(),
new Promise((_, reject) => {
onAbort = () => reject(upstreamSignal.reason)
upstreamSignal.addEventListener('abort', onAbort, { once: true })
if (upstreamSignal.aborted) onAbort()
}),
]).finally(() => upstreamSignal.removeEventListener('abort', onAbort))
if (next.done) break
const chunk = next.value
idle.reset()
const output = modelRewriter.write(chunk)
if (!output) continue
parser.feed(output)
if (firstTokenMs === null && parser.hasContent()) firstTokenMs = Date.now() - startedAt
await writeChunk(reply.raw, output, upstreamSignal)
}
} catch (error) {
// 已接管响应,异常时也必须关闭客户端连接,不能交给 Fastify send。
reply.raw.destroy()
void iterator.return?.().catch(() => {})
throw idle.signal.aborted ? idle.signal.reason : error
} finally {
idle.clear()
}
const tail = modelRewriter.end()
if (tail) {
parser.feed(tail)
await writeChunk(reply.raw, tail, upstreamSignal)
}
reply.raw.end()
return {
ok: true,
status: response.status,
latencyMs: Date.now() - startedAt,
firstTokenMs,
// 统一把上游上报的 usage 放在顶层,调用方不必区分流式 / 非流式
usage: parser.result(),
}
}
import { and, eq } from 'drizzle-orm'
import { LRUCache } from 'lru-cache'
import { db } from '#src/db/client.js'
import { aiModelMappings } from '#src/db/schema/ai/model-mappings.js'
import { httpError } from '#src/lib/http-error.js'
import { accountFor } from './upstream.js'
import { getPrice, invalidatePriceCache } from './pricing.service.js'
const cache = new LRUCache({ max: 500, ttl: 30_000 })
const typeLabels = { text: '文本', image: '图片', video: '视频' }
const pricingUnitLabels = {
per_1m_tokens: '按百万 Token 计价',
per_image: '按张计价',
per_second: '按秒计价',
}
export const resolveModel = async (alias) => {
let mapping = cache.get(alias)
if (mapping === undefined) {
const rows = await db.select().from(aiModelMappings).where(eq(aiModelMappings.alias, alias)).limit(1)
mapping = rows[0] ?? null
cache.set(alias, mapping)
}
if (!mapping) throw httpError(404, `模型不存在:${alias}`)
if (!mapping.enabled) throw httpError(400, `模型已下线:${alias}`)
return {
alias,
type: mapping.type,
provider: mapping.provider,
upstreamModel: mapping.upstream_model,
account: accountFor(mapping.provider),
}
}
export const resolveBillableModel = async (alias, { type, pricingUnit }) => {
const mapping = await resolveModel(alias)
if (mapping.type !== type) {
throw httpError(400, `模型 ${alias} 不是${typeLabels[type]}模型(${mapping.type})`)
}
const price = await getPrice(alias)
if (!price) throw httpError(503, `模型未配置价格:${alias}`)
if (price.pricing_unit !== pricingUnit) {
throw httpError(500, `模型价格配置错误:${alias} 应为${pricingUnitLabels[pricingUnit]}`)
}
return { mapping, price }
}
export const listEnabledModels = async (type) => {
const filters = [eq(aiModelMappings.enabled, 1)]
if (type) filters.push(eq(aiModelMappings.type, type))
return db
.select()
.from(aiModelMappings)
.where(and(...filters))
}
export const invalidateModelCache = (alias) => {
cache.delete(alias)
invalidatePriceCache(alias)
}
import { and, desc, eq, sql } from 'drizzle-orm'
import { LRUCache } from 'lru-cache'
import { db } from '#src/db/client.js'
import { aiModelPrices } from '#src/db/schema/ai/model-prices.js'
const cache = new LRUCache({ max: 500, ttl: 60_000 })
/**
* 「当前生效的价格」判定条件,管理面与数据面必须用同一份。
* 生效 = status=1 且(未设失效时间或失效时间还没到)。
*/
export const activePriceCondition = (alias) => and(
eq(aiModelPrices.alias, alias),
eq(aiModelPrices.status, 1),
sql`(${aiModelPrices.effective_to} IS NULL OR ${aiModelPrices.effective_to} > NOW())`,
)
/**
* 取某别名「当前生效」的价格版本:
* status=1 且 effective_to IS NULL(或未过期)中 version 最大的
*
* 暂不做分辨率档位:每个别名一个价(2026-09-10 决定)。
*/
export const getPrice = async (alias) => {
let price = cache.get(alias)
if (price === undefined) {
const rows = await db
.select()
.from(aiModelPrices)
.where(activePriceCondition(alias))
.orderBy(desc(aiModelPrices.version))
.limit(1)
price = rows[0] ?? null
cache.set(alias, price)
}
return price
}
/** 异步任务结算时按提交时记录的版本取价,避免任务期间改价影响旧任务。 */
export const getPriceByVersion = async (alias, version) => {
const key = `${alias}:${version}`
let price = cache.get(key)
if (price === undefined) {
const rows = await db
.select()
.from(aiModelPrices)
.where(and(eq(aiModelPrices.alias, alias), eq(aiModelPrices.version, version)))
.limit(1)
price = rows[0] ?? null
cache.set(key, price)
}
return price
}
/** 改价 / 停用后清掉缓存,让下一次取价立刻生效 */
export const invalidatePriceCache = (alias) => cache.delete(alias)
/**
* 文本成本(微元,1 元 = 1e6 微元)。
* 单价是「元 / 1M tokens」,所以每个 token 的微元数正好等于单价本身:
* cost(元) = tokens / 1e6 * price → cost(微元) = tokens * price
*
* 缓存命中的输入 token 单价更低;未配置 cached_input_price(为 0)时按 input_price 计。
*/
export const textCostMicro = (price, promptTokens = 0, completionTokens = 0, cachedTokens = 0) => {
if (!price) return 0
const cached = Math.min(Math.max(cachedTokens, 0), promptTokens)
const uncached = promptTokens - cached
const cachedPrice = Number(price.cached_input_price) || Number(price.input_price)
return Math.round(
uncached * Number(price.input_price) + cached * cachedPrice + completionTokens * Number(price.output_price),
)
}
/** 按张 / 按秒的成本(微元) */
export const unitCostMicro = (price, quantity = 0) => {
if (!price) return 0
return Math.round(quantity * Number(price.unit_price) * 1_000_000)
}
/** 微元 -> 入库用的 decimal 字符串 */
export const microToCost = (micro) => (micro / 1_000_000).toFixed(6)
import { forwardJson, streamChatCompletion } from '../gateway.service.js'
export const deepseekProvider = {
chat: (options) => forwardJson({ ...options, path: '/chat/completions' }),
streamChat: (options) => streamChatCompletion({ ...options, path: '/chat/completions' }),
}
import { httpError } from '#src/lib/http-error.js'
import { deepseekProvider } from './deepseek.js'
import { volcanoProvider } from './volcano.js'
const providers = {
volcano: volcanoProvider,
deepseek: deepseekProvider,
}
export const providerFor = (name, operation) => {
const provider = providers[name]
if (!provider) throw httpError(500, `未实现供应商适配器:${name}`)
if (operation && !provider[operation]) throw httpError(500, `供应商 ${name} 不支持 ${operation}`)
return provider
}
export const providerNames = Object.keys(providers)
import { forwardJson, streamChatCompletion } from '../gateway.service.js'
const videoBody = ({ model, prompt, image, duration, aspect_ratio, resolution, fps, watermark }) => {
const content = [{ type: 'text', text: prompt }]
if (image) content.push({ type: 'image_url', image_url: { url: image }, role: 'first_frame' })
return {
model,
content,
...(duration === undefined ? {} : { duration }),
...(aspect_ratio === undefined ? {} : { ratio: aspect_ratio }),
...(resolution === undefined ? {} : { resolution }),
...(fps === undefined ? {} : { framespersecond: fps }),
...(watermark === undefined ? {} : { watermark }),
}
}
export const volcanoProvider = {
chat: (options) => forwardJson({ ...options, path: '/chat/completions' }),
streamChat: (options) => streamChatCompletion({ ...options, path: '/chat/completions' }),
image: (options) => forwardJson({ ...options, path: '/images/generations' }),
createVideo: (options) => forwardJson({
...options,
path: '/contents/generations/tasks',
body: videoBody(options.body),
}),
getVideo: ({ taskId, ...options }) => forwardJson({
...options,
method: 'GET',
path: `/contents/generations/tasks/${encodeURIComponent(taskId)}`,
}),
normalizeVideo(data) {
return {
status: data?.status,
duration: data?.usage?.duration ?? data?.duration ?? null,
url: data?.content?.video_url ?? data?.video?.url ?? data?.video_url ?? null,
expiresAt: data?.expires_at ?? null,
error: data?.error ?? null,
completionTokens: data?.usage?.completion_tokens ?? 0,
}
},
}
import dayjs from 'dayjs'
import { and, eq, gte, lte, sql } from 'drizzle-orm'
import { db } from '#src/db/client.js'
import { BILLING_MODES } from '#src/db/schema/ai/apps.js'
import { aiQuotaPackages, QUOTA_PACKAGE_STATUSES } from '#src/db/schema/ai/quota-packages.js'
import { httpError } from '#src/lib/http-error.js'
/** 内部应用不限额;额度应用必须有一份当前有效的额度包。 */
export const resolveQuotaPackage = async ({ appId, billingMode }, tx = db) => {
if (billingMode === BILLING_MODES.INTERNAL) return null
const today = dayjs().format('YYYY-MM-DD')
const [quotaPackage] = await tx.select().from(aiQuotaPackages).where(and(
eq(aiQuotaPackages.app_id, appId),
eq(aiQuotaPackages.status, QUOTA_PACKAGE_STATUSES.ENABLED),
lte(aiQuotaPackages.start_date, today),
gte(aiQuotaPackages.end_date, today),
)).limit(1)
if (!quotaPackage) throw httpError(429, '当前应用没有生效中的额度')
if (Number(quotaPackage.used_micro) >= Number(quotaPackage.amount_micro)) {
throw httpError(429, '当前应用额度已用完')
}
return quotaPackage
}
/** 按供应商真实用量结算;内部应用或缺失真实用量时不扣额度。 */
export const chargeQuotaPackage = async (quotaPackageId, costMicro, { tx = db } = {}) => {
if (!quotaPackageId || costMicro <= 0) return
await tx.update(aiQuotaPackages)
.set({ used_micro: sql`${aiQuotaPackages.used_micro} + ${costMicro}` })
.where(eq(aiQuotaPackages.id, quotaPackageId))
}
import config from '#src/config.js'
/**
* AI 数据面限流:按 API key 计数(一个 key 共享一份配额)。
* 鉴权钩子在 onRequest,限流挂在 preHandler,所以这里能拿到 request.ai。
* 写接口(chat / images / videos)和读接口(models / usage / quota)共用同一份额度。
*/
export const aiRateLimitOptions = {
max: config.ai.rateLimitPerMinute,
timeWindow: '1 minute',
keyGenerator: (request) => `ai:${request.ai?.keyId ?? request.ip}`,
}
import dayjs from 'dayjs'
import { and, count, desc, eq, getTableColumns, gte, inArray, lt, sql } from 'drizzle-orm'
import { db } from '#src/db/client.js'
import { aiUsage } from '#src/db/schema/ai/ai-usage.js'
import { aiApps } from '#src/db/schema/ai/apps.js'
import { aiApiKeys } from '#src/db/schema/ai/api-keys.js'
// 标签列必须用聚合函数包一层:MySQL 的 ONLY_FULL_GROUP_BY 不允许 select 未参与分组的列
const GROUP_SPECS = {
app: { expr: aiUsage.app_id, label: sql`MAX(${aiApps.code})`, join: 'app' },
api_key: { expr: aiUsage.api_key_id, label: sql`MAX(${aiApiKeys.name})`, join: 'key' },
user: { expr: aiUsage.user_id, label: sql`MAX(${aiUsage.user_name})` },
model: { expr: aiUsage.model },
provider: { expr: aiUsage.provider },
type: { expr: aiUsage.type },
usage_source: { expr: aiUsage.usage_source },
status: { expr: aiUsage.status },
day: { expr: sql`DATE(${aiUsage.created_at})` },
}
const buildWhere = ({ from, to, appId, apiKeyId, userId, type, usageSource, billingStatus, status, model, requestId }) => {
const conditions = []
if (from) conditions.push(gte(aiUsage.created_at, from))
if (to) conditions.push(lt(aiUsage.created_at, dayjs(to).add(1, 'day').format('YYYY-MM-DD')))
if (appId) conditions.push(eq(aiUsage.app_id, appId))
if (apiKeyId) conditions.push(eq(aiUsage.api_key_id, apiKeyId))
if (userId) conditions.push(eq(aiUsage.user_id, userId))
if (type) conditions.push(eq(aiUsage.type, type))
if (usageSource) conditions.push(eq(aiUsage.usage_source, usageSource))
if (billingStatus === 'pending') conditions.push(eq(aiUsage.status, 'pending'))
if (billingStatus === 'charged') conditions.push(and(eq(aiUsage.status, 'success'), eq(aiUsage.usage_source, 'reported')))
if (billingStatus === 'not_charged') conditions.push(eq(aiUsage.status, 'error'))
if (billingStatus === 'reconcile') conditions.push(and(inArray(aiUsage.status, ['success', 'aborted']), eq(aiUsage.usage_source, 'missing')))
if (status) conditions.push(eq(aiUsage.status, status))
if (model) conditions.push(eq(aiUsage.model, model))
if (requestId) conditions.push(eq(aiUsage.request_id, requestId))
return conditions.length ? and(...conditions) : undefined
}
const AGGREGATES = {
calls: count(),
quantity: sql`COALESCE(SUM(${aiUsage.quantity}), 0)`,
token_quantity: sql`COALESCE(SUM(CASE WHEN ${aiUsage.unit} = 'token' THEN ${aiUsage.quantity} ELSE 0 END), 0)`,
image_quantity: sql`COALESCE(SUM(CASE WHEN ${aiUsage.unit} = 'image' THEN ${aiUsage.quantity} ELSE 0 END), 0)`,
second_quantity: sql`COALESCE(SUM(CASE WHEN ${aiUsage.unit} = 'second' THEN ${aiUsage.quantity} ELSE 0 END), 0)`,
prompt_tokens: sql`COALESCE(SUM(${aiUsage.prompt_tokens}), 0)`,
completion_tokens: sql`COALESCE(SUM(${aiUsage.completion_tokens}), 0)`,
cost: sql`COALESCE(SUM(${aiUsage.cost}), 0)`,
}
/** 不传时间范围时默认看最近这么多天——防止一次请求对 ai_usage 全表做聚合 */
export const DEFAULT_RANGE_DAYS = 30
/** 补默认时间窗:任一端缺失都补上,避免退化成全表扫描 */
export const withDefaultRange = (filters, now = new Date()) => {
const { from, to } = filters
if (from && to) return filters
const toDate = to ?? dayjs(now).format('YYYY-MM-DD')
const fromDate = from ?? dayjs(toDate).subtract(DEFAULT_RANGE_DAYS - 1, 'day').format('YYYY-MM-DD')
return { ...filters, from: fromDate, to: toDate }
}
/** 按维度聚合用量 */
export const summarize = async (filters) => {
const spec = GROUP_SPECS[filters.groupBy]
const where = buildWhere(withDefaultRange(filters))
let query = db
.select({ key: spec.expr, label: spec.label ?? spec.expr, ...AGGREGATES })
.from(aiUsage)
if (spec.join === 'app') query = query.leftJoin(aiApps, eq(aiUsage.app_id, aiApps.id))
if (spec.join === 'key') query = query.leftJoin(aiApiKeys, eq(aiUsage.api_key_id, aiApiKeys.id))
const rows = await query
.where(where)
.groupBy(spec.expr)
.orderBy(desc(sql`SUM(${aiUsage.cost})`))
const [total] = await db.select(AGGREGATES).from(aiUsage).where(where)
// 按计费单位拆分总量:token / image / second 无法直接相加,前端要分开展示
const units = await db
.select({ unit: aiUsage.unit, quantity: sql`COALESCE(SUM(${aiUsage.quantity}), 0)` })
.from(aiUsage)
.where(where)
.groupBy(aiUsage.unit)
return { rows, total, units }
}
/** 最近调用明细 */
export const listRecent = async (filters, { page = 1, limit = 50 } = {}) => {
const where = buildWhere(withDefaultRange(filters))
const [list, [totalRow]] = await Promise.all([
db
.select({
...getTableColumns(aiUsage),
app_name: aiApps.name,
app_code: aiApps.code,
})
.from(aiUsage)
.leftJoin(aiApps, eq(aiUsage.app_id, aiApps.id))
.where(where)
.orderBy(desc(aiUsage.id))
.limit(limit)
.offset((page - 1) * limit),
db.select({ value: count() }).from(aiUsage).where(where),
])
return {
list,
pagination: { page, limit, total: Number(totalRow.value), pages: Math.ceil(Number(totalRow.value) / limit) },
}
}
import config from '#src/config.js'
/**
* 按 provider 解析上游配置(v1 简化:一个 provider 一个 key,读环境变量)。
* baseUrl / apiKey 均来自 config(config 本身读 env);
* baseUrl 额外支持 `${PROVIDER}_BASE_URL` 环境变量覆盖(测试 mock / 临时切流)。
*/
export const accountFor = (provider) => {
const cfg = config.ai.providers[provider]
if (!cfg) throw new Error(`未知上游:${provider}`)
return {
provider,
getBaseUrl: () => process.env[`${provider.toUpperCase()}_BASE_URL`] || cfg.baseUrl,
getApiKey: () => cfg.apiKey,
}
}
import { StringDecoder } from 'node:string_decoder'
/**
* 从 OpenAI 兼容的 SSE 流里提取 usage(纯函数,可离线单测)。
*
* 上游需要 `stream_options: { include_usage: true }` 才会在最后一条
* chunk(choices 为空)里带 usage。
*
* 注意:分片可能把一行 JSON 从中间切开,也可能把多字节汉字切成两半,
* 所以用 StringDecoder 解码并保留未完成的行。
*/
export const createUsageParser = () => {
const decoder = new StringDecoder('utf8')
let buffer = ''
let usage = null
let sawContent = false
const handleLine = (line) => {
if (!line.startsWith('data:')) return
const payload = line.slice(5).trim()
if (!payload || payload === '[DONE]') return
let event
try {
event = JSON.parse(payload)
} catch {
return
}
if (event.usage) usage = event.usage
const delta = event.choices?.[0]?.delta
if (delta && (delta.content || delta.reasoning_content)) sawContent = true
}
return {
feed(chunk) {
buffer += typeof chunk === 'string' ? chunk : decoder.write(chunk)
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) handleLine(line)
},
/** { prompt_tokens, completion_tokens, total_tokens } | null */
result() {
return usage
},
/** 是否已经吐出过正文(用于首 token 延迟) */
hasContent() {
return sawContent
},
}
}
import { db } from '#src/db/client.js'
import { aiUsage } from '#src/db/schema/ai/ai-usage.js'
import logger from '#src/lib/logger.js'
export const buildUsageRecord = ({ request, mapping, model, price, ...values }) => ({
request_id: request.id,
app_id: request.ai.appId,
api_key_id: request.ai.keyId,
user_id: request.ai.userId,
user_name: request.ai.userName,
provider: mapping.provider,
model,
upstream_model: mapping.upstreamModel,
price_version: price.version,
...values,
})
/**
* 记录一次调用。计量失败不能影响转发结果,只记日志。
*/
export const recordUsage = async (record, { throwOnError = false, tx = db } = {}) => {
try {
await tx.insert(aiUsage).values(record)
} catch (error) {
logger.error({ err: error, requestId: record.request_id }, 'ai_usage 落库失败')
if (throwOnError) throw error
}
}
import dayjs from 'dayjs'
import { and, eq, lt } from 'drizzle-orm'
import { db } from '#src/db/client.js'
import { aiUsage, USAGE_ERROR_CODES, USAGE_SOURCES, USAGE_STATUSES, USAGE_UNITS } from '#src/db/schema/ai/ai-usage.js'
import { microToCost, unitCostMicro } from './pricing.service.js'
import { chargeQuotaPackage } from './quota.service.js'
import { normalizeProviderTaskError } from './gateway.service.js'
import logger from '#src/lib/logger.js'
import { accountFor } from './upstream.js'
import { providerFor } from './providers/index.js'
/** 超过这个时间仍未完成的 pending 任务视为过期,下次请求时兜底处理 */
const PENDING_TTL_MINUTES = 30
/** 上游任务状态 -> 我们的状态 */
const mapStatus = (upstreamStatus) => {
if (['succeeded', 'success', 'completed'].includes(upstreamStatus)) return USAGE_STATUSES.SUCCESS
if (['failed', 'error', 'cancelled', 'expired'].includes(upstreamStatus)) return USAGE_STATUSES.ERROR
return USAGE_STATUSES.PENDING
}
const publicStatus = (upstreamStatus) => {
if (['succeeded', 'success', 'completed'].includes(upstreamStatus)) return 'succeeded'
if (['failed', 'error', 'cancelled'].includes(upstreamStatus)) return 'failed'
if (['expired'].includes(upstreamStatus)) return 'expired'
if (['queued', 'pending', 'created'].includes(upstreamStatus)) return 'queued'
return 'processing'
}
const publicTask = ({ row, task, mapping, requestId }) => {
const status = publicStatus(task.status)
const failed = status === 'failed' || status === 'expired'
return {
id: row.upstream_task_id,
model: row.model,
status,
video: status === 'succeeded' ? {
url: task.url,
duration: task.duration,
expires_at: task.expiresAt,
} : null,
usage: status === 'succeeded' && task.duration !== null ? { seconds: task.duration } : null,
error: failed ? normalizeProviderTaskError({
mapping,
error: task.error ?? { code: 'generation_failed', message: '供应商返回生成失败' },
requestId,
}) : null,
}
}
/**
* 刷新一条 pending 记录:成功按真实时长计费,失败不计费。
* 用于客户端查询时(主路径)和过期兜底(惰性)。
*
*/
export const refreshVideoTask = async ({ row, price, signal, requestId }) => {
const mapping = {
provider: row.provider,
upstreamModel: row.upstream_model,
account: accountFor(row.provider),
}
const provider = providerFor(row.provider, 'getVideo')
let upstream
try {
upstream = await provider.getVideo({
mapping,
taskId: row.upstream_task_id,
signal: signal ?? AbortSignal.timeout(30_000),
requestId,
})
} catch (error) {
logger.warn({ err: error, taskId: row.upstream_task_id }, '查询视频任务失败,保持 pending')
return { status: USAGE_STATUSES.PENDING, data: null }
}
if (!upstream.ok) return { ok: false, status: upstream.status, data: upstream.data }
const task = provider.normalizeVideo(upstream.data)
const nextStatus = mapStatus(task.status)
if (nextStatus === USAGE_STATUSES.PENDING) return { status: nextStatus, data: publicTask({ row, task, mapping, requestId }) }
if (nextStatus === USAGE_STATUSES.SUCCESS) {
// 真实时长以上游为准:
// 报了 usage.duration / duration → reported,按真实时长结算;
// 没报 → 不估算,标 missing + usage_missing 待核对。
const reportedSeconds = task.duration
const actualSeconds = reportedSeconds ?? 0
const actualMicro = unitCostMicro(price, actualSeconds)
await db.transaction(async (tx) => {
const [result] = await tx
.update(aiUsage)
.set({
status: USAGE_STATUSES.SUCCESS,
quantity: actualSeconds,
cost: microToCost(actualMicro),
usage_source: reportedSeconds === null ? USAGE_SOURCES.MISSING : USAGE_SOURCES.REPORTED,
error_code: reportedSeconds === null ? USAGE_ERROR_CODES.USAGE_MISSING : null,
// 火山对视频按 token 计费,记下来便于对账(我们对外按秒计价)
completion_tokens: task.completionTokens,
http_status: upstream.status,
})
.where(and(eq(aiUsage.id, row.id), eq(aiUsage.status, USAGE_STATUSES.PENDING)))
if (result.affectedRows > 0) {
await chargeQuotaPackage(row.quota_package_id, actualMicro, { tx })
}
})
return { status: USAGE_STATUSES.SUCCESS, data: publicTask({ row, task, mapping, requestId }) }
}
await db.transaction(async (tx) => {
await tx
.update(aiUsage)
.set({ status: USAGE_STATUSES.ERROR, quantity: 0, cost: '0.000000', error_code: upstream.data?.error?.code ?? null, http_status: upstream.status })
.where(and(eq(aiUsage.id, row.id), eq(aiUsage.status, USAGE_STATUSES.PENDING)))
})
return { status: USAGE_STATUSES.ERROR, data: publicTask({ row, task, mapping, requestId }) }
}
/**
* 惰性兜底:该 key 下次发起请求时,顺带结算过期的 pending 任务。
* 不跑后台定时器,查询驱动。
*/
export const settleOverduePending = async ({ apiKeyId, priceLookup }) => {
const cutoff = dayjs().subtract(PENDING_TTL_MINUTES, 'minute').format('YYYY-MM-DD HH:mm:ss')
const rows = await db
.select()
.from(aiUsage)
.where(and(
eq(aiUsage.api_key_id, apiKeyId),
eq(aiUsage.status, USAGE_STATUSES.PENDING),
eq(aiUsage.unit, USAGE_UNITS.SECOND),
lt(aiUsage.created_at, cutoff),
))
.limit(10)
for (const row of rows) {
try {
const price = await priceLookup(row.model, row.price_version)
if (!price) {
logger.warn({ usageId: row.id, model: row.model }, '兜底结算跳过:模型已无价格')
continue
}
await refreshVideoTask({ row, price })
} catch (error) {
logger.warn({ err: error, usageId: row.id }, '兜底结算视频任务失败')
}
}
}
......@@ -11,7 +11,16 @@ const getSsoUser = createCachedFetcher({ ttlMs: config.dmsAuth.userInfoCacheTtlS
export const getTgc = (req) => {
const cookies = req.headers.cookie || ''
const item = cookies.split(';').map((part) => part.trim()).find((part) => part.startsWith('TGC='))
return item ? decodeURIComponent(item.slice(4)) : ''
if (!item) return ''
// 畸形 percent-encoding(如 TGC=%)会抛 URIError;它没有 statusCode,
// 被全局错误处理当成 500 并打 error 日志——匿名者一个字符就能刷 5xx。
// 这里按「没有有效登录态」处理,与「无 TGC」走同一条 401 路径。
try {
return decodeURIComponent(item.slice(4))
} catch {
return ''
}
}
const normalizeSsoUser = (payload) => {
......@@ -66,7 +75,12 @@ export const authenticateRequest = async (req) => {
error.statusCode = 403
throw error
}
throw cause
// 其余情况是「我们的依赖出问题」,不是调用方的错:
// 有响应说明上游返回了非预期状态(502),连不上/超时则视为依赖不可用(503)。
// 原来直接 throw cause 会把上游状态码(如 404)当成 DMS 接口的状态码透给前端。
const error = new Error('权限中心暂时不可用')
error.statusCode = cause.response ? 502 : 503
throw error
}
const authenticatedUser = {
......
......@@ -117,7 +117,12 @@ export const getCaseById = async (id) => {
}
export const createCase = async (payload = {}, currentUser = {}) => {
const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
// files 省略 = 不改(与 description/product_id 一致,也和 schemas.test 的契约一致);
// 只有显式传了才序列化,传 null 表示清空
const values = {
...payload,
...(payload.files === undefined ? {} : { files: JSON.stringify(payload.files ?? []) }),
}
validate(values)
const [result] = await db.insert(cases).values({
...values,
......@@ -131,7 +136,12 @@ export const updateCase = async (id, payload = {}, currentUser = {}) => {
const record = await getCaseById(id)
if (!record) return null
const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
// files 省略 = 不改(与 description/product_id 一致,也和 schemas.test 的契约一致);
// 只有显式传了才序列化,传 null 表示清空
const values = {
...payload,
...(payload.files === undefined ? {} : { files: JSON.stringify(payload.files ?? []) }),
}
validate(values)
await db
.update(cases)
......
......@@ -32,6 +32,7 @@ import {
buildProjectFilters,
getUserProjectRoles,
} from './db-helpers.js'
import { buildAccessFlags } from './policy.js'
export const createProject = async (payload = {}, currentUser = {}) => {
const projectCode = payload.project_code || buildProjectCode()
......@@ -44,8 +45,7 @@ export const createProject = async (payload = {}, currentUser = {}) => {
city: payload.city || null,
school_name: payload.school_name || null,
department_name: payload.department_name || null,
product_id: productFields.product_id || null,
product_name: productFields.product_name || null,
product_id: productFields.product_id ?? null,
contact_name: payload.contact_name || null,
contact_title: payload.contact_title || null,
contact_phone: payload.contact_phone || null,
......@@ -55,7 +55,7 @@ export const createProject = async (payload = {}, currentUser = {}) => {
description: payload.description || null,
...resolveOperator(currentUser),
})
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
const projectId = insertResult.insertId || project.id
if (currentUser.userId) {
......@@ -91,11 +91,13 @@ export const listProjects = async (filters = {}, options = {}, currentUser = {})
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
const isAdmin = Boolean(currentUser.roles?.includes('admin'))
const ssoId = currentUser.ssoId ?? currentUser.userId
let projectIds
if (!currentUser.roles?.includes('admin')) {
if (!isAdmin) {
const rows = await db.select({ project_id: projectRoleAssignments.project_id })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.sso_id, currentUser.userId), eq(projectRoleAssignments.status, 1)))
.where(and(eq(projectRoleAssignments.sso_id, ssoId), eq(projectRoleAssignments.status, 1)))
projectIds = rows.map((row) => row.project_id)
if (!projectIds.length) return { list: [], pagination: { page, limit, total: 0, pages: 0 } }
}
......@@ -134,12 +136,14 @@ export const listProjects = async (filters = {}, options = {}, currentUser = {})
}
const list = projectRows.map((row) => ({
...withProjectOwnerFields(row.project, assignmentsByProjectId.get(row.project.id)),
can_edit_project: currentUser.roles?.includes('admin')
|| (
row.project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(row.project.stage)
&& assignmentsByProjectId.get(row.project.id)?.some((item) => item.role === 'project_manager' && item.sso_id === currentUser.userId)
),
// 与详情侧、写接口断言共用 policy,避免同名字段两处结论不一致
...buildAccessFlags(row.project, {
isAdmin,
roles: (assignmentsByProjectId.get(row.project.id) || [])
.filter((item) => item.sso_id === ssoId).map((item) => item.role),
isStageOwner: (assignmentsByProjectId.get(row.project.id) || [])
.some((item) => item.sso_id === ssoId && item.role === STAGE_OWNER_ROLES[row.project.stage]),
}),
product_name: row.product_name || '',
}))
const total = totalRows[0]?.total || 0
......@@ -198,33 +202,19 @@ export const getProjectDetail = async (projectCode) => {
}
}
/**
* 详情侧能力标志:取角色后交给 policy 统一判定。
* 管理员短路只跳过查角色,不再跳过阶段/归档判断(与列表侧、写接口断言一致)。
*/
export const getProjectAccess = async (project, user = {}) => {
const activeStages = Object.values(PROJECT_STAGES).filter((stage) => stage !== PROJECT_STAGES.ARCHIVED)
const isActive = project.status === PROJECT_STATUSES.ACTIVE
const editableStages = isActive && activeStages.includes(project.stage) ? [project.stage] : []
const isAdmin = Boolean(user?.roles?.includes('admin'))
const roles = isAdmin ? [] : await getUserProjectRoles(project.id, user?.ssoId)
if (user?.roles?.includes('admin')) {
return {
can_edit_project: isActive && [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage),
can_manage_team: true,
can_move_stage: isActive,
can_rollback_stage: true,
editable_stages: editableStages,
}
}
const roles = await getUserProjectRoles(project.id, user?.ssoId)
const isProjectManager = roles.includes('project_manager')
const isCurrentOwner = roles.includes(STAGE_OWNER_ROLES[project.stage])
const canOperateStage = isProjectManager || isCurrentOwner
return {
can_edit_project: isProjectManager && isActive,
can_manage_team: isProjectManager,
can_move_stage: isActive && canOperateStage,
can_rollback_stage: isProjectManager,
editable_stages: canOperateStage ? editableStages : [],
}
return buildAccessFlags(project, {
isAdmin,
roles,
isStageOwner: isAdmin ? false : roles.includes(STAGE_OWNER_ROLES[project.stage]),
})
}
export const updateProject = async (projectCode, payload = {}, currentUser = {}) => {
......@@ -243,7 +233,7 @@ export const updateProject = async (projectCode, payload = {}, currentUser = {})
if (Object.keys(values).length) {
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
assertProjectBaseEditable(project)
......@@ -258,7 +248,7 @@ export const deleteProject = async (projectCode) => {
let removedProject = null
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
if (!isProjectDeleteAllowed({ stage: project.stage, status: project.status })) {
......
......@@ -2,12 +2,8 @@ import { and, eq, inArray, like, or } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import {
acceptances,
contracts,
initiations,
projects,
projectTimelines,
PROJECT_STAGES,
} from '#src/db/schema/projects.js'
import { products } from '#src/db/schema/products.js'
import { projectRoleAssignments } from '#src/db/schema/project-roles.js'
......@@ -16,17 +12,28 @@ import {
STATUS_LABELS,
PROJECT_OWNER_ROLES,
toNumberOrUndefined,
hasFiles,
} from './helpers.js'
export const getProjectByCodeWithDb = async (db, projectCode) => {
const rows = await db
/**
* 按 project_code 取项目。
*
* @param {boolean} [options.forUpdate] 写路径必须传 true:加 `FOR UPDATE` 行锁,
* 让同一项目的并发写串行化。否则两个并发请求会读到同一份旧状态、
* 各自通过校验并各写一条 timeline(实测复现:并发两次阶段流转 → 2 条审计流水)。
* 所有写路径都在这里先锁项目行,锁顺序一致,因此不会互相死锁。
*/
export const getProjectByCodeWithDb = async (db, projectCode, { forUpdate = false } = {}) => {
let query = db
.select({ project: projects, product_name: products.name })
.from(projects)
.leftJoin(products, eq(projects.product_id, products.id))
.where(eq(projects.project_code, projectCode))
.limit(1)
if (forUpdate) query = query.for('update')
const rows = await query
if (!rows[0]) return null
return {
......@@ -35,22 +42,18 @@ export const getProjectByCodeWithDb = async (db, projectCode) => {
}
}
/**
* 只解析并校验 product_id;产品名一律由读路径 join product_list 得到。
* 不再回写 product_name 冗余列(2026-09-10:该列无人读,写进去只会漂移)。
*/
export const resolveProductFields = async (db, payload = {}) => {
const productId = toNumberOrUndefined(payload.product_id)
if (productId === undefined) {
return payload.product_name !== undefined ? { product_name: payload.product_name || null } : {}
}
if (productId === undefined) return {}
const rows = await db.select().from(products).where(eq(products.id, productId)).limit(1)
const product = rows[0]
if (!product) {
throw httpError(400, '产品不存在')
}
const rows = await db.select({ id: products.id }).from(products).where(eq(products.id, productId)).limit(1)
if (!rows[0]) throw httpError(400, '产品不存在')
return {
product_id: productId,
product_name: product.name,
}
return { product_id: productId }
}
export const appendTimeline = async (db, project, { title, description, stage, status, operatorUserId, operatorName }) => {
......@@ -126,31 +129,9 @@ const getUserProjectRoles = async (projectId, ssoId, database = db) => {
return rows.map((row) => row.role)
}
const validateStageCompletion = async (tx, project) => {
if (project.stage === PROJECT_STAGES.SOLUTION && !project.solution?.trim()) {
throw httpError(400, '方案完成前请填写方案内容')
}
if (project.stage === PROJECT_STAGES.INITIATION) {
const rows = await tx.select({ application_file_url: initiations.application_file_url }).from(initiations).where(eq(initiations.project_id, project.id)).limit(1)
if (!hasFiles(rows[0]?.application_file_url)) throw httpError(400, '立项完成前请上传项目申报书')
}
if (project.stage === PROJECT_STAGES.CONTRACT) {
const rows = await tx.select().from(contracts).where(eq(contracts.project_id, project.id)).limit(1)
const contract = rows[0]
if (!contract?.contract_no?.trim() || !contract.contract_name?.trim() || !hasFiles(contract.archive_file_url)) {
throw httpError(400, '合同完成前请填写合同编号、合同名称并上传合同文件')
}
}
if (project.stage === PROJECT_STAGES.ACCEPTANCE) {
const rows = await tx.select({ acceptance_report_url: acceptances.acceptance_report_url }).from(acceptances).where(eq(acceptances.project_id, project.id)).limit(1)
if (!hasFiles(rows[0]?.acceptance_report_url)) throw httpError(400, '验收完成前请上传验收报告')
}
}
export {
appendStageTimeline,
buildProjectFilters,
getOrCreatePhaseRow,
getUserProjectRoles,
validateStageCompletion,
}
import { httpError } from '#src/lib/http-error.js'
import { PROJECT_STATUSES, PROJECT_STAGES } from '#src/db/schema/projects.js'
import { EDITABLE_STAGES, isActiveProject } from './policy.js'
export const STAGE_LABELS = {
[PROJECT_STAGES.SOLUTION]: '方案阶段',
......@@ -62,63 +63,15 @@ export const pickDefined = (payload, fields) =>
return values
}, {})
const FILE_URL_FIELDS = new Set([
'application_file_url',
'argument_file_url',
'winning_notice_file_url',
'bid_archive_file_url',
'archive_file_url',
'acceptance_report_url',
])
const normalizeFileUrls = (value) => {
if (value === null || value === '') return ''
let files
try {
files = JSON.parse(value)
} catch {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
if (!Array.isArray(files) || files.some((file) => (
!file
|| typeof file.url !== 'string' || !file.url
|| typeof file.size !== 'string'
|| typeof file.name !== 'string' || !file.name
|| typeof file.type !== 'string'
))) {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
return JSON.stringify(files.map(({ url, size, name, type }) => ({ url, size, name, type })))
}
export const hasFiles = (value) => {
if (!value) return false
try {
const files = JSON.parse(value)
return Array.isArray(files) && files.length > 0
} catch {
return false
}
}
export const normalizeFileUrlFields = (values) => {
for (const field of Object.keys(values)) {
if (FILE_URL_FIELDS.has(field)) values[field] = normalizeFileUrls(values[field])
}
return values
}
export const assertProjectStageEditable = (project, stage) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改阶段资料')
if (project.stage !== stage) throw httpError(409, '只能修改当前阶段资料,请先退回到该阶段')
}
export const assertProjectBaseEditable = (project) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改基础信息')
if (![PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage)) {
if (!isActiveProject(project)) throw httpError(400, '已归档项目不可修改基础信息')
// 阶段清单只此一份(policy.EDITABLE_STAGES),与 can_edit_project 保持一致
if (!EDITABLE_STAGES.includes(project.stage)) {
throw httpError(409, '进入后续阶段后不可修改项目基础信息')
}
}
......
import { eq } from 'drizzle-orm'
import { db } from '#src/db/client.js'
import { acceptances, contracts, deliveries, initiations, procurements, projects, PROJECT_STAGES } from '#src/db/schema/projects.js'
import { pickDefined, normalizeFileUrlFields, assertProjectStageEditable, resolveOperator } from './helpers.js'
import { assertProjectStageEditable, resolveOperator } from './helpers.js'
import { getProjectByCodeWithDb, getOrCreatePhaseRow } from './db-helpers.js'
import { getProjectDetail } from './crud.js'
export const updateSolution = async (projectCode, payload = {}, currentUser = {}) => {
const solution = typeof payload.solution === 'string' ? payload.solution : ''
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
exists = true
assertProjectStageEditable(project, PROJECT_STAGES.SOLUTION)
await tx.update(projects).set({ solution, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await tx.update(projects).set({ ...payload, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
return exists ? getProjectDetail(projectCode) : null
}
// 阶段表里可被接口更新的字段 = 表的所有列减去系统列(校验边界仍由 zod schema 把关)
const SYSTEM_FIELDS = new Set(['id', 'project_id', 'created_at', 'updated_at', 'completed_at'])
const editableFields = (table) =>
Object.entries(table)
.filter(([key, col]) => col && typeof col === 'object' && col.name && col.dataType && !SYSTEM_FIELDS.has(key))
.map(([key]) => key)
const upsertPhase = async (projectCode, table, payload, stage, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
exists = true
assertProjectStageEditable(project, stage)
if (Object.keys(payload).length) {
const row = await getOrCreatePhaseRow(tx, table, project.id)
const values = normalizeFileUrlFields(pickDefined(payload, editableFields(table)))
if (Object.keys(values).length) {
await tx.update(table).set(values).where(eq(table.id, row.id))
await tx.update(table).set(payload).where(eq(table.id, row.id))
await tx.update(projects).set(resolveOperator(currentUser)).where(eq(projects.id, project.id))
}
})
......
import { PROJECT_STAGES, PROJECT_STATUSES } from '#src/db/schema/projects.js'
/**
* 项目访问能力的唯一判定处(纯函数,无 IO)。
*
* 背景:`can_edit_project` 这类能力标志原来在列表、详情、以及各写接口断言里
* 各写一遍,已经漂移过两次(详情侧补了阶段判断、列表侧管理员分支漏了)。
* 现在统一走这里,写接口的 assert* 也复用同一份阶段常量,
* 「前端看到的按钮」与「后端会不会拒绝」就不再可能对不上。
*/
/** 可编辑基础信息的阶段——与 helpers.assertProjectBaseEditable 共用同一份 */
export const EDITABLE_STAGES = [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION]
/** 归档以外的阶段,用于算 editable_stages */
export const ACTIVE_STAGES = Object.values(PROJECT_STAGES).filter((stage) => stage !== PROJECT_STAGES.ARCHIVED)
export const isActiveProject = (project) => project.status === PROJECT_STATUSES.ACTIVE
/** 阶段是否可编辑基础信息(归档 / 进入后续阶段都不行,管理员也不例外) */
export const isStageEditable = (project) => isActiveProject(project) && EDITABLE_STAGES.includes(project.stage)
/**
* 能力标志。
*
* @param {object} project 项目行(需要 status / stage)
* @param {object} ctx
* @param {boolean} ctx.isAdmin 是否管理员
* @param {string[]} [ctx.roles] 该项目内的角色数组(getUserProjectRoles 结果)
* @param {boolean} [ctx.isStageOwner] 是否当前阶段负责人(列表批量场景由调用方算好)
*
* admin 短路只绕过「角色」,不绕过「归档 / 阶段」:
* 归档或进入后续阶段后管理员同样不能改基础信息,与 helpers 里的写接口断言一致。
*/
export const buildAccessFlags = (project, { isAdmin = false, roles = [], isStageOwner = false } = {}) => {
const isActive = isActiveProject(project)
const isProjectManager = roles.includes('project_manager')
const canEditBasics = isStageEditable(project)
const canOperateStage = isActive && (isAdmin || isProjectManager || isStageOwner)
return {
can_edit_project: isAdmin ? canEditBasics : (isProjectManager && canEditBasics),
can_manage_team: isAdmin || isProjectManager,
can_move_stage: canOperateStage,
can_rollback_stage: isAdmin || isProjectManager,
// 只能编辑自己当前所属阶段(且项目处于活跃状态)
editable_stages: canOperateStage && ACTIVE_STAGES.includes(project.stage) ? [project.stage] : [],
}
}
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import { httpError } from '#src/lib/http-error.js'
import { db } from '#src/db/client.js'
import { deliveries, projects, PROJECT_STAGES, PROJECT_STATUSES } from '#src/db/schema/projects.js'
import {
acceptances,
contracts,
deliveries,
initiations,
procurements,
projects,
PROJECT_STAGES,
PROJECT_STATUSES,
} from '#src/db/schema/projects.js'
import { projectRoleAssignments } from '#src/db/schema/project-roles.js'
import { isManualTransitionAllowed } from '#src/services/dms/workflow.js'
import { toNumberOrUndefined, resolveOperator } from './helpers.js'
import { getProjectByCodeWithDb, getUserProjectRoles, validateStageCompletion, appendStageTimeline } from './db-helpers.js'
import { resolveOperator, STAGE_OWNER_ROLES, toNumberOrUndefined } from './helpers.js'
import { getOrCreatePhaseRow, getProjectByCodeWithDb, getUserProjectRoles, appendStageTimeline } from './db-helpers.js'
import { getProjectDetail } from './crud.js'
const PHASE_TABLES = {
[PROJECT_STAGES.INITIATION]: initiations,
[PROJECT_STAGES.PROCUREMENT]: procurements,
[PROJECT_STAGES.CONTRACT]: contracts,
[PROJECT_STAGES.DELIVERY]: deliveries,
[PROJECT_STAGES.ACCEPTANCE]: acceptances,
}
const persistCurrentStageData = async (tx, project, payload = {}) => {
if (!Object.keys(payload).length) return
if (project.stage === PROJECT_STAGES.SOLUTION) {
await tx.update(projects).set(payload).where(eq(projects.id, project.id))
Object.assign(project, payload)
return
}
const table = PHASE_TABLES[project.stage]
if (!table) return
const row = await getOrCreatePhaseRow(tx, table, project.id)
await tx.update(table).set(payload).where(eq(table.id, row.id))
}
const assertNextStageOwner = async (tx, projectId, stage) => {
const role = STAGE_OWNER_ROLES[stage]
if (!role) return
const rows = await tx.select({ id: projectRoleAssignments.id })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.project_id, projectId),
eq(projectRoleAssignments.role, role),
eq(projectRoleAssignments.status, 1),
))
.limit(1)
if (!rows.length) throw httpError(400, '进入下一阶段前请先配置对应阶段负责人')
}
export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => {
const stage = toNumberOrUndefined(payload.to_stage)
if (stage === undefined) {
......@@ -15,7 +63,7 @@ export const moveProjectStage = async (projectCode, payload = {}, currentUser =
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
exists = true
......@@ -29,28 +77,22 @@ export const moveProjectStage = async (projectCode, payload = {}, currentUser =
if (!matchedTransition) throw httpError(400, '不允许的阶段流转')
const isAdministrator = currentUser.roles?.includes('admin')
let isProjectManager = false
if (!isAdministrator) {
const roleByStage = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager') && !roles.includes(roleByStage[project.stage])) {
isProjectManager = roles.includes('project_manager')
if (!isProjectManager && !roles.includes(STAGE_OWNER_ROLES[project.stage])) {
throw httpError(403, '您不是当前阶段负责人')
}
}
await persistCurrentStageData(tx, project, payload.phase_data)
if (!isAdministrator && !isProjectManager) await assertNextStageOwner(tx, project.id, stage)
if (project.stage === PROJECT_STAGES.DELIVERY) {
await tx.update(deliveries)
.set({ completed_at: new Date() })
.where(eq(deliveries.project_id, project.id))
}
await validateStageCompletion(tx, project)
await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, stage, status, payload.description || '已完成当前阶段', {
operatorUserId: currentUser.userId,
......@@ -64,7 +106,7 @@ export const moveProjectStage = async (projectCode, payload = {}, currentUser =
export const rollbackProjectStage = async (projectCode, reason, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
const project = await getProjectByCodeWithDb(tx, projectCode, { forUpdate: true })
if (!project) return
exists = true
if (!currentUser.roles?.includes('admin')) {
......@@ -80,6 +122,9 @@ export const rollbackProjectStage = async (projectCode, reason, currentUser = {}
[PROJECT_STAGES.ARCHIVED]: PROJECT_STAGES.ACCEPTANCE,
}[project.stage]
if (!previousStage) throw httpError(400, '当前阶段不能退回')
if (previousStage === PROJECT_STAGES.DELIVERY) {
await tx.update(deliveries).set({ completed_at: null }).where(eq(deliveries.project_id, project.id))
}
await tx.update(projects).set({ stage: previousStage, status: PROJECT_STATUSES.ACTIVE, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, previousStage, PROJECT_STATUSES.ACTIVE, reason || '项目退回上一阶段', {
operatorUserId: currentUser.userId,
......
......@@ -48,7 +48,9 @@ export const query = async (filters = {}, options = {}) => {
if (filters.endTime) query.createdAt.$lte = filters.endTime
}
if (filters.keyword) {
query.message = { $regex: filters.keyword, $options: 'i' }
// 用户输入按字面量匹配:直接当正则既可被 ReDoS,也会让 `.*` 绕过关键词语义。
// escapeRegex 本来就在本文件(给 URL 白名单用的),这里复用同一份。
query.message = { $regex: escapeRegex(filters.keyword), $options: 'i' }
}
const [list, total] = await Promise.all([
......
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import { eq, inArray, like } from 'drizzle-orm'
// ---- mock 上游:必须在导入 config 之前设好环境变量 ----
let videoPolls = 0
// 用于验证「客户端断开 -> 上游请求被取消」
let slowImageAborted = false
const upstream = http.createServer((req, res) => {
let body = ''
req.on('data', (chunk) => { body += chunk })
req.on('end', () => {
const parsed = body ? JSON.parse(body) : {}
if (req.url.includes('/contents/generations/tasks')) {
if (req.method === 'POST') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ id: 'task-mock-1' }))
return
}
videoPolls += 1
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(
videoPolls === 1
? { id: 'task-mock-1', status: 'running' }
: { id: 'task-mock-1', status: 'succeeded', duration: 6, usage: { duration: 6, completion_tokens: 108900 } },
))
return
}
if (req.url.includes('/images/generations')) {
// 慢图片通道:客户端断开时应被取消
if (parsed.model === 'slow-image-model') {
const timer = setTimeout(() => {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ data: [{ url: 'https://example.test/slow.png' }] }))
}, 3000)
req.on('close', () => {
clearTimeout(timer)
slowImageAborted = true
})
return
}
const count = parsed.sequential_image_generation_options?.max_images ?? 1
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({
created: Date.now(),
data: Array.from({ length: count }, (_, i) => ({ url: `https://example.test/${i}.png` })),
usage: { generated_images: count, output_tokens: 16384 },
}))
return
}
if (parsed.model === 'upstream-error-model') {
res.writeHead(500, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: { code: 'internal_error', message: 'mock upstream failure' } }))
return
}
if (parsed.stream) {
res.writeHead(200, { 'content-type': 'text/event-stream' })
res.write(`data: ${JSON.stringify({ model: parsed.model, choices: [{ delta: { role: 'assistant' } }] })}\n\n`)
if (parsed.model === 'slow-stream-model') {
// 慢速推流:留出时间让客户端中途断开
let n = 0
const timer = setInterval(() => {
n += 1
if (n > 20) { clearInterval(timer); res.end(); return }
res.write('data: {"choices":[{"delta":{"content":"x"}}]}\n\n')
}, 100)
req.on('close', () => clearInterval(timer))
return
}
res.write('data: {"choices":[{"delta":{"content":"流式"}}]}\n\n')
res.write('data: {"choices":[{"delta":{"content":"通过"}}]}\n\n')
res.write('data: {"choices":[],"usage":{"prompt_tokens":9,"completion_tokens":4,"total_tokens":13}}\n\n')
res.write('data: [DONE]\n\n')
res.end()
return
}
if (parsed.model === 'no-usage-model') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({
id: 'chatcmpl-nousage',
object: 'chat.completion',
model: parsed.model,
choices: [{ index: 0, message: { role: 'assistant', content: '没带用量' }, finish_reason: 'stop' }],
}))
return
}
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({
id: 'chatcmpl-test',
object: 'chat.completion',
model: parsed.model,
choices: [{ index: 0, message: { role: 'assistant', content: '联调通过' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 20, completion_tokens: 5, total_tokens: 25 },
}))
})
})
await new Promise((resolve) => upstream.listen(0, '127.0.0.1', resolve))
process.env.VOLCANO_BASE_URL = `http://127.0.0.1:${upstream.address().port}`
process.env.VOLCANO_API_KEY = 'mock-key'
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { aiQuotaPackages } = await import('#src/db/schema/ai/quota-packages.js')
const { aiApiKeys } = await import('#src/db/schema/ai/api-keys.js')
const { aiApps } = await import('#src/db/schema/ai/apps.js')
const { aiModelMappings } = await import('#src/db/schema/ai/model-mappings.js')
const { aiModelPrices } = await import('#src/db/schema/ai/model-prices.js')
const { default: config } = await import('#src/config.js')
const { default: mysql } = await import('mysql2/promise')
const { build } = await import('../helper.js')
// ---- 本地没有 MySQL 时跳过(CI 保持无依赖)----
const canUseDatabase = async () => {
try {
const connection = await mysql.createConnection(
config.mysql.url
? { uri: config.mysql.url }
: {
host: config.mysql.host,
port: config.mysql.port,
user: config.mysql.user,
password: config.mysql.password,
database: config.mysql.database,
},
)
await connection.query('SELECT 1 FROM ai_usage LIMIT 1')
await connection.end()
return true
} catch {
return false
}
}
const dbReady = await canUseDatabase()
const skip = dbReady ? false : '本地 MySQL 或 ai_* 表不可用,跳过'
// 整个文件共用一个实例:build(t) 会在每个测试结束时关闭 app,
// 而关闭 app 会连带关闭共享的 MySQL 连接池,导致后续测试无法落库。
const server = dbReady ? await build() : null
test.after(() => server?.close())
// 需要真实 socket 的用例(验证客户端断开)共用同一个监听地址:
// Fastify 实例只能 listen 一次,且监听后必须等所有用例跑完才能 close——
// 否则 file 级的 test.after(server.close) 会提前关掉共享的 MySQL 连接池。
// 因此这两个用例统一放在文件末尾,并在这里 close。
let listenAddress = null
const listenOnce = async () => {
if (!listenAddress) listenAddress = await server.listen({ port: 0, host: '127.0.0.1' })
return listenAddress
}
/**
* 每轮测试后清理测试数据。
*
* 为什么用 afterEach 兜底而不是每个用例自己写:原来 18 处清理里漏了 6 处
* (5 个删了 key/app 却没删 ai_usage,1 个连 t.after 都没有),
* 结果测试库攒下 186 行 app_id 已失效的孤儿用量行、凭空多出 178 个「模型」。
*
* 识别方式按命名约定:本文件创建的应用 code 一律以 `test-` 开头;
* 真实应用(saas-ai / saas-bi / ebook)与真实模型配置不受影响。
*/
const TEST_APP_CODE = 'test-%'
afterEach(async () => {
if (!dbReady) return
const testApps = await db.select({ id: aiApps.id }).from(aiApps).where(like(aiApps.code, TEST_APP_CODE))
const appIds = testApps.map((row) => row.id)
if (appIds.length) {
await db.delete(aiUsage).where(inArray(aiUsage.app_id, appIds))
await db.delete(aiQuotaPackages).where(inArray(aiQuotaPackages.app_id, appIds))
await db.delete(aiApiKeys).where(inArray(aiApiKeys.app_id, appIds))
await db.delete(aiApps).where(inArray(aiApps.id, appIds))
}
// 测试用的模型别名同样以 test- 开头
await db.delete(aiModelPrices).where(like(aiModelPrices.alias, TEST_APP_CODE))
await db.delete(aiModelMappings).where(like(aiModelMappings.alias, TEST_APP_CODE))
})
const addQuota = async (admin, app, amountMicro, usedMicro = 0) => {
await admin.updateApp(app.id, { billing_mode: 'quota' })
const quota = await admin.createQuotaPackage({
app_id: app.id,
name: '测试额度',
amount_micro: amountMicro,
start_date: '2020-01-01',
end_date: '2099-12-31',
status: 1,
})
if (usedMicro) {
await db.update(aiQuotaPackages).set({ used_micro: usedMicro }).where(eq(aiQuotaPackages.id, quota.id))
quota.used_micro = usedMicro
}
return quota
}
test('AI 网关:管理面建的 key 能在数据面使用,并正确落库用量', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-joint-app-${Date.now()}`
const alias = `test-joint-model-${Date.now()}`
const app = await admin.createApp({ code, name: '联调测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '联调测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-test', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '4' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
// 创建时写入操作人
assert.equal(app.operator_user_id, 'test-sso-001')
assert.equal(app.operator_name, '测试创建人')
assert.match(key.key, /^sk-ezj-[\w-]{32}$/)
// 列表用「前缀 + 末4位」掩码展示,绝不返回完整密钥或哈希
const listed = await admin.listApiKeys({ app_id: app.id }, { page: 1, limit: 10 })
const listedKey = listed.list.find((row) => row.id === key.id)
assert.equal(listedKey.key_masked, `sk-ezj-${key.key.slice(7, 15)}*****`)
assert.equal(listedKey.key, undefined)
assert.equal(listedKey.key_hash, undefined)
const fastify = server
const payload = { model: alias, messages: [{ role: 'user', content: 'hi' }] }
const unauthorized = await fastify.inject({ method: 'POST', url: '/ai/chat/completions', payload })
assert.equal(unauthorized.statusCode, 401)
const ok = await fastify.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload,
})
assert.equal(ok.statusCode, 200)
assert.equal(ok.json().choices[0].message.content, '联调通过')
assert.equal(ok.json().model, alias)
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.equal(rows.length, 1)
const usage = rows[0]
assert.equal(usage.api_key_id, key.id)
assert.equal(usage.prompt_tokens, 20)
assert.equal(usage.completion_tokens, 5)
assert.equal(usage.quantity, 25)
assert.equal(usage.status, 'success')
assert.equal(usage.usage_source, 'reported')
// 20 × 1 + 5 × 4 = 40 微元
assert.equal(Number(usage.cost), 0.00004)
// 更新时刷新为最新操作人
const updated = await admin.updateApp(app.id, { name: '改名后' }, { userId: 'test-sso-002', userName: '测试修改人' })
assert.equal(updated.operator_user_id, 'test-sso-002')
assert.equal(updated.operator_name, '测试修改人')
})
test('AI 网关:修改模型价格会关闭旧版本并创建新版本', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiModelPrices } = await import('#src/db/schema/ai/model-prices.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const alias = `test-price-model-${Date.now()}`
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-price', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '4' }, operator)
t.after(async () => { try { await admin.deleteModel(alias) } catch { /* afterEach 兜底已删 */ } })
const updated = await admin.updateModel(alias, { output_price: '6' }, operator)
assert.equal(updated.output_price, '6.000000')
const rows = await db.select().from(aiModelPrices).where(eq(aiModelPrices.alias, alias)).orderBy(aiModelPrices.version)
assert.equal(rows.length, 2)
assert.equal(rows[0].status, 0)
assert.equal(rows[1].status, 1)
assert.equal(rows[1].version, 2)
})
test('AI 网关:流式透传分片并记录 usage 与首 token 延迟', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-stream-app-${Date.now()}`
const alias = `test-stream-model-${Date.now()}`
const app = await admin.createApp({ code, name: '流式测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '流式测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-stream', pricing_unit: 'per_1m_tokens', input_price: '3', output_price: '9' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const fastify = server
const res = await fastify.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, stream: true, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal(res.statusCode, 200)
assert.match(res.headers['content-type'], /text\/event-stream/)
assert.ok(res.body.includes('流式') && res.body.includes('通过'))
assert.ok(res.body.includes('[DONE]'))
assert.ok(res.body.includes(alias))
assert.equal(res.body.includes('ep-stream'), false)
// 用量在响应结束后异步落库,轮询等待
let usage
for (let i = 0; i < 20 && !usage; i++) {
await new Promise((resolve) => setTimeout(resolve, 25))
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
usage = rows[0]
}
assert.ok(usage, 'usage 未落库')
assert.equal(usage.stream, 1)
assert.equal(usage.prompt_tokens, 9)
assert.equal(usage.completion_tokens, 4)
assert.equal(usage.status, 'success')
assert.ok(usage.first_token_ms !== null && usage.first_token_ms >= 0)
// 9 × 3 + 4 × 9 = 63 微元
assert.equal(Number(usage.cost), 0.000063)
})
test('AI 网关:额度用完返回 429,检查不增加已用金额', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-quota-app-${Date.now()}`
const alias = `test-quota-model-${Date.now()}`
const app = await admin.createApp({ code, name: '配额测试应用' }, operator)
const quota = await addQuota(admin, app, 1, 1)
const key = await admin.createApiKey({ app_id: app.id, name: '配额测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-quota', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal(res.statusCode, 429)
const [current] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(current.used_micro, 1, '额度检查不应修改已用金额')
})
test('AI 网关:输出上限不参与额度检查', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-maxtokens-app-${Date.now()}`
const alias = `test-maxtokens-model-${Date.now()}`
const app = await admin.createApp({ code, name: '输出上限测试应用' }, operator)
await addQuota(admin, app, 60)
const key = await admin.createApiKey({ app_id: app.id, name: '输出上限测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-maxtokens', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }], max_completion_tokens: 100 },
})
assert.equal(res.statusCode, 200, '有剩余额度时直接放行,不估算输出成本')
})
test('AI 网关:完成后累计真实成本', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-settle-app-${Date.now()}`
const alias = `test-settle-model-${Date.now()}`
// 预算充足:1 元 = 1,000,000 微元
const app = await admin.createApp({ code, name: '结算测试应用' }, operator)
const quota = await addQuota(admin, app, 1_000_000)
const key = await admin.createApiKey({ app_id: app.id, name: '结算测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-settle', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '4' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal(res.statusCode, 200)
// mock 上游返回 prompt 20 / completion 5 -> 20×1 + 5×4 = 40 微元
const [current] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(current.used_micro, 40, '额度包已用金额应等于真实成本')
})
test('AI 网关:并发请求均按实际用量累计', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-concurrent-app-${Date.now()}`
const alias = `test-concurrent-model-${Date.now()}`
const app = await admin.createApp({ code, name: '并发测试应用' }, operator)
const quota = await addQuota(admin, app, 6_000)
const key = await admin.createApiKey({ app_id: app.id, name: '并发测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-concurrent', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const call = () =>
server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
const results = await Promise.all([call(), call()])
const codes = results.map((r) => r.statusCode).sort()
assert.deepEqual(codes, [200, 200], '未用完额度的并发请求均可放行')
const [current] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(current.used_micro, 50)
})
test('AI 网关:图片按张计费,能力不匹配则拒绝', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-image-app-${Date.now()}`
const alias = `test-image-model-${Date.now()}`
const textAlias = `test-text-model-${Date.now()}`
const app = await admin.createApp({ code, name: '图片测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '图片测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'image', provider: 'volcano', upstream_model: 'ep-image', pricing_unit: 'per_image', unit_price: '0.2' }, operator)
await admin.createModel({ alias: textAlias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-image-text', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteModel(textAlias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
// 用文本别名调图片端点 -> 能力校验拦截
const wrong = await server.inject({
method: 'POST',
url: '/ai/images/generations',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: textAlias, prompt: 'x' },
})
assert.equal(wrong.statusCode, 400)
assert.match(wrong.json().error.message, /不是图片模型/)
// 正常生成 2 张
const ok = await server.inject({
method: 'POST',
url: '/ai/images/generations',
headers: { authorization: `Bearer ${key.key}` },
payload: {
model: alias,
prompt: '一只猫',
sequential_image_generation: 'auto',
sequential_image_generation_options: { max_images: 2 },
},
})
assert.equal(ok.statusCode, 200)
assert.equal(ok.json().data.length, 2)
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
const usage = rows.find((row) => row.type === 'image')
assert.ok(usage)
assert.equal(usage.unit, 'image')
assert.equal(usage.quantity, 2)
assert.equal(usage.status, 'success')
// 2 张 × 0.2 元 = 0.4 元
assert.equal(Number(usage.cost), 0.4)
// 上游按 token 计费,记下来便于和火山账单核对
assert.equal(usage.completion_tokens, 16384)
})
test('AI 网关:视频异步任务 pending → 查询时结算', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-video-app-${Date.now()}`
const alias = `test-video-model-${Date.now()}`
const app = await admin.createApp({ code, name: '视频测试应用' }, operator)
const quota = await addQuota(admin, app, 100_000_000)
const key = await admin.createApiKey({ app_id: app.id, name: '视频测试 key' }, operator)
const otherKey = await admin.createApiKey({ app_id: app.id, name: '视频其他 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'video', provider: 'volcano', upstream_model: 'ep-video', pricing_unit: 'per_second', unit_price: '0.5' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(otherKey.id)
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const auth = { authorization: `Bearer ${key.key}` }
// 提交任务
const submit = await server.inject({
method: 'POST',
url: '/ai/videos',
headers: auth,
payload: { model: alias, prompt: '一只猫在跳舞', duration: 5 },
})
assert.equal(submit.statusCode, 202)
assert.equal(submit.json().id, 'task-mock-1')
const taskId = submit.json().id
const otherKeyResult = await server.inject({ method: 'GET', url: `/ai/videos/${taskId}`, headers: { authorization: `Bearer ${otherKey.key}` } })
assert.equal(otherKeyResult.statusCode, 404, '其他密钥不能查询当前密钥的视频任务')
const pending = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.equal(pending.length, 1)
assert.equal(pending[0].status, 'pending')
assert.equal(pending[0].unit, 'second')
assert.equal(pending[0].quantity, 0)
assert.equal(Number(pending[0].cost), 0) // pending 不占额度
// 第一次查询:仍在运行
const running = await server.inject({ method: 'GET', url: `/ai/videos/${taskId}`, headers: auth })
assert.equal(running.statusCode, 200)
assert.equal(running.json().status, 'processing')
const stillPending = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.equal(stillPending[0].status, 'pending')
// 第二次查询:已完成,按真实时长 6 秒结算
const done = await server.inject({ method: 'GET', url: `/ai/videos/${taskId}`, headers: auth })
assert.equal(done.statusCode, 200)
assert.equal(done.json().status, 'succeeded')
const settled = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.equal(settled[0].status, 'success')
assert.equal(settled[0].quantity, 6)
assert.equal(Number(settled[0].cost), 3) // 6 秒 × 0.5 元
assert.equal(settled[0].completion_tokens, 108900) // 上游按 token 计费,用于对账
assert.equal(settled[0].usage_source, 'reported')
// 账本应等于真实成本(3 元 = 3,000,000 微元)
const [currentQuota] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(currentQuota.used_micro, 3_000_000)
})
test('AI 网关:报表按维度聚合正确', { skip }, async (t) => {
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { summarize, listRecent } = await import('#src/services/ai/report.service.js')
const { eq } = await import('drizzle-orm')
const stamp = Date.now()
const modelA = `report-text-${stamp}`
const modelB = `report-image-${stamp}`
await db.insert(aiUsage).values([
{
request_id: `report-${stamp}`, type: 'text', provider: 'volcano', model: modelA,
upstream_model: 'ep-r1', unit: 'token', quantity: 100, prompt_tokens: 60, completion_tokens: 40,
cost: '0.100000', status: 'success',
},
{
request_id: `report-${stamp}`, type: 'image', provider: 'volcano', model: modelB,
upstream_model: 'ep-r2', unit: 'image', quantity: 2, cost: '0.400000', status: 'success',
},
])
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.model, modelA))
await db.delete(aiUsage).where(eq(aiUsage.model, modelB))
} catch { /* 已删除,忽略 */ }
})
const text = await summarize({ groupBy: 'type', model: modelA })
assert.equal(Number(text.total.calls), 1)
assert.equal(Number(text.total.cost), 0.1)
assert.equal(Number(text.total.prompt_tokens), 60)
assert.equal(text.rows[0].key, 'text')
assert.equal(Number(text.rows[0].token_quantity), 100)
// 按计费单位拆分:这条是 token 类
assert.equal(Number(text.units.find((u) => u.unit === 'token')?.quantity), 100)
const image = await summarize({ groupBy: 'model', model: modelB })
assert.equal(Number(image.total.calls), 1)
assert.equal(Number(image.total.quantity), 2)
assert.equal(Number(image.rows[0].image_quantity), 2)
assert.equal(image.rows[0].key, modelB)
assert.equal(Number(image.units.find((u) => u.unit === 'image')?.quantity), 2)
const recent = await listRecent({ model: modelA }, { page: 1, limit: 10 })
assert.equal(recent.list.length, 1)
assert.equal(recent.pagination.total, 1)
})
test('AI 网关:按用户归因,业务系统只能查到本应用用量', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const stamp = Date.now()
const alias = `test-user-model-${stamp}`
const appA = await admin.createApp({ code: `test-user-app-a-${stamp}`, name: '应用 A' }, operator)
const keyA = await admin.createApiKey({ app_id: appA.id, name: 'key A' }, operator)
const appB = await admin.createApp({ code: `test-user-app-b-${stamp}`, name: '应用 B' }, operator)
const keyB = await admin.createApiKey({ app_id: appB.id, name: 'key B' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-user', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, appA.id))
await db.delete(aiUsage).where(eq(aiUsage.app_id, appB.id))
await admin.deleteApiKey(keyA.id)
await admin.deleteApiKey(keyB.id)
await admin.deleteModel(alias)
await admin.deleteApp(appA.id)
await admin.deleteApp(appB.id)
} catch { /* 已删除,忽略 */ }
})
const call = (key, userId, userName) =>
server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: {
authorization: `Bearer ${key.key}`,
'x-user-id': userId,
'x-user-name': encodeURIComponent(userName),
},
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal((await call(keyA, 'u-1', '张三')).statusCode, 200)
assert.equal((await call(keyA, 'u-2', '李四')).statusCode, 200)
assert.equal((await call(keyA, 'u-1', '张三')).statusCode, 200)
assert.equal((await call(keyB, 'u-9', '王五')).statusCode, 200)
// 归因写入
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, appA.id))
assert.equal(rows.length, 3)
assert.equal(rows.filter((row) => row.user_id === 'u-1').length, 2)
assert.equal(rows.find((row) => row.user_id === 'u-1').user_name, '张三') // 已解码
// 业务系统按用户查自己的用量
const summary = await server.inject({
url: '/ai/usage/summary?group_by=user',
headers: { authorization: `Bearer ${keyA.key}` },
})
assert.equal(summary.statusCode, 200)
const u1 = summary.json().rows.find((row) => row.key === 'u-1')
assert.equal(Number(u1.calls), 2)
assert.equal(u1.label, '张三')
// 只能看到本应用:B 应用查不到 A 的用户
const summaryB = await server.inject({
url: '/ai/usage/summary?group_by=user',
headers: { authorization: `Bearer ${keyB.key}` },
})
const keysB = summaryB.json().rows.map((row) => row.key)
assert.deepEqual(keysB, ['u-9'])
// 明细也按应用隔离
const recentB = await server.inject({
url: '/ai/usage',
headers: { authorization: `Bearer ${keyB.key}` },
})
assert.equal(recentB.json().list.length, 1)
assert.equal(recentB.json().list[0].user_id, 'u-9')
})
test('AI 网关:报表可按 status 分组(用量页的失败率靠它)', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const report = await import('#src/services/ai/report.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-bystatus-app-${Date.now()}`
const aliasOk = `test-bystatus-ok-${Date.now()}`
const aliasErr = `test-bystatus-err-${Date.now()}`
const app = await admin.createApp({ code, name: '状态分组测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '状态分组 key' }, operator)
await admin.createModel({ alias: aliasOk, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-test', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
await admin.createModel({ alias: aliasErr, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'upstream-error-model', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(aliasOk)
await admin.deleteModel(aliasErr)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const call = (model) =>
server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model, messages: [{ role: 'user', content: 'hi' }] },
})
await call(aliasOk)
await call(aliasErr)
const byStatus = await report.summarize({ groupBy: 'status', appId: app.id })
const counts = Object.fromEntries(byStatus.rows.map((row) => [String(row.key), Number(row.calls)]))
assert.equal(counts.success, 1, '成功次数')
assert.equal(counts.error, 1, '失败次数')
// 失败行的成本必须是 0(上游没成功,不该计费)
const errorRow = byStatus.rows.find((row) => String(row.key) === 'error')
assert.equal(Number(errorRow.cost), 0)
})
test('AI 网关:管理面报表支持按 model / status 过滤', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const report = await import('#src/services/ai/report.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-report-app-${Date.now()}`
const aliasOk = `test-report-ok-${Date.now()}`
const aliasErr = `test-report-err-${Date.now()}`
const app = await admin.createApp({ code, name: '报表过滤测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '报表过滤 key' }, operator)
await admin.createModel({ alias: aliasOk, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'ep-test', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
await admin.createModel({ alias: aliasErr, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'upstream-error-model', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(aliasOk)
await admin.deleteModel(aliasErr)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const call = (model) =>
server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal((await call(aliasOk)).statusCode, 200)
assert.equal((await call(aliasErr)).statusCode, 500)
// 按状态过滤:只应命中失败那一条
const errors = await report.summarize({ groupBy: 'model', appId: app.id, status: 'error' })
assert.equal(errors.rows.length, 1)
assert.equal(errors.rows[0].key, aliasErr)
assert.equal(Number(errors.total.calls), 1)
const notCharged = await report.summarize({ groupBy: 'model', appId: app.id, billingStatus: 'not_charged' })
assert.equal(notCharged.rows.length, 1)
assert.equal(notCharged.rows[0].key, aliasErr)
const charged = await report.summarize({ groupBy: 'model', appId: app.id, billingStatus: 'charged' })
assert.equal(charged.rows.length, 1)
assert.equal(charged.rows[0].key, aliasOk)
// 按模型过滤:只应命中成功那一条
const okOnly = await report.summarize({ groupBy: 'model', appId: app.id, model: aliasOk })
assert.equal(okOnly.rows.length, 1)
assert.equal(okOnly.rows[0].key, aliasOk)
})
test('AI 网关:上游缺失 usage 时不估算并标记 usage_missing', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-nousage-app-${Date.now()}`
const alias = `test-nousage-model-${Date.now()}`
const app = await admin.createApp({ code, name: '缺用量测试应用' }, operator)
const quota = await addQuota(admin, app, 100_000_000)
const key = await admin.createApiKey({ app_id: app.id, name: '缺用量测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'no-usage-model', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal(res.statusCode, 200)
const [usage] = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.ok(usage, 'usage 未落库')
assert.equal(usage.status, 'success')
assert.equal(usage.usage_source, 'missing', '没拿到真实用量应标 missing')
assert.equal(usage.error_code, 'usage_missing', '应留下可对账的标记')
const [currentQuota] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(Number(usage.cost), 0)
assert.equal(currentQuota.used_micro, 0, '缺用量不增加已用额度')
})
test('AI 网关:上游报错时不计费', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-error-app-${Date.now()}`
const alias = `test-error-model-${Date.now()}`
const app = await admin.createApp({ code, name: '上游报错应用' }, operator)
const quota = await addQuota(admin, app, 100_000_000)
const key = await admin.createApiKey({ app_id: app.id, name: '报错测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'upstream-error-model', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/chat/completions',
headers: { authorization: `Bearer ${key.key}` },
payload: { model: alias, messages: [{ role: 'user', content: 'hi' }] },
})
assert.equal(res.statusCode, 500)
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
assert.equal(rows.length, 1)
assert.equal(rows[0].status, 'error')
assert.equal(rows[0].http_status, 500)
const [currentQuota] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(currentQuota.used_micro, 0, '上游报错不增加已用额度')
})
test('AI 网关:视频提交不占额度', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-smartduration-app-${Date.now()}`
const alias = `test-smartduration-model-${Date.now()}`
const app = await admin.createApp({ code, name: '智能时长测试应用' }, operator)
const quota = await addQuota(admin, app, 2_600_000)
const key = await admin.createApiKey({ app_id: app.id, name: '智能时长测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'video', provider: 'volcano', upstream_model: 'ep-video', pricing_unit: 'per_second', unit_price: '0.5' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const res = await server.inject({
method: 'POST',
url: '/ai/videos',
headers: { authorization: `Bearer ${key.key}` },
payload: {
model: alias,
prompt: '一只猫在雪地里打滚',
duration: 5,
},
})
assert.equal(res.statusCode, 202)
const [currentQuota] = await db.select().from(aiQuotaPackages).where(eq(aiQuotaPackages.id, quota.id))
assert.equal(currentQuota.used_micro, 0, '视频提交不占额度')
})
test('AI 网关:惰性兜底结算过期的 pending 视频任务', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const dayjs = (await import('dayjs')).default
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-lazy-app-${Date.now()}`
const alias = `test-lazy-model-${Date.now()}`
const app = await admin.createApp({ code, name: '惰性兜底应用' }, operator)
const quota = await addQuota(admin, app, 100_000_000)
const key = await admin.createApiKey({ app_id: app.id, name: '兜底测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'video', provider: 'volcano', upstream_model: 'ep-video', pricing_unit: 'per_second', unit_price: '0.5' }, operator)
// 直接造一条 31 分钟前的 pending 记录(模拟客户端提交后再也没查询)
await db.insert(aiUsage).values({
request_id: `lazy-${Date.now()}`,
app_id: app.id,
api_key_id: key.id,
quota_package_id: quota.id,
type: 'video',
provider: 'volcano',
model: alias,
upstream_model: 'ep-video',
unit: 'second',
quantity: 0,
price_version: 1,
cost: '0.000000',
status: 'pending',
upstream_task_id: 'task-mock-1',
stream: 0,
created_at: dayjs().subtract(31, 'minute').format('YYYY-MM-DD HH:mm:ss'),
})
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
// 任意一次该 key 的请求都会触发惰性兜底(异步,不阻塞本次请求)
await server.inject({ url: '/ai/models', headers: { authorization: `Bearer ${key.key}` } })
let row
for (let i = 0; i < 40; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
if (rows[0]?.status !== 'pending') { row = rows[0]; break }
}
assert.ok(row, '过期 pending 未被兜底处理')
assert.equal(row.status, 'success')
assert.equal(row.quantity, 6) // mock 返回真实时长 6 秒
assert.equal(Number(row.cost), 3) // 6 × 0.5 元
})
// ---- 以下两个用例需要真实 socket,必须放在文件末尾(见 listenOnce 注释)----
test('AI 网关:非流式请求客户端断开时取消上游,不再白跑', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-cancel-app-${Date.now()}`
const alias = `test-cancel-image-${Date.now()}`
slowImageAborted = false
const app = await admin.createApp({ code, name: '取消上游测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '取消上游测试 key' }, operator)
await admin.createModel({ alias, name: '测试模型', type: 'image', provider: 'volcano', upstream_model: 'slow-image-model', pricing_unit: 'per_image', unit_price: '0.2' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const address = await listenOnce()
const controller = new AbortController()
const request = fetch(`${address}/ai/images/generations`, {
method: 'POST',
headers: { authorization: `Bearer ${key.key}`, 'content-type': 'application/json' },
body: JSON.stringify({ model: alias, prompt: '一只猫' }),
signal: controller.signal,
}).catch(() => null)
await new Promise((resolve) => setTimeout(resolve, 300))
controller.abort() // 客户端断开
await request
// 上游应收到连接关闭(我们主动取消了 fetch)
for (let i = 0; i < 20 && !slowImageAborted; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
}
assert.equal(slowImageAborted, true, '客户端断开后应取消上游请求')
})
test('AI 网关:客户端中途断开 -> 记 aborted 和缺失用量,不估算费用', { skip }, async (t) => {
const admin = await import('#src/services/ai/admin.service.js')
const { db } = await import('#src/db/client.js')
const { aiUsage } = await import('#src/db/schema/ai/ai-usage.js')
const { eq } = await import('drizzle-orm')
const operator = { userId: 'test-sso-001', userName: '测试创建人' }
const code = `test-abort-app-${Date.now()}`
const alias = `test-abort-model-${Date.now()}`
const app = await admin.createApp({ code, name: '断开测试应用' }, operator)
const key = await admin.createApiKey({ app_id: app.id, name: '断开测试 key' }, operator)
// 上游模型名触发 mock 的慢速推流
await admin.createModel({ alias, name: '测试模型', type: 'text', provider: 'volcano', upstream_model: 'slow-stream-model', pricing_unit: 'per_1m_tokens', input_price: '1', output_price: '1' }, operator)
t.after(async () => {
// 幂等:afterEach 的兜底清理可能已经删过,重复删不该让用例失败
try {
await db.delete(aiUsage).where(eq(aiUsage.app_id, app.id))
await admin.deleteApiKey(key.id)
await admin.deleteModel(alias)
await admin.deleteApp(app.id)
} catch { /* 已删除,忽略 */ }
})
const address = await listenOnce()
const controller = new AbortController()
const response = await fetch(`${address}/ai/chat/completions`, {
method: 'POST',
headers: { authorization: `Bearer ${key.key}`, 'content-type': 'application/json' },
body: JSON.stringify({ model: alias, stream: true, messages: [{ role: 'user', content: 'hi' }] }),
signal: controller.signal,
})
const reader = response.body.getReader()
await reader.read() // 收到第一片
controller.abort() // 客户端断开
await reader.cancel().catch(() => {})
// 落库是异步的,轮询等待
let usage
for (let i = 0; i < 40 && !usage; i++) {
await new Promise((resolve) => setTimeout(resolve, 50))
const rows = await db.select().from(aiUsage).where(eq(aiUsage.app_id, app.id))
usage = rows[0]
}
assert.ok(usage, '未落库')
assert.equal(usage.status, 'aborted')
assert.equal(usage.stream, 1)
assert.equal(usage.usage_source, 'missing')
assert.equal(Number(usage.cost), 0)
})
test.after(() => upstream.close())
......@@ -3,23 +3,23 @@ import assert from 'node:assert/strict'
import { build } from '../helper.js'
const DMS_ROUTES = [
['GET', '/api/dms/auth/me'], ['GET', '/api/dms/system/users'],
['GET', '/api/dms/products'], ['POST', '/api/dms/products'],
['PUT', '/api/dms/products/:id'], ['PUT', '/api/dms/products/:id/status'], ['DELETE', '/api/dms/products/:id'],
['GET', '/api/dms/cases'], ['POST', '/api/dms/cases/sync'], ['POST', '/api/dms/cases'],
['GET', '/api/dms/cases/:id'], ['PUT', '/api/dms/cases/:id'], ['DELETE', '/api/dms/cases/:id'],
['POST', '/api/dms/projects'], ['GET', '/api/dms/projects'],
['GET', '/api/dms/projects/:projectCode'], ['PUT', '/api/dms/projects/:projectCode'],
['DELETE', '/api/dms/projects/:projectCode'],
['PUT', '/api/dms/projects/:projectCode/stage'],
['PUT', '/api/dms/projects/:projectCode/stage/rollback'],
['PUT', '/api/dms/projects/:projectCode/solution'],
['GET', '/api/dms/projects/:projectCode/team'], ['PUT', '/api/dms/projects/:projectCode/team'],
['PUT', '/api/dms/projects/:projectCode/initiation'],
['PUT', '/api/dms/projects/:projectCode/procurement'],
['PUT', '/api/dms/projects/:projectCode/contract'],
['PUT', '/api/dms/projects/:projectCode/delivery'],
['PUT', '/api/dms/projects/:projectCode/acceptance'],
['GET', '/dms/auth/me'], ['GET', '/dms/system/users'],
['GET', '/dms/products'], ['POST', '/dms/products'],
['PUT', '/dms/products/:id'], ['PUT', '/dms/products/:id/status'], ['DELETE', '/dms/products/:id'],
['GET', '/dms/cases'], ['POST', '/dms/cases/sync'], ['POST', '/dms/cases'],
['GET', '/dms/cases/:id'], ['PUT', '/dms/cases/:id'], ['DELETE', '/dms/cases/:id'],
['POST', '/dms/projects'], ['GET', '/dms/projects'],
['GET', '/dms/projects/:projectCode'], ['PUT', '/dms/projects/:projectCode'],
['DELETE', '/dms/projects/:projectCode'],
['PUT', '/dms/projects/:projectCode/stage'],
['PUT', '/dms/projects/:projectCode/stage/rollback'],
['PUT', '/dms/projects/:projectCode/solution'],
['GET', '/dms/projects/:projectCode/team'], ['PUT', '/dms/projects/:projectCode/team'],
['PUT', '/dms/projects/:projectCode/initiation'],
['PUT', '/dms/projects/:projectCode/procurement'],
['PUT', '/dms/projects/:projectCode/contract'],
['PUT', '/dms/projects/:projectCode/delivery'],
['PUT', '/dms/projects/:projectCode/acceptance'],
]
test('app boots and exposes the full route table', async (t) => {
......@@ -31,10 +31,10 @@ test('app boots and exposes the full route table', async (t) => {
assert.equal(app.hasRoute({ method: 'POST', url: '/getInfo' }), true, 'wechat')
assert.equal(app.hasRoute({ method: 'GET', url: '/get/wx-chart/*' }), true, 'wx-chart get')
assert.equal(app.hasRoute({ method: 'GET', url: '/set/wx-chart/*' }), true, 'wx-chart set')
assert.equal(app.hasRoute({ method: 'POST', url: '/api/logs' }), true, 'logs create')
assert.equal(app.hasRoute({ method: 'POST', url: '/api/logs/batch' }), true, 'logs batch')
assert.equal(app.hasRoute({ method: 'GET', url: '/api/logs' }), true, 'logs query')
assert.equal(app.hasRoute({ method: 'GET', url: '/api/logs/stats' }), true, 'logs stats')
assert.equal(app.hasRoute({ method: 'POST', url: '/logs' }), true, 'logs create')
assert.equal(app.hasRoute({ method: 'POST', url: '/logs/batch' }), true, 'logs batch')
assert.equal(app.hasRoute({ method: 'GET', url: '/logs' }), true, 'logs query')
assert.equal(app.hasRoute({ method: 'GET', url: '/logs/stats' }), true, 'logs stats')
for (const [method, url] of DMS_ROUTES) {
assert.equal(app.hasRoute({ method, url }), true, `missing ${method} ${url}`)
......@@ -71,12 +71,12 @@ test('zod schema failure returns 400 in the unified shape', async (t) => {
test('dms routes require authentication before validation', async (t) => {
const app = await build(t)
const me = await app.inject({ url: '/api/dms/auth/me' })
const me = await app.inject({ url: '/dms/auth/me' })
assert.equal(me.statusCode, 401)
assert.equal(me.json().error.message, '登录状态已失效')
// invalid params + no cookie -> 401 (auth runs in onRequest, before schema validation)
const del = await app.inject({ method: 'DELETE', url: '/api/dms/projects/X' })
const del = await app.inject({ method: 'DELETE', url: '/dms/projects/X' })
assert.equal(del.statusCode, 401)
})
......@@ -97,7 +97,7 @@ test('wx-chart wildcard routing', async (t) => {
test('logs query validation rejects page=0', async (t) => {
const app = await build(t)
const res = await app.inject({ url: '/api/logs?page=0' })
const res = await app.inject({ url: '/logs?page=0' })
assert.equal(res.statusCode, 400)
assert.equal(res.json().success, false)
})
......@@ -106,7 +106,7 @@ test('urlencoded bodies are parsed', async (t) => {
const app = await build(t)
const res = await app.inject({
method: 'POST',
url: '/api/logs',
url: '/logs',
payload: 'message=hello&level=info',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
})
......@@ -118,7 +118,7 @@ test('body limit allows multi-megabyte log batches', async (t) => {
const payload = JSON.stringify({ logs: [{ message: 'x'.repeat(2 * 1024 * 1024) }] })
const res = await app.inject({
method: 'POST',
url: '/api/logs/batch',
url: '/logs/batch',
payload,
headers: { 'content-type': 'application/json' },
})
......@@ -128,10 +128,10 @@ test('body limit allows multi-megabyte log batches', async (t) => {
test('usercenter proxy is registered and forwards upstream', async (t) => {
const app = await build(t)
assert.equal(
app.hasRoute({ method: 'GET', url: '/api/usercenter/*' }) || app.hasRoute({ method: 'GET', url: '/api/usercenter/x' }),
app.hasRoute({ method: 'GET', url: '/proxy/usercenter/*' }) || app.hasRoute({ method: 'GET', url: '/proxy/usercenter/x' }),
true,
)
const res = await app.inject({ url: '/api/usercenter/v2/frontend/user/get-user-info' })
const res = await app.inject({ url: '/proxy/usercenter/v2/frontend/user/get-user-info' })
assert.doesNotMatch(String(res.body), /接口未定义/)
})
......
import { test } from 'node:test'
import assert from 'node:assert/strict'
import axios from 'axios'
import { eq } from 'drizzle-orm'
/**
* DMS 路由授权矩阵 + 「省略字段不得清空」回归。
*
* 只 mock 最外层的 axios 出网(SSO 用户信息 + 权限中心),
* 认证、路由 hooks、service 断言、数据库全部走真实链路——
* 要验证的正是「路由钩子 / service 断言 / 能力标志」三者是否一致。
*/
process.env.SSO_USER_INFO_URL = 'http://sso.test/user'
process.env.PERMISSION_API_URL = 'http://perm.test'
process.env.PERMISSION_APP_ID = 'test-app'
process.env.PERMISSION_APP_SECRET = 'test-secret'
// ---- 假上游:按请求路径给出 SSO 用户 / 权限中心响应 ----
const upstream = {
/** tgc -> { id, realname } */
users: {},
/** ssoId -> { roles, routes },roles 元素形状与权限中心一致(对象带 tag) */
authz: {},
}
const stubAxios = test.mock.method(axios, 'get', async (url, options = {}) => {
if (url === process.env.SSO_USER_INFO_URL) {
const tgc = decodeURIComponent(String(options.headers?.Cookie ?? '').replace('TGC=', ''))
const user = upstream.users[tgc]
if (!user) {
const error = new Error('unauthorized')
error.response = { status: 401 }
throw error
}
return { data: { code: 0, data: user } }
}
if (String(url).startsWith(process.env.PERMISSION_API_URL)) {
const tgc = decodeURIComponent(String(options.headers?.Cookie ?? '').replace('TGC=', ''))
const ssoId = upstream.users[tgc]?.id
const authz = upstream.authz[ssoId]
if (!authz) {
const error = new Error('forbidden')
error.response = { status: 401 }
throw error
}
const items = String(url).endsWith('/roles')
? authz.roles.map((tag) => ({ tag }))
: authz.routes
return { data: { data: { items } } }
}
throw new Error(`未预期的上游请求: ${url}`)
})
// 这两个文件直接用 db(没建 app 实例),连接池不会被 Fastify 的 onClose 释放,
// 不显式关闭的话 node:test 进程会一直挂着不退出
test.after(async () => {
const { closeMysqlPool } = await import('#src/lib/mysql.js')
await closeMysqlPool()
})
test.after(() => stubAxios.mock.restore())
const { build } = await import('../helper.js')
const { db } = await import('#src/db/client.js')
const { cases } = await import('#src/db/schema/cases.js')
const { projects, PROJECT_STAGES, PROJECT_STATUSES } = await import('#src/db/schema/projects.js')
const { projectRoleAssignments } = await import('#src/db/schema/project-roles.js')
const casesService = await import('#src/services/dms/cases.service.js')
const projectsService = await import('#src/services/dms/projects/index.js')
const app = await build()
test.after(() => app.close())
let seq = 0
/** 注册一个假身份,返回可用的 cookie */
const login = ({ roles = [], routes = ['/dms/projects'], name = '测试用户' } = {}) => {
seq += 1
const tgc = `tgc-${seq}`
const ssoId = `sso-${seq}`
upstream.users[tgc] = { id: ssoId, realname: name }
upstream.authz[ssoId] = { roles, routes }
return { header: { cookie: `TGC=${tgc}` }, ssoId, name }
}
const createProject = async (ssoId, { name, stage, status }) => {
const projectCode = `AUTH-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const [row] = await db.insert(projects).values({
project_code: projectCode,
name,
stage,
status,
})
await db.insert(projectRoleAssignments).values({
project_id: row.insertId,
sso_id: ssoId,
user_name: '项目经理',
role: 'project_manager',
status: 1,
})
return { id: row.insertId, projectCode }
}
// ============ 「省略字段不得清空」============
test('updateCase:省略 files 不清空附件,null / 空数组才清空', async () => {
const [created] = await db.insert(cases).values({
name: `授权测试案例-${Date.now()}`,
description: '原描述',
files: JSON.stringify([{ name: 'a.pdf', url: 'https://x/a.pdf' }]),
})
try {
const renamed = await casesService.updateCase(created.insertId, { name: '改名后' })
assert.equal(renamed.name, '改名后')
assert.equal(renamed.description, '原描述', '未提交的字段不应被清空')
assert.equal(renamed.files.length, 1, '省略 files 不应清空附件')
// name 是必填(validate 校验的是合并后的值),其余字段按需带
assert.equal((await casesService.updateCase(created.insertId, { name: '改名后', files: null })).files.length, 0)
await casesService.updateCase(created.insertId, { name: '改名后', files: [{ name: 'b.pdf', url: 'https://x/b.pdf' }] })
assert.equal((await casesService.updateCase(created.insertId, { name: '改名后', files: [] })).files.length, 0)
} finally {
await db.delete(cases).where(eq(cases.id, created.insertId))
}
})
test('updateSolution:省略 solution 不清空方案,null 才清空', async () => {
const projectCode = `AUTH-SOL-${Date.now()}`
const [created] = await db.insert(projects).values({
project_code: projectCode,
name: '方案测试项目',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
solution: '原有方案正文',
})
const operator = { userId: 'tester', userName: 'tester' }
try {
await projectsService.updateSolution(projectCode, {}, operator)
const [kept] = await db.select().from(projects).where(eq(projects.id, created.insertId))
assert.equal(kept.solution, '原有方案正文', '省略 solution 不应清空方案')
const returned = await projectsService.updateSolution(projectCode, { solution: null }, operator)
const [cleared] = await db.select().from(projects).where(eq(projects.id, created.insertId))
assert.equal(cleared.solution ?? '', '', `显式 null 应清空方案(服务返回 ${JSON.stringify(returned?.solution)})`)
} finally {
await db.delete(projects).where(eq(projects.id, created.insertId))
}
})
// ============ 路由授权矩阵 ============
test('未登录 / 无 DMS 路由权限 → 401 与 403', async () => {
const anon = await app.inject({ method: 'GET', url: '/dms/projects', headers: { cookie: 'TGC=不存在的tgc' } })
assert.equal(anon.statusCode, 401)
const outsider = login({ routes: [] })
const forbidden = await app.inject({ method: 'GET', url: '/dms/projects', headers: outsider.header })
assert.equal(forbidden.statusCode, 403)
})
test('详情:非项目成员 403,项目成员 200', async () => {
const pm = login({ routes: ['/dms/projects'] })
const outsider = login({ routes: ['/dms/projects'] })
const project = await createProject(pm.ssoId, {
name: '授权矩阵项目',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
})
try {
const stranger = await app.inject({
method: 'GET',
url: `/dms/projects/${project.projectCode}`,
headers: outsider.header,
})
assert.equal(stranger.statusCode, 403)
const member = await app.inject({
method: 'GET',
url: `/dms/projects/${project.projectCode}`,
headers: pm.header,
})
assert.equal(member.statusCode, 200)
const { access } = member.json().data
assert.equal(access.can_manage_team, true)
assert.equal(access.can_edit_project, true, '方案阶段的项目负责人应可编辑基础信息')
} finally {
await db.delete(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, project.id))
await db.delete(projects).where(eq(projects.id, project.id))
}
})
test('能力标志与写接口断言一致:归档项目 can_edit_project 必须为 false 且 PUT 被拒', async () => {
const admin = login({ roles: ['admin'], routes: ['/dms/projects'] })
const project = await createProject(admin.ssoId, {
name: '归档项目',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ARCHIVED,
})
try {
const detail = await app.inject({
method: 'GET',
url: `/dms/projects/${project.projectCode}`,
headers: admin.header,
})
assert.equal(detail.statusCode, 200)
const { access } = detail.json().data
// 归档项目:管理员也不能改基础信息——标志与写接口必须一致
assert.equal(access.can_edit_project, false, '归档项目的 can_edit_project 必须是 false')
assert.equal(access.can_move_stage, false, '归档项目不能流转阶段')
assert.deepEqual(access.editable_stages, [])
const put = await app.inject({
method: 'PUT',
url: `/dms/projects/${project.projectCode}`,
headers: admin.header,
payload: { name: '改名尝试' },
})
assert.equal(put.statusCode, 400, '归档项目的写入应被断言拒绝')
} finally {
await db.delete(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, project.id))
await db.delete(projects).where(eq(projects.id, project.id))
}
})
test('列表:管理员对归档项目同样不可编辑(与详情同一份判定)', async () => {
const admin = login({ roles: ['admin'], routes: ['/dms/projects'] })
const project = await createProject(admin.ssoId, {
name: '列表归档项目',
stage: PROJECT_STAGES.DELIVERY,
status: PROJECT_STATUSES.ARCHIVED,
})
try {
const res = await app.inject({ method: 'GET', url: '/dms/projects?limit=100', headers: admin.header })
assert.equal(res.statusCode, 200)
const row = res.json().data.list.find((item) => item.project_code === project.projectCode)
assert.ok(row, '应能在列表里找到该项目')
assert.equal(row.can_edit_project, false, '列表侧管理员也不应对归档项目显示可编辑')
} finally {
await db.delete(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, project.id))
await db.delete(projects).where(eq(projects.id, project.id))
}
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { eq } from 'drizzle-orm'
/**
* 项目阶段状态机:全转移矩阵 + 前置校验 + 并发。
*
* 走真实 service + 真实 MySQL(不 mock 状态机本身),只 mock 认证出网——
* 这里要验证的正是「workflow 转换表 ↔ service 断言 ↔ 落库结果」是否一致。
*/
process.env.SSO_USER_INFO_URL = 'http://sso.test/user'
process.env.PERMISSION_API_URL = 'http://perm.test'
process.env.PERMISSION_APP_ID = 'test-app'
process.env.PERMISSION_APP_SECRET = 'test-secret'
import axios from 'axios'
import { mock } from 'node:test'
const upstream = { users: {}, authz: {} }
const stubAxios = mock.method(axios, 'get', async (url, options = {}) => {
if (url === process.env.SSO_USER_INFO_URL) {
const tgc = decodeURIComponent(String(options.headers?.Cookie ?? '').replace('TGC=', ''))
if (!upstream.users[tgc]) {
const error = new Error('unauthorized')
error.response = { status: 401 }
throw error
}
return { data: { code: 0, data: upstream.users[tgc] } }
}
const tgc = decodeURIComponent(String(options.headers?.Cookie ?? '').replace('TGC=', ''))
const authz = upstream.authz[upstream.users[tgc]?.id]
const items = String(url).endsWith('/roles') ? authz.roles.map((tag) => ({ tag })) : authz.routes
return { data: { data: { items } } }
})
// 这两个文件直接用 db(没建 app 实例),连接池不会被 Fastify 的 onClose 释放,
// 不显式关闭的话 node:test 进程会一直挂着不退出
test.after(async () => {
const { closeMysqlPool } = await import('#src/lib/mysql.js')
await closeMysqlPool()
})
test.after(() => stubAxios.mock.restore())
const { db } = await import('#src/db/client.js')
const {
projects, PROJECT_STAGES, PROJECT_STATUSES,
initiations, procurements, contracts, acceptances, deliveries, projectTimelines,
} = await import('#src/db/schema/projects.js')
const { projectRoleAssignments } = await import('#src/db/schema/project-roles.js')
const stages = await import('#src/services/dms/projects/stages.js')
let seq = 0
const operator = () => {
seq += 1
return { userId: `sso-${seq}`, userName: `用户${seq}`, roles: [] }
}
const adminOperator = () => ({ ...operator(), roles: ['admin'] })
/** 建一个处于指定阶段的项目 */
const ownerRoleByStage = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const setupProject = async (stage, { status = PROJECT_STATUSES.ACTIVE, nextOwner = true } = {}) => {
const projectCode = `SM-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`
const [row] = await db.insert(projects).values({
project_code: projectCode,
name: `状态机测试-${stage}`,
stage,
status,
solution: '',
})
const nextOwnerRole = ownerRoleByStage[stage + 10]
if (nextOwner && nextOwnerRole) {
await db.insert(projectRoleAssignments).values({
project_id: row.insertId,
sso_id: `next-owner-${row.insertId}`,
user_name: '下一阶段负责人',
role: nextOwnerRole,
})
}
return { id: row.insertId, projectCode }
}
const cleanup = async (id) => {
for (const table of [initiations, procurements, contracts, acceptances, deliveries, projectTimelines]) {
await db.delete(table).where(eq(table.project_id, id))
}
await db.delete(projectRoleAssignments).where(eq(projectRoleAssignments.project_id, id))
await db.delete(projects).where(eq(projects.id, id))
}
const timelineCount = async (id) => {
const rows = await db.select().from(projectTimelines).where(eq(projectTimelines.project_id, id))
return rows.length
}
// ============ 转换表本身(纯函数,不需要数据库)============
test('workflow: 手动流转表只允许相邻前进', async () => {
const { isManualTransitionAllowed } = await import('#src/services/dms/workflow.js')
const S = PROJECT_STAGES
const A = PROJECT_STATUSES.ACTIVE
const allowed = [[S.SOLUTION, S.INITIATION], [S.INITIATION, S.PROCUREMENT], [S.PROCUREMENT, S.CONTRACT],
[S.CONTRACT, S.DELIVERY], [S.DELIVERY, S.ACCEPTANCE], [S.ACCEPTANCE, S.ARCHIVED]]
const stages = Object.values(S)
for (const from of stages) {
for (const to of stages) {
const expected = allowed.some(([f, t]) => f === from && t === to)
const actual = isManualTransitionAllowed({
fromStage: from,
fromStatus: A,
toStage: to,
toStatus: to === S.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : A,
})
assert.equal(actual, expected, `${from} → ${to} 的判定与转换表不一致`)
}
}
// 非 ACTIVE 起点一律不允许
assert.equal(
isManualTransitionAllowed({ fromStage: S.SOLUTION, fromStatus: PROJECT_STATUSES.ARCHIVED, toStage: S.INITIATION, toStatus: A }),
false,
'已归档项目不能流转',
)
})
// ============ 合法流转:6 条(含归档)全跑一遍 ============
test('状态机:6 条前进流转 + 归档,逐条都能走通且只写一条 timeline', async () => {
const transitions = [
[PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION],
[PROJECT_STAGES.INITIATION, PROJECT_STAGES.PROCUREMENT],
[PROJECT_STAGES.PROCUREMENT, PROJECT_STAGES.CONTRACT],
[PROJECT_STAGES.CONTRACT, PROJECT_STAGES.DELIVERY],
[PROJECT_STAGES.DELIVERY, PROJECT_STAGES.ACCEPTANCE],
[PROJECT_STAGES.ACCEPTANCE, PROJECT_STAGES.ARCHIVED],
]
for (const [from, to] of transitions) {
const { id, projectCode } = await setupProject(from)
try {
const result = await stages.moveProjectStage(projectCode, { to_stage: to }, adminOperator())
assert.ok(result, `${from} → ${to} 应返回项目详情`)
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.stage, to, `${from} → ${to} 阶段应落到 ${to}`)
assert.equal(
row.status,
to === PROJECT_STAGES.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE,
'归档目标阶段应联动 status=ARCHIVED',
)
assert.equal(await timelineCount(id), 1, `${from} → ${to} 应只写一条 timeline`)
} finally {
await cleanup(id)
}
}
})
// ============ 非法流转 ============
test('状态机:跳阶段 / 目标非法 / 从归档再流转,全部拒绝且不落库', async () => {
const illegal = [
{ from: PROJECT_STAGES.SOLUTION, to: PROJECT_STAGES.PROCUREMENT, label: '跳阶段(方案→招投标)' },
{ from: PROJECT_STAGES.SOLUTION, to: PROJECT_STAGES.ARCHIVED, label: '跳阶段(方案→归档)' },
{ from: PROJECT_STAGES.DELIVERY, to: PROJECT_STAGES.INITIATION, label: '倒退式前进' },
{ from: PROJECT_STAGES.INITIATION, to: 999, label: '不存在的目标阶段' },
]
for (const { from, to, label } of illegal) {
const { id, projectCode } = await setupProject(from)
try {
await assert.rejects(
() => stages.moveProjectStage(projectCode, { to_stage: to }, adminOperator()),
(error) => error.statusCode === 400,
`${label} 应被拒绝`,
)
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.stage, from, `${label} 被拒后阶段不应变化`)
assert.equal(await timelineCount(id), 0, `${label} 被拒后不应写 timeline`)
} finally {
await cleanup(id)
}
}
// 已归档项目不能再流转
const archived = await setupProject(PROJECT_STAGES.ARCHIVED, { status: PROJECT_STATUSES.ARCHIVED })
try {
await assert.rejects(
() => stages.moveProjectStage(archived.projectCode, { to_stage: PROJECT_STAGES.ACCEPTANCE }, adminOperator()),
(error) => error.statusCode === 400,
)
} finally {
await cleanup(archived.id)
}
})
test('状态机:to_stage 缺失 → 400', async () => {
const { id, projectCode } = await setupProject(PROJECT_STAGES.SOLUTION)
try {
await assert.rejects(
() => stages.moveProjectStage(projectCode, {}, adminOperator()),
(error) => error.statusCode === 400,
)
} finally {
await cleanup(id)
}
})
test('状态机:不存在的项目返回 null(不抛错、不建数据)', async () => {
assert.equal(await stages.moveProjectStage('NOT-EXIST', { to_stage: PROJECT_STAGES.INITIATION }, adminOperator()), null)
assert.equal(await stages.rollbackProjectStage('NOT-EXIST', '原因', adminOperator()), null)
})
test('状态机:完成阶段在同一事务中保存表单并推进', async () => {
const { id, projectCode } = await setupProject(PROJECT_STAGES.SOLUTION)
const attachment_file_url = JSON.stringify([
{ url: 'https://example.test/a.pdf', size: '10', name: 'a.pdf', type: 'application/pdf' },
{ url: 'https://example.test/b.pdf', size: '20', name: 'b.pdf', type: 'application/pdf' },
])
try {
await stages.moveProjectStage(projectCode, {
to_stage: PROJECT_STAGES.INITIATION,
phase_data: { solution: '事务内保存的方案', attachment_file_url },
}, adminOperator())
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.solution, '事务内保存的方案')
assert.equal(row.attachment_file_url, attachment_file_url)
assert.equal(row.stage, PROJECT_STAGES.INITIATION)
assert.equal(await timelineCount(id), 1)
} finally {
await cleanup(id)
}
})
test('状态机:普通阶段负责人推进时,下一阶段未配置负责人则拒绝并回滚表单保存', async () => {
const { id, projectCode } = await setupProject(PROJECT_STAGES.SOLUTION, { nextOwner: false })
const actor = operator()
try {
await db.insert(projectRoleAssignments).values({
project_id: id,
sso_id: actor.userId,
user_name: actor.userName,
role: 'solution_owner',
})
await assert.rejects(
() => stages.moveProjectStage(projectCode, {
to_stage: PROJECT_STAGES.INITIATION,
phase_data: { solution: '不应被保存' },
}, actor),
(error) => error.statusCode === 400 && error.message.includes('负责人'),
)
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.solution, '')
assert.equal(row.stage, PROJECT_STAGES.SOLUTION)
assert.equal(await timelineCount(id), 0)
} finally {
await cleanup(id)
}
})
test('状态机:项目负责人可在下一阶段负责人未配置时继续推进', async () => {
const { id, projectCode } = await setupProject(PROJECT_STAGES.SOLUTION, { nextOwner: false })
const actor = operator()
try {
await db.insert(projectRoleAssignments).values({
project_id: id,
sso_id: actor.userId,
user_name: actor.userName,
role: 'project_manager',
})
await stages.moveProjectStage(projectCode, {
to_stage: PROJECT_STAGES.INITIATION,
phase_data: { solution: '项目负责人提交的方案' },
}, actor)
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.stage, PROJECT_STAGES.INITIATION)
assert.equal(row.solution, '项目负责人提交的方案')
} finally {
await cleanup(id)
}
})
// ============ 退回 ============
test('状态机:退回上一阶段,方案阶段不可退回', async () => {
const chain = [
[PROJECT_STAGES.INITIATION, PROJECT_STAGES.SOLUTION],
[PROJECT_STAGES.ARCHIVED, PROJECT_STAGES.ACCEPTANCE],
]
for (const [from, to] of chain) {
const { id, projectCode } = await setupProject(from, {
status: from === PROJECT_STAGES.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE,
})
try {
await stages.rollbackProjectStage(projectCode, '退回原因', adminOperator())
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.stage, to, `${from} 应退回到 ${to}`)
assert.equal(row.status, PROJECT_STATUSES.ACTIVE, '退回后项目必须回到 ACTIVE')
assert.equal(await timelineCount(id), 1)
} finally {
await cleanup(id)
}
}
const solution = await setupProject(PROJECT_STAGES.SOLUTION)
try {
await assert.rejects(
() => stages.rollbackProjectStage(solution.projectCode, '原因', adminOperator()),
(error) => error.statusCode === 400,
)
} finally {
await cleanup(solution.id)
}
})
// ============ 并发 ============
test('状态机:并发两次前进只生效一次,审计流水不重复', async () => {
const { id, projectCode } = await setupProject(PROJECT_STAGES.SOLUTION)
const actor = adminOperator()
try {
// 同一项目并发两次「前进到立项」:事务内在 project_list 行上加 FOR UPDATE,
// 后到的请求会等锁,拿到已提交的新阶段后被转换表拒绝。
const results = await Promise.allSettled([
stages.moveProjectStage(projectCode, { to_stage: PROJECT_STAGES.INITIATION }, actor),
stages.moveProjectStage(projectCode, { to_stage: PROJECT_STAGES.INITIATION }, actor),
])
const succeeded = results.filter((item) => item.status === 'fulfilled' && item.value).length
const rejected = results.filter((item) => item.status === 'rejected')
const [row] = await db.select().from(projects).where(eq(projects.id, id))
assert.equal(row.stage, PROJECT_STAGES.INITIATION, '终态阶段必须是 INITIATION')
// 一次流转只能产生一条审计流水
assert.equal(await timelineCount(id), 1, '并发下不应写出重复的 timeline')
// 恰好一个成功、一个被状态机拒绝(而不是 500 / 死锁)
assert.equal(succeeded, 1, '只应有一次成功')
assert.equal(rejected.length, 1, '另一次应被拒绝')
assert.equal(rejected[0].reason?.statusCode, 400, '被拒的那次应是业务 400,不是数据库报错')
} finally {
await cleanup(id)
}
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { forwardJson } from '#src/services/ai/gateway.service.js'
import { chatCompletionBody } from '#src/schemas/ai/chat.js'
import { imageGenerationBody } from '#src/schemas/ai/image.js'
import { videoSubmitBody } from '#src/schemas/ai/video.js'
const mapping = (apiKey) => ({
provider: 'mock',
upstreamModel: 'mock-model',
account: {
getApiKey: () => apiKey,
getBaseUrl: () => 'http://127.0.0.1:1',
},
})
test('JSON 转发统一返回上游未配置错误', async () => {
const result = await forwardJson({ path: '/chat/completions', mapping: mapping(''), body: {} })
assert.equal(result.ok, false)
assert.equal(result.status, 503)
assert.equal(result.data.error.source, 'gateway')
assert.equal(result.data.error.code, 'upstream_not_configured')
})
test('JSON 转发统一返回网络错误', async () => {
const result = await forwardJson({
path: '/chat/completions',
mapping: mapping('test-key'),
body: {},
signal: AbortSignal.abort(),
})
assert.equal(result.ok, false)
assert.equal(result.status, 502)
assert.equal(result.data.error.source, 'provider')
assert.equal(result.data.error.provider, 'mock')
assert.equal(result.data.error.code, 'upstream_unavailable')
})
test('供应商 HTTP 错误使用统一外层,并完整保留原始错误', async (t) => {
const original = { error: { code: 'InvalidParameter', message: 'invalid image' } }
t.mock.method(globalThis, 'fetch', async () => ({
ok: false,
status: 400,
headers: new Headers({ 'x-request-id': 'provider-request-1' }),
text: async () => JSON.stringify(original),
}))
const result = await forwardJson({
path: '/images/generations',
mapping: mapping('test-key'),
body: {},
requestId: 'gateway-request-1',
})
assert.equal(result.status, 400)
assert.equal(result.data.error.source, 'provider')
assert.equal(result.data.error.provider, 'mock')
assert.equal(result.data.error.code, 'InvalidParameter')
assert.equal(result.data.error.message, 'invalid image')
assert.equal(result.data.error.request_id, 'gateway-request-1')
assert.equal(result.data.error.provider_request_id, 'provider-request-1')
assert.deepEqual(result.data.error.details, original)
})
test('视频响应体断连和超时返回失败结果,供路由记录错误', async (t) => {
for (const name of ['TypeError', 'TimeoutError']) {
t.mock.method(globalThis, 'fetch', async () => ({
ok: true,
status: 200,
text: async () => { throw Object.assign(new Error('body interrupted'), { name }) },
}))
const result = await forwardJson({ path: '/contents/generations/tasks', mapping: mapping('test-key'), body: {} })
assert.equal(result.ok, false)
assert.equal(result.status, name === 'TimeoutError' ? 504 : 502)
t.mock.restoreAll()
}
})
test('文本和图片保留兼容协议的扩展字段,视频使用公共结构', () => {
assert.equal(chatCompletionBody.parse({
model: 'text-default',
messages: [{ role: 'user', content: 'hi' }],
max_completion_tokens: 100,
}).max_completion_tokens, 100)
assert.equal(chatCompletionBody.parse({
model: 'text-default', messages: [{ role: 'user', content: 'hi' }], max_tokens: 100,
}).max_tokens, 100)
assert.equal(imageGenerationBody.parse({ model: 'image-default', prompt: 'cat', count: 2 }).count, 2)
assert.throws(() => chatCompletionBody.parse({
model: 'text-default', messages: [{ role: 'user', content: 'hi' }], web_search: true,
}))
assert.throws(() => videoSubmitBody.parse({ model: 'video-default', content: [] }))
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { parseImageUsage } from '#src/routes/ai/images.js'
// 真实响应样本(火山方舟 Seedream 文生图,2026-09-10 实测)
const realResponse = {
model: 'doubao-seedream-4-5-251128',
created: 1789023548,
data: [
{
url: 'https://ark-content-generation-v2-cn-beijing.tos-cn-beijing.volces.com/doubao-seedream-4-5/xxx.jpeg?X-Tos-Signature=yyy',
size: '2048x2048',
},
],
usage: { generated_images: 1, output_tokens: 16384, total_tokens: 16384 },
}
test('图片用量:从真实响应取张数与 token', () => {
const usage = parseImageUsage(realResponse)
assert.equal(usage.generated_images, 1)
// 上游按 token 计费,落库到 completion_tokens 供对账
assert.equal(usage.completion_tokens, 16384)
})
test('图片用量:一组多图时以 generated_images 为准', () => {
const usage = parseImageUsage({
data: [{ url: 'a' }, { url: 'b' }, { url: 'c' }],
usage: { generated_images: 3, output_tokens: 49152 },
})
assert.equal(usage.generated_images, 3)
assert.equal(usage.completion_tokens, 49152)
})
test('图片用量:没有 usage 字段时退化为数 data 数组长度', () => {
const usage = parseImageUsage({ data: [{ url: 'a' }, { url: 'b' }] })
assert.equal(usage.generated_images, 2)
assert.equal(usage.completion_tokens, 0)
})
test('图片用量:连 data 都没有则返回 null(标记缺失用量)', () => {
assert.equal(parseImageUsage(undefined), null)
assert.equal(parseImageUsage({}), null)
assert.equal(parseImageUsage({ usage: {} }), null)
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { textCostMicro, unitCostMicro, microToCost } from '#src/services/ai/pricing.service.js'
test('文本成本:单价是「元/1M tokens」时,微元数 = tokens × 单价', () => {
const price = { input_price: '2', output_price: '8' }
// 1,000,000 输入 token × 2 元/1M = 2 元 = 2,000,000 微元
assert.equal(textCostMicro(price, 1_000_000, 0), 2_000_000)
// 11 × 2 + 7 × 8 = 78 微元
assert.equal(textCostMicro(price, 11, 7), 78)
assert.equal(textCostMicro(price, 0, 0), 0)
})
test('文本成本:没有配置价格时返回 0,不抛错', () => {
assert.equal(textCostMicro(null, 100, 100), 0)
})
test('按张/按秒成本', () => {
const price = { unit_price: '0.2' } // 0.2 元/张
assert.equal(unitCostMicro(price, 1), 200_000)
assert.equal(unitCostMicro(price, 3), 600_000)
assert.equal(unitCostMicro(null, 3), 0)
})
test('微元转 decimal 字符串(入库用)', () => {
assert.equal(microToCost(78), '0.000078')
assert.equal(microToCost(2_000_000), '2.000000')
assert.equal(microToCost(0), '0.000000')
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import http from 'node:http'
import { Writable } from 'node:stream'
import { streamChatCompletion } from '#src/services/ai/gateway.service.js'
/** 起一个假上游:先吐一片、再卡住,用于验证流式空闲超时 */
const startStallingUpstream = async () => {
const server = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/event-stream' })
res.write('data: {"choices":[{"delta":{"content":"开始"}}]}\n\n')
// 之后不再发送,也不结束
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return server
}
const startSlowButAliveUpstream = async () => {
const server = http.createServer((req, res) => {
res.writeHead(200, { 'content-type': 'text/event-stream' })
let n = 0
const timer = setInterval(() => {
n += 1
if (n > 4) {
clearInterval(timer)
res.write('data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2}}\n\n')
res.write('data: [DONE]\n\n')
res.end()
return
}
res.write('data: {"choices":[{"delta":{"content":"x"}}]}\n\n')
}, 30)
req.on('close', () => clearInterval(timer))
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return server
}
const fakeReply = () => {
const raw = new Writable({ write(_chunk, _enc, cb) { cb() } })
raw.writeHead = () => {}
return { raw, request: { id: 'idle-timeout-test' }, hijack: () => {} }
}
const mappingFor = (port) => ({
provider: 'volcano',
upstreamModel: 'ep-idle',
account: { getApiKey: () => 'test-key', getBaseUrl: () => `http://127.0.0.1:${port}` },
})
test('流式空闲超时:上游卡住不再吐数据时报错,而不是永久挂起', async () => {
const upstream = await startStallingUpstream()
const port = upstream.address().port
const reply = fakeReply()
try {
await assert.rejects(
streamChatCompletion({
mapping: mappingFor(port),
path: '/chat/completions',
body: { model: 'x', stream: true, messages: [{ role: 'user', content: 'hi' }] },
reply,
idleTimeoutMs: 150,
}),
(error) => {
assert.equal(error.code, 'stream_idle_timeout')
assert.equal(error.statusCode, 504)
return true
},
)
assert.equal(reply.raw.destroyed, true)
} finally {
upstream.closeAllConnections()
upstream.close()
}
})
test('慢速但持续有数据的流不会被空闲超时误杀', async () => {
const upstream = await startSlowButAliveUpstream()
const port = upstream.address().port
try {
const result = await streamChatCompletion({
mapping: mappingFor(port),
path: '/chat/completions',
body: { model: 'x', stream: true, messages: [{ role: 'user', content: 'hi' }] },
reply: fakeReply(),
// 单次间隔 30ms,远小于 300ms 的空闲窗口;总时长 150ms 超过窗口但不应触发
idleTimeoutMs: 300,
})
assert.equal(result.ok, true)
assert.equal(result.status, 200)
assert.equal(result.usage.prompt_tokens, 3)
assert.equal(result.usage.completion_tokens, 2)
assert.ok(result.firstTokenMs !== null)
} finally {
upstream.closeAllConnections()
upstream.close()
}
})
test('上游中途断流会关闭已接管的客户端响应', async (t) => {
t.mock.method(globalThis, 'fetch', async () => ({
ok: true,
status: 200,
headers: new Headers(),
body: {
async *[Symbol.asyncIterator]() {
yield Buffer.from('data: {}\n\n')
throw new Error('upstream reset')
},
},
}))
const reply = fakeReply()
await assert.rejects(streamChatCompletion({ mapping: mappingFor(1), path: '/chat/completions', body: {}, reply }), /upstream reset/)
assert.equal(reply.raw.destroyed, true)
})
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { getTgc } from '#src/services/dms/auth.service.js'
import {
authenticate,
hasRole,
......@@ -16,6 +17,16 @@ test('authenticate: missing TGC rejects with 401', async () => {
await assert.rejects(authenticate({ headers: {} }), (err) => err.statusCode === 401)
})
test('getTgc: 畸形 percent-encoding 视为无登录态(不抛 URIError 打 5xx)', () => {
const cookie = (value) => ({ headers: { cookie: value } })
assert.equal(getTgc(cookie('TGC=%')), '')
assert.equal(getTgc(cookie('TGC=a%ZZ')), '')
assert.equal(getTgc(cookie('other=1')), '')
assert.equal(getTgc({ headers: {} }), '')
// 正常值仍要解回来(含中文)
assert.equal(getTgc(cookie(`TGC=${encodeURIComponent('含中文的tgc')}`)), '含中文的tgc')
})
test('hasRole: admin passes any role, exact role matches, other roles do not', () => {
assert.equal(hasRole({ roles: ['admin'] }, 'viewer'), true)
assert.equal(hasRole({ roles: ['viewer'] }, 'viewer'), true)
......
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { db } from '#src/db/client.js'
import { deleteApp, deleteApiKey, deleteModel } from '#src/services/ai/admin.service.js'
import { runCall } from '#src/services/ai/call-flow.js'
import { resolveQuotaPackage } from '#src/services/ai/quota.service.js'
import { refreshVideoTask } from '#src/services/ai/video.service.js'
import config from '#src/config.js'
import { listProjects } from '#src/services/dms/projects/crud.js'
import { STAGE_OWNER_ROLES } from '#src/services/dms/projects/helpers.js'
import { PROJECT_STAGES, PROJECT_STATUSES } from '#src/db/schema/projects.js'
// 只模拟查询结果,不连接数据库;意外写操作直接让测试失败。
const queryResult = (value) => new Proxy({}, {
get: (_target, key) => key === 'then'
? (resolve, reject) => Promise.resolve(value).then(resolve, reject)
: () => queryResult(value),
})
const mockSelects = (t, rows) => {
t.mock.method(db, 'select', () => {
assert.ok(rows.length, 'unexpected select')
return queryResult(rows.shift())
})
t.mock.method(db, 'delete', () => assert.fail('unexpected delete'))
t.mock.method(db, 'transaction', () => assert.fail('unexpected transaction'))
}
for (const [name, remove, rows] of [
['应用有密钥', deleteApp, [[{ id: 1 }], [{ total: 2 }]]],
['应用有用量', deleteApp, [[{ id: 1 }], [{ total: 0 }], [{ total: 3 }]]],
['密钥有用量', deleteApiKey, [[{ id: 1, key_prefix: 'mock' }], [{ total: 3 }]]],
['模型有用量', deleteModel, [[{ alias: 'mock' }], [{ version: 1 }], [{ total: 3 }]]],
]) {
test(`${name}时禁止删除`, async (t) => {
mockSelects(t, [...rows])
await assert.rejects(remove(1), { statusCode: 400 })
})
}
test('项目列表按当前成员计算能力,阶段负责人不继承项目经理权限', async (t) => {
const stage = PROJECT_STAGES.SOLUTION
mockSelects(t, [
[{ project_id: 1 }],
[{ project: { id: 1, stage, status: PROJECT_STATUSES.ACTIVE }, product_name: 'mock' }],
[{ total: 1 }],
[
{ project_id: 1, sso_id: 'manager', role: 'project_manager' },
{ project_id: 1, sso_id: 'owner', role: STAGE_OWNER_ROLES[stage] },
],
])
const { list: [project] } = await listProjects({}, {}, { ssoId: 'owner', roles: [] })
assert.equal(project.can_edit_project, false)
assert.equal(project.can_manage_team, false)
assert.equal(project.can_rollback_stage, false)
assert.equal(project.can_move_stage, true)
assert.deepEqual(project.editable_stages, [stage])
})
for (const outcome of ['success', 'missing', 'http-error', 'network-error', 'aborted']) {
test(`跨月 ${outcome} 仅真实用量计费且归入请求账期`, async (t) => {
t.mock.timers.enable({ apis: ['Date'], now: new Date(2026, 8, 30, 23, 59, 59) })
t.mock.method(db, 'select', () => queryResult([]))
const periods = []
const records = []
const tx = {
insert: () => ({ values: (row) => {
if (row.period) periods.push(row.period)
else records.push(row)
return queryResult([])
} }),
update: () => queryResult([{ affectedRows: 1 }]),
}
t.mock.method(db, 'transaction', (fn) => fn(tx))
t.mock.method(db, 'insert', () => ({ values: async (row) => records.push(row) }))
const call = runCall({
request: { id: 'mock', ai: {
appId: 1, keyId: 1,
billingMode: 'internal',
} },
mapping: { provider: 'mock', upstreamModel: 'mock' },
model: 'mock', price: { version: 1 }, type: 'text', unit: 'token',
execute: async () => {
assert.equal(periods.length, 0, '转发前不应写账本')
t.mock.timers.setTime(new Date(2026, 9, 1, 0, 0, 1).getTime())
if (outcome === 'network-error') throw new Error('network error')
if (outcome === 'aborted') throw new DOMException('aborted', 'AbortError')
return { status: ['success', 'missing'].includes(outcome) ? 200 : 502 }
},
usageOf: () => outcome === 'missing' ? null : ({ prompt_tokens: 1, completion_tokens: 1 }),
settleCostMicro: () => 50,
})
if (['network-error', 'aborted'].includes(outcome)) await assert.rejects(call)
else await call
assert.deepEqual(periods, [])
assert.equal(records.length, 1)
assert.equal(records[0].cost, outcome === 'success' ? '0.000050' : '0.000000')
})
}
test('内部应用不检查额度', async (t) => {
t.mock.method(db, 'select', () => assert.fail('内部应用不应查询额度'))
assert.equal(await resolveQuotaPackage({ appId: 1, billingMode: 'internal' }), null)
})
for (const outcome of ['succeeded', 'missing', 'failed']) {
test(`视频 ${outcome}:真实成本只计一次,失败或缺失时长不计费`, async (t) => {
const provider = config.ai.providers.volcano
const previousKey = provider.apiKey
provider.apiKey = 'mock-key'
t.after(() => { provider.apiKey = previousKey })
t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({
status: outcome === 'missing' ? 'succeeded' : outcome,
...(['succeeded', 'missing'].includes(outcome) ? { video_url: 'https://example.test/video.mp4' } : {}),
...(outcome === 'succeeded' ? { duration: 6 } : {}),
})))
let state = 'pending'
const charges = [], updates = []
const tx = {
update: () => ({ set: (values) => ({ where: async () => {
if (state !== 'pending') return [{ affectedRows: 0 }]
state = values.status
updates.push(values)
return [{ affectedRows: 1 }]
} }) }),
insert: () => ({ values: (value) => {
charges.push(value)
return queryResult([])
} }),
}
t.mock.method(db, 'transaction', (fn) => fn(tx))
const options = {
row: { id: 1, provider: 'volcano', upstream_model: 'mock', upstream_task_id: 'mock', created_at: '2026-09-30 23:59:59' },
price: { unit_price: '0.5' },
}
await Promise.all([refreshVideoTask(options), refreshVideoTask(options)])
assert.equal(updates.length, 1)
assert.equal(updates[0].cost, outcome === 'succeeded' ? '3.000000' : '0.000000')
assert.deepEqual(charges, [])
if (outcome === 'missing') assert.equal(updates[0].error_code, 'usage_missing')
})
}
......@@ -9,6 +9,7 @@ import {
projectCodeParam,
projectCreateBody,
projectUpdateBody,
solutionUpsertBody,
} from '#src/schemas/dms/projects.js'
import { createLogBody, queryLogsQuery, statsQuery } from '#src/schemas/logs.js'
import { shareBody } from '#src/schemas/wechat.js'
......@@ -36,12 +37,31 @@ test('projectCodeParam: passes a non-empty code, throws when missing', () => {
test('moveStageBody: coerces toStage to a number, requires it', () => {
assert.deepEqual(moveStageBody.parse({ to_stage: '3' }), { to_stage: 3 })
assert.deepEqual(
moveStageBody.parse({ to_stage: '20', phase_data: { solution: '方案' } }),
{ to_stage: 20, phase_data: { solution: '方案' } },
)
assert.throws(() => moveStageBody.parse({}))
})
test('initiationUpsertBody: empty projectAmount becomes undefined, numeric string coerces', () => {
test('attachment_file_url: accepts and preserves multiple files', () => {
const files = [
{ url: 'https://example.test/a.pdf', size: '10', name: 'a.pdf', type: 'application/pdf' },
{ url: 'https://example.test/b.docx', size: '20', name: 'b.docx', type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
]
const attachment_file_url = JSON.stringify(files)
assert.deepEqual(solutionUpsertBody.parse({ attachment_file_url }), { attachment_file_url })
})
test('initiationUpsertBody: empty projectAmount becomes null(清空), numeric string coerces', () => {
// 必须是 null 而不是 undefined:undefined 会被 drizzle 从 update set 里丢掉,
// 导致「清空金额」接口返回 200 但旧值还在。
const empty = initiationUpsertBody.parse({ project_amount: '' })
assert.equal(empty.project_amount, undefined)
assert.equal(empty.project_amount, null)
const cleared = initiationUpsertBody.parse({ project_amount: null })
assert.equal(cleared.project_amount, null)
// 未提交的字段仍应是 undefined,不能被误写成 null
assert.equal(initiationUpsertBody.parse({}).project_amount, undefined)
const filled = initiationUpsertBody.parse({ project_amount: '123.45' })
assert.equal(filled.project_amount, 123.45)
})
......@@ -71,8 +91,9 @@ test('可选文本字段接受 null(前端清空字段时传 null)', () => {
// cases
const c = caseCreateBody.parse({ name: 'x', description: null, product_name: null, files: null })
assert.equal(c.description, null)
assert.equal(c.product_name, null)
assert.equal(c.files, null)
// product_name 是产品表的冗余列,已从契约移除(读路径一律 join product_list)
assert.equal(c.product_name, undefined)
// projects 创建
const created = projectCreateBody.parse({
......
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createUsageParser } from '#src/services/ai/usage-parser.js'
const sse = (obj) => `data: ${JSON.stringify(obj)}\n\n`
test('完整流:从最后一条 chunk 提取 usage', () => {
const parser = createUsageParser()
parser.feed(sse({ choices: [{ delta: { role: 'assistant' } }] }))
parser.feed(sse({ choices: [{ delta: { content: '你好' } }] }))
parser.feed(sse({ choices: [], usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 } }))
parser.feed('data: [DONE]\n\n')
assert.deepEqual(parser.result(), { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 })
assert.equal(parser.hasContent(), true)
})
test('usage 所在的那行被切成两半也能解析', () => {
const parser = createUsageParser()
const line = sse({ choices: [], usage: { prompt_tokens: 3, completion_tokens: 2 } })
const cut = Math.floor(line.length / 2)
parser.feed(line.slice(0, cut))
parser.feed(line.slice(cut))
assert.equal(parser.result().prompt_tokens, 3)
})
test('多字节汉字被切成两半不会乱码,且能识别为正文', () => {
const parser = createUsageParser()
const payload = Buffer.from(sse({ choices: [{ delta: { content: '你好世界' } }] }), 'utf8')
// 在汉字中间切一刀(一个汉字 3 字节)
const cut = payload.indexOf(Buffer.from('好')) + 1
parser.feed(payload.subarray(0, cut))
parser.feed(payload.subarray(cut))
assert.equal(parser.hasContent(), true)
})
test('没有 usage 时返回 null', () => {
const parser = createUsageParser()
parser.feed(sse({ choices: [{ delta: { content: 'hi' } }] }))
parser.feed('data: [DONE]\n\n')
assert.equal(parser.result(), null)
})
test('忽略注释行、空行与非法 JSON', () => {
const parser = createUsageParser()
parser.feed(': keep-alive\n\n')
parser.feed('data: {不是JSON}\n\n')
parser.feed(sse({ choices: [{ delta: { content: 'ok' } }] }))
assert.equal(parser.hasContent(), true)
assert.equal(parser.result(), null)
})
test('reasoning_content 也算正文(思考型模型)', () => {
const parser = createUsageParser()
parser.feed(sse({ choices: [{ delta: { reasoning_content: '思考中' } }] }))
assert.equal(parser.hasContent(), true)
})
test('跨 chunk 的 CRLF 也能处理', () => {
const parser = createUsageParser()
parser.feed('data: {"choices":[{"delta":{"content":"a"}}]}\r\n\r\n')
assert.equal(parser.hasContent(), true)
})
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论