优化: MCP 收尾(update原子CAS治TOCTOU/advance_task数据变更映射/list_trash分页) + miniapp渲染收尾(mention chip可视化/代码块语言标签) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:02 +08:00
parent 37040616bd
commit e099eff4cb
8 changed files with 479 additions and 36 deletions
+83
View File
@@ -538,6 +538,43 @@ impl IdeaRepo {
.await
.map_err(storage_err)?
}
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &IdeaRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
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",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
impl KnowledgeRepo {
@@ -1497,4 +1534,50 @@ mod tests {
// i1 活跃不出现;i3 删除最晚在前
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
}
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn idea_update_full_cas_success_when_expected_matches() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
let current = repo.get_by_id("i1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("i1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("i1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}
+106
View File
@@ -414,6 +414,41 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &ProjectRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
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",
params![
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
rec.path, rec.stack, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
@@ -611,3 +646,74 @@ impl_repo!(
)
}
);
// ============================================================
// 单元测试 — update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
fn prec(id: &str, name: &str) -> ProjectRecord {
ProjectRecord {
id: id.to_string(),
name: name.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(),
}
}
async fn setup() -> ProjectRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
ProjectRepo::new(&db)
}
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
let current = repo.get_by_id("p1").await.unwrap().unwrap();
// 本地构建新版本(name + updated_at 递增)
let mut rec = current.clone();
rec.name = "新项目".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "新项目");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(prec("p1", "原项目")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("p1").await.unwrap().unwrap();
other.name = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("p1").await.unwrap().unwrap();
mine.name = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("p1").await.unwrap().unwrap();
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}
+84
View File
@@ -743,6 +743,44 @@ impl TaskRepo {
.await
.map_err(storage_err)?
}
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖。对齐 [`advance_status_atomic`](同款 `WHERE ... = ?expected` 原子条件写)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_task)。`Ok(false)` = id 不存在或版本冲突。
pub async fn update_full_cas(
&self,
record: &TaskRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
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",
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.module_id,
rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
// ============================================================
@@ -1160,4 +1198,50 @@ mod tests {
assert_eq!(updated.queue, "todo");
assert_eq!(updated.status.as_str(), "todo");
}
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn update_full_cas_success_when_expected_matches() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
let current = repo.get_by_id("t1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn update_full_cas_conflict_when_expected_stale() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("t1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("t1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}