优化: 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
+98 -15
View File
@@ -140,7 +140,7 @@ pub fn all_tools() -> &'static Vec<&'static ToolSpec> {
"confidence": opt_str_field("置信度(可空:high/medium/low)")
}), &["kind", "title", "content"]), Medium, insert_knowledge),
// ─── 回收站 ───
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash),
spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务;分页 offset/limit,默认 limit=50 上限 100,projects/tasks 各自独立分页)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_trash),
spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project),
]
})
@@ -440,12 +440,15 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 与 check_expected_updated_at(客户端显式 expected_updated_at 的提前拦截)不同,
// 这里封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
}
Ok(false) => CallToolResult::error(format!("项目不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
}
})
@@ -694,12 +697,14 @@ fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "task": updated }))
}
Ok(false) => CallToolResult::error(format!("任务不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
}
})
@@ -854,12 +859,14 @@ fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
created_at: existing.created_at,
updated_at: now,
};
match repo.update_full(&rec).await {
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 封死「读 existing → 写 rec」之间的跨进程竞态窗口:并发端(GUI)已改则 affected==0。
match repo.update_full_cas(&rec, &existing.updated_at).await {
Ok(true) => {
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "idea": updated }))
}
Ok(false) => CallToolResult::error(format!("想法不存在: {id}")),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
}
})
@@ -928,10 +935,14 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")),
});
rec.updated_at = now;
if let Err(e) = repo.update_full(&rec).await {
return err_str(e);
// 权威 CAS:以 get_by_id 刚读到的 updated_at 为 expected,单条原子条件写。
// 旧 update_full 忽略 Ok(false) 静默覆盖;改后并发端(GUI)已改则 affected==0,
// 报版本冲突,不再互相覆盖。
match repo.update_full_cas(&rec, &idea.updated_at).await {
Ok(true) => json_ok(json!({ "id": id, "idea": rec, "scores": scores })),
Ok(false) => CallToolResult::error("记录已被其他端修改,请重新获取最新数据后重试"),
Err(e) => err_str(e),
}
json_ok(json!({ "id": id, "idea": rec, "scores": scores }))
})
}
@@ -973,8 +984,9 @@ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult>
// handler 实现 — 回收站
// ============================================================
fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
fn list_trash(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
let db = ctx.db.clone();
let (offset, limit) = pagination(&args);
Box::pin(async move {
let projects = match ProjectRepo::new(&db).list_deleted().await {
Ok(v) => v,
@@ -984,11 +996,34 @@ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v,
Err(e) => return err_str(e),
};
// 分页:与 list_projects/tasks/ideas 同契约(offset/limit/has_more)。
// projects 与 tasks 各自独立分页(同页语义:各自第 N 页),has_more 取两者并集。
// 回收站数据量小(仅软删项),内存分页足够,避免给共享 list_deleted 加 SQL 分页侵入。
// 探页:多取一条探测是否有下一页(对齐 list_* 的 limit+1 探页语义)。
let project_probe: Vec<_> = projects
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let project_has_more = project_probe.len() > limit as usize;
let project_page: Vec<_> = project_probe.into_iter().take(limit as usize).collect();
let task_probe: Vec<_> = tasks
.into_iter()
.skip(offset as usize)
.take(limit as usize + 1)
.collect();
let task_has_more = task_probe.len() > limit as usize;
let task_page: Vec<_> = task_probe.into_iter().take(limit as usize).collect();
json_ok(json!({
"projects": projects,
"tasks": tasks,
"project_count": projects.len(),
"task_count": tasks.len()
"projects": project_page,
"tasks": task_page,
"project_count": project_page.len(),
"task_count": task_page.len(),
"offset": offset,
"limit": limit,
"has_more": project_has_more || task_has_more,
"project_has_more": project_has_more,
"task_has_more": task_has_more
}))
})
}
@@ -1820,4 +1855,52 @@ mod tests {
assert!(visible_for_test(false, "search_knowledge"));
assert!(visible_for_test(false, "insert_knowledge"));
}
// ── list_trash 分页(offset/limit/has_more,projects/tasks 各自独立分页)──
#[tokio::test]
async fn list_trash_pagination_and_has_more() {
let ctx = test_ctx().await;
// 3 项目软删进回收站 + 1 任务软删(宿主项目不删,仅任务进回收站)
let mut pids = Vec::new();
for i in 0..3 {
pids.push(seed_project(&ctx, &format!("回收项目{i}")).await);
}
for p in &pids {
ProjectRepo::new(&ctx.db).soft_delete(p).await.unwrap();
}
let tid = seed_task(&ctx, &pids[0], "回收任务").await;
TaskRepo::new(&ctx.db).soft_delete(&tid).await.unwrap();
// 首页 limit=2 → 项目 2 条(仍有下一页),任务 1 条(无下一页)
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 0 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 2);
assert_eq!(v["tasks"].as_array().unwrap().len(), 1);
assert_eq!(v["project_count"], 2);
assert_eq!(v["task_count"], 1);
assert_eq!(v["project_has_more"], true, "3 项目取 2 还有下一页");
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], true);
assert_eq!(v["limit"], 2);
assert_eq!(v["offset"], 0);
// 第二页 offset=2 → 项目 1 条,两边都无下一页
let r = list_trash(&ctx, json!({ "limit": 2, "offset": 2 })).await;
let v = json_of(&r);
assert_eq!(v["projects"].as_array().unwrap().len(), 1);
assert_eq!(v["project_has_more"], false);
assert_eq!(v["task_has_more"], false);
assert_eq!(v["has_more"], false);
}
/// limit 超上限钳到 100(对齐其他 list 工具的钳制)。
#[tokio::test]
async fn list_trash_caps_limit_to_100() {
let ctx = test_ctx().await;
let r = list_trash(&ctx, json!({ "limit": 999 })).await;
assert!(r.is_error.is_none(), "{:?}", text_of(&r));
assert_eq!(json_of(&r)["limit"], 100, "limit 超上限应钳到 100");
}
}