Files
new-api-key-usage/docs/design/API契约.md
T
skycurtain 1295667c94 feat(contract): amount_rmb 支持 null + 汇总增 unpriced_count;实施计划补「实现假设」机制
- API 契约:无归属购买的明细行 amount_rmb = null(展示「—」);汇总增 unpriced_count(未折算条数)
- shared 类型同步(amount_rmb: number | null · unpriced_count: number)
- 实施计划:loop 停车规则改为「自主推进 + 假设记录」(作者开发期可能缺席);新增 8 条实现假设;待定问题 #1/#3 标记已决
2026-09-15 00:12:19 +08:00

19 KiB
Raw Blame History

API 契约

前端 ↔ 服务端的接口契约:约定层(跨页共性)+ 逐页端点(按 docs/design/交互设计.md 的页面清单推导)。只描述「前端能用到的 HTTP 接口」;上游侧调用不在本文(见 docs/upstream/NewAPI-接口范围.md)。

项目 内容
状态 定稿(2026-09-13:约定层 23 条 + 逐页端点——四页推完、/dashboard 二期占位)
关联 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——当前名(join api_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 边界) 技术稿