- API 契约:无归属购买的明细行 amount_rmb = null(展示「—」);汇总增 unpriced_count(未折算条数) - shared 类型同步(amount_rmb: number | null · unpriced_count: number) - 实施计划:loop 停车规则改为「自主推进 + 假设记录」(作者开发期可能缺席);新增 8 条实现假设;待定问题 #1/#3 标记已决
273 lines
19 KiB
Markdown
273 lines
19 KiB
Markdown
# 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` 内字段级细节以逐页推导为准)**
|
||
|
||
```jsonc
|
||
// 成功:查看面查询(分页 + 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 定)**
|
||
|
||
```jsonc
|
||
// 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`
|
||
|
||
**响应示例(示意)**
|
||
|
||
```jsonc
|
||
// 明细(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" | "disabled"}`;启用需上游验证) |
|
||
|
||
**清单响应字段(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` 随即算不出来——折算在展示层实时算、不落库,此为预期行为(亦可视为「改错即修」的即时反馈)
|
||
|
||
**观测响应(示意)**
|
||
|
||
```jsonc
|
||
{
|
||
"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` 边界) | 技术稿 |
|