新增: 三实体列表查询维度补全(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:
2026-06-22 01:03:37 +08:00
parent 72e57c6232
commit c9b6e28433
18 changed files with 878 additions and 76 deletions

View File

@@ -131,6 +131,53 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
// Repo 实现
// ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
// ============================================================
/// 灵感多条件查询入参。
///
/// 所有字段可选;全 None → 等价 `list_all`(向后兼容旧全量调用)。
/// 设计对齐 `查询能力补全方案-2026-06-21.md` 4.1:可选字段 struct 而非逐个加 IPC 参数,
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 拼接模式(if-let 分支拼 SQL + 分支化参数绑定)。
///
/// - `status`:状态精确匹配(走 `idx_tasks_status` 同类索引语义,后端 WHERE 收口前端 filter)
/// - `keyword`:`title LIKE %kw% OR description LIKE %kw%`(对齐知识库 LIKE 检索,不上 FTS5)
/// - `order_by`:白名单枚举(`created_at`/`updated_at`/`priority`/`status`/`score`,
/// 见 `validate_idea_order_by`,防 SQL 注入;不进字符串拼接)
/// - `limit`/`offset`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)
///
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
/// `Default`:命令层兼容旧 `status` 单参数路径(构造 `IdeaQuery { status, ..Default }`)。
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct IdeaQuery {
pub status: Option<String>,
pub keyword: Option<String>,
pub order_by: Option<String>,
pub limit: Option<u32>,
pub offset: Option<u32>,
}
/// `order_by` 白名单校验(防 SQL 注入,对齐 `impl_repo!` 宏 `validate_column_name` 思路)。
///
/// 列名直进 SQL 字符串(`ORDER BY {col} DESC`),故必须白名单枚举校验,不接受任意字符串。
/// 允许的排序列:created_at(默认/新在前)、updated_at(最近更新)、priority(优先级)、
/// status(状态聚合)、score(评分,后端 NULL 视为 0)。
fn validate_idea_order_by(col: &str) -> df_types::error::Result<&'static str> {
Ok(match col {
"created_at" => "created_at",
"updated_at" => "updated_at",
"priority" => "priority",
"status" => "status",
"score" => "score",
_ => {
return Err(df_types::error::Error::Storage(format!(
"ideas order_by 不允许的字段名: {col}"
)))
}
})
}
impl_repo!(
/// 想法表 CRUD
IdeaRepo,
@@ -193,6 +240,109 @@ impl_repo!(
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
///
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
/// 拼入(防 SQL 注入),`limit` 钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`)。
///
/// **向后兼容**:空 query(全 None)→ `WHERE` 子句空 + 默认 `created_at DESC`,等价 `list_all`。
///
/// `score DESC NULLS LAST`:SQLite NULL 在 ASC 升序最前、DESC 降序最后,但为与前端原
/// computed 语义一致(`(b.score ?? 0) - (a.score ?? 0)`,NULL 当 0),显式 `COALESCE(score,0)`
/// 把 NULL 当 0 排序,避免 NULL 意外下沉到结果末尾。
pub async fn list_by_query(&self, q: &IdeaQuery) -> Result<Vec<IdeaRecord>> {
// 解析 + 校验 order_by(默认 created_at DESC)
let order_col = match &q.order_by {
Some(o) => validate_idea_order_by(o)?,
None => "created_at",
};
let conn = self.conn.clone();
// 所有按值移动进闭包的量,提前 clone 避免 move 借用问题
let status = q.status.clone();
let keyword = q.keyword.clone();
// 钳制 limit 上限 200(对齐 list_recent),默认不限制分页(None → 不拼 LIMIT)
let limit_i: Option<i64> = q.limit.map(|l| (l.min(200)) as i64);
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符)
let mut where_clauses: Vec<String> = Vec::new();
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(s) = &status {
where_clauses.push(format!("status = ?{}", where_clauses.len() + 1));
params_vec.push(Box::new(s.clone()));
}
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));
}
let where_sql = if where_clauses.is_empty() {
String::new()
} else {
format!(" WHERE {}", where_clauses.join(" AND "))
};
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
let order_expr = if order_col == "score" {
"COALESCE(score, 0) DESC".to_string()
} else {
format!("{order_col} DESC")
};
// LIMIT/OFFSET:limit 为 None → 不拼(全量),offset 仅在 limit 存在时有意义。
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(),
};
let sql = format!(
"SELECT {IDEA_COLS} FROM ideas{where_sql} ORDER BY {order_expr}{limit_sql_bound}"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
// 组装参数引用数组(where 参数 + 可选 limit/offset)。
// limit/offset 也压入 Box<Vec> 收口:借用引用需指向同一生命周期存活处,
// 收口到 params_vec 后再统一取引用,避免局部 l 生命周期不足(E0597)。
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| idea_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)?
}
}
impl KnowledgeRepo {
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
///

View File

@@ -15,6 +15,62 @@ use crate::models::{
use super::impl_repo;
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
// ============================================================
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
// ============================================================
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
///
/// 对齐方案 docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1 统一查询结构。
/// 项目表无 status 生命周期(P1 跳过),仅软删 deleted_at(由 list_by_query 固定排除)。
/// 故本结构不含 status,只含 keyword/order_by/limit/offset 四维。
///
/// 序列化 derive:命令层经 serde 从前端 IPC 接收(Option 字段缺省 None,旧调用方不传等价全量)。
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct ProjectQuery {
/// 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词)
#[serde(default)]
pub keyword: Option<String>,
/// 排序字段(白名单校验,格式 "col" / "col asc" / "col desc",默认 created_at desc)
#[serde(default)]
pub order_by: Option<String>,
/// 返回上限(钳制 [0,200],None = 不加 LIMIT 全量)
#[serde(default)]
pub limit: Option<u32>,
/// 偏移量(None = 0,配合 limit 分页)
#[serde(default)]
pub offset: Option<u32>,
}
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
///
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `validate_column_name`
/// 校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
let Some(raw) = order_by else {
return Ok("created_at DESC".to_string());
};
let raw = raw.trim();
if raw.is_empty() {
return Ok("created_at DESC".to_string());
}
// 拆 "col [dir]",最多 2 段。
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")?;
match dir.as_deref() {
None | Some("DESC") => Ok(format!("{col} DESC")),
Some("ASC") => Ok(format!("{col} ASC")),
// 其它非法方向词:拒绝(防 "; DROP TABLE" 之类拼串绕过)。
Some(other) => Err(df_types::error::Error::Storage(format!(
"非法排序方向: {other}(仅支持 asc/desc)"
))),
}
}
// ============================================================
// from_row 辅助函数
// ============================================================
@@ -144,6 +200,74 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
///
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
///
/// - `keyword`:name/description `LIKE %kw%`(对齐知识库 title/content LIKE)。
/// - `order_by`:走白名单 `validate_column_name` 防 SQL 注入(列名不可参数化,必须拼字符串,
/// 白名单是唯一防注入手段)。默认 `created_at DESC`(与 list_active 等价)。
/// - `limit`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`),防恶意/失误传超大值。
/// - `offset`:偏移量,配合 limit 做分页。
///
/// 向后兼容:全字段 None 时 SQL 退化为
/// `SELECT ... WHERE deleted_at IS NULL ORDER BY created_at DESC`
/// 与 `list_active` 完全等价,零破坏。
pub async fn list_by_query(&self, q: ProjectQuery) -> Result<Vec<ProjectRecord>> {
// order_by 白名单校验(列名拼字符串前必须校验,防注入)。
// 校验后再拼方向:允许 "col" / "col asc" / "col desc"(大小写不敏感)。
let order_clause = build_order_clause(q.order_by.as_deref())?;
let conn = self.conn.clone();
// limit 钳制:None 不加 LIMIT(全量,对齐 list_active);Some 钳到 [0,200]。
let limit_i: Option<i64> = q.limit.map(|n| n.min(200) as i64);
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
let mut sql = String::from(
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
FROM projects WHERE deleted_at IS NULL",
);
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(kw) = &q.keyword {
let trimmed = kw.trim();
if !trimmed.is_empty() {
sql.push_str(" AND (name LIKE ? OR description LIKE ?)");
let pattern = format!("%{trimmed}%");
params_vec.push(Box::new(pattern.clone()));
params_vec.push(Box::new(pattern));
}
}
sql.push_str(" ORDER BY ");
sql.push_str(&order_clause);
// 分页:有 limit 才加 LIMIT/OFFSET(全量场景不加,等价 list_active)。
if let Some(lim) = limit_i {
sql.push_str(" LIMIT ? OFFSET ?");
params_vec.push(Box::new(lim));
params_vec.push(Box::new(offset_i));
}
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
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| project_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(进回收站,可恢复)。仅作用于未删项目,返回是否命中。
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();

View File

@@ -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),