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