//! 数据库迁移 — 建表 SQL 与版本管理 use crate::migrations_helpers::column_exists; use anyhow::Result; use rusqlite::Connection; /// 执行所有迁移 pub fn run(conn: &Connection) -> Result<()> { // 创建迁移版本表 conn.execute_batch( "CREATE TABLE IF NOT EXISTS schema_version ( version INTEGER PRIMARY KEY );" )?; let current_version: i32 = conn .query_row( "SELECT COALESCE(MAX(version), 0) FROM schema_version", [], |row| row.get(0), ) .unwrap_or(0); // 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。 // 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。 // V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id; // V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表); // V23 = knowledges.embedding_status 列(嵌入失败可补偿重试); // V24 = ideas.related_ids 列(灵感间关联关系持久化打底); // V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底); // V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。 // V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed, // 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 + // 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。 // V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。 // V29 = 知识图谱 Phase 1 任务网络基础(对标 docs/02-架构设计/专项设计/ // 项目知识图谱与任务队列系统-2026-06-26.md §2.1/§2.2):tasks 加 queue(管理池)/ // parent_id(父任务纵向关联)/content_json(结构化需求规格)三列 + task_links 表 // (横向关联 depends_on/blocks/relates_to),为 AI 编排地基打底。 // V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表, // 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索 // 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。 // V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表, // 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了 // 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。 // V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照, // 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。 let steps: [(i32, fn(&Connection) -> Result<()>); 35] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), (4, migrate_v4), (5, migrate_v5), (6, migrate_v6), (7, migrate_v7), (8, migrate_v8), (9, migrate_v9), (10, migrate_v10), (11, migrate_v11), (12, migrate_v12), (13, migrate_v13), (14, migrate_v14), (15, migrate_v15), (16, migrate_v16), (17, migrate_v17), (18, migrate_v18), (19, migrate_v19), (20, migrate_v20), (21, migrate_v21), (22, migrate_v22), (23, migrate_v23), (24, migrate_v24), (25, migrate_v25), (26, migrate_v26), (27, migrate_v27), (28, migrate_v28), (29, migrate_v29), (30, migrate_v30), (31, migrate_v31), (32, migrate_v32), (33, migrate_v33), (34, migrate_v34), (35, migrate_v35), ]; for (version, migrate_fn) in steps { if current_version < version { migrate_fn(conn)?; } } Ok(()) } /// V1: 初始表结构 fn migrate_v1(conn: &Connection) -> Result<()> { conn.execute_batch(V1_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [1])?; tracing::info!("迁移 v1 完成"); Ok(()) } /// V2: 补齐关联字段 + branches 表 fn migrate_v2(conn: &Connection) -> Result<()> { conn.execute_batch(V2_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [2])?; tracing::info!("迁移 v2 完成"); Ok(()) } /// V3: AI 对话表补建(新库) + 归档标记列(新老库统一) fn migrate_v3(conn: &Connection) -> Result<()> { conn.execute_batch(V3_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [3])?; tracing::info!("迁移 v3 完成"); Ok(()) } /// V4: 幂等补 ai_conversations.archived 列 /// /// 修复历史缺陷:早期 v3 迁移仅写入版本号 3,ALTER ADD COLUMN archived 未实际生效, /// 导致 schema_version=3 但 ai_conversations 缺列,from_row 读 archived 报错, /// list_all 失败 → 前端历史会话不显示 + 新对话 insert 失败。 /// 因 run() 按 `current_version < 3` 跳过 v3,该列无法靠 v3 自补。 /// 此处用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全。 fn migrate_v4(conn: &Connection) -> Result<()> { let has_archived = column_exists(conn, "ai_conversations", "archived"); if !has_archived { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v4: 补建 ai_conversations.archived 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [4])?; tracing::info!("迁移 v4 完成"); Ok(()) } /// V5: 幂等补 ai_conversations.prompt_tokens / completion_tokens 列 /// /// 流式 token 用量记录:对话级累计 input/output token(由 save_conversation 写入)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)。 fn migrate_v5(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "prompt_tokens") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN prompt_tokens INTEGER", [])?; tracing::info!("v5: 补建 ai_conversations.prompt_tokens 列"); } if !column_exists(conn, "ai_conversations", "completion_tokens") { conn.execute("ALTER TABLE ai_conversations ADD COLUMN completion_tokens INTEGER", [])?; tracing::info!("v5: 补建 ai_conversations.completion_tokens 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [5])?; tracing::info!("迁移 v5 完成"); Ok(()) } /// V6: 幂等补 ai_conversations.models 列 /// /// 对话级多 model 记录:JSON 数组字符串(去重存对话用过的所有 model)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5 模式)。 fn migrate_v6(conn: &Connection) -> Result<()> { let has_models = column_exists(conn, "ai_conversations", "models"); if !has_models { conn.execute("ALTER TABLE ai_conversations ADD COLUMN models TEXT", [])?; tracing::info!("v6: 补建 ai_conversations.models 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [6])?; tracing::info!("迁移 v6 完成"); Ok(()) } /// V7: 知识库表 — 经验沉淀的基本单元(共享记忆层) /// /// 状态机: candidate → pending_review → published → archived /// AI 只产 candidate,人工门控发布;reuse_count 是唯一客观排序信号。 /// effectiveness 列不建(决策撤销人工评分)。时间字段用毫秒字符串(同既有 model 约定)。 fn migrate_v7(conn: &Connection) -> Result<()> { conn.execute_batch(V7_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [7])?; tracing::info!("迁移 v7 完成"); Ok(()) } /// V8: 幂等补 knowledges.embedding 列(向量检索) /// /// 存 Vec 的小端字节序列化 BLOB。NULL = 未嵌入(走 LIKE 降级)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6 模式)。 fn migrate_v8(conn: &Connection) -> Result<()> { let has_embedding = column_exists(conn, "knowledges", "embedding"); if !has_embedding { conn.execute("ALTER TABLE knowledges ADD COLUMN embedding BLOB", [])?; tracing::info!("v8: 补建 knowledges.embedding 列"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [8])?; tracing::info!("迁移 v8 完成"); Ok(()) } /// V9: 幂等补建 ai_providers + ai_tool_executions 表 /// /// 历史遗漏:这两张表从未写入迁移文件(V1-V8 均未包含), /// 旧库可能通过其他方式已建,新库缺失导致 save_provider 等操作报 SQL 错误。 /// 用 CREATE TABLE IF NOT EXISTS 幂等,已有表不受影响。 fn migrate_v9(conn: &Connection) -> Result<()> { conn.execute_batch(V9_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [9])?; tracing::info!("迁移 v9 完成"); Ok(()) } /// V10: 知识生命线 — 补 knowledges.reasoning 列 + 新建 knowledge_events 事件表 /// /// - reasoning: AI 提炼时给出的"为何值得沉淀"判断依据(此前 prompt 要求但写库丢弃, /// 此处补列修复;老库行默认 NULL,前端降级展示"手动录入/无依据")。幂等(PRAGMA 探测)。 /// - knowledge_events: 追加型审计表,记录产生/审核/引用/归档四类事件,支撑生命线视图。 /// 独立表(非 JSON 嵌主表): 一条知识可被引用数百次,JSON 嵌入致行膨胀+更新竞争。 fn migrate_v10(conn: &Connection) -> Result<()> { if !column_exists(conn, "knowledges", "reasoning") { conn.execute("ALTER TABLE knowledges ADD COLUMN reasoning TEXT", [])?; tracing::info!("v10: 补建 knowledges.reasoning 列"); } conn.execute_batch(V10_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [10])?; tracing::info!("迁移 v10 完成"); Ok(()) } /// V11: 幂等补 projects.deleted_at 列(软删回收站) /// /// 删除项目改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// ProjectRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL 过滤; /// 子表(tasks/releases/branches)不动,FK 仍满足,项目数据完整保留待恢复。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10 模式)。 fn migrate_v11(conn: &Connection) -> Result<()> { if !column_exists(conn, "projects", "deleted_at") { conn.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v11: 补建 projects.deleted_at 列(软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [11])?; tracing::info!("迁移 v11 完成"); Ok(()) } /// V12: 幂等补 projects.path / projects.stack 列(项目绑定真实代码目录) /// /// 项目与磁盘代码库脱钩是项目管理核心缺失:此版补 path(绑定目录绝对路径) + /// stack(技术栈 JSON 数组字符串),为「绑定目录 + 探测技术栈」打地基, /// 第二步「导入历史项目」直接复用。两列均 nullable,老项目 path/stack=NULL 天然兼容。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。 fn migrate_v12(conn: &Connection) -> Result<()> { if !column_exists(conn, "projects", "path") { conn.execute("ALTER TABLE projects ADD COLUMN path TEXT", [])?; tracing::info!("v12: 补建 projects.path 列(绑定代码目录)"); } if !column_exists(conn, "projects", "stack") { conn.execute("ALTER TABLE projects ADD COLUMN stack TEXT", [])?; tracing::info!("v12: 补建 projects.stack 列(技术栈)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [12])?; tracing::info!("迁移 v12 完成"); Ok(()) } /// V13: 通用应用设置 KV 表(前端 localStorage 迁移目标) /// /// 存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。CREATE TABLE IF NOT EXISTS 幂等。 fn migrate_v13(conn: &Connection) -> Result<()> { conn.execute_batch(V13_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [13])?; tracing::info!("迁移 v13 完成"); Ok(()) } /// V14: 幂等补 tasks.deleted_at 列(软删回收站,对标 projects.deleted_at V11) /// /// 删除任务改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// 与 projects.soft_delete 同模板:TaskRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL /// 过滤;子表(branches)不动,FK 仍满足,任务数据完整保留待恢复。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。 fn migrate_v14(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "deleted_at") { conn.execute("ALTER TABLE tasks ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v14: 补建 tasks.deleted_at 列(软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [14])?; tracing::info!("迁移 v14 完成"); Ok(()) } /// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04) /// /// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1, /// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。 /// NOT NULL DEFAULT 0 保证老库行迁移后取值确定(非 NULL),TaskRecord 字段为 i32(非 Option)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14 模式)。 fn migrate_v15(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "review_rounds") { conn.execute( "ALTER TABLE tasks ADD COLUMN review_rounds INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v15: 补建 tasks.review_rounds 列(review 退回累计轮数)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [15])?; tracing::info!("迁移 v15 完成"); Ok(()) } /// V16: 幂等补 ai_conversations.pinned 列(对话置顶,UX-17) /// /// 侧栏置顶分组排序信号:前端按 pinned DESC, updated_at DESC 排,置顶在前。 /// 纯元数据标记(同 archived),NOT NULL DEFAULT 0 保证老库行非 NULL,AiConversationRecord.pinned 为 bool。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15 模式),对新库/老库均安全。 fn migrate_v16(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_conversations", "pinned") { conn.execute( "ALTER TABLE ai_conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0", [], )?; tracing::info!("v16: 补建 ai_conversations.pinned 列(对话置顶)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [16])?; tracing::info!("迁移 v16 完成"); Ok(()) } /// V17: 幂等补 tasks.output_json 列(AiNode 自审闭环产出,决策 a:task 中心) /// /// 任务产出 JSON 字符串:ai_execute 写产出 / ai_self_review 读产出做自审 / human_review 展示对象。 /// TEXT NULL 向后兼容(老库行默认 NULL,TaskRecord 字段为 Option), /// 经通用 update_field 白名单写入(非 status 状态机收口字段,status 收口 F-03 不变)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16 模式),对新库/老库均安全。 fn migrate_v17(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "output_json") { conn.execute("ALTER TABLE tasks ADD COLUMN output_json TEXT", [])?; tracing::info!("v17: 补建 tasks.output_json 列(AiNode 自审闭环产出)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [17])?; tracing::info!("迁移 v17 完成"); Ok(()) } /// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1) /// /// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容: /// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 模式),对新库/老库均安全。 fn migrate_v18(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_providers", "model_configs") { conn.execute("ALTER TABLE ai_providers ADD COLUMN model_configs TEXT", [])?; tracing::info!("v18: 补建 ai_providers.model_configs 列(模型能力配置)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [18])?; tracing::info!("迁移 v18 完成"); Ok(()) } /// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(F-260614-04 多 Provider 负载均衡池) /// /// - `enabled INTEGER NOT NULL DEFAULT 1`:provider 是否进入负载均衡池。 /// 老库行迁移后默认 1(所有现存 provider 默认启用,单 provider 路径零变化)。 /// is_default 仍保留作启动兜底(get_active_provider 无 active_provider_id 时取 is_default)。 /// - `weight INTEGER NOT NULL DEFAULT 50`:provider 在池中的选择权重(0-100)。 /// 高权重 provider 优先被选为主;同权重时退化近似轮询。 /// /// 向后兼容:老库行 ALTER 后取 DEFAULT,from_row 经 i32→bool / i32→u32 解析。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v17/v18 模式),对新库/老库均安全。 fn migrate_v19(conn: &Connection) -> Result<()> { if !column_exists(conn, "ai_providers", "enabled") { conn.execute( "ALTER TABLE ai_providers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1", [], )?; tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池,F-260614-04)"); } if !column_exists(conn, "ai_providers", "weight") { conn.execute( "ALTER TABLE ai_providers ADD COLUMN weight INTEGER NOT NULL DEFAULT 50", [], )?; tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池,F-260614-04)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?; tracing::info!("迁移 v19 完成"); Ok(()) } /// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,1对1 单向) /// /// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式 /// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord /// 字段为 Option(未关联灵感的任务为 None)。 /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v11/v14/v17 模式),对新库/老库均安全。 /// 新库已在 V9_SQL(tasks 建表)直接带 idea_id 列,此处只补老库。 fn migrate_v20(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "idea_id") { conn.execute( "ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)", [], )?; tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感,F-260619-01)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?; tracing::info!("迁移 v20 完成"); Ok(()) } /// V21:消息拆分存储(ai_messages 表 + 全量迁移)+ ai_tool_executions.message_id 列 /// /// **一次原子迁移**(决策 V21 合并,不拆 V21a/V21b): /// 1. 建表 ai_messages(IF NOT EXISTS 幂等,新库空表/老库均安全) /// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit) /// 3. COUNT 探测 ai_messages 已有数据 → 跳过数据迁移(仅写版本号,防重复迁移) /// 4. 遍历 ai_conversations.messages JSON → 逐条提取到 ai_messages(分批 commit) /// /// 设计要点(详见消息拆分存储设计 §4.2): /// - **幂等安全**:COUNT 探测 + INSERT OR IGNORE,中途崩溃重跑跳过已迁移数据 /// - **分批 commit**:每 50 对话一批,避免长事务持有 SQLite 写锁 /// - **迁移期 ID**:`msg_migrated_{conv_id}_{seq}` —— 天然唯一(UNIQUE 是 conv_id+seq)、零依赖 /// - **裸 JSON 提取**:用 `serde_json::Value` 而非 ChatMessage(df-storage 不依赖 df-ai-core) /// - **坏数据跳过**:JSON 解析失败 → warn + continue,不中断迁移 /// - **status 归一化**:None/空 → "active",列语义清晰永不 NULL /// - **created_at 语义**:有 timestamp 用消息自己的;没有 fallback 到对话 created_at /// /// ⚠️ **迁移耦合点**:迁移函数硬编码 JSON 字段名(role/content/parts/tool_call_id/ /// tool_calls/model/status/reasoning_content/timestamp),与 ChatMessage serde 序列化字段 /// 一一对应。ChatMessage 改字段名必须同步更新此函数,否则老库迁移漏数据。 /// 同步标注已在 types.rs ChatMessage 定义处加注释。 fn migrate_v21(conn: &Connection) -> Result<()> { // 1. 建 ai_messages 表(IF NOT EXISTS 幂等) conn.execute_batch(V21_SQL)?; // 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit,F-260619-04) // 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。 // 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。 let tool_exec_table_exists: bool = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type='table' AND name='ai_tool_executions'", [], |_| Ok(()), ) .is_ok(); if tool_exec_table_exists && !column_exists(conn, "ai_tool_executions", "message_id") { conn.execute( "ALTER TABLE ai_tool_executions ADD COLUMN message_id TEXT", [], )?; tracing::info!("v21: 补建 ai_tool_executions.message_id 列(消息级溯源 audit)"); } // 3. COUNT 探测:ai_messages 已有数据 → 跳过迁移只写版本号(幂等) // INSERT OR IGNORE 防崩溃重跑(schema_version PK 冲突):run() 正常流程只调 // 一次 migrate_v21(current_version<21),但崩溃重跑/手动重调时 version=21 // 可能已存在,IGNORE 保证幂等不报错。 let existing: i64 = conn.query_row( "SELECT COUNT(*) FROM ai_messages", [], |row| row.get(0), )?; if existing > 0 { tracing::info!("v21: ai_messages 已有 {} 条,跳过数据迁移", existing); conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?; return Ok(()); } // 4. 遍历 ai_conversations,反序列化 messages JSON → 逐条写入 ai_messages let mut stmt = conn.prepare( "SELECT id, messages, created_at FROM ai_conversations", )?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, )) })?; let all_rows: Vec<(String, String, String)> = rows.collect::, _>>()?; // 5. 分批 commit(每 50 个对话一批,避免长事务持有写锁) const BATCH_SIZE: usize = 50; let mut migrated_count: usize = 0; for (batch_idx, batch) in all_rows.chunks(BATCH_SIZE).enumerate() { let tx = conn.unchecked_transaction()?; for (conv_id, messages_json, conv_created_at) in batch { // 6. 逐对话反序列化 messages JSON → Vec // (用裸 JSON 而非 ChatMessage,因 df-storage 不依赖 df-ai-core) let messages: Vec = match serde_json::from_str(messages_json) { Ok(v) => v, Err(e) => { tracing::warn!("v21: 对话 {} messages JSON 解析失败,跳过: {}", conv_id, e); continue; // 坏数据跳过,不中断迁移 } }; for (seq, msg) in messages.iter().enumerate() { // 7. 逐条消息提取字段 → INSERT INTO ai_messages // 字段名硬编码("role"/"content" 等)——ChatMessage 改名会漏数据! // 迁移期 ID 天然唯一(UNIQUE 是 conv_id+seq),人类可读,零依赖 let id = format!("msg_migrated_{}_{}", conv_id, seq); let role = msg.get("role").and_then(|v| v.as_str()).unwrap_or("user"); let content = msg.get("content").and_then(|v| v.as_str()).unwrap_or(""); let parts = msg.get("parts") .filter(|v| !v.is_null()) .map(|v| v.to_string()); let tool_call_id = msg.get("tool_call_id") .and_then(|v| v.as_str()) .map(String::from); let tool_calls = msg.get("tool_calls") .filter(|v| !v.is_null()) .map(|v| v.to_string()); let model = msg.get("model") .and_then(|v| v.as_str()) .map(String::from); // status 归一化:None/空 → "active"(列语义清晰,永不 NULL) let status = msg.get("status") .and_then(|v| v.as_str()) .filter(|s| !s.is_empty()) .unwrap_or("active"); let reasoning_content = msg.get("reasoning_content") .and_then(|v| v.as_str()) .map(String::from); // created_at:有 timestamp 用消息自己的,没有 fallback 到对话创建时间 let timestamp = msg.get("timestamp").and_then(|v| v.as_i64()); let created_at = timestamp .map(|ts| ts.to_string()) .unwrap_or_else(|| conv_created_at.clone()); tx.execute( "INSERT OR IGNORE INTO ai_messages (id, conversation_id, seq, role, content, parts, tool_call_id, tool_calls, model, status, reasoning_content, timestamp, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", rusqlite::params![ id, conv_id, seq as i64, role, content, parts, tool_call_id, tool_calls, model, status, reasoning_content, timestamp, created_at ], )?; migrated_count += 1; } } tx.commit()?; tracing::info!("v21: 批次 {} 完成({} 对话)", batch_idx, batch.len()); } conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [21])?; tracing::info!("迁移 v21 完成,共迁移 {} 条消息", migrated_count); Ok(()) } /// V22:灵感评估历史持久化 — idea_evaluations 追加型审计表 /// /// 把灵感每次 AI 评估快照(ai_analysis / scores / score)按版本追加存表, /// 替代覆写 ideas.ai_analysis / ideas.scores 列。一条灵感多次评估产生多条记录, /// version 单调递增,前端按 (idea_id, version DESC) 取最新 + 翻历史。 /// /// 设计要点(对齐 knowledge_events 追加型审计表模式 V10): /// - **追加型**:只 INSERT 不 UPDATE,审计语义(评估快照不可篡改,历史可追溯) /// - **IF NOT EXISTS 幂等**:对新库建表 / 老库已有表跳过,均安全 /// - **索引**:`(idea_id, version DESC)` 覆盖「取某灵感最新评估」最高频查询 /// - **evaluated_by**:评估发起者(model 名 / human / system,可空) /// - **evaluated_at**:评估时间(毫秒字符串,同既有 model 约定) /// /// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea, /// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝 /// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。 fn migrate_v22(conn: &Connection) -> Result<()> { conn.execute_batch(V22_SQL)?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?; tracing::info!("迁移 v22 完成"); Ok(()) } /// V23:幂等补 knowledges.embedding_status 列(嵌入生成失败可补偿重试) /// /// P1 修复(嵌入失败无标记):spawn_embedding_for_knowledge 此前 fire-and-forget, /// provider 临时不可用 → 仅 warn,该条永久无向量索引但无人感知(下次也不会重试)。 /// 新增 embedding_status 列跟踪嵌入生命周期: /// - NULL:未生成(老库行迁移后默认 NULL;代码从无显式写 NULL/pending 的路径, /// 仅此两种取值实际出现:done / failed) /// - done:成功(已有有效 embedding,由 KnowledgeRepo::set_embedding 写入) /// - failed:失败可重试(下次发布 / 手动 retry 时补偿,由 KnowledgeRepo::mark_embedding_failed 写入) /// /// 语义:embedding 列(BLOB)与 embedding_status 解耦 —— embedding 仅在 done 时有值; /// 失败时 status=failed + embedding 仍 NULL,检索侧 `embedding IS NOT NULL` 自然跳过。 /// 老库行(已成功嵌入的)embedding 有值但 status=NULL:这类条目检索正常(embedding IS NOT NULL), /// 不影响功能;若需精确状态,可在后台补偿脚本回填 done,但非必需(检索不依赖 status)。 /// /// TEXT NULL 向后兼容;不进通用 update_field 白名单(写入走 KnowledgeRepo::set_embedding / /// mark_embedding_failed 两个专用方法,而非独立的 set_embedding_status)。用 PRAGMA 探测列存在性, /// 缺失才 ALTER(同既有幂等模式),对新库/老库均安全。 fn migrate_v23(conn: &Connection) -> Result<()> { if !column_exists(conn, "knowledges", "embedding_status") { conn.execute("ALTER TABLE knowledges ADD COLUMN embedding_status TEXT", [])?; tracing::info!("v23: 补建 knowledges.embedding_status 列(嵌入失败可补偿重试)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [23])?; tracing::info!("迁移 v23 完成"); Ok(()) } /// V24:幂等补 ideas.related_ids 列(灵感间关联关系持久化打底) /// /// 为灵感关联关系 UI 打底:related_ids 存「关联灵感 id JSON 数组」字符串 /// (同 tags 的 JSON-in-TEXT 模式)。TEXT NULL 向后兼容:老库行默认 NULL, /// IdeaRecord 字段为 Option(未设关联的灵感为 None)。 /// /// 进通用 update_field 白名单(Ideas.vue 关联关系 UI 走 update_idea → /// update_field('related_ids', ...),同 tags 一样白名单登记该列;另有整行 /// update(update_full)路径,二者均可写入)。 /// /// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17 /// /v18/v19/v20/v23 模式),对新库/老库均安全。 fn migrate_v24(conn: &Connection) -> Result<()> { if !column_exists(conn, "ideas", "related_ids") { conn.execute("ALTER TABLE ideas ADD COLUMN related_ids TEXT", [])?; tracing::info!("v24: 补建 ideas.related_ids 列(灵感关联关系持久化打底)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [24])?; tracing::info!("迁移 v24 完成"); Ok(()) } /// V25:幂等补 idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底) /// /// 评估历史 version 此前由 evaluate_idea 算 `list_by_idea().first().version + 1` /// 得到,读-改-写非原子。并发两次评估同一灵感可能读到相同最新 version,各自 +1 后 /// 写入相同 version(重复),破坏「version 单调递增 + 唯一」语义。单用户桌面应用 /// 概率低,但唯一约束是数据完整性兜底,值得加。 /// /// 实现选 CREATE UNIQUE INDEX IF NOT EXISTS 而非 ALTER TABLE ADD CONSTRAINT: /// SQLite 不支持 ALTER TABLE 加约束 / 也不支持 ALTER ... IF NOT EXISTS,而 /// `CREATE UNIQUE INDEX IF NOT EXISTS` 原生幂等(新库建 / 老库已有则跳过),满足 /// 迁移「对新库与老库均安全」要求。索引语义等价于表级 UNIQUE(idea_id, version), /// 同样在 INSERT 冲突时抛 SQLITE_CONSTRAINT_UNIQUE。 /// /// 注:既有重复数据(若老库已有重复 version 行)会导致建索引失败。单用户桌面应用 /// 几乎不会有重复,若真发生此处**降级跳过**(建索引失败 → warn + 继续迁移),而非 `?` 上抛 /// 致整个应用启动崩溃、用户无感。理由:唯一索引只是并发重复的兜底防御网,缺失它不影响历史 /// 数据读取(list_by_idea / list_recent_idea_evals 照常工作),应用仍可用,远胜启动失败黑屏。 /// 建索引失败时日志带原始错误,用户/开发者可据此清理重复后手动重跑迁移补索引。 fn migrate_v25(conn: &Connection) -> Result<()> { let build_result = conn.execute_batch( "CREATE UNIQUE INDEX IF NOT EXISTS uq_idea_evaluations_idea_version \ ON idea_evaluations(idea_id, version)", ); if let Err(e) = build_result { // 降级:建唯一索引失败(典型根因——老库已存在重复 (idea_id, version) 行)不阻断迁移。 // 索引缺失仅削弱并发重复防御,不破坏既有数据可读性;跳过继续记录 schema_version=25。 tracing::warn!( error = %e, "v25: 建 idea_evaluations(idea_id, version) 唯一索引失败(老库可能有重复 version 行),\ 降级跳过索引创建不阻断启动。清理重复后可手动重跑迁移补建索引" ); } else { tracing::info!("v25: 建 idea_evaluations(idea_id, version) 唯一索引完成"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [25])?; tracing::info!("迁移 v25 完成"); Ok(()) } /// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口) /// /// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee), /// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status / /// idx_tasks_project_id 同类过滤维度。当前数据量 ~15(前瞻基建非性能驱动), /// 但索引零成本(SQLite 维护代价极小)且向后续 priority 排序/分配人筛选铺路。 /// /// 实现选 CREATE INDEX IF NOT EXISTS:原生幂等(新库 V1_SQL 已建则跳过, /// 老库无则补建),对新库与老库均安全,无需 PRAGMA 探测。索引定义须与 V1_SQL 中 /// 的同名索引一致(仅 priority/assignee 单列索引)。 /// /// 注:assignee 列允许 NULL(V1_SQL 未 NOT NULL),SQLite 索引正常包含 NULL 行, /// 不影响 assignee = ? 等值查询命中(过滤掉 NULL 行)。 fn migrate_v26(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);\ CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)", )?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [26])?; tracing::info!("迁移 v26 完成"); Ok(()) } /// V27: 审批状态统一 executed→completed(TD-260621-05) /// /// chat.rs ai_approve/ai_authorize_dir 审批通过后工具执行成功历史写 "executed",audit 内联执行 /// (低风险无审批)写 "completed",双轨并存致:① DTO 文档(audit/mod.rs:53)只列 completed 契约失配; /// ② 前端 AuditLog statusClass/i18n auditLog.status 无 executed 键 → executed 记录显示蓝pending /// 标签 + raw"executed"文案矛盾(用户见"待审批 executed");③ 未来 WHERE status='completed' /// 统计会漏 executed 路径。统一为 completed(find_cached SW-16 透传已兼容双值,无破坏)。 /// 数据迁移:存量 executed→completed,新库/老库均跑(UPDATE 0 行也安全)。 fn migrate_v27(conn: &Connection) -> Result<()> { conn.execute( "UPDATE ai_tool_executions SET status = 'completed' WHERE status = 'executed'", [], )?; conn.execute("INSERT INTO schema_version (version) VALUES (?)", [27])?; tracing::info!("迁移 v27 完成(审批状态 executed→completed 统一)"); Ok(()) } /// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14) /// /// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。 /// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE /// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足, /// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER /// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。 fn migrate_v28(conn: &Connection) -> Result<()> { if !column_exists(conn, "ideas", "deleted_at") { conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?; tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?; tracing::info!("迁移 v28 完成"); Ok(()) } /// V29:知识图谱 Phase 1 任务网络基础 — 幂等补 tasks.queue / parent_id / content_json 三列 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.1, /// 为「AI 拥有完整项目知识图谱、自主分解任务编排依赖」打数据层地基。三列各司其职: /// /// - `queue TEXT NOT NULL DEFAULT 'todo'`:管理维度池标记,与 status(执行维度)正交。 /// 取值 backlog(需求池)/todo(待办池)/decision(待决策池)/active(执行中)/done(已完成)。 /// DEFAULT 'todo' 保证老任务迁移后取值确定(非 NULL),向后兼容:历史任务原 status=todo, /// 落 todo 池语义一致。queue 与 status 一致性约束由 IPC 层校验(不进状态机,不进 DB 约束)。 /// /// - `parent_id TEXT REFERENCES tasks(id)`:父任务纵向关联(AI 分解-执行编排结构)。 /// NULL = 叶子任务(走状态机 advance_task);非空 = 子任务。 /// 限制 1 级嵌套(无孙任务)由 IPC 层校验,不进 DB 约束。父任务=容器模型,status 由 /// 子任务聚合计算(不走状态机)。TEXT NULL 向后兼容(老任务无 parent → None)。 /// /// - `content_json TEXT`:结构化需求规格 JSON 字符串(AI 可读写的执行规格)。 /// 结构 { background, acceptance_criteria[], scope[], technical_design, custom_fields }。 /// AI 从对话提取填充,执行中用 acceptance_criteria 自检。NULL = 无结构化规格(纯文本 description)。 /// TEXT NULL 向后兼容(老任务无 content_json → None)。 /// /// 对标 v14(v14 tasks.deleted_at)/ v24(v24 ideas.related_ids)幂等模式:每列用 PRAGMA /// 探测存在性,缺失才 ALTER,对新库/老库/坏库均安全(列已存在时跳过 ALTER 不报 duplicate column)。 fn migrate_v29(conn: &Connection) -> Result<()> { if !column_exists(conn, "tasks", "queue") { conn.execute( "ALTER TABLE tasks ADD COLUMN queue TEXT NOT NULL DEFAULT 'todo'", [], )?; tracing::info!("v29: 补建 tasks.queue 列(管理池,知识图谱 Phase 1)"); } if !column_exists(conn, "tasks", "parent_id") { conn.execute( "ALTER TABLE tasks ADD COLUMN parent_id TEXT REFERENCES tasks(id)", [], )?; tracing::info!("v29: 补建 tasks.parent_id 列(父任务纵向关联,知识图谱 Phase 1)"); } if !column_exists(conn, "tasks", "content_json") { conn.execute("ALTER TABLE tasks ADD COLUMN content_json TEXT", [])?; tracing::info!("v29: 补建 tasks.content_json 列(结构化需求规格,知识图谱 Phase 1)"); } // task_links 表(横向关联,对标设计 §2.2):depends_on/blocks/relates_to。 // CREATE TABLE IF NOT EXISTS 幂等:新库建、老库(V29 之前的库已跑过前半三列)已有则跳过。 // 循环依赖检测走应用层(TaskLinkRepo::create_link BFS),非 DB 约束(对标设计 D8)。 // 软删除语义:Task 软删不级联删 link(恢复后关系还在),故无 ON DELETE,FK 仅引用完整性。 conn.execute( "CREATE TABLE IF NOT EXISTS task_links ( id TEXT PRIMARY KEY, source_id TEXT NOT NULL REFERENCES tasks(id), target_id TEXT NOT NULL REFERENCES tasks(id), link_type TEXT NOT NULL, remark TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_task_links_source ON task_links(source_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_task_links_target ON task_links(target_id)", [], )?; tracing::info!("v29: 建 task_links 表 + 索引(任务横向关联,知识图谱 Phase 1)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [29])?; tracing::info!("迁移 v29 完成"); Ok(()) } /// V30:知识图谱 Phase 2 统一事件流 — project_events 追加型审计表 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.4, /// 为「AI 精准检索项目事件流」打数据层地基。一张表承载跨实体(idea/task/workflow/ /// knowledge/module/service/project)的全部事件,回答"上周做了什么 / 这个任务为何 blocked / /// 这个决策何时做出"——此前需跨 6 张表 JOIN。 /// /// **追加型审计表**(对标 knowledge_events V10 / idea_evaluations V22):只 INSERT 不 UPDATE/ /// DELETE,历史不改可追溯。故表无 updated_at,无通用 update/delete 路径,无列白名单登记 /// (ProjectEventRepo 走专用方法,与 TaskLinkRepo 同款全专用路径)。 /// /// **与 knowledge_events 的关系**(对标设计 D9 / §2.4):knowledge_events 保持不变(知识库专属, /// 向后兼容),project_events 是全局事件流;knowledge 相关事件**同时写入两者**——本表只管写入, /// 双写策略在埋点层(commands/IPC hook)实现,非本表职责。 /// /// 列语义(对标设计 §2.4): /// - `event_type`:事件类型白名单(idea_created/idea_promoted/...task_created/task_advanced/ /// .../decision_made 等,见设计 §2.4 注释枚举)。白名单**应用层校验**(类比 task_links /// link_type),不进 DB 约束(保留扩展性,新增事件类型无需迁移)。 /// - `entity_type` / `entity_id`:事件指向的实体(可空——部分事件无明确实体,如纯决策日志)。 /// entity_type 白名单同样应用层校验(idea/project/task/workflow/knowledge/module/service)。 /// - `from_state` / `to_state`:状态变化前后(仅状态变化类事件有值,如 task_advanced; /// created/referenced 类为 NULL)。 /// - `context_json`:事件附加上下文(JSON 字符串,因 event_type 而异)。 /// - `source`:ai / human / system(AI Working 溯源——区分是 AI 自主操作还是人操作)。 /// - `conversation_id`:触发事件的对应对话(AI Working 溯源链:事件→对话→决策,可空)。 /// /// **埋点策略**(设计 §2.4 hook/after):在现有 IPC 命令(create_task/advance_task/create_project /// /idea_promote 等)执行后追加事件写入,不侵入业务逻辑。事件写入失败 best-effort 不阻断主操作 /// (设计 §10.1 风险已识别),由埋点层实现,非本表职责。 /// /// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V30 之前的库)已有则跳过,均安全。 /// 索引覆盖最高频查询: /// - `idx_project_events_project(project_id, created_at)`:按项目查事件流(时间倒序,Dashboard 时间线) /// - `idx_project_events_entity(entity_type, entity_id)`:按实体反查(AI「这个任务发生过什么」) fn migrate_v30(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_events ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), event_type TEXT NOT NULL, entity_type TEXT, entity_id TEXT, from_state TEXT, to_state TEXT, context_json TEXT, source TEXT, conversation_id TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_events_project \ ON project_events(project_id, created_at)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_events_entity \ ON project_events(entity_type, entity_id)", [], )?; tracing::info!("v30: 建 project_events 表 + 索引(统一事件流,知识图谱 Phase 2)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [30])?; tracing::info!("迁移 v30 完成"); Ok(()) } /// V31:知识图谱 Phase 3 基础设施数据层 — project_services 表 /// /// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3, /// 为「AI 执行任务时拥有项目基础设施上下文」打数据层地基。AI 知道"这项目用了什么数据库、 /// Redis 在哪、有没有 MQ、API 在哪个地址",从而在编码/部署/排障时不必反复问人。 /// /// **定位**:项目基础设施配置的元数据层(对标设计 §2.3)。 /// - `service_type`:基础设施类型(mysql/postgresql/sqlite/redis/mongodb/mq/api/other), /// **应用层校验**(类比 task_links.link_type / project_events.event_type,不进 DB 约束, /// 保留扩展性,新增类型无需迁移)。 /// - `endpoint`:连接地址(localhost:3306 / URL),纯连接信息。 /// - `config_json`:类型相关配置 JSON 字符串(如数据库连接池参数、MQ topic 列表)。 /// - `environment`:环境标识(development/staging/production),DEFAULT 'development'。 /// 同一服务可在不同环境各存一行(项目维度 + 环境维度组合定位)。 /// /// **边界(D10,对标设计 §2.3)**:⚠️ **不存敏感凭证**(密码/密钥/Token)。本表只存连接信息, /// 凭证走环境变量/外部密钥管理。config_json 不应含 password/secret/key 字段——此约束由 /// 应用层(ProjectServiceRepo)在 insert/update_full 时做内容审查(检测 password/secret/key/ /// token 子串拒绝),DB 层无 CHECK 约束(SQLite CHECK 对 JSON 内容无法表达)。 /// /// **元数据非运维工具**:不做连接池/健康检查/探活,仅记录"项目用了什么基础设施"这一事实, /// 供 AI 与 Dashboard 检索消费。运维能力是后续独立模块的职责。 /// /// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V31 之前的库)已有则跳过,均安全。 /// 索引 `idx_project_services_project(project_id)` 覆盖最高频查询:按项目列其全部基础设施 /// (Dashboard 项目视图 / AI 项目上下文注入)。 fn migrate_v31(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_services ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, service_type TEXT NOT NULL, endpoint TEXT, config_json TEXT, environment TEXT NOT NULL DEFAULT 'development', remark TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_services_project \ ON project_services(project_id)", [], )?; tracing::info!("v31: 建 project_services 表 + 索引(基础设施数据层,知识图谱 Phase 3)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [31])?; tracing::info!("迁移 v31 完成"); Ok(()) } /// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化) /// /// 对话目标由 PerConvState.pinned_goals(Vec)管理,原先仅在内存态存在, /// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。 fn migrate_v32(conn: &Connection) -> Result<()> { conn.execute_batch( "ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';" )?; tracing::info!("v32: ai_conversations 加 pinned_goals 列"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?; tracing::info!("迁移 v32 完成"); Ok(()) } fn migrate_v33(conn: &Connection) -> Result<()> { // 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式) let has_col: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM pragma_table_info('ai_conversations') WHERE name = 'pending_approvals'", [], |row| row.get(0), ) .unwrap_or(false); if !has_col { conn.execute_batch( "ALTER TABLE ai_conversations ADD COLUMN pending_approvals TEXT DEFAULT '{}';" )?; tracing::info!("v33: ai_conversations 加 pending_approvals 列(审批重启恢复)"); } else { tracing::info!("v33: pending_approvals 列已存在,跳过"); } // 任务4: workflow_executions.updated_at 列 —— 工作流执行记录更新时间戳(用于排序/增量同步/中文)。 // 同样用 PRAGMA 探测列存在性(同 v4 模式),缺失才 ALTER;若表本身不存在(极端坏库),跳过该列不阻断迁移。 let has_updated_at: bool = conn .query_row( "SELECT COUNT(*) > 0 FROM pragma_table_info('workflow_executions') WHERE name = 'updated_at'", [], |row| row.get(0), ) .unwrap_or(false); if !has_updated_at { // workflow_executions 表在 V1 建表,此处仅加列。若表不存在(理论上 V1 必建,但坏库防御) // pragma_table_info 返 0 行,has_updated_at 为 false,会尝试 ALTER → 报错被跳过(下面 match)。 match conn.execute_batch("ALTER TABLE workflow_executions ADD COLUMN updated_at TEXT;") { Ok(_) => tracing::info!("v33: workflow_executions 加 updated_at 列"), Err(e) => tracing::warn!("v33: workflow_executions.updated_at 加列失败(表不存在?)跳过: {}", e), } } else { tracing::info!("v33: workflow_executions.updated_at 列已存在,跳过"); } conn.execute("INSERT INTO schema_version (version) VALUES (?)", [33])?; tracing::info!("迁移 v33 完成"); Ok(()) } /// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库) /// /// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 /// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。 /// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。 /// /// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。 fn migrate_v34(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS project_modules ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), name TEXT NOT NULL, path TEXT NOT NULL, git_url TEXT, stack TEXT, auto_detected BOOLEAN NOT NULL DEFAULT FALSE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)", [], )?; tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?; tracing::info!("迁移 v34 完成"); Ok(()) } /// V35:工程依赖关系—module_dependencies 表(工程间依赖边,用于依赖图) /// /// dep_type 枚举值:library(类库) / api(API调用) / mq(消息队列) / shared(共享资源) / custom(自定义) fn migrate_v35(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS module_dependencies ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), from_module_id TEXT NOT NULL REFERENCES project_modules(id), to_module_id TEXT NOT NULL REFERENCES project_modules(id), dep_type TEXT NOT NULL DEFAULT 'library', label TEXT, created_at TEXT NOT NULL )", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_project ON module_dependencies(project_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_from ON module_dependencies(from_module_id)", [], )?; conn.execute( "CREATE INDEX IF NOT EXISTS idx_module_deps_to ON module_dependencies(to_module_id)", [], )?; tracing::info!("v35: 建 module_dependencies 表 + 索引(工程依赖图)"); conn.execute("INSERT INTO schema_version (version) VALUES (?)", [35])?; tracing::info!("迁移 v35 完成"); Ok(()) } /// V21 建表 SQL — 消息拆分存储 ai_messages 表 /// /// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。 /// 改动须两边同步。 const V21_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', parts TEXT, tool_call_id TEXT, tool_calls TEXT, model TEXT, status TEXT NOT NULL DEFAULT 'active', reasoning_content TEXT, timestamp INTEGER, created_at TEXT NOT NULL, UNIQUE(conversation_id, seq) ); CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq); "; /// V22 建表 SQL — 灵感评估历史(追加型审计表) /// /// 8 列:id(主键)/ idea_id(关联灵感)/ version(评估版本号,单调递增)/ /// ai_analysis(AI 分析结果 JSON,可空)/ scores(多维评分 JSON,可空)/ /// score(综合评分 REAL,可空)/ evaluated_by(评估者,可空)/ evaluated_at(毫秒字符串)。 /// 索引:(idea_id, version DESC) 覆盖「取某灵感最新评估」最高频查询。 const V22_SQL: &str = " CREATE TABLE IF NOT EXISTS idea_evaluations ( id TEXT PRIMARY KEY, idea_id TEXT NOT NULL, version INTEGER NOT NULL, ai_analysis TEXT, scores TEXT, score REAL, evaluated_by TEXT, evaluated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_idea_evaluations_idea ON idea_evaluations(idea_id, version DESC); "; /// V1 建表 SQL const V1_SQL: &str = " -- 想法表 CREATE TABLE IF NOT EXISTS ideas ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', priority INTEGER NOT NULL DEFAULT 1, score REAL, tags TEXT, source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 项目表 CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'planning', idea_id TEXT REFERENCES ideas(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 任务表 CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', -- priority 默认 2 对齐 task.rs default_priority()=2(medium) priority INTEGER NOT NULL DEFAULT 2, branch_name TEXT, assignee TEXT, -- F-260619-01 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。 idea_id TEXT REFERENCES ideas(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); -- 发布表 CREATE TABLE IF NOT EXISTS releases ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), version TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'planned', task_ids TEXT NOT NULL DEFAULT '[]', changelog TEXT, created_at TEXT NOT NULL, released_at TEXT ); -- 工作流执行表 CREATE TABLE IF NOT EXISTS workflow_executions ( id TEXT PRIMARY KEY, name TEXT NOT NULL, dag_json TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', triggered_by TEXT, created_at TEXT NOT NULL, completed_at TEXT ); -- 节点执行表 CREATE TABLE IF NOT EXISTS node_executions ( id TEXT PRIMARY KEY, workflow_id TEXT NOT NULL REFERENCES workflow_executions(id), node_id TEXT NOT NULL, node_type TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', input_json TEXT, output_json TEXT, error_message TEXT, started_at TEXT, completed_at TEXT ); -- 索引 CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); -- V26 补建(F-260621-02 索引缺口):priority/assignee 过滤下推索引,新库一次性建; -- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。 CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority); CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee); CREATE INDEX IF NOT EXISTS idx_releases_project_id ON releases(project_id); CREATE INDEX IF NOT EXISTS idx_node_executions_workflow_id ON node_executions(workflow_id); "; /// V2 迁移 SQL — 补齐数据层断裂字段 /// /// 注意: SQLite 的 ALTER TABLE ADD COLUMN 一条语句只能加一列。 const V2_SQL: &str = " -- 想法表: 晋升关联 + AI 分析 + 多维评分 ALTER TABLE ideas ADD COLUMN promoted_to TEXT; ALTER TABLE ideas ADD COLUMN ai_analysis TEXT; ALTER TABLE ideas ADD COLUMN scores TEXT; -- 任务表: 工作流定义关联 + 基础分支 ALTER TABLE tasks ADD COLUMN workflow_def_id TEXT; ALTER TABLE tasks ADD COLUMN base_branch TEXT; -- 工作流执行表: 项目 / 任务关联 ALTER TABLE workflow_executions ADD COLUMN project_id TEXT; ALTER TABLE workflow_executions ADD COLUMN task_id TEXT; -- 分支表 — 任务与 Git 分支绑定(核心功能) CREATE TABLE IF NOT EXISTS branches ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL REFERENCES projects(id), task_id TEXT REFERENCES tasks(id), name TEXT NOT NULL, base TEXT NOT NULL DEFAULT 'main', status TEXT NOT NULL DEFAULT 'active', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, merged_at TEXT ); -- 索引 CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id); CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id); "; /// V3 迁移 SQL — AI 对话表补建(新库首次创建;老库 IF NOT EXISTS 跳过) /// /// 注:archived 列不在此处 ALTER —— 由 v4 迁移幂等补建。 /// (历史 v3 曾写入版本号但 ALTER 未生效,统一交 v4 用 PRAGMA 探测修复) const V3_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_conversations ( id TEXT PRIMARY KEY, title TEXT, messages TEXT NOT NULL DEFAULT '[]', provider_id TEXT, model TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); "; /// V7 建表 SQL — 知识库表 /// /// kind: 7 种 KnowledgeKind snake_case(review_rule/prompt_template/pitfall/ /// architecture_pattern/diagnosis/deployment_note/workflow_optimization) /// status: candidate|pending_review|published|archived /// confidence: high|medium|low(AI 提炼自评,可空) /// verified: 发布审核时一次性人工标(INTEGER 0/1) /// reuse_count: 检索命中自动 +1(唯一客观排序信号) /// source_project/source_ref: 来源溯源(不过滤,仅展示) const V7_SQL: &str = " CREATE TABLE IF NOT EXISTS knowledges ( id TEXT PRIMARY KEY, kind TEXT NOT NULL DEFAULT 'pitfall', title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', tags TEXT, status TEXT NOT NULL DEFAULT 'candidate', confidence TEXT, reuse_count INTEGER NOT NULL DEFAULT 0, verified INTEGER NOT NULL DEFAULT 0, source_project TEXT, source_ref TEXT, -- V23 补列(嵌入失败可补偿重试):新库直接带列,老库由 migrate_v23 ALTER 补; -- 两边列定义须一致(老库迁移注释 V23 已注明)。 embedding_status TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status); CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind); CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC); "; /// V9 建表 SQL — AI Provider 配置 + 工具执行审计 /// /// 历史遗漏补建:ai_providers(AI 提供商配置) + ai_tool_executions(工具调用审计记录)。 /// CREATE TABLE IF NOT EXISTS 保证老库(已有表)和新库(缺表)均安全。 const V9_SQL: &str = " CREATE TABLE IF NOT EXISTS ai_providers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, provider_type TEXT NOT NULL DEFAULT 'openai_compat', api_key TEXT NOT NULL, base_url TEXT NOT NULL, default_model TEXT NOT NULL, models TEXT, is_default INTEGER NOT NULL DEFAULT 0, config TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, model_configs TEXT, enabled INTEGER NOT NULL DEFAULT 1, weight INTEGER NOT NULL DEFAULT 50 ); CREATE TABLE IF NOT EXISTS ai_tool_executions ( id TEXT PRIMARY KEY, conversation_id TEXT, message_id TEXT, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL, arguments TEXT NOT NULL, result TEXT, status TEXT NOT NULL DEFAULT 'pending', risk_level TEXT NOT NULL DEFAULT 'medium', requested_at TEXT NOT NULL, executed_at TEXT, decided_by TEXT ); -- F-260619-03 消息拆分存储:每条 ChatMessage 一行的独立表。 -- 与 V21 迁移建表 SQL 镜像(V21 用于老库 ALTER,此处给新库直接建最终态)。 -- 改动须两边同步(V21_SQL 见下方)。 CREATE TABLE IF NOT EXISTS ai_messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', parts TEXT, tool_call_id TEXT, tool_calls TEXT, model TEXT, status TEXT NOT NULL DEFAULT 'active', reasoning_content TEXT, timestamp INTEGER, created_at TEXT NOT NULL, UNIQUE(conversation_id, seq) ); CREATE INDEX IF NOT EXISTS idx_ai_messages_conv ON ai_messages(conversation_id, seq); "; /// V10 建表 SQL — 知识生命线事件表 /// /// 追加型审计表(只增不改),记录知识产生/审核/引用/归档四类事件,支撑生命线视图。 /// event_type: created | extracted | status_changed | referenced | archived /// context_json: 因 event_type 而异的上下文(如引用事件的 conv_id+query)。 const V10_SQL: &str = " CREATE TABLE IF NOT EXISTS knowledge_events ( id TEXT PRIMARY KEY, knowledge_id TEXT NOT NULL, event_type TEXT NOT NULL, source_ref TEXT, context_json TEXT, timestamp TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid ON knowledge_events(knowledge_id); CREATE INDEX IF NOT EXISTS idx_knowledge_events_type ON knowledge_events(event_type); CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid_type ON knowledge_events(knowledge_id, event_type); "; /// V13 建表 SQL — 通用应用设置 KV 表 /// /// 前端 localStorage 迁移目标:key/value(JSON 字符串)+ updated_at。 /// CREATE TABLE IF NOT EXISTS 幂等(新库建、老库已有则跳过)。 const V13_SQL: &str = " CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL ); "; // ============================================================ // 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03) // ============================================================ #[cfg(test)] mod tests { use super::*; use rusqlite::Connection; /// 构造最小老库 schema:ai_conversations 表(含 messages JSON 列)+ schema_version 表。 /// 不跑 V1-V19(测试聚焦 V21 单步行为),手动建最小依赖表。 fn setup_legacy_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_conversations ( id TEXT PRIMARY KEY, title TEXT, messages TEXT NOT NULL DEFAULT '[]', provider_id TEXT, model TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", ) .expect("create legacy tables"); conn } /// 断言 ai_messages 表存在 + 列齐全 fn assert_ai_messages_schema(conn: &Connection) { assert!(column_exists(conn, "ai_messages", "id")); assert!(column_exists(conn, "ai_messages", "conversation_id")); assert!(column_exists(conn, "ai_messages", "seq")); assert!(column_exists(conn, "ai_messages", "role")); assert!(column_exists(conn, "ai_messages", "content")); assert!(column_exists(conn, "ai_messages", "status")); assert!(column_exists(conn, "ai_messages", "created_at")); } /// 新库空跑:无 ai_conversations 数据,迁移应建表 + 写版本号 + 不崩 + ai_messages 空 #[test] fn v21_new_db_empty_runs_clean() { let conn = setup_legacy_db(); migrate_v21(&conn).expect("v21 应在新库空跑成功"); assert_ai_messages_schema(&conn); // ai_tool_executions.message_id 列已补建 // 注:setup 未建 ai_tool_executions 表,column_exists 对不存在表返回 false。 // 此处验证迁移不因表不存在而崩(函数内 ALTER 被 column_exists 短路)。 let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 0, "新库空跑 ai_messages 应为空"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 21, "应写入版本号 21"); } /// 老库有数据:正确迁移 messages JSON → ai_messages,字段全提取 #[test] fn v21_legacy_db_migrates_messages() { let conn = setup_legacy_db(); // 插入一条对话,messages 含 3 条消息(覆盖 user/assistant/tool + 各字段) let messages_json = serde_json::json!([ {"role": "user", "content": "你好", "timestamp": 1718800000000i64}, {"role": "assistant", "content": "你好,有什么可以帮你?", "model": "glm-4", "reasoning_content": "思考中"}, {"role": "tool", "content": "工具结果", "tool_call_id": "call_abc", "tool_calls": [{"id": "call_abc"}]} ]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, title, messages, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)", rusqlite::params!["conv_1", "测试", messages_json, "1718800000000", "1718800000000"], ) .unwrap(); migrate_v21(&conn).expect("v21 应成功迁移"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 3, "应迁移 3 条消息"); // 校验 seq 递增 + 字段提取 let mut stmt = conn .prepare("SELECT seq, role, content, model, tool_call_id, status, created_at FROM ai_messages WHERE conversation_id = 'conv_1' ORDER BY seq") .unwrap(); let rows: Vec<(i64, String, String, Option, Option, String, String)> = stmt .query_map([], |r| { Ok(( r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, )) }) .unwrap() .map(|r| r.unwrap()) .collect(); assert_eq!(rows.len(), 3); assert_eq!(rows[0].0, 0); // seq assert_eq!(rows[0].1, "user"); assert_eq!(rows[0].2, "你好"); assert_eq!(rows[0].5, "active", "无 status → 归一化为 active"); assert_eq!(rows[0].6, "1718800000000", "有 timestamp → created_at 用它"); assert_eq!(rows[1].0, 1); assert_eq!(rows[1].1, "assistant"); assert_eq!(rows[1].3.as_deref(), Some("glm-4")); assert_eq!(rows[1].6, "1718800000000", "assistant 无 timestamp → fallback conv created_at"); assert_eq!(rows[2].0, 2); assert_eq!(rows[2].1, "tool"); assert_eq!(rows[2].4.as_deref(), Some("call_abc")); } /// 坏数据:messages JSON 解析失败 → 该对话跳过,不中断整体迁移 #[test] fn v21_bad_json_skipped_not_crash() { let conn = setup_legacy_db(); // 坏数据对话 conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('bad', '{not valid json', '0', '0')", [], ) .unwrap(); // 正常对话 let good = serde_json::json!([{"role": "user", "content": "好"}]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('good', ?1, '0', '0')", rusqlite::params![good], ) .unwrap(); migrate_v21(&conn).expect("坏数据不应中断迁移"); let count: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count, 1, "仅正常对话的 1 条被迁移"); // 坏数据对话在 ai_messages 无记录 let bad_count: i64 = conn .query_row( "SELECT COUNT(*) FROM ai_messages WHERE conversation_id = 'bad'", [], |r| r.get(0), ) .unwrap(); assert_eq!(bad_count, 0); } /// 幂等重跑:第二次 migrate_v21 不重复迁移(COUNT 探测跳过) #[test] fn v21_idempotent_rerun() { let conn = setup_legacy_db(); let msgs = serde_json::json!([{"role": "user", "content": "hi"}]).to_string(); conn.execute( "INSERT INTO ai_conversations (id, messages, created_at, updated_at) VALUES ('c', ?1, '0', '0')", rusqlite::params![msgs], ) .unwrap(); migrate_v21(&conn).expect("首次迁移"); let count_after_first: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count_after_first, 1); // 第二次跑:COUNT 探测 > 0 → 跳过数据迁移,不重复 migrate_v21(&conn).expect("二次迁移应幂等成功"); let count_after_second: i64 = conn .query_row("SELECT COUNT(*) FROM ai_messages", [], |r| r.get(0)) .unwrap(); assert_eq!(count_after_second, 1, "重跑不应重复插入"); // 版本号不重复写(schema_version version 是 PK,migrate_v21 用 INSERT OR IGNORE // 防崩溃重跑 PK 冲突) let v_count: i64 = conn .query_row( "SELECT COUNT(*) FROM schema_version WHERE version = 21", [], |r| r.get(0), ) .unwrap(); assert_eq!(v_count, 1, "版本号 21 应只写一次"); } /// ai_tool_executions.message_id 列补建(老库已有表无该列) #[test] fn v21_adds_message_id_column_to_tool_executions() { let conn = setup_legacy_db(); // 模拟老库已有 ai_tool_executions 表(V9 建的旧形态,无 message_id) conn.execute_batch( "CREATE TABLE ai_tool_executions ( id TEXT PRIMARY KEY, conversation_id TEXT, tool_call_id TEXT NOT NULL, tool_name TEXT NOT NULL, arguments TEXT NOT NULL, result TEXT, status TEXT NOT NULL DEFAULT 'pending', risk_level TEXT NOT NULL DEFAULT 'medium', requested_at TEXT NOT NULL, executed_at TEXT, decided_by TEXT );", ) .unwrap(); assert!( !column_exists(&conn, "ai_tool_executions", "message_id"), "迁移前应无 message_id 列" ); migrate_v21(&conn).expect("v21 应补建 message_id 列"); assert!( column_exists(&conn, "ai_tool_executions", "message_id"), "迁移后应有 message_id 列" ); } // ============================================================ // V20 迁移幂等安全(F-260619-01 任务关联灵感) // ============================================================ /// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。 fn setup_legacy_tasks_db() -> Connection { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE tasks ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'todo', priority INTEGER NOT NULL DEFAULT 2, branch_name TEXT, assignee TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL );", ) .expect("create legacy tasks table"); conn } /// 老库无 idea_id 列:迁移应补建 + 写版本号 20 #[test] fn v20_legacy_db_adds_idea_id_column() { let conn = setup_legacy_tasks_db(); assert!( !column_exists(&conn, "tasks", "idea_id"), "迁移前应无 idea_id 列" ); migrate_v20(&conn).expect("v20 应在老库补建 idea_id 列"); assert!( column_exists(&conn, "tasks", "idea_id"), "迁移后应有 idea_id 列" ); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 20, "应写入版本号 20"); } /// 幂等重跑:列已存在时跳过 ALTER,版本号不重复写(PK 冲突防) /// 注:migrate_v20 用普通 INSERT(非 IGNORE),重跑会因 PK 冲突报错——这是预期行为, /// run() 正常流程下 current_version<20 只调一次;此处验证列存在时 ALTER 被短路(不报 duplicate column)。 #[test] fn v20_column_exists_skips_alter() { let conn = setup_legacy_tasks_db(); // 先跑一次补列 migrate_v20(&conn).expect("首次迁移"); assert!(column_exists(&conn, "tasks", "idea_id")); // 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column conn.execute("DELETE FROM schema_version WHERE version = 20", []) .unwrap(); // 此时列存在但版本号 20 缺失 → migrate_v20 应跳过 ALTER 只补版本号 migrate_v20(&conn).expect("列存在时应跳过 ALTER 不报错"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 20); } /// V27:审批状态统一 executed→completed(TD-260621-05) /// 存量 executed 记录转 completed,rejected/completed/failed 不变(只动 executed) #[test] fn v27_unifies_executed_to_completed() { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_tool_executions ( id TEXT PRIMARY KEY, tool_call_id TEXT, tool_name TEXT, status TEXT NOT NULL, requested_at TEXT, executed_at TEXT );", ) .expect("create ai_tool_executions"); // 混合状态:2 executed(待转)+ completed/rejected/failed(应不变) conn.execute_batch( "INSERT INTO ai_tool_executions (id, tool_call_id, tool_name, status) VALUES ('e1', 'tc1', 'write_file', 'executed'), ('e2', 'tc2', 'read_file', 'executed'), ('c1', 'tc3', 'list_directory', 'completed'), ('r1', 'tc4', 'write_file', 'rejected'), ('f1', 'tc5', 'run_command', 'failed');", ) .unwrap(); migrate_v27(&conn).expect("v27 应成功统一状态"); // executed 全部转 completed let executed_left: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'executed'", [], |r| r.get(0)) .unwrap(); assert_eq!(executed_left, 0, "executed 应全部转为 completed"); let completed: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'completed'", [], |r| r.get(0)) .unwrap(); assert_eq!(completed, 3, "原 2 executed + 1 completed = 3 completed"); // 其他状态不受影响 let rejected: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'rejected'", [], |r| r.get(0)) .unwrap(); assert_eq!(rejected, 1, "rejected 不变"); let failed: i64 = conn .query_row("SELECT COUNT(*) FROM ai_tool_executions WHERE status = 'failed'", [], |r| r.get(0)) .unwrap(); assert_eq!(failed, 1, "failed 不变"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 27, "应写入版本号 27"); } /// V27 幂等:无 executed 时再跑不崩(UPDATE 0 行),版本号照写 #[test] fn v27_idempotent_no_executed() { let conn = Connection::open_in_memory().expect("open in-memory db"); conn.execute_batch( "CREATE TABLE schema_version (version INTEGER PRIMARY KEY); CREATE TABLE ai_tool_executions (id TEXT PRIMARY KEY, status TEXT NOT NULL);", ) .expect("create tables"); conn.execute( "INSERT INTO ai_tool_executions (id, status) VALUES ('c1', 'completed')", [], ) .unwrap(); migrate_v27(&conn).expect("v27 无 executed 时应幂等成功(UPDATE 0 行)"); let v: i64 = conn .query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0)) .unwrap(); assert_eq!(v, 27); } }