//! 工具暴露层 — 从 df-storage Repo 复用 CRUD,转 MCP Tool schema //! //! 不重复实现数据层逻辑,只做:① 工具元数据(name/description/inputSchema)声明; //! ② 工具调用 → df-storage Repo 方法 → 序列化为 MCP CallToolResult。 //! //! 安全降级: //! - [`RiskLevel::High`]:默认拒绝,返「请在 DevFlow 应用内执行」 //! - [`RiskLevel::Medium`]:默认允许 + tracing::warn 审计日志 //! - [`RiskLevel::Low`]:默认允许,只读无副作用 //! - `--read-only`:dispatch 阶段过滤,仅 Low 工具可见 //! //! handler 形态:`fn(&Ctx, Value) -> BoxFuture`(函数指针 + async 块), //! 避免闭包捕获带来的 Box 开销与生命周期问题。 // name→id 解析:src-tauri 有机制层解析(audit/mod.rs auto_resolve),MCP 面暂不同步。 use std::sync::{Arc, OnceLock}; use df_storage::crud::{IdeaQuery, IdeaRepo, ProjectQuery, ProjectRepo, TaskQuery, TaskRepo}; use df_storage::db::Database; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id}; use futures::future::BoxFuture; use serde_json::{json, Value}; use crate::protocol::{CallToolResult, Tool}; // ============================================================ // 风险等级 // ============================================================ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RiskLevel { /// 只读无副作用:list/get Low, /// 有副作用但可逆:create/update/bind/restore/advance Medium, /// 不可逆或高破坏:delete/purge/run_workflow → 默认拒绝 High, } // ============================================================ // 工具元数据 + 上下文 // ============================================================ /// 工具元数据 + 风险等级 + handler 函数指针。 pub struct ToolSpec { pub tool: Tool, pub risk: RiskLevel, pub handler: HandlerFn, } /// handler 函数指针类型:接收上下文引用 + 参数,返回 boxed future。 pub type HandlerFn = fn(&Ctx, Value) -> BoxFuture<'static, CallToolResult>; /// 工具执行上下文 — 持有所有 Repo 句柄。每个 handler 内部重新构造 Repo(零开销,Repo 仅持 Arc)。 /// /// 不预存 Repo 是因为 Repo::new 借用 &Database,生命周期管理麻烦;Arc clone 廉价。 pub struct Ctx { pub db: Arc, } impl Ctx { pub fn new(db: Arc) -> Self { Self { db } } } // ============================================================ // inputSchema 构造助手 // ============================================================ fn object_schema(properties: Value, required: &[&str]) -> Value { json!({ "type": "object", "properties": properties, "required": required, "additionalProperties": false }) } fn str_field(desc: &str) -> Value { json!({ "type": "string", "description": desc }) } fn opt_str_field(desc: &str) -> Value { json!({ "type": "string", "description": desc }) } fn int_field(desc: &str) -> Value { json!({ "type": "integer", "description": desc }) } // ============================================================ // 工具清单 // ============================================================ /// 返回全部已注册工具(只读模式由 dispatch 过滤 High/Medium)。 static TOOLS: OnceLock> = OnceLock::new(); pub fn all_tools() -> &'static Vec<&'static ToolSpec> { TOOLS.get_or_init(|| { use RiskLevel::*; vec![ // ─── 项目 ─── spec("list_projects", "列出所有未删除项目(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_projects), spec("get_project", "按 ID 获取项目", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Low, get_project), spec("create_project", "创建项目(Medium 风险,默认允许+审计日志)", object_schema(json!({"name": str_field("项目名"), "description": str_field("描述"), "status": opt_str_field("状态(默认 planning)")}), &["name", "description"]), Medium, create_project), spec("update_project", "更新项目(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("项目 ID"), "name": opt_str_field("项目名(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "status": opt_str_field("状态(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_project), spec("delete_project", "软删项目(进回收站,可恢复)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), High, delete_project), spec("bind_directory", "为项目绑定本地代码目录(会做路径冲突检测,Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID"), "path": str_field("本地目录绝对路径")}), &["id", "path"]), Medium, bind_directory), // ─── 任务 ─── spec("list_tasks", "列出所有未删除任务(可按 project_id/status 过滤;分页 offset/limit,默认 limit=50 上限 100)", object_schema(json!({"project_id": opt_str_field("按项目过滤(可空)"), "status": opt_str_field("按状态过滤(todo/in_progress/in_review/testing/blocked/done/cancelled,可空)"), "offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_tasks), spec("create_task", "创建任务(Medium 风险,默认允许+审计日志;可选 parent_id 父任务 ID,限 1 级嵌套,父任务自身不能是子任务)", object_schema(json!({"project_id": str_field("项目 ID"), "title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)"), "parent_id": opt_str_field("父任务 ID(可空)")}), &["project_id", "title", "description"]), Medium, create_task), spec("update_task", "更新任务(部分更新:仅传需要改的字段,未传字段保留原值;状态须走 advance_task)", object_schema(json!({"id": str_field("任务 ID"), "project_id": opt_str_field("项目 ID(可空=保留原值)"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_task), spec("advance_task", "推进任务状态(传目标 status,内部读当前态+状态机校验,Medium 风险+审计日志)", object_schema(json!({"id": str_field("任务 ID"), "to": str_field("目标 status(todo/in_progress/in_review/testing/blocked/done/cancelled)")}), &["id", "to"]), Medium, advance_task), spec("delete_task", "软删任务(进回收站)——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("任务 ID")}), &["id"]), High, delete_task), // ─── 灵感 ─── spec("list_ideas", "列出所有想法/灵感(分页:offset/limit,默认 limit=50 上限 100)", object_schema(json!({"offset": int_field("偏移量(可空,默认 0)"), "limit": int_field("返回上限(可空,默认 50,上限 100)")}), &[]), Low, list_ideas), spec("create_idea", "创建想法(Medium 风险,默认允许+审计日志)", object_schema(json!({"title": str_field("标题"), "description": str_field("描述"), "priority": int_field("优先级(可空,默认 0)")}), &["title", "description"]), Medium, create_idea), spec("update_idea", "更新想法(部分更新:仅传需要改的字段,未传字段保留原值)", object_schema(json!({"id": str_field("想法 ID"), "title": opt_str_field("标题(可空=保留原值)"), "description": opt_str_field("描述(可空=保留原值)"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, update_idea), spec("delete_idea", "软删想法——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), High, delete_idea), spec("evaluate_idea", "对想法做启发式评估(只读:只返分数不写库,基于 description/title 计算 feasibility/impact/urgency/overall)", object_schema(json!({"id": str_field("想法 ID")}), &["id"]), Low, evaluate_idea), spec("score_idea", "评分并写库(Medium 风险+审计日志):对想法做启发式评估,把 scores 写回 DB 并返回更新后的记录", object_schema(json!({"id": str_field("想法 ID"), "expected_updated_at": int_field("乐观锁版本(可空):上次读取到的 updated_at 毫秒时间戳,不一致则拒绝写入")}), &["id"]), Medium, score_idea), // ─── 工作流(High) ─── spec("run_workflow", "触发工作流——High 风险,默认拒绝,请在 DevFlow 应用内执行", object_schema(json!({"project_id": str_field("项目 ID"), "task_id": opt_str_field("任务 ID(可空)")}), &["project_id"]), High, run_workflow), // ─── 回收站 ─── spec("list_trash", "列出回收站(deleted_at IS NOT NULL 的项目与任务)", object_schema(json!({}), &[]), Low, list_trash), spec("restore_project", "从回收站恢复项目(Medium 风险+审计日志)", object_schema(json!({"id": str_field("项目 ID")}), &["id"]), Medium, restore_project), ] }) } /// 工具元数据构造助手 — Box::leak 静态化(进程生命周期,启动一次性构造)。 #[allow(clippy::too_many_arguments)] fn spec( name: &'static str, description: &'static str, input_schema: Value, risk: RiskLevel, handler: HandlerFn, ) -> &'static ToolSpec { Box::leak(Box::new(ToolSpec { tool: Tool { name, description, input_schema, }, risk, handler, })) } // ============================================================ // 工具查找 // ============================================================ /// 按 name 查找工具(线性扫描,工具数 20,O(n) 足够)。 pub fn find(name: &str) -> Option<&'static ToolSpec> { all_tools().iter().find(|t| t.tool.name == name).copied() } // ============================================================ // handler 公共助手 // ============================================================ fn now_millis() -> String { df_types::now_millis().to_string() } fn json_ok(v: Value) -> CallToolResult { CallToolResult::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into())) } fn err_str(e: impl std::fmt::Display) -> CallToolResult { CallToolResult::error(format!("内部错误: {e}")) } fn medium_audit(name: &str, args_summary: &str) { tracing::warn!(target: "df_mcp_audit", tool = name, args = %args_summary, "MCP Medium 风险工具被外部调用"); } /// 取必填字符串参数 fn arg_str(args: &Value, key: &str) -> Result { args.get(key) .and_then(|v| v.as_str()) .map(|s| s.to_owned()) .ok_or_else(|| CallToolResult::error(format!("缺少必填参数: {key}"))) } /// 取可选字符串参数(默认值) fn arg_str_or(args: &Value, key: &str, default: &str) -> String { args.get(key) .and_then(|v| v.as_str()) .unwrap_or(default) .to_owned() } /// 取可选整数参数(默认值) fn arg_int_or(args: &Value, key: &str, default: i32) -> i32 { args.get(key) .and_then(|v| v.as_i64()) .map(|i| i as i32) .unwrap_or(default) } /// 解析分页参数 offset/limit(offset 默认 0;limit 默认 50,钳制上限 100)。 /// 供 list_projects / list_tasks / list_ideas 三个列表工具统一使用。 fn pagination(args: &Value) -> (u32, u32) { let offset = args .get("offset") .and_then(|v| v.as_i64()) .map(|i| i.max(0) as u32) .unwrap_or(0); let limit = args .get("limit") .and_then(|v| v.as_i64()) .map(|i| i.max(1) as u32) .unwrap_or(50); (offset, limit.min(100)) } /// 乐观锁版本校验(CAS):调用方传入 expected_updated_at(毫秒时间戳)时, /// 与 DB 当前 updated_at 比对,不一致即拒绝写入(数据已被其他进程修改)。 /// 未传则跳过校验(向后兼容,不破坏旧调用方)。接受数字或字符串两种传法。 fn check_expected_updated_at(args: &Value, db_updated_at: &str) -> Result<(), CallToolResult> { let Some(expected) = args.get("expected_updated_at").and_then(|v| { v.as_i64() .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok())) }) else { return Ok(()); }; let current: i64 = db_updated_at.trim().parse().unwrap_or(i64::MIN); if current != expected { return Err(CallToolResult::error(format!( "数据已被其他进程修改(当前 updated_at={db_updated_at}),请刷新后重试" ))); } Ok(()) } // ============================================================ // 跨实体校验(防 B-260801-01:跨实体误操作) // ============================================================ // // 各实体表(projects / tasks / ideas)独立存储,Repo::get_by_id 只查本表。 // 当 id 实属另一实体(如把 idea id 传给 update_task),本表查询返回 None, // 旧实现一律报「任务/项目/想法不存在」,错误信息具有误导性,且对 LLM/客户端 // 跨实体误操作无防护(误以为 id 拼错重试,实际是实体类型搞错)。 // // 此函数在「本表未命中」时跨另两张表探测 id 归属,返回命中的实体名 // (Some("idea") / Some("task") / Some("project")),调用方据此报更精确错误: // 「id 属于 idea,不能用 update_task 修改」。三表都未命中 → None(真不存在)。 // // 仅在错误路径(本表 None)执行,正常路径零开销。 // // 返回值命名约定:中文实体名(对齐 handler 中文错误信息风格),与 handler 名一致。 /// 跨实体探测:id 在另两张表中的归属(None=都不在)。 /// `excluding` 是调用方实体名(本表已查过,跳过避免重复查)。 async fn detect_entity_owner(db: &Arc, id: &str, excluding: &str) -> Option<&'static str> { // 顺序按调用方常见误操作倾向排列(task ↔ idea 互混最常见,project 较少跨)。 // 三个候选用 if 链(非循环)以静态分发各 Repo,避免 dyn。 if excluding != "task" { if let Ok(Some(_)) = TaskRepo::new(db).get_by_id(id).await { return Some("task"); } } if excluding != "idea" { if let Ok(Some(_)) = IdeaRepo::new(db).get_by_id(id).await { return Some("idea"); } } if excluding != "project" { if let Ok(Some(_)) = ProjectRepo::new(db).get_by_id(id).await { return Some("project"); } } None } /// 跨实体错误信息构造:`excluding` 是当前 handler 期望的实体名, /// `id` 是客户端传入的 id。若 id 属于其他实体,返回描述性错误串;否则 None。 async fn cross_entity_err(db: &Arc, id: &str, excluding: &str) -> Option { match detect_entity_owner(db, id, excluding).await { Some(actual) => Some(format!( "id「{id}」属于 {actual},不能用 update_{excluding} 修改(跨实体误操作)" )), None => None, } } // ============================================================ // handler 实现 — 项目 // ============================================================ fn list_projects(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let (offset, limit) = pagination(&args); Box::pin(async move { let repo = ProjectRepo::new(&db); // 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。 // 复用 list_by_query(默认 created_at DESC,与 list_active 排序一致)。 let q = ProjectQuery { limit: Some(limit + 1), offset: Some(offset), ..Default::default() }; match repo.list_by_query(q).await { Ok(list) => { let has_more = list.len() as u32 > limit; let page: Vec<_> = list.into_iter().take(limit as usize).collect(); json_ok(json!({ "projects": page, "count": page.len(), "offset": offset, "limit": limit, "has_more": has_more })) } Err(e) => err_str(e), } }) } fn get_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; Box::pin(async move { let repo = ProjectRepo::new(&db); match repo.get_by_id(&id).await { Ok(Some(p)) => json_ok(json!(p)), Ok(None) => CallToolResult::error(format!("项目不存在: {id}")), Err(e) => err_str(e), } }) } fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let name = match arg_str(&args, "name") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); let status = arg_str_or(&args, "status", "planning"); medium_audit("create_project", &name); Box::pin(async move { let now = now_millis(); let status = match ProjectStatus::from_db_str(&status) { Some(s) => s, None => return CallToolResult::error( format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled") ), }; let rec = ProjectRecord { id: new_id(), name, description, status, idea_id: None, path: None, stack: None, created_at: now.clone(), updated_at: now, }; let repo = ProjectRepo::new(&db); match repo.insert(rec).await { Ok(id) => { let created = repo.get_by_id(&id).await.ok().flatten(); json_ok(json!({ "id": id, "project": created })) } Err(e) => err_str(e), } }) } fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("update_project", &id); Box::pin(async move { let repo = ProjectRepo::new(&db); // 先读现有保留 path/stack/idea_id,以及未传字段的回退源(部分更新语义) let existing = match repo.get_by_id(&id).await { Ok(Some(p)) => p, Ok(None) => { // 跨实体校验(B-260801-01):id 可能属于 task/idea,给精确错误防误操作 if let Some(msg) = cross_entity_err(&db, &id, "project").await { return CallToolResult::error(msg); } return CallToolResult::error(format!("项目不存在: {id}")); } Err(e) => return err_str(e), }; // 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入 if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { return r; } // 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据 let name = arg_str(&args, "name").unwrap_or_else(|_| existing.name.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let status = arg_str(&args, "status").unwrap_or_else(|_| existing.status.as_str().to_owned()); let status = match ProjectStatus::from_db_str(&status) { Some(s) => s, None => return CallToolResult::error( format!("非法状态值: {status}, 有效值: planning/in_progress/testing/releasing/completed/paused/cancelled") ), }; let now = now_millis(); let rec = ProjectRecord { id: id.clone(), name, description, status, idea_id: existing.idea_id, path: existing.path, stack: existing.stack, created_at: existing.created_at, updated_at: now, }; match repo.update_full(&rec).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}")), Err(e) => err_str(e), } }) } fn delete_project(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { // High 风险:默认拒绝(dispatch 兜底 + 此处二次防御,防 dispatch 漏判) Box::pin(std::future::ready(CallToolResult::error( "High 风险操作(delete_project)默认拒绝,请在 DevFlow 应用内执行。", ))) } fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; let path = match arg_str(&args, "path") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("bind_directory", &format!("{id} <- {path}")); Box::pin(async move { let repo = ProjectRepo::new(&db); // 分段检测 `..`(防穿越)——纯子串 contains("..") 会误伤 my..file 这类合法名, // 改用逐段判断对齐 tool_registry.rs:validate_path 的分段检测逻辑。 let has_traversal = path.split(|c| c == '\\' || c == '/').any(|seg| seg == ".."); if has_traversal { return CallToolResult::error(format!("路径不得包含 '..' 段: {}", path)); } let norm = normalize_path(&path); // 路径冲突检测 if let Some(conflict) = repo.find_path_conflict(&norm, Some(&id)).await.ok().flatten() { return CallToolResult::error(format!( "路径已被项目「{}」({})绑定,请先解绑", conflict.name, conflict.id )); } // 仅更新 path 字段(用 normalize 后的规范化路径,保留其它) match repo.update_field(&id, "path", &norm).await { Ok(true) => {} Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")), Err(e) => return err_str(e), } let updated = repo.get_by_id(&id).await.ok().flatten(); json_ok(json!({ "id": id, "project": updated })) }) } // ============================================================ // handler 实现 — 任务 // ============================================================ fn list_tasks(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let project_id_filter = args.get("project_id").and_then(|v| v.as_str()).map(|s| s.to_owned()); let status_filter = args.get("status").and_then(|v| v.as_str()).map(|s| s.to_owned()); let (offset, limit) = pagination(&args); Box::pin(async move { let repo = TaskRepo::new(&db); // 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。 let query = TaskQuery { project_id: project_id_filter, status: status_filter, limit: Some(limit + 1), offset: Some(offset), ..Default::default() }; match repo.list_by_query(&query).await { Ok(list) => { let has_more = list.len() as u32 > limit; let page: Vec<_> = list.into_iter().take(limit as usize).collect(); json_ok(json!({ "tasks": page, "count": page.len(), "offset": offset, "limit": limit, "has_more": has_more })) } Err(e) => err_str(e), } }) } fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let project_id = match arg_str(&args, "project_id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; let title = match arg_str(&args, "title") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); let priority = arg_int_or(&args, "priority", 0); medium_audit("create_task", &format!("{project_id}/{title}")); Box::pin(async move { // parent_id 可选(arg_str_or 给 "" 哨兵,空串视为 None)。非空时校验 1 级嵌套铁律: // 父任务存在 + 父任务自身无 parent_id(防孙任务),违反返回明确错误(与 IPC create_task 同规则)。 let parent_id_raw = arg_str_or(&args, "parent_id", ""); let parent_id = if parent_id_raw.trim().is_empty() { None } else { let pid = parent_id_raw.trim(); let repo = TaskRepo::new(&db); match repo.get_by_id(pid).await { Ok(Some(parent)) => { if parent.parent_id.is_some() { return CallToolResult::error(format!( "父任务不能是子任务(1 级嵌套限制): {pid} 自身有 parent_id={:?}", parent.parent_id )); } Some(pid.to_string()) } Ok(None) => return CallToolResult::error(format!("父任务不存在: {pid}")), Err(e) => return err_str(e), } }; let now = now_millis(); let rec = TaskRecord { id: new_id(), project_id, title, description, status: TaskStatus::Todo, priority, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, idea_id: None, module_id: None, queue: "todo".to_string(), parent_id, content_json: None, created_at: now.clone(), updated_at: now, }; let repo = TaskRepo::new(&db); match repo.insert(rec).await { Ok(id) => { let created = repo.get_by_id(&id).await.ok().flatten(); json_ok(json!({ "id": id, "task": created })) } Err(e) => err_str(e), } }) } fn update_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("update_task", &id); Box::pin(async move { let repo = TaskRepo::new(&db); let existing = match repo.get_by_id(&id).await { Ok(Some(t)) => t, Ok(None) => { // 跨实体校验(B-260801-01):id 可能属于 project/idea,给精确错误防误操作 if let Some(msg) = cross_entity_err(&db, &id, "task").await { return CallToolResult::error(msg); } return CallToolResult::error(format!("任务不存在: {id}")); } Err(e) => return err_str(e), }; // 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入 if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { return r; } // 部分更新:project_id/title/description 缺省回退 existing,避免空默认清空数据 let project_id = arg_str(&args, "project_id").unwrap_or_else(|_| existing.project_id.clone()); let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let now = now_millis(); let rec = TaskRecord { id: id.clone(), project_id, title, description, // 不允许经 MCP 改状态(状态机收口,须走 advance_task) status: existing.status, priority: existing.priority, branch_name: existing.branch_name, assignee: existing.assignee, workflow_def_id: existing.workflow_def_id, base_branch: existing.base_branch, review_rounds: existing.review_rounds, output_json: existing.output_json, 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, }; match repo.update_full(&rec).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}")), Err(e) => err_str(e), } }) } fn advance_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; // 不再接收 from:推进链内部读当前态 + 三层校验(is_valid_state / can_transition / // 同态拒绝),避免外部客户端直调 advance_status_atomic 绕过状态机非法跳态(todo→done)。 let to = match arg_str(&args, "to") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("advance_task", &format!("{id} -> {to}")); Box::pin(async move { let repo = TaskRepo::new(&db); // 复用推进链唯一 status 写入路径(与 IPC advance_task 同源,设计 D3 消除双轨): // - is_valid_state + can_transition + 同态拒绝三层校验 // - CAS 防 TOCTOU // - is_regression 自动判定 bump review_rounds(取代旧内联 bump 副本) // - advance_task_with_parent:子任务推进后自动触发父 status 聚合(聚合失败仅 warn 不阻断) // - 错误类型(NotFound/Validation/InvalidState)由 thiserror Display 串化 match df_nodes::task_advance_node::advance_task_with_parent(&repo, &id, &to).await { Ok(updated) => json_ok(json!({ "id": id, "task": updated })), Err(e) => err_str(e), } }) } fn delete_task(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { Box::pin(std::future::ready(CallToolResult::error( "High 风险操作(delete_task)默认拒绝,请在 DevFlow 应用内执行。", ))) } // ============================================================ // handler 实现 — 灵感 // ============================================================ fn list_ideas(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let (offset, limit) = pagination(&args); Box::pin(async move { let repo = IdeaRepo::new(&db); // 分页:取 limit+1 条探测是否有下一页(has_more),再截断到 limit。 // 复用 list_by_query(默认 created_at DESC,与 list_all 排序一致)。 let q = IdeaQuery { limit: Some(limit + 1), offset: Some(offset), ..Default::default() }; match repo.list_by_query(&q).await { Ok(list) => { let has_more = list.len() as u32 > limit; let page: Vec<_> = list.into_iter().take(limit as usize).collect(); json_ok(json!({ "ideas": page, "count": page.len(), "offset": offset, "limit": limit, "has_more": has_more })) } Err(e) => err_str(e), } }) } fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let title = match arg_str(&args, "title") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; let description = arg_str_or(&args, "description", ""); let priority = arg_int_or(&args, "priority", 0); medium_audit("create_idea", &title); Box::pin(async move { let now = now_millis(); let rec = IdeaRecord { id: new_id(), title, description, status: IdeaStatus::Draft, priority, score: None, tags: None, source: Some("mcp".to_owned()), promoted_to: None, ai_analysis: None, scores: None, related_ids: None, created_at: now.clone(), updated_at: now, }; let repo = IdeaRepo::new(&db); match repo.insert(rec).await { Ok(id) => { let created = repo.get_by_id(&id).await.ok().flatten(); json_ok(json!({ "id": id, "idea": created })) } Err(e) => err_str(e), } }) } fn update_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("update_idea", &id); Box::pin(async move { let repo = IdeaRepo::new(&db); let existing = match repo.get_by_id(&id).await { Ok(Some(i)) => i, Ok(None) => { // 跨实体校验(B-260801-01):id 可能属于 project/task,给精确错误防误操作 if let Some(msg) = cross_entity_err(&db, &id, "idea").await { return CallToolResult::error(msg); } return CallToolResult::error(format!("想法不存在: {id}")); } Err(e) => return err_str(e), }; // 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入 if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) { return r; } // 部分更新:title/description 缺省回退 existing,避免空默认清空数据 let title = arg_str(&args, "title").unwrap_or_else(|_| existing.title.clone()); let description = arg_str(&args, "description").unwrap_or_else(|_| existing.description.clone()); let now = now_millis(); let rec = IdeaRecord { id: id.clone(), title, description, status: existing.status, priority: existing.priority, score: existing.score, tags: existing.tags, source: existing.source, promoted_to: existing.promoted_to, ai_analysis: existing.ai_analysis, scores: existing.scores, related_ids: existing.related_ids.clone(), created_at: existing.created_at, updated_at: now, }; match repo.update_full(&rec).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}")), Err(e) => err_str(e), } }) } fn delete_idea(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { Box::pin(std::future::ready(CallToolResult::error( "High 风险操作(delete_idea)默认拒绝,请在 DevFlow 应用内执行。", ))) } /// 对想法做启发式评估(**只读,纯计算**):基于 description/title 计算 /// feasibility/impact/urgency/overall,只返分数不写库(对齐 Low=只读契约)。 /// /// 需要把分数写回 DB 的,用 [`score_idea`](Medium 风险,写库)。 fn evaluate_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; Box::pin(async move { let repo = IdeaRepo::new(&db); let idea = match repo.get_by_id(&id).await { Ok(Some(i)) => i, Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")), Err(e) => return err_str(e), }; // 启发式评分(本地确定性纯函数,不调 LLM,不写库) let scores = heuristic_scores(&idea.title, &idea.description); // 原样回 idea(未改库),仅供客户端预览;写库请走 score_idea json_ok(json!({ "id": id, "idea": idea, "scores": scores })) }) } /// 评分并写库(Medium 风险):对想法做启发式评估,把 scores 写回 DB, /// 返回更新后的记录 + scores。read-only 模式会被 dispatch 拒绝。 /// /// 评分逻辑与 [`evaluate_idea`](Low 只读)共用 [`heuristic_scores`] 纯函数, /// 唯一差异是这里做 `update_full`(写副作用 → Medium)。 fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("score_idea", &id); Box::pin(async move { let repo = IdeaRepo::new(&db); let idea = match repo.get_by_id(&id).await { Ok(Some(i)) => i, Ok(None) => return CallToolResult::error(format!("想法不存在: {id}")), Err(e) => return err_str(e), }; // 乐观锁 CAS:调用方传入 expected_updated_at 则与 DB 当前版本比对,不一致拒绝写入 if let Err(r) = check_expected_updated_at(&args, &idea.updated_at) { return r; } // 与 evaluate_idea 共用的纯函数评分 let scores = heuristic_scores(&idea.title, &idea.description); let now = now_millis(); // 写回 scores 字段(整体更新) let mut rec = idea.clone(); rec.scores = Some(match serde_json::to_string(&scores) { Ok(s) => s, Err(e) => return CallToolResult::error(format!("评分序列化失败: {e}")), }); rec.updated_at = now; if let Err(e) = repo.update_full(&rec).await { return err_str(e); } json_ok(json!({ "id": id, "idea": rec, "scores": scores })) }) } /// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。 /// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai。 fn heuristic_scores(title: &str, description: &str) -> Value { let desc_len = description.chars().count(); // feasibility:描述越详细越可行(评估前已有思考) let feasibility = ((desc_len as f64 / 200.0).min(1.0) * 6.0 + 3.0).min(9.0); // impact:含「核心/关键/重要」等关键词加权 let impact_keywords: &[&str] = &["核心", "关键", "重要", "紧急", "blocker", "critical", "core"]; let kw_hits = impact_keywords.iter().filter(|k| title.contains(*k) || description.contains(*k)).count(); let impact = (5.0 + kw_hits as f64 * 1.5).min(9.0); // urgency:priority 字段不在此,用关键词近似 let urgency_kw = ["紧急", "urgent", "asap", "立即", "马上"]; let urgency_hits = urgency_kw.iter().filter(|k| title.contains(*k) || description.contains(*k)).count(); let urgency = (4.0 + urgency_hits as f64 * 2.0).min(9.0); // overall:加权平均(feasibility/impact/urgency = 0.4/0.4/0.2) let overall = feasibility * 0.4 + impact * 0.4 + urgency * 0.2; json!({ "feasibility": (feasibility * 10.0).round() / 10.0, "impact": (impact * 10.0).round() / 10.0, "urgency": (urgency * 10.0).round() / 10.0, "overall": (overall * 10.0).round() / 10.0 }) } // ============================================================ // handler 实现 — 工作流(High,拒绝) // ============================================================ fn run_workflow(_ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { Box::pin(std::future::ready(CallToolResult::error( "High 风险操作(run_workflow)默认拒绝。工作流涉及代码生成/审查/分支操作,请在 DevFlow 应用内执行。", ))) } // ============================================================ // handler 实现 — 回收站 // ============================================================ fn list_trash(ctx: &Ctx, _args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); Box::pin(async move { let projects = match ProjectRepo::new(&db).list_deleted().await { Ok(v) => v, Err(e) => return err_str(e), }; let tasks = match TaskRepo::new(&db).list_deleted().await { Ok(v) => v, Err(e) => return err_str(e), }; json_ok(json!({ "projects": projects, "tasks": tasks, "project_count": projects.len(), "task_count": tasks.len() })) }) } fn restore_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> { let db = ctx.db.clone(); let id = match arg_str(&args, "id") { Ok(v) => v, Err(r) => return Box::pin(std::future::ready(r)), }; medium_audit("restore_project", &id); Box::pin(async move { match ProjectRepo::new(&db).restore(&id).await { Ok(true) => json_ok(json!({ "id": id, "restored": true })), Ok(false) => CallToolResult::error(format!("恢复失败(项目不在回收站): {id}")), Err(e) => err_str(e), } }) } // ============================================================ // 路径规范化(镜像 df_project::scan::normalize_path,本 crate 不依赖 df-project) // ============================================================ fn normalize_path(p: &str) -> String { // 先尝试 canonicalize(解析符号链接 + 绝对化 + .. 折叠,仅已存在的路径有效) if let Ok(abs) = std::path::Path::new(p).canonicalize() { return abs.to_string_lossy().replace('\\', "/").to_lowercase(); } // fallback:路径尚未创建,手动做以下处理: // ① 统一分隔符 // ② 逐段折叠 ..(防 foo/../bar → foo/bar) // ③ 去尾斜杠 // ④ 小写化 let normalized = p.replace('\\', "/"); let mut segments: Vec<&str> = Vec::new(); for seg in normalized.split('/') { match seg { "." | "" => continue, // 当前目录 / 空段(连续斜杠) ".." if segments.is_empty() => segments.push(".."), // 根级 .. 保留(相对路径语义) ".." => { segments.pop(); } // 上级 → 弹出上一段 _ => segments.push(seg), } } let result = if segments.is_empty() { String::new() } else { segments.join("/") }; // 去尾斜杠 result.trim_end_matches('/').to_lowercase() } // ============================================================ // 单测:evaluate_idea(只读,不写库)/ score_idea(写库)/ 风险契约 // ============================================================ #[cfg(test)] mod tests { use super::*; use crate::protocol::ContentBlock; use df_storage::crud::{IdeaRepo, ProjectRepo, TaskRepo}; use df_storage::models::{IdeaRecord, ProjectRecord, TaskRecord}; use df_types::types::{IdeaStatus, ProjectStatus, TaskStatus, new_id}; /// 构造内存 DB + Ctx async fn test_ctx() -> Ctx { let db = Arc::new(Database::open_in_memory().await.unwrap()); Ctx::new(db) } /// 从 CallToolResult 提取文本内容 fn text_of(r: &CallToolResult) -> &str { match &r.content[0] { ContentBlock::Text { text } => text, } } /// 取 CallToolResult 的 JSON 文本并解析为 Value fn json_of(r: &CallToolResult) -> Value { serde_json::from_str(text_of(r)).unwrap() } /// 插入一条想法,返回 (id, 原始 scores) async fn seed_idea(ctx: &Ctx, title: &str, desc: &str) -> String { let repo = IdeaRepo::new(&ctx.db); let now = now_millis(); let rec = IdeaRecord { id: new_id(), title: title.to_owned(), description: desc.to_owned(), status: IdeaStatus::Draft, priority: 0, score: None, tags: None, source: Some("test".to_owned()), promoted_to: None, ai_analysis: None, scores: None, related_ids: None, created_at: now.clone(), updated_at: now, }; repo.insert(rec).await.unwrap() } /// 插入一条项目,返回 id(跨实体校验测试用) async fn seed_project(ctx: &Ctx, name: &str) -> String { let repo = ProjectRepo::new(&ctx.db); let now = now_millis(); let rec = ProjectRecord { id: new_id(), name: name.to_owned(), description: String::new(), status: ProjectStatus::Planning, idea_id: None, path: None, stack: None, created_at: now.clone(), updated_at: now, }; repo.insert(rec).await.unwrap() } /// 插入一条任务,返回 id(跨实体校验测试用) async fn seed_task(ctx: &Ctx, project_id: &str, title: &str) -> String { let repo = TaskRepo::new(&ctx.db); let now = now_millis(); let rec = TaskRecord { id: new_id(), project_id: project_id.to_owned(), title: title.to_owned(), description: String::new(), status: TaskStatus::Todo, priority: 0, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, review_rounds: 0, output_json: None, idea_id: None, module_id: None, queue: "todo".to_string(), parent_id: None, content_json: None, created_at: now.clone(), updated_at: now, }; repo.insert(rec).await.unwrap() } /// 读当前 DB 中的 idea.scores(原始字符串) async fn db_scores(ctx: &Ctx, id: &str) -> Option { IdeaRepo::new(&ctx.db) .get_by_id(id) .await .unwrap() .and_then(|i| i.scores) } // ── evaluate_idea:Low 只读契约 ────────────────────────────────── #[tokio::test] async fn evaluate_idea_returns_scores_without_writing_db() { let ctx = test_ctx().await; let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await; let r = evaluate_idea(&ctx, json!({ "id": id })).await; assert!(r.is_error.is_none(), "evaluate_idea 不应返回错误"); let v = json_of(&r); assert_eq!(v["id"], id); // scores 维度齐 assert!(v["scores"]["feasibility"].is_number()); assert!(v["scores"]["impact"].is_number()); assert!(v["scores"]["urgency"].is_number()); assert!(v["scores"]["overall"].is_number()); // 契约核心:DB 中 scores 仍为 None(没写库) assert!( db_scores(&ctx, &id).await.is_none(), "evaluate_idea 违反只读契约:DB scores 被写" ); } #[tokio::test] async fn evaluate_idea_missing_id_arg_errors() { let ctx = test_ctx().await; let r = evaluate_idea(&ctx, json!({})).await; assert_eq!(r.is_error, Some(true)); assert!(text_of(&r).contains("缺少必填参数")); } #[tokio::test] async fn evaluate_idea_unknown_id_errors() { let ctx = test_ctx().await; let r = evaluate_idea(&ctx, json!({ "id": "no-such-id" })).await; assert_eq!(r.is_error, Some(true)); assert!(text_of(&r).contains("想法不存在")); } // ── score_idea:Medium 写库契约 ────────────────────────────────── #[tokio::test] async fn score_idea_writes_scores_to_db() { let ctx = test_ctx().await; let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块以解除阻塞").await; // 前置:写前 DB scores 为空 assert!(db_scores(&ctx, &id).await.is_none()); let r = score_idea(&ctx, json!({ "id": id })).await; assert!(r.is_error.is_none(), "score_idea 不应返回错误"); let v = json_of(&r); assert_eq!(v["id"], id); let scores_str = v["idea"]["scores"].as_str(); assert!(scores_str.is_some(), "返回的 idea.scores 应非空(已写库)"); let persisted = db_scores(&ctx, &id).await; assert!(persisted.is_some(), "DB scores 应已写入"); // 返回值里的 scores 字符串 == DB 持久化的字符串(一致性) assert_eq!(scores_str.unwrap(), persisted.as_deref().unwrap()); } #[tokio::test] async fn score_idea_unknown_id_errors() { let ctx = test_ctx().await; let r = score_idea(&ctx, json!({ "id": "no-such-id" })).await; assert_eq!(r.is_error, Some(true)); assert!(text_of(&r).contains("想法不存在")); } // ── 风险契约(工具注册表)────────────────────────────────────── // // 锁定拆分的根本契约:evaluate_idea=Low(只读,read-only 放行), // score_idea=Medium(写库,read-only 拒)。改回合并即此测会红。 #[test] fn evaluate_idea_is_low_and_score_idea_is_medium() { let eval = find("evaluate_idea").expect("evaluate_idea 必须注册"); let score = find("score_idea").expect("score_idea 必须注册"); assert_eq!( eval.risk, RiskLevel::Low, "evaluate_idea 必须 Low(只读契约)" ); assert_eq!( score.risk, RiskLevel::Medium, "score_idea 必须 Medium(写库 → read-only 拒)" ); } /// read-only 可见性:end-to-end 验证 dispatch 层对两个工具的过滤。 /// (与 server.rs 测试呼应,锁定 read-only 放 evaluate / 拒 score 的契约) #[test] fn read_only_visibility_splits_evaluate_and_score() { // read-only:evaluate(Low)可见,score(Medium)不可见 assert!(visible_for_test(true, "evaluate_idea")); assert!(!visible_for_test(true, "score_idea")); // 非 read-only:两者都可见 assert!(visible_for_test(false, "evaluate_idea")); assert!(visible_for_test(false, "score_idea")); } // 辅助:复用 server.rs 的 visible 谓词(单一事实来源,避免两份逻辑漂移) fn visible_for_test(read_only: bool, name: &str) -> bool { crate::server::visible(read_only, find(name).expect("工具存在").risk) } // ── heuristic_scores 纯函数:两工具共用,确定性 ────────────────── #[test] fn heuristic_scores_is_deterministic_and_bounded() { let a = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理"); let b = heuristic_scores("核心功能", "这是非常重要的关键模块,需要紧急处理"); assert_eq!(a, b, "相同输入应得相同分数(纯函数)"); let s = &a; for k in ["feasibility", "impact", "urgency", "overall"] { let v = s[k].as_f64().unwrap(); assert!( (0.0..=9.0).contains(&v), "{k} 分数 {v} 越界 [0,9]" ); } } // ── 跨实体校验(B-260801-01):update_* 检测 id 属于其他实体时报精确错误 ── // // 各实体表独立,Repo::get_by_id 只查本表。当 id 实属另一实体时, // 旧实现只报「任务/项目/想法不存在」(误导),改后报「id 属于 X,不能用 update_Y 修改」。 /// update_task 传入 idea id → 报跨实体错误,不报「任务不存在」。 #[tokio::test] async fn update_task_with_idea_id_reports_cross_entity() { let ctx = test_ctx().await; let idea_id = seed_idea(&ctx, "灵感A", "误传给 update_task").await; let r = update_task(&ctx, json!({ "id": idea_id, "title": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 idea") && msg.contains("update_task"), "应报跨实体错误,实际: {msg}" ); // 不应回退到模糊的「任务不存在」 assert!(!msg.contains("任务不存在"), "不应是模糊错误: {msg}"); } /// update_task 传入 project id → 报跨实体错误。 #[tokio::test] async fn update_task_with_project_id_reports_cross_entity() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目P").await; let r = update_task(&ctx, json!({ "id": pid, "title": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 project") && msg.contains("update_task"), "应报跨实体错误,实际: {msg}" ); } /// update_project 传入 idea id → 报跨实体错误。 #[tokio::test] async fn update_project_with_idea_id_reports_cross_entity() { let ctx = test_ctx().await; let idea_id = seed_idea(&ctx, "灵感B", "误传给 update_project").await; let r = update_project(&ctx, json!({ "id": idea_id, "name": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 idea") && msg.contains("update_project"), "应报跨实体错误,实际: {msg}" ); assert!(!msg.contains("项目不存在"), "不应是模糊错误: {msg}"); } /// update_project 传入 task id → 报跨实体错误。 #[tokio::test] async fn update_project_with_task_id_reports_cross_entity() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目宿主").await; let tid = seed_task(&ctx, &pid, "任务T").await; let r = update_project(&ctx, json!({ "id": tid, "name": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 task") && msg.contains("update_project"), "应报跨实体错误,实际: {msg}" ); } /// update_idea 传入 task id → 报跨实体错误。 #[tokio::test] async fn update_idea_with_task_id_reports_cross_entity() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目宿主").await; let tid = seed_task(&ctx, &pid, "任务T2").await; let r = update_idea(&ctx, json!({ "id": tid, "title": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 task") && msg.contains("update_idea"), "应报跨实体错误,实际: {msg}" ); assert!(!msg.contains("想法不存在"), "不应是模糊错误: {msg}"); } /// update_idea 传入 project id → 报跨实体错误。 #[tokio::test] async fn update_idea_with_project_id_reports_cross_entity() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目P2").await; let r = update_idea(&ctx, json!({ "id": pid, "title": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("属于 project") && msg.contains("update_idea"), "应报跨实体错误,实际: {msg}" ); } /// 三表都不存在的真随机 id → 仍报原「不存在」错误(跨实体校验不应改变此行为)。 #[tokio::test] async fn update_task_with_unknown_id_still_reports_not_found() { let ctx = test_ctx().await; let r = update_task(&ctx, json!({ "id": "ghost-id-12345", "title": "x" })).await; assert_eq!(r.is_error, Some(true)); let msg = text_of(&r); assert!( msg.contains("任务不存在"), "三表都无此 id 应回退到原「不存在」错误,实际: {msg}" ); } /// 正常路径:update_task 传入真实 task id → 成功(校验不应破坏正常路径)。 #[tokio::test] async fn update_task_with_real_task_id_succeeds() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目宿主").await; let tid = seed_task(&ctx, &pid, "原标题").await; let r = update_task(&ctx, json!({ "id": tid, "title": "新标题" })).await; assert!(r.is_error.is_none(), "正常路径不应报错: {:?}", text_of(&r)); let v = json_of(&r); assert_eq!(v["task"]["title"], "新标题"); } /// 跨实体探测纯逻辑:三实体互查正确性。 #[tokio::test] async fn detect_entity_owner_returns_correct_entity() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目").await; let tid = seed_task(&ctx, &pid, "任务").await; let iid = seed_idea(&ctx, "灵感", "x").await; // 各 id 跨表探测应返回其真实所属实体(注意:不包括 excluding 自身) assert_eq!(detect_entity_owner(&ctx.db, &tid, "task").await, None, "task 自身被排除"); assert_eq!(detect_entity_owner(&ctx.db, &tid, "project").await, Some("task")); assert_eq!(detect_entity_owner(&ctx.db, &tid, "idea").await, Some("task")); assert_eq!(detect_entity_owner(&ctx.db, &iid, "idea").await, None, "idea 自身被排除"); assert_eq!(detect_entity_owner(&ctx.db, &iid, "task").await, Some("idea")); assert_eq!(detect_entity_owner(&ctx.db, &pid, "project").await, None, "project 自身被排除"); assert_eq!(detect_entity_owner(&ctx.db, &pid, "task").await, Some("project")); // 三表都没有 → None assert_eq!(detect_entity_owner(&ctx.db, "ghost", "task").await, None); } // ── 乐观锁 CAS(update 读-改-写竞态防护)───────────────────────── /// update_project 传错误的 expected_updated_at → 拒绝写入。 #[tokio::test] async fn update_project_cas_mismatch_rejects() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目").await; let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap(); let wrong: i64 = p.updated_at.parse::().unwrap() + 1; let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": wrong })).await; assert_eq!(r.is_error, Some(true)); assert!( text_of(&r).contains("数据已被其他进程修改"), "应报版本冲突,实际: {}", text_of(&r) ); } /// update_project 传正确的 expected_updated_at → 成功。 #[tokio::test] async fn update_project_cas_match_succeeds() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目").await; let p = ProjectRepo::new(&ctx.db).get_by_id(&pid).await.unwrap().unwrap(); let expected: i64 = p.updated_at.parse().unwrap(); let r = update_project(&ctx, json!({ "id": pid, "name": "新名", "expected_updated_at": expected })).await; assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r)); assert_eq!(json_of(&r)["project"]["name"], "新名"); } /// update_project 不传 expected_updated_at → 跳过校验(向后兼容)。 #[tokio::test] async fn update_project_cas_absent_is_backward_compatible() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "项目").await; let r = update_project(&ctx, json!({ "id": pid, "name": "新名" })).await; assert!(r.is_error.is_none(), "不传版本应成功: {:?}", text_of(&r)); assert_eq!(json_of(&r)["project"]["name"], "新名"); } /// score_idea 传错误的 expected_updated_at → 拒绝写入且 DB 不落库。 #[tokio::test] async fn score_idea_cas_mismatch_rejects() { let ctx = test_ctx().await; let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await; let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap(); let wrong: i64 = idea.updated_at.parse::().unwrap() + 1; let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": wrong })).await; assert_eq!(r.is_error, Some(true)); assert!( text_of(&r).contains("数据已被其他进程修改"), "应报版本冲突,实际: {}", text_of(&r) ); assert!(db_scores(&ctx, &id).await.is_none(), "CAS 拒绝时不应写库"); } /// score_idea 传正确的 expected_updated_at → 成功写库。 #[tokio::test] async fn score_idea_cas_match_succeeds() { let ctx = test_ctx().await; let id = seed_idea(&ctx, "核心功能重构", "需要立即重构关键模块").await; let idea = IdeaRepo::new(&ctx.db).get_by_id(&id).await.unwrap().unwrap(); let expected: i64 = idea.updated_at.parse().unwrap(); let r = score_idea(&ctx, json!({ "id": id, "expected_updated_at": expected })).await; assert!(r.is_error.is_none(), "版本一致应成功: {:?}", text_of(&r)); assert!(db_scores(&ctx, &id).await.is_some(), "版本一致应写库"); } /// update_task 传错误的 expected_updated_at → 拒绝写入。 #[tokio::test] async fn update_task_cas_mismatch_rejects() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "宿主").await; let tid = seed_task(&ctx, &pid, "原标题").await; let t = TaskRepo::new(&ctx.db).get_by_id(&tid).await.unwrap().unwrap(); let wrong: i64 = t.updated_at.parse::().unwrap() + 1; let r = update_task(&ctx, json!({ "id": tid, "title": "新标题", "expected_updated_at": wrong })).await; assert_eq!(r.is_error, Some(true)); assert!(text_of(&r).contains("数据已被其他进程修改"), "实际: {}", text_of(&r)); } // ── list 分页(offset/limit/has_more)───────────────────────────── /// list_ideas 分页:limit 截断 + has_more 翻页标记。 #[tokio::test] async fn list_ideas_pagination_has_more_and_page() { let ctx = test_ctx().await; for i in 0..5 { seed_idea(&ctx, &format!("灵感{i}"), "x").await; } // 首页 limit=2 → 2 条 + has_more=true let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 0 })).await; assert!(r.is_error.is_none(), "{:?}", text_of(&r)); let v = json_of(&r); assert_eq!(v["ideas"].as_array().unwrap().len(), 2); assert_eq!(v["count"], 2); assert_eq!(v["has_more"], true); assert_eq!(v["limit"], 2); assert_eq!(v["offset"], 0); // 第二页 offset=2 → 又 2 条,仍有下一页(共 5 条) let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 2 })).await; let v = json_of(&r); assert_eq!(v["ideas"].as_array().unwrap().len(), 2); assert_eq!(v["has_more"], true); // 第三页 offset=4 → 1 条,has_more=false(到尾) let r = list_ideas(&ctx, json!({ "limit": 2, "offset": 4 })).await; let v = json_of(&r); assert_eq!(v["ideas"].as_array().unwrap().len(), 1); assert_eq!(v["has_more"], false); } /// list_tasks 分页:limit 超上限钳到 100;不足一页无下一页。 #[tokio::test] async fn list_tasks_pagination_caps_limit_and_no_has_more() { let ctx = test_ctx().await; let pid = seed_project(&ctx, "宿主").await; for i in 0..3 { seed_task(&ctx, &pid, &format!("任务{i}")).await; } let r = list_tasks(&ctx, json!({ "limit": 999 })).await; assert!(r.is_error.is_none(), "{:?}", text_of(&r)); let v = json_of(&r); assert_eq!(v["limit"], 100, "limit 超上限应钳到 100"); assert_eq!(v["tasks"].as_array().unwrap().len(), 3); assert_eq!(v["has_more"], false, "3 条 < 100,无下一页"); } /// list_projects 分页:offset 生效 + has_more 标记。 #[tokio::test] async fn list_projects_pagination_offset_and_has_more() { let ctx = test_ctx().await; for i in 0..4 { seed_project(&ctx, &format!("项目{i}")).await; } let r = list_projects(&ctx, json!({ "limit": 3, "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(), 3); assert_eq!(v["has_more"], true, "4 条取 3,还有下一页"); let r = list_projects(&ctx, json!({ "limit": 3, "offset": 3 })).await; let v = json_of(&r); assert_eq!(v["projects"].as_array().unwrap().len(), 1); assert_eq!(v["has_more"], false); } }