# 经验记录

> DevFlow 开发中沉淀的**经验性内容**——踩坑、约定、技巧、bug 排查教训。聚焦「这个坑怎么踩的 / 这个约定为什么这么定 / 这个 bug 怎么定位的」，区别于 [功能决策记录](./功能决策记录-2026-06-14.md)（记需求规格 + 设计决策规格）。
>
> 创建：2026-06-14（从功能决策记录-2026-06-14.md 分流出经验性条目） | 维护：随开发追加

## 约定

- 按类型分组：**踩坑**（隐性坑/反直觉）/ **约定**（代码实现约定 / 命名约定）/ **技巧**（具体技巧/配置）/ **bug 排查**（bug 定位过程与教训）。
- 每条标题标 `[来源日期]` + `[Sprint]`（如有），便于回溯原上下文。
- 三要素：**现象/决策** → **原因/根因** → **状态/教训**。
- 与功能决策记录区分：这里记「怎么实现的细节坑」，不记「为什么这么设计」。

---

## 一、踩坑

### i18n 模块必须命名空间化导出（扁平导出会断 $t + 键覆盖）[2026-06-14]

- **现象**：左侧菜单显示 `'nav.tasks'`（原样键名）；`dashboard` 整页显示 key 名；`AiChat` 的 `$t('ai.assistant')` 失效。
- **决策**：每个 i18n 模块文件 `export default { 命名空间: {...} }`（如 `nav.ts` → `{ nav: {...} }`），**禁止扁平导出顶层词条**。模板查询走 `$t('命名空间.key')`。
- **根因**：`index.ts` 聚合是 `Object.assign` 扁平合并各模块顶层 key（见「locale 拆分 + glob 聚合」决策）。扁平导出导致两个 bug：① `$t('nav.tasks')` 找 `messages.nav` 不存在，原样显示键名；② 扁平键（如 nav 的 `ideas`/`projects`/`tasks`/`knowledge`）与同名命名空间模块（ideas.ts/projects.ts/...）按文件名字母序互相覆盖。本次 nav.ts 扁平导出导致 4 个键被覆盖。
- **状态/教训**：✅ 系统性修复，共 4 模块扁平已全部改嵌套（zh/en 8 文件）：nav / common（8 文件 25 处 $t 引用）/ dashboard / ai。原本正确嵌套：ideas/projects/tasks/settings/knowledge/projectDetail/aiChat。**教训**：扁平导出是体系性 bug 非单点。排查「$t 显示原样键名」时应**优先怀疑模块导出结构（扁平 vs 嵌套）**，而非 SSR / locale 初始化。
- **绕路纠错**：曾误判根因为 SSR（实际 Tauri 纯客户端无 SSR）→ nav 走 `getNavTranslations` 硬编码 map + displayText 绕路 → 清除绕路恢复标准 `$t`。

---

## 二、约定

### Anthropic 流式 output_tokens 当累计值直接覆盖 / 流式 token 落库走累加模式 [2026-06-13]

- **决策**：① `message_delta` 事件的 output_tokens 直接覆盖 completion_tokens，**不像 prompt 那样累加**；② `save_conversation` upsert 路径 token 读旧值叠加（非覆盖）；`run_agentic_loop` 局部累加器每轮叠加、退出时一次性传 save。
- **原因**：① Anthropic 协议在 `message_delta` 返回的是**累计** output_tokens（截至当前总量），非增量；当增量处理会重复计算。OpenAI 则是末 chunk 一次性给全量——两协议语义不同，各自处理。② 审批暂停→恢复 spawn 全新 `run_agentic_loop` 实例，新 loop 局部累加器从 0 起；若覆盖写会丢旧 loop 已落库的 token。累加保证跨 loop 实例的对话总用量正确。
- **状态**：✅ 2026-06-13（两协议各自语义处理 + 跨 loop 累加保对话总量）

### db 字段加列须同步四处：migration + crud 白名单 + AI 工具层白名单 + 工具描述 [2026-06-14]

- **现象**：AI 对话让 AI 绑定目录，update_project(path) 报「不允许更新字段 'path'」，但 db schema 和 crud 白名单都已有 path。
- **根因**：可更新字段有两套独立白名单——crud.rs::allowed_columns_for（DB 层）+ ai.rs 工具闭包硬编码 match（AI 工具层）。加 path/stack 时只同步 DB 层漏 AI 工具层，两层不一致。
- **教训**：加 Record 可变字段同步四处（migration + crud 白名单 + ai.rs 工具白名单 + 工具描述）。排查「DB 有字段但工具报不允许」直查 ai.rs 硬编码。架构债：白名单双份去重。

### Tauri 命令文件拆子 module：命令函数必须 glob `pub use *`，不能逐个显式 [2026-06-14]

- **现象**：把含 `#[tauri::command]` 的单文件（如 ai.rs）拆成 `ai/` 子 module 时，mod.rs 用 `pub use self::commands::{ai_chat_send, ...}` 逐个显式重导出 17 个命令，`cargo check` 报 40 个 E0433：`cannot find __cmd__ai_chat_send in ai` / `cannot find __tauri_command_name_ai_chat_send in ai`。
- **根因**：`#[tauri::command]` 宏不只生成命令函数本身，还用 `paste!` 宏拼接生成一组同模块定义的内部符号（`__cmd__xxx`、`__tauri_command_name_xxx`）。`generate_handler!` 解析 `commands::ai::ai_chat_send` 时会查找 `commands::ai::__cmd__ai_chat_send`。逐个 `pub use self::commands::{ai_chat_send}` 只拉函数本身，**拉不到这些 `__cmd__` 内部符号**（即使它们在原模块是 pub 的）。
- **教训**：拆命令文件时，mod.rs 重导出命令必须用 `pub use self::commands::*;`（glob 把宏生成的全部符号一起拉到上层路径），不能用逐个显式。非命令 pub 项（如 `build_ai_tool_registry`/`restore_pending_approvals`）可逐个显式。后续若拆 idea.rs/project.rs/task.rs 等其他含命令的大文件，同此模式。
- **状态**：✅ 2026-06-14 验证（ai.rs 拆 11 子 module，glob 重导出后 cargo check 0 error）

### 跨层模块拆分：`super::xxx` 路径失效需改全限定 [2026-06-14]

- **现象**：ai.rs（commands 直接子模块）拆到 `ai/xxx.rs`（commands 孙模块）后，6 个子文件 `use super::now_millis` 全报 E0425 unresolved import。
- **根因**：`super` 指向当前模块的父——ai.rs 时 `super` = `commands`（`now_millis` 定义处）；拆到 `ai/xxx.rs` 后 `super` = `commands::ai`，`now_millis` 在祖父模块 `commands`。
- **教训**：拆层后所有 `super::xxx` 引用需重审。父模块的 helper（如 `now_millis`）改全限定 `crate::commands::now_millis` 最稳（不依赖层级）。或拆层前把 helper 下沉到子 mod.rs 内 `use` 一次，子文件用 `super::xxx`。
- **状态**：✅ 2026-06-14 验证（批量改 `crate::commands::now_millis`，6 文件 20+ 处）

### 删文件后被 linter/工具重建为 0 字节触发 E0761 [2026-06-14]

- **现象**：`rm commands/ai.rs` 后某 linter/hook 又建了 0 字节的 ai.rs，触发 `E0761: file for module ai found at both ai.rs and ai/mod.rs`，且 Rust 优先选空文件导致后续 40 个 `cannot find __cmd__xxx`（与 glob 重导出坑叠加，表象一致根因不同）。
- **教训**：拆分时删原文件后**立即 ls 验证不存在**再跑 cargo check，避免空文件 + 目录并存的 E0761 与命令宏符号坑混淆。E0761 出现先查是否有 0 字节残留文件。
- **状态**：✅ 2026-06-14 验证（删空 ai.rs 后通过）

### ALLOWED_COLUMNS 从全局共享演进为按表隔离 [2026-06-13]

- **决策**：`crud.rs` 列名白名单从单一全局 `ALLOWED_COLUMNS` 改为 `allowed_columns_for(table)` 按表 match；`validate_column_name(field, table)` 接收表名；宏 `query`/`update_field` 传 `$table`。专用更新路径列（knowledges.embedding 走 set_embedding、projects.deleted_at 走 soft_delete/restore）排除出白名单。
- **演进原因**：原原则（见功能决策记录需求澄清「代码审查甄别原则」）基于「全局白名单够防注入」。本轮多代理代码审查发现**真实 bug**：全局白名单**误含 ideas 表没有的 `reasoning` 列**（reasoning 属 knowledges/V10），`update_idea("reasoning")` 会 validate 通过但 SQLite 报 `no such column`——错误从「白名单拒绝」退化成「底层 SQL 错」且语义错。按表隔离既修此 bug（ideas 白名单不含 reasoning）又防未来跨表字段（update_task 误传 projects 的 `name` 在校验阶段拒绝，非靠 SQL 兜底）。
- **代价/取舍**：12 表 × N 列的 match 冗长，但数据驱动、可读、一次写对。**规模判断不变**（仍不加分页/不拆 LIMIT），仅白名单从「全局防注入」升级为「按表防注入 + 防跨表字段」。
- **状态**：✅ 2026-06-13 落地（cargo check + df-storage 32 test 全绿，含 `update_field_rejects_cross_table_column_tasks_name` 用例验证跨表字段被拒）

### 配置存储：SQLite/AppState Arc<Mutex>，非 Tauri app config [2026-06-13]

- **决策**：KnowledgeConfig（提取+注入共 5 项）**存 AppState 内存**（`knowledge_config: Arc<Mutex<KnowledgeConfig>>`），前后端通过 `knowledge_get_config`/`knowledge_save_config` IPC 读写；**不引入 tauri-plugin-store**。
- **演进**：[2026-06-13 初版设计] 写「存 Tauri app config」 → [2026-06-13 审查修正] 代码实证项目 Cargo.toml 仅 opener+window_state 两插件，**从未用过 config/store 机制**；现有设置走两条路（SQLite 存 provider / localStorage 存 UI 偏好） → [2026-06-14] **`SettingsRepo` 兑现本条预言**：`app_settings` KV 表（V13 迁移）+ 手写 `SettingsRepo`(get/set/get_all/delete，不走 `impl_repo!` 宏因 KV 无固定 schema)。localStorage 11 key 迁移启动：敏感 `df-connections` + UI 偏好(theme/language/ai-width/ai-ui/token/concurrency) + `df-ai-active-conv`；例外 `df-ai-gen`/`df-ai-text`（流式临时快照，每个 delta 写一次，SQLite 高频写拖慢流式，留 localStorage）。
- **原因**：AI Provider 配置已是 SQLite+Repo+IPC 模式，知识库行为配置（后端行为，非 UI 偏好）对齐同模式最一致。引入 tauri-plugin-store 是全新基础设施依赖，与既有 DB 路线割裂。AppState Arc<Mutex> 内存持有 + IPC 读写，启动时 `default()` 初始化（Tier 1 未持久化到 DB，进程重启回默认——够用，因这是行为偏好非数据）。未来要持久化时复用同一套 SettingsRepo 即可。
- **状态**：✅ 已实施（Tier 1）

### 知识删除语义：knowledge_archive 软删除（命名统一）[2026-06-13]

- **决策**：知识删除 command 命名 `knowledge_archive`（执行 `UPDATE status='archived'`），**不叫 knowledge_delete**。匹配 `ai_conversation_archive` 先例；主列表 `knowledge_list(status=None)` 默认仅返回 published（library 纯已发布，[2026-06-16] F-260616-02 决策 a 收窄：pending_review 归 `knowledge_list_candidates` 收件箱，不再混杂 library）。
- **原因/取舍**：idea/task/project 的 `delete_xxx` 都是硬删（DELETE FROM），若 knowledge 也叫 delete 却做归档，API 语义混淆（调用方期望数据消失，实际还在 DB）。conversation 模块已有正确先例（archive 命名表示软删除）。软删除复用 archived 状态，数据保留可追溯，列表默认过滤保证用户感知「已删除」。状态机 published→archived 也走同一路径。
- **状态**：✅ 已实施（Tier 1）

### Store 状态字段用 getter 替代引用快照 [Sprint 10]

- **决策**：`useProjectStore()` 返回对象的状态字段（projects/tasks/ideas/workflowExecutions/liveEvents/loading/error）改 getter 实时读 state，而非 `ideas: state.ideas` 引用快照。
- **原因**：引用快照在 `loadIdeas()` 等重新赋值 state.ideas 后，返回对象的 ideas 属性不更新（刷新后视图空，需切菜单再切回才显示）；getter 每次读 state，响应链成立。computed（stats/pendingApproval）在 reactive 内仍自动解包，各视图用法零改动。
- **状态**：🚧 Sprint 10（编译/构建通过，未 tauri dev 实测，根因通杀 Projects/Tasks/Dashboard）

---

## 三、技巧

### migrate_v4：PRAGMA table_info 探测列存在性 [Sprint 10]

- **决策**：v4 加 `archived` 列时，用 `PRAGMA table_info` 幂等探测列是否已存在，而非仅依赖 `schema_version` 版本号 gate。
- **原因**：历史坏库 `schema_version` 值混乱（早期迁移异常致版本号与实际 schema 不符），版本号不可靠；直接探列存在性最稳——已存在则跳过，不存在则补建，幂等可重入。
- **状态**：✅ Sprint 10

### Vite 端口 `strictPort: true` 不自动迁移 [Sprint 1]

- **决策**：`vite.config.ts` 设 `port: 1420` + `strictPort: true`，端口被占时**直接报错退出**而非自动 +1 迁移；`tauri.conf.json` 的 `devUrl` 写死 `http://localhost:1420`。
- **原因**：Tauri webview 启动时按 `devUrl` 加载前端，若 Vite 因冲突静默迁移到 1421 而 devUrl 仍是 1420 → 白屏/连不上，错误难定位（易误判为前端代码 bug）。`strictPort` 让端口冲突当场炸出，定位明确。代价：1420 被占需手动杀进程，但换取「devUrl 与实际端口必一致」的不变量。
- **状态**：✅ Sprint 1（本次会话核对：1420 vs 2661 反复折腾后回退到 1420，即此耦合的直接体现）

---

## 四、bug 排查

### ai_tool_executions 审计回写失效（Med/High 审批后卡 pending）[#54 实测]

- **现象**：用户审批 Med/High 工具后执行成功（副作用落库，如 create_project→projects 有记录），但 `ai_tool_executions` 仍 `status=pending / decided_by=None / executed_at=None / result=None`，审计未闭环。Low 工具正常（`decided_by=auto` 完整）。
- **根因（代码层定位）**：`crud.rs:103` 宏 `impl_repo!` 生成的通用 `query` 硬编码 `ORDER BY created_at DESC`，但 `ai_tool_executions` 表**无 `created_at` 列** → `audit_finalize` 的 `query("tool_call_id", x)` SQL 报 `no such column: created_at` → `.unwrap_or_default()` 吞错返回空 → `if let Some(rec)` 为 None → **永不回写**。Low 工具不走 query（`process_tool_calls` Low 分支直接 `audit_tool_call` insert 完整记录）故不受影响。
- **架构隐患**：通用 `query` 的 `ORDER BY created_at` 假设所有表都有该列——`ai_tool_executions`（及潜在其他无 `created_at` 的表）任何 `query()` 调用都静默失败；`unwrap_or_default` 吞 SQL 错误放大隐患。
- **修复**：✅ 已落地（2026-06-13）。采用方向①：`crud.rs` 给 `AiToolExecutionRepo` 加专用 `find_by_tool_call_id`（裸 SQL `ORDER BY requested_at DESC LIMIT 1`，绕过宏的 `created_at` 假设）；`ai.rs audit_finalize` 改用之，查不到记录改 `tracing::warn`（不再 `unwrap_or_default` 静默吞错）。
  - → 未改宏（方向②影响 7+ 表）/ 未加列（方向③需迁移）：隐患仅 `ai_tool_executions` 一处暴露，局部修最小影响。
  - → **架构隐患仍存（未根治）**：通用 `query`/`list_all` 宏对无 `created_at` 的表（`ai_tool_executions`/`node_executions`/`workflow_executions`）调用仍静默失败。当前仅 `ai_tool_executions` 有 `query` 调用且已绕开，余者暂无 `query` 调用点。未来新增调用时，要么该表登记 `created_at`，要么宏做容错。
- **教训**：宏生成的通用方法对表 schema 的隐式假设（这里「所有表都有 created_at」）是隐蔽的系统性风险；`unwrap_or_default()` 吞错误让 bug 隐形——关键路径慎用。

### reasoning 字段回填（修 bug：prompt 要求但写库丢弃）[2026-06-13]

- **现象/决策**：`KnowledgeRecord` 加 `reasoning: Option<String>`（V10 ALTER），`extract_knowledge_from_conversation` 解析 LLM JSON 的 `reasoning` 字段写入主表；前端详情溯源区展示「🤖 AI 判断依据」。
- **根因**：`EXTRACTION_SYSTEM_PROMPT` 早已要求 LLM 输出 `reasoning: "为何值得沉淀"`，但提炼循环（ai.rs 旧版）只取 kind/title/content/tags/confidence，**reasoning 被 LLM 产出却遭代码丢弃**——是信息链断裂的 bug，非缺功能。审核员光看 content 结论，缺 AI 判断依据（尤其 confidence=low 的弱信号更靠 reasoning 解释为何还提炼）。回填后溯源完整。
- **状态**：✅ 已实施（reasoning 存主表 + extracted 事件 context.reasoning 双写，前端优先取主表降级取事件）
- **教训**：LLM 输出字段与代码消费字段须对账——prompt 要求 LLM 产出的字段，代码侧漏消费是常见隐性 bug。

### prompt_tokens=0：深挖证伪非代码 bug（疑 GLM 订阅端点 message_start 缺 input_tokens）[#54 实测发现]

- **现象**：`ai_conversations.prompt_tokens=0`（completion=1496 正常）。GLM-订阅（anthropic 协议）1 对话 24 消息，所有 assistant 消息 `usage=None`。
- **深挖结论（→ 修正初判）**：初判「anthropic_compat usage 解析漏 input_tokens，待修」**证伪**。逐段验证：
  1. `anthropic_compat` message_start 取 `input_tokens→prompt_tokens` **有单测**（input=42 过）；
  2. `stream_llm`（857）`final_usage=chunk.usage.clone()` 累积对；
  3. `ai.rs:699` `tokens.add` 链路对。
  代码按标准 Anthropic 协议解析正确。`inp as u32`（Some→值，None→0）：completion 有值说明 message_delta 的 output GLM 返回了，**prompt=0 = GLM 订阅端点 message_start 疑未返回 `usage.input_tokens`**（协议非标）。**勿改 anthropic_compat**（改了 = 误改正确实现）。
- **状态**：📐 待修（误判）→ 🚫 非代码 bug。待抓 GLM 订阅 SSE 原文确认 input_tokens 在哪个事件/字段（临时打 message_start/message_delta 的 usage JSON 日志，测完删）；若确认端点缺则属 provider 兼容性待办，非解析 bug。
- **教训**：bug 定位优先用单测/逐段验证证伪代码层假设，不要急着改「看似正确」的实现。深挖证伪避免了一次误改。

---

**相关文档**：
- [功能决策记录](./功能决策记录-2026-06-14.md) — 需求规格 + 设计决策规格
- [功能决策记录-归档](./功能决策记录-归档-2026-06-14.md) — 纯流水 / 老 Sprint / UX 微调 / 已被取代
