修复: 前端UX一致性(Dashboard状态映射/状态徽章全局/Knowledge下一条与失败反馈/Ideas筛选与错误条/ConfirmDialog安全/快捷菜单状态机/分页越界) + 后端校验(queue/count-list一致/软删拒改/父聚合/promote CAS/MCP状态机收口/update白名单剔除id/created_at) + 销账

This commit is contained in:
lxy
2026-08-09 21:35:58 +08:00
parent 8bc5380ecb
commit fbd8fae44b
30 changed files with 829 additions and 138 deletions
+50 -1
View File
@@ -152,13 +152,35 @@ where
let resp = dispatch(ctx, config.read_only, req.id.clone(), method).await;
write_response(&mut writer, &resp).await?;
if let Some(name) = tool_name {
fire_write_hook(config, &name);
// MC-5(MCP-6):仅业务成功才触发写回调——防业务失败(如 update_project 非法 status、
// create_project 空名等 result.isError=true)仍假触发 df-data-changed。
if is_success_tool_call(&resp) {
fire_write_hook(config, &name);
}
}
}
Ok(())
}
/// 判定 tools/call 响应是否为「业务成功」(写回调触发条件)。
///
/// - JSON-RPC 层 `error` 非空(协议错/未识别方法)→ 失败
/// - `result` 内 `isError=true`(MCP 业务错,handler 返 `CallToolResult::error`)→ 失败
/// - 其余(正常 result / initialize/ping 等非 tools/call)→ 成功
///
/// MC-5(MCP-6):stdio(main_loop)与 HTTP(server_http on_tool_call)共用此判定,
/// 保证两 transport 对「业务失败不触发写回调」语义一致。
pub(crate) fn is_success_tool_call(resp: &Response) -> bool {
if resp.error.is_some() {
return false;
}
match &resp.result {
Some(v) => !matches!(v.get("isError"), Some(Value::Bool(true))),
None => false,
}
}
/// 方法分发 → 构造 Response。
///
/// `id`:JSON-RPC 请求 id(回响应时原样回填;通知由 main_loop 已过滤)。
@@ -626,4 +648,31 @@ mod tests {
*calls.lock().unwrap()
);
}
#[tokio::test]
async fn main_loop_no_write_callback_on_business_failure() {
// MC-5(MCP-6):业务失败(handler 返 isError=true)不得触发写回调(防假 df-data-changed)。
// create_project 空名(name 为纯空白)→ MC-6 拒空 → isError=true。
let ctx = test_ctx().await;
let calls: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let calls_cb = calls.clone();
let config = ServerConfig {
on_write_call: Some(Arc::new(move |name| {
calls_cb.lock().unwrap().push(name.to_string());
})),
..test_config(false).await
};
let (mut tx, rx) = tokio::io::duplex(1024);
let line = r#"{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"create_project","arguments":{"name":" ","description":"d"}}}"#;
tx.write_all(format!("{line}\n").as_bytes()).await.unwrap();
drop(tx);
main_loop(rx, tokio::io::sink(), &ctx, &config)
.await
.unwrap();
assert!(
calls.lock().unwrap().is_empty(),
"业务失败(isError=true)不应触发写回调,实际: {:?}",
*calls.lock().unwrap()
);
}
}
+4 -2
View File
@@ -162,8 +162,10 @@ async fn handle_body(state: &McpHttpState, body: &[u8]) -> Outcome {
let resp = dispatch(&state.ctx, state.read_only, req.id.clone(), method).await;
// ⑥ 成功 tools/call(resp.error.is_none())→ on_tool_call 回调(桌面端据此刷新 GUI)
if resp.error.is_none() {
// ⑥ 成功 tools/call(JSON-RPC 无 error 且 result.isError≠true)→ on_tool_call 回调
// (桌面端据此刷新 GUI)。MC-5/MCP-6:业务失败(handler 返 isError=true)不触发,
// 防假 df-data-changed;与 stdio fire_write_hook 共用 is_success_tool_call,两 transport 统一。
if crate::server::is_success_tool_call(&resp) {
if let (Some(name), Some(cb)) = (tool_name, &state.on_tool_call) {
cb(&name);
}
+47 -19
View File
@@ -211,14 +211,6 @@ fn arg_str_or(args: &Value, key: &str, default: &str) -> String {
.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) {
@@ -360,6 +352,14 @@ fn create_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
// MC-6(MCP-4):name trim + 拒空(对齐 GUI create_with_binding / ProjectManager::create,
// 防空白项目名进库——required 只保证传参,不保证非空白)。
let name = name.trim().to_string();
if name.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"项目名不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", "");
let status = arg_str_or(&args, "status", "planning");
medium_audit("create_project", &name);
@@ -418,16 +418,13 @@ fn update_project(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
if let Err(r) = check_expected_updated_at(&args, &existing.updated_at) {
return r;
}
// 部分更新:name/description/status 缺省回退 existing,避免空默认清空数据
// 部分更新:name/description 缺省回退 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")
),
};
// MC-1(MCP-1):status 不再写入——移除 status 参数对 DB 的影响,保留原值(对齐 update_task
// 收口思路:status 是生命周期状态,不经 update_project 旁路改写,防制造 GUI 不可能的非法跳态)。
// 客户端传 status 参数被静默忽略(保留原值);项目状态变更走专用流转路径。
let status = existing.status;
let now = now_millis();
let rec = ProjectRecord {
id: id.clone(),
@@ -520,8 +517,15 @@ fn bind_directory(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult>
Ok(false) => return CallToolResult::error(format!("项目不存在: {id}")),
Err(e) => return err_str(e),
}
// MC-6(MCP-2):两步写降级——stack 写失败不中断绑定(path 已落库,核心目标达成)。
// tracing::warn 记录,仍返回已绑定结果(path 已更新);GUI 侧可后续经 relocate 重探测。
if let Err(e) = repo.update_field(&id, "stack", &stack_json).await {
return err_str(e);
tracing::warn!(
tool = "bind_directory",
project_id = %id,
error = %e,
"stack 写回失败(降级不中断,path 已绑定)"
);
}
let updated = repo.get_by_id(&id).await.ok().flatten();
json_ok(json!({ "id": id, "project": updated }))
@@ -574,6 +578,13 @@ fn create_task(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Ok(v) => v,
Err(r) => return Box::pin(std::future::ready(r)),
};
// MC-6(MCP-4):title trim + 拒空(对齐 GUI create_task BE-CMD-1,防空白标题进库)。
let title = title.trim().to_string();
if title.is_empty() {
return Box::pin(std::future::ready(CallToolResult::error(
"任务标题不能为空".to_string(),
)));
}
let description = arg_str_or(&args, "description", "");
medium_audit("create_task", &format!("{project_id}/{title}"));
Box::pin(async move {
@@ -784,7 +795,14 @@ fn create_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
Err(r) => return Box::pin(std::future::ready(r)),
};
let description = arg_str_or(&args, "description", "");
let priority = arg_int_or(&args, "priority", 1);
// MC-6(MCP-3):priority 值域校验(复用 df-storage normalize_priority 与 GUI/AI 工具同源,
// 拦截 99 等越界值——原 arg_int_or 直落 99 被静默吞为前端 Critical,IPC 拒/MCP 放行漂移)。
let priority = match df_storage::crud::normalize_priority(
args.get("priority").and_then(|v| v.as_i64()),
) {
Ok(p) => p,
Err(e) => return Box::pin(std::future::ready(CallToolResult::error(e))),
};
medium_audit("create_idea", &title);
Box::pin(async move {
let now = now_millis();
@@ -947,7 +965,17 @@ fn score_idea(ctx: &Ctx, args: Value) -> BoxFuture<'static, CallToolResult> {
}
/// 启发式评分:基于标题长度/描述详细度/关键词,产出 feasibility/impact/urgency/overall 0-10 分。
/// 确定性纯函数,与 df-ideas 评估器对齐维度但不依赖 df-ai
/// 确定性纯函数。
///
/// **独立口径声明(MC-6/MCP-5 决策)**:本实现与 `df_ideas::scoring::ScoringEngine` 是两套
/// 并行算法,维度对齐(均为 0-10 的 feasibility/impact/urgency/overall)但权重/信号词/边界不同。
/// 保留独立实现而非复用 ScoringEngine 的取舍:
/// - df-mcp 是轻量独立 server,当前不依赖 df-ideas;而 df-ideas 传递依赖 df-ai(reqwest/
/// eventsource-stream 等重 HTTP 依赖),为消除 ~20 行评分函数把整棵重依赖树拉进 MCP 进程
/// 投入产出比不划算(编译/二进制/攻击面)。
/// - 复用还需在 df-mcp 内复制 IdeaRecord→df_ideas::Idea 转换(record_to_idea 同款),再次引入
/// 双实现;且会改变 MCP 侧既有评分输出值(行为变更)。
/// 故保持独立口径,在此显式声明;若未来 df-ideas 轻量化或 MCP 确需与 GUI 评分完全一致,再收敛。
fn heuristic_scores(title: &str, description: &str) -> Value {
let desc_len = description.chars().count();
// feasibility:描述越详细越可行(评估前已有思考)
+100
View File
@@ -401,6 +401,61 @@ impl IdeaRepo {
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// LW-8(BE-CMD-7):promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
@@ -1516,6 +1571,51 @@ mod tests {
assert!(!repo.restore("i1").await.unwrap());
}
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[tokio::test]
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
let repo = setup_idea_repo().await;
+42 -4
View File
@@ -43,10 +43,17 @@ pub struct ProjectQuery {
pub offset: Option<u32>,
}
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
///
/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
/// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else {
@@ -60,8 +67,13 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let parts: Vec<&str> = raw.split_whitespace().collect();
let col = parts[0];
let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
// 白名单校验列名(防 SQL 注入:列名拼字符串前必须校验)。
validate_column_name(col, "projects")?;
// 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
if !PROJECT_ORDER_BY_ALLOWED.contains(&col) {
return Err(df_types::error::Error::Storage(format!(
"非法 order_by 字段名: {col},合法值: {:?}",
PROJECT_ORDER_BY_ALLOWED
)));
}
match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")),
@@ -449,6 +461,32 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "projects")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
///
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
+5 -1
View File
@@ -125,7 +125,11 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
],
"projects" => &[
"id", "name", "description", "status", "idea_id", "path", "stack", "created_at",
// id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (BE-CMD-3,对标 ideas/tasks 白名单同款防护,防篡改主键/伪造创建时间致子表悬空)。
// 注:projects 排序用 created_at 不受影响 — build_order_clause 走独立排序白名单
// (PROJECT_ORDER_BY_ALLOWED,update_field 白名单与排序白名单解耦,同 ideas 模式)。
"name", "description", "status", "idea_id", "path", "stack",
"updated_at",
],
"tasks" => &[
+118 -4
View File
@@ -455,12 +455,20 @@ impl TaskRepo {
///
/// 复用 list_by_query 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
/// 返回满足条件的总行数(忽略分页裁剪)。
///
/// LW-5(BE-CMD-2):补齐 assignee/queue/parent_id/module_id 维度,与 list_by_query
/// 全维度对齐——此前 count 缺四维导致「count 超算、list 空页」翻页不一致
/// (前端分页 total 与页数据对不上)。
pub async fn count_by_query(&self, query: &TaskQuery) -> Result<i64> {
let conn = self.conn.clone();
let project_id = query.project_id.clone();
let status = query.status.clone();
let priority = query.priority;
let assignee = query.assignee.clone();
let keyword = query.keyword.clone();
let queue = query.queue.clone();
let parent_id = query.parent_id.clone();
let module_id = query.module_id.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
@@ -480,6 +488,11 @@ impl TaskRepo {
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(p));
}
// LW-5: assignee 维度(与 list_by_query 同 WHERE 构造,防 count/list 漂移)
if let Some(ref a) = assignee {
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(a.clone()));
}
if let Some(ref kw) = keyword {
let escaped = kw.replace('%', "\\%").replace('_', "\\_");
let pat = format!("%{escaped}%");
@@ -489,6 +502,19 @@ impl TaskRepo {
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
// LW-5: queue / parent_id / module_id 维度(知识图谱 V29 + 工程 V41)
if let Some(ref q) = queue {
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(q.clone()));
}
if let Some(ref pid) = parent_id {
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(pid.clone()));
}
if let Some(ref mid) = module_id {
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(mid.clone()));
}
let sql = format!(
"SELECT COUNT(*) FROM tasks WHERE {}",
@@ -781,11 +807,34 @@ impl TaskRepo {
.await
.map_err(storage_err)?
}
}
// ============================================================
// 单元测试 — 知识图谱 Phase 1:queue/parent_id 筛选 + get_children(内存 DB)
// ============================================================
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站任务仍可改字段),
/// 本方法收口软删防护,供命令层 `update_task` 使用——软删任务(回收站)返回 `false`,
/// 调用方据此报「已删除」,杜绝回收站任务被字段更新复活/改动。
/// 字段名走同款 [`validate_column_name`] 白名单(防注入 + 按表隔离)。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "tasks")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE tasks SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
#[cfg(test)]
mod tests {
@@ -1244,4 +1293,69 @@ mod tests {
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
// ============================================================
// LW-5 count_by_query 维度对齐 list_by_query(防 count/list 漂移翻页)
// ============================================================
#[tokio::test]
async fn count_by_query_matches_list_by_query_dimensions() {
let repo = setup().await;
// 父任务 + 3 子/叶任务,覆盖 queue/assignee/parent_id 三维(module_id 需 FK 另测)
repo.insert(trec("parent", "todo", None)).await.unwrap();
let mut c1 = trec("c1", "backlog", Some("parent"));
c1.assignee = Some("alice".to_string());
let mut c2 = trec("c2", "todo", Some("parent"));
c2.assignee = Some("bob".to_string());
let mut c3 = trec("c3", "active", None);
c3.assignee = Some("alice".to_string());
repo.insert(c1).await.unwrap();
repo.insert(c2).await.unwrap();
repo.insert(c3).await.unwrap();
// 单维度:queue=backlog → 1(c1)
let q = TaskQuery { queue: Some("backlog".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// assignee=alice → 2(c1 + c3)
let q = TaskQuery { assignee: Some("alice".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// parent_id=parent → 2(c1 + c2)
let q = TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() };
assert_eq!(repo.count_by_query(&q).await.unwrap(), 2);
// 组合:queue=backlog AND parent_id=parent → 1(c1)
let q = TaskQuery {
queue: Some("backlog".to_string()),
parent_id: Some("parent".to_string()),
..Default::default()
};
assert_eq!(repo.count_by_query(&q).await.unwrap(), 1);
// 关键契约:count 与 list_by_query 对同一 query 结果数一致(翻页 total 与页数据对齐)
for q in [
TaskQuery { queue: Some("backlog".to_string()), ..Default::default() },
TaskQuery { assignee: Some("alice".to_string()), ..Default::default() },
TaskQuery { parent_id: Some("parent".to_string()), ..Default::default() },
] {
let count = repo.count_by_query(&q).await.unwrap();
let list_len = repo.list_by_query(&q).await.unwrap().len() as i64;
assert_eq!(count, list_len, "count 与 list 维度必须一致,query={q:?}");
}
}
// ============================================================
// LW-6 update_field_active 软删过滤(回收站任务不可改字段)
// ============================================================
#[tokio::test]
async fn update_field_active_skips_soft_deleted() {
let repo = setup().await;
repo.insert(trec("t1", "todo", None)).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("t1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("t1").await.unwrap();
assert!(!repo.update_field_active("t1", "title", "又改").await.unwrap());
let after = repo.get_by_id("t1").await.unwrap().unwrap();
assert_eq!(after.title, "新标题", "软删后字段不应被改动");
}
}