api_keys_issue_analysis.md 6.7 KB

api_keys 表两个问题的原因分析

问题现象

  1. 重复的 key_version=288:表中存在两条 key_version=288 的记录(id=345 与 id=349)。
  2. 出现 is_active=0:存在多条 is_active=0 的记录(如 id=345,346,347,348),而期望是仅用 expire_at 判断是否失效,is_active 保持为 1。

根因分析

1. is_active=0 的来源:backend-client 在写库

api_keys 表由 backend-admin 与 backend-client 共用。backend-admin 只负责插入新密钥(is_active=1),从未把已有密钥改为 is_active=0。

把密钥标成“非活跃”的逻辑在 backend-client 的 app/services/key_service.py 中:

位置 行为
get_public_key (约 45–47 行) 若当前取到的密钥已过期(expire_at < now),则 api_key.is_active = 0 并 commit,再取“下一个”密钥返回。
get_active_key_pair (约 84–85 行) 同上:发现过期就把该密钥 is_active = 0。
rotate_keys (约 199–204 行) 轮换时先把所有 is_active=1 的密钥统一 update({"is_active": 0}),再生成新密钥。
check_and_rotate_expired_keys (约 391–399 行) 把所有“已过期且 is_active=1”的密钥遍历并置为 key.is_active = 0。

因此:is_active=0 是 client 端在“发现过期”或“轮换/检查过期”时写库导致的。若设计上希望“只凭 expire_at 判断是否有效、不修改 is_active”,则不应在 client 里改 is_active。


2. key_version=288 重复的来源:admin 定时任务 + client 把 288 置为 is_active=0

admin 定时任务(backend-admin/app/background_tasks.py 中 _key_expiry_check_task)的逻辑是:

  1. 取当前密钥:current_key = 满足 is_active=1 且 key_version 最大的那条。
  2. 若当前密钥快过期(剩余时间 ≤ key_advance_generate_minutes):
    • 检查是否存在 key_version == current_key.key_version + 1 且 is_active == 1 的密钥。
    • 若不存在,则生成一条新密钥,版本号 = current_key.key_version + 1。

时间线可以还原为:

  1. 某时刻 287 为“当前密钥”,任务生成了 288(对应 id=345),此时 287、288 均为 is_active=1。
  2. 之后 288 过期,backend-client 在 get_public_key / get_active_key_pair 或 check_and_rotate_expired_keys 中把 288 的 is_active 置为 0。
  3. 此时 admin 端再跑定时任务时:
    • “当前密钥”变为最大且 is_active=1 的那条,即 287(288 已被标成 0)。
    • 任务检查:是否存在 key_version=288 且 is_active=1?
      只有 id=345 的 288,且已被置为 is_active=0,所以不存在。
    • 任务于是再次生成一条 version=288 的新密钥(id=349),且 is_active=1。

因此:重复的 key_version=288 是因为 client 把“旧 288”标成 is_active=0 后,admin 的“当前密钥”回退到 287,又按“没有 288 且 is_active=1”的条件重新生成了 288。


设计冲突小结

  • backend-admin:只插入新密钥,不修改旧密钥的 is_active;“当前密钥” = is_active=1 且版本最大;是否过期依赖 expire_at 的查询条件(如 encrypt 里 expire_at > now)。
  • backend-client:在“取公钥/密钥对”和“轮换、检查过期”时会把过期或轮换掉的密钥设为 is_active=0,并依赖 is_active=1 取“当前密钥”。

两边的约定不一致:admin 不写 is_active=0,client 会写;admin 的定时任务又依赖“is_active=1 + 版本号”判断是否已有下一版密钥,一旦 client 把某版本置为 0,admin 就会认为“下一版不存在”从而再次生成同版本号的新密钥,导致版本号重复。


修复建议

方案 A:以 expire_at 为准,client 不再改 is_active(推荐)

  • 约定:api_keys 的“是否有效”仅由 expire_at 和 is_deleted 决定;所有地方都不再写入 is_active=0,插入时一律 is_active=1。
  • backend-client 修改:
    • get_public_key、get_active_key_pair:若当前密钥已过期,不要写 api_key.is_active = 0;只按“未删除且未过期”重新查下一条密钥并返回(或返回错误)。
    • rotate_keys:若仍由 client 触发轮换,不应“把所有 is_active=1 置 0”;只生成新密钥即可(或不再在 client 做全局置 0)。
    • check_and_rotate_expired_keys:不要对过期密钥写 is_active=0;只负责在需要时生成新密钥(或移除该逻辑,由 admin 定时任务统一生成)。
  • backend-admin:
    • 定时任务中“是否已有下一版密钥”的检查改为:存在 key_version == current_key.key_version + 1 且未删除且未过期(或直接存在该版本即可),而不再要求 is_active==1,这样即使 client 误写了 is_active=0 也不会重复生成。
    • 若采用“只凭 expire_at”,可进一步把“当前密钥”查询改为:未删除、未过期、版本最大,不再依赖 is_active(或保留 is_active 仅作展示/兼容,不参与逻辑)。

这样可以从根源上避免 is_active=0 被误用,并避免因 is_active 被改成 0 而触发的版本号重复。

方案 B:保留 is_active,但由 admin 统一维护

  • 约定:只有 backend-admin 可以写 api_keys(包括 is_active);backend-client 只读 api_keys,不更新任何字段。
  • backend-client:删除所有对 APIKey.is_active 的赋值和 update;取密钥时只按 is_deleted=0 与 expire_at > now(及需要的 key_version)查询。
  • backend-admin:在“生成新密钥”时(定时任务或手动轮换),按业务需要决定是否把“上一批”密钥设为 is_active=0;若设,需保证“当前密钥”与“是否存在下一版”的判断与 is_active 一致,且不会因 client 写库而出现版本回退导致的重复生成。

建议优先采用方案 A,逻辑更简单,且与“用过期时间判断失效”的预期一致。


数据修复(针对当前脏数据)

  • 重复的 key_version=288:保留一条作为“正式 288”,另一条视情况标记删除或移入历史。例如保留 id=349(当前 is_active=1、更新),将 id=345 的 288 标记为 is_deleted=1 或仅作历史保留,避免业务再使用。
  • is_active=0 的记录:若采用方案 A,可批量把“未过期”的密钥的 is_active 改回 1(仅做数据修复);若已过期,可保持为 0 或按清理策略标记删除。

修复 SQL 需根据你们对“保留哪条 288、是否物理删除”的决策再写,此处不给出具体 SQL,以免误删。


文档生成时间:2026-02-21