- docs/delivery/部署文档.md:前置条件 / 环境变量 / 一条命令链 / systemd 与 Nginx 示例 / 首次运行检查 / 运行约束(后端单实例 · 3 天窗口 · 主密钥 · 401 摘除)/ 升级 / 排查 - 从零端到端走查:清库 → db:push → 启动(空转 + QPU 基线)→ 管理面登记 → 引擎立即补一轮(984 条)→ 查看面查询(未折算「—」)→ 补录购买记录 → 金额出现 → 换算参数时间线;无 pageerror - 8 篇设计文档状态 → 已实现(2026-09-15) - README:状态行 / 文档地图(含 delivery)/ 下一步(部署 + 待补验与裁决清单) - AGENTS.md:项目速览更新
19 KiB
API 契约
前端 ↔ 服务端的接口契约:约定层(跨页共性)+ 逐页端点(按
docs/design/交互设计.md的页面清单推导)。只描述「前端能用到的 HTTP 接口」;上游侧调用不在本文(见docs/upstream/NewAPI-接口范围.md)。
| 项目 | 内容 |
|---|---|
| 状态 | 已实现(2026-09-15) |
| 关联 | docs/design/交互设计.md(页面清单与职责)· 四张表字段设计(数据形状)· docs/design/交互设计.md §0(管理面边界与口令门帘) |
| 范围 | 路径、鉴权形态、时间 / 数字形态、分页、错误与响应形状、逐页端点;校验细节、存储、调度归技术稿 |
| 不含 | 上游 NewAPI 的调用方式(同步引擎设计)· 展示文案与交互细节(交互设计细节层) |
1. 约定层(跨页共性)
2026-09-13 逐条过审:第 3 / 9 / 14 / 18 / 19 / 20 / 22 条专项讨论后落定,其余默认通过。本文已定稿,后续如需修订就地更新并注明日期。
1.1 路径与命名
| # | 约定 | 理由 |
|---|---|---|
| 1 | 查看面 API 前缀 /api/*;管理面 API 前缀 /api/manage/* |
两套鉴权模型物理隔离,口径一刀切在路径上;前端路由(/usage-log、/manage/*)与 API 路径不冲突 |
| 2 | 不加版本号前缀(不做 /api/v1) |
单实例小工具、前后端同仓同发,无多版本并行需求 |
| 3 | JSON 字段一律 snake_case,与库表列名 / 上游字段同名同形;前端 TS 类型不做 camelCase 转换 |
全链路零改名,看文档、看库、看日志是同一套词;other(jsonb 透传)内层无法转换,转换方案必然产生两套命名混用 |
| 4 | 字段命名以事实为准(如换算值带 _usd 后缀);「不标注币种」是展示层措辞约定,不约束字段名 |
契约给开发者看,语义准确优先 |
1.2 鉴权与身份
| # | 约定 | 理由 |
|---|---|---|
| 5 | 查看面无会话、无服务端状态:每个查询请求自带 key 集合,服务端仅以 key_hash 命中判定(HMAC),不存、不回显 key 原文 |
与「无登录页、无账户体系、查询域完全平等」的既定边界一致;服务端不留任何使用者痕迹 |
| 6 | 管理面单口令:每次请求携带 X-Manage-Token: <env 口令>,校验失败 → 401 manage_unauthorized;无登录端点、无令牌签发 |
口令门帘是「访问校验」而非会话;有会话就长出了用户体系的影子 |
| 7 | 任何凭证不进 Cookie、不进 URL(不进 access log / 浏览器历史 / Referer) | 不依赖 Cookie 即天然免疫 CSRF(跨站表单无法设置自定义头、JSON 体需预检);凭证不进 URL 是安全基线 |
| 8 | 查看面对「未收录 / 已停用」的 key 不返回 HTTP 错误:查询照常 200,per-key 状态在响应 meta 中反馈 |
key 未收录是查询结果的一部分(交互设计 §0.2 的「未收录」提示),不是接口失败 |
1.3 时间与时区
| # | 约定 | 理由 |
|---|---|---|
| 9 | 时间一律 ISO 8601 带显式偏移:输出统一北京时间 +08:00(+08:00 是产品口径、非服务器时区);输入宽容——接受任何带偏移的 ISO 8601(含 Z / 毫秒),服务端按绝对时刻解析 |
使用者全是北京时间语境,输出即可直接展示(前端零时区转换);输入宽容免去前端格式化负担(toISOString() 的 Z 形式可直接提交);显式偏移杜绝歧义(2026-09-13 作者裁决) |
| 10 | 所有时间范围左闭右开 [start, end) |
全项目期间惯例(purchases 设计 §3.4 同款) |
| 11 | 时间戳类字段(registered_at / log_synced_at / usage_synced_at / paid_at / effective_from)同制输出 |
一处规则管全部,前端只有一个时间处理分支 |
1.4 数字与折算
| # | 约定 | 理由 |
|---|---|---|
| 12 | 整数(quota / token 计数)以 JSON number 传输,不做字符串化 | 最大量级 ~1e9(实测 subscription_total = 8e8),远小于 2^53 安全域 |
| 13 | 折算一律在服务端完成:折尺信息(购买期间 + QPU 时间线)只有服务端持有;聚合响应给折算数值,同时保留 quota 原值 | 前端拿不到折尺;折算语义(按购买时刻取尺、跨期按购买期间边界分段)是服务端职责,前端只消费结果 |
| 14 | 服务端不做舍入、不做千分位/币种格式化:数值全精度回传 | 格式化归展示层,避免「服务端先舍入、前端再格式化」的双重失真(2026-09-13 作者裁决:保持全精度) |
| 15 | 折算不落在日志行上、不新增日志列(沿用 purchases 设计 §3.2):日志响应只给 quota 原值与 other 原始内容 |
折算随时间线变化,任何固化到日志行的值都会与时间线脱钩 |
金额展示口径(2026-09-13 作者裁决):查看面的「消费」类金额一律为折算实付人民币(按购买期间分段,每段
quota 占比 × 该笔实付)——purchases设计 §6 的「天然不含实付金额」边界就此放开;仍不上查看面的是实付总额、购买比率与购买记录本身(管理面独占)。已知代价:明细带 quota 列、QPU 公开,使用者理论上可从两者反推购买比率——接受。
1.5 查询形态与分页
| # | 约定 | 理由 |
|---|---|---|
| 16 | 明细排序固定 created_at DESC(不开放排序参数) |
日志天然时序;开放排序只增加索引负担 |
| 17 | key 集合在请求内先去重、设数量上限(超限 → 400 invalid_params) |
防御性校验:避免单请求打爆 IN (...) + HMAC 循环 |
| 18 | 分页:请求 limit + offset,响应含 total(总条数);分页响应形态 { "items": [...], "total": N, "meta": {...} } |
条数对使用者有信息价值(「这段时间共多少次调用」);时间范围过滤下深翻风险小、前端页码器直观(2026-09-13 裁决;cursor 方案已否决) |
| 19 | 查看面查询一律 POST + JSON 请求体:key 集合与过滤参数统一进体({"keys": ["sk-…"], "start": …, "end": …, "type": [...]}) |
多 key 数组最自然、复杂过滤(type 列表)好承载;密钥不进 URL、不进 access log(2026-09-13 裁决) |
1.6 响应与错误
| # | 约定 | 理由 |
|---|---|---|
| 20 | 成功 = 2xx + 裸数据体(无 { data: ... } 包装);失败 = 非 2xx + { "error": { "code": "...", "message": "..." } } |
前端判断简单(res.ok);code 供分支文案,message 供人读(2026-09-13 作者裁决:保持此形态,信封方案已否决) |
| 21 | code 为机器可读枚举:invalid_params · manage_unauthorized · not_found · internal · key_rejected(上游拒绝该 key;400)· already_registered(重复登记;409)· upstream_unavailable(上游故障 / 超时;502) |
按需增补;未知 code 前端兜底为通用错误文案(2026-09-13 扩展后三枚:前端可分清「输入错 / 重复 / 系统故障」三种处置) |
| 22 | 查看面查询响应统一带 meta 块:data_until(参与 key 中最慢的 log_synced_at)、served_at、keys[](per-key:key_id / token_name / registered / status / 各自的 log_synced_at;未收录项 = { registered: false, key_hint: "sk-…a1b2" }) |
log_synced_at 是「数据截止」唯一来源(api_keys 设计 §3.3);取最慢 = 声称的截止对每个 key 都成立——统一 tick 下正常差异为秒级(同一轮内完成先后),某 key 失败或停用才拉大,per-key 字段负责解释;key_hint 即时生成、不落库(api_keys §5 否决的是存库,不是响应携带);新登记尚未首同步的 key log_synced_at 为 null——顶层 data_until 取各 key 中非 null 的最慢值,全部未同步时为 null |
| 23 | 参与 key 全部未收录时同样 200(空结果 + meta.keys[] 标未收录) |
与约定 8 一致:未收录是结果、不是错误;前端据此渲染「未收录」提示 |
| 24 | 前后端连线(部署层约定):同域反代为主(/api/* 反代到后端——浏览器视角同源,无 CORS、前端用相对路径);跨源直连为辅——后端 CORS 白名单可配(允许 X-Manage-Token 等自定义头与 OPTIONS 预检),前端以运行时配置读取 API base(同一份产物可换环境) |
前后端独立部署(README 决策 7 修订);凭证不进 Cookie → CORS 无需 credentials 模式;运行时 base 免去「改地址要重新构建」(2026-09-13 作者裁决) |
响应示例(形状示意;items 内字段级细节以逐页推导为准)
// 成功:查看面查询(分页 + meta)
{
"items": [
{ "id": 8412, "key_id": 3, "created_at": "2026-09-11T19:37:12+08:00", "type": 2,
"model_name": "gpt-5.6-terra", "quota": 12000 }
],
"total": 1873,
"meta": {
"data_until": "2026-09-13T21:10:00+08:00",
"served_at": "2026-09-13T21:36:00+08:00",
"keys": [
{ "key_id": 3, "token_name": "小王", "registered": true, "status": "active", "log_synced_at": "2026-09-13T21:35:03+08:00" },
{ "key_id": 7, "token_name": "小李", "registered": true, "status": "disabled", "log_synced_at": "2026-09-13T21:10:00+08:00" },
{ "registered": false, "key_hint": "sk-…a1b2" }
]
}
}
// 失败:error 信封
{ "error": { "code": "invalid_params", "message": "keys 数量超过上限(最多 20 个)" } }
2. 逐页端点
按 交互设计.md 的页面清单逐页推导,每页记录:端点(方法 / 路径 / 请求 / 响应 / 错误分支)+ 页面元素到字段的映射。
2.1 /usage-log(2026-09-13 推完)
页职责(交互设计):key 输入(多个)+ 时间范围 → 明细表格;表格上方汇总卡片(累计用量、预估消费)。
端点划分(2026-09-13 定):明细与汇总分两个端点——首次并行请求(Promise.all),翻页只调明细、不重算汇总。
| # | 端点 | 职责 | 响应形状 |
|---|---|---|---|
| 1 | POST /api/usage/logs/query |
明细行(分页) | { items, total, meta } |
| 2 | POST /api/usage/summary |
卡片汇总 | { request_count, …, meta } |
明细行字段(2026-09-13 定)
- 与表 1:1 全量返回(除本系统内部列
dedupe_key):id/key_id/user_id/username/created_at/type/content/model_name/quota/prompt_tokens/completion_tokens/use_time/is_stream/channel/channel_name/token_id/group_name/ip/request_id/upstream_request_id/other- 恒定 / 空值字段(
user_id/username/channel/channel_name/ip/group_name/upstream_request_id)照返——展示与否是交互层决策
- 恒定 / 空值字段(
- 两个覆盖/计算字段:
token_name——当前名(joinapi_keys),不用日志里的快照名(同一个 key 的每行显示同一个名字;快照列仍在库中保真)amount_rmb——该行折算实付人民币(quota ÷ 购买时刻 QPU × 购买汇率;错误行quota = 0→ 0);匹配不到归属购买时为null(未登记购买 / 充值来源)——展示层显示「—」,绝不退化为额度美元(2026-09-14 定)
汇总响应字段(2026-09-13 定;2026-09-14 增 unpriced_count)
| 字段 | 含义 |
|---|---|
request_count |
时段内总行数 |
consume_count / error_count |
按 type 拆分(2 消费 / 5 错误) |
prompt_tokens / completion_tokens / cache_tokens |
token 三项合计(cache_tokens ⊆ prompt_tokens,单列可看缓存命中量) |
quota |
quota 合计(原值,供核对) |
amount_rmb |
可折算部分的实付人民币合计(跨期按购买期间分段累加) |
unpriced_count |
无法折算的行数(amount_rmb 为 null 的行)——前端据此提示「其中 N 条未登记购买、未计入金额」 |
meta |
同约定 22 |
页面元素 → 数据来源
| 页面元素 | 来源 |
|---|---|
| key 输入(多个)+ 时间范围 | 前端持有(输入即查询;服务端无状态) |
| 卡片:调用次数 | 汇总:request_count / consume_count / error_count |
| 卡片:累计用量 | 汇总:token 三项 + quota |
| 卡片:预估消费 | 汇总:amount_rmb(口径见 §1.4 说明) |
| 卡片:数据截止 | meta.data_until |
| 「未收录」提示 | meta.keys[].registered / key_hint |
| 明细表格 | items[] |
| 分页器 | total + limit / offset |
请求参数(2026-09-13 定)
// POST /api/usage/logs/query
{
"keys": ["sk-…", "sk-…"], // 必填;去重后最多 20 个(初值,超限 → invalid_params)
"start": "2026-09-06T00:00:00+08:00", // 必填;左闭右开 [start, end)
"end": "2026-09-13T00:00:00+08:00",
"type": [2, 5], // 可选;缺省 = 全部类型(0–7);只作用于本端点
"limit": 50, // 可选;默认 50,上限 200(超限 → invalid_params)
"offset": 0 // 可选;默认 0
}
// POST /api/usage/summary —— 同一套 keys / start / end;无 type / limit / offset
{ "keys": ["sk-…"], "start": "2026-09-06T00:00:00+08:00", "end": "2026-09-13T00:00:00+08:00" }
- 字段集稳定:可空列(如
upstream_request_id)返回null而非省略字段——前端类型可直接写string | null - 不设跨度上限(2026-09-13 定);非法参数(
start ≥ end、无法解析、keys空数组)→400 invalid_params
响应示例(示意)
// 明细(amount_rmb 为 null = 匹配不到归属购买:未登记 / 充值来源)
{
"items": [
{ "id": 8412, "key_id": 3, "user_id": 12, "username": "…", "created_at": "2026-09-11T19:37:12+08:00",
"type": 2, "content": "", "token_name": "小王", "model_name": "gpt-5.6-terra", "quota": 12000,
"prompt_tokens": 1500, "completion_tokens": 500, "use_time": 2, "is_stream": true,
"channel": 0, "channel_name": "", "token_id": 100, "group_name": "default", "ip": "",
"request_id": "20260911193652…", "upstream_request_id": null, "amount_rmb": 0.017,
"other": { "cache_tokens": 29056, "model_ratio": 0.075, "subscription_id": 90, "…": "…" } }
],
"total": 1873,
"meta": { "…": "见约定 22" }
}
// 汇总
{
"request_count": 1873, "consume_count": 1849, "error_count": 24,
"prompt_tokens": 1234567, "completion_tokens": 234567, "cache_tokens": 89012,
"quota": 53939847, "amount_rmb": 23.45, "unpriced_count": 0,
"meta": { "…": "见约定 22" }
}
2.2 /manage/api-keys(2026-09-13 推完)
端点(2026-09-13 定)
| # | 端点 | 职责 |
|---|---|---|
| 1 | GET /api/manage/api-keys |
清单(按 id 升序,不分页) |
| 2 | POST /api/manage/api-keys |
登记(body 只带 key;上游验证 → 入库) |
| 3 | PATCH /api/manage/api-keys/:id |
启停(`{"status": "active" |
清单响应字段(2026-09-13 定):与表 1:1(17 列,除 key_hash / key_ciphertext 两个敏感列——永不出接口)。disabled_source 使「已失效(auto_401)」与「已停用(manual)」可辨(该列为此于同日补进 api_keys 表设计,见其 §3.8)。
登记与启停细节(2026-09-13 定)
| 项 | 定法 |
|---|---|
| 登记请求 | POST {"key": "sk-…"} |
| 登记响应 | 201 + 新行完整对象(同清单形状;此时 log_synced_at: null) |
| 登记后的可见时机 | 不阻塞:验证(GET /api/usage/token)+ INSERT 即返回;首轮日志同步由同步引擎立即异步补一轮——未完成时 meta 如实给 null,前端显示「数据同步中」 |
| 启停请求 / 响应 | PATCH {"status": …} → 200 + 更新后的完整行;幂等(提交当前已是的状态不报错) |
| 启用成功的副作用 | consecutive_401s 清零——验证调用本身即一次成功调用,「连续」已断(否则下一轮若 401 会立刻再次摘除) |
| 错误分支 | key_rejected(400,上游拒绝该 key:登记验证 / 启用验证失败)· already_registered(409,key 已在库)· upstream_unavailable(502,上游故障、无法完成验证)· not_found(404,PATCH 的 id 不存在)· invalid_params(400,key 缺失 / 格式非法) |
2.3 /manage/purchases(2026-09-13 推完)
端点(2026-09-13 定)
| # | 端点 | 职责 |
|---|---|---|
| 1 | GET /api/manage/purchases |
清单(按 paid_at DESC,同时间按 id DESC;不分页) |
| 2 | POST /api/manage/purchases |
登记 |
| 3 | PATCH /api/manage/purchases/:id |
编辑(部分更新) |
| 4 | DELETE /api/manage/purchases/:id |
删除(物理) |
| 5 | GET /api/manage/purchases/observations |
观测(核对提示的数据面) |
字段与必填(2026-09-13 定)
- 清单 / 登记响应:与表 1:1(9 列)
- 登记必填:
kind/paid_at/paid_amount/quota_granted(前三为支付事实;quota_granted是折算链的分母);note与订阅标识 / 期间(subscription_id/period_start/period_end)可后补——缺失时该笔暂无法自动匹配日志,由观测端点引导补录 - 编辑:
PATCH部分更新语义,8 个录入字段全部可改(含kind) - 删除:物理删除;该笔对应日志的
amount_rmb随即算不出来——折算在展示层实时算、不落库,此为预期行为(亦可视为「改错即修」的即时反馈)
观测响应(示意)
{
"items": [
{ "subscription_id": 90, "plan_title": "Max", "observed_quota_total": 800000000,
"first_seen_at": "2026-09-01T00:00:00+08:00", "last_seen_at": "2026-09-13T21:10:00+08:00",
"log_count": 976, "matched_purchase_id": null }
]
}
- 数据源:日志
other的subscription_id/subscription_plan_title/subscription_total按 ID 聚合(jsonb) matched_purchase_id非空 = 已对应某条购买记录;null= 观测到但未登记(核对提示的目标)
错误码:invalid_params(校验失败)· not_found(编辑 / 删除目标不存在)
2.4 /manage/qpu(2026-09-13 推完;无实质分叉,按默认定)
- 端点:
GET /api/manage/qpu——时间线清单(按effective_from升序;不分页) - 字段:与表 1:1(
id/qpu/effective_from,3 列) - 纯只读:无任何写端点(
qpu_history表设计 §3.5 已锁定只读;管理通道仅提供查看)
2.5 /dashboard(二期候选 · 占位)
本页为二期候选(一期可不实现),本期不推导;届期按同一流程补端点与字段。数据面天然具备(本地全量日志 + 聚合能力),无需预留接口。
3. 待裁决与待定
| 事项 | 归属 |
|---|---|
同步引擎调度口径(一轮 = 遍历全部 active key 的统一 tick——2026-09-13 作者确认方向) |
同步引擎设计(失败重试策略等细节随行) |
| 管理面口令的传输加固(常量时间比较等) | 技术稿 |
各端点参数校验细则(key 格式与条数、时间解析失败、type 取值校验、limit / offset 边界) |
技术稿 |