新增: 灵感软删除回收站+雷达图正名(父⑤)

- 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:
2026-06-26 20:42:48 +08:00
parent 308775cf4d
commit 005079f762
7 changed files with 230 additions and 37 deletions

View File

@@ -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");
}
}

View File

@@ -32,7 +32,8 @@ pub fn run(conn: &Connection) -> Result<()> {
// V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
let steps: [(i32, fn(&Connection) -> Result<()>); 27] = [
// V28 = 灵感软删回收站(ideas.deleted_at,对标 tasks.deleted_at V14/projects.deleted_at V11)。
let steps: [(i32, fn(&Connection) -> Result<()>); 28] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -60,6 +61,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(25, migrate_v25),
(26, migrate_v26),
(27, migrate_v27),
(28, migrate_v28),
];
for (version, migrate_fn) in steps {
@@ -679,6 +681,23 @@ fn migrate_v27(conn: &Connection) -> Result<()> {
Ok(())
}
/// V28: 幂等补 ideas.deleted_at 列(灵感软删回收站,对标 tasks.deleted_at V14)
///
/// 删除灵感改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
/// 与 tasks/projects.soft_delete 同模板:IdeaRecord 不带该字段,纯靠 SQL WHERE
/// deleted_at IS NULL 过滤;关联数据(idea_evaluations 评估历史)不动,FK 仍满足,
/// 灵感数据完整保留待恢复。用 PRAGMA 探测列存在性,缺失才 ALTER
/// (同 v4/v5/v6/v8/v10/v11/v14/v15/v16/v17/v18/v19/v20/v23/v24 模式),对新库/老库均安全。
fn migrate_v28(conn: &Connection) -> Result<()> {
if !column_exists(conn, "ideas", "deleted_at") {
conn.execute("ALTER TABLE ideas ADD COLUMN deleted_at TEXT", [])?;
tracing::info!("v28: 补建 ideas.deleted_at 列(灵感软删回收站)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [28])?;
tracing::info!("迁移 v28 完成");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。