Files
new-api-key-usage/docs/design/api_keys表字段设计.md
T
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00

11 KiB
Raw Blame History

api_keys 表字段设计

数据模型第一张表:API key 登记表——每个被登记的 API key 一行,由管理通道登记。本文记录字段清单、语义与决策理由。

项目 内容
状态 定稿(2026-10-01)
关联 docs/references/NewAPI-接口范围.md §2.⑧(/api/usage/token 字段依据)· 同文 §1(鉴权三态)· docs/design/members表字段设计.md(member_id 指向)
定稿范围 列结构 + 语义 + 写入时机;同步引擎细节不在此文;方言映射归数据访问层设计
字段数 16 = 8 本系统管理字段 + 8 上游接口字段

1. 表定位

  • 一行 = 一个登记过的 API key:在管理通道登记一次,这里多一行;之后所有轮询同步、查询都围绕它转
  • 它是四个基本问题的答案所在地:
    1. 「这个 key 登记过吗」→ key_hash 查询命中(支撑「本地查库、未命中即提示未收录」的判定流程)
    2. 「该轮询哪些 key」→ status = active 的行
    3. 「凭证安全存哪」→ key_ciphertext(加密存储)
    4. 「这个 key 归属哪个成员」→ member_id(用量统计与成员视图的归属锚点;空 = 未归属,用量归作者)
  • 与 usage_logs 一对多:每条日志经 api_key_id 指向本表;与 members 多对一:归属列在本表(见 3.9)

2. 字段清单

2.1 管理字段(8 个,本系统产生)

列 类型 值来源 消费方 / 用途
id bigserial PK 自增 usage_logs.api_key_id 外键指向
key_hash text UNIQUE 登记时 HMAC-SHA256(管理员粘贴输入) 查询命中判定(「查库优先」);不可逆
key_ciphertext bytea 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 调上游时解密原文(唯一用途)
status text 管理面启停(引擎只读) active / disabled;轮询清单 = active;disabled 数据保留、仅移出轮询
member_id bigint FK NULL 归属登记(管理通道) → members.id;成员视图与按成员统计的锚点;空 = 未归属
billing_domain text NULL 人工标注(登记 / 行内编辑) 账本域标签(如 carpool / office)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 billing_domain 对齐
registered_at timestamptz 登记时写入 空态文案「数据自该 key 登记起累积」
log_synced_at timestamptz NULL 日志同步成功时写入 「数据截止 HH:mm」的唯一来源;同步失败的显形信号(停滞)
usage_synced_at timestamptz NULL 额度采集成功时写入 额度信息的新鲜度(UI 若做)、排查

2.2 上游字段(8 个,取自 GET /api/usage/token,除 object 外全部落库)

列 类型 上游字段 说明
token_name text name 展示名(约定:展示名 = token_name)
total_granted bigint total_granted 总额度(上游原值;无限额度时无意义,照存)
total_used bigint total_used 累计已用 →「累计已用」列
total_available bigint total_available 按上游原值存(数字上等于 granted − used,但保留原值,含无限额度时的负数行为)
unlimited_quota bool unlimited_quota 驱动展示分支(「累计剩余」显示「不限」而非数字)
model_limits jsonb model_limits map 结构的自然落法
model_limits_enabled bool model_limits_enabled
expires_at timestamptz NULL expires_at 上游 0 = 永不过期 → 存 NULL(见 3.5)

3. 决策记录(含理由)

3.1 凭证双列分离:key_hash + key_ciphertext

  • key_hash(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
  • key_ciphertext(AES-256-GCM,可解密):唯一用途是调上游
  • 密钥 SK_KEY_ENCRYPTION_SECRET 经 env 注入(HKDF 派生 hmac / aes 子密钥);密钥丢失 = 全部 key 需重新登记

注解:为什么是两个字段(给非密码学背景的读者)

两列各管一件事——key_hash 管「找得到」,key_ciphertext 管「用得起来」:

  • key_hash 像单向粉碎机:同样的输入永远产出同样的指纹,指纹却反推不出原文。输入 key 时(登记或查询),服务端算出的指纹与库中比对,命中即「已登记」,零上游调用;即便整库泄露,外人拿到的也只是指纹。选用 HMAC(而非裸哈希)是多掺一味只有服务器持有的「调料」:没有它,就无法用「猜测输入 → 对照指纹」的方式离线验证。
  • key_ciphertext 像保险箱:用 SK_KEY_ENCRYPTION_SECRET 加密锁存,轮询调上游时开锁取明文;AES-GCM 自带防篡改,密文被动过则解密直接报错。
  • 两列并存的原因:加密每次都掺随机数(同一 key 两次密文不同)→ 密文不能用于查找;哈希不可逆 → 指纹还原不出明文。「可查找」与「可还原」两个需求互斥,只能分存两份形态。
  • 防线边界:能同时接触数据库与环境变量的人(服务器运营方)防不住——这是已接受的信任模型前提;本设计防的是「数据库单独落到第三方手里」的常见拖库场景(SQL 注入、备份泄露)。
  • 实现提示:两种算法均为标准件,Bun 内置 crypto 直接提供,无需第三方库。

3.2 命名:与上游对齐

  • 上游字段直接采用原名(total_granted 等),对照 docs/references/NewAPI-接口范围.md §2.⑧ 看表即懂
  • 例外:name → token_name(name 太泛;本工具以 token_name 指代展示名)

3.3 两个同步时间戳,不合并

  • 两个上游调用(日志 / 额度)独立成功、独立失败——如实建模、数据溯源
  • 「数据截止」语义钉死:只随日志同步成功前进;合并会导致额度接口故障时反向失真(日志已新,却报旧时间)
  • 为未来的额度信息展示留新鲜度表达位(UI 方案未稳定,不轻易断言其无消费方)

3.4 上游字段「全体落库」

  • /api/usage/token 除 object 外 8 个字段全部入列(含当前无消费方的 model_limits 等)
  • 这类数据随时可从上游重新获取,入列本质是「本地最新快照」;当前策略为宁多勿缺,待 UI 稳定后回审

3.5 expires_at 的 0 → NULL 转换

  • 上游 0 = 永不过期;转 NULL 表达该语义
  • 安全性依据:8 个上游字段整组一起写入(同一条 UPDATE + usage_synced_at),「未获取」由 usage_synced_at IS NULL 承载——expires_at 的 NULL 专用于「永不过期」,语义不重叠

3.6 生命周期:纯手动开关,无 401 自动摘除

  • active / disabled 两值,唯一写入者是管理面的启停操作——引擎对 status 只读
  • 停用:数据保留、移出轮询清单;启用:回清单,下一轮同步即自然验证(成败经数据截止与引擎日志立即显形,无需人工预验证环节)
  • 引擎对失败(401 / 403 / 超时等)只记录、不决策:失败不写行——log_synced_at 停滞即管理面可见信号,失败明细逐轮进引擎运行日志(母体 journald / CFW Workers Logs);持续 401 的处置(停用)是作者的管理动作
  • 为什么不做自动摘除:管理决策归人(平台呈现事实——与不做分摊计算同构);作者即运维者(自动化的受众唯一);对上游瞬时误报免疫(不杀就无误杀);代价是作者不看平台期间死 key 持续消耗轮询预算(CFW Free 档有感),可见且可控

3.7 表名:api_keys

  • 理由:与页面 /manage/api-keys 同词对齐;sk- 只是 key 的实际格式前缀(不属表名职责),api_keys 为领域通用词,与其余各表(usage_logs / subscriptions / topups / qpu_history)风格一致

3.8 归属列 member_id:外键在本表,且可空

  • 为什么外键在 key 侧:成员与 key 是 1:N——外键由 N 侧持有是标准做法;成员表的行数不随 key 数量膨胀
  • 为什么可空:未归属的 key 是真实存在的状态(测试 key、暂未分配的 key)——空值即「用量归作者」的默认归属,无需哨兵行
  • 归属变更即时生效:member_id 是管理通道的 UPDATE,不影响日志行(历史日志按行级归属自然分组——见 usage_logs表字段设计.md 3.6)

4. 写入时机(两个写入者:管理通道 / 同步引擎)

时机 动作
登记(管理通道,上游验证通过) INSERT 一行:管理字段(HMAC / 加密 / registered_at / 归属 member_id)+ 上游字段全组(一次 /api/usage/token 拿全)+ 两个 synced_at;首轮日志同步不阻塞登记响应(由同步引擎立即异步补一轮)
启停(管理通道) UPDATE status:启用 → active(回轮询清单,下一轮同步即自然验证)/ 停用 → disabled(移出轮询清单)——数据保留、不删除
归属变更(管理通道) UPDATE member_id——即时生效,不触碰日志行(统计按行级归属分组,见 3.8)
每轮轮询 仅写成功结果:UPDATE 上游字段全组 + usage_synced_at;日志同步成功后写 log_synced_at;任何失败(401 / 403 / 超时等)不写行——log_synced_at 停滞即管理面可见信号,失败明细进引擎运行日志
  • 两个上游调用各自独立:各自的成功写各自的时间戳,互不阻塞、互不污染

5. 明确不存的(已否决字段与理由)

字段 否决理由
key_hint(sk-…a1b2 尾号) 登记即有 token_name,回显价值归零(注:查看面响应里的 key_hint 是即时生成的,与本列否决无关)
别名 / 备注名 上游 token_name 已提供展示名(本地已同步该字段),再记一份是平行冗余;要改名去上游站点改(2026-09-13 定)
upstream_token_id 本地 id 足够;对账时日志行自有 token_id
last_sync_error 错误详情进服务器日志即可;UI 无展示位(有需求再加)
object / code / message 固定值 / 传输层包装,无信息量

6. 待定事项(关联后续环节)

事项 归属
轮次摘要与失败明细的观测呈现(引擎日志格式) 同步引擎设计(待立)
多 key 时「数据截止」的聚合语义 已定:取最慢
「累计已用/剩余」展示格式与无限额度分支 交互细节层
usage_logs 表字段清单 见 docs/design/usage_logs表字段设计.md