# 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*