# api_keys 表字段设计 > 数据模型第一张表:API key 登记表——每个被登记的 API key 一行,**由管理通道登记**(2026-09-13 起,取代原「输入即注册」)。本文记录字段清单、语义与决策理由;`usage_logs` 表待对齐后另文记录。 | 项目 | 内容 | |------|------| | 状态 | 已实现(2026-09-15) | | 关联 | `docs/upstream/NewAPI-sk-key接口文档.md`(接口①字段依据)· `docs/upstream/NewAPI-接口范围.md` · `docs/design/API契约.md` §2.2(登记 / 启停端点) | | 定稿范围 | 列结构 + 语义 + 写入时机;同步引擎细节(401 阈值等)不在此文 | | 字段数 | 17 = 9 本系统管理字段 + 8 上游接口字段 | --- ## 1. 表定位 - **一行 = 一个登记过的 API key**:在管理通道登记一次,这里多一行;之后所有轮询同步、查询都围绕它转 - 它是三个基本问题的答案所在地: 1. 「这个 key 登记过吗」→ `key_hash` 查询命中(支撑「本地查库、未命中即提示未收录」的判定流程) 2. 「该轮询哪些 key」→ `status = active` 的行 3. 「凭证安全存哪」→ `key_ciphertext`(加密存储) - 与 `usage_logs` 一对多:每条日志经 `key_id` 指向本表 ## 2. 字段清单 ### 2.1 管理字段(9 个,本系统产生) | 列 | 类型 | 值来源 | 消费方 / 用途 | |----|------|--------|---------------| | `id` | bigserial PK | 自增 | `usage_logs.key_id` 外键指向 | | `key_hash` | text UNIQUE | 登记时 HMAC-SHA256(管理员粘贴输入) | 查询命中判定(「查库优先」);不可逆 | | `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) | | `status` | text | 401 摘除 / 手动启停 | `active` / `disabled`;「key 已失效」提示与轮询范围 | | `disabled_source` | text NULL | 401 摘除 → `auto_401`;手动停用 → `manual`;启用 / 登记时清 NULL | 管理面区分「已失效」与「已停用」(见 3.8) | | `consecutive_401s` | int | 每轮同步:401 时 +1,成功清零 | 「连续 401 自动摘除」的判定状态 | | `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,可解密):唯一用途是调上游 - 密钥 `KEYS_MASTER_KEY` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档) > **注解:为什么是两个字段(给非密码学背景的读者)** > > 两列各管一件事——`key_hash` 管「找得到」,`key_ciphertext` 管「用得起来」: > > - `key_hash` 像**单向粉碎机**:同样的输入永远产出同样的指纹,指纹却反推不出原文。输入 key 时(登记或查询),服务端算出的指纹与库中比对,命中即「已登记」,零上游调用;即便整库泄露,外人拿到的也只是指纹。选用 HMAC(而非裸哈希)是多掺一味只有服务器持有的「调料」:没有它,就无法用「猜测输入 → 对照指纹」的方式离线验证。 > - `key_ciphertext` 像**保险箱**:用 `KEYS_MASTER_KEY` 加密锁存,轮询调上游时开锁取明文;AES-GCM 自带防篡改,密文被动过则解密直接报错。 > - **两列并存的原因**:加密每次都掺随机数(同一 key 两次密文不同)→ 密文不能用于查找;哈希不可逆 → 指纹还原不出明文。「可查找」与「可还原」两个需求互斥,只能分存两份形态。 > - **防线边界**:能同时接触数据库与环境变量的人(服务器运营方)防不住——这是已接受的信任模型前提;本设计防的是「数据库单独落到第三方手里」的常见拖库场景(SQL 注入、备份泄露)。 > - 实现提示:两种算法均为标准件,Bun 内置 `crypto` 直接提供,无需第三方库。 ### 3.2 命名:与上游对齐 - 上游字段直接采用原名(`total_granted` 等),对照 `docs/upstream/NewAPI-sk-key接口文档.md` 看表即懂 - 例外:`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 生命周期 - `active` →(连续 N 次 401)→ `disabled` + `disabled_source = auto_401`(数据保留、不再轮询)→(管理通道手动启用、上游验证通过)→ `active` + 清 `disabled_source` - 管理通道也可手动停用(`disabled_source = manual`)——与 401 摘除是同一状态、不同来源(见 `docs/design/交互设计.md` §3.2) - N 的取值属同步引擎设计(不在此文) ### 3.7 表名:从 `sk_keys` 更名 `api_keys`(2026-09-13) - 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余三表(`usage_logs` / `purchases` / `qpu_history`)风格一致 ### 3.8 新增 `disabled_source`:停用来源可辨(2026-09-13) - 起因:「已失效(401 自动摘除)」与「已停用(手动)」需要在管理面区分——`status` 只有 `active` / `disabled` 两值,401 自动摘除与手动停用落在同一个值上,事后无法区分 - 方案对比: - ① 用 `consecutive_401s` 推断——不可靠(手动停用会保留停用前的任意值,成功同步又会清零) - ② 拆分 `status` 取值(如 `auto_disabled` / `manual_disabled`)——污染状态机,启停与轮询逻辑分叉 - ③ **新增一列记录来源**——最直接;取值 `manual` / `auto_401`,`active` 时为 NULL - 代价:字段数 16 → 17(2026-09-13 由契约推导回头补列) ## 4. 写入时机(两个写入者:管理通道 / 同步引擎) | 时机 | 动作 | |------|------| | **登记**(管理通道,上游验证通过) | INSERT 一行:管理字段(HMAC / 加密 / registered_at,`disabled_source` 为 NULL)+ 上游字段全组(一次 `/api/usage/token` 拿全)+ 两个 synced_at;**首轮日志同步不阻塞登记响应**(由同步引擎立即异步补一轮,见 `docs/design/API契约.md` §2.2) | | **启停**(管理通道) | UPDATE `status` + `disabled_source`:启用(上游验证通过 → `active` + 清 NULL + `consecutive_401s` 清零——验证调用本身即一次成功调用)/ 停用(→ `disabled` + `manual`)——数据保留、不删除 | | **每轮轮询** | UPDATE:上游字段全组 + `usage_synced_at`;日志同步成功后写 `log_synced_at`;401 计数随结果更新;连续 N 次 401 → `status = disabled` + `disabled_source = auto_401` | - 两个上游调用各自独立:**各自的成功写各自的时间戳,互不阻塞、互不污染** ## 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. 待定事项(关联后续环节) | 事项 | 归属 | |------|------| | 401 摘除阈值 N | 同步引擎设计 | | 多 key 时「数据截止」的聚合语义 | 已定:取最慢(`docs/design/API契约.md` 约定 22) | | 「累计已用/剩余」展示格式与无限额度分支 | 交互细节层 | | `usage_logs` 表字段清单 | 已定稿:见 `docs/design/usage_logs表字段设计.md` |