//! 灵感评估历史 Repo:IdeaEvalRepo(追加型审计表 idea_evaluations,V22) //! //! 每次灵感 AI 评估追加一行快照(version 单调递增),保留评估历史可追溯。 //! 对齐 [`KnowledgeEventsRepo`](crate::crud::KnowledgeEventsRepo) 模式: //! `impl_repo!` 宏生成基础 CRUD(insert/get_by_id/list_all/query/update_field/delete/ //! update_full)+ 额外专用方法 `list_by_idea`(按 idea_id 过滤,version DESC 排序)。 //! //! # ⚠️ 宏 list_all / query 不可用(表无 created_at) //! //! 本表时间列是 `evaluated_at`(非 `created_at`),但 `impl_repo!` 宏生成的 //! `list_all` / `query` 硬编码 `ORDER BY created_at DESC`(见 crud/mod.rs 宏内 //! `ORDER BY created_at DESC` 字面量)。误调 `state.idea_eval.list_all()` 或 //! `state.idea_eval.query(...)` 会触发 SQLite "no such column: created_at",运行时崩。 //! **跨灵感按时间倒序浏览评估历史须走专用方法 [`IdeaEvalRepo::list_recent_idea_evals`]** //! (按 evaluated_at DESC + limit 钳制 200),对标 `KnowledgeEventsRepo::list_recent` //! 对 knowledge_events 表的同款兜底处理(那张表亦无 created_at,时间列名是 timestamp)。 use std::sync::Arc; use rusqlite::{params, Connection, OptionalExtension, Row}; use tokio::sync::Mutex; use df_types::error::Result; use crate::db::Database; use crate::models::IdeaEvaluationRecord; use super::impl_repo; use super::{now_millis_str, storage_err, validate_column_name}; // ============================================================ // from_row 辅助函数 // ============================================================ /// 按 name 取 idea_evaluations 表 8 列 → IdeaEvaluationRecord。 fn idea_eval_from_row(row: &Row<'_>) -> std::result::Result { Ok(IdeaEvaluationRecord { id: row.get("id")?, idea_id: row.get("idea_id")?, version: row.get("version")?, ai_analysis: row.get("ai_analysis")?, scores: row.get("scores")?, score: row.get("score")?, evaluated_by: row.get("evaluated_by")?, evaluated_at: row.get("evaluated_at")?, }) } // ============================================================ // Repo 实现 // ============================================================ impl_repo!( /// 灵感评估历史表 CRUD(追加型审计表,只增不改) IdeaEvalRepo, IdeaEvaluationRecord, "idea_evaluations", from_row => |row| idea_eval_from_row(row), insert => |conn, rec| { conn.execute( "INSERT INTO idea_evaluations (id, idea_id, version, ai_analysis, scores, score, evaluated_by, evaluated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ rec.id, rec.idea_id, rec.version, rec.ai_analysis, rec.scores, rec.score, rec.evaluated_by, rec.evaluated_at ], ) }, update => |_conn, _rec| { // 加固(审计不可篡改):追加型审计表语义禁止 UPDATE(历史快照只增不改,版本单调递增)。 // 宏(impl_repo!)要求 update 闭包生成 update_full,但本表设计上仅 insert 追加新版本。 // 若有调用方误调 update_full → 返回 Err(SQLITE_ERROR)而非真执行 UPDATE,杜绝历史被 // 静默篡改(否则审计追溯失真,版本单调性被破坏)。Err 经 storage_err 映射为 // Error::Storage,调用方拿到 Err 可发现误用。注:update_field 走宏通用 SQL 路径, // 不经此闭包,故本表 update_field 同样不应被调用(调用方约束,非编译期保证)。 // // 显式标注 rusqlite::Result(宏吃闭包 expr 不能写 -> 返回类型,改 let 绑定标注): // 闭包返回 Err 经 update_full 的 .map_err(storage_err)? → Err(Error::Storage), // affected=usize 的类型锚点确保宏内 `affected > 0` 编译。 let res: rusqlite::Result = Err(rusqlite::Error::SqliteFailure( rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_ERROR), Some("idea_evaluations 为追加型审计表,禁止 UPDATE(仅 insert 追加新版本)".to_string()), )); res } ); impl IdeaEvalRepo { /// 按 idea_id 查询全部评估历史(version DESC,最新版本在前)。 /// /// 对标 [`KnowledgeEventsRepo::list_by_knowledge`](crate::crud::KnowledgeEventsRepo::list_by_knowledge) /// 的 spawn_blocking + prepare + query_map 模式。前端取最新评估直接取返回 Vec 首元素。 pub async fn list_by_idea(&self, idea_id: &str) -> Result> { let conn = self.conn.clone(); let idea_id = idea_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \ FROM idea_evaluations WHERE idea_id = ?1 ORDER BY version DESC", ) .map_err(storage_err)?; let rows = stmt .query_map(params![idea_id], |row| idea_eval_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)? } /// 跨灵感列最近 N 条评估历史(全表 evaluated_at DESC,top-N)。 /// /// **专用兜底方法**:本表时间列名是 `evaluated_at` 而非 `created_at`,但 /// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at` /// (见 crud/mod.rs 宏内 `ORDER BY created_at DESC` 字面量),误调 /// `state.idea_eval.list_all()` 或 `state.idea_eval.query(...)` 会触发 SQLite /// "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,供需要跨灵感 /// 按评估时间倒序浏览历史的调用方使用(对标 [`KnowledgeEventsRepo::list_recent`] /// 对 knowledge_events 表的同款兜底处理——那张表同样无 created_at,时间列名是 timestamp)。 /// limit 上限钳制 200,防前端恶意/失误传超大值。 pub async fn list_recent_idea_evals(&self, limit: u32) -> Result> { let conn = self.conn.clone(); // 钳制 limit 防滥用(最大 200) let safe_limit = limit.min(200) as i64; tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id,idea_id,version,ai_analysis,scores,score,evaluated_by,evaluated_at \ FROM idea_evaluations ORDER BY evaluated_at DESC LIMIT ?1", ) .map_err(storage_err)?; let rows = stmt .query_map(params![safe_limit], |row| idea_eval_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)? } } // ============================================================ // 单元测试 — IdeaEvalRepo 内存 DB(insert + list_by_idea 排序) // ============================================================ #[cfg(test)] mod tests { use super::*; use crate::db::Database; fn erec(id: &str, idea_id: &str, version: i64, score: Option) -> IdeaEvaluationRecord { IdeaEvaluationRecord { id: id.to_string(), idea_id: idea_id.to_string(), version, ai_analysis: Some("{\"ok\":true}".to_string()), scores: Some("{\"overall\":1}".to_string()), score, evaluated_by: Some("glm-4".to_string()), evaluated_at: "1700000000000".to_string(), } } async fn setup_repo() -> IdeaEvalRepo { let db = Database::open_in_memory().await.expect("open_in_memory"); IdeaEvalRepo::new(&db) } #[tokio::test] async fn list_by_idea_orders_version_desc() { let repo = setup_repo().await; repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap(); repo.insert(erec("e2", "idea_a", 3, Some(0.9))).await.unwrap(); repo.insert(erec("e3", "idea_a", 2, Some(0.7))).await.unwrap(); repo.insert(erec("e4", "idea_b", 1, Some(0.1))).await.unwrap(); let list = repo.list_by_idea("idea_a").await.unwrap(); assert_eq!(list.len(), 3, "仅 idea_a 的 3 条评估"); let versions: Vec = list.iter().map(|r| r.version).collect(); assert_eq!(versions, vec![3, 2, 1], "version DESC: 最新在前"); let list_b = repo.list_by_idea("idea_b").await.unwrap(); assert_eq!(list_b.len(), 1); assert_eq!(list_b[0].version, 1); } #[tokio::test] async fn list_by_idea_empty_for_unknown_idea() { let repo = setup_repo().await; let list = repo.list_by_idea("nope").await.unwrap(); assert!(list.is_empty()); } #[tokio::test] async fn list_recent_idea_evals_orders_evaluated_at_desc_and_clamps_limit() { // 兜底方法:跨灵感按 evaluated_at DESC + limit 钳制 200(规避宏硬编码 // ORDER BY created_at 致本表崩)。evaluated_at 为毫秒字符串,字典序与时间序一致。 let repo = setup_repo().await; // erec 默认 evaluated_at 同值,这里覆盖以验证排序 let mut a = erec("e1", "idea_a", 1, Some(0.5)); a.evaluated_at = "1700000000001".to_string(); let mut b = erec("e2", "idea_b", 1, Some(0.6)); b.evaluated_at = "1700000000003".to_string(); let mut c = erec("e3", "idea_a", 2, Some(0.7)); c.evaluated_at = "1700000000002".to_string(); repo.insert(a).await.unwrap(); repo.insert(b).await.unwrap(); repo.insert(c).await.unwrap(); // limit=10 取全部,按 evaluated_at DESC(跨灵感) let list = repo.list_recent_idea_evals(10).await.unwrap(); assert_eq!(list.len(), 3); let times: Vec<&str> = list.iter().map(|r| r.evaluated_at.as_str()).collect(); assert_eq!(times, vec!["1700000000003", "1700000000002", "1700000000001"]); // limit=2 截断到前 2 let list2 = repo.list_recent_idea_evals(2).await.unwrap(); assert_eq!(list2.len(), 2); assert_eq!(list2[0].id, "e2"); assert_eq!(list2[1].id, "e3"); // limit 钳制:超大值被压到 200(不会崩,仅返回实际行数) let list_big = repo.list_recent_idea_evals(u32::MAX).await.unwrap(); assert_eq!(list_big.len(), 3, "u32::MAX 钳制到 200,但表仅 3 行"); // 空表 let repo_empty = setup_repo().await; assert!(repo_empty.list_recent_idea_evals(10).await.unwrap().is_empty()); } #[tokio::test] async fn insert_then_get_by_id_roundtrip() { let repo = setup_repo().await; let rec = erec("e1", "idea_a", 1, Some(0.42)); let id = repo.insert(rec.clone()).await.unwrap(); assert_eq!(id, "e1"); let got = repo.get_by_id("e1").await.unwrap().expect("记录存在"); assert_eq!(got.idea_id, "idea_a"); assert_eq!(got.version, 1); assert!((got.score.unwrap() - 0.42).abs() < 1e-9); } #[tokio::test] async fn optional_fields_persist_none() { let repo = setup_repo().await; let rec = IdeaEvaluationRecord { id: "e1".to_string(), idea_id: "idea_a".to_string(), version: 1, ai_analysis: None, scores: None, score: None, evaluated_by: None, evaluated_at: "1700000000000".to_string(), }; repo.insert(rec).await.unwrap(); let got = repo.get_by_id("e1").await.unwrap().expect("记录存在"); assert!(got.ai_analysis.is_none()); assert!(got.scores.is_none()); assert!(got.score.is_none()); assert!(got.evaluated_by.is_none()); } #[tokio::test] async fn update_full_rejected_for_append_only_audit() { // 加固(审计不可篡改):追加型审计表禁止 UPDATE。update_full 应返 Err 而非真执行 // (防误用静默篡改历史快照,破坏版本单调性 + 审计追溯)。调用方应走 insert 追加新版本。 let repo = setup_repo().await; repo.insert(erec("e1", "idea_a", 1, Some(0.5))).await.unwrap(); // 试 update_full(篡改 version 1 → 2 + 改 score) let mut rec = repo.get_by_id("e1").await.unwrap().expect("记录存在"); rec.version = 2; rec.score = Some(0.99); let res = repo.update_full(&rec).await; assert!( res.is_err(), "update_full 应被拒(追加型审计表禁止 UPDATE),实际: {:?}", res ); // 原记录未被篡改(version 仍 1,score 仍 0.5) let got = repo.get_by_id("e1").await.unwrap().expect("记录存在"); assert_eq!(got.version, 1, "原记录 version 不应被 update_full 篡改"); assert!( (got.score.unwrap() - 0.5).abs() < 1e-9, "原记录 score 不应被 update_full 篡改" ); } }