Files
DevFlow/crates/df-storage/src/crud/idea_repo.rs
T
lxy 5faaa88302 新增: 知识路标投影进系统提示词
- published 且验证或高复用条目按项目优先进投影稳定段
- 段尾固定引导工具故障先检索路标,治有经验不想查
- 与规则文件投影共用会话级缓存,空库零回归
2026-10-13 13:29:16 +08:00

2446 lines
107 KiB
Rust

//! 想法/知识域 Repo:IdeaRepo / KnowledgeRepo / KnowledgeEventsRepo + 向量工具(embedding BLOB 序列化 + 余弦相似度)
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::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord};
use df_types::types::IdeaStatus;
use super::impl_repo;
use super::{now_millis_str, storage_err, validate_column_name};
// ============================================================
// 知识库 SELECT 列清单(防 COLS 漂移)
// ============================================================
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口:集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
///
/// V23 新增 embedding_status 列(嵌入失败可补偿重试),已纳入列清单 + 计数。
const KNOWLEDGE_COLS: &str = "id,kind,title,content,tags,status,confidence,reuse_count,verified,source_project,source_ref,reasoning,embedding_status,created_at,updated_at";
/// `KnowledgeRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
/// 仅测试期消费(列漂移断言);保留为非 `cfg(test)` 以便测试外的阅读者一眼看到字段数。
#[cfg_attr(not(test), allow(dead_code))]
const KNOWLEDGE_COL_COUNT: usize = 15;
/// `search_vector` 用的列清单:KNOWLEDGE_COLS + embedding(余弦计算用,不入 KnowledgeRecord)。
const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
"id,kind,title,content,tags,status,confidence,reuse_count,verified,",
"source_project,source_ref,reasoning,embedding_status,created_at,updated_at,embedding"
);
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 同 KNOWLEDGE_COLS 的列漂移防护:idea 表 INSERT/UPDATE/from_row 三处
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
///
/// V24 新增 related_ids 列(灵感间关联关系持久化打底),已纳入列清单 + 计数。
/// V47 新增 archived 列(灵感归档,归档功能 P1),已纳入列清单 + 计数(14→15)。
#[cfg_attr(not(test), allow(dead_code))]
const IDEA_COLS: &str = "id,title,description,status,priority,score,tags,source,promoted_to,ai_analysis,scores,related_ids,archived,created_at,updated_at";
/// `IdeaRecord` 字段数(与上面列清单的逗号分隔项数一致,被测试断言)。
#[cfg_attr(not(test), allow(dead_code))]
const IDEA_COL_COUNT: usize = 15;
// ============================================================
// 向量工具 — embedding BLOB 序列化 + 余弦相似度
// ============================================================
/// Vec<f32> → 小端字节 BLOB
fn f32s_to_blob(v: &[f32]) -> Vec<u8> {
v.iter().flat_map(|f| f.to_le_bytes()).collect()
}
/// BLOB → Vec<f32>(长度非 4 倍数时截断尾部残字节)
fn blob_to_f32s(blob: &[u8]) -> Vec<f32> {
blob.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
/// 余弦相似度(确定性数学,与任何实现结果一致)
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
dot / (norm_a * norm_b + 1e-8)
}
/// 解析 JSON 数组字符串为 Vec<String>。NULL / 空 / 非法 → 空 Vec。
fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
match raw {
Some(s) if !s.is_empty() => match serde_json::from_str::<Vec<String>>(s) {
Ok(v) => v,
Err(_) => Vec::new(),
},
_ => Vec::new(),
}
}
/// LIKE 通配符转义(与 SQL 侧 `ESCAPE '|'` 配套)。
///
/// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符,
/// 统一在此转义,避免关键词意外扩大命中面。
fn escape_like(s: &str) -> String {
s.replace('|', "||").replace('%', "|%").replace('_', "|_")
}
// ============================================================
// from_row 辅助函数
// ============================================================
fn idea_from_row(row: &Row<'_>) -> std::result::Result<IdeaRecord, rusqlite::Error> {
Ok(IdeaRecord {
id: row.get("id")?,
title: row.get("title")?,
description: row.get("description")?,
status: {
let s: String = row.get("status")?;
match IdeaStatus::from_db_str(&s) {
Some(v) => v,
None => {
// 未知 status 静默降级默认值后,后续 update_full 回写会把库里的未知值
// 覆盖成默认值(读→改→写放大静默),warn 留痕让脏数据可发现。
let id: String = row.get("id")?;
tracing::warn!("ideas.id={id} 含未知 status {s:?},按默认值降级(回写会覆盖原值,请核查)");
Default::default()
}
}
},
priority: row.get("priority")?,
score: row.get("score")?,
tags: row.get("tags")?,
source: row.get("source")?,
promoted_to: row.get("promoted_to")?,
ai_analysis: row.get("ai_analysis")?,
scores: row.get("scores")?,
related_ids: row.get("related_ids")?,
// 归档功能 P1 V47:15 列同步之一(SQLite 无真 BOOLEAN,0→false/非0→true)。
archived: row.get::<_, i32>("archived")? != 0,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn knowledge_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeRecord, rusqlite::Error> {
Ok(KnowledgeRecord {
id: row.get("id")?,
kind: row.get("kind")?,
title: row.get("title")?,
content: row.get("content")?,
tags: row.get("tags")?,
status: row.get("status")?,
confidence: row.get("confidence")?,
reuse_count: row.get("reuse_count")?,
verified: row.get::<_, i32>("verified")? != 0,
source_project: row.get("source_project")?,
source_ref: row.get("source_ref")?,
reasoning: row.get("reasoning")?,
embedding_status: row.get("embedding_status")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
})
}
fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEventRecord, rusqlite::Error> {
Ok(KnowledgeEventRecord {
id: row.get("id")?,
knowledge_id: row.get("knowledge_id")?,
event_type: row.get("event_type")?,
source_ref: row.get("source_ref")?,
context_json: row.get("context_json")?,
timestamp: row.get("timestamp")?,
})
}
// ============================================================
// Repo 实现
// ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(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,
IdeaRecord,
"ideas",
from_row => |row| idea_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO ideas (id, title, description, status, priority, score, tags, source, promoted_to, ai_analysis, scores, related_ids, archived, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.archived as i32, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, archived = ?12, updated_at = ?13 WHERE id = ?14",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.archived as i32, rec.updated_at, rec.id
],
)
}
);
impl_repo!(
/// 知识库表 CRUD
KnowledgeRepo,
KnowledgeRecord,
"knowledges",
from_row => |row| knowledge_from_row(row),
insert => |conn, rec| {
let verified = if rec.verified { 1i32 } else { 0i32 };
conn.execute(
"INSERT INTO knowledges (id, kind, title, content, tags, status, confidence, reuse_count, verified, source_project, source_ref, reasoning, embedding_status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
params![
rec.id, rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
rec.embedding_status, rec.created_at, rec.updated_at
],
)
},
update => |conn, rec| {
let verified = if rec.verified { 1i32 } else { 0i32 };
conn.execute(
"UPDATE knowledges SET kind = ?1, title = ?2, content = ?3, tags = ?4, status = ?5, confidence = ?6, reuse_count = ?7, verified = ?8, source_project = ?9, source_ref = ?10, reasoning = ?11, embedding_status = ?12, updated_at = ?13 WHERE id = ?14",
params![
rec.kind, rec.title, rec.content, rec.tags, rec.status, rec.confidence,
rec.reuse_count, verified, rec.source_project, rec.source_ref, rec.reasoning,
rec.embedding_status, rec.updated_at, rec.id
],
)
}
);
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页。
///
/// 复用 `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 子句 + 收集参数(按出现顺序绑定占位符)。
// deleted_at IS NULL 恒带(常量条件无占位符),回收站任务不进结果
// (对标 task_repo list_by_query 同款模式,防回收站泄漏,不可被 query 关闭)。
let mut where_clauses: Vec<String> = vec!["deleted_at IS NULL".to_string(), "archived = 0".to_string()];
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
if let Some(s) = &status {
// 占位符编号用 params_vec.len()+1(参数实际位置),非 where_clauses.len()+1
// (where_clauses 含 deleted_at IS NULL 常量无占位符子句,len() 会偏移致 ?N 与参数错位
// — 父⑤⑤.1 加 deleted_at 恒带引入的潜伏 bug,非空 status/keyword 查询 rusqlite 报
// "needed N, got M"。对标 task_repo list_by_query ②.2 同款修复)
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
params_vec.push(Box::new(s.clone()));
}
if let Some(kw) = &keyword {
let escaped = kw.replace('|', "||").replace('%', "|%").replace('_', "|_");
let pat = format!("%{escaped}%");
let p1 = params_vec.len() + 1;
let p2 = p1 + 1;
// ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
where_clauses.push(format!("(title LIKE ?{p1} ESCAPE '|' OR description LIKE ?{p2} ESCAPE '|')"));
params_vec.push(Box::new(pat.clone()));
params_vec.push(Box::new(pat));
}
// 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。
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)?
}
/// 软删:标记 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)?
}
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
///
/// LW-6通用 [`update_field`] 不过滤软删(回收站灵感仍可改字段),
/// 本方法收口软删防护,供命令层 `update_idea` 使用——软删灵感(回收站)返回 `false`,
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
validate_column_name(field, "ideas")?;
let conn = self.conn.clone();
let sql = format!(
"UPDATE ideas SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL AND archived = 0",
field
);
let id = id.to_owned();
let value = value.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(&sql, params![value, now, id])
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 归档/取消归档(归档功能 P1,V47)。
///
/// 纯元数据标记,仅作用于未软删灵感(回收站优先级高于归档,先恢复再操作)。
/// archived=true 后灵感退出主视图(list_by_query 恒带 archived = 0),仅在归档视图可见。
/// 对标 TaskRepo::set_archived。返回是否命中。
pub async fn set_archived(&self, id: &str, archived: bool) -> 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 archived = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
params![if archived { 1 } else { 0 }, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 列出归档灵感(archived = 1 且未软删),按更新时间降序(最近归档在前)。
///
/// 归档视图数据源,与 list_by_query 对偶(恒带 archived = 1)。
pub async fn list_archived(&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 NULL AND archived = 1 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)?
}
/// 原子「立项认领」:CAS 写回 status=promoted + promoted_to(`WHERE id AND promoted_to IS NULL`)。
///
/// promote_idea 读-改-写竞态的原子关闭。promote_idea 先建项目再回写灵感,
/// 双击/并发两次 promote 都读到 promoted_to=None → 各自建项目;回写时本方法用
/// `promoted_to IS NULL` 做 CAS——仅首个认领成功(affected=1),第二个 affected=0,
/// 调用方据此判定「灵感已立项」并回滚自己刚建的项目(补偿删除),杜绝重复立项。
///
/// - `deleted_at IS NULL` 收口软删(回收站灵感不可立项认领)。
/// - 返回 false = 已立项(并发) / 软删 / 不存在,调用方须回滚其副作用。
pub async fn claim_promotion(&self, id: &str, promoted_to: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let promoted_to = promoted_to.to_owned();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE ideas SET status = 'promoted', promoted_to = ?1, updated_at = ?2 \
WHERE id = ?3 AND promoted_to IS NULL AND deleted_at IS NULL",
params![promoted_to, now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 双向同步关联关系:原子地更新主体灵感及其所有关联目标的 `related_ids`。
///
/// `subject_id` 的 `related_ids` 被设为 `new_target_ids`(全量替换);
/// 新增的关联目标追加 `subject_id` 到其 `related_ids`;
/// 移除的关联目标从中删除 `subject_id`。
/// 全部操作在同一 SQLite 事务中完成,保证原子性。
pub async fn sync_related_ids(
&self,
subject_id: &str,
new_target_ids: &[String],
) -> Result<()> {
use rusqlite::Transaction;
let conn = self.conn.clone();
let subject_id = subject_id.to_owned();
let new_target_ids = new_target_ids.to_vec();
let now = now_millis_str();
tokio::task::spawn_blocking(move || {
let mut guard = conn.blocking_lock();
let tx: Transaction = guard
.transaction()
.map_err(storage_err)?;
// 1. 读主体当前 related_ids — 同段校验主体存在且未软删(deleted_at IS NULL)。
// 治走查🟡:原仅读 related_ids,主体不存在/已软删时步骤 5 UPDATE 静默 affected=0
// 仍提交,而步骤 3/4 已改写目标侧 related_ids → 「目标有关联、主体无记录」单向脏关联。
// 查无立即 Err 回滚整个事务,目标侧零改写。
let old_raw: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1 AND deleted_at IS NULL",
params![subject_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten()
.ok_or_else(|| {
df_types::error::Error::Storage(format!(
"灵感 {subject_id} 不存在或已删除,无法建立关联"
))
})?;
// 2. 解析新旧集合
let old_set: std::collections::HashSet<String> =
parse_json_id_array(&old_raw).into_iter().collect();
let new_set: std::collections::HashSet<String> =
new_target_ids.iter().cloned().collect();
let added: Vec<&str> = new_set
.difference(&old_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id) // 不自关联
.collect();
let removed: Vec<&str> = old_set
.difference(&new_set)
.map(|s| s.as_str())
.filter(|id| *id != subject_id)
.collect();
// 3. 更新 added 目标:追加 subject_id
for target_id in &added {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
if !ids.iter().any(|i| i == &subject_id) {
ids.push(subject_id.clone());
}
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 4. 更新 removed 目标:移除 subject_id
for target_id in &removed {
let cur: Option<String> = tx
.query_row(
"SELECT related_ids FROM ideas WHERE id = ?1",
params![target_id],
|row| row.get(0),
)
.optional()
.map_err(storage_err)?
.flatten();
let mut ids: Vec<String> = parse_json_id_array(&cur);
ids.retain(|i| i != &subject_id);
let json = serde_json::to_string(&ids).map_err(|e| {
storage_err::<df_types::error::Error>(e.into())
})?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![json, &now, target_id],
)
.map_err(storage_err)?;
}
// 5. 更新主体
let new_json = serde_json::to_string(&new_target_ids)
.map_err(|e| storage_err::<df_types::error::Error>(e.into()))?;
tx.execute(
"UPDATE ideas SET related_ids = ?1, updated_at = ?2 WHERE id = ?3",
params![new_json, &now, &subject_id],
)
.map_err(storage_err)?;
tx.commit().map_err(storage_err)?;
Ok(())
})
.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)?
}
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
///
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
///
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
/// 需要乐观锁版本的调用方(df-mcp update_idea / score_idea)。
/// `Ok(false)` = id 不存在、已进回收站(deleted_at IS NULL 守卫,V28 灵感软删)、
/// 已归档(archived = 0 守卫,V47 灵感归档,须先取消归档)或版本冲突。
pub async fn update_full_cas(
&self,
record: &IdeaRecord,
expected_updated_at: &str,
) -> Result<bool> {
let conn = self.conn.clone();
let rec = record.clone();
let expected = expected_updated_at.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
// deleted_at IS NULL:回收站灵感不可被 CAS 覆盖(同 tasks/projects 同族守卫);
// archived = 0:归档灵感冻结(同 tasks update_full_cas 同族守卫,须先取消归档再改)。
"UPDATE ideas SET title = ?1, description = ?2, status = ?3, priority = ?4, score = ?5, tags = ?6, source = ?7, promoted_to = ?8, ai_analysis = ?9, scores = ?10, related_ids = ?11, updated_at = ?12 WHERE id = ?13 AND updated_at = ?14 AND deleted_at IS NULL AND archived = 0",
params![
rec.title, rec.description, rec.status.as_str(), rec.priority,
rec.score, rec.tags, rec.source, rec.promoted_to, rec.ai_analysis,
rec.scores, rec.related_ids, rec.updated_at, rec.id, expected
],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
}
impl KnowledgeRepo {
/// 检索知识(整句 LIKE,兼容路径): title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
///
/// 保留整句匹配语义,服务 UI 知识面板搜索与 df-mcp `search_knowledge`(调用方传的就是
/// 用户显式输入的搜索串,整句即意图)。AI 注入路径的分词检索走 [`KnowledgeRepo::search_keywords`]。
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let pattern = format!("%{}%", escape_like(query));
let kind = kind.map(|s| s.to_owned());
let limit_i = limit as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut results = Vec::new();
if let Some(k) = &kind {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '|' OR content LIKE ?2 ESCAPE '|') AND kind = ?3 ORDER BY reuse_count DESC LIMIT ?4"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, k, limit_i], |row| knowledge_from_row(row))
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
} else {
let mut stmt = guard
.prepare(&format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' AND (title LIKE ?1 ESCAPE '|' OR content LIKE ?2 ESCAPE '|') ORDER BY reuse_count DESC LIMIT ?3"))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![pattern, pattern, limit_i], |row| knowledge_from_row(row))
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 分词关键词检索(AI 注入路径专用): 多关键词 OR 命中 title/content
///
/// 排序:标题命中词数 > 内容命中 > reuse_count 降序(SQL 内 CASE 计分,同分再看复用次数)。
///
/// 背景(知识库走查 2026-09-29):注入路径原复用 [`KnowledgeRepo::search`] 把**整句用户消息**
/// 当 LIKE pattern,`%怎么修复知识库检索命中率%` 这类长串几乎不可能作为连续子串出现在
/// 任何知识正文里,命中率≈0。本方法由 caller 分词后逐词匹配,任一词命中即入候选,
/// 多词同时命中者排前。关键词条数为 WHERE 的 OR 分支数,通常 ≤6,全表扫描量级可控。
///
/// `include_candidates`:是否把 status='candidate'(AI 自动提炼产物)纳入检索面。
/// 提炼产物曾长期全部滞留 candidate 而本路径只认 published,存量知识近乎零生效;
/// 默认纳入后靠「复用即信任」([`KnowledgeRepo::auto_publish_if_reused`])渐进晋升,
/// 人工审核降级为可选修正路径。
///
/// 关键词为空 → 返回空(不回退整句 LIKE,避免退化回原缺陷)。
pub async fn search_keywords(
&self,
keywords: &[String],
include_candidates: bool,
limit: usize,
) -> Result<Vec<KnowledgeRecord>> {
if keywords.is_empty() {
return Ok(Vec::new());
}
let conn = self.conn.clone();
// 每个关键词占一个编号占位符;WHERE 与 ORDER BY 的 CASE 复用同号(SQLite 允许 ?N 复用),
// 参数列表只装一份 pattern,末尾追加 LIMIT 占位符。
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
let mut title_score_terms: Vec<String> = Vec::with_capacity(keywords.len());
for (i, kw) in keywords.iter().enumerate() {
let n = i + 1;
where_parts.push(format!(
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
));
title_score_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
patterns.push(format!("%{}%", escape_like(kw)));
}
let status_clause = if include_candidates {
"status IN ('published','candidate')"
} else {
"status = 'published'"
};
let limit_i = limit as i64;
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE {status_clause} AND ({}) \
ORDER BY ({}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
where_parts.join(" OR "),
title_score_terms.join(" + "),
patterns.len() + 1,
);
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
param_refs.push(&limit_i);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(param_refs.as_slice(), |row| knowledge_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)?
}
/// 项目维度软排序检索(跨会话召回最小闭环,D2 拍板:软排序非硬过滤)。
///
/// 排序语义:同项目(source_project = ?)第一档 > 全局(IS NULL)第二档 > 异项目第三档;
/// 同档内保持原有 title 命中分 → reuse_count → created_at 顺序。`limit` 不变只改顺序,
/// token 膨胀为零。`project_id = None` 时行为与 [`Self::search_keywords`] 逐字一致(回归门)。
///
/// 与 [`Self::search_keywords`] 的关系:后者是本方法的 project_id=None 特例(旧签名
/// 保留给 MCP 等外部调用方,内部转调本方法,零重复实现)。
pub async fn search_keywords_scoped(
&self,
keywords: &[String],
include_candidates: bool,
limit: usize,
project_id: Option<&str>,
) -> Result<Vec<KnowledgeRecord>> {
if project_id.is_none() {
return self.search_keywords(keywords, include_candidates, limit).await;
}
if keywords.is_empty() {
return Ok(Vec::new());
}
let project_id = project_id.unwrap().to_string();
let conn = self.conn.clone();
// 每个关键词占一个编号占位符(1..n);project_id 占 ?(n+1),WHERE 的 ORDER BY CASE
// 与三档投影各复用同号(SQLite 允许 ?N 复用)。patterns 只装关键词一份。
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
let mut order_terms: Vec<String> = Vec::with_capacity(keywords.len() + 1);
for (i, kw) in keywords.iter().enumerate() {
let n = i + 1;
where_parts.push(format!(
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
));
order_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
patterns.push(format!("%{}%", escape_like(kw)));
}
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
let pid_n = keywords.len() + 1;
let project_tier =
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
let title_terms = order_terms.join(" + ");
let status_clause = if include_candidates {
"status IN ('published','candidate')"
} else {
"status = 'published'"
};
let limit_i = limit as i64;
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE {status_clause} AND ({}) \
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
where_parts.join(" OR "),
pid_n + 1,
);
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
param_refs.push(&project_id);
param_refs.push(&limit_i);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(param_refs.as_slice(), |row| knowledge_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)?
}
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
///
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
pub async fn list_by_status(&self, status: &str) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let status = status.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = ?1
ORDER BY CASE confidence
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC, created_at DESC",
))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![status], |row| knowledge_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)?
}
/// 复用计数 +1(SQL 行级原子操作,并发安全)
pub async fn increment_reuse_count(&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 knowledges SET reuse_count = reuse_count + 1, updated_at = ?1 WHERE id = ?2",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 复用即信任:candidate 的 reuse_count 达到阈值后自动晋升 published
///
/// 背景(知识库走查 2026-09-29):AI 提炼产物长期滞留 candidate,人工审核入口堆积无人处理,
/// 存量知识近乎零生效。「被真实注入命中 N 次」比「被人工翻到」信号更强,故以复用达标为
/// 主晋升路径;人工审核保留为可选修正(仍可在面板驳回/归档)。
///
/// SQL 原子判定(`status='candidate' AND reuse_count >= threshold`):published/archived
/// 条目不受影响,幂等可重复调用。返回 true = 本次发生了晋升(调用方据此打留痕日志)。
pub async fn auto_publish_if_reused(&self, id: &str, threshold: i64) -> 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 knowledges SET status = 'published', updated_at = ?1 \
WHERE id = ?2 AND status = 'candidate' AND reuse_count >= ?3",
params![now, id, threshold],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 显式转正:candidate 单向晋升 published(对话内 knowledge_promote 工具 / 人工放行通道)
///
/// 与 [`KnowledgeRepo::auto_publish_if_reused`] 同款 SQL 原子条件写,区别在晋升条件:
/// 本方法不做复用计数判定,仅 `status='candidate'` 一个门槛——显式转正是「人/agent
/// 拍板」语义,不复用即信任的阈值逻辑。SQL WHERE 收口边界:published/archived 条目
/// 不命中(affected=0 → false),调用方据此回「仅 candidate 可转正」,天然幂等且
/// 禁止降级/归档/改内容(只写 status + updated_at 两列)。
pub async fn promote(&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 knowledges SET status = 'published', updated_at = ?1 \
WHERE id = ?2 AND status = 'candidate'",
params![now, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
///
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
/// V23:同时把 embedding_status 置 'done'(成功标记),供补偿重试逻辑判别。
pub async fn set_embedding(&self, id: &str, embedding: &[f32]) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
let blob = f32s_to_blob(embedding);
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE knowledges SET embedding = ?1, embedding_status = 'done' WHERE id = ?2",
params![blob, id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 标记嵌入生成失败(embedding_status = 'failed'),供补偿重试逻辑定位。
///
/// 失败时不写 embedding(保持 NULL,检索侧 `embedding IS NOT NULL` 自然跳过该条走 LIKE)。
/// 幂等:重复标记 failed 无副作用(同值覆写)。
pub async fn mark_embedding_failed(&self, id: &str) -> Result<bool> {
let conn = self.conn.clone();
let id = id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let affected = guard
.execute(
"UPDATE knowledges SET embedding_status = 'failed' WHERE id = ?1",
params![id],
)
.map_err(storage_err)?;
Ok(affected > 0)
})
.await
.map_err(storage_err)?
}
/// 列出 embedding_status = 'failed' 的已发布知识(补偿重试入口用)。
///
/// 仅返回 published(候选/归档不参与检索,重试无意义),按 created_at 升序(老条目优先补)。
pub async fn list_failed_embeddings(&self) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE status = 'published' AND embedding_status = 'failed' \
ORDER BY created_at ASC"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| knowledge_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)?
}
/// 向量检索: 加载全部 published 且有 embedding 的记录,纯 Rust 余弦相似度取 top-N
///
/// 返回 (记录, 相似度分数)。数据量 <50k 时暴力遍历 <50ms,够用;
/// 更大规模再升 sqlite-vec HNSW(结果不变,只提速)。
///
/// 列限定: 显式列出所需列(与 search/list_by_status/top_used 一致),避免 SELECT *
/// 拉到未知新增列;embedding 单独取(不入 KnowledgeRecord)。
///
/// TODO(性能,低优先): 调用方(hybrid_search→merge_hybrid_results→build_knowledge_context)
/// 实际只消费 id/kind/title/content/reuse_count;reasoning(AI 生成大文本)、tags、
/// source_project/source_ref 等元字段未被使用却仍随每行读出。真正省 IO 需返回精简结构
/// (如 KnowledgeVectorHit { id, kind, title, content, reuse_count })替换返回类型,
/// 但这会改变 search_vector 签名与 merge_hybrid_results 调用契约——当前保守不动,
/// 待向量检索量级或 reasoning 文本体积成为瓶颈再单独立项。SELECT 列化本身不省字段,
/// 仅消除 SELECT * 的隐式依赖与未知列风险。
pub async fn search_vector(&self, query_vec: &[f32], limit: usize) -> Result<Vec<(KnowledgeRecord, f32)>> {
let conn = self.conn.clone();
let query_vec = query_vec.to_vec();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 显式列: 15 个 KnowledgeRecord 字段(含 embedding_status)+ embedding(余弦计算用,不入 KnowledgeRecord)
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS_WITH_EMBEDDING} FROM knowledges WHERE status = 'published' AND embedding IS NOT NULL"
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| {
let rec = knowledge_from_row(row)?;
let blob: Vec<u8> = row.get("embedding")?;
Ok((rec, blob))
})
.map_err(storage_err)?;
let mut scored: Vec<(KnowledgeRecord, f32)> = Vec::new();
for r in rows {
let (rec, blob) = r.map_err(storage_err)?;
let emb = blob_to_f32s(&blob);
// 维度不匹配(换过 embedding 模型的旧向量)跳过
if emb.len() != query_vec.len() {
continue;
}
let score = cosine_similarity(&query_vec, &emb);
scored.push((rec, score));
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(limit);
Ok(scored)
})
.await
.map_err(storage_err)?
}
/// 列出非归档知识(全部 status != 'archived'),按 confidence 语义排序
pub async fn list_non_archived(&self) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status != 'archived'
ORDER BY CASE confidence
WHEN 'high' THEN 3
WHEN 'medium' THEN 2
WHEN 'low' THEN 1
ELSE 0
END DESC, created_at DESC",
))
.map_err(storage_err)?;
let rows = stmt
.query_map([], |row| knowledge_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)?
}
/// 热门知识(已发布,按复用次数降序)
pub async fn top_used(&self, limit: usize) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let limit_i = limit as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(&format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE status = 'published' ORDER BY reuse_count DESC LIMIT ?1"
))
.map_err(storage_err)?;
let rows = stmt
.query_map(params![limit_i], |row| knowledge_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)?
}
/// 知识路标读侧(devflow.md 投影通道):已发布且(人工验证 或 复用达标)的条目选摘。
///
/// 与检索族(search/search_keywords)的差别:路标无查询词,是「库里有什么」的静态选摘,
/// 供 system prompt 路标段铺开让 agent 知晓库藏、想起检索。入选门槛 = published 且
/// (verified = 1 OR reuse_count >= 3);排序 = 复用降序(唯一客观信号,对齐 search/top_used),
/// 同复用按 created_at 降序定稳定次序。verified 只作入选不作排序(复用是更强的实战信号)。
///
/// 两档拼接:项目档(source_project = project_id,上限 project_limit)在前——会话归属
/// 项目的知识最相关;跨项目补充档(非本项目含无归属条目,上限 global_limit)在后。
/// 两档 WHERE 互斥(= pid vs IS NULL/≠ pid),结果天然无重复。
/// project_id = None → 仅补充档(会话未解析出归属项目,项目档无从谈起)。
pub async fn list_waymark(
&self,
project_id: Option<&str>,
project_limit: usize,
global_limit: usize,
) -> Result<Vec<KnowledgeRecord>> {
let conn = self.conn.clone();
let project_id = project_id.map(|s| s.to_owned());
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
// 入选门槛与排序两档共用(verified 在 SQLite 存 0/1;复用达标压线 3 即入选)
let qualify = "status = 'published' AND (verified = 1 OR reuse_count >= 3)";
let order = " ORDER BY reuse_count DESC, created_at DESC LIMIT ";
let mut results = Vec::new();
// 项目档:会话归属项目内的合格条目(复用降序,上限 project_limit)
if let Some(pid) = &project_id {
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify} AND source_project = ?1{order}?2"
);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(params![pid, project_limit as i64], knowledge_from_row)
.map_err(storage_err)?;
for r in rows {
results.push(r.map_err(storage_err)?);
}
}
// 跨项目补充档:非本项目(含无归属全局条目)按复用降序补足 global_limit;
// WHERE 与项目档互斥 → 天然去重。project_id = None 时无排他条件,即全量合格条目。
// LIMIT 占位符编号随排他条件有无而变(?1 空档排他时归 LIMIT,有排他时排他占 ?1、LIMIT 占 ?2)
let exclude = if project_id.is_some() {
" AND (source_project IS NULL OR source_project != ?1)"
} else {
""
};
let lim_n = if project_id.is_some() { 2 } else { 1 };
let sql = format!("SELECT {KNOWLEDGE_COLS} FROM knowledges WHERE {qualify}{exclude}{order}?{lim_n}");
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = match &project_id {
Some(pid) => stmt
.query_map(params![pid, global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
None => stmt
.query_map(params![global_limit as i64], knowledge_from_row)
.map_err(storage_err)?,
};
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
impl_repo!(
/// 知识生命线事件表 CRUD(追加型审计表)
KnowledgeEventsRepo,
KnowledgeEventRecord,
"knowledge_events",
from_row => |row| knowledge_event_from_row(row),
insert => |conn, rec| {
conn.execute(
"INSERT INTO knowledge_events (id, knowledge_id, event_type, source_ref, context_json, timestamp)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
params![rec.id, rec.knowledge_id, rec.event_type, rec.source_ref, rec.context_json, rec.timestamp],
)
},
update => |conn, rec| {
conn.execute(
"UPDATE knowledge_events SET knowledge_id = ?1, event_type = ?2, source_ref = ?3, context_json = ?4, timestamp = ?5 WHERE id = ?6",
params![rec.knowledge_id, rec.event_type, rec.source_ref, rec.context_json, rec.timestamp, rec.id],
)
}
);
impl KnowledgeEventsRepo {
/// 按知识 ID 查询全部事件(时间正序,构建生命线视图用)
pub async fn list_by_knowledge(&self, knowledge_id: &str) -> Result<Vec<KnowledgeEventRecord>> {
let conn = self.conn.clone();
let knowledge_id = knowledge_id.to_owned();
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id,knowledge_id,event_type,source_ref,context_json,timestamp FROM knowledge_events WHERE knowledge_id = ?1 ORDER BY timestamp ASC")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![knowledge_id], |row| knowledge_event_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)?
}
/// 按知识 ID + event_type 查询最近 N 条(如引用记录翻页: type=referenced)
pub async fn list_by_knowledge_type(
&self,
knowledge_id: &str,
event_type: &str,
limit: usize,
) -> Result<Vec<KnowledgeEventRecord>> {
let conn = self.conn.clone();
let knowledge_id = knowledge_id.to_owned();
let event_type = event_type.to_owned();
let limit_i = limit as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare("SELECT id,knowledge_id,event_type,source_ref,context_json,timestamp FROM knowledge_events WHERE knowledge_id = ?1 AND event_type = ?2 ORDER BY timestamp DESC LIMIT ?3")
.map_err(storage_err)?;
let rows = stmt
.query_map(params![knowledge_id, event_type, limit_i], |row| knowledge_event_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 条事件(全表 timestamp DESC,top-N)。
///
/// **专用兜底方法**:本表时间列名是 `timestamp` 而非 `created_at`,但
/// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at`
/// (见宏内 `ORDER BY created_at DESC` 字面量),误调 `state.knowledge_events.query(...)`
/// 会触发 SQLite "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,
/// 供需要跨知识按时间倒序浏览事件的调用方使用(对标 AiToolExecutionRepo::list_recent
/// 对 ai_tool_executions 表的同款兜底处理——那张表同样无 created_at 列)。
/// limit 上限钳制 200,防前端恶意/失误传超大值。
pub async fn list_recent(&self, limit: u32) -> Result<Vec<KnowledgeEventRecord>> {
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,knowledge_id,event_type,source_ref,context_json,timestamp \
FROM knowledge_events ORDER BY timestamp DESC LIMIT ?1",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![safe_limit], |row| knowledge_event_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)?
}
}
// ============================================================
// 单元测试 — 向量纯函数 + KnowledgeRepo 内存 DB + COLS 漂移防护
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
// ---------- COLS 漂移防护 ----------
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
#[test]
fn test_knowledge_cols_matches_record() {
let count = KNOWLEDGE_COLS.split(',').count();
assert_eq!(
count, KNOWLEDGE_COL_COUNT,
"KNOWLEDGE_COLS({count}列) ≠ KNOWLEDGE_COL_COUNT({KNOWLEDGE_COL_COUNT}); \
修改一处须同步另一处"
);
// search_vector 多一列 embedding
let count_with_emb = KNOWLEDGE_COLS_WITH_EMBEDDING.split(',').count();
assert_eq!(
count_with_emb,
KNOWLEDGE_COL_COUNT + 1,
"KNOWLEDGE_COLS_WITH_EMBEDDING({count_with_emb}列) ≠ KNOWLEDGE_COL_COUNT+1({}); \
embedding 列应单独追加",
KNOWLEDGE_COL_COUNT + 1
);
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
for col in KNOWLEDGE_COLS.split(',') {
assert!(!col.is_empty(), "KNOWLEDGE_COLS 含空列名段");
}
}
/// IDEA_COLS 列数须等于 IDEA_COL_COUNT(任一处漂移:加列漏改 INSERT/UPDATE/from_row
/// 三处之一 → 立即失败)。`idea_from_row` 按 name 取列,SELECT/INSERT 漏列会在运行时被
/// rusqlite 报错;此断言提前到测试期捕获(本次 V24 加 related_ids 已改 3 处的回归保险)。
#[test]
fn test_idea_cols_matches_record() {
let count = IDEA_COLS.split(',').count();
assert_eq!(
count, IDEA_COL_COUNT,
"IDEA_COLS({count}列) ≠ IDEA_COL_COUNT({IDEA_COL_COUNT}); \
修改一处须同步另一处(INSERT/UPDATE/from_row/IDEA_COLS/IDEA_COL_COUNT 五处)"
);
// 每个列名须能被 split 出来(防末尾多逗号 / 空段)
for col in IDEA_COLS.split(',') {
assert!(!col.is_empty(), "IDEA_COLS 含空列名段");
}
}
// ---------- 向量纯函数 ----------
#[test]
fn f32s_blob_roundtrip_nonempty() {
let v = vec![0.0, 1.5, -2.25, 3.14159, -0.0001];
let blob = f32s_to_blob(&v);
assert_eq!(blob.len(), v.len() * 4);
let back = blob_to_f32s(&blob);
assert_eq!(back, v);
}
#[test]
fn f32s_blob_roundtrip_empty() {
let v: Vec<f32> = vec![];
let blob = f32s_to_blob(&v);
assert!(blob.is_empty());
assert!(blob_to_f32s(&blob).is_empty());
}
#[test]
fn blob_to_f32s_drops_trailing_partial_bytes() {
// 1 个完整 f32 + 3 残字节 → chunks_exact 截断尾部
let blob = f32s_to_blob(&[42.0]);
let with_garbage: Vec<u8> = blob.into_iter().chain([0xff, 0xff, 0xff]).collect();
assert_eq!(blob_to_f32s(&with_garbage), vec![42.0]);
}
#[test]
fn cosine_identical_vectors_near_one() {
let a = vec![1.0, 2.0, 3.0];
let sim = cosine_similarity(&a, &a);
assert!((sim - 1.0).abs() < 1e-5, "identical ≈ 1.0, got {}", sim);
}
#[test]
fn cosine_orthogonal_vectors_near_zero() {
let a = vec![1.0, 0.0];
let b = vec![0.0, 1.0];
let sim = cosine_similarity(&a, &b);
assert!(sim.abs() < 1e-5, "orthogonal ≈ 0.0, got {}", sim);
}
#[test]
fn cosine_opposite_vectors_near_neg_one() {
let a = vec![1.0, 2.0, 3.0];
let b = vec![-1.0, -2.0, -3.0];
let sim = cosine_similarity(&a, &b);
assert!((sim + 1.0).abs() < 1e-5, "opposite ≈ -1.0, got {}", sim);
}
#[test]
fn cosine_dimension_mismatch_zips_to_shortest() {
// 实现用 zip(a,b),维度不匹配按较短的那个对齐,不 panic
let a = vec![1.0, 0.0, 0.0]; // 3 维
let b = vec![1.0, 0.0]; // 2 维 → zip 取前 2 个
let sim = cosine_similarity(&a, &b);
assert!((sim - 1.0).abs() < 1e-5, "zip 截断后应 ≈ 1.0, got {}", sim);
}
#[test]
fn cosine_zero_vector_does_not_panic() {
// 分母有 +1e-8 保护,零向量返回有限值而非 NaN/inf
let z = vec![0.0, 0.0, 0.0];
let sim = cosine_similarity(&z, &z);
assert!(sim.is_finite(), "零向量相似度应有限, got {}", sim);
}
// ---------- KnowledgeRepo 内存 DB ----------
/// 构造一条 KnowledgeRecord fixture
fn krec(
id: &str,
title: &str,
content: &str,
kind: &str,
status: &str,
confidence: Option<&str>,
reuse: i32,
) -> KnowledgeRecord {
KnowledgeRecord {
id: id.to_string(),
kind: kind.to_string(),
title: title.to_string(),
content: content.to_string(),
tags: Some("[]".to_string()),
status: status.to_string(),
confidence: confidence.map(|s| s.to_string()),
reuse_count: reuse,
verified: false,
source_project: Some("proj-1".to_string()),
source_ref: Some("conv:c1".to_string()),
reasoning: None,
embedding_status: None,
created_at: "1700000000000".to_string(),
updated_at: "1700000000000".to_string(),
}
}
async fn setup_repo() -> KnowledgeRepo {
let db = Database::open_in_memory().await.expect("open_in_memory");
KnowledgeRepo::new(&db)
}
#[tokio::test]
async fn search_matches_title_and_filters_non_published() {
let repo = setup_repo().await;
// 一条命中(title 含关键词)、一条 published 不命中、一条 status 非 published 但命中
repo.insert(krec("k1", "Rust 异步并发模型", "tokio 运行时", "lesson", "published", Some("high"), 5))
.await
.unwrap();
repo.insert(krec("k2", "无关标题", "无关内容", "lesson", "published", None, 0))
.await
.unwrap();
repo.insert(krec("k3", "Rust 异步进阶", "...", "lesson", "candidate", Some("low"), 9))
.await
.unwrap();
let hits = repo.search("异步", None, 10).await.unwrap();
// 只有 k1 是 published 且命中;k3 命中但非 published 被过滤
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "k1");
}
#[tokio::test]
async fn search_matches_content_branch() {
let repo = setup_repo().await;
// title 不含关键词,content 含 → 命中 content LIKE 分支
repo.insert(krec("k1", "标题", "深入理解 Rust 所有权与借用", "lesson", "published", None, 1))
.await
.unwrap();
let hits = repo.search("所有权", None, 10).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "k1");
}
#[tokio::test]
async fn search_no_keyword_match_returns_empty() {
let repo = setup_repo().await;
repo.insert(krec("k1", "Rust", "tokio", "lesson", "published", None, 1))
.await
.unwrap();
let hits = repo.search("不存在的关键词xyz", None, 10).await.unwrap();
assert!(hits.is_empty());
}
#[tokio::test]
async fn search_kind_filter_narrows_results() {
let repo = setup_repo().await;
repo.insert(krec("k1", "Rust 规范", "...", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("k2", "Rust 决策", "...", "decision", "published", None, 1))
.await
.unwrap();
// 不过滤 kind → 两条都命中
let all = repo.search("Rust", None, 10).await.unwrap();
assert_eq!(all.len(), 2);
// 只取 lesson → 仅 k1
let only_lesson = repo.search("Rust", Some("lesson"), 10).await.unwrap();
assert_eq!(only_lesson.len(), 1);
assert_eq!(only_lesson[0].id, "k1");
}
#[tokio::test]
async fn search_orders_by_reuse_count_desc() {
let repo = setup_repo().await;
repo.insert(krec("low", "Rust A", "...", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("high", "Rust B", "...", "lesson", "published", None, 50))
.await
.unwrap();
repo.insert(krec("mid", "Rust C", "...", "lesson", "published", None, 10))
.await
.unwrap();
let hits = repo.search("Rust", None, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["high", "mid", "low"]);
}
#[tokio::test]
async fn search_truncates_to_limit() {
let repo = setup_repo().await;
for i in 0..5 {
repo.insert(krec(&format!("k{i}"), &format!("Rust-{i}"), "...", "lesson", "published", None, i))
.await
.unwrap();
}
let hits = repo.search("Rust", None, 2).await.unwrap();
assert_eq!(hits.len(), 2);
// reuse_count 最高的两条(k4=4, k3=3)
assert_eq!(hits[0].id, "k4");
assert_eq!(hits[1].id, "k3");
}
// ---------- 分词关键词检索(search_keywords,知识注入路径) ----------
#[tokio::test]
async fn search_keywords_empty_input_returns_empty() {
let repo = setup_repo().await;
repo.insert(krec("k1", "Rust 异步", "tokio", "lesson", "published", None, 1))
.await
.unwrap();
// 关键词为空 → 直接空结果,不回退整句 LIKE(避免退化回整句匹配缺陷)
let hits = repo.search_keywords(&[], true, 10).await.unwrap();
assert!(hits.is_empty());
}
#[tokio::test]
async fn search_keywords_or_matches_any_keyword() {
let repo = setup_repo().await;
// k1 命中 title 的「异步」;k2 命中 content 的「tokio」——OR 语义两路都收
repo.insert(krec("k1", "Rust 异步并发", "无关内容", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("k2", "运行时", "tokio 深入", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["异步".to_string(), "tokio".to_string()];
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids.len(), 2);
assert!(ids.contains(&"k1"));
assert!(ids.contains(&"k2"));
}
#[tokio::test]
async fn search_keywords_title_hit_ranks_above_content_hit() {
let repo = setup_repo().await;
// k2 内容命中且复用次数极高,k1 仅标题命中 → 标题权重压过 reuse_count
repo.insert(krec("k1", "Rust 所有权", "无关内容", "lesson", "published", None, 0))
.await
.unwrap();
repo.insert(krec("k2", "无关标题", "聊聊 Rust 所有权与借用", "lesson", "published", None, 99))
.await
.unwrap();
let kws = vec!["所有权".to_string()];
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "k1", "标题命中应排在内容命中之前");
assert_eq!(hits[1].id, "k2");
}
#[tokio::test]
async fn search_keywords_more_title_hits_rank_first() {
let repo = setup_repo().await;
// 两条都是标题命中:k1 命中 2 词,k2 命中 1 词 → k1 前
repo.insert(krec("k1", "Rust 异步模型", "...", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("k2", "异步进阶", "...", "lesson", "published", None, 50))
.await
.unwrap();
let kws = vec!["Rust".to_string(), "异步".to_string()];
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "k1", "标题命中词数多者应排前(同档才看 reuse_count)");
}
#[tokio::test]
async fn search_keywords_include_candidates_toggle() {
let repo = setup_repo().await;
repo.insert(krec("pub1", "Rust 规范", "...", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("cand1", "Rust 提炼稿", "...", "lesson", "candidate", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
// 默认(纳入 candidate):published + candidate 都出
let hits = repo.search_keywords(&kws, true, 10).await.unwrap();
assert_eq!(hits.len(), 2, "include_candidates=true 应纳入 candidate");
// 关闭:回到仅 published(兼容旧行为)
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "pub1");
}
// ---------- search_keywords_scoped(跨会话召回 D2 软排序) ----------
/// 同项目知识排前:同档内 title 命分定序,异档同项目压过异项目高复用
#[tokio::test]
async fn scoped_same_project_ranks_above_foreign_despite_reuse() {
let repo = setup_repo().await;
// 外项目知识:标题命中 + 复用 999(档位应压过复用)
let mut foreign = krec("foreign", "Rust 所有权", "...", "lesson", "published", None, 999);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
// 同项目(proj-1,krec 默认):内容命中 + 复用 1
repo.insert(krec("same", "无关标题", "聊聊 Rust 所有权", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["所有权".to_string(), "Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "same", "同项目知识应压过异项目(即便后者 title 命中+高复用)");
assert_eq!(hits[1].id, "foreign");
}
/// 全局知识(NULL)居中档:同项目 > 全局 > 异项目
#[tokio::test]
async fn scoped_global_null_tier_between_same_and_foreign() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 1);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
let mut global = krec("global", "Rust 生命周期", "...", "lesson", "published", None, 1);
global.source_project = None;
repo.insert(global).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["same", "global", "foreign"], "三档顺序: 同项目>全局>异项目");
}
/// 回归门:project_id=None 与旧 search_keywords 行为逐字一致(同输入同顺序)
#[tokio::test]
async fn scoped_none_project_matches_legacy_search_keywords() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 50);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let legacy = repo.search_keywords(&kws, false, 10).await.unwrap();
let scoped = repo
.search_keywords_scoped(&kws, false, 10, None)
.await
.unwrap();
let a: Vec<_> = legacy.iter().map(|h| h.id.as_str()).collect();
let b: Vec<_> = scoped.iter().map(|h| h.id.as_str()).collect();
assert_eq!(a, b, "project_id=None 应与旧路径行为逐字一致(回归门)");
}
#[tokio::test]
async fn search_keywords_like_wildcards_escaped() {
let repo = setup_repo().await;
// 关键词含 % 时不应充当通配符("100%" 不该命中任意串)
repo.insert(krec("k1", "进度 100% 完成", "普通内容", "lesson", "published", None, 1))
.await
.unwrap();
repo.insert(krec("k2", "完全不同", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["100%".to_string()];
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, "k1");
}
// ---------- 知识路标读侧(list_waymark,devflow.md 投影通道) ----------
/// 入选门槛:published 且(verified 或 复用达标);candidate/archived/复用不足均淘汰
#[tokio::test]
async fn waymark_qualifies_verified_or_reused_published_only() {
let repo = setup_repo().await;
// verified=true 复用 0 → 入选(验证豁免复用门槛)
let mut v = krec("v", "已验证条目", "...", "lesson", "published", None, 0);
v.verified = true;
repo.insert(v).await.unwrap();
// 未验证复用 3 → 入选(压线达标)
repo.insert(krec("r3", "复用达标条目", "...", "lesson", "published", None, 3))
.await
.unwrap();
// 未验证复用 2 → 淘汰
repo.insert(krec("r2", "复用不足条目", "...", "lesson", "published", None, 2))
.await
.unwrap();
// 复用再高,状态非 published(candidate)→ 淘汰
repo.insert(krec("cand", "候选未发布", "...", "lesson", "candidate", None, 99))
.await
.unwrap();
// archived → 淘汰
repo.insert(krec("arch", "已归档", "...", "lesson", "archived", None, 99))
.await
.unwrap();
let hits = repo.list_waymark(None, 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids.len(), 2, "只应入选 已验证 + 复用达标 两条");
assert!(ids.contains(&"v") && ids.contains(&"r3"));
}
/// 两档序:项目档在前(复用降序,verified 豁免复用但仍按复用参与排序),
/// 跨项目补充档在后(复用降序);异项目高复用压不过同项目低复用(档位优先于复用)
#[tokio::test]
async fn waymark_project_tier_first_global_supplement_after() {
let repo = setup_repo().await;
repo.insert(krec("p9", "同项目复用9", "...", "lesson", "published", None, 9))
.await
.unwrap();
repo.insert(krec("p5", "同项目复用5", "...", "lesson", "published", None, 5))
.await
.unwrap();
// 同项目 verified 但零复用:入选项目档,排序落在复用条目之后
let mut pv = krec("pv", "同项目已验证零复用", "...", "lesson", "published", None, 0);
pv.verified = true;
repo.insert(pv).await.unwrap();
// 异项目高复用 → 补充档
let mut f = krec("f99", "异项目复用99", "...", "lesson", "published", None, 99);
f.source_project = Some("proj-2".to_string());
repo.insert(f).await.unwrap();
// 无归属全局条目 → 补充档
let mut g = krec("g10", "全局复用10", "...", "lesson", "published", None, 10);
g.source_project = None;
repo.insert(g).await.unwrap();
let hits = repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p9", "p5", "pv", "f99", "g10"],
"项目档在前(复用降序),补充档在后(复用降序:异项目99 > 全局10)"
);
// 两档 WHERE 互斥的去重属性:任一条目恰好出现一次
assert_eq!(ids.iter().filter(|i| **i == "p9").count(), 1, "同项目条目不得因两档重复出现");
}
/// 各档上限独立生效:项目档取复用最高的 project_limit 条,补充档另计 global_limit 条
/// (条目复用全部压线达标,确保裁剪只来自 LIMIT 而非入选门槛)
#[tokio::test]
async fn waymark_limits_cap_tiers_independently() {
let repo = setup_repo().await;
for reuse in 3..=5 {
repo.insert(krec(&format!("p{reuse}"), "同项目", "...", "lesson", "published", None, reuse))
.await
.unwrap();
}
for reuse in 3..=5 {
let mut g = krec(&format!("g{reuse}"), "全局", "...", "lesson", "published", None, reuse);
g.source_project = None;
repo.insert(g).await.unwrap();
}
let hits = repo.list_waymark(Some("proj-1"), 2, 2).await.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(
ids,
vec!["p5", "p4", "g5", "g4"],
"项目档取复用最高的 2 条,补充档独立取全局最高的 2 条"
);
}
/// 空库 → 空结果(路标子块整体缺席的前提,投影侧据此零追加)
#[tokio::test]
async fn waymark_empty_db_returns_empty() {
let repo = setup_repo().await;
assert!(repo.list_waymark(Some("proj-1"), 20, 10).await.unwrap().is_empty());
assert!(repo.list_waymark(None, 20, 10).await.unwrap().is_empty());
}
// ---------- 复用即信任(auto_publish_if_reused) ----------
#[tokio::test]
async fn auto_publish_promotes_candidate_at_threshold() {
let repo = setup_repo().await;
repo.insert(krec("c1", "提炼知识", "...", "lesson", "candidate", None, 0))
.await
.unwrap();
// 复用 1 次(< 阈值 2)→ 不晋升
repo.increment_reuse_count("c1").await.unwrap();
assert!(!repo.auto_publish_if_reused("c1", 2).await.unwrap());
assert_eq!(
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
"candidate"
);
// 复用第 2 次(达标)→ 晋升 published
repo.increment_reuse_count("c1").await.unwrap();
assert!(repo.auto_publish_if_reused("c1", 2).await.unwrap());
assert_eq!(
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
"published"
);
// 已 published 再调:幂等 false(不再重复晋升)
assert!(!repo.auto_publish_if_reused("c1", 2).await.unwrap());
}
#[tokio::test]
async fn auto_publish_ignores_published_and_missing() {
let repo = setup_repo().await;
repo.insert(krec("p1", "已发布", "...", "lesson", "published", None, 10))
.await
.unwrap();
// published 即使 reuse_count 远超阈值也不动(晋升仅面向 candidate)
assert!(!repo.auto_publish_if_reused("p1", 2).await.unwrap());
assert_eq!(
repo.get_by_id("p1").await.unwrap().unwrap().status.as_str(),
"published"
);
// 不存在的 id → false
assert!(!repo.auto_publish_if_reused("ghost", 2).await.unwrap());
}
// ---------- 显式转正(promote,对话内 knowledge_promote 工具通道) ----------
#[tokio::test]
async fn promote_candidates_to_published() {
let repo = setup_repo().await;
repo.insert(krec("c1", "候选知识", "...", "lesson", "candidate", None, 0))
.await
.unwrap();
// candidate → published: true,状态落库
assert!(repo.promote("c1").await.unwrap());
assert_eq!(
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
"published"
);
// 已 published 再调:幂等 false(晋升仅面向 candidate,不降级不重复)
assert!(!repo.promote("c1").await.unwrap());
assert_eq!(
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
"published"
);
}
#[tokio::test]
async fn promote_ignores_published_archived_and_missing() {
let repo = setup_repo().await;
repo.insert(krec("p1", "已发布", "...", "lesson", "published", None, 3))
.await
.unwrap();
repo.insert(krec("a1", "已归档", "...", "lesson", "archived", None, 1))
.await
.unwrap();
// published / archived 均 WHERE 不命中 → false 且状态不变
assert!(!repo.promote("p1").await.unwrap());
assert_eq!(
repo.get_by_id("p1").await.unwrap().unwrap().status.as_str(),
"published"
);
assert!(!repo.promote("a1").await.unwrap());
assert_eq!(
repo.get_by_id("a1").await.unwrap().unwrap().status.as_str(),
"archived"
);
// 不存在的 id → false
assert!(!repo.promote("ghost").await.unwrap());
}
#[tokio::test]
async fn list_by_status_orders_by_confidence_semantics() {
// confidence 字典序 high>low>medium 非预期,实现用 CASE 强制 high>medium>low
let repo = setup_repo().await;
repo.insert(krec("low", "t", "c", "lesson", "pending_review", Some("low"), 0))
.await
.unwrap();
repo.insert(krec("high", "t", "c", "lesson", "pending_review", Some("high"), 0))
.await
.unwrap();
repo.insert(krec("medium", "t", "c", "lesson", "pending_review", Some("medium"), 0))
.await
.unwrap();
// created_at 相同,纯靠 confidence 排序
let list = repo.list_by_status("pending_review").await.unwrap();
let confidences: Vec<_> = list.iter().map(|r| r.confidence.as_deref().unwrap_or("")).collect();
assert_eq!(confidences, vec!["high", "medium", "low"]);
}
#[tokio::test]
async fn list_by_status_filters_other_status() {
let repo = setup_repo().await;
repo.insert(krec("a", "t", "c", "lesson", "pending_review", Some("high"), 0))
.await
.unwrap();
repo.insert(krec("b", "t", "c", "lesson", "published", Some("high"), 0))
.await
.unwrap();
let list = repo.list_by_status("pending_review").await.unwrap();
assert_eq!(list.len(), 1);
assert_eq!(list[0].id, "a");
}
#[tokio::test]
async fn increment_reuse_count_is_atomic_plus_one() {
let repo = setup_repo().await;
repo.insert(krec("k1", "Rust", "...", "lesson", "published", None, 0))
.await
.unwrap();
// 连续 +1 两次
assert!(repo.increment_reuse_count("k1").await.unwrap());
assert!(repo.increment_reuse_count("k1").await.unwrap());
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
assert_eq!(rec.reuse_count, 2);
// 不存在的 id → false
assert!(!repo.increment_reuse_count("nope").await.unwrap());
}
#[tokio::test]
async fn search_vector_orders_by_cosine_and_truncates() {
let repo = setup_repo().await;
// 三条 published 记录,embedding 维度均为 2
repo.insert(krec("exact", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.insert(krec("orth", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.insert(krec("neg", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
// 一条非 published(应被过滤)
repo.insert(krec("hidden", "e", "c", "lesson", "candidate", None, 0))
.await
.unwrap();
repo.set_embedding("exact", &[1.0, 0.0]).await.unwrap(); // 与 query 完全同向
repo.set_embedding("orth", &[0.0, 1.0]).await.unwrap(); // 正交 ≈ 0
repo.set_embedding("neg", &[-1.0, 0.0]).await.unwrap(); // 反向 ≈ -1
repo.set_embedding("hidden", &[1.0, 0.0]).await.unwrap(); // 非 published 过滤掉
let results = repo.search_vector(&[1.0, 0.0], 10).await.unwrap();
// hidden 被 status 过滤,剩 3 条
assert_eq!(results.len(), 3);
// 按相似度降序: exact(≈1) > orth(≈0) > neg(≈-1)
assert_eq!(results[0].0.id, "exact");
assert_eq!(results[1].0.id, "orth");
assert_eq!(results[2].0.id, "neg");
assert!((results[0].1 - 1.0).abs() < 1e-5);
assert!(results[1].1.abs() < 1e-5);
assert!((results[2].1 + 1.0).abs() < 1e-5);
}
#[tokio::test]
async fn search_vector_skips_dimension_mismatch() {
let repo = setup_repo().await;
repo.insert(krec("dim2", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.insert(krec("dim3", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("dim2", &[1.0, 0.0]).await.unwrap();
repo.set_embedding("dim3", &[1.0, 0.0, 0.0]).await.unwrap(); // 维度不匹配
// query 是 2 维,dim3 被跳过
let results = repo.search_vector(&[1.0, 0.0], 10).await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].0.id, "dim2");
}
#[tokio::test]
async fn search_vector_truncates_to_limit() {
let repo = setup_repo().await;
for i in 0..4 {
repo.insert(krec(&format!("k{i}"), "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
// 全部与 query 同向,相似度相同 → 仅验证 truncate 生效
repo.set_embedding(&format!("k{i}"), &[1.0, 0.0]).await.unwrap();
}
let results = repo.search_vector(&[1.0, 0.0], 2).await.unwrap();
assert_eq!(results.len(), 2);
}
#[tokio::test]
async fn search_vector_ignores_records_without_embedding() {
let repo = setup_repo().await;
// published 但未写 embedding
repo.insert(krec("noemb", "e", "c", "lesson", "published", None, 0))
.await
.unwrap();
let results = repo.search_vector(&[1.0, 0.0], 10).await.unwrap();
assert!(results.is_empty());
}
// ---------- V23 embedding_status 补偿重试 ----------
#[tokio::test]
async fn set_embedding_marks_status_done() {
// V23:set_embedding 成功写入时应同步置 embedding_status='done'
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
assert_eq!(rec.embedding_status.as_deref(), Some("done"));
}
#[tokio::test]
async fn mark_embedding_failed_sets_status() {
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
assert!(repo.mark_embedding_failed("k1").await.unwrap());
let rec = repo.get_by_id("k1").await.unwrap().expect("记录存在");
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
}
#[tokio::test]
async fn mark_embedding_failed_idempotent() {
// 重复标记 failed 无副作用
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
assert!(repo.mark_embedding_failed("k1").await.unwrap());
let rec = repo.get_by_id("k1").await.unwrap().unwrap();
assert_eq!(rec.embedding_status.as_deref(), Some("failed"));
}
#[tokio::test]
async fn mark_embedding_failed_missing_row_returns_false() {
let repo = setup_repo().await;
// 不存在的 id → affected=0
let ok = repo.mark_embedding_failed("ghost").await.unwrap();
assert!(!ok);
}
#[tokio::test]
async fn list_failed_embeddings_only_returns_published_failed() {
let repo = setup_repo().await;
// k1:published + failed → 应列出
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
// k2:candidate + failed → 不应列出(候选不参与检索,重试无意义)
repo.insert(krec("k2", "t", "c", "lesson", "candidate", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k2").await.unwrap();
// k3:published + done → 不应列出
repo.insert(krec("k3", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k3", &[1.0, 0.0]).await.unwrap();
// k4:published + 未标记(NULL)→ 不应列出
repo.insert(krec("k4", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
let failed = repo.list_failed_embeddings().await.unwrap();
let ids: Vec<_> = failed.iter().map(|r| r.id.as_str()).collect();
assert_eq!(ids, vec!["k1"], "只应返回 published + failed 的条目");
}
#[tokio::test]
async fn list_failed_embeddings_empty_when_none_failed() {
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.set_embedding("k1", &[1.0, 0.0]).await.unwrap();
let failed = repo.list_failed_embeddings().await.unwrap();
assert!(failed.is_empty());
}
#[tokio::test]
async fn failed_retry_flow_done_after_set_embedding() {
// 补偿重试完整流程:failed → set_embedding 成功 → done(不再出现在 list_failed)
let repo = setup_repo().await;
repo.insert(krec("k1", "t", "c", "lesson", "published", None, 0))
.await
.unwrap();
repo.mark_embedding_failed("k1").await.unwrap();
assert_eq!(repo.list_failed_embeddings().await.unwrap().len(), 1);
// 重试成功
repo.set_embedding("k1", &[0.5, 0.5]).await.unwrap();
assert!(repo.list_failed_embeddings().await.unwrap().is_empty());
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(15 字段全填,IdeaRecord 不含 deleted_at —— 纯 SQL 过滤)。
fn irec(id: &str, title: &str) -> IdeaRecord {
IdeaRecord {
id: id.to_string(),
title: title.to_string(),
description: String::new(),
status: IdeaStatus::Draft,
priority: 1,
score: None,
tags: None,
source: None,
promoted_to: None,
ai_analysis: None,
scores: None,
related_ids: None,
archived: false,
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());
}
// ── LW-8 立项认领 CAS(claim_promotion)──────────────────────────
// 锁定:① 首次认领成功(写入 status=promoted + promoted_to);② 二次认领 CAS 失败
// (promoted_to IS NULL 前置)且不覆盖已有立项;③ 软删灵感不可认领。
#[tokio::test]
async fn idea_claim_promotion_claims_once_only() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "立项")).await.unwrap();
// 首次认领:promoted_to IS NULL → true,status=promoted + promoted_to 落库
assert!(repo.claim_promotion("i1", "p-1").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.status.as_str(), "promoted");
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"));
// 二次认领:promoted_to 已非空(CAS)→ false(双击/并发重复立项防护)
assert!(!repo.claim_promotion("i1", "p-2").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.promoted_to.as_deref(), Some("p-1"), "CAS 失败不得覆盖已有立项");
}
#[tokio::test]
async fn idea_claim_promotion_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "回收站")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
assert!(
!repo.claim_promotion("i1", "p-1").await.unwrap(),
"软删灵感不可立项认领(deleted_at IS NULL 收口)"
);
}
// ── LW-6 update_field_active(软删过滤)─────────────────────────
#[tokio::test]
async fn idea_update_field_active_skips_soft_deleted() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 未软删:可改
assert!(repo.update_field_active("i1", "title", "新标题").await.unwrap());
// 软删后:update_field_active 拒(0 行),字段不被改动
repo.soft_delete("i1").await.unwrap();
assert!(!repo.update_field_active("i1", "title", "回收站改").await.unwrap());
let rec = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(rec.title, "新标题", "软删后字段不应被改动");
}
#[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");
}
// ============================================================
// update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
// ============================================================
#[tokio::test]
async fn idea_update_full_cas_success_when_expected_matches() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
let current = repo.get_by_id("i1").await.unwrap().unwrap();
// 本地构建新版本(title + updated_at 递增)
let mut rec = current.clone();
rec.title = "本地新标题".to_string();
rec.updated_at = "1800000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(ok, "expected 与 DB 一致应写入");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "本地新标题");
assert_eq!(after.updated_at, "1800000000000");
}
#[tokio::test]
async fn idea_update_full_cas_skips_soft_deleted() {
// 回收站灵感不可被 CAS 命中(deleted_at IS NULL 守卫,V28 软删)
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
repo.soft_delete("i1").await.unwrap();
// soft_delete 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("i1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.title = "回收站里改标题".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "软删灵感 CAS 应拒绝(deleted_at 守卫)");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "原标题", "回收站灵感内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
#[tokio::test]
async fn idea_update_full_cas_skips_archived() {
// 归档灵感不可被 CAS 命中(archived = 0 守卫,V47 归档,对标 tasks 同族):
// 归档=冻结,须先取消归档(set_archived(false))再改,防 MCP 对归档灵感静默改写。
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
assert!(repo.set_archived("i1", true).await.unwrap());
// set_archived 刷新了 updated_at,取当前值作 expected(排除版本不匹配干扰)
let current = repo.get_by_id("i1").await.unwrap().unwrap();
let mut rec = current.clone();
rec.title = "归档里改标题".to_string();
rec.updated_at = "1900000000000".to_string();
let ok = repo
.update_full_cas(&rec, &current.updated_at)
.await
.unwrap();
assert!(!ok, "归档灵感 CAS 应拒绝(archived 守卫)");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "原标题", "归档灵感内容不应被改写");
assert_eq!(after.updated_at, current.updated_at, "updated_at 不应抖动");
}
#[tokio::test]
async fn idea_update_full_cas_conflict_when_expected_stale() {
let repo = setup_idea_repo().await;
repo.insert(irec("i1", "原标题")).await.unwrap();
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
let mut other = repo.get_by_id("i1").await.unwrap().unwrap();
other.title = "他人已改".to_string();
other.updated_at = "1800000000000".to_string();
assert!(repo.update_full(&other).await.unwrap());
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
let mut mine = repo.get_by_id("i1").await.unwrap().unwrap();
mine.title = "我的修改".to_string();
mine.updated_at = "1900000000000".to_string();
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
assert!(!ok, "expected 过期(并发已改)应返回 false");
let after = repo.get_by_id("i1").await.unwrap().unwrap();
assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改");
assert_eq!(after.updated_at, "1800000000000");
}
}