# 密钥迁移健壮性设计

> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
>
> 背景：R-PD-1（全局代码 review 2026-06-15 §🔴 P1 需设计）— 编辑 provider 提交空 `api_key` 时，无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`，**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废，密钥永久丢失。
> 状态：✅ **已落地**(2026-06-28 核验:provider.rs:104-136 即时迁移补密钥 + 阻断保存 + secret.rs 启动迁移) | 创建：2026-06-15 | 来源：全局代码 review 2026-06-15

---

## 一、问题复现：精确触发条件

### 1.1 触发链路

前置条件（**未迁移态**）：
- 历史 DB：`ai_providers.api_key` 列存有明文密钥（FR-S1 之前的老数据）。
- keyring：对应 `provider_id` 无 entry（迁移未成功，或启动迁移被跳过/失败）。
- 即「DB 有明文、keyring 空」的双源不一致态。

操作：
1. 用户进入「设置 → 提供商」，点编辑某 provider。
2. 仅修改 `name` / `base_url`（**不重新填 `api_key`**）。
3. 前端按约定把空 `api_key` 字段传给 IPC（约定：空 = 不改密钥）。
4. 后端 `ai_save_provider` 命中空 `api_key` 分支 → 不写 keyring → `record.api_key = String::new()` → `INSERT OR REPLACE` 全字段覆盖。

### 1.2 keyring / DB 状态时序

```
                   DB.api_key      keyring
─────────────────────────────────────────────
T0  初始(老明文)      "sk-real"        (空)
T1  编辑提交空 key    → commands.rs:334 record.api_key=String::new()
T2  INSERT OR REPLACE  "sk-real" 覆盖为 ""   (仍空)
T3  resolve_provider_secret
     record.api_key 空 → fallback keyring   → 仍空
T4  build_provider_for → ensure_resolved_key → Err「未读取到密钥」
T5  provider 报废，密钥永久丢失（无任何日志/提示）
```

**关键坏点**：T0→T2 的「DB 有明文」这个唯一存活副本被无条件清空。一旦清空，DB 和 keyring 同时空，**无任何兜底**——`resolve_provider_secret`（secret.rs:30-35）先看 DB、再看 keyring，两源都空就返空串。

### 1.3 为什么 R-PD-1 比 CR-01 严重

| 项 | CR-01（已修） | R-PD-1（本设计） |
|---|---|---|
| 触发 | 删 provider 漏清 keyring | 编辑 provider 不改 key |
| 后果 | keyring 残留（无消费方，不可复活） | **密钥永久丢失，provider 报废** |
| 可逆性 | 残留可后续清，无危害 | **不可逆**——明文唯一副本被覆盖成空 |
| 用户感知 | 无 | 静默丢失，下次调用 401/空密钥错才暴露 |

CR-01 是「清理时机」问题（残留不可复活），R-PD-1 是「明文副本被毁」问题（密钥丢失）——后者危害量级更高。

---

## 二、根因

双根因叠加：

### 2.1 根因 A：`INSERT OR REPLACE` 全字段覆盖

`crud.rs:890-900` 的 `AiProviderRepo::insert` 用 `INSERT OR REPLACE INTO ai_providers (...api_key...) VALUES (...)` —— 编辑场景下 id 已存在，REPLACE 整行删除重建，**所有字段**（含 `api_key`）按传入值落库。即使本次只改 `name`，`api_key` 也被强写为 `record.api_key` 的值。

调用方 `ai_save_provider`（commands.rs:334）始终把 `record.api_key` 设为空串，于是无论是否改密钥，DB 明文都被清。

> 注：`update_full`（crud.rs:901-910）走 `UPDATE ... SET api_key = ?` 同样全字段覆盖，问题对称。当前 `ai_save_provider` 走的是 `insert`，但即便切到 `update_full` 也不解决——根因在调用方传的值，不在 SQL 形式。

### 2.2 根因 B：「空 api_key = 不改」约定二义性

commands.rs:326-334 的约定：
- `api_key` 非空 → 写 keyring（新/改密钥）
- `api_key` 空 → 不写 keyring，「保留原 keyring 密钥不动」

这套约定隐含假设：**「保留原密钥」就是保留 keyring 里的密钥**。但未迁移态下 keyring 根本没有密钥，真正的密钥副本在 DB 明文里。约定只 cover 了「迁移完成态」（DB 空、keyring 有），完全没考虑「未迁移态」（DB 有明文、keyring 空）。

「空 = 不改」这个三字符约定的语义其实是**「不要动密钥」**，但代码实现成了**「把 DB 明文也清空」**——后者在迁移完成态碰巧无害（DB 本来就空），在未迁移态就是数据丢失。**约定本身没错，错的是实现把「不改」落成了「清空唯一副本」。**

### 2.3 为什么启动迁移没兜住

`migrate_secrets_to_keyring`（secret.rs:50-72）在启动时跑一次：
- 成功：DB 明文 → keyring → DB 置空。完成后「未迁移态」消失。
- 失败：`warn` 日志 + `continue`，**DB 明文保留**（设计意图：「下次重试」）。

正是「失败保留明文」这条安全网，制造了「未迁移态」长期存在的可能：keyring 写入失败（权限/锁定/平台差异）→ 明文滞留 DB → 用户进来编辑 → R-PD-1 触发。**这条安全网本意是保住密钥，却被根因 A/B 在编辑路径上反向利用成密钥丢失入口。**

---

## 三、方案对比

修复方向（review 给的指引）：**空 `api_key` 时先确认 keyring 有/DB 有再决定清 DB——若 keyring 无且原 DB 非空，先 `set_provider_secret` 补迁再清 DB（即时迁移），保住密钥不丢。**

围绕这个方向，三个候选方案：

### 方案 A：编辑路径即时迁移（推荐）

`ai_save_provider` 空密钥分支前，加「保住密钥」前置：
1. 读原 DB 记录的 `api_key`（明文）。
2. 读 keyring 当前值。
3. 决策矩阵：

| DB 原值 | keyring 现值 | 动作 |
|---|---|---|
| 非空 | 非空 | 二者一致？以 keyring 为准，DB 清空（迁移完成态编辑，行为同现状） |
| 非空 | 空 | **即时迁移**：`set_provider_secret(DB 原值)` → 成功后 DB 清空；失败 → 报错阻断保存，**DB 明文不动** |
| 空 | 非空 | 已迁移态编辑，DB 保持空（现状） |
| 空 | 空 | 无密钥 provider（新建未填过 key），DB 保持空（现状） |

伪代码（commands.rs:328-355 改动）：

```rust
let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() {
    // 显式改密钥：写 keyring（现状不变）
    if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
        return Err(format!("密钥保存到系统钥匙串失败: {}", e));
    }
} else if let Some(pid) = &id {
    // 空 key 编辑：保住密钥，防未迁移态丢失
    let old = state.ai_providers.get_by_id(pid).await
        .map_err(|e| e.to_string())?;
    if let Some(old) = old {
        if !old.api_key.is_empty() {
            // DB 有明文 → 检查 keyring 是否已迁
            if super::secret::get_provider_secret(pid).is_none() {
                // keyring 空：即时迁移补密钥（失败则阻断保存，明文不动）
                if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
                    return Err(format!(
                        "检测到密钥尚未迁移至系统钥匙串，本次保存尝试迁移失败: {}。\
                         已保留原密钥未改动，请重试或检查系统钥匙串权限后再次保存。",
                        e
                    ));
                }
                tracing::info!("[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring", pid);
            }
            // keyring 已有/迁移成功：DB 明文将在下方 INSERT OR REPLACE 清空（迁移完成）
        }
    }
}
let api_key = String::new(); // DB 恒空（真实密钥在 keyring）
let record = AiProviderRecord { /* ... */ };
```

**优点**：
- 保住密钥不丢（核心目标达成）。
- 顺带把「未迁移态」在编辑路径收敛到「迁移完成态」——用户每编辑一次，未迁移的 provider 自动补迁。
- 调用方局部改动，不动 crud.rs / 不改 IPC 契约 / 不改前端。
- 失败兜底明确：迁移失败直接 `Err` 阻断保存，**不会比现状更糟**（现状是静默丢失，这里至少明确报错 + 不动 DB）。

**缺点**：
- 即时迁移失败时阻断保存——用户改个 name 也保存不了。但这是**正确行为**：保存就意味着要清 DB 明文，密钥没保住之前清掉就是丢失，宁可阻断也不丢。
- 多一次 DB 读（`get_by_id`）——可接受（编辑本就低频，且 `ai_save_provider` 已读两次 `get_by_id` 取 `created_at`/`is_default`，再加一次读明文合理）。

### 方案 B：保留 DB 明文直到 keyring 确认成功

`ai_save_provider` 空密钥分支下，**不无条件清 DB**：若 keyring 无值，则 `record.api_key` 保留原 DB 明文，INSERT OR REPLACE 落库的还是明文；待启动迁移或下次显式改密钥时再清。

伪代码：
```rust
let api_key_for_db = if api_key.is_empty() {
    // 编辑不改 key：若 keyring 无值，保留 DB 明文不动
    let keyring_val = super::secret::get_provider_secret(&provider_id);
    match (keyring_val, id.as_ref().and_then(|pid| /* 读旧 DB 明文 */)) {
        (Some(_), _) => String::new(),       // keyring 有 → DB 可空
        (None, Some(plaintext)) => plaintext, // keyring 无 → 保留 DB 明文
        (None, None) => String::new(),       // 都无 → 新建无 key
    }
} else {
    // 显式改 key：写 keyring，DB 空
    /* set_provider_secret ... */
    String::new()
};
let record = AiProviderRecord { api_key: api_key_for_db, /* ... */ };
```

**优点**：保住明文，不依赖即时迁移成功。

**缺点**：
- **DB 明文长期滞留**：与 FR-S1「DB api_key 列恒空」目标矛盾，恶化 R-PD-4（迁移失败明文滞留 SQLite 文件未加密）。
- 把「编辑不改 key」从「收敛到迁移完成态」变成「维持未迁移态」，方向反了——本应借编辑机会收敛，方案 B 反而固化未迁移态。
- 决策矩阵更绕（要协调「写 keyring 失败时回退 DB 明文」），引入新的不一致窗口（keyring 写一半失败、DB 仍明文、下次又来一遍）。

### 方案 C：显式迁移标志位

给 `AiProviderRecord` 加 `secret_migrated: bool` 列（或用 `config` JSON 存），空密钥分支下：
- 标志位 true → 已迁移，DB 清空安全。
- 标志位 false → 未迁移，DB 明文必须保留（或即时迁移）。

**优点**：状态显式可观测（不靠「DB 空 vs keyring 有」反推），排查友好。

**缺点**：
- **schema 演进成本**：加列要迁移历史库（ALTER TABLE / 默认值 / 向后兼容老客户端读不懂新列）。
- 三个真值源（标志位、DB 明文、keyring）比两个（DB 明文、keyring）更难保持一致——标志位忘更新又成新坑。
- 收益与复杂度不匹配：方案 A 用「keyring 有/DB 有」二元判定已经足够，标志位是过度设计。
- 与项目「务实最小改动」原则相悖。

### 方案取舍

| 维度 | A 即时迁移 | B 保留明文 | C 标志位 |
|---|---|---|---|
| 密钥不丢 | ✅ | ✅ | ✅（靠 A/B 实现） |
| 收敛未迁移态 | ✅ 编辑即迁移 | ❌ 维持未迁移 | 取决于实现 |
| 改动面 | 局部（commands.rs） | 局部（commands.rs） | 大（schema + crud + 模型 + 迁移） |
| 与 FR-S1/R-PD-4 一致 | ✅ | ❌ 恶化明文滞留 | 中性 |
| 复杂度 | 低 | 中 | 高 |

**推荐方案 A**：最小局部改动达成核心目标（密钥不丢），顺带收敛未迁移态，与 FR-S1 方向一致，失败兜底明确不劣化现状。

---

## 四、推荐方案 A：改动清单

### 4.1 改动文件

| 文件 | 改动 | 行号（截至 2026-06-15） |
|---|---|---|
| `src-tauri/src/commands/ai/commands.rs` | `ai_save_provider` 空密钥分支前加「保住密钥」前置（即时迁移） | 328-334（在 `let provider_id = ...` 与 `let api_key = String::new()` 之间插入） |

**不改动**：
- `crates/df-storage/src/crud.rs` —— `INSERT OR REPLACE` 全字段覆盖是 storage 层中性能力，根因在调用方传值；改 SQL 反而把「保留密钥」语义下推到 storage（不该 storage 关心密钥迁移）。
- `src-tauri/src/commands/ai/secret.rs` —— `set/get_provider_secret` 已具备所需能力，复用即可，无需新方法。
- IPC 签名 / 前端 / DB schema —— 全部不动。

### 4.2 改动伪代码（完整版）

`commands.rs:328` 处（原代码）：

```rust
let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() {
    if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
        return Err(format!("密钥保存到系统钥匙串失败: {}", e));
    }
}
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
```

改为：

```rust
let provider_id = id.clone().unwrap_or_else(new_id);
if !api_key.is_empty() {
    // 显式改/填密钥 → 写 keyring（现状不变）
    if let Err(e) = super::secret::set_provider_secret(&provider_id, &api_key) {
        return Err(format!("密钥保存到系统钥匙串失败: {}", e));
    }
} else if let Some(pid) = &id {
    // 空 key 编辑：保住密钥，防未迁移态静默丢失（R-PD-1）
    let old = state.ai_providers.get_by_id(pid).await
        .map_err(|e| e.to_string())?;
    if let Some(old) = old {
        if !old.api_key.is_empty()
            && super::secret::get_provider_secret(pid).is_none()
        {
            // DB 有明文 且 keyring 无 → 即时迁移补密钥
            if let Err(e) = super::secret::set_provider_secret(pid, &old.api_key) {
                return Err(format!(
                    "检测到该提供商密钥尚未迁移至系统钥匙串，本次保存尝试即时迁移失败({})。\
                     已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
                    e
                ));
            }
            tracing::info!(
                "[FR-S1] 编辑路径即时迁移 provider {} 密钥至 keyring（R-PD-1 兜底）",
                pid
            );
        }
        // else: keyring 已有 / DB 已空 → INSERT OR REPLACE 清空 DB 明文安全
    }
}
let api_key = String::new(); // DB 恒空(真实密钥在 keyring)
```

### 4.3 决策点

| 决策 | 取值 | 原因 |
|---|---|---|
| 即时迁移失败时 | `Err` 阻断保存，**DB 明文不动** | 保存即清 DB 明文，密钥没保住前清掉就是丢失；阻断 + 明确报错优于静默丢失 |
| 判定密钥源 | keyring 有 → 安全清；DB 有 + keyring 无 → 即时迁移；都无 → 新建无 key | 三状态全覆盖，无遗漏分支 |
| 迁移后是否额外校验 keyring 写入 | 不校验（信任 `set_provider_secret` 返回 Ok） | `set_provider_secret` 已是 keyring 写入的真相源，重复读 keyring 验证属过度防御 |
| 即时迁移的范围 | 仅编辑路径（`ai_save_provider` 空 key 分支） | 启动迁移 `migrate_secrets_to_keyring` 是批量兜底，编辑路径是单点收敛；两者互补不重叠 |
| 是否记日志 | 成功迁移记 `info`，失败走 `Err`（用户可见） | 成功迁移是状态收敛好事值得记；失败用户必须知道 |

---

## 五、风险与兜底

### 5.1 即时迁移失败时的兜底

即时迁移失败（keyring 权限/锁定/平台问题）→ 函数 `return Err` → **DB 明文保留不变**（INSERT OR REPLACE 未执行）。

- 用户看到：明确错误「即时迁移失败，请检查钥匙串权限后再次保存」。
- 系统状态：与保存前完全一致（DB 明文还在，keyring 仍空，下次启动迁移或下次编辑还会再试）。
- **绝不劣化现状**：现状是静默丢失，本方案最坏是「保存失败 + 明确报错 + 状态不变」。

### 5.2 边界场景

| 场景 | 行为 |
|---|---|
| 编辑刚新建（id 不存在/无 old 记录） | `old = None` → 不进迁移分支 → DB 空（新建无 key 正常） |
| 编辑已迁移态（DB 空、keyring 有） | `old.api_key.is_empty()` → 不进迁移分支 → DB 保持空（现状） |
| 编辑已迁移态但 keyring 被外部清空 | DB 空 + keyring 空 → 不进迁移分支 → DB 保持空 → provider 早已报废（非本设计引入的新问题，属 R-PD-4 范畴） |
| 并发两次保存同一 provider | `get_by_id` 各读各的，INSERT OR REPLACE 串行化落库；最坏后写覆盖先写，密钥不丢（两者都迁成功或都报错） |
| 用户编辑同时填了新 api_key | 走 `!api_key.is_empty()` 显式分支，覆盖写 keyring（现状不变，不进即时迁移分支） |

### 5.3 不解决的问题（明确边界）

- **R-PD-4**（迁移失败明文长期滞留 SQLite 文件未加密）：本方案不直接解决——即时迁移只是把「未迁移态」在编辑路径收敛，启动迁移失败仍会留下滞留明文。R-PD-4 走独立方向（补 N 次失败阈值警告），不在本设计范围。
- **provider 被外部清空 keyring 导致已迁移态变废**：本方案不感知外部 keyring 变更（编辑时读 keyring 是即时快照），属 keyring 健康监控范畴，不在本设计。
- **CR-01**（删 provider 漏清 keyring，已修）：删除路径已加 `delete_provider_secret` 兜底，与本设计（编辑路径）正交。

---

## 六、关联

- **全局代码 review 2026-06-15** §🔴 P1 R-PD-1 — 问题来源与本设计指针。
- **CR-260615-01 / CR-01**（已修）：`ai_delete_provider` 删 provider 漏清 keyring → 已加 `delete_provider_secret` 兜底（commands.rs:397-399）。本设计是「编辑路径」的对称补丁，与「删除路径」构成密钥生命周期的两端健壮性。
- **R-PD-4**（P2 需设计）：keyring 迁移失败明文滞留 SQLite 文件未加密 — 与本设计同源（启动迁移失败制造未迁移态），但治理方向不同（本设计收敛编辑路径，R-PD-4 加滞留告警）。两者互补。
- **FR-S1**（api_key 密钥管理）：`secret.rs` 的 keyring 迁移机制（启动迁移 + resolve fallback + set/get/delete）是本设计依赖的基础设施。本方案在编辑路径补一个「即时迁移」单点，与启动批量迁移形成双层兜底。
- **migrate_secrets_to_keyring**（secret.rs:50-72）：启动批量迁移，失败保留明文重试——本设计借编辑路径在用户操作时再做一次单点迁移，提升收敛率。
- **resolve_provider_secret**（secret.rs:30-35）：DB 优先 fallback keyring 的双源 resolve，是「未迁移态」仍可用的原因；本方案收敛未迁移态后，resolve 路径长期看会稳定走 keyring 分支。

---

## 七、测试设计

| 用例 | 方法 | 期望 |
|---|---|---|
| 未迁移态编辑不改 key → 即时迁移成功 | mock：DB 存明文 + keyring 空，调用 `ai_save_provider` 空 key 改 name | 迁移成功，keyring 写入明文，DB `api_key` 清空，函数返回 Ok(id) |
| 未迁移态编辑不改 key → 即时迁移失败 | mock：`set_provider_secret` 返回 Err，DB 存明文 | 函数返回 Err（含迁移失败提示），**DB `api_key` 明文保留不变**（核心兜底） |
| 已迁移态编辑不改 key | mock：DB 空 + keyring 有，调用空 key 编辑 | 不进迁移分支，DB 保持空，函数返回 Ok |
| 新建 provider 无 key | `id=None`，`api_key=""` | 不进迁移分支（无 old 记录），DB 空，返回 Ok |
| 显式改 key（非空 api_key） | 任意态，传非空 api_key | 走显式分支写 keyring，不进即时迁移分支（现状不变） |
| 已迁移态但 keyring 被外部清 + DB 也空 | mock：DB 空 + keyring 空 | 不进迁移分支，DB 保持空（provider 已废，非本设计引入） |
| 即时迁移后 resolve 正常 | 即时迁移成功后调 `resolve_provider_secret` | 返回非空密钥（迁移成功后 keyring 是唯一源） |

测试位置：`src-tauri/src/commands/ai/commands.rs` 的 `#[cfg(test)]` 模块（若现无则新增），mock `set/get_provider_secret`（可通过 trait 抽象 + 测试替身，或抽 secret 操作到可注入句柄）。

---

**相关**：
- `docs/05-代码审查/全局代码review-2026-06-15.md` §🔴 P1 R-PD-1 — 问题来源
- `src-tauri/src/commands/ai/commands.rs:299-355` — `ai_save_provider` 实施位置
- `src-tauri/src/commands/ai/secret.rs` — keyring 迁移/resolve 基础设施
- `crates/df-storage/src/crud.rs:884-911` — `AiProviderRepo` insert/update_full（不改）
- 功能决策记录「密钥迁移健壮性」— 设计摘要（待补）
