文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并: - 意图识别层论证(8维度+10业界佐证) - 多主题上下文管理愿景+并存论证+补充论证(多轮agentic) - 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化) - 前端架构技术债清单归档
This commit is contained in:
343
docs/02-架构设计/专项设计/密钥迁移健壮性-2026-06-15.md
Normal file
343
docs/02-架构设计/专项设计/密钥迁移健壮性-2026-06-15.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 密钥迁移健壮性设计
|
||||
|
||||
> **真相源**(本文档唯一展开完整设计)。功能决策记录仅放摘要 + 指针。
|
||||
>
|
||||
> 背景:R-PD-1(全局代码 review 2026-06-15 §🔴 P1 需设计)— 编辑 provider 提交空 `api_key` 时,无条件把 DB `api_key` 置空走 `INSERT OR REPLACE`,**未迁移态** provider 的明文密钥被静默覆盖成空 → keyring 也空 → resolve 返空 → provider 报废,密钥永久丢失。
|
||||
> 状态:📐 **设计完成,未实施** | 创建: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(不改)
|
||||
- 功能决策记录「密钥迁移健壮性」— 设计摘要(待补)
|
||||
Reference in New Issue
Block a user