新增: 三实体列表查询维度补全(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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user