新增: 灵感软删除回收站+雷达图正名(父⑤)
- 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user