1408 lines
66 KiB
Rust
1408 lines
66 KiB
Rust
//! 任务域 Repo:TaskRepo(含 advance_status_atomic 状态机收口)
|
||
|
||
use std::sync::Arc;
|
||
|
||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||
use serde::{Deserialize, Serialize};
|
||
use tokio::sync::Mutex;
|
||
|
||
use df_types::error::Result;
|
||
use df_types::types::TaskStatus;
|
||
|
||
use crate::db::Database;
|
||
use crate::models::TaskRecord;
|
||
|
||
use super::impl_repo;
|
||
use super::{now_millis_str, storage_err, validate_column_name};
|
||
|
||
// ============================================================
|
||
// from_row 辅助函数
|
||
// ============================================================
|
||
|
||
fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Error> {
|
||
Ok(TaskRecord {
|
||
id: row.get("id")?,
|
||
project_id: row.get("project_id")?,
|
||
title: row.get("title")?,
|
||
description: row.get("description")?,
|
||
status: {
|
||
let s: String = row.get("status")?;
|
||
TaskStatus::from_db_str(&s).unwrap_or_default()
|
||
},
|
||
priority: row.get("priority")?,
|
||
branch_name: row.get("branch_name")?,
|
||
assignee: row.get("assignee")?,
|
||
workflow_def_id: row.get("workflow_def_id")?,
|
||
base_branch: row.get("base_branch")?,
|
||
review_rounds: row.get("review_rounds")?,
|
||
output_json: row.get("output_json")?,
|
||
idea_id: row.get("idea_id")?,
|
||
// 知识图谱 Phase 1 V29 三列(queue/parent_id/content_json),15→18 列同步之一。
|
||
queue: row.get("queue")?,
|
||
parent_id: row.get("parent_id")?,
|
||
content_json: row.get("content_json")?,
|
||
// 工程系统 V41:任务关联具体工程(module_id),18→19 列同步之一。
|
||
module_id: row.get("module_id")?,
|
||
created_at: row.get("created_at")?,
|
||
updated_at: row.get("updated_at")?,
|
||
})
|
||
}
|
||
|
||
// ============================================================
|
||
// 任务列表查询入参(查询维度补全)
|
||
// ============================================================
|
||
|
||
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
|
||
///
|
||
/// 设计对齐方案文档(docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1):
|
||
/// ① 字段全 Option,旧调用方不传 / 传空 → 等价 list_active(全量未删),零破坏;
|
||
/// ② 复用 KnowledgeRepo::search 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 参数绑定);
|
||
/// ③ order_by 走白名单(TASK_ORDER_BY_WHITELIST)防 SQL 注入,对齐
|
||
/// impl_repo! 宏 validate_column_name 的白名单防注入思路;
|
||
/// ④ limit/offset 钳制上限(limit.min(500))防滥用,对齐 conversation_repo::list_recent
|
||
/// 的 limit.min(200)(任务场景放宽至 500,数据量 ~15 任务)。
|
||
///
|
||
/// 当前仅 status(P1 下沉)+ keyword(P2 LIKE)在视图链路使用;project_id/priority/assignee/
|
||
/// order_by/limit/offset 为基建就绪(视图暂不用,P3 排序分页待数据量增长)。
|
||
///
|
||
/// 知识图谱 Phase 1 V29(对标设计 §2.1)新增 queue / parent_id 两个筛选维度:
|
||
/// - `queue`:管理池筛选(backlog/todo/decision/active/done),看板视图按池分列的数据源;
|
||
/// - `parent_id`:父任务筛选(Some(id)=某父的子任务;特殊语义 None 仅叶子 vs 全部 由调用方拼)。
|
||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||
pub struct TaskQuery {
|
||
/// 项目 ID 过滤(SQL 下推,命中 idx_tasks_project_id)
|
||
pub project_id: Option<String>,
|
||
/// 状态过滤(P1 下沉,命中 idx_tasks_status)。值集由上层 TaskStatus::is_valid 兜底校验。
|
||
pub status: Option<String>,
|
||
/// 优先级过滤(0=critical..3=low)。P3 基建就绪,视图暂不用。
|
||
pub priority: Option<i32>,
|
||
/// 负责人过滤。P3 基建就绪,视图暂不用。
|
||
pub assignee: Option<String>,
|
||
/// 关键词搜索(P2):title/description LIKE %kw%,对齐知识库 search 的 LIKE 模式。
|
||
pub keyword: Option<String>,
|
||
/// 管理池过滤(知识图谱 Phase 1 V29):backlog/todo/decision/active/done。
|
||
/// 看板视图按池分列的数据源。值合法性由上层 queue 语义校验兜底(非法值 DB 无匹配返回空)。
|
||
#[serde(default)]
|
||
pub queue: Option<String>,
|
||
/// 父任务 ID 过滤(知识图谱 Phase 1 V29):Some(id) = 查某父任务的子任务;
|
||
/// 查叶子任务(parent_id IS NULL)由专用方法 get_children 之外的语义决定,本字段只做等值匹配。
|
||
#[serde(default)]
|
||
pub parent_id: Option<String>,
|
||
/// 所属工程 ID 过滤(工程系统 V41):Some(id) = 查关联到某 module 的任务。
|
||
/// 工程维度筛选数据源(任务按工程分列/过滤)。
|
||
#[serde(default)]
|
||
pub module_id: Option<String>,
|
||
/// 排序字段(白名单 created_at/updated_at/priority/status,降序)。P3 基建就绪。
|
||
pub order_by: Option<String>,
|
||
/// 分页上限(钳制 ≤500)。P3 基建就绪。
|
||
pub limit: Option<u32>,
|
||
/// 分页偏移。P3 基建就绪。
|
||
pub offset: Option<u32>,
|
||
}
|
||
|
||
/// order_by 白名单:只允许这些列名拼进 SQL(防注入,列名不可参数化只能白名单)。
|
||
/// 对齐 impl_repo! 宏 validate_column_name 的「白名单先于拼接校验」防注入思路。
|
||
/// 注:created_at 是默认值(白名单含它,query 未指定时用 created_at DESC)。
|
||
const TASK_ORDER_BY_WHITELIST: &[&str] = &["created_at", "updated_at", "priority", "status"];
|
||
|
||
/// 校验 order_by 列名在白名单内,否则返回 Err(防 SQL 注入)。
|
||
fn validate_order_by(field: &str) -> Result<()> {
|
||
if TASK_ORDER_BY_WHITELIST.contains(&field) {
|
||
Ok(())
|
||
} else {
|
||
Err(df_types::error::Error::Storage(format!(
|
||
"非法 order_by 字段名: {},合法值: {:?}",
|
||
field, TASK_ORDER_BY_WHITELIST
|
||
)))
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// Repo 实现
|
||
// ============================================================
|
||
|
||
impl_repo!(
|
||
/// 任务表 CRUD
|
||
TaskRepo,
|
||
TaskRecord,
|
||
"tasks",
|
||
from_row => |row| task_from_row(row),
|
||
insert => |conn, rec| {
|
||
conn.execute(
|
||
"INSERT INTO tasks (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)
|
||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)",
|
||
params![
|
||
rec.id, 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.created_at, rec.updated_at
|
||
],
|
||
)
|
||
},
|
||
update => |conn, rec| {
|
||
conn.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",
|
||
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
|
||
],
|
||
)
|
||
}
|
||
);
|
||
|
||
impl TaskRepo {
|
||
/// 列出未删除任务(deleted_at IS NULL)— 对标 ProjectRepo::list_active
|
||
///
|
||
/// 显式列出全部 19 个 TaskRecord 列名(同 ProjectRepo::list_active 写法),
|
||
/// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错。
|
||
pub async fn list_active(&self) -> Result<Vec<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
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 deleted_at IS NULL ORDER BY created_at DESC")
|
||
.map_err(storage_err)?;
|
||
let rows = stmt
|
||
.query_map([], |row| task_from_row(row))
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删任务,返回是否命中。
|
||
/// 对标 ProjectRepo::soft_delete。
|
||
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
||
let conn = self.conn.clone();
|
||
let id = id.to_owned();
|
||
let now = now_millis_str();
|
||
tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
let affected = guard
|
||
.execute(
|
||
"UPDATE tasks SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
|
||
params![now, id],
|
||
)
|
||
.map_err(storage_err)?;
|
||
Ok(affected > 0)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 恢复:清 deleted_at(从回收站还原)。仅作用于已删任务,返回是否命中。
|
||
/// 对标 ProjectRepo::restore。
|
||
pub async fn restore(&self, id: &str) -> Result<bool> {
|
||
let conn = self.conn.clone();
|
||
let id = id.to_owned();
|
||
let now = now_millis_str();
|
||
tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
let affected = guard
|
||
.execute(
|
||
"UPDATE tasks SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
|
||
params![now, id],
|
||
)
|
||
.map_err(storage_err)?;
|
||
Ok(affected > 0)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
|
||
///
|
||
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
|
||
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
|
||
/// (task_advance_node)据此报「状态已变,推进中止」。`review_rounds` 不进通用
|
||
/// update_field 白名单(收口:仅本方法可改 status 与 review_rounds)。
|
||
///
|
||
/// - `expected`:调用方读取的当前 status(状态机校验时的 from),CAS 前置。
|
||
/// - `new_status`:目标 status(状态机 can_transition 已校验合法)。
|
||
/// - `bump_rounds`:退回转换(in_review→in_progress / testing→in_review)传 true,
|
||
/// 一并 `review_rounds = review_rounds + 1`(同 UPDATE 原子,避免读改写竞争)。
|
||
/// 前向推进 / 进出 blocked / 进 cancelled 传 false,不动 review_rounds。
|
||
///
|
||
/// 返回:成功推进返回更新后的 TaskRecord;affected==0(状态已变/任务不存在)返回 None。
|
||
pub async fn advance_status_atomic(
|
||
&self,
|
||
id: &str,
|
||
expected: &str,
|
||
new_status: &str,
|
||
bump_rounds: bool,
|
||
) -> Result<Option<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
let id = id.to_owned();
|
||
let expected = expected.to_owned();
|
||
let new_status = new_status.to_owned();
|
||
let now = now_millis_str();
|
||
tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
// CAS:WHERE id AND status=expected 锁定当前态;affected==0 即并发已改动。
|
||
// deleted_at IS NULL:回收站任务(soft_delete 设了 deleted_at)CAS 必败→affected=0,
|
||
// 返回 None,杜绝回收站任务被推进(D-02 软删语义收口,一处关闭)。
|
||
let sql = if bump_rounds {
|
||
"UPDATE tasks SET status = ?1, review_rounds = review_rounds + 1, updated_at = ?2 \
|
||
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
|
||
} else {
|
||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||
WHERE id = ?3 AND status = ?4 AND deleted_at IS NULL"
|
||
};
|
||
let affected = guard
|
||
.execute(sql, params![new_status, now, id, expected])
|
||
.map_err(storage_err)?;
|
||
if affected == 0 {
|
||
return Ok(None);
|
||
}
|
||
// 回读更新后的记录(含新 status / 累加后的 review_rounds / 新 updated_at)。
|
||
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")
|
||
.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)?
|
||
}
|
||
|
||
/// 按项目列出未删除任务(deleted_at IS NULL AND project_id = ?),按创建时间降序。
|
||
///
|
||
/// SQL 下推 project_id 过滤:替代旧 list_active + 内存 retain 全表扫(任务量增长后
|
||
/// N×M 热点,每页都拉全表进内存再丢)。list_tasks 在有 project_id 时优先走本方法。
|
||
pub async fn list_active_by_project(&self, project_id: &str) -> Result<Vec<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
let pid = project_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 deleted_at IS NULL AND project_id = ?1 ORDER BY created_at DESC")
|
||
.map_err(storage_err)?;
|
||
let rows = stmt
|
||
.query_map(params![pid], |row| task_from_row(row))
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 动态条件列出未删除任务(查询维度补全)。
|
||
///
|
||
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
|
||
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
|
||
/// TaskQuery 有多个过滤维度(project_id/status/priority/assignee/keyword/queue/parent_id/
|
||
/// module_id),2^n 分支不可行,累积式天然支持任意维度组合,且每个 if-let 分支只 push
|
||
/// 子句+参数,新增维度零样板。
|
||
///
|
||
/// - 过滤维度:project_id / status / priority / assignee / queue / parent_id / module_id
|
||
/// (精确等值)+ keyword(title/description LIKE)
|
||
/// - keyword 拼成 `(title LIKE ?N OR description LIKE ?M)`,pattern = `%kw%`(对齐知识库 search)
|
||
/// - order_by 白名单(validate_order_by 防 SQL 注入),默认 created_at,恒 DESC(与 list_active 一致)
|
||
/// - limit/offset 钳制(limit ≤500 防滥用,对齐 conversation_repo::list_recent 的 limit≤200 思路)
|
||
/// - deleted_at IS NULL 恒带(回收站任务不进结果,语义同 list_active,不可被 query 关闭)
|
||
/// - 显式列出全部 19 列(不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 报未知列)
|
||
///
|
||
/// 空 query(全 None)→ 等价 list_active(全量未删,created_at DESC),向后兼容。
|
||
/// status 值合法性由上层 list_tasks 命令(TaskStatus::is_valid)兜底,本层不过滤值集
|
||
/// (非法 status 在 DB 无匹配行,返回空 Vec,无害)。
|
||
pub async fn list_by_query(&self, query: &TaskQuery) -> Result<Vec<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
|
||
// order_by 白名单校验在闭包外做(提前 fail-fast,非法值不进 DB 层)。列名不可
|
||
// 参数化,只能白名单防注入(对齐 impl_repo! 宏 validate_column_name 思路)。
|
||
// order_col 转 String 拥有所有权:避免 &str 借用 query 跨 spawn_blocking 'static
|
||
// 闭包(E0521 borrowed data escapes)。
|
||
let order_col = query.order_by.as_deref().unwrap_or("created_at");
|
||
validate_order_by(order_col)?;
|
||
let order_col = order_col.to_string();
|
||
|
||
// limit 钳制上限 500 防滥用(对齐 conversation_repo::list_recent 的 limit.min(200)
|
||
// 思路,任务场景放宽;None → 不拼 LIMIT = 全量,语义同 list_active)。
|
||
let limit_i: Option<i64> = query.limit.map(|l| (l.min(500)) as i64);
|
||
let offset_i: i64 = query.offset.unwrap_or(0) as i64;
|
||
|
||
// 拷贝 query 字段进闭包('static 生命周期,spawn_blocking 要求)
|
||
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();
|
||
|
||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = params_vec.len()+1)──
|
||
// 占位符序号必须按「实际参数位置」(params_vec.len()+1)而非「子句数」(where_clauses.len()+1)
|
||
// 编号:deleted_at IS NULL 是常量条件无占位符却占 where_clauses[0],用子句数编号会让首个
|
||
// 真参数拿到 ?2 而 params_vec 只有 1 个元素 → rusqlite "needed 2, got 1"。
|
||
// 用 params_vec.len()+1 保证 ?N 与参数位置严格对齐(N = 参数序号)。
|
||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果(语义同 list_active)。
|
||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||
|
||
if let Some(pid) = &project_id {
|
||
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(pid.clone()));
|
||
}
|
||
if let Some(s) = &status {
|
||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(s.clone()));
|
||
}
|
||
if let Some(p) = priority {
|
||
where_clauses.push(format!("priority = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(p));
|
||
}
|
||
if let Some(a) = &assignee {
|
||
where_clauses.push(format!("assignee = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(a.clone()));
|
||
}
|
||
// queue(知识图谱 Phase 1 V29):管理池等值过滤,看板视图按池分列数据源
|
||
if let Some(q) = &queue {
|
||
where_clauses.push(format!("queue = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(q.clone()));
|
||
}
|
||
// parent_id(知识图谱 Phase 1 V29):父任务等值过滤(查某父的子任务)
|
||
if let Some(pid) = &parent_id {
|
||
where_clauses.push(format!("parent_id = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(pid.clone()));
|
||
}
|
||
// module_id(工程系统 V41):所属工程等值过滤(查关联到某 module 的任务)
|
||
if let Some(mid) = &module_id {
|
||
where_clauses.push(format!("module_id = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(mid.clone()));
|
||
}
|
||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||
// ESCAPE 字符用 |(管道符,任务标题/描述几乎不含),不用反斜杠——反斜杠在
|
||
// Rust format! → rusqlite 绑定 → SQLite 多层转义里极易出错(SQLite 报
|
||
// "ESCAPE expression must be a single character"),改 | 一劳永逸。
|
||
// 注意:ESCAPE 只能跟单个 LIKE,不能跟括号分组(实测 `(a OR b) ESCAPE 'x'`
|
||
// 报 near "ESCAPE" syntax error),故每个 LIKE 各自 ESCAPE。
|
||
if let Some(kw) = &keyword {
|
||
let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
|
||
let pat = format!("%{escaped}%");
|
||
let p1 = params_vec.len() + 1;
|
||
let p2 = p1 + 1;
|
||
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
|
||
params_vec.push(Box::new(pat.clone()));
|
||
params_vec.push(Box::new(pat));
|
||
}
|
||
|
||
// LIMIT/OFFSET:limit 为 None → 不拼(全量);有 limit 时 offset 紧跟其后。
|
||
let where_param_count = params_vec.len();
|
||
let limit_sql_bound = match limit_i {
|
||
Some(_) => format!(
|
||
" LIMIT ?{} OFFSET ?{}",
|
||
where_param_count + 1,
|
||
where_param_count + 2
|
||
),
|
||
None => String::new(),
|
||
};
|
||
|
||
// 拼 SQL:?N 占位符序号与 params_vec 顺序严格对应(累积时按 +1 递增保证)。
|
||
// 显式列出全部 19 列(含 V29 queue/parent_id/content_json 与 V41 module_id,
|
||
// 不 SELECT deleted_at:TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
||
let sql = format!(
|
||
"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 {} ORDER BY {} DESC{}",
|
||
where_clauses.join(" AND "),
|
||
order_col,
|
||
limit_sql_bound
|
||
);
|
||
|
||
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||
|
||
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
|
||
// limit/offset 也压入 params_vec 收口:借用引用需指向同一生命周期存活处,
|
||
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597,
|
||
// 对齐 idea_repo.rs list_by_query 同名模式)。
|
||
if let Some(l) = limit_i {
|
||
params_vec.push(Box::new(l));
|
||
params_vec.push(Box::new(offset_i));
|
||
}
|
||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||
|
||
let rows = stmt
|
||
.query_map(param_refs.as_slice(), |row| task_from_row(row))
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 按 TaskQuery 条件计数(不含 limit/offset,用于分页 total)。
|
||
///
|
||
/// 复用 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();
|
||
|
||
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string()];
|
||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||
|
||
if let Some(ref pid) = project_id {
|
||
where_clauses.push(format!("project_id = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(pid.clone()));
|
||
}
|
||
if let Some(ref s) = status {
|
||
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||
params_vec.push(Box::new(s.clone()));
|
||
}
|
||
if let Some(p) = priority {
|
||
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('%', "|%").replace('_', "|_");
|
||
let pat = format!("%{escaped}%");
|
||
let p1 = params_vec.len() + 1;
|
||
let p2 = p1 + 1;
|
||
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
|
||
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 {}",
|
||
where_clauses.join(" AND ")
|
||
);
|
||
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||
let count: i64 = guard
|
||
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
|
||
.map_err(storage_err)?;
|
||
Ok(count)
|
||
})
|
||
.await
|
||
.map_err(|e| storage_err(format!("count_by_query join failed: {e}")))?
|
||
}
|
||
|
||
/// 查某父任务的全部子任务(parent_id = ?,deleted_at IS NULL),按创建时间升序。
|
||
///
|
||
/// 知识图谱 Phase 1 V29(对标设计 §2.1 父任务聚合规则):父任务=容器模型,status
|
||
/// 不走状态机,由子任务聚合计算。本方法取子任务列表供聚合规则消费。
|
||
///
|
||
/// 注:嵌套深度限制 1 级(无孙任务)由 IPC 层校验不进 DB 约束,本方法只做等值查询。
|
||
pub async fn get_children(&self, parent_id: &str) -> Result<Vec<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
let pid = parent_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 deleted_at IS NULL AND parent_id = ?1 ORDER BY created_at ASC")
|
||
.map_err(storage_err)?;
|
||
let rows = stmt
|
||
.query_map(params![pid], |row| task_from_row(row))
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 父任务聚合:按 status 分组统计子任务计数(对标设计 §2.1 聚合规则)。
|
||
///
|
||
/// 父任务 status 由子任务聚合计算(不走状态机),聚合规则:
|
||
/// - 全 todo → 父 todo
|
||
/// - 任一 in_progress → 父 in_progress
|
||
/// - 任一 blocked → 父 blocked
|
||
/// - 全 done/cancelled → 父 done
|
||
///
|
||
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
|
||
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
|
||
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标跨表校验下沉思路)。
|
||
pub async fn count_children_by_status(
|
||
&self,
|
||
parent_id: &str,
|
||
) -> Result<Vec<(String, i64)>> {
|
||
let conn = self.conn.clone();
|
||
let pid = parent_id.to_owned();
|
||
tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
let mut stmt = guard
|
||
.prepare(
|
||
"SELECT status, COUNT(*) AS cnt FROM tasks \
|
||
WHERE deleted_at IS NULL AND parent_id = ?1 \
|
||
GROUP BY status",
|
||
)
|
||
.map_err(storage_err)?;
|
||
let rows = stmt
|
||
.query_map(params![pid], |row| {
|
||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||
})
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 父任务聚合专用 status 写入(知识图谱 Phase 1 V29,对标设计 §2.1 D3 父任务=容器模型)。
|
||
///
|
||
/// **这是父任务 status 的唯一写入路径**,绕过 D-260616-04 status 收口(通用 update_field
|
||
/// 白名单不含 status,所有叶子任务 status 走 advance_status_atomic 状态机)。父任务 status
|
||
/// **不走状态机**(容器模型,由子任务聚合计算),故需专用写入路径。
|
||
///
|
||
/// 防护:
|
||
/// - 方法名 `set_status_for_aggregation` 显式表明语义,非通用 setter,防误用。
|
||
/// - 调用方(df-nodes task_advance_node::recompute_parent_status,2026-08-04 下沉共享层)负责
|
||
/// 聚合规则计算,本方法只落库。
|
||
/// - 不动 review_rounds(父任务不执行工作流,无 review 退回语义)。
|
||
///
|
||
/// 返回是否命中(父任务不存在/已删 → false)。
|
||
pub async fn set_status_for_aggregation(&self, id: &str, new_status: &str) -> Result<bool> {
|
||
let conn = self.conn.clone();
|
||
let id = id.to_owned();
|
||
let new_status = new_status.to_owned();
|
||
let now = now_millis_str();
|
||
tokio::task::spawn_blocking(move || {
|
||
let guard = conn.blocking_lock();
|
||
let affected = guard
|
||
.execute(
|
||
"UPDATE tasks SET status = ?1, updated_at = ?2 \
|
||
WHERE id = ?3 AND deleted_at IS NULL",
|
||
params![new_status, now, id],
|
||
)
|
||
.map_err(storage_err)?;
|
||
Ok(affected > 0)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 跨池移动任务(单事务原子:读当前 → 一致性联动 status → 写 queue + status)。
|
||
///
|
||
/// 整合原 IPC 层(src-tauri commands::task::move_task_queue)与 AI 工具
|
||
/// (src-tauri commands::ai::tools::task_graph)各自的两段式
|
||
/// (`update_field` queue + `set_status_for_aggregation` status)为**单一 repo 方法**:
|
||
/// 一次 `blocking_lock` 内 get + update queue + update status 于**同一 transaction**,
|
||
/// 杜绝「queue 已改、status 未改」的非原子中间态(G1.4)。两调用方共用本方法防漂移。
|
||
///
|
||
/// **不能 naive 在 tx 内复用既有 repo 方法**(各自内部 `blocking_lock`,std Mutex
|
||
/// 非重入必死锁),故本方法用裸 SQL 在事务内完成全部读写。
|
||
///
|
||
/// 一致性约束联动(设计 §2.1):
|
||
/// - queue=done → status 强制=done(池完成即任务完成)
|
||
/// - queue=backlog → status 强制=todo(需求池任务尚未开始)
|
||
/// - queue=active → status 若不在 {in_progress,in_review,testing} 则强制=in_progress
|
||
/// - queue=todo → status 非 todo 则强制=todo(待办池任务尚未开始)
|
||
/// - queue=decision → status 不变(待决策池保留执行态,暂停推进不重置)
|
||
///
|
||
/// 父任务(容器模型)也可 move_task_queue(其 status 由聚合规则 recompute_parent_status
|
||
/// 在子任务推进时重算,本方法仅满足一致性约束联动,不动 review_rounds)。
|
||
///
|
||
/// - queue 白名单校验(bad_queue 防进 `_ => unreachable!` match)收口在方法内;
|
||
/// - `deleted_at IS NULL` 收口:软删回收站任务不可 move(与 set_status_for_aggregation
|
||
/// 语义一致,返回 None);
|
||
/// - 返回:更新后的 TaskRecord(Some);任务不存在/已软删 → None(调用方据此报「任务不存在」)。
|
||
pub async fn move_task_queue(
|
||
&self,
|
||
id: &str,
|
||
new_queue: &str,
|
||
) -> Result<Option<TaskRecord>> {
|
||
// queue 白名单校验(对标 commands::task::validate_queue,防非法值进 match unreachable)。
|
||
// 常量与联动规则集中在 Repo 层,commands/task.rs 与 ai/tools/task_graph.rs 两调用方
|
||
// 共用同一方法(防漂移),不再各自实现。
|
||
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
|
||
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
|
||
if !TASK_QUEUE_VALUES.contains(&new_queue) {
|
||
return Err(df_types::error::Error::Validation(format!(
|
||
"非法 queue 值 {:?},合法值: {:?}",
|
||
new_queue, TASK_QUEUE_VALUES
|
||
)));
|
||
}
|
||
|
||
let conn = self.conn.clone();
|
||
let id = id.to_owned();
|
||
let new_queue = new_queue.to_owned();
|
||
let now = now_millis_str();
|
||
// 显式列出全部 19 列(同 from_row 消费列,不 SELECT deleted_at:
|
||
// TaskRecord 不带该字段,取了 from_row 会因未知列报错)。
|
||
const TASK_COLS: &str = "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";
|
||
tokio::task::spawn_blocking(move || {
|
||
let mut guard = conn.blocking_lock();
|
||
let tx = guard.transaction().map_err(storage_err)?;
|
||
|
||
// 1. 读当前(取 status 做一致性联动决策;deleted_at IS NULL 收口软删任务不可 move)。
|
||
let current: Option<TaskRecord> = {
|
||
let mut stmt = tx
|
||
.prepare(&format!(
|
||
"SELECT {TASK_COLS} FROM tasks WHERE id = ?1 AND deleted_at IS NULL"
|
||
))
|
||
.map_err(storage_err)?;
|
||
stmt.query_row(params![id], |row| task_from_row(row))
|
||
.optional()
|
||
.map_err(storage_err)?
|
||
};
|
||
let Some(current) = current else {
|
||
return Ok(None);
|
||
};
|
||
|
||
// 2. 一致性联动:根据 new_queue 决定 status 是否需调整(设计 §2.1)。
|
||
let new_status = match new_queue.as_str() {
|
||
"done" => "done".to_string(),
|
||
"backlog" => "todo".to_string(),
|
||
"active" => {
|
||
if ACTIVE_OK_STATUSES.contains(¤t.status.as_str()) {
|
||
current.status.as_str().to_string() // 已在执行中三态,保留
|
||
} else {
|
||
"in_progress".to_string() // 否则强制进 in_progress
|
||
}
|
||
}
|
||
"todo" => "todo".to_string(),
|
||
"decision" => current.status.as_str().to_string(), // 保留执行态
|
||
_ => unreachable!("queue 白名单已收口"),
|
||
};
|
||
|
||
// 3. 同一事务内写 queue + status(与 current 不同才写,避免无谓 updated_at 抖动)。
|
||
let queue_changed = current.queue != new_queue;
|
||
let status_changed = current.status.as_str() != new_status;
|
||
if queue_changed || status_changed {
|
||
tx.execute(
|
||
"UPDATE tasks SET queue = ?1, status = ?2, updated_at = ?3 WHERE id = ?4",
|
||
params![new_queue, new_status, now, id],
|
||
)
|
||
.map_err(storage_err)?;
|
||
}
|
||
|
||
// 4. 回读最新记录返回。
|
||
let updated: Option<TaskRecord> = {
|
||
let mut stmt = tx
|
||
.prepare(&format!("SELECT {TASK_COLS} FROM tasks WHERE id = ?1"))
|
||
.map_err(storage_err)?;
|
||
stmt.query_row(params![id], |row| task_from_row(row))
|
||
.optional()
|
||
.map_err(storage_err)?
|
||
};
|
||
|
||
tx.commit().map_err(storage_err)?;
|
||
Ok(updated)
|
||
})
|
||
.await
|
||
.map_err(storage_err)?
|
||
}
|
||
|
||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||
///
|
||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||
/// 无 pid 时 fallback list_active。
|
||
pub async fn list_deleted(&self) -> Result<Vec<TaskRecord>> {
|
||
let conn = self.conn.clone();
|
||
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 deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
||
.map_err(storage_err)?;
|
||
let rows = stmt
|
||
.query_map([], |row| task_from_row(row))
|
||
.map_err(storage_err)?;
|
||
let mut results = Vec::new();
|
||
for r in rows {
|
||
results.push(r.map_err(storage_err)?);
|
||
}
|
||
Ok(results)
|
||
})
|
||
.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)?
|
||
}
|
||
|
||
/// 更新单字段,**仅作用于未软删记录**(`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 {
|
||
use super::*;
|
||
use crate::crud::{ProjectModuleRepo, ProjectRepo};
|
||
use crate::models::{ProjectModuleRecord, ProjectRecord};
|
||
use df_types::types::{ProjectStatus, TaskStatus};
|
||
|
||
/// 构造一条 TaskRecord fixture(queue/parent_id/status 可定制,V29 新维度 + 聚合测试用 status)。
|
||
fn trec(id: &str, queue: &str, parent_id: Option<&str>) -> TaskRecord {
|
||
trec_full(id, queue, parent_id, TaskStatus::Todo)
|
||
}
|
||
|
||
/// 全参 fixture(聚合测试需自定义 status 时用)。
|
||
fn trec_full(id: &str, queue: &str, parent_id: Option<&str>, status: TaskStatus) -> TaskRecord {
|
||
TaskRecord {
|
||
id: id.to_string(),
|
||
project_id: "proj-1".to_string(),
|
||
title: format!("task-{id}"),
|
||
description: String::new(),
|
||
status,
|
||
priority: 1,
|
||
branch_name: None,
|
||
assignee: None,
|
||
workflow_def_id: None,
|
||
base_branch: None,
|
||
review_rounds: 0,
|
||
output_json: None,
|
||
idea_id: None,
|
||
queue: queue.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(),
|
||
}
|
||
}
|
||
|
||
/// 构造内存 DB + 占位 project(满足 tasks.project_id FK,PRAGMA foreign_keys=ON)。
|
||
async fn setup() -> TaskRepo {
|
||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||
let project_repo = ProjectRepo::new(&db);
|
||
project_repo
|
||
.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)
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_queue_filter() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("t1", "backlog", None)).await.unwrap();
|
||
repo.insert(trec("t2", "todo", None)).await.unwrap();
|
||
repo.insert(trec("t3", "todo", None)).await.unwrap();
|
||
repo.insert(trec("t4", "done", None)).await.unwrap();
|
||
|
||
// queue=todo → 只返回 t2/t3
|
||
let q = TaskQuery {
|
||
queue: Some("todo".to_string()),
|
||
..Default::default()
|
||
};
|
||
let res = repo.list_by_query(&q).await.unwrap();
|
||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids.len(), 2);
|
||
assert!(ids.contains(&"t2"));
|
||
assert!(ids.contains(&"t3"));
|
||
|
||
// queue=backlog → 只返回 t1
|
||
let q = TaskQuery {
|
||
queue: Some("backlog".to_string()),
|
||
..Default::default()
|
||
};
|
||
let res = repo.list_by_query(&q).await.unwrap();
|
||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids, vec!["t1"]);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_parent_id_filter() {
|
||
let repo = setup().await;
|
||
// parent 父任务(叶子,parent_id=None)+ 3 子任务(parent_id=parent)
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||
|
||
// parent_id=parent → 只返回 c1/c2(父任务自身不匹配)
|
||
let q = TaskQuery {
|
||
parent_id: Some("parent".to_string()),
|
||
..Default::default()
|
||
};
|
||
let res = repo.list_by_query(&q).await.unwrap();
|
||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids.len(), 2);
|
||
assert!(ids.contains(&"c1"));
|
||
assert!(ids.contains(&"c2"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_queue_and_parent_id_combined() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("c2", "backlog", Some("parent"))).await.unwrap();
|
||
|
||
// queue=todo AND parent_id=parent → 只 c1(c2 是 backlog)
|
||
let q = TaskQuery {
|
||
queue: Some("todo".to_string()),
|
||
parent_id: Some("parent".to_string()),
|
||
..Default::default()
|
||
};
|
||
let res = repo.list_by_query(&q).await.unwrap();
|
||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids, vec!["c1"]);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_empty_returns_all_active() {
|
||
// 空 query(全 None)→ 等价 list_active 全量未删,向后兼容
|
||
let repo = setup().await;
|
||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||
repo.insert(trec("t2", "backlog", None)).await.unwrap();
|
||
|
||
let res = repo.list_by_query(&TaskQuery::default()).await.unwrap();
|
||
assert_eq!(res.len(), 2);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_module_id_filter() {
|
||
// 工程系统 V41:按 module_id 等值过滤任务。
|
||
// 需先建 project_modules 行满足 tasks.module_id FK(PRAGMA foreign_keys=ON)。
|
||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||
let project_repo = ProjectRepo::new(&db);
|
||
project_repo
|
||
.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();
|
||
let module_repo = ProjectModuleRepo::new(&db);
|
||
module_repo
|
||
.insert(ProjectModuleRecord {
|
||
id: "mod-1".to_string(),
|
||
project_id: "proj-1".to_string(),
|
||
name: "backend".to_string(),
|
||
path: "/repo/backend".to_string(),
|
||
git_url: None,
|
||
stack: None,
|
||
auto_detected: false,
|
||
sort_order: 0,
|
||
created_at: "1700000000000".to_string(),
|
||
updated_at: "1700000000000".to_string(),
|
||
description: None,
|
||
status: None,
|
||
})
|
||
.await
|
||
.unwrap();
|
||
let repo = TaskRepo::new(&db);
|
||
|
||
// 2 个任务关联 mod-1,1 个无关联工程
|
||
let mut t1 = trec("t1", "todo", None);
|
||
t1.module_id = Some("mod-1".to_string());
|
||
let mut t2 = trec("t2", "todo", None);
|
||
t2.module_id = Some("mod-1".to_string());
|
||
repo.insert(t1).await.unwrap();
|
||
repo.insert(t2).await.unwrap();
|
||
repo.insert(trec("t3", "todo", None)).await.unwrap();
|
||
|
||
let q = TaskQuery {
|
||
module_id: Some("mod-1".to_string()),
|
||
..Default::default()
|
||
};
|
||
let res = repo.list_by_query(&q).await.unwrap();
|
||
let ids: Vec<_> = res.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids.len(), 2, "module_id 过滤应只返回关联 mod-1 的任务");
|
||
assert!(ids.contains(&"t1"));
|
||
assert!(ids.contains(&"t2"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_children_returns_only_direct_children() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("c2", "in_progress", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("orphan", "todo", None)).await.unwrap();
|
||
|
||
let children = repo.get_children("parent").await.unwrap();
|
||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids.len(), 2);
|
||
assert!(ids.contains(&"c1"));
|
||
assert!(ids.contains(&"c2"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_children_empty_when_no_children() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
let children = repo.get_children("parent").await.unwrap();
|
||
assert!(children.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn count_children_by_status_groups() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
|
||
.await
|
||
.unwrap();
|
||
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
|
||
.await
|
||
.unwrap();
|
||
|
||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||
// 转 map 便于断言(顺序由 GROUP BY 决定,不依赖)。父任务自身不计入(parent_id 非自身)。
|
||
let map: std::collections::HashMap<String, i64> = counts.into_iter().collect();
|
||
assert_eq!(map.get("todo"), Some(&2));
|
||
assert_eq!(map.get("in_progress"), Some(&1));
|
||
assert_eq!(map.get("done"), Some(&1));
|
||
assert!(!map.contains_key("cancelled"));
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn count_children_by_status_empty_when_no_children() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
let counts = repo.count_children_by_status("parent").await.unwrap();
|
||
assert!(counts.is_empty());
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn get_children_excludes_soft_deleted() {
|
||
// 软删子任务不进 get_children 结果(deleted_at IS NULL 过滤)
|
||
let repo = setup().await;
|
||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||
repo.insert(trec("c1", "todo", Some("parent"))).await.unwrap();
|
||
repo.insert(trec("c2", "todo", Some("parent"))).await.unwrap();
|
||
repo.soft_delete("c2").await.unwrap();
|
||
|
||
let children = repo.get_children("parent").await.unwrap();
|
||
let ids: Vec<_> = children.iter().map(|r| r.id.as_str()).collect();
|
||
assert_eq!(ids, vec!["c1"], "软删子任务应被过滤");
|
||
}
|
||
|
||
// ============================================================
|
||
// 父聚合专用 status 写入:set_status_for_aggregation(知识图谱 Phase 1 V29)
|
||
// 父任务=容器模型,status 不走状态机,由子任务聚合计算后经此方法落库。
|
||
// 锁定:① 写入命中 + status 变更;② 不动 review_rounds;③ 软删任务返回 false。
|
||
// ============================================================
|
||
|
||
#[tokio::test]
|
||
async fn set_status_for_aggregation_writes_status() {
|
||
let repo = setup().await;
|
||
// 父任务初始 todo(queue=todo, parent_id=None 容器模型)
|
||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||
assert!(ok, "应命中写入");
|
||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
||
assert_eq!(after.review_rounds, 0, "父聚合写入不动 review_rounds");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn set_status_for_aggregation_skips_soft_deleted() {
|
||
// 软删任务(回收站)不进聚合写入(WHERE deleted_at IS NULL),返回 false
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
repo.soft_delete("parent").await.unwrap();
|
||
let ok = repo.set_status_for_aggregation("parent", "done").await.unwrap();
|
||
assert!(!ok, "软删任务不应被聚合写入命中");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn set_status_for_aggregation_nonexistent_returns_false() {
|
||
let repo = setup().await;
|
||
let ok = repo.set_status_for_aggregation("ghost", "done").await.unwrap();
|
||
assert!(!ok, "不存在的任务应返回 false");
|
||
}
|
||
|
||
// ============================================================
|
||
// move_task_queue 单事务跨池移动(G1.4:读当前 → 联动 status → 写 queue+status 原子)
|
||
// ============================================================
|
||
|
||
/// 移动后断言 queue/status 双落地(单事务原子,非两段式独立写)。
|
||
#[tokio::test]
|
||
async fn move_task_queue_done_sets_queue_and_status_atomically() {
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "active", None, TaskStatus::InProgress))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "done").await.unwrap().unwrap();
|
||
assert_eq!(updated.queue, "done", "queue 应改为 done");
|
||
assert_eq!(updated.status.as_str(), "done", "status 应联动强制 done");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_backlog_forces_todo_status() {
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "active", None, TaskStatus::InProgress))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "backlog").await.unwrap().unwrap();
|
||
assert_eq!(updated.queue, "backlog");
|
||
assert_eq!(updated.status.as_str(), "todo", "backlog 池 status 强制 todo");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_active_preserves_executing_status() {
|
||
// active 池:已在执行中三态(in_progress)则保留,不重置
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "backlog", None, TaskStatus::InProgress))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "active").await.unwrap().unwrap();
|
||
assert_eq!(updated.queue, "active");
|
||
assert_eq!(updated.status.as_str(), "in_progress", "active 池保留执行中三态");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_active_forces_in_progress_when_idle() {
|
||
// active 池:非执行中三态(todo)→ 强制 in_progress
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "active").await.unwrap().unwrap();
|
||
assert_eq!(updated.queue, "active");
|
||
assert_eq!(updated.status.as_str(), "in_progress", "非执行态进 active 强制 in_progress");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_decision_preserves_status() {
|
||
// decision 池保留当前执行态(不重置)
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "active", None, TaskStatus::InReview))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "decision").await.unwrap().unwrap();
|
||
assert_eq!(updated.queue, "decision");
|
||
assert_eq!(updated.status.as_str(), "in_review", "decision 池保留执行态");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_soft_deleted_returns_none() {
|
||
// 软删回收站任务不可 move(deleted_at IS NULL 收口,与 set_status_for_aggregation 一致)
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
repo.soft_delete("t1").await.unwrap();
|
||
let res = repo.move_task_queue("t1", "done").await.unwrap();
|
||
assert!(res.is_none(), "软删任务 move 应返回 None");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_nonexistent_returns_none() {
|
||
let repo = setup().await;
|
||
let res = repo.move_task_queue("ghost", "done").await.unwrap();
|
||
assert!(res.is_none(), "不存在的任务 move 应返回 None");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_invalid_queue_rejected() {
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
let err = repo.move_task_queue("t1", "bogus").await.unwrap_err();
|
||
assert!(matches!(err, df_types::error::Error::Validation(_)), "非法 queue 应拒绝");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn move_task_queue_same_queue_noop() {
|
||
// 同池 no-op:queue/status 均不变,updated_at 不抖动(updated 记录仍正常返回)
|
||
let repo = setup().await;
|
||
repo.insert(trec_full("t1", "todo", None, TaskStatus::Todo))
|
||
.await
|
||
.unwrap();
|
||
let updated = repo.move_task_queue("t1", "todo").await.unwrap().unwrap();
|
||
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, ¤t.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");
|
||
}
|
||
|
||
// ============================================================
|
||
// 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, "新标题", "软删后字段不应被改动");
|
||
}
|
||
|
||
// ============================================================
|
||
// keyword LIKE 查询(ESCAPE 转义)—— 防 2026-08-11 语法回归
|
||
// ============================================================
|
||
// 背景:旧写法 `(a LIKE ?1 OR b LIKE ?2) ESCAPE '|'`(ESCAPE 跟括号分组)在 SQLite
|
||
// 报 near "ESCAPE" syntax error,keyword 查询全挂。正确写法:ESCAPE 跟每个 LIKE。
|
||
// 本测试锁两种语义:普通子串匹配 + 含 %/_ 通配符字面匹配(转义生效)。
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_keyword_matches_substring() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||
repo.insert(trec("t2", "todo", None)).await.unwrap();
|
||
// 定制 title:t1 含「支付」,t2 不含
|
||
repo.update_field_active("t1", "title", "海外支付集成").await.unwrap();
|
||
|
||
let rows = repo
|
||
.list_by_query(&TaskQuery { keyword: Some("支付".into()), ..Default::default() })
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(rows.len(), 1, "keyword 子串应只命中 t1");
|
||
assert_eq!(rows[0].id, "t1");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn list_by_query_keyword_escapes_wildcards() {
|
||
let repo = setup().await;
|
||
repo.insert(trec("t1", "todo", None)).await.unwrap();
|
||
repo.insert(trec("t2", "todo", None)).await.unwrap();
|
||
// t1 标题含字面 % 与 _(通配符需转义,按字面匹配)
|
||
repo.update_field_active("t1", "title", "比率 100%_cache").await.unwrap();
|
||
repo.update_field_active("t2", "title", "比率 100x_cache").await.unwrap();
|
||
|
||
// 查询字面 "%_"(含两个通配符,转义后应按字面匹配 t1;t2 的 x 不匹配 %)
|
||
let rows = repo
|
||
.list_by_query(&TaskQuery { keyword: Some("100%_".into()), ..Default::default() })
|
||
.await
|
||
.unwrap();
|
||
assert_eq!(rows.len(), 1, "% 与 _ 应被转义为字面,只命中 t1");
|
||
assert_eq!(rows[0].id, "t1");
|
||
}
|
||
}
|