Files
DevFlow/docs/02-架构设计/专项设计/secret下沉与provider注入方案-2026-06-16.md
绝尘 998a2f243d 文档: 架构方案文档(意图识别论证+多主题愿景/论证+文档物理分类+边界清晰化)
squash合并:
- 意图识别层论证(8维度+10业界佐证)
- 多主题上下文管理愿景+并存论证+补充论证(多轮agentic)
- 架构设计文档物理分类(四子目录+INDEX+命名规范+引用同步+边界清晰化)
- 前端架构技术债清单归档
2026-06-19 15:04:04 +08:00

327 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# secret 下沉与 provider 注入方案
> 背景AiNode 自审闭环②③④⑤已实施(commit c10adaf + 741b0b9)provider 配置注入阻塞⑥端到端联调。
> 根因api_key 明文经 `run_workflow config` 注入 `NodeContext.config` 违背 FR-S1 mask 设计(前端 / LLM tool_call schema 可拿明文 key)。
> secret 解析(`ensure_resolved_key` / `resolve_provider_secret`)位于 `src-tauri/src/commands/ai/secret.rs`(app crate)df-nodes 不可依赖 src-tauri(单向 app→df-nodes)。
> 范围:**仅设计**,不改任何 .rs/.ts/.vue 代码。
---
> ## ✅ 实施状态2026-06-18 核对:方案 B 全量落地)
>
> **推荐方案 Bdf-storage 加薄 secret 查询方法)已全量实施**,行为与设计一致。
>
> **下沉层**`crates/df-storage/src/secret.rs`(新建,纯密钥逻辑唯一源)—— `KEYRING_SERVICE` 常量(`:26`)、failcount sidecar(`:34-56`)、get/set/delete/resolve/ensure/migrate 全套函数均下沉至此。`crates/df-storage/src/lib.rs` `pub mod secret` 暴露。
>
> **df-storage 依赖**`crates/df-storage/Cargo.toml:16-18` 已加 `keyring = { workspace = true }`(注释标注 FR-S1 密钥解析下沉)。
>
> **src-tauri 转发壳**`src-tauri/src/commands/ai/secret.rs:20` `pub use df_storage::secret::*;`12 调用点路径不变),`:32-43` 保留 `build_provider_for`(依赖 `df_ai::build_provider` 不下沉,对齐设计「留 app 层避免 df-storage→df-ai 循环」)。
>
> **AiNode 注入链(阶段 3**`crates/df-nodes/src/ai_node.rs:21` import `resolve_provider_secret`/`ensure_resolved_key``:60-64/101-139` 三路径解析provider_id 优先 → 老明文兼容 → 空兜底取 is_default 首条);`:166-178` 从 record 解析 provider 构造要素resolve→ensure→base_url/api_key。AiSelfReviewNode 同构(`:476` 起)。
>
> **schema**`ai_node.rs:344-351` schema 描述 provider_id/base_url/api_keybase_url/api_key 标注「已废弃过渡」),`:351` required=[]SW-260618-15prompt/provider_id 均「留空走兜底」与 required 矛盾,改 required=[] 对齐运行时)。
>
> **设计偏离**:无功能性偏离。仅 schema required 值与设计 §3.4「`["provider_id"]`」略不同(实际 `required=[]`对齐「留空走兜底」运行时语义SW-260618-15 决策)。
>
> **本文档 §1.2/§五的 file:line 索引**:原指向 `src-tauri/src/commands/ai/secret.rs` 的函数行号resolve:97-102 等)下沉后已迁移至 `crates/df-storage/src/secret.rs`src-tauri 文件已瘦身为转发壳(原 228 行 → 现 43 行)。查阅实际函数请走 `crates/df-storage/src/secret.rs`。
---
## 一、现状核验(file:line 证据)
### 1.1 secret.rs 函数清单(src-tauri/src/commands/ai/secret.rs)
| 函数 | 位置 | 签名 | 核心逻辑 |
|---|---|---|---|
| `get_provider_secret` | `secret.rs:88` | `fn(id: &str) -> Option<String>` | keyring(`devflow-ai-provider`/<id>)读密钥,空/无→None(`secret.rs:88-94`) |
| `resolve_provider_secret` | `secret.rs:97` | `fn(record: &AiProviderRecord) -> String` | **DB.api_key 非空→直接返回**(兼容未迁移);否则 keyring 取,空→`unwrap_or_default()` 空串(`secret.rs:97-102`) |
| `ensure_resolved_key` | `secret.rs:179` | `fn(provider_name: &str, resolved: &str) -> Result<(), String>` | 空串/纯空白→Err「未读取到密钥」非空 Ok(`secret.rs:179-188`) |
| `set_provider_secret` | `secret.rs:105` | `fn(id, key) -> anyhow::Result<()>` | keyring 写(`secret.rs:105-108`) |
| `delete_provider_secret` | `secret.rs:111` | `fn(id) -> anyhow::Result<()>` | keyring 删(`secret.rs:111-114`) |
| `migrate_secrets_to_keyring` | `secret.rs:117` | `async fn(repo: &AiProviderRepo) -> Result<usize>` | 启动一次性DB 明文→keyring→DB 置空;失败累计 `failcount` 达 3 升级 warn(`secret.rs:117-156`) |
| `build_provider_for` | `secret.rs:164` | `fn(record) -> Result<Box<dyn LlmProvider>, String>` | 三步打包 resolve→ensure→`df_ai::build_provider`(`secret.rs:164-175`) |
| keyring entry | `secret.rs:83-85` | `entry_for(id)` | `Entry::new("devflow-ai-provider", id)`,服务名常量 `KEYRING_SERVICE`(`secret.rs:18`) |
| failcount sidecar | `secret.rs:26-81` | read/write/record/clear | `<cwd>/.devflow-keyring-failcount``provider_id=count` 跨启动持久化告警计数 |
依赖:`use df_storage::crud::AiProviderRepo; use df_storage::models::AiProviderRecord; use keyring::Entry;`(`secret.rs:14-16`)。
### 1.2 调用点清单(下沉影响面)
| 调用点 | 位置 | 调的函数 |
|---|---|---|
| `lib.rs` 启动迁移 | `src-tauri/src/lib.rs:28` | `migrate_secrets_to_keyring(&app_state.ai_providers)` |
| `commands/ai/commands.rs` ai_list_providers | `commands.rs:683` | `get_provider_secret`(list mask) |
| `commands/ai/commands.rs` ai_save_provider | `commands.rs:724, 737, 740` | `set_provider_secret`(新/改写)、`get_provider_secret`(未迁移检测)、`set_provider_secret`(即时迁移) |
| `commands/ai/commands.rs` ai_delete_provider | `commands.rs:818` | `delete_provider_secret` |
| `commands/ai/agentic.rs` 主对话循环 | `agentic.rs:126, 128` | `resolve_provider_secret` + `ensure_resolved_key`(内联三步B-17 复用 resolved key) |
| `commands/ai/title.rs` 标题生成 | `title.rs:62` | `build_provider_for` |
| `commands/ai/knowledge_inject.rs` 知识提炼 | `knowledge_inject.rs:35, 327` | `build_provider_for`(两处embed + 提炼) |
| `commands/idea.rs` 灵感评分 | `idea.rs:272` | `build_provider_for` |
| `commands/project.rs` 项目导入 | `project.rs:438, 598` | `build_provider_for`(两处) |
**影响面**9 文件、~12 调用点,全在 `src-tauri/commands/`。下沉后这些调用点改 import 路径(`crate::commands::ai::secret::``df_storage::secret::`)即可,函数签名不变,行为零变化。
### 1.3 ai_providers 表 schema(df-storage)
**models.rs**(`crates/df-storage/src/models.rs:147-159`)
```
AiProviderRecord { id, name, provider_type, api_key, base_url,
default_model, models: Option<String>, is_default,
config: Option<String>, created_at, updated_at }
```
**建表 SQL**(`crates/df-storage/src/migrations.rs:488-500`V9)
```
api_key TEXT NOT NULL, -- 列定义无 default、无加密、无 env 引用语法
```
**CRUD**(`crates/df-storage/src/crud.rs:1113-1140`)`impl_repo!` 宏生成 AiProviderRepoinsert/update/`from_row`(`crud.rs:1034-1048`)`list_all`/`get_by_id`(宏内置)。
**api_key 存储形态结论**
- **不是 mask**(列定义无 `sk-****` 语法)**不是 env 引用**(无 `$ENV_VAR` 解析)**不是加密**(明文 TEXT)。
- 真相FR-S1 后 **DB api_key 列恒空**(迁移后/新建均空,`secret.rs:1-8` 模块文档明确),真实密钥唯一源 = OS keyring(service=`devflow-ai-provider`, username=provider_id)。
- 消费读路径:`resolve_provider_secret` 先看 DB(兼容未迁移老库明文)→空则 keyring(`secret.rs:97-102`)。
- 前端只拿到 mask`ai_list_providers`(`commands.rs:679-686`) 用 `mask_api_key`(`commands.rs:663-671`,首尾各 4 字符 + 中间 `••••`)返回,前端 `Settings.vue:458` 编辑不回填 apiKey。
### 1.4 依赖链(df-nodes / df-storage Cargo.toml)
**df-nodes**(`crates/df-nodes/Cargo.toml:6-17`)已依赖:
- `df-core`, `df-execute`, `df-workflow`, `df-ai`, `df-storage`, serde, tokio, async-trait, anyhow, tracing。
- **关键**df-nodes **已依赖 df-storage + df-ai**,可直接调 `AiProviderRepo` + `df_ai::build_provider`,无需新增 crate 依赖。
**df-storage**(`crates/df-storage/Cargo.toml:6-13`)依赖df-core, serde, serde_json, anyhow, tokio, rusqlite, tracing。**不含 keyring**。
**keyring crate**:声明在 workspace 根 `Cargo.toml:42`(features=`windows-native, apple-native`),仅 src-tauri 引用。下沉到 df-storage 需在 df-storage 的 `Cargo.toml``keyring = { workspace = true }`(workspace 已定义path 无需重声明)。
### 1.5 AiNode 现状(ai_node.rs)
- `AiNode``Arc<Database>`(`ai_node.rs:119-128`),注册时注入(`state.rs:260-263`)。
- `parse_params`(`ai_node.rs:38-112`)从 `ctx.config``base_url` / `api_key`(**明文P0 风险点**),空 key 早失败(`ai_node.rs:54-56`,对齐 `ensure_resolved_key`)。
- `execute`(`ai_node.rs:135-138`)直接 `df_ai::build_provider(protocol, base_url, api_key, default_model)`
- schema `required: ["base_url","api_key"]`(`ai_node.rs:214`)`api_key` 字段对 LLM tool_call 可见。
- AiSelfReviewNode 同构(`ai_node.rs:330-358`)。
### 1.6 run_workflow config 注入链
- IPC `run_workflow(name, dag, config, task_id, target_status)`(`workflow.rs:67-76`)`config: serde_json::Value` 由前端构造传入。
- `executor.run(&runtime_dag, config)`(`workflow.rs:245`)→ 透传到 `NodeContext.config`(`node.rs:13-26`),节点 execute 直接读。
- **前端目前不构造 ai/ai_self_review 节点 config**:模板由后端 `template_for`(`workflow.rs:84-92``task_workflow_templates.rs`)提供,节点 config 来自模板定义 + 前端 run_workflow 传入的 config 合并/覆盖。
- config 经 IPC(明文) + NodeContext.config(明文) 两层暴露,前端 Vue devtools / LLM tool_call schema 均可拿到明文 api_key。
---
## 二、下沉方案对比(A/B/C)
### 方案 A完整下沉 secret.rs → df-storage(新建 `crates/df-storage/src/secret.rs`)
**做什么**:把 `secret.rs` 全部逻辑(get/set/delete/resolve/ensure/migrate/build_provider_for/failcount)整体 move 到 `crates/df-storage/src/secret.rs`df-storage 加 `keyring` 依赖src-tauri 改 `use df_storage::secret::*;` 转发。
| 维度 | 评估 |
|---|---|
| 影响面 | 9 文件 12 调用点改 import 路径df-storage Cargo.toml +1 依赖df-nodes Cargo.toml 已有 df-storage 依赖(零改动)src-tauri/Cargo.toml keyring 可保留(workspace 引用)或移除(改由 df-storage 传递) |
| 工作量 | 中move 文件 + 改 12 调用点 import + df-storage 加 keyring feat + cargo check。约 1-2h |
| 风险 | **keyring feature 传递**df-storage 加 `keyring = { workspace = true }`feat=`windows-native, apple-native` 随 workspace 注入Linux 构建(df-storage 是否跑测)需补 `linux-native`。df-storage 单测若启 keyring 会触发 OS keyring 副作用(单测应 cfg-gate)。`build_provider_for` 依赖 `df_ai::build_provider`——df-storage **不依赖 df-ai**,下沉 `build_provider_for` 会打破单向依赖(df-storage→df-ai 反向df-ai 不依赖 df-storage 但 df-storage 反引 df-ai 形成潜在循环)。需把 `build_provider_for` **留在 src-tauri**(不下沉),只下沉 resolve/ensure/get/set/migrate |
| 收益 | df-nodes 可直接 `df_storage::secret::resolve_provider_secret`,最干净 |
**致命点**`build_provider_for`(`secret.rs:164`)调 `df_ai::build_provider`,下沉 df-storage 会引 df-storage→df-ai。需拆分下沉边界(见方案 B 的边界划分)。
### 方案 Bdf-storage 加薄 secret 查询方法(推荐) ★
**做什么**
1. **下沉**纯密钥解析逻辑到 `crates/df-storage/src/secret.rs``get/set/delete_provider_secret`、`resolve_provider_secret`、`ensure_resolved_key`、`migrate_secrets_to_keyring`、failcount helper。**不含** `build_provider_for`(因依赖 df-ai)。
2. df-storage 加 `keyring = { workspace = true }` 依赖。
3. src-tauri `secret.rs` 保留 `build_provider_for` 作为转发壳:内部调 `df_storage::secret::resolve_provider_secret + ensure_resolved_key + df_ai::build_provider`(src-tauri 已依赖 df-ai无循环)。
4. df-nodes AiNode/AiSelfReviewNode 调 `df_storage::secret::resolve_provider_secret(&record)` + `ensure_resolved_key` 自己拼 `df_ai::build_provider`(df-nodes 已依赖 df-ai + df-storage)。
| 维度 | 评估 |
|---|---|
| 影响面 | df-storage 新增 1 文件 + Cargo.toml +1 依赖src-tauri secret.rs 瘦身为转发壳(保留 build_provider_for)12 调用点 import 改为 `df_storage::secret::`(build_provider_for 仍从 src-tauri 调6 调用点不变)df-nodes AiNode 改读 provider 配置(见第三章) |
| 工作量 | 中move 纯密钥逻辑(约 100 行)+ df-storage Cargo.toml + 改 import + AiNode 注入链。约 2-3h |
| 风险 | keyring feature 同 Adf-storage 单测需 mock keyring 或 cfg-gate。**无循环依赖**df-storage 不引 df-aibuild_provider_for 留 src-tauri。最稳 |
| 收益 | 边界清晰密钥解析下沉、provider 构造留 app 层df-nodes 获密钥解析能力FR-S1 mask 链路后端闭环 |
### 方案 Cprovider_id 注入 config + df-nodes 直查 ai_providers.api_key(df-nodes 自己解析)
**做什么**config 注 `provider_id`AiNode 用 `AiProviderRepo.get_by_id` 查 record自己实现 keyring 解析(在 df-nodes 里复制一份 resolve 逻辑)。
| 维度 | 评估 |
|---|---|
| 影响面 | df-nodes 复制一份 keyring 解析(逻辑重复,违反 DRY);或 df-nodes 也下沉 secret(等同方案 B 但把解析放 df-nodes 而非 df-storage) |
| 工作量 | 中,但**重复实现** |
| 风险 | **DRY 破坏**:两份 keyring 解析逻辑(secret.rs 一份 + df-nodes 一份),未来迁移逻辑/常量(service name `devflow-ai-provider`)变更需同步两处易漏。FAIL_THRESHOLD/failcount 等告警逻辑更难复制 |
| 收益 | 无(被 B 完全覆盖) |
### 推荐:**方案 B**
理由:
1. **无循环依赖**:纯密钥解析(无 df_ai 依赖)下沉 df-storage`build_provider_for` 留 src-tauri(app 层既依赖 df-storage 又依赖 df-ai天然适合拼装)。
2. **DRY**keyring 解析逻辑唯一源在 df-storagesrc-tauri 与 df-nodes 共用。
3. **df-nodes 零新依赖**df-nodes Cargo.toml 已含 df-storage + df-ai方案 B 下沉后直接可调,不改 crate 依赖。
4. **FR-S1 闭环**config 只注 `provider_id`,明文 api_key 不进 IPC / NodeContext.config前端 / LLM tool_call schema 拿不到明文,与现有 `ai_list_providers` mask 设计一致。
5. **最小破坏**12 调用点中 6 个调 `build_provider_for`(src-tauri 保留import 不变)6 个调 resolve/ensure/get/set/import 改路径,行为零变化。
---
## 三、run_workflow → AiNode provider 注入链设计(方案 B)
### 3.1 config 注入什么
**注入(明文安全)**
```
{
"provider_id": "<uuid>", // 必填AiNode 据此查 ai_providers 表
"prompt": "...", // 必填(可上游覆盖)
"system_prompt": "...", // 可选
"model": "glm-4-flash", // 可选,留空用 record.default_model
"temperature": 0.3, // 可选
"max_tokens": 1024, // 可选
"task_id": "..." // 可选(自审闭环用)
}
```
**不注入(api_key/base_url/protocol 经下沉层解析,不进 config)**
- `api_key` ❌ — 明文,违背 FR-S1
- `base_url` ❌ — 从 record 读(record.base_url)config 注入是冗余 + 泄漏
- `protocol` ❌ — 从 record.provider_type 映射(openai_compat / anthropic)config 注入冗余
### 3.2 api_key 如何经 df-storage 层解析不进 config
AiNode.execute 内部链路(伪代码)
```
let provider_id = ctx.config["provider_id"]?; // 仅 id 进 config
let repo = AiProviderRepo::new(&self.db);
let record = repo.get_by_id(provider_id).await?; // 查 ai_providers 行
let api_key = df_storage::secret::resolve_provider_secret(&record); // DB 优先→keyring
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?; // 空 key 早失败
let provider = df_ai::build_provider(
&record.provider_type, &record.base_url, &api_key, &model_or_default);
```
**api_key 全程在 AiNode 进程内存,不出 IPC / 不进 NodeContext.config**
- IPC `run_workflow` 收到的 config 只有 `provider_id`(非密)。
- NodeContext.config 只含 provider_idLLM tool_call schema 看不到 api_key。
- api_key 经 `df_storage::secret::resolve_provider_secret` 在 AiNode 内存解析,直达 `df_ai::build_provider`
### 3.3 run_workflow 如何拿到 provider 配置注入 config
**前端 → run_workflow config 注入路径(三选一)**
**路径 1(推荐):前端只传 `provider_id`,其余从 DB 默认 provider**
- 前端 `ai_list_providers` 拿到 provider 列表(mask无明文)。
- 触发 run_workflow 时 config 注 `{provider_id: <default or chosen id>}`(或留空AiNode 内部取 `is_default=true` 首条)。
- base_url/protocol/model 全由 AiNode 从 record 读,前端不构造。
- **最安全**前端全程不接触明文config 最小化。
**路径 2run_workflow IPC 加 provider_id 顶层参数**
- `run_workflow(name, dag, config, task_id, target_status, provider_id: Option<String>)`
- 后端 `workflow.rs:245` 前把 provider_id 注入 config 注入到 ai/ai_self_review 节点 config。
- 显式更安全,但改 IPC 签名(向后兼容Option 缺省 None)。
**路径 3模板内嵌 provider_id 占位**
- `task_workflow_templates.rs` 的 ai 节点 config 写 `{"provider_id": "{{default}}"}`executor 注入时替换为 DB 默认 provider id。
- 需 executor 支持 placeholder 替换,改动大。
**推荐路径 1**:最小改动,前端 config 注 provider_id(或留空走默认)AiNode 内部解析。run_workflow 签名不变(向后兼容)。
### 3.4 兼容老 config(base_url/api_key 明文)迁移
- AiNode `parse_params` 兼容窗口:**有 provider_id → 走下沉解析路径;无 provider_id → 走老 base_url/api_key 明文路径**(对齐现有 ai_node.rs:42-56兼容期保留)。
- 老路径打 deprecation warn引导 config 迁移到 provider_id。
- 模板(`task_workflow_templates.rs`)切到 provider_id 后,老路径仅 demo dag(ProjectDetail.vue demoDag)用,逐步淘汰。
- schema `required``["base_url","api_key"]` 改为 `["provider_id"]`(明文路径保留但不再 required)。
---
## 四、影响面 + 风险 + 实施步骤
### 4.1 影响面
| 模块 | 改动 |
|---|---|
| `crates/df-storage/src/secret.rs` | **新建**move 纯密钥逻辑(get/set/delete/resolve/ensure/migrate/failcount) |
| `crates/df-storage/Cargo.toml` | +`keyring = { workspace = true }` |
| `crates/df-storage/src/lib.rs` | `pub mod secret;` |
| `src-tauri/src/commands/ai/secret.rs` | 瘦身为转发壳:`pub use df_storage::secret::*;` + 保留 `build_provider_for`(调 df_ai) |
| `crates/df-nodes/src/ai_node.rs` | `parse_params` 改读 provider_idexecute 加 repo.get_by_id + resolve + ensure + build_providerschema required 改 |
| `src-tauri/src/lib.rs:28` | `df_storage::secret::migrate_secrets_to_keyring`(改 import) |
| `commands/ai/commands.rs`(3 处) | import 改 `df_storage::secret::`(行为不变) |
| `commands/ai/agentic.rs`(2 处) | import 改(build_provider_for 仍 src-tauriresolve/ensure 改 df-storage) |
| `commands/ai/title.rs` / `knowledge_inject.rs`(3 处) / `idea.rs`(1 处) / `project.rs`(2 处) | build_provider_for 调用点不变(src-tauri 保留) |
| `crates/df-nodes/src/task_workflow_templates.rs` | ai/ai_self_review 节点 config 改注 `provider_id`(替代 base_url/api_key) |
| 前端 `api/workflow.ts`(触发 run_workflow 处) | config 注 provider_id(替代 base_url/api_key 明文) |
### 4.2 风险
| 风险 | 等级 | 缓解 |
|---|---|---|
| keyring feature 传递df-storage 加 keyring 后 Linux 无 native feat | 中 | workspace `Cargo.toml:42` 已含 `windows-native, apple-native`CI 若有 Linux 跑 df-storage 单测需补 `linux-native` 或 cfg-gate keyring 单测 |
| df-storage 单测触发 OS keyring 副作用 | 中 | keyring 相关单测加 `#[cfg(not(test))]` 或 mock trait 抽象migrate 测试用 in_memory db + mock keyring |
| 老配置兼容期明文路径残留 | 低 | parse_params 双路径兼容(provider_id 优先)deprecation warn 引导迁移 |
| build_provider_for 下沉边界划错引循环依赖 | 中 | 严格df-storage 只下沉纯密钥逻辑build_provider_for 留 src-tauri(依赖 df-ai),方案 B 明确划界 |
| run_workflow config 注入 provider_id 后,模板节点缺 provider_id 致 AiNode 报错 | 中 | AiNode 内部 provider_id 留空时取 DB `is_default=true` 首条(对齐 idea.rs:265-279 build_default_provider 模式) |
| 前端 demoDag(ProjectDetail.vue)仍注明文 base_url/api_key | 低 | 老路径兼容,逐步切模板 provider_id |
### 4.3 实施步骤(可并发标注)
**阶段 1下沉(串行,基础)**
- S1df-storage 加 keyring 依赖 + 新建 `crates/df-storage/src/secret.rs`(move 纯密钥逻辑,删除 build_provider_for)
- S2src-tauri `secret.rs` 改转发壳(`pub use df_storage::secret::*;` + 保留 build_provider_for 调 df_ai)
- S3`cargo check -p df-storage && -p src-tauri` 验证下沉零行为变化
**阶段 2调用点 import 切换(可并发S3 完成后)**
- 并行 P1`lib.rs:28` / `commands.rs`(3 处) / `agentic.rs`(2 处) 改 import(纯路径替换,行为零变)
- 并行 P2build_provider_for 调用点(title/knowledge_inject/idea/project6 处)无需改(src-tauri 保留)
- 合并:`cargo check` 全绿
**阶段 3AiNode 注入链改造(串行,阶段 2 完成后)**
- S4`ai_node.rs parse_params` 改 provider_id 路径(双路径兼容)
- S5`ai_node.rs execute``AiProviderRepo::get_by_id + resolve + ensure + build_provider`
- S6`AiSelfReviewNode` 同构改造
- S7schema required 改 `["provider_id"]`
**阶段 4模板 + 前端(可并发,阶段 3 完成后)**
- 并行 P3`task_workflow_templates.rs` ai/ai_self_review 节点 config 改 provider_id
- 并行 P4前端 `api/workflow.ts` / `TaskDetail.vue` 触发 run_workflow 处 config 注 provider_id
**阶段 5验证(串行)**
- S8`cargo test -p df-storage`(keyring 单测 cfg-gate) / `-p df-nodes`(parse_params 双路径) / `-p src-tauri`
- S9端到端联调 AiNode 自审闭环⑥(模板触发 → AiNode 查 provider → LLM 调用 → 产出落 task.output_json)
**并发点**:阶段 2(P1+P2)、阶段 4(P3+P4) 可分别并发;阶段 1/3/5 串行(前后依赖)。
---
## 五、关键 file:line 索引
| 关注点 | 位置 |
|---|---|
| secret.rs 全貌 | `src-tauri/src/commands/ai/secret.rs:1-228` |
| resolve_provider_secret | `secret.rs:97-102` |
| ensure_resolved_key | `secret.rs:179-188` |
| build_provider_for(留 src-tauri) | `secret.rs:164-175` |
| keyring 服务名常量 | `secret.rs:18`(`devflow-ai-provider`) |
| AiProviderRecord schema | `crates/df-storage/src/models.rs:147-159` |
| ai_providers 建表 SQL | `crates/df-storage/src/migrations.rs:488-500`(V9) |
| AiProviderRepo CRUD | `crates/df-storage/src/crud.rs:1113-1140` |
| df-nodes Cargo.toml(已含 df-storage+df-ai) | `crates/df-nodes/Cargo.toml:6-17` |
| df-storage Cargo.toml(缺 keyring) | `crates/df-storage/Cargo.toml:6-13` |
| workspace keyring 声明 | `Cargo.toml:42` |
| AiNode parse_params(明文 api_key 风险点) | `crates/df-nodes/src/ai_node.rs:38-112` |
| AiNode execute build_provider | `ai_node.rs:135-138` |
| AiSelfReviewNode execute | `ai_node.rs:330-358` |
| run_workflow config 注入 | `src-tauri/src/commands/workflow.rs:67-76,245` |
| NodeContext.config | `crates/df-workflow/src/node.rs:13-26` |
| build_registry AiNode 工厂 | `src-tauri/src/state.rs:260-263` |
| ai_list_providers mask | `src-tauri/src/commands/ai/commands.rs:675-688` |
| 启动迁移 | `src-tauri/src/lib.rs:27-31` |
| 12 调用点 | 见 1.2 表 |
---
## 六、决策记录(规格)
- **方案选型**:方案 B(df-storage 加薄 secret 查询方法)。理由:无循环依赖(build_provider_for 留 src-tauri)、DRY(keyring 解析唯一源)、df-nodes 零新依赖、FR-S1 闭环。
- **config 注入**:仅 `provider_id`(非密)base_url/protocol/model 经 AiNode 从 record 读api_key 经 `df_storage::secret::resolve_provider_secret` 解析,全程不进 IPC / NodeContext.config。
- **run_workflow config 来源**:路径 1(前端只传 provider_idAiNode 内部取默认/查表)run_workflow 签名不变(向后兼容)。
- **兼容窗口**parse_params 双路径(provider_id 优先,老 base_url/api_key 明文路径保留 + deprecation warn)。