新增: 灵感软删除回收站+雷达图正名(父⑤)
- IDEA-FIX-01: ideas 软删除(对标 tasks V14)— V28 迁移 deleted_at(幂等 ALTER) + IdeaRepo soft_delete/restore/list_deleted + list_by_query 恒带 deleted_at IS NULL(防回收站泄漏) + delete_idea 改软删 + restore_idea/list_deleted_ideas IPC + IdeaRecord 不加字段(纯 SQL WHERE 过滤) - IDEA-FIX-14: 雷达图 CSS class radar→score-bar(IdeaDetail/ProjectDetail 2 文件 template+style+注释,纯正名零行为变更) - 6 新测试(soft_delete 标记+过滤/幂等/missing, restore 还原/仅已删, list_deleted 排序);df-storage 73 测试过
This commit is contained in:
@@ -271,8 +271,10 @@ impl IdeaRepo {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
|
||||
// 动态拼 WHERE 子句 + 收集参数(按出现顺序绑定占位符)
|
||||
let mut where_clauses: Vec<String> = Vec::new();
|
||||
// 累积 WHERE 子句 + 收集参数(按出现顺序绑定占位符)。
|
||||
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
|
||||
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
|
||||
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(s) = &status {
|
||||
@@ -288,11 +290,8 @@ impl IdeaRepo {
|
||||
params_vec.push(Box::new(pat));
|
||||
}
|
||||
|
||||
let where_sql = if where_clauses.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" WHERE {}", where_clauses.join(" AND "))
|
||||
};
|
||||
// where_clauses 恒含 deleted_at IS NULL(初始项),永非空,直接 join 拼 WHERE。
|
||||
let where_sql = format!(" WHERE {}", where_clauses.join(" AND "));
|
||||
|
||||
// 排序:score 走 COALESCE(NULL 当 0,对齐前端 computed 语义);其余直接列名。
|
||||
// order_col 来自白名单 &'static str,format! 出来是 String,生命周期随 sql 一起 OK。
|
||||
@@ -341,6 +340,70 @@ impl IdeaRepo {
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::soft_delete / 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 ideas 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(从回收站还原)。仅作用于已删灵感,返回是否命中。
|
||||
/// 对标 TaskRepo::restore / 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 ideas 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)?
|
||||
}
|
||||
|
||||
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序。
|
||||
/// 对标 TaskRepo::list_deleted / ProjectRepo::list_deleted。
|
||||
pub async fn list_deleted(&self) -> Result<Vec<IdeaRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare(&format!(
|
||||
"SELECT {IDEA_COLS} FROM ideas WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC"
|
||||
))
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt
|
||||
.query_map([], |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 {
|
||||
@@ -1202,4 +1265,101 @@ mod tests {
|
||||
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
|
||||
}
|
||||
|
||||
// ---------- IdeaRepo 软删回收站(对标 task_repo 软删测试,V28) ----------
|
||||
|
||||
/// 构造一条 IdeaRecord fixture(14 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
|
||||
fn irec(id: &str, title: &str) -> IdeaRecord {
|
||||
IdeaRecord {
|
||||
id: id.to_string(),
|
||||
title: title.to_string(),
|
||||
description: String::new(),
|
||||
status: "draft".to_string(),
|
||||
priority: 1,
|
||||
score: None,
|
||||
tags: None,
|
||||
source: None,
|
||||
promoted_to: None,
|
||||
ai_analysis: None,
|
||||
scores: None,
|
||||
related_ids: None,
|
||||
created_at: "1700000000000".to_string(),
|
||||
updated_at: "1700000000000".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_idea_repo() -> IdeaRepo {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
IdeaRepo::new(&db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_marks_and_filters_from_list_by_query() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "保留")).await.unwrap();
|
||||
repo.insert(irec("i2", "软删")).await.unwrap();
|
||||
|
||||
// 软删 i2:返回 true(命中)
|
||||
assert!(repo.soft_delete("i2").await.unwrap());
|
||||
|
||||
// list_by_query(空 query = 等价全量未删)应只返回 i1,i2 进回收站被过滤
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "soft_delete 后 list_by_query 应过滤回收站");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_idempotent_on_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
assert!(repo.soft_delete("i1").await.unwrap());
|
||||
// 已删再删:WHERE deleted_at IS NULL 不命中 → false
|
||||
assert!(!repo.soft_delete("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_soft_delete_missing_returns_false() {
|
||||
let repo = setup_idea_repo().await;
|
||||
// 不存在的 id → affected=0
|
||||
assert!(!repo.soft_delete("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_brings_back_to_active() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
repo.soft_delete("i1").await.unwrap();
|
||||
// 恢复:返回 true,回到 list_by_query
|
||||
assert!(repo.restore("i1").await.unwrap());
|
||||
let active = repo.list_by_query(&IdeaQuery::default()).await.unwrap();
|
||||
let ids: Vec<_> = active.iter().map(|r| r.id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["i1"], "restore 后灵感应回到活跃列表");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_restore_only_affects_deleted() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "t")).await.unwrap();
|
||||
// 未删灵感 restore:WHERE deleted_at IS NOT NULL 不命中 → false
|
||||
assert!(!repo.restore("i1").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn idea_list_deleted_returns_only_trash_ordered_by_updated_desc() {
|
||||
let repo = setup_idea_repo().await;
|
||||
repo.insert(irec("i1", "活跃")).await.unwrap();
|
||||
repo.insert(irec("i2", "回收1")).await.unwrap();
|
||||
repo.insert(irec("i3", "回收2")).await.unwrap();
|
||||
|
||||
// 软删 i2 / i3(updated_at 在 soft_delete 时被刷新为 now)
|
||||
repo.soft_delete("i2").await.unwrap();
|
||||
// 让 i3 的删除时间晚于 i2,保证 updated_at DESC 顺序确定(i3 在前)
|
||||
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
|
||||
repo.soft_delete("i3").await.unwrap();
|
||||
|
||||
let deleted = repo.list_deleted().await.unwrap();
|
||||
let ids: Vec<_> = deleted.iter().map(|r| r.id.as_str()).collect();
|
||||
// i1 活跃不出现;i3 删除最晚在前
|
||||
assert_eq!(ids, vec!["i3", "i2"], "list_deleted 应只含回收站灵感,按 updated_at DESC");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user