新增: 任务关联工程(module_id 数据层V41 + AI module 写工具6个 + 任务构造补字段)

This commit is contained in:
lxy
2026-08-08 20:01:34 +08:00
parent fe780c0084
commit 97525a3143
12 changed files with 567 additions and 43 deletions
+3
View File
@@ -567,6 +567,7 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
review_rounds: 0,
output_json: None,
idea_id: None,
module_id: None,
queue: "todo".to_string(),
parent_id,
content_json: None,
@@ -630,6 +631,7 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
idea_id: existing.idea_id,
queue: existing.queue,
parent_id: existing.parent_id,
module_id: existing.module_id,
content_json: existing.content_json,
created_at: existing.created_at,
updated_at: now,
@@ -1071,6 +1073,7 @@ mod tests {
review_rounds: 0,
output_json: None,
idea_id: None,
module_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
@@ -336,6 +336,7 @@ mod tests {
review_rounds: 0,
output_json: task_output_json.map(String::from),
idea_id: None,
module_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
+1
View File
@@ -303,6 +303,7 @@ mod tests {
review_rounds: 0,
output_json: None,
idea_id: None,
module_id: None,
queue: "todo".to_string(),
parent_id: None,
content_json: None,
+2 -1
View File
@@ -330,7 +330,7 @@ mod tests {
use crate::models::TaskRecord;
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(18 字段全填,queue 默认 todo)。
/// 构造一条 TaskRecord fixture(19 字段全填,queue 默认 todo)。
fn trec(id: &str, project_id: &str) -> TaskRecord {
TaskRecord {
id: id.to_string(),
@@ -349,6 +349,7 @@ mod tests {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
module_id: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
+97 -22
View File
@@ -41,6 +41,8 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
queue: row.get("queue")?,
parent_id: row.get("parent_id")?,
content_json: row.get("content_json")?,
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
module_id: row.get("module_id")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
@@ -86,6 +88,10 @@ pub struct TaskQuery {
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
#[serde(default)]
pub parent_id: Option<String>,
/// 所属工程 ID 过滤(工程系统 V41):Some(id) = 查关联到某 module 的任务。
/// 工程维度筛选数据源(任务按工程分列/过滤)。
#[serde(default)]
pub module_id: Option<String>,
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
pub order_by: Option<String>,
/// 分页上限(钳制 ≤500)。P3 基建就绪。
@@ -123,25 +129,25 @@ impl_repo!(
from_row => |row| task_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
"INSERT INTO tasks (id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
params![
rec.id, rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, updated_at = ?16 WHERE id = ?17",
"UPDATE tasks SET project_id = ?1, title = ?2, description = ?3, status = ?4, priority = ?5, branch_name = ?6, assignee = ?7, workflow_def_id = ?8, base_branch = ?9, review_rounds = ?10, output_json = ?11, idea_id = ?12, queue = ?13, parent_id = ?14, content_json = ?15, module_id = ?16, updated_at = ?17 WHERE id = ?18",
params![
rec.project_id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.branch_name, rec.assignee, rec.workflow_def_id, rec.base_branch,
rec.review_rounds, rec.output_json, rec.idea_id,
rec.queue, rec.parent_id, rec.content_json,
rec.queue, rec.parent_id, rec.content_json, rec.module_id,
rec.updated_at, rec.id
],
)
@@ -151,14 +157,14 @@ impl_repo!(
impl TaskRepo {
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
///
/// 显式列出全部 18 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 显式列出全部 19 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -259,7 +265,7 @@ impl TaskRepo {
}
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE id = ?1")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE id = ?1")
.map_err(storage_err)?;
let row = stmt
.query_row(params![id], |row| task_from_row(row))
@@ -281,7 +287,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -300,15 +306,17 @@ impl TaskRepo {
///
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
/// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行,
/// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。
/// TaskQuery 有个过滤维度(project_id/status/priority/assignee/keyword/queue/parent_id/
/// module_id),2^n 分支不可行,累积式天然支持任意维度组合,且每个 if-let 分支只 push
/// 子句+参数,新增维度零样板。
///
/// - 过滤维度:project_id / status / priority / assignee(精确等值)+ keyword(title/description LIKE)
/// - 过滤维度:project_id / status / priority / assignee / queue / parent_id / module_id
/// (精确等值)+ keyword(title/description LIKE)
/// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search)
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
/// - 显式列出全部 18 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
/// - 显式列出全部 19 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
///
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
@@ -337,6 +345,7 @@ impl TaskRepo {
let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
@@ -376,6 +385,11 @@ impl TaskRepo {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
// module_id(工程系统 V41):所属工程等值过滤(查关联到某 module 的任务)
if let Some(mid) = &module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
if let Some(kw) = &keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
@@ -399,12 +413,12 @@ impl TaskRepo {
};
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
// 显式列出全部 18 列(含 V29 queue/parent_id/content_json,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
let sql = format!(
"SELECT id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, \
queue, parent_id, content_json, created_at, updated_at \
queue, parent_id, content_json, module_id, created_at, updated_at \
FROM tasks WHERE {} ORDER BY {} DESC{}",
where_clauses.join(" AND "),
order_col,
@@ -503,7 +517,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pid], |row| task_from_row(row))
@@ -638,11 +652,11 @@ impl TaskRepo {
let id = id.to_owned();
let new_queue = new_queue.to_owned();
let now = now_millis_str();
// 显式列出全部 18 列(同 from_row 消费列,不 SELECT deleted_at:
// 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at:
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
const TASK_COLS: &str = "id, project_id, title, description, status, priority, branch_name, \
assignee, workflow_def_id, base_branch, review_rounds, output_json, \
idea_id, queue, parent_id, content_json, created_at, updated_at";
idea_id, queue, parent_id, content_json, module_id, created_at, updated_at";
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx = guard.transaction().map_err(storage_err)?;
@@ -715,7 +729,7 @@ impl TaskRepo {
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.prepare("SELECT id, project_id, title, description, status, priority, branch_name, assignee, workflow_def_id, base_branch, review_rounds, output_json, idea_id, queue, parent_id, content_json, module_id, created_at, updated_at FROM tasks WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| task_from_row(row))
@@ -738,8 +752,8 @@ impl TaskRepo {
#[cfg(test)]
mod tests {
use super::*;
use crate::crud::ProjectRepo;
use crate::models::ProjectRecord;
use crate::crud::{ProjectModuleRepo, ProjectRepo};
use crate::models::{ProjectModuleRecord, ProjectRecord};
use df_types::types::{ProjectStatus, TaskStatus};
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
@@ -766,6 +780,7 @@ mod tests {
queue: queue.to_string(),
parent_id: parent_id.map(|s| s.to_string()),
content_json: None,
module_id: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
@@ -871,6 +886,66 @@ mod tests {
assert_eq!(res.len(), 2);
}
#[tokio::test]
async fn list_by_query_module_id_filter() {
// 工程系统 V41:按 module_id 等值过滤任务。
// 需先建 project_modules 行满足 tasks.module_id FK(PRAGMA foreign_keys=ON)。
let db = Database::open_in_memory().await.expect("open_in_memory");
let project_repo = ProjectRepo::new(&db);
project_repo
.insert(ProjectRecord {
id: "proj-1".to_string(),
name: "proj-1".to_string(),
description: String::new(),
status: ProjectStatus::Planning,
idea_id: None,
path: None,
stack: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
})
.await
.unwrap();
let module_repo = ProjectModuleRepo::new(&db);
module_repo
.insert(ProjectModuleRecord {
id: "mod-1".to_string(),
project_id: "proj-1".to_string(),
name: "backend".to_string(),
path: "/repo/backend".to_string(),
git_url: None,
stack: None,
auto_detected: false,
sort_order: 0,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
description: None,
status: None,
})
.await
.unwrap();
let repo = TaskRepo::new(&db);
// 2 个任务关联 mod-1,1 个无关联工程
let mut t1 = trec("t1", "todo", None);
t1.module_id = Some("mod-1".to_string());
let mut t2 = trec("t2", "todo", None);
t2.module_id = Some("mod-1".to_string());
repo.insert(t1).await.unwrap();
repo.insert(t2).await.unwrap();
repo.insert(trec("t3", "todo", None)).await.unwrap();
let q = TaskQuery {
module_id: Some("mod-1".to_string()),
..Default::default()
};
let res = repo.list_by_query(&q).await.unwrap();
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids.len(), 2, "module_id 过滤应只返回关联 mod-1 的任务");
assert!(ids.contains(&"t1"));
assert!(ids.contains(&"t2"));
}
#[tokio::test]
async fn get_children_returns_only_direct_children() {
let repo = setup().await;
+115 -11
View File
@@ -45,7 +45,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 40] = [
// V41 = 任务关联工程模块:tasks.module_id 列(工程系统打底,项目多工程下任务落到具体 module)。
let steps: [(i32, fn(&Connection) -> Result<()>); 41] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -86,6 +87,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(38, migrate_v38),
(39, migrate_v39),
(40, migrate_v40),
(41, migrate_v41),
];
for (version, migrate_fn) in steps {
@@ -1230,6 +1232,31 @@ fn migrate_v40(conn: &Connection) -> Result<()> {
Ok(())
}
/// V41: 幂等补 tasks.module_id 列(任务关联到具体工程 module)
///
/// 工程系统打底(V34 project_modules 表):一个项目可含多个工程(Monorepo 多仓库 /
/// 微服务 / 前后端分离),任务此前只能关联到项目(project_id),本列让任务落到具体
/// 工程,是后续 IPC / 前端 / AI 按工程筛选任务的数据基础。TEXT NULL 向后兼容:
/// 老任务无关联 → None(TaskRecord 字段为 Option<String>)。
///
/// 外键 REFERENCES project_modules(id) ON DELETE SET NULL:module 被删除时该任务
/// module_id 自动置 NULL 解关联(不阻塞删除、不留悬挂引用)。新列默认值 NULL,
/// 存量行全为 NULL,foreign_keys=ON 下 ALTER ADD COLUMN 不报错(SQLite 要求
/// 带 REFERENCES 的新列默认值为 NULL)。用 PRAGMA 探测列存在性,缺失才 ALTER
/// (同 v20/v29 模式),对新库/老库/坏库均安全。
fn migrate_v41(conn: &Connection) -> Result<()> {
if !column_exists(conn, "tasks", "module_id") {
conn.execute(
"ALTER TABLE tasks ADD COLUMN module_id TEXT REFERENCES project_modules(id) ON DELETE SET NULL",
[],
)?;
tracing::info!("v41: 补建 tasks.module_id 列(任务关联工程)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [41])?;
tracing::info!("迁移 v41 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1915,7 +1942,7 @@ mod tests {
}
// ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V40 路径
// 全量迁移测试 — 新库从零跑完整 V1-V41 路径
// ------------------------------------------------------------
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
@@ -1939,12 +1966,13 @@ mod tests {
);
}
/// 全量迁移:新库从零跑完 V1-V40,验证关键表齐全 + 列数 > 0 + 关键列存在。
/// 全量迁移:新库从零跑完 V1-V41,验证关键表齐全 + 列数 > 0 + 关键列存在。
///
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
/// conversation_checkpoints / ai_providers / projects / ideas。
/// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、
/// tasks.idea_id、project_modules.description/status(这些列由不同 vN 加,任一漏加此处失败)。
/// tasks.idea_id、tasks.module_id、project_modules.description/status(这些列由
/// 不同 vN 加,任一漏加此处失败)。
#[tokio::test]
async fn test_full_migration_on_fresh_db() {
// 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移
@@ -1989,8 +2017,13 @@ mod tests {
column_exists(&conn, "tasks", "idea_id"),
"tasks.idea_id 列缺失(V1 建表已带)"
);
// tasks.module_id(V41 老库 ALTER 补,新库也走 V41——新库应有)
assert!(
column_exists(&conn, "tasks", "module_id"),
"tasks.module_id 列缺失(V41 加)"
);
// 3. schema_version 应推进到 40(全量迁移成功落版本号)
// 3. schema_version 应推进到 41(全量迁移成功落版本号)
let max_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
@@ -1999,8 +2032,8 @@ mod tests {
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 40,
"全量迁移后 schema_version 应为 40(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version, 41,
"全量迁移后 schema_version 应为 41(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
@@ -2056,13 +2089,13 @@ mod tests {
assert_eq!(v_count, 1, "版本号 32 应只写一次");
}
/// G5.5: 全链幂等不变量——V1-V40 每步执行两遍不抛错。
/// G5.5: 全链幂等不变量——V1-V41 每步执行两遍不抛错。
///
/// 首轮 run() 建全 schema;清空 schema_version 强制下一轮从 V1 重跑每步
/// (模拟存量库 + 崩溃重跑/版本号回退)。任何 migrate_vN 的裸 ALTER(无 column_exists
/// 守卫,如 v32 修前形态)都会在第二遍报 duplicate column 被此测试捕获。
#[test]
fn v1_to_v40_full_chain_rerun_idempotent() {
fn v1_to_v41_full_chain_rerun_idempotent() {
let conn = Connection::open_in_memory().expect("open in-memory db");
run(&conn).expect("首轮全量迁移应成功");
let max_v: i64 = conn
@@ -2072,7 +2105,7 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v, 40, "首轮应推进到 40");
assert_eq!(max_v, 41, "首轮应推进到 41");
// 清空版本表强制全链第二遍(每步 execute 第二次)
conn.execute("DELETE FROM schema_version", []).unwrap();
@@ -2084,6 +2117,77 @@ mod tests {
|r| r.get(0),
)
.unwrap();
assert_eq!(max_v2, 40, "重跑后应重新推进到 40");
assert_eq!(max_v2, 41, "重跑后应重新推进到 41");
}
// ============================================================
// V41 迁移幂等安全(任务关联工程 module)
// ============================================================
/// 构造最小老库 schema:tasks 表(无 module_id 列,模拟 V40 前老形态)+ project_modules
/// 表(V41 外键引用目标,工程系统 V34)+ schema_version。
fn setup_legacy_tasks_no_module_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 project_modules (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
name TEXT NOT NULL,
path TEXT NOT NULL
);
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 tables");
conn
}
/// 老库无 module_id 列:迁移应补建 + 写版本号 41
#[test]
fn v41_legacy_db_adds_module_id_column() {
let conn = setup_legacy_tasks_no_module_db();
assert!(
!column_exists(&conn, "tasks", "module_id"),
"迁移前应无 module_id 列"
);
migrate_v41(&conn).expect("v41 应在老库补建 module_id 列");
assert!(
column_exists(&conn, "tasks", "module_id"),
"迁移后应有 module_id 列"
);
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 41, "应写入版本号 41");
}
/// 幂等重跑:列已存在时跳过 ALTER,不报 duplicate column(对齐 v20/v32 守卫模式)
#[test]
fn v41_column_exists_skips_alter() {
let conn = setup_legacy_tasks_no_module_db();
migrate_v41(&conn).expect("首次迁移");
assert!(column_exists(&conn, "tasks", "module_id"));
// 手动回退版本号模拟「列已存在但版本号未写」场景,验证 ALTER 被短路不报 duplicate column
conn.execute("DELETE FROM schema_version WHERE version = 41", [])
.unwrap();
migrate_v41(&conn).expect("列存在时应跳过 ALTER 不报错");
let v: i64 = conn
.query_row("SELECT MAX(version) FROM schema_version", [], |r| r.get(0))
.unwrap();
assert_eq!(v, 41);
}
}
+7
View File
@@ -119,6 +119,13 @@ pub struct TaskRecord {
/// #[serde(default, skip_serializing_if = "Option::is_none")] 兼容旧 JSON + 无值不序列化。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content_json: Option<String>,
/// 所属工程 ID(任务关联到具体 module,工程系统 V41 列)。
/// 一个项目可含多个工程(Monorepo/微服务),本字段让任务落到具体工程,是后续
/// IPC / 前端 / AI 按工程筛选任务的数据基础。外键 REFERENCES project_modules(id)
/// ON DELETE SET NULL(module 删除时任务自动解关联)。可空(老任务无关联 → None)。
/// #[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
pub module_id: Option<String>,
pub created_at: String,
pub updated_at: String,
}
@@ -55,6 +55,7 @@ fn task(id: &str, project_id: &str) -> TaskRecord {
queue: "todo".to_string(),
parent_id: None,
content_json: None,
module_id: None,
created_at: now_ts(),
updated_at: now_ts(),
}