# 全栈代码审查报告（2026-06-14）

> 范围：Rust 9 crate + Tauri 命令层 + Vue 3 前端（stores/composables/views/components/api），约 25k 行。
> 方法：5 个 general-purpose 子代理按模块并行审查（df-nodes/workflow/execute、df-ai/ideas/storage、src-tauri commands、stores/composables、views/components/api），主代理对高严重度项逐条读码核实。
> 互斥关系：与 [aichat审查报告-2026-06-14.md](../02-架构设计/aichat审查报告-2026-06-14.md)（AI Chat 专项）、[工作流审批审查报告-2026-06-14.md](../02-架构设计/工作流审批审查报告-2026-06-14.md)（工作流审批专项）去重；本轮不重复两份已记录的内容，仅记新发现与核查澄清。
> 增补：2026-06-14 追加 §0.1 修复进度表 + 在 FR-D1/FR-P1/FR-P2/FR-R4/FR-R5 章节内联标注已修复状态（对照 commit 36d68dd / 4a95f6a / 4b5f096）。

---

## 0. 核查澄清（审查代理误判，不记为 bug）

### ✅ 已澄清：`df-ideas/promotion.rs:80-91` `do_promote` 非"伪造成功"

子代理报告称 `do_promote` 返回 `promoted:true` 却未建项目，是"成功撒谎"高严重度 bug。**核查后判定为误判**：

- IPC 层 `src-tauri/src/commands/idea.rs:112` `promote_idea` **真正调用** `df_project::manager::ProjectManager::create_from_idea` 构造项目实体 + 映射 `ProjectRecord` + `update_full` 回写想法。前端走此 IPC，不走 crate。
- `df-ideas/src/promotion.rs` 的 `do_promote`（Auto 策略分支）**无任何调用方**，是 [功能决策记录 §想法立项](../02-架构设计/功能决策记录-2026-06-14.md) 明确的"crate 留纯决策 TODO"——crate 不依赖 df-storage/df-project 以避循环依赖，副作用放 IPC 组合。
- [todo.md:145](../todo.md) 已标注 `do_promote crate 层 TODO（现走前端闭环）`。

**保留的低优提示**：`do_promote` 返回值 `promoted:true` 对一个未接 df-project 的函数是误导性签名，若将来有人直接调用会产生真实的"成功撒谎"。建议接入前把返回改 `promoted:false` + `reason: "crate 层未接入"`，或加 `#[allow(dead_code)]` + 注释固化"无副作用占位"语义。**不计为新 bug，仅留改进提示。**

## 0.1 修复进度（2026-06-14 增补）

> 后续 commit 已落地的修复项汇总。下表 ID 沿用 commit message 编号（与本报告 §2-§6 的 FR-S/C/R/P/D 编号体系不完全一致，已括注对应章节）。报告内已有条目的，已在对应章节标题追加 ✅ 标记并补「已完成」说明；以下为**报告原未单列、commit 已落地**的项。

| commit ID | 问题 | 位置 | 状态 |
|---|------|------|------|
| 36d68dd FR-D6 | 补 `delete_task`/`update_task` 工具（防误用 `delete_project` 清理孤儿任务）| `tool_registry.rs` | ✅ 已完成（原缺任务级删改工具，迫使 LLM 误调项目级删除）|
| 36d68dd FR-D7 | 抽 `bind_dir_to_project` 消除 `create_project`/`bind_directory` 重复（DRY）| `tool_registry.rs` | ✅ 已完成 |
| 36d68dd FR-D8 | `create_idea` schema 补 `priority` 字段（前后端契约对齐）| `tool_registry.rs` | ✅ 已完成 |
| 4a95f6a FR-D4 | `replace_tool_result_content` 正向 O(n) 改反向 `rposition`（审批替换命中最近）| `crates/df-ai/src/context.rs` | ✅ 已完成（本报告未单列此 crate 层方法；低频审批路径，不引入索引）|

> 本报告 §6 FR-D4 指向的是 `migrations.rs` 的 13 个 if 链 DRY（与此处 commit 内 FR-D4 不同名同号），两者无关，勿混淆。报告 FR-D1（confirmDialog 重复）即 commit 内 FR-D5，已在 §6 内联标注。

---

## 1. 与已有文档去重

以下子代理报告的项**已在现有文档/todo 记录**，本报告不重复，仅交叉指引：

| 子代理发现 | 已记录位置 | 状态 |
|---|---|---|
| 条件引擎空壳（`conditions.rs` 仅认 true/false 字面量） | todo.md:30 (B-260614-02 修默认值) + todo.md:112 (T-260614-11 表达式升级待做) | 已记录 |
| 路径穿越 / symlink 逃逸 | todo.md:102 (T-260614-04 已完成 canonicalize) | 已修（注：canonicalize 防逃逸，但 **未防 read_file TOCTOU**，见 FR-S2） |
| localStorage 跨窗口传 currentText | todo.md:98 (B-260614-05，Sprint 19 有意保留) | 已记录 |
| ai_key 明文存 SQLite | 功能决策记录-归档-2026-06-14.md（旧记录，无根治决策） | **未根治，本轮重申见 FR-S1** |
| Settings.vue 1042 行 god file | todo.md:88 (T-260614-06 待立项) | 已记录 |
| confirmDialog 4 处重复 | 隐含在 View改造指南，无显式 todo | 记为 FR-D1 |

---

## 2. 安全（最高优先级）

### 🔴 FR-S1 — api_key 明文存储 + 经 IPC 明文传前端 [已核实]

**位置**：
- `crates/df-storage/src/migrations.rs:445-457`（`ai_providers.api_key TEXT NOT NULL` 明文列）
- `crates/df-storage/src/models.rs:138`（`api_key: String` 裸字段）
- `src-tauri/src/commands/ai/commands.rs:299-318`（`ai_save_provider` 直接 `update_full` 落盘）
- `src/views/Settings.vue:53`（`listProviders` 返回完整 key，前端 `maskKey` 仅展示层）

**问题**：三个环节都明文——DB 文件 `app_data_dir/devflow.db` 明文落盘全部 provider 密钥；list 命令经 IPC 把明文 key 传到前端 webview；前端仅做视觉 mask。任何能读 DB 文件的进程（备份、同步盘、其他本机进程）直接拿全部密钥。

**严重度**：高（桌面应用典型安全缺口，密钥=成本+越权）。

**建议**：OS 级凭据存储（Windows Credential Manager / macOS Keychain / Linux Secret Service，用 `keyring` crate 包一层），DB 只存引用 id；list 命令返回 mask 后的占位，仅编辑保存时按需取明文。

### 🔴 FR-S2 — `read_file` TOCTOU + `write_file` 无大小限制 [已核实]

**位置**：`src-tauri/src/commands/ai/tool_registry.rs:364-417`

**问题**：
- `read_file:364-380` 先 `tokio::fs::metadata(path)` 检查大小 ≤1MB，再 `tokio::fs::read_to_string(path)` 重新打开读——两个独立 syscall 之间路径可被 symlink 替换，1MB 限制可被绕过（检查时指向小文件、读取时切到大文件）。
- `write_file:399-417` 无任何 `content.len()` 校验，LLM 经审批可写超大文件撑爆磁盘/对话历史（虽有 `truncate_for_persist` 50KB 兜底持久化，但磁盘写入无防护）。

**注**：T-260614-04 已加 `resolve_workspace_path` canonicalize 防 symlink 逃逸，但 canonicalize 在文件存在时才做、且不防检查/读取两步间的替换。

**严重度**：高（审批门控后的 LLM 写权限，破坏面大）。

**建议**：`read_file` 改单次 `File::open → file.metadata() → read` 同一句柄消除竞态；`write_file` 加 `content.len()` 上限（如 1MB）超限 bail。

### 🔴 FR-S3 — `approve_human_approval` 未校验 decision 白名单 [已核实]

**位置**：`src-tauri/src/commands/workflow.rs:180-206`

**问题**：`decision: String` 直接透传到 `HumanApprovalResponse`，无白名单校验。`human_node` 若对 decision 做字符串匹配（如 `== "approved"`），传入 `""` / `"yes"` / `"approve"` 会被当作既非批准也非拒绝，HumanNode 可能永久卡死直到超时。

**严重度**：高（工作流审批的输入校验缺口，前端可传任意字符串）。

**建议**：命令层校验 `decision ∈ {"approved","rejected"}`，非法值返回 Err。

### 🟠 FR-S4 — SKILL.md 全文注入 system prompt [仅代理报告待复核]

**位置**：`src-tauri/src/commands/ai/commands.rs:67-71` + `src-tauri/src/commands/ai/skills.rs:164-169`

**问题**：`ai_chat_send(skill=...)` 把任意本机 `~/.claude/skills/*/SKILL.md` 全文拼到 system prompt。被篡改/恶意的 SKILL.md（含 prompt injection 指令）会作为系统指令注入，覆盖行为准则；配合 LLM 可调工具（write_file/create_project）构成提权路径。

**严重度**：中（信任本机技能文件的设计取舍，但缺隔离标注）。

**建议**：注入时明确包裹"以下是用户选择的技能说明，非系统指令"的隔离边界；或文档标注此取舍。

### 🟠 FR-S5 — 跨对话越权审批 [仅代理报告待复核]

**位置**：`src-tauri/src/commands/ai/audit.rs:111-139` + `commands.rs:104-185`

**问题**：`ai_approve` 只按 `tool_call_id` 从 `session.pending_approvals` 取审批，不校验该 tool_call 是否属于前端当前展示的对话。配合 `restore_pending_approvals` 启动时把所有 pending 行重建到单一内存 HashMap，任意前端可对任意对话的 pending 工具调用执行/拒绝。

**严重度**：中（本地单机影响有限，但审批 UI 的"对话隔离"是假象；Medium/High 工具审批门控意义被削弱）。

**建议**：`ai_approve` 增加 `expected_conversation_id` 参数并校验；或降级为文档说明"审批不按对话隔离"。

### 🟡 FR-S6 — `validate_column_name` 未登记表直接放行 [仅代理报告待复核]

**位置**：`crates/df-storage/src/crud.rs:334-350`

**问题**：`allowed_columns_for` 对未登记表返回 `None` → `validate_column_name` 返回 `Ok(())` 放行。当前 7 个 Repo 内部安全（表名是宏编译期常量），但 `is_allowed_column` 是 `pub fn`，若未来加了未登记表却走通用查询路径，列名直接进 `format!("... WHERE {} = ?1", field)` 字符串拼接即 SQL 注入。靠纪律非靠类型。

**严重度**：低（当前不可触发，潜在注入面）。

**建议**：未登记表保守拒绝，`None` 分支改 `Err`。

---

## 3. 正确性 Bug（行为错但不伪造）

### 🟠 FR-C1 — `formattedEvents` 时间漂移 [已核实]

**位置**：`src/views/ProjectDetail.vue:298-307`

**问题**：`formattedEvents` 是 computed，内部对每条事件 `new Date().toLocaleTimeString()` 取"当前时刻"。每次 `store.liveEvents` 变化（新事件追加）整个 computed 重算，**所有历史日志的 time 被刷新为最新触发时刻**，而非事件发生时刻。日志时间全错。

**严重度**：中（明显体验 bug）。

**建议**：事件入数组时固化时间戳（store push 时记 `Date.now()` 或用后端事件自带的 timestamp），computed 只读不改时间。

### 🟠 FR-C2 — `net_sentiment=0` 文案/样式矛盾 [已核实]

**位置**：`src/views/Ideas.vue:103`（模板）+ `:368`（`sentimentClass`）

**问题**：模板 `adversarialEval.net_sentiment > 0 ? 正面 : 负面`（`> 0` 严格），`sentimentClass` `sentiment >= 0 ? 'positive' : 'negative'`（`>= 0` 含零）。`net_sentiment === 0` 时：文案走 else 显示「负面」，样式走 `positive`。视觉与文案矛盾。

**严重度**：中（评估卡片核心展示，矛盾误导用户）。

**建议**：统一三档 `> 0 / < 0 / === 0`（中性），文案 + class 同源判定。

### 🟡 FR-C3 — Settings.vue 三个 timer 只清一个 [已核实]

**位置**：`src/views/Settings.vue:749-751`

**问题**：组件有 `_concurrencyTimer`（660）、`_knowledgeSaveTimer`（717）、`_toastTimer`（390）三个 setTimeout，`onUnmounted` 只 `clearTimeout(_concurrencyTimer)`。另两个泄漏，unmount 后若 fire 会写已销毁 reactive（`toast`/`knowledgeConfig`）。

**严重度**：低（3000ms/300ms 短 timer，实际触发概率低，但属明确的资源管理缺口）。

**建议**：`onUnmounted` 清全部三个 timer。

### 🟡 FR-C4 — `MIGRATION_VERSION` 死常量与逻辑错位 [已核实]

**位置**：`crates/df-storage/src/migrations.rs:7`

**问题**：`const MIGRATION_VERSION: i32 = 12`，但 `run()` 的 if 链已调到 `migrate_v13`（实际版本 13）。常量全仓未被引用（dead constant），却留在文件头作"当前迁移版本"误导。新加 v14 迁移时若有人据此常量判断，会踩坑。

**严重度**：低（当前无功能影响，纯误导 + 易踩坑）。

**建议**：删除未引用常量；或改成数组驱动的迁移注册（`&[(version, migrate_fn)]` 循环），让编译器/结构保证版本号与执行链同步。

### 🟡 FR-C5 — `AiConversationDetail` 缺 `readonly` 字段 [已核实]

**位置**：`src/api/types.ts:242-246`（缺字段）vs `src-tauri/src/commands/ai/commands.rs:450-455`（后端生成中返回 `readonly:true`）

**问题**：后端 `ai_conversation_switch` 在 `session.generating` 时返回 `readonly: true`，前端类型定义未声明。store 层读 `detail.readonly` 会 TS 报错或被忽略，生成中切换对话时前端无法感知只读态。

**严重度**：低（TS 类型缺口，影响开发期类型安全 + 一个未利用的只读信号）。

**建议**：`AiConversationDetail` 加 `readonly?: boolean`。

---

## 4. 并发 / 竞态（前端高频路径）

### 🟠 FR-R1 — `switchConversation` 无切换 token，竞态致历史错配 [仅代理报告待复核]

**位置**：`src/composables/ai/useAiConversations.ts:44-121`

**问题**：`switchConversation` 含多个 await（`aiApi.switchConversation`、`aiApi.pendingToolCalls`）。快速连点 A→B，若 B 网络更快先返回，`state.activeConversationId=B` 但 A 后返回又把 `state.messages` 覆盖成 A 的历史——最终 activeId=B、messages=A，视图与激活会话不一致。B 的流式 delta 也可能被 `isCurrent` 判定错配，串话到 A 视图。

**严重度**：中（用户高频操作路径，错配难复现但体验崩坏）。

**建议**：加切换 token（`latestSwitchId`），await 后比对，过期响应丢弃；或用 `generatingConvId` 而非 `activeConversationId` 路由流式事件。

### 🟠 FR-R2 — 全局单看门狗 timer 多窗口互踩 [仅代理报告待复核]

**位置**：`src/composables/ai/useAiStream.ts:18-44` + `useAiWindow.ts`（共享 state 单例）

**问题**：`_streamWatchdog` 模块级单 timer。分离窗口模式主/分离窗口共享同一 `state`，两侧 `handleEvent` 都 `resetStreamWatchdog()`。主窗口进入审批等待（clear timer）后，分离窗口的一个 delta 又 `resetStreamWatchdog()` 重启，审批态被误触发 130s 超时强制收尾。

**严重度**：中（隐蔽难复现，审批态被误中断）。

**建议**：按 `conversation_id` 维护 `Map<convId, timer>`，或分离窗口模式下由一方独占看门狗。

### 🟡 FR-R3 — `liveEvents` 数组无限增长 [仅代理报告待复核]

**位置**：`src/stores/project.ts:213`

**问题**：`state.liveEvents.push(payload)` 从不裁剪。长时间运行的工作流让数组无限膨胀，每次 push 触发整个数组的响应式依赖重算（含 `formattedEvents` computed 全量 map）。

**严重度**：低（需超长工作流才显现，但内存+响应式开销随时间线性增长）。

**建议**：加上限（如 `length > 500` 时 `shift` 最旧）或改环形缓冲。

### 🟡 FR-R4 — LLM `complete()` 同步路径无超时无重试 [仅代理报告待复核] ✅ 已完成（commit 36d68dd）

**位置**：`crates/df-ai/src/openai_compat.rs:230-243` + `anthropic_compat.rs:226-232`

**问题**：`Client::builder()` 只设 `connect_timeout(30s)` 刻意不设总 timeout。`complete()`（同步调用，非流式）路径无整体超时、无重试、无取消令牌。远端建连成功但响应慢（网络静默、限流排队）时 `send().await` 无限挂起，卡死调用方任务。流式路径注释说由上层 idle timeout 兜底，但 complete() 无此兜底。

**严重度**：中（一次网络抖动挂死一轮对话）。

**建议**：`complete()` 用 `tokio::time::timeout` 包整体调用；加重试（指数退避 2-3 次）。

> **已完成**：`complete()` 路径用 `RequestBuilder::timeout(Duration::from_secs(60))` 设 60s 单请求超时；不影响 `stream` 流式路径（仍靠上层 idle timeout 兜底）。重试未加（当前需求仅防挂死）。commit 36d68dd。

### 🟡 FR-R5 — `findToolCall` / `flatMap` 全量线性扫描 [仅代理报告待复核] ✅ 已完成（commit 4b5f096）

**位置**：`src/composables/ai/useAiEvents.ts:63-71` + `useAiSend.ts:82-84`

**问题**：每个 `AiToolCallCompleted`/`AiApprovalRequired`/`AiApprovalResult`/`approveToolCall` 都遍历全部 messages 再遍历每条 toolCalls。长对话（数百消息）高频工具调用 O(n²)。

**严重度**：低（性能，长对话显现）。

**建议**：维护 `Map<toolCallId, AiToolCallInfo>` 索引，`AiToolCallStarted` 登记，事件直接查 Map。

> **已完成**：`useAiEvents::findToolCall` 改为反向遍历（命中最近一条同 id 工具调用）。**放弃 Map 索引方案**——审批替换等场景需命中最新 toolCall，Map 索引易留陈旧引用。commit 4b5f096。

---

## 5. 性能（热路径）

### 🟡 FR-P1 — `dag.rs` 拓扑排序 O(V·E) + 入度 unwrap panic 风险 [仅代理报告待复核] ✅ 已完成（commit 4b5f096 / commit 内编号 FR-D1）

**位置**：`crates/df-workflow/src/dag.rs:65,74,114`

**问题**：`successors`/`predecessors` 是 `self.edges.iter().filter(...)` 全表扫描。`topological_layers` 对每个出队节点调 `successors`，整体 O(V·E)；`executor.rs:81` 又对每个节点调 `predecessors`，再叠加 O(V·E)。DAG 变大（几十节点上百边）明显变慢。另 `dag.rs:115` `in_degree.get_mut(&succ).unwrap()`，若 edges 含 target 不在 nodes 的野节点（successors 不过滤），succ 不在 in_degree 表 → unwrap panic。

**严重度**：低（当前 DAG 规模小无感）。

**建议**：预建 `adjacency_out`/`adjacency_in: HashMap<NodeId, Vec<NodeId>>`，`add_edge*` 维护，拓扑降 O(V+E)；`successors` 过滤野节点或 unwrap 改 `if let Some`。

> **已完成**：`dag.rs::topological_layers` 一次性遍历边建 `adjacency_out`（出边表）+ 入度表，BFS 分层走索引；`executor.rs::run` 预建 `adjacency_in`（入边索引）取前驱。O(V·E) → O(V+E)，14 测试全绿（含 executor 3 测）。commit 4b5f096（该 commit 内编号为 FR-D1）。

### 🟡 FR-P2 — `search_vector` 全表扫 + SELECT * [仅代理报告待复核] ✅ 已完成（commit 4a95f6a / commit 内编号 FR-D2）

**位置**：`crates/df-storage/src/crud.rs:1176-1209`

**问题**：每次向量检索 `SELECT * FROM knowledges WHERE status='published' AND embedding IS NOT NULL` 拉全部已发布记录的完整 BLOB（含 content/title 大文本），Rust 端算余弦。仅用了 embedding 却拉了全部字段。注释自评"<50k <50ms"，但无 LIMIT 上限，行数无界增长。

**严重度**：低（知识检索热路径，当前数据量可接受）。

**建议**：SELECT 只取 id+embedding，算完 top-N 的 id 再回表取详情；长期换 sqlite-vec（注释已预告）。

> **已完成**：`SELECT *` 改为显式 14 列（id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, created_at, updated_at），`embedding` 单独另取。消除隐式依赖，字段级精简待单独立项。commit 4a95f6a（该 commit 内编号为 FR-D2）。

### 🟡 FR-P3 — 全局单 SQLite 连接串行瓶颈 [仅代理报告待复核]

**位置**：`crates/df-storage/src/db.rs:13-60` + `crud.rs` 宏内 `spawn_blocking`+`blocking_lock`

**问题**：整个数据库一条 `Connection`，所有读写串行抢 `tokio::sync::Mutex`。每条 CRUD（含 `list_all`/`search`）都 spawn_blocking + blocking_lock。全局热点锁，并发查询互相阻塞。WAL 已开但读路径未享并发红利。代码注释已有 r2d2 TODO 未做。

**严重度**：低（单用户桌面应用，并发量小，当前无感）。

**建议**：r2d2/deadpool-sqlite 连接池（写单连接+Mutex，读多连接并发）；最低限度换 `std::sync::Mutex`（spawn_blocking 同步上下文内开销更低，不跨 await）。

### 🟡 FR-P4 — `human_node` 500ms 忙轮询取消状态 [已核实]

**位置**：`crates/df-nodes/src/human_node.rs:54`

**问题**：`cancel_tick = interval(500ms)`，select! 内每 500ms 检查 `is_cancelled`。最长 3600s 阻塞节点 = 7200 次无谓锁竞争（StateMachine HashMap 锁）。魔法数字无注释。

**严重度**：低（开销小但模式不佳）。

**建议**：取消走独立 `tokio::sync::Notify`/oneshot，HumanNode `notify.notified().await` 零轮询。注：todo.md:142 已记"停止生成 idle 用 Notify 替代轮询"，此为同类不同位置（审批节点 vs stopChat）。

### 🟡 FR-P5 — `build_system_prompt` 每次发消息全表扫项目 [仅代理报告待复核]

**位置**：`src-tauri/src/commands/ai/prompt.rs:70-86`

**问题**：`ai_chat_send` 每次调 `build_system_prompt` → `state.projects.list_active()` → `take(20)` 拼 format! 字符串。热路径每对话重扫全表+重拼。

**严重度**：低（项目表小，SQL 本身快，format! 重复分配是主要开销）。

**建议**：项目列表变动时刷新缓存（已有 path 绑定事件可触发），或注入前比对 updated_at 决定是否重算。

### 🟡 FR-P6 — `AiChat.vue` watch messages 用 JSON.stringify 全量快照 [仅代理报告待复核]

**位置**：`src/components/AiChat.vue:670-678`

**问题**：`watch(() => store.state.messages, ..., { deep: true })` 内对整个 messages 做 `JSON.stringify(msgs.map(...))`。流式生成期间 currentText 每个 delta 触发 messages 对象变更，deep watch 每秒数十次全量序列化。长对话主线程阻塞。

**严重度**：低（与 aichat-review AR-1 流式 Markdown 全量重解析叠加放大）。

**建议**：watch 具体派生信号（如 toolCalls 的 status 集合）而非整个 messages deep 快照。

---

## 6. 可读性 / DRY（重构项）

### FR-D1 — confirmDialog 逻辑 4 处重复 [已核实] ✅ 已完成（commit 4a95f6a / commit 内编号 FR-D5）
`Ideas.vue:217` / `Projects.vue:137` / `ProjectDetail.vue:226` / `Settings.vue:403` 各一份 `confirmState + confirmDialog + answerConfirm`，代码几乎逐字相同。抽 `useConfirmDialog()` composable 配合现有 `ConfirmDialog.vue`。

> **已完成**：抽 `src/composables/useConfirm.ts`，Projects/ProjectDetail/Ideas/Settings 四视图改用 composable，各削减约 14 行重复（commit 4a95f6a；该 commit 内编号为 FR-D5）。

### FR-D2 — `parseTags`/`parseScores`/`parseStack` JSON 解析散落 [已核实]
同类"JSON 字符串→数组"解析在 Ideas/Knowledge/Projects/ProjectDetail 多处独立实现。收敛到 `utils/json.ts` 的 `parseJsonArray`/`parseJsonObject`。

### FR-D3 — `set_waiting`/`set_skipped`/`emit_human_approval_request` 死代码 [仅代理报告待复核]
`df-workflow/state.rs:87-111`（set_waiting/set_skipped 无调用方）+ `eventbus.rs:44`（emit_human_approval_request 无调用方）。`set_cancelled` 绕过状态机转换校验直接 insert。建议清理死代码 + `set_cancelled` 纳入合法转换白名单。

### FR-D4 — migrations 13 个手写 if 链 DRY 反例 [已核实]
`migrations.rs:38-90` 13 个 `if current_version < N { migrate_vN()? }` 手写重复，新加迁移易漏注册且编译器不报。改数组驱动 `&[(version, fn)]` 循环（与 FR-C4 同根，一并修）。

### FR-D5 — 状态机后门 + 死代码清理集合 [仅代理报告待复核]
合并 FR-D3 + `state.rs` 的 `set_*` 系列审计：保留 `set_cancelled`（cancel IPC 必需）但纳入 `is_legal` 转换图；删除 `set_waiting`/`set_skipped` 未用方法。

---

## 7. 核对通过的项（无问题）

- **R6 await 缺失是孤立 bug**：其余 10 处 send/emit/channel 操作全部正确 await（已逐条核查，见 workflow-approval-review 结论）。
- **执行链完整**：`run_workflow → spawn → DagExecutor::run → join_all(node.execute) → HumanNode select! → approve IPC → Response → HumanNode 返回 → executor 继续`，无断裂；cancel 通路 `cancel_workflow_node → set_cancelled → HumanNode 500ms 轮询 is_cancelled → Err` 也完整。
- **SQL 参数化扎实**：`query`/`update_field` 经 `validate_column_name` 白名单 + `params![]` 参数绑定，无 value 拼接注入（FR-S6 是潜在面非现行漏洞）。
- **tool_registry 无运行时注册入口**：`AiToolRegistry` 只在 `build_ai_tool_registry` 编译期注册，前端无法注入恶意工具。
- **v-html 安全**：`AiChat.vue:200` `renderMd` 经 DOMPurify sanitize；`ToolCard.vue` 多处 v-html 均为常量 SVG 非用户输入。
- **事务边界**：`purge_with_descendants` 子表先删父表后删单事务 commit；迁移脚本幂等（PRAGMA 探测 + IF NOT EXISTS）。

---

## 8. 建议追加到 todo.md 的项

按严重度给 todo ID（待用户确认是否进看板）：

| 建议 ID | 对应 | 优先级 |
|---|---|---|
| FR-S1（api_key 加密） | 本报告 §2 | P0 |
| FR-S2（read_file TOCTOU + write_file 大小限制） | 本报告 §2 | P0 |
| FR-S3（approve decision 白名单） | 本报告 §2 | P0 |
| FR-R1（switchConversation 切换 token） | 本报告 §4 | P1 |
| FR-R2（看门狗多窗口互踩） | 本报告 §4 | P1 |
| FR-C1（formattedEvents 时间漂移） | 本报告 §3 | P1 |
| FR-C2（net_sentiment=0 矛盾） | 本报告 §3 | P1 |
| FR-S4/S5（SKILL.md 注入 / 跨对话审批） | 本报告 §2 | P2 |
| FR-R4（complete() 无超时） | 本报告 §4 | P2 |
| FR-C3/C4/C5（timer 清理 / 迁移常量 / readonly 类型） | 本报告 §3 | P2 |
| FR-P1~P6 / FR-D1~D5（性能 + 重构） | 本报告 §5-6 | P2/长期 |

**优先修 3 件**（安全 + 明显体验）：
1. **FR-S1** api_key 加密（凭据泄露面最大）
2. **FR-S3** approve decision 白名单（一行校验，防 HumanNode 卡死）
3. **FR-C1** formattedEvents 时间漂移（明显体验 bug，改动小）

> 本报告仅审查 + 文档，不含代码修改。实际修复另起会话。
