01目标与边界
对外暴露 OpenAI 兼容的接口,对内路由到火山或 DeepSeek,把每一次调用归因到「哪个应用、哪个 key」,并记录用量与成本。
要做
- 多应用接入:每个应用一个或多个 API key,用量按应用和 key 查看,配额在 key 上配置。
- 三种模型类型:文本(token 计费、流式)、图片(按张)、视频(按秒、异步任务)。
- OpenAI 兼容入口:文本走
POST /api/ai/chat/completions,接入方无需改 SDK。 - 模型映射:客户端请求别名,网关翻译成上游真实模型(火山用 endpoint id)。
- 用量统计:每次调用一行明细,含应用、key、用量、成本、耗时、状态。
- 配额控制:key 级预算,超额返回 429。
- 管理面:应用 / key / 模型映射 / 价格的配置接口,以及调用日志与报表查询——全部由 center-dms 调用与展示。
暂不做
- 按用户配额(账本结构已预留,见 §08)。
- 接入方自带上游密钥(统一用网关的密钥,便于归因与结算)。
- 多租户账单、充值、发票。
- Prompt / 生成内容留存(只存计量元数据)。
- Agent 编排与工具调用——网关只负责转发与计量。
02关键决策
以下七条是后续所有设计的约束。
- 上游
- 火山方舟 + DeepSeek,文本接口均为 OpenAI 兼容,可共用一个适配器。
- 模型类型范围
- 文本 / 图片 / 视频三种;图片按张、视频按秒。
- 接入方
- 多应用,每应用多 key(例如同一产品下每所学校一个 key);用量按应用 + key + 用户三维归因。
- 配额粒度
- key 级;用户级配额由业务系统自己控制(网关只记录用量)。
- 传输方式
- 文本必须支持流式(前端默认
stream: true)。 - 管理面
- 应用/key/映射/价格的配置与日志、报表查看,都在 center-dms;网关只提供接口。
- 计量统一
- 三种模型类型按成本(金额)记账,避免 token / 张 / 秒 无法比较。
沿用仓库既有约定:接口字段 snake_case、校验用路由 schema:{}、共享资源用 fastify-plugin 装饰、跨目录引用 #src/*。网关是新增的一个域,不改动现有任何一层。
03三种调用形态
文本同步流式、图片同步一次性、视频异步任务——三条路径的结算时机完全不同。
文本:同步 + 流式
flowchart LR A["客户端
stream: true"] --> B{"鉴权
key → app"} B -- 无效 --> E1["401"] B --> C["模型映射"] C --> D{"检查已用成本
key 日/月软限额"} D -- 超额 --> E2["429"] D --> F["转发上游
注入 include_usage"] F --> G["SSE 透传
旁路解析 usage"] G --> H["计费
真实用量"] H --> I[("ai_usage")]
图片:同步一次性
响应直接带生成结果,没有流。按响应中的实际张数计费并落库。
视频:异步任务
flowchart LR A["POST 提交任务"] --> B["检查已用成本
不占额度"] B --> C["上游返回 task_id"] C --> D["落 pending"] D --> E{"客户端查询状态"} E -- 进行中 --> E E -- 完成 --> F["结算
真实时长 × 单价"] F --> G["更新 success"] E -- 失败 --> H["不计费
更新 error"] D -.-> I["惰性兜底
该 key 下次请求时结算"]
不跑后台定时器:结算发生在客户端查询任务状态时(主路径)。为防止客户端从此不再查询、账目长期悬挂,在该 key 下一次发起任意请求时顺带结算其过期的 pending 任务——查询驱动,不引入常驻轮询。
四个边界情况
| 情况 | 处理 | 落库状态 |
|---|---|---|
| 正常结束 | 按真实用量累计成本 | success |
| 客户端中途断开(文本) | 标记缺失用量待核对,不估算费用 | aborted |
| 上游返回错误 | 不扣额度,只记录 | error |
| 视频任务失败 | 不计费 | error |
03b业务系统接入
调用方(业务系统)用自己应用的 key 调网关,并在请求头里带上「谁在用」。
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 }
网关不维护使用人名单,也不做用户级限额——每个业务系统的人员模型不同。业务系统在调用前先查自己的额度,用完就自己拒绝;网关只负责把用量准确记到 (应用, key, 用户) 上。
业务系统查询用量
用同一个 key 调网关的用量接口,结果自动限定在本应用内,业务系统拿回去自己拼装页面:
GET /api/ai/v1/usage/summary?group_by=user|model|type|day&from=&to=
GET /api/ai/v1/usage?user_id=&model=&page=&limit=
前端能拿到的 key 等于公开的。若 AI 功能在前端直接发起,应由业务系统后端转发(key 留在服务端,用户标识从 session 取,归因才可信),或由后端签发短期令牌后再直连。
04目录落位
完全套用现有结构,新域与 dms / logs 同级。数据面与管理面用子目录分开,各自挂 autohooks,互不影响。
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
05数据模型
六张表:四张配置、两张计量。全部 MySQL + Drizzle,复用 columns.js 的时间戳。
apps — 接入应用
| 列 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | |
| code | varchar(64) unique | 应用标识,如 center-dms |
| name | varchar(120) | 展示名 |
| billing_mode | varchar(16) | internal 仅记录用量;quota 使用应用额度 |
| status | tinyint | 1 启用 / 0 停用(停用后该应用所有 key 失效) |
| operator_user_id / operator_name | varchar | 最后操作人(与业务表 operator_* 命名一致) |
api_keys — 密钥
| 列 | 类型 | 说明 |
|---|---|---|
| id | bigint PK | |
| app_id | bigint | 归属应用 |
| name | varchar(120) | 备注名 |
| key_hash | char(64) unique | sha256(明文);明文只在创建时返回一次 |
| key_prefix | char(8) | 前 8 位,列表展示与排查用 |
| status | tinyint | 1 启用 / 0 停用 |
| operator_user_id / operator_name | varchar | 最后操作人(与业务表 operator_* 命名一致) |
model_mappings — 别名到上游模型
| 列 | 类型 | 说明 |
|---|---|---|
| alias | varchar(80) PK | 客户端请求的 model 名 |
| type | varchar(16) | text / image / video |
| provider | varchar(32) | volcano / deepseek |
| upstream_model | varchar(120) | 火山为 endpoint id(ep-...) |
| enabled | tinyint | 下线别名置 0,保留历史归因 |
| operator_user_id / operator_name | varchar | 最后操作人(与业务表 operator_* 命名一致) |
model_prices — 单价(按模型类型区分计价单位)
| 列 | 类型 | 说明 |
|---|---|---|
| provider + upstream_model | 联合 PK | |
| pricing_unit | varchar(24) | per_1m_tokens / per_image / per_second |
| input_price | decimal(12,6) | 文本按输入 token |
| output_price | decimal(12,6) | 文本按输出 token |
| unit_price | decimal(12,6) | 图片每张 / 视频每秒 |
| currency | char(3) | 暂定 CNY |
| effective_from | datetime | 调价不改历史 |
| operator_user_id / operator_name | varchar | 最后操作人(与业务表 operator_* 命名一致) |
quota_usage — 账本(统一按成本计)
| 列 | 类型 | 说明 |
|---|---|---|
| scope_type | varchar(16) | api_key |
| scope_id | varchar(64) | api_key_id |
| unit | varchar(8) | DAY / MONTH |
| period | varchar(16) | 2026-09-08 / 2026-09 |
| used_micro | bigint | 已占用金额,单位 1e-6 元(整数避免浮点漂移) |
前四列联合主键。当前账本只记录 API key 的日/月配额占用。
ai_usage — 调用明细(用量统计的核心)
| 列 | 类型 | 说明 |
|---|---|---|
| request_id | varchar(64) | 关联 x-request-id,直接对上服务端日志 |
| app_id / api_key_id / user_id | bigint / varchar | 三级归因,前两级必有 |
| type | varchar(16) | text / image / video |
| provider / model / upstream_model | varchar | model 是客户端别名 |
| unit | varchar(16) | token / image / second |
| quantity | bigint | 计费数量:token 数 / 张数 / 秒数 |
| prompt_tokens / completion_tokens | bigint | 仅文本有值 |
| usage_source | varchar(16) | reported / estimated |
| cost | decimal(12,6) | 按调用当时价格计算 |
| status | varchar(16) | pending / success / error / aborted |
| upstream_task_id | varchar(128) | 视频异步任务才有 |
| http_status / error_code | int / varchar | |
| latency_ms / first_token_ms | int | 首 token 延迟仅流式文本有 |
| stream | tinyint |
索引:(app_id, created_at)、(api_key_id, created_at)、(provider, model, created_at)、(type, created_at)、(status)(惰性兜底扫 pending)。
06上游适配
文本接口两家都是 OpenAI 兼容;图片和视频目前只有火山方舟提供。
| 模型类型 | 上游 | 端点 | 计量单位 |
|---|---|---|---|
| 文本 | 火山 / DeepSeek | /chat/completions | token |
| 图片 | 火山 | /images/generations | 张 |
| 视频 | 火山 | /contents/generations/tasks | 秒(异步任务) |
| 火山方舟 | DeepSeek | |
|---|---|---|
| Base URL | https://ark.cn-beijing.volces.com/api/v3 | https://api.deepseek.com/v1 |
| 鉴权 | Authorization: Bearer <ark key> | Authorization: Bearer <ds key> |
| 模型名 | endpoint id,如 ep-20250901xxxx | deepseek-chat / deepseek-reasoner |
| include_usage | 支持 | 支持 |
两家都支持 stream_options.include_usage,所以流式文本的用量可以统一走 reported;上游没返回 usage 时不估算费用,明细标成 missing 待核对。
上游密钥走环境变量(VOLCANO_API_KEY / DEEPSEEK_API_KEY),不进数据库。上游清单放 services/ai/providers.js,一个 provider 一条配置,按模型类型声明可用端点。接入方不需要也不允许自带上游 key,统一用网关密钥——归因和结算才说得清。
现有 logs.service.js 已经在跟踪这三个端点,说明调用量真实存在——网关上线后这批流量可以直接迁过来。
07用量提取
文本要从 SSE 分片里捞 usage;图片和视频的用量在响应体里,直接读。
| 模型类型 | 用量来源 | 拿不到时 |
|---|---|---|
| 文本(流式) | 注入 stream_options.include_usage,最后一条 chunk(choices: [])带 usage | 不估算,标 missing 待核对 |
| 文本(非流式) | 响应体 usage 字段 | 同上 |
| 图片 | 响应里的实际张数(usage.generated_images 或结果数组长度) | 标 missing 待核对 |
| 视频 | 任务完成后返回的时长 | 标 missing 待核对 |
// services/ai/usage-parser.js —— 纯函数,输入分片字符串,输出累计用量
export const createUsageParser = (provider) => {
let usage = null
return {
feed(chunk) { /* 解析 SSE data 行,捕获最后一条 usage */ },
result() { return usage }, // { prompt_tokens, completion_tokens } | null
}
}
流式透传写法
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()
关键点:用 reply.raw 直写,不走 Fastify 序列化——SSE 是流,任何缓冲都会破坏前端的实时性。
08配额与账本
三种模型类型计量单位不同,统一按实际成本记账;只有额度结算应用才校验应用额度包。
一张图、一秒视频折算成多少 token 没有客观标准,因此额度包按金额管理。网关不管理合同,只记录应用可使用的总额度、有效期和实际已用金额。
应用额度与真实计费
内部应用只记录成本,不限制额度。额度结算应用在请求前确认存在生效且未用完的额度包;同一应用的生效日期范围不能重叠。
不预扣、不估算。文本按真实输入/输出 Token,图片按实际张数,视频完成后按真实时长累计到额度包;视频状态更新和计费在同一事务内,只执行一次。
缺失用量或客户端断开时不估算费用,记录 usage_source=missing 供核对,成本 0 表示尚未确认。保留提交时价格版本及视频提交账期。
09报表接口
直接聚合明细表,不做预聚合——数据量到百万级再考虑。
GET /api/ai/usage/summary
?group_by=app | api_key | model | provider | type | day
&from=2026-09-01&to=2026-09-30
&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" }
} }
管理面(center-dms 用,DMS 登录态鉴权):GET /api/ai/admin/usage/summary、/usage/recent、/quota。
数据面(业务系统用,API key 鉴权、自动限定本应用):GET /api/ai/v1/usage/summary、/usage。
10分阶段实施
每阶段独立可验证,且都能用本地已有的 MySQL 跑真实数据。
阶段一 · 骨架与文本
六张表(建表 SQL)+ 应用与 key 管理;鉴权钩子;模型映射;非流式文本透传;ai_usage 落库。
阶段二 · 流式与用量提取
SSE 透传;usage-parser 纯函数 + 单测;首 token 延迟。
阶段三 · 配额与账本
成本口径的 key 级软限额 / 真实计费 / 429;断开与报错分支。
验收:并发测试验证真实成本累计和视频幂等性阶段四 · 图片
图片端点、按实际张数计价。
验收:一次真实生成落库,张数与成本正确阶段五 · 视频(异步)
任务提交、pending 落库、查询时结算、惰性兜底。
阶段六 · 报表与限流
summary / recent / models / quota 接口;接入 @fastify/rate-limit。
11测试策略
| 对象 | 方式 | 为什么 |
|---|---|---|
usage-parser | 纯函数单测,喂各种 SSE 分片 | 最容易出 bug,且完全可离线验证 |
quota.service | 打本地 MySQL 跑真实多 scope 事务 | 并发与原子性只有真库能验 |
| 网关透传 | 起 mock 上游,覆盖流式 / 报错 / 断开 | 不依赖真实上游配额与网络 |
| 视频按需结算 | mock 任务状态流转,验证 pending → success / error 与惰性兜底 | 异步路径分支多 |
| 报表聚合 | 插入固定数据后断言聚合结果 | 数字必须可复现 |
沿用现有 test/unit 与 test/routes 的划分,网关测试同样不需要 .env。
12待定问题
- 图片/视频的真实计费口径——图片已确认按张;视频按秒还是按次、是否区分分辨率档位,需要以火山账单为准校准价格表。
- 惰性兜底的有效期——超过 30 分钟的
pending在下一次请求时查询上游,按实际终态处理,不自动判失败。 - 应用与 key 的创建方式——后台页面、还是先用脚本初始化。
确认上面几条后即可开始阶段一:六张表建表 + 应用/key 初始化 + 文本非流式透传骨架。代码按 routes/api/ai/ · services/ai/ · db/schema/ai/ 落位,不触碰现有模块。