ezijing-node-server · 设计方案

AI 网关设计

面向多应用接入的中转站:火山 + DeepSeek 双上游,覆盖文本、图片、视频三种生成模型类型。 数据面走 API key,管理面(应用配置、调用日志、用量报表)落在 center-dms。

版本 v6 草案 日期 2026-09-08 状态 待评审 接入方 多应用 · 每应用多 key · 管理面在 center-dms

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 不能进浏览器

前端能拿到的 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 — 接入应用

列类型说明
idbigint PK
codevarchar(64) unique应用标识,如 center-dms
namevarchar(120)展示名
billing_modevarchar(16)internal 仅记录用量;quota 使用应用额度
statustinyint1 启用 / 0 停用(停用后该应用所有 key 失效)
operator_user_id / operator_namevarchar最后操作人(与业务表 operator_* 命名一致)

api_keys — 密钥

列类型说明
idbigint PK
app_idbigint归属应用
namevarchar(120)备注名
key_hashchar(64) uniquesha256(明文);明文只在创建时返回一次
key_prefixchar(8)前 8 位,列表展示与排查用
statustinyint1 启用 / 0 停用
operator_user_id / operator_namevarchar最后操作人(与业务表 operator_* 命名一致)

model_mappings — 别名到上游模型

列类型说明
aliasvarchar(80) PK客户端请求的 model 名
typevarchar(16)text / image / video
providervarchar(32)volcano / deepseek
upstream_modelvarchar(120)火山为 endpoint id(ep-...)
enabledtinyint下线别名置 0,保留历史归因
operator_user_id / operator_namevarchar最后操作人(与业务表 operator_* 命名一致)

model_prices — 单价(按模型类型区分计价单位)

列类型说明
provider + upstream_model联合 PK
pricing_unitvarchar(24)per_1m_tokens / per_image / per_second
input_pricedecimal(12,6)文本按输入 token
output_pricedecimal(12,6)文本按输出 token
unit_pricedecimal(12,6)图片每张 / 视频每秒
currencychar(3)暂定 CNY
effective_fromdatetime调价不改历史
operator_user_id / operator_namevarchar最后操作人(与业务表 operator_* 命名一致)

quota_usage — 账本(统一按成本计)

列类型说明
scope_typevarchar(16)api_key
scope_idvarchar(64)api_key_id
unitvarchar(8)DAY / MONTH
periodvarchar(16)2026-09-08 / 2026-09
used_microbigint已占用金额,单位 1e-6 元(整数避免浮点漂移)

前四列联合主键。当前账本只记录 API key 的日/月配额占用。

ai_usage — 调用明细(用量统计的核心)

列类型说明
request_idvarchar(64)关联 x-request-id,直接对上服务端日志
app_id / api_key_id / user_idbigint / varchar三级归因,前两级必有
typevarchar(16)text / image / video
provider / model / upstream_modelvarcharmodel 是客户端别名
unitvarchar(16)token / image / second
quantitybigint计费数量:token 数 / 张数 / 秒数
prompt_tokens / completion_tokensbigint仅文本有值
usage_sourcevarchar(16)reported / estimated
costdecimal(12,6)按调用当时价格计算
statusvarchar(16)pending / success / error / aborted
upstream_task_idvarchar(128)视频异步任务才有
http_status / error_codeint / varchar
latency_ms / first_token_msint首 token 延迟仅流式文本有
streamtinyint

索引:(app_id, created_at)、(api_key_id, created_at)、(provider, model, created_at)、(type, created_at)、(status)(惰性兜底扫 pending)。

06上游适配

文本接口两家都是 OpenAI 兼容;图片和视频目前只有火山方舟提供。

模型类型上游端点计量单位
文本火山 / DeepSeek/chat/completionstoken
图片火山/images/generations张
视频火山/contents/generations/tasks秒(异步任务)
火山方舟DeepSeek
Base URLhttps://ark.cn-beijing.volces.com/api/v3https://api.deepseek.com/v1
鉴权Authorization: Bearer <ark key>Authorization: Bearer <ds key>
模型名endpoint id,如 ep-20250901xxxxdeepseek-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 没有客观标准,因此额度包按金额管理。网关不管理合同,只记录应用可使用的总额度、有效期和实际已用金额。

应用额度与真实计费

内部应用只记录成本,不限制额度。额度结算应用在请求前确认存在生效且未用完的额度包;同一应用的生效日期范围不能重叠。

不预扣、不估算。文本按真实输入/输出 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 落库。

验收:本地 MySQL 能查到一次真实调用的明细行(带 app 与 key)

阶段二 · 流式与用量提取

SSE 透传;usage-parser 纯函数 + 单测;首 token 延迟。

验收:mock 上游跑通流式,usage 正确落库

阶段三 · 配额与账本

成本口径的 key 级软限额 / 真实计费 / 429;断开与报错分支。

验收:并发测试验证真实成本累计和视频幂等性

阶段四 · 图片

图片端点、按实际张数计价。

验收:一次真实生成落库,张数与成本正确

阶段五 · 视频(异步)

任务提交、pending 落库、查询时结算、惰性兜底。

验收:客户端只提交不查询,账目也能在下一次请求时收敛

阶段六 · 报表与限流

summary / recent / models / quota 接口;接入 @fastify/rate-limit。

验收:报表数字与 ai_usage 明细能对上

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/ 落位,不触碰现有模块。