优化: 存储层清债与子轨迹来源迁移
- V44 加 ai_conversations.origin 列(子代理轨迹来源标记) - V21 断点续迁改造(逐对话进度判定,崩溃重跑补漏) - 手写事务改 guard.transaction,panic 不残留打开事务 - 软删 CAS 补 deleted_at IS NULL(任务/项目/灵感三处) - 父任务校验拒回收站挂子,删模块先清依赖边 - 列缺失与未知 status 降级补 warn 留痕,limit=0 返空集
This commit is contained in:
@@ -40,8 +40,31 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
};
|
||||
// id 先读:① 结构体字段必填列(缺失直接 ? 报错);② enabled/weight 降级时日志带行标识,
|
||||
// 坏数据可定位到具体行而非只剩全局报警。
|
||||
let id: String = row.get("id")?;
|
||||
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。get 失败(列缺失
|
||||
// 或 NULL 坏数据)降级默认值保功能(防单行坏数据拖垮 list_all),但 warn 留痕:
|
||||
// 正常新库列必然存在,此分支触发即迁移未跑/表结构被外力改动,坏数据可观测。
|
||||
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
|
||||
let enabled = match row.get::<_, i32>("enabled") {
|
||||
Ok(v) => v != 0,
|
||||
Err(e) => {
|
||||
tracing::warn!("ai_providers[{id}] enabled 列读取失败({e}),降级默认 enabled=true");
|
||||
true
|
||||
}
|
||||
};
|
||||
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
|
||||
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
|
||||
let weight = match row.get::<_, i32>("weight") {
|
||||
Ok(v) => v.clamp(0, 100) as u32,
|
||||
Err(e) => {
|
||||
tracing::warn!("ai_providers[{id}] weight 列读取失败({e}),降级默认 weight=50");
|
||||
50
|
||||
}
|
||||
};
|
||||
Ok(AiProviderRecord {
|
||||
id: row.get("id")?,
|
||||
id,
|
||||
name: row.get("name")?,
|
||||
provider_type: row.get("provider_type")?,
|
||||
api_key: row.get("api_key")?,
|
||||
@@ -53,14 +76,8 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
|
||||
config: row.get("config")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
|
||||
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
|
||||
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
|
||||
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
|
||||
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
|
||||
weight: row.get::<_, i32>("weight")
|
||||
.unwrap_or(50)
|
||||
.clamp(0, 100) as u32,
|
||||
enabled,
|
||||
weight,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -88,6 +105,9 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
pending_approvals: row.get("pending_approvals")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
// origin 列老库经 v44 迁移补建。unwrap_or(None) 兜底(同 message_id v21 模式):
|
||||
// 迁移未跑/极端情况防御,NULL=主会话。
|
||||
origin: row.get("origin").unwrap_or(None),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,13 +183,13 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, pending_approvals, created_at, updated_at, origin)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at
|
||||
rec.pinned_goals, rec.pending_approvals, rec.created_at, rec.updated_at, rec.origin
|
||||
],
|
||||
)
|
||||
},
|
||||
@@ -261,33 +281,28 @@ impl AiToolExecutionRepo {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard.execute_batch("BEGIN").map_err(storage_err)?;
|
||||
let result = (|| -> std::result::Result<(), rusqlite::Error> {
|
||||
let mut stmt = guard.prepare(
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let mut guard = conn.blocking_lock();
|
||||
// transaction() guard 范式(同 clear_conversation_atomic / message_repo::insert_batch):
|
||||
// rusqlite Transaction 在 Drop 未 commit 时自动回滚,中途 panic/早退不残留打开事务;
|
||||
// 原手写 BEGIN/COMMIT 无此保证,panic 后连接带开事务,后续写全部静默失败。
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO ai_tool_executions (id, conversation_id, message_id, tool_call_id, tool_name, arguments, result, status, risk_level, requested_at, executed_at, decided_by)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
)?;
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
for rec in &records {
|
||||
stmt.execute(params![
|
||||
rec.id, rec.conversation_id, rec.message_id, &*rec.tool_call_id, rec.tool_name,
|
||||
rec.arguments, rec.result, rec.status, rec.risk_level,
|
||||
rec.requested_at, rec.executed_at, rec.decided_by
|
||||
])?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
match result {
|
||||
Ok(()) => {
|
||||
guard.execute_batch("COMMIT").map_err(storage_err)?;
|
||||
}
|
||||
Err(e) => {
|
||||
// 回滚失败静默(尽量保一致性;rollback 失败通常是连接已坏,交给上层)
|
||||
let _ = guard.execute_batch("ROLLBACK");
|
||||
return Err(storage_err(e));
|
||||
])
|
||||
.map_err(storage_err)?;
|
||||
}
|
||||
}
|
||||
tx.commit().map_err(storage_err)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
@@ -606,6 +621,12 @@ impl AiConversationRepo {
|
||||
/// 参数化绑定(替代原调用方的 format! 拼 SQL + execute_batch),防 snapshot 含引号/
|
||||
/// 特殊字符致注入或损坏。列对齐 conversation_checkpoints(id, conv_id, snapshot,
|
||||
/// token_total, created_at)。
|
||||
///
|
||||
/// 冲突语义:调用方 ck_id 由 (conv, 消息数) 确定性生成,同轮重复 save 撞同 id 是
|
||||
/// 预期幂等跳过;但静默会掩盖另一情形——会话回退/重生成回同消息数且内容已变,
|
||||
/// 新 snapshot 被旧 checkpoint 挡住未落库。affected=0 时 warn 留痕,两种情形都
|
||||
/// 可观测(是否异常由调用方日志上下文判别)。签名保 Result<()> 不外泄跳过信息:
|
||||
/// 跳过非错误,调用方对 Err 已按 warn 处理,无需改调用点。
|
||||
pub async fn insert_checkpoint(
|
||||
&self,
|
||||
id: &str,
|
||||
@@ -619,20 +640,25 @@ impl AiConversationRepo {
|
||||
let conv_id = conv_id.to_string();
|
||||
let snapshot = snapshot.to_string();
|
||||
let created_at = created_at.to_string();
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
let affected = guard
|
||||
.execute(
|
||||
"INSERT OR IGNORE INTO conversation_checkpoints \
|
||||
(id, conv_id, snapshot, token_total, created_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, conv_id, snapshot, token_total, created_at],
|
||||
)
|
||||
.map_err(storage_err)
|
||||
.map_err(storage_err)?;
|
||||
if affected == 0 {
|
||||
tracing::warn!(
|
||||
"checkpoint 幂等跳过(同 id 已存在,新 snapshot 未落库): id={id} conv={conv_id}"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(())
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数)
|
||||
@@ -943,4 +969,31 @@ mod tests {
|
||||
let got2 = repo.find_by_conv_tool_args("conv1", "write_file", "{\"path\":\"a.go\"}").await.expect("query");
|
||||
assert_eq!(got2.unwrap().id, "a2", "同三元组应返回最新落定记录");
|
||||
}
|
||||
|
||||
/// insert_checkpoint 冲突语义:同 id 二次写入 Ok 不报错(幂等跳过,affected=0 走 warn),
|
||||
/// 保留首次 snapshot。锁死 OR IGNORE 行为,防未来误改成 REPLACE 静默覆盖快照。
|
||||
#[tokio::test]
|
||||
async fn insert_checkpoint_conflict_skips_and_keeps_first() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiConversationRepo::new(&db);
|
||||
|
||||
repo.insert_checkpoint("ck_1", "conv1", r#"[{"seq":0}]"#, 1, "100")
|
||||
.await
|
||||
.expect("首次写入");
|
||||
// 同 id 不同内容:IGNORE 生效应 Ok,首次 snapshot 保留
|
||||
repo.insert_checkpoint("ck_1", "conv1", r#"[{"seq":0},{"seq":1}]"#, 2, "200")
|
||||
.await
|
||||
.expect("冲突写入应幂等跳过不报错");
|
||||
|
||||
let conn = db.conn();
|
||||
let g = conn.lock().await;
|
||||
let count: i64 = g
|
||||
.query_row("SELECT COUNT(*) FROM conversation_checkpoints WHERE id = 'ck_1'", [], |r| r.get(0))
|
||||
.expect("count");
|
||||
assert_eq!(count, 1, "同 id 只应有一行");
|
||||
let snapshot: String = g
|
||||
.query_row("SELECT snapshot FROM conversation_checkpoints WHERE id = 'ck_1'", [], |r| r.get(0))
|
||||
.expect("snapshot");
|
||||
assert_eq!(snapshot, r#"[{"seq":0}]"#, "冲突时应保留首次快照");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,16 @@ fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Err
|
||||
description: row.get("description")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
IdeaStatus::from_db_str(&s).unwrap_or_default()
|
||||
match IdeaStatus::from_db_str(&s) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
|
||||
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
|
||||
let id: String = row.get("id")?;
|
||||
tracing::warn!("ideas.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
priority: row.get("priority")?,
|
||||
score: row.get("score")?,
|
||||
@@ -613,7 +622,7 @@ impl IdeaRepo {
|
||||
///
|
||||
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
||||
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
|
||||
/// `Ok(false)` = id 不存在或版本冲突。
|
||||
/// `Ok(false)` = id 不存在、已进回收站(deleted_at IS NULL 守卫,V28 灵感软删)或版本冲突。
|
||||
pub async fn update_full_cas(
|
||||
&self,
|
||||
record: &IdeaRecord,
|
||||
@@ -626,7 +635,8 @@ impl IdeaRepo {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14",
|
||||
// deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫)
|
||||
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14 AND deleted_at IS NULL",
|
||||
params![
|
||||
rec.title, rec.description, rec.status.as_str(), rec.priority,
|
||||
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
|
||||
@@ -1992,6 +2002,27 @@ mod tests {
|
||||
assert_eq!(after.updated_at, "1800000000000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_update_full_cas_skips_soft_deleted() {
|
||||
// 回收站灵感不可被 CAS 命中(deleted_at IS NULL 守卫,V28 软删)
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "原标题")).await.unwrap();
|
||||
repo.soft_delete("i1").await.unwrap();
|
||||
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
|
||||
let current = repo.get_by_id("i1").await.unwrap().unwrap();
|
||||
let mut rec = current.clone();
|
||||
rec.title = "回收站里改标题".to_string();
|
||||
rec.updated_at = "1900000000000".to_string();
|
||||
let ok = repo
|
||||
.update_full_cas(&rec, ¤t.updated_at)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ok, "软删灵感 CAS 应拒绝(deleted_at 守卫)");
|
||||
let after = repo.get_by_id("i1").await.unwrap().unwrap();
|
||||
assert_eq!(after.title, "原标题", "回收站灵感内容不应被改写");
|
||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_update_full_cas_conflict_when_expected_stale() {
|
||||
let repo = setup_idea_repo().await;
|
||||
|
||||
@@ -147,6 +147,10 @@ impl AiMessageRepo {
|
||||
/// list_by_conversation 一致的 seq 升序)。`before_seq` 可选:指定后只取 seq < before_seq
|
||||
/// 的消息(滚顶加载更多时的游标,取下一页更早的历史)。
|
||||
///
|
||||
/// `limit = 0` 契约:返空集(SQLite LIMIT 0 合法且恒 0 行),不做旧版的 `.max(1)`
|
||||
/// 静默钳制——请求 0 条却回 1 条会让调用方误判页内有数据。当前唯一调用方传
|
||||
/// 常量 PAGE_SIZE=50 恒 >0,0 只可能来自未来调用方失误,空集是最不易误导的语义。
|
||||
///
|
||||
/// 典型用法:
|
||||
/// - 首次切入对话:list_recent(conv_id, 50, None) → 最近 50 条
|
||||
/// - 滚顶加载更多:list_recent(conv_id, 50, Some(最早已加载消息的 seq)) → 再加载 50 条更早的
|
||||
@@ -160,8 +164,9 @@ impl AiMessageRepo {
|
||||
let conv_id = conversation_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位,Rust 侧 clamp 防 0)
|
||||
let limit = limit.max(1) as i64;
|
||||
// before_seq 有无分两个 SQL(参数化 LIMIT 必须用固定占位);limit=0 直传
|
||||
// LIMIT 0 返空集(契约见方法文档),不再钳成 1
|
||||
let limit = limit as i64;
|
||||
let sql = if before_seq.is_some() {
|
||||
"SELECT id, conversation_id, seq, role, content, parts, tool_call_id,
|
||||
tool_calls, model, status, reasoning_content, timestamp, created_at,
|
||||
@@ -681,4 +686,29 @@ mod tests {
|
||||
let got = repo.list_by_conversation("c").await.expect("list");
|
||||
assert_eq!(got.len(), 1, "重复 replace 不应叠加");
|
||||
}
|
||||
|
||||
/// limit=0 契约:返空集(直传 SQLite LIMIT 0),不再旧版 max(1) 钳成 1 条。
|
||||
/// 锁死语义防回归:请求 0 条却回 1 条会让调用方误判页内有数据。
|
||||
#[tokio::test]
|
||||
async fn list_recent_limit_zero_returns_empty() {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let repo = AiMessageRepo::new(&db);
|
||||
repo.insert_batch(vec![
|
||||
mk_msg("m0", "c", 0, "user", "0"),
|
||||
mk_msg("m1", "c", 1, "user", "1"),
|
||||
])
|
||||
.await
|
||||
.expect("insert");
|
||||
|
||||
let got = repo.list_recent("c", 0, None).await.expect("list limit=0");
|
||||
assert!(got.is_empty(), "limit=0 无游标应返空集");
|
||||
|
||||
let got2 = repo.list_recent("c", 0, Some(5)).await.expect("list limit=0 带游标");
|
||||
assert!(got2.is_empty(), "limit=0 带游标也应返空集");
|
||||
|
||||
// 邻位 sanity:limit=1 行为不受影响,仍返最近 1 条
|
||||
let got3 = repo.list_recent("c", 1, None).await.expect("list limit=1");
|
||||
assert_eq!(got3.len(), 1);
|
||||
assert_eq!(got3[0].seq, 1, "limit=1 应取 seq 最大(最新)一条");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,17 +169,28 @@ impl ProjectModuleRepo {
|
||||
}
|
||||
|
||||
/// 按 id 硬删工程(工程不需要软删审计追溯)。
|
||||
///
|
||||
/// 单事务先清 module_dependencies 依赖边(from/to 任一端引用本工程)再删工程:
|
||||
/// module_dependencies 的 FK 无 ON DELETE 级联且 PRAGMA foreign_keys=ON,带着依赖边
|
||||
/// 直接删工程必 FK 违例回滚(调用侧 module.rs/task_graph 各自清边的兜底收口到本层)。
|
||||
pub async fn delete(&self, id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
tx.execute(
|
||||
"DELETE FROM module_dependencies WHERE from_module_id = ?1 OR to_module_id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let affected = tx
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE id = ?1",
|
||||
params![id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
tx.commit().map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
@@ -187,17 +198,26 @@ impl ProjectModuleRepo {
|
||||
}
|
||||
|
||||
/// 按项目删除全部工程(删项目时级联清理,硬删)。
|
||||
///
|
||||
/// 单事务先清本项目全部依赖边再删工程(顺序同 [`delete`],防 FK 违例)。
|
||||
pub async fn delete_by_project(&self, project_id: &str) -> Result<bool, df_types::error::Error> {
|
||||
let conn = self.conn.clone();
|
||||
let project_id = project_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
let mut guard = conn.blocking_lock();
|
||||
let tx = guard.transaction().map_err(storage_err)?;
|
||||
tx.execute(
|
||||
"DELETE FROM module_dependencies WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
let affected = tx
|
||||
.execute(
|
||||
"DELETE FROM project_modules WHERE project_id = ?1",
|
||||
params![project_id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
tx.commit().map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
@@ -338,4 +358,62 @@ mod tests {
|
||||
assert!(hit);
|
||||
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 删工程先清 module_dependencies 依赖边(⑦走查项:FK 无级联,带边删必违例)
|
||||
// ============================================================
|
||||
|
||||
/// 建一条 a→b 依赖边(模块 id 规则见 mrec:mod-a / mod-b)。
|
||||
async fn seed_edge(db: &Database, pid: &str) -> crate::crud::ModuleDependencyRepo {
|
||||
let dep_repo = crate::crud::ModuleDependencyRepo::new(db);
|
||||
dep_repo
|
||||
.insert(crate::models::ModuleDependencyRecord {
|
||||
id: "dep-1".to_string(),
|
||||
project_id: pid.to_string(),
|
||||
from_module_id: "mod-a".to_string(),
|
||||
to_module_id: "mod-b".to_string(),
|
||||
dep_type: "library".to_string(),
|
||||
label: None,
|
||||
created_at: "0".to_string(),
|
||||
})
|
||||
.await
|
||||
.expect("insert dependency edge");
|
||||
dep_repo
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_clears_dependency_edges_first() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
|
||||
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
|
||||
let dep_repo = seed_edge(&_db, &pid).await;
|
||||
|
||||
// 修前行为对照:带边直接删工程会 FK 违例报错;修后先清边再删,单事务原子
|
||||
let hit = repo.delete("mod-a").await.expect("delete 应成功不报 FK");
|
||||
assert!(hit);
|
||||
assert!(
|
||||
dep_repo.list_by_field("from_module_id", "mod-a").await.unwrap().is_empty()
|
||||
&& dep_repo.list_by_field("to_module_id", "mod-a").await.unwrap().is_empty(),
|
||||
"引用被删工程的依赖边应一并清除"
|
||||
);
|
||||
assert!(repo.get_by_id("mod-a").await.unwrap().is_none());
|
||||
// 另一端工程不受影响
|
||||
assert!(repo.get_by_id("mod-b").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_by_project_clears_dependency_edges() {
|
||||
let (_db, repo, pid) = setup().await;
|
||||
repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap();
|
||||
repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap();
|
||||
let dep_repo = seed_edge(&_db, &pid).await;
|
||||
|
||||
let hit = repo.delete_by_project(&pid).await.expect("delete_by_project 应成功不报 FK");
|
||||
assert!(hit);
|
||||
assert!(
|
||||
dep_repo.list_by_field("project_id", &pid).await.unwrap().is_empty(),
|
||||
"项目级删除应先清全部依赖边"
|
||||
);
|
||||
assert!(repo.list_by_project(&pid).await.unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,16 @@ fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlit
|
||||
description: row.get("description")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
ProjectStatus::from_db_str(&s).unwrap_or_default()
|
||||
match ProjectStatus::from_db_str(&s) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
|
||||
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
|
||||
let id: String = row.get("id")?;
|
||||
tracing::warn!("projects.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
idea_id: row.get("idea_id")?,
|
||||
path: row.get("path")?,
|
||||
@@ -436,7 +445,8 @@ impl ProjectRepo {
|
||||
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
|
||||
///
|
||||
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
||||
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
|
||||
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在、已进回收站
|
||||
/// (deleted_at IS NULL 守卫)或版本冲突。
|
||||
pub async fn update_full_cas(
|
||||
&self,
|
||||
record: &ProjectRecord,
|
||||
@@ -449,7 +459,9 @@ impl ProjectRepo {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9",
|
||||
// deleted_at IS NULL:回收站项目不可被 CAS 覆盖(MCP 持旧 expected 也能
|
||||
// 命中已软删行,造成回收站内容被静默改写),与 advance 类守卫同族。
|
||||
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9 AND deleted_at IS NULL",
|
||||
params![
|
||||
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
||||
rec.path, rec.stack, rec.updated_at, rec.id, expected
|
||||
@@ -755,4 +767,26 @@ mod tests {
|
||||
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
|
||||
assert_eq!(after.updated_at, "1800000000000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_cas_skips_soft_deleted() {
|
||||
// 回收站项目不可被 CAS 命中(deleted_at IS NULL 守卫):即使 expected 与 DB
|
||||
// updated_at 一致,已软删项目也拒绝写入,防 MCP 对回收站项目静默改写。
|
||||
let repo = setup().await;
|
||||
repo.insert(prec("p1", "原项目")).await.unwrap();
|
||||
repo.soft_delete("p1").await.unwrap();
|
||||
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
|
||||
let current = repo.get_by_id("p1").await.unwrap().unwrap();
|
||||
let mut rec = current.clone();
|
||||
rec.name = "回收站里改名".to_string();
|
||||
rec.updated_at = "1900000000000".to_string();
|
||||
let ok = repo
|
||||
.update_full_cas(&rec, ¤t.updated_at)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ok, "软删项目 CAS 应拒绝(deleted_at 守卫)");
|
||||
let after = repo.get_by_id("p1").await.unwrap().unwrap();
|
||||
assert_eq!(after.name, "原项目", "回收站项目内容不应被改写");
|
||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,10 @@ impl_repo!(
|
||||
"project_services",
|
||||
from_row => |row| project_service_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
// 宏生成的 insert/update_full 是公开写入路径,SQL 体由本调用点整体传入——校验就挂
|
||||
// 在体首(等价 insert_validated/update_full_validated 的 validate_record 收口),
|
||||
// 堵住旁路宏方法把非法 service_type / 含凭证字段写进库的 D10 缺口。
|
||||
validate_record(rec)?;
|
||||
conn.execute(
|
||||
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
@@ -147,6 +151,8 @@ impl_repo!(
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
// 同 insert:宏 update_full 的校验收口在体首(update_full_validated 同源校验)。
|
||||
validate_record(rec)?;
|
||||
conn.execute(
|
||||
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||
params![
|
||||
@@ -436,6 +442,41 @@ mod tests {
|
||||
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 宏生成的 insert/update_full 校验收口(⑥走查项:宏写入路径同过 validate_record)
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn macro_insert_rejects_invalid_service_type() {
|
||||
// 旁路 insert_validated 直接走宏 insert,非法 service_type 同样被拒
|
||||
let (_db, repo) = setup().await;
|
||||
let err = repo
|
||||
.insert(srec("s1", "elasticsearch", "development"))
|
||||
.await;
|
||||
assert!(err.is_err(), "宏 insert 应过 validate_record 拒非法类型");
|
||||
assert!(repo.get_by_id("s1").await.unwrap().is_none(), "拒后不应入库");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn macro_update_full_rejects_sensitive_config() {
|
||||
// 旁路 update_full_validated 直接走宏 update_full,凭证审查同样生效
|
||||
let (_db, repo) = setup().await;
|
||||
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut bad = srec("s1", "mysql", "development");
|
||||
bad.endpoint = Some("https://api.example.com?token=abc".to_string());
|
||||
let err = repo.update_full(&bad).await;
|
||||
assert!(err.is_err(), "宏 update_full 应过 D10 凭证审查");
|
||||
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||
assert_eq!(
|
||||
got.endpoint.as_deref(),
|
||||
Some("localhost:3306"),
|
||||
"被拒后原记录不应被改写"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_validated_changes_fields() {
|
||||
let (_db, repo) = setup().await;
|
||||
|
||||
@@ -27,7 +27,16 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
description: row.get("description")?,
|
||||
status: {
|
||||
let s: String = row.get("status")?;
|
||||
TaskStatus::from_db_str(&s).unwrap_or_default()
|
||||
match TaskStatus::from_db_str(&s) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
|
||||
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
|
||||
let id: String = row.get("id")?;
|
||||
tracing::warn!("tasks.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
|
||||
Default::default()
|
||||
}
|
||||
}
|
||||
},
|
||||
priority: row.get("priority")?,
|
||||
branch_name: row.get("branch_name")?,
|
||||
@@ -219,6 +228,30 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 按 id 查未删除任务(`WHERE id AND deleted_at IS NULL`)。
|
||||
///
|
||||
/// 专供 [`validate_parent_id`](super::task_validation) 的父任务存在性检查:
|
||||
/// 回收站父任务(soft_delete 已标 deleted_at)对"挂子任务"视角等同不存在,
|
||||
/// 通用 get_by_id 不带软删过滤会让子任务挂到回收站父下(⑤走查项)。
|
||||
/// 列清单与 get_children 一致(TaskRecord 不带 deleted_at,不 SELECT 该列)。
|
||||
pub async fn get_by_id_active(&self, id: &str) -> Result<Option<TaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
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, module_id, created_at, updated_at FROM tasks WHERE id = ?1 AND deleted_at IS NULL")
|
||||
.map_err(storage_err)?;
|
||||
let row = stmt
|
||||
.query_row(params![id], |row| task_from_row(row))
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
|
||||
///
|
||||
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
|
||||
@@ -784,7 +817,8 @@ impl TaskRepo {
|
||||
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
|
||||
///
|
||||
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
||||
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
|
||||
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在、已进回收站
|
||||
/// (deleted_at IS NULL 守卫)或版本冲突。
|
||||
pub async fn update_full_cas(
|
||||
&self,
|
||||
record: &TaskRecord,
|
||||
@@ -797,7 +831,9 @@ impl TaskRepo {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.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, module_id = ?16, updated_at = ?17 WHERE id = ?18 AND updated_at = ?19",
|
||||
// deleted_at IS NULL:回收站任务不可被 CAS 覆盖——MCP 拿旧 expected 也能
|
||||
// 命中已软删行,造成回收站内容被改且 updated_at 抖动,调用方无从感知。
|
||||
"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 AND updated_at = ?19 AND deleted_at IS NULL",
|
||||
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,
|
||||
@@ -1299,6 +1335,28 @@ mod tests {
|
||||
assert_eq!(after.updated_at, "1800000000000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_full_cas_skips_soft_deleted() {
|
||||
// 回收站任务不可被 CAS 命中(deleted_at IS NULL 守卫):即使 expected 与 DB
|
||||
// updated_at 一致,已软删行也拒绝写入,防 MCP 对回收站任务静默改写。
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||||
repo.soft_delete("t1").await.unwrap();
|
||||
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
|
||||
let current = repo.get_by_id("t1").await.unwrap().unwrap();
|
||||
let mut rec = current.clone();
|
||||
rec.title = "回收站里改标题".to_string();
|
||||
rec.updated_at = "1900000000000".to_string();
|
||||
let ok = repo
|
||||
.update_full_cas(&rec, ¤t.updated_at)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!ok, "软删任务 CAS 应拒绝(deleted_at 守卫)");
|
||||
let after = repo.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_eq!(after.title, "task-t1", "回收站任务内容不应被改写");
|
||||
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
|
||||
// ============================================================
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn validate_content_json(content: Option<&str>) -> Result<Option<String>, St
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验 parent_id 1 级嵌套铁律:父任务存在 + 父任务自身不是子任务(防孙任务)。
|
||||
/// 校验 parent_id 1 级嵌套铁律:父任务存在(未软删) + 父任务自身不是子任务(防孙任务)。
|
||||
/// 返回归一化 parent_id(空串/缺省 → None)。
|
||||
pub async fn validate_parent_id(
|
||||
repo: &TaskRepo,
|
||||
@@ -55,11 +55,13 @@ pub async fn validate_parent_id(
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty());
|
||||
if let Some(pid) = &pid {
|
||||
// get_by_id_active 带 deleted_at IS NULL:回收站父任务拒挂子,否则子任务
|
||||
// 挂在已删父下,父恢复前子任务在树/看板视图两不可见(get_by_id 无软删过滤)。
|
||||
let parent = repo
|
||||
.get_by_id(pid)
|
||||
.get_by_id_active(pid)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("父任务 {pid} 不存在,无法创建子任务"))?;
|
||||
.ok_or_else(|| format!("父任务 {pid} 不存在(或已在回收站),无法创建子任务"))?;
|
||||
if parent.parent_id.is_some() {
|
||||
return Err(format!(
|
||||
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
|
||||
@@ -80,3 +82,90 @@ pub fn normalize_priority(priority: Option<i64>) -> Result<i32, String> {
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::crud::ProjectRepo;
|
||||
use crate::db::Database;
|
||||
use crate::models::{ProjectRecord, TaskRecord};
|
||||
use df_types::types::ProjectStatus;
|
||||
|
||||
/// 内存 DB + 占位 project(tasks.project_id FK 要求)+ TaskRepo。
|
||||
async fn setup() -> TaskRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
ProjectRepo::new(&db)
|
||||
.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();
|
||||
TaskRepo::new(&db)
|
||||
}
|
||||
|
||||
fn trec(id: &str, parent_id: Option<&str>) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: "proj-1".to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: String::new(),
|
||||
status: df_types::types::TaskStatus::Todo,
|
||||
priority: 2,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
review_rounds: 0,
|
||||
output_json: None,
|
||||
idea_id: None,
|
||||
queue: "todo".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(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn parent_in_trash_rejected() {
|
||||
// 回收站父任务拒挂子(deleted_at IS NULL 存在性检查,⑤走查项)
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", None)).await.unwrap();
|
||||
repo.soft_delete("parent").await.unwrap();
|
||||
let err = validate_parent_id(&repo, Some("parent")).await.unwrap_err();
|
||||
assert!(err.contains("回收站"), "软删父应报「不存在(或已在回收站)」: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_parent_accepted_and_grandchild_still_rejected() {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", None)).await.unwrap();
|
||||
// 活跃父:校验通过,归一化保留 pid
|
||||
let pid = validate_parent_id(&repo, Some(" parent ")).await.unwrap();
|
||||
assert_eq!(pid.as_deref(), Some("parent"), "trim 后应保留 pid");
|
||||
// 1 级嵌套铁律不回归:父是子任务时仍拒孙
|
||||
repo.insert(trec("child", Some("parent"))).await.unwrap();
|
||||
let err = validate_parent_id(&repo, Some("child")).await.unwrap_err();
|
||||
assert!(err.contains("嵌套"), "孙任务应被拒: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_and_empty_parent() {
|
||||
let repo = setup().await;
|
||||
// 不存在的父:报不存在
|
||||
let err = validate_parent_id(&repo, Some("ghost")).await.unwrap_err();
|
||||
assert!(err.contains("不存在"));
|
||||
// 空串/缺省:归一化为 None 直接放行
|
||||
assert_eq!(validate_parent_id(&repo, None).await.unwrap(), None);
|
||||
assert_eq!(validate_parent_id(&repo, Some(" ")).await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user