新增: 三实体列表查询维度补全(status/关键词/排序/分页下沉后端)
任务/项目/灵感三实体新增 list_by_query 动态 WHERE(累积式 where_clauses
+params_vec 收口)+ order_by 白名单防注入 + limit/offset 钳制。命令层
list_{tasks,projects,ideas} 吃 Option<XxxQuery> 双参向后兼容(旧无参/单参
路径等价全量)。前端 Tasks/Ideas status/keyword 筛选下沉后端 query。
F-260621-02
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_types::error::Result;
|
||||
@@ -37,6 +38,59 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务列表查询入参(F-260621-02 查询维度补全)
|
||||
// ============================================================
|
||||
|
||||
/// 任务列表动态查询入参。全可选,空 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 排序分页待数据量增长)。
|
||||
#[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>,
|
||||
/// 排序字段(白名单 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 实现
|
||||
// ============================================================
|
||||
@@ -218,6 +272,127 @@ impl TaskRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
|
||||
///
|
||||
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
|
||||
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
|
||||
/// TaskQuery 有 4 个过滤维度(project_id/status/priority/assignee/keyword),2^n 分支不可行,
|
||||
/// 累积式天然支持任意维度组合,且每个 if-let 分支只 push 子句+参数,新增维度零样板。
|
||||
///
|
||||
/// - 过滤维度:project_id / status / priority / assignee(精确等值)+ 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 关闭)
|
||||
/// - 显式列出全部 15 列(不 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();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// ── 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符 ?N,序号 = 已有子句数+1)──
|
||||
// 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 = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(pid.clone()));
|
||||
}
|
||||
if let Some(s) = &status {
|
||||
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(s.clone()));
|
||||
}
|
||||
if let Some(p) = priority {
|
||||
where_clauses.push(format!("priority = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(p));
|
||||
}
|
||||
if let Some(a) = &assignee {
|
||||
where_clauses.push(format!("assignee = ?{}", where_clauses.len() + 1));
|
||||
params_vec.push(Box::new(a.clone()));
|
||||
}
|
||||
// keyword: title/description LIKE %kw%(P2,对齐知识库 search 的 LIKE 模式)
|
||||
if let Some(kw) = &keyword {
|
||||
let pat = format!("%{kw}%");
|
||||
let p1 = where_clauses.len() + 1;
|
||||
let p2 = p1 + 1;
|
||||
where_clauses.push(format!("(title LIKE ?{p1} OR description LIKE ?{p2})"));
|
||||
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 递增保证)。
|
||||
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, \
|
||||
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)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。对标 ProjectRepo::list_deleted。
|
||||
///
|
||||
/// 注:按项目列活跃任务走 list_active_by_project(SQL 下推 project_id),
|
||||
|
||||
Reference in New Issue
Block a user