新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,19 @@ use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// 当前迁移版本
|
||||
const MIGRATION_VERSION: i32 = 2;
|
||||
const MIGRATION_VERSION: i32 = 12;
|
||||
|
||||
/// 检测表是否存在指定列(幂等迁移通用辅助)
|
||||
fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
|
||||
let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { return false };
|
||||
let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(1)) else { return false };
|
||||
for r in rows {
|
||||
if let Ok(name) = r {
|
||||
if name == col { return true; }
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 执行所有迁移
|
||||
pub fn run(conn: &Connection) -> Result<()> {
|
||||
@@ -31,8 +43,49 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
migrate_v2(conn)?;
|
||||
}
|
||||
|
||||
// 未来迁移在此扩展:
|
||||
// if current_version < 3 { migrate_v3(conn)?; }
|
||||
if current_version < 3 {
|
||||
migrate_v3(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 4 {
|
||||
migrate_v4(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 5 {
|
||||
migrate_v5(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 6 {
|
||||
migrate_v6(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 7 {
|
||||
migrate_v7(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 9 {
|
||||
migrate_v9(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 10 {
|
||||
migrate_v10(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 11 {
|
||||
migrate_v11(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 12 {
|
||||
migrate_v12(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 13 {
|
||||
migrate_v13(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -53,6 +106,170 @@ fn migrate_v2(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V3: AI 对话表补建(新库) + 归档标记列(新老库统一)
|
||||
fn migrate_v3(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V3_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [3])?;
|
||||
tracing::info!("迁移 v3 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V4: 幂等补 ai_conversations.archived 列
|
||||
///
|
||||
/// 修复历史缺陷:早期 v3 迁移仅写入版本号 3,ALTER ADD COLUMN archived 未实际生效,
|
||||
/// 导致 schema_version=3 但 ai_conversations 缺列,from_row 读 archived 报错,
|
||||
/// list_all 失败 → 前端历史会话不显示 + 新对话 insert 失败。
|
||||
/// 因 run() 按 `current_version < 3` 跳过 v3,该列无法靠 v3 自补。
|
||||
/// 此处用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全。
|
||||
fn migrate_v4(conn: &Connection) -> Result<()> {
|
||||
let has_archived = column_exists(conn, "ai_conversations", "archived");
|
||||
if !has_archived {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v4: 补建 ai_conversations.archived 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [4])?;
|
||||
tracing::info!("迁移 v4 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V5: 幂等补 ai_conversations.prompt_tokens / completion_tokens 列
|
||||
///
|
||||
/// 流式 token 用量记录:对话级累计 input/output token(由 save_conversation 写入)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)。
|
||||
fn migrate_v5(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_conversations", "prompt_tokens") {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN prompt_tokens INTEGER", [])?;
|
||||
tracing::info!("v5: 补建 ai_conversations.prompt_tokens 列");
|
||||
}
|
||||
if !column_exists(conn, "ai_conversations", "completion_tokens") {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN completion_tokens INTEGER", [])?;
|
||||
tracing::info!("v5: 补建 ai_conversations.completion_tokens 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [5])?;
|
||||
tracing::info!("迁移 v5 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V6: 幂等补 ai_conversations.models 列
|
||||
///
|
||||
/// 对话级多 model 记录:JSON 数组字符串(去重存对话用过的所有 model)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5 模式)。
|
||||
fn migrate_v6(conn: &Connection) -> Result<()> {
|
||||
let has_models = column_exists(conn, "ai_conversations", "models");
|
||||
if !has_models {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN models TEXT", [])?;
|
||||
tracing::info!("v6: 补建 ai_conversations.models 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [6])?;
|
||||
tracing::info!("迁移 v6 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V7: 知识库表 — 经验沉淀的基本单元(共享记忆层)
|
||||
///
|
||||
/// 状态机: candidate → pending_review → published → archived
|
||||
/// AI 只产 candidate,人工门控发布;reuse_count 是唯一客观排序信号。
|
||||
/// effectiveness 列不建(决策撤销人工评分)。时间字段用毫秒字符串(同既有 model 约定)。
|
||||
fn migrate_v7(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V7_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [7])?;
|
||||
tracing::info!("迁移 v7 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V8: 幂等补 knowledges.embedding 列(向量检索)
|
||||
///
|
||||
/// 存 Vec<f32> 的小端字节序列化 BLOB。NULL = 未嵌入(走 LIKE 降级)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6 模式)。
|
||||
fn migrate_v8(conn: &Connection) -> Result<()> {
|
||||
let has_embedding = column_exists(conn, "knowledges", "embedding");
|
||||
if !has_embedding {
|
||||
conn.execute("ALTER TABLE knowledges ADD COLUMN embedding BLOB", [])?;
|
||||
tracing::info!("v8: 补建 knowledges.embedding 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [8])?;
|
||||
tracing::info!("迁移 v8 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V9: 幂等补建 ai_providers + ai_tool_executions 表
|
||||
///
|
||||
/// 历史遗漏:这两张表从未写入迁移文件(V1-V8 均未包含),
|
||||
/// 旧库可能通过其他方式已建,新库缺失导致 save_provider 等操作报 SQL 错误。
|
||||
/// 用 CREATE TABLE IF NOT EXISTS 幂等,已有表不受影响。
|
||||
fn migrate_v9(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V9_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [9])?;
|
||||
tracing::info!("迁移 v9 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V10: 知识生命线 — 补 knowledges.reasoning 列 + 新建 knowledge_events 事件表
|
||||
///
|
||||
/// - reasoning: AI 提炼时给出的"为何值得沉淀"判断依据(此前 prompt 要求但写库丢弃,
|
||||
/// 此处补列修复;老库行默认 NULL,前端降级展示"手动录入/无依据")。幂等(PRAGMA 探测)。
|
||||
/// - knowledge_events: 追加型审计表,记录产生/审核/引用/归档四类事件,支撑生命线视图。
|
||||
/// 独立表(非 JSON 嵌主表): 一条知识可被引用数百次,JSON 嵌入致行膨胀+更新竞争。
|
||||
fn migrate_v10(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "knowledges", "reasoning") {
|
||||
conn.execute("ALTER TABLE knowledges ADD COLUMN reasoning TEXT", [])?;
|
||||
tracing::info!("v10: 补建 knowledges.reasoning 列");
|
||||
}
|
||||
conn.execute_batch(V10_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [10])?;
|
||||
tracing::info!("迁移 v10 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V11: 幂等补 projects.deleted_at 列(软删回收站)
|
||||
///
|
||||
/// 删除项目改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
|
||||
/// ProjectRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL 过滤;
|
||||
/// 子表(tasks/releases/branches)不动,FK 仍满足,项目数据完整保留待恢复。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10 模式)。
|
||||
fn migrate_v11(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "projects", "deleted_at") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT", [])?;
|
||||
tracing::info!("v11: 补建 projects.deleted_at 列(软删回收站)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [11])?;
|
||||
tracing::info!("迁移 v11 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V12: 幂等补 projects.path / projects.stack 列(项目绑定真实代码目录)
|
||||
///
|
||||
/// 项目与磁盘代码库脱钩是项目管理核心缺失:此版补 path(绑定目录绝对路径) +
|
||||
/// stack(技术栈 JSON 数组字符串),为「绑定目录 + 探测技术栈」打地基,
|
||||
/// 第二步「导入历史项目」直接复用。两列均 nullable,老项目 path/stack=NULL 天然兼容。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。
|
||||
fn migrate_v12(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "projects", "path") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN path TEXT", [])?;
|
||||
tracing::info!("v12: 补建 projects.path 列(绑定代码目录)");
|
||||
}
|
||||
if !column_exists(conn, "projects", "stack") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN stack TEXT", [])?;
|
||||
tracing::info!("v12: 补建 projects.stack 列(技术栈)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [12])?;
|
||||
tracing::info!("迁移 v12 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V13: 通用应用设置 KV 表(前端 localStorage 迁移目标)
|
||||
///
|
||||
/// 存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。CREATE TABLE IF NOT EXISTS 幂等。
|
||||
fn migrate_v13(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V13_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [13])?;
|
||||
tracing::info!("迁移 v13 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V1 建表 SQL
|
||||
const V1_SQL: &str = "
|
||||
-- 想法表
|
||||
@@ -172,3 +389,116 @@ CREATE TABLE IF NOT EXISTS branches (
|
||||
CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id);
|
||||
";
|
||||
|
||||
/// V3 迁移 SQL — AI 对话表补建(新库首次创建;老库 IF NOT EXISTS 跳过)
|
||||
///
|
||||
/// 注:archived 列不在此处 ALTER —— 由 v4 迁移幂等补建。
|
||||
/// (历史 v3 曾写入版本号但 ALTER 未生效,统一交 v4 用 PRAGMA 探测修复)
|
||||
const V3_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]',
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// V7 建表 SQL — 知识库表
|
||||
///
|
||||
/// kind: 7 种 KnowledgeKind snake_case(review_rule/prompt_template/pitfall/
|
||||
/// architecture_pattern/diagnosis/deployment_note/workflow_optimization)
|
||||
/// status: candidate|pending_review|published|archived
|
||||
/// confidence: high|medium|low(AI 提炼自评,可空)
|
||||
/// verified: 发布审核时一次性人工标(INTEGER 0/1)
|
||||
/// reuse_count: 检索命中自动 +1(唯一客观排序信号)
|
||||
/// source_project/source_ref: 来源溯源(不过滤,仅展示)
|
||||
const V7_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS knowledges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'pitfall',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'candidate',
|
||||
confidence TEXT,
|
||||
reuse_count INTEGER NOT NULL DEFAULT 0,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
source_project TEXT,
|
||||
source_ref TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC);
|
||||
";
|
||||
|
||||
/// V9 建表 SQL — AI Provider 配置 + 工具执行审计
|
||||
///
|
||||
/// 历史遗漏补建:ai_providers(AI 提供商配置) + ai_tool_executions(工具调用审计记录)。
|
||||
/// CREATE TABLE IF NOT EXISTS 保证老库(已有表)和新库(缺表)均安全。
|
||||
const V9_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
default_model TEXT NOT NULL,
|
||||
models TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
tool_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments TEXT NOT NULL,
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium',
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT
|
||||
);
|
||||
";
|
||||
|
||||
/// V10 建表 SQL — 知识生命线事件表
|
||||
///
|
||||
/// 追加型审计表(只增不改),记录知识产生/审核/引用/归档四类事件,支撑生命线视图。
|
||||
/// event_type: created | extracted | status_changed | referenced | archived
|
||||
/// context_json: 因 event_type 而异的上下文(如引用事件的 conv_id+query)。
|
||||
const V10_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS knowledge_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
knowledge_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
context_json TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid ON knowledge_events(knowledge_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_type ON knowledge_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid_type ON knowledge_events(knowledge_id, event_type);
|
||||
";
|
||||
|
||||
/// V13 建表 SQL — 通用应用设置 KV 表
|
||||
///
|
||||
/// 前端 localStorage 迁移目标:key/value(JSON 字符串)+ updated_at。
|
||||
/// CREATE TABLE IF NOT EXISTS 幂等(新库建、老库已有则跳过)。
|
||||
const V13_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct ProjectRecord {
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub idea_id: Option<String>,
|
||||
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
|
||||
pub path: Option<String>,
|
||||
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
||||
pub stack: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -149,6 +153,10 @@ pub struct AiConversationRecord {
|
||||
pub messages: String, // JSON array of ChatMessage
|
||||
pub provider_id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重)
|
||||
pub archived: bool, // 是否归档(侧栏折叠展示)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -168,3 +176,37 @@ pub struct AiToolExecutionRecord {
|
||||
pub executed_at: Option<String>,
|
||||
pub decided_by: Option<String>, // human/auto
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库模型 (V7)
|
||||
// ============================================================
|
||||
|
||||
/// 知识条目记录(经验沉淀基本单元,共享记忆层)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeRecord {
|
||||
pub id: String,
|
||||
pub kind: String, // KnowledgeKind snake_case(7 种)
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: Option<String>, // JSON 数组字符串
|
||||
pub status: String, // candidate|pending_review|published|archived
|
||||
pub confidence: Option<String>, // high|medium|low(AI 提炼自评,可空)
|
||||
pub reuse_count: i32, // 唯一客观排序信号
|
||||
pub verified: bool, // 发布审核时一次性人工标
|
||||
pub source_project: Option<String>, // 来源项目(仅溯源不过滤)
|
||||
pub source_ref: Option<String>, // 来源实体引用(如 conv:{id})
|
||||
pub reasoning: Option<String>, // AI 提炼判断依据("为何值得沉淀"),手动录入为 None
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 知识生命线事件记录(追加型审计:产生/审核/引用/归档)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeEventRecord {
|
||||
pub id: String,
|
||||
pub knowledge_id: String,
|
||||
pub event_type: String, // created | extracted | status_changed | referenced | archived
|
||||
pub source_ref: Option<String>, // 触发来源: conv:{id} / manual / system
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
271
crates/df-storage/tests/project_soft_delete.rs
Normal file
271
crates/df-storage/tests/project_soft_delete.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
//! ProjectRepo 软删回收站 + 级联清理 的集成测试。
|
||||
//!
|
||||
//! 覆盖三类行为:
|
||||
//! 1. purge_with_descendants — 事务级联删 branches→releases→tasks→projects。
|
||||
//! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。
|
||||
//! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。
|
||||
|
||||
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
fn now_ts() -> String {
|
||||
"1700000000000".to_string()
|
||||
}
|
||||
|
||||
fn project(id: &str) -> ProjectRecord {
|
||||
ProjectRecord {
|
||||
id: id.to_string(),
|
||||
name: format!("proj-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "todo".to_string(),
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn release(id: &str, project_id: &str) -> ReleaseRecord {
|
||||
ReleaseRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
status: "draft".to_string(),
|
||||
task_ids: "[]".to_string(),
|
||||
changelog: None,
|
||||
created_at: now_ts(),
|
||||
released_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn branch(id: &str, project_id: &str) -> BranchRecord {
|
||||
BranchRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
task_id: None,
|
||||
name: format!("br-{id}"),
|
||||
base: "main".to_string(),
|
||||
status: "open".to_string(),
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
merged_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (ProjectRepo, TaskRepo, ReleaseRepo, BranchRepo) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
(
|
||||
ProjectRepo::new(&db),
|
||||
TaskRepo::new(&db),
|
||||
ReleaseRepo::new(&db),
|
||||
BranchRepo::new(&db),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. purge_with_descendants — 级联删除
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_removes_project_and_all_children() {
|
||||
let (projects, tasks, releases, branches) = setup().await;
|
||||
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
tasks.insert(task("t2", "p1")).await.unwrap();
|
||||
releases.insert(release("r1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b2", "p1")).await.unwrap();
|
||||
|
||||
// 另一个项目的子记录不应被波及(只删 p1 的)
|
||||
projects.insert(project("p2")).await.unwrap();
|
||||
tasks.insert(task("t3", "p2")).await.unwrap();
|
||||
|
||||
let affected = projects.purge_with_descendants("p1").await.unwrap();
|
||||
assert!(affected, "应命中 p1");
|
||||
|
||||
// 四表对应记录全消失
|
||||
assert!(projects.get_by_id("p1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t2").await.unwrap().is_none());
|
||||
assert!(releases.get_by_id("r1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b2").await.unwrap().is_none());
|
||||
|
||||
// 邻居项目 p2 完好
|
||||
assert!(projects.get_by_id("p2").await.unwrap().is_some());
|
||||
assert!(tasks.get_by_id("t3").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
let affected = projects.purge_with_descendants("ghost").await.unwrap();
|
||||
assert!(!affected, "不存在的 id 不命中");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_is_atomic_on_missing_child_table_row() {
|
||||
// 事务性验证:即使 project 存在但被删语句之间无冲突,purge 应整体成功或整体不变。
|
||||
// 这里验证 commit 成功路径下子表清空、父表删除;反向证明未部分提交(若部分提交,
|
||||
// projects 会在事务中途被删但 branches 残留 —— 通过重新 purge 验证幂等空操作)。
|
||||
let (projects, tasks, _releases, branches) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b1", "p1")).await.unwrap();
|
||||
|
||||
assert!(projects.purge_with_descendants("p1").await.unwrap());
|
||||
|
||||
// 事务已提交:父表与全部子表一致清空(不会出现父删子留)
|
||||
assert!(projects.get_by_id("p1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b1").await.unwrap().is_none());
|
||||
|
||||
// 再次 purge 已不存在的 id → false,且无副作用
|
||||
assert!(!projects.purge_with_descendants("p1").await.unwrap());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. soft_delete / list_active / list_deleted / restore
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_moves_project_out_of_active_into_deleted() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
projects.insert(project("p2")).await.unwrap();
|
||||
|
||||
let ok = projects.soft_delete("p1").await.unwrap();
|
||||
assert!(ok, "软删 p1 应命中");
|
||||
|
||||
let active = projects.list_active().await.unwrap();
|
||||
let active_ids: Vec<_> = active.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(!active_ids.contains(&"p1"), "p1 不应在 active");
|
||||
assert!(active_ids.contains(&"p2"), "p2 仍在 active");
|
||||
|
||||
let deleted = projects.list_deleted().await.unwrap();
|
||||
let deleted_ids: Vec<_> = deleted.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(deleted_ids.contains(&"p1"), "p1 应在 deleted");
|
||||
assert!(!deleted_ids.contains(&"p2"), "p2 不在 deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_moves_project_back_into_active() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
projects.soft_delete("p1").await.unwrap();
|
||||
|
||||
let ok = projects.restore("p1").await.unwrap();
|
||||
assert!(ok, "恢复 p1 应命中");
|
||||
|
||||
let active_ids: Vec<_> = projects
|
||||
.list_active()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert!(active_ids.contains(&"p1".to_string()), "p1 恢复后回到 active");
|
||||
|
||||
let deleted_ids: Vec<_> = projects
|
||||
.list_deleted()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert!(!deleted_ids.contains(&"p1".to_string()), "p1 恢复后离开 deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_on_already_deleted_returns_false() {
|
||||
// WHERE deleted_at IS NULL 守卫:已删项二次软删不命中
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
|
||||
assert!(projects.soft_delete("p1").await.unwrap());
|
||||
// 第二次对已删项 → false(守卫生效)
|
||||
assert!(
|
||||
!projects.soft_delete("p1").await.unwrap(),
|
||||
"已删项二次软删应返回 false"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
assert!(!projects.soft_delete("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
assert!(!projects.restore("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. allowed_columns_for — 按表隔离
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_rejects_cross_table_column_tasks_name() {
|
||||
// tasks 表白名单无 "name"(那是 projects/branches 的列)→ Err
|
||||
let (projects, tasks, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
|
||||
let res = tasks.update_field("t1", "name", "x").await;
|
||||
assert!(res.is_err(), "tasks 表无 name 列,应被白名单拒绝");
|
||||
|
||||
// 对照:tasks 的合法列 title 通过
|
||||
let ok = tasks.update_field("t1", "title", "renamed").await.unwrap();
|
||||
assert!(ok);
|
||||
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.title, "renamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_allows_tasks_status() {
|
||||
let (projects, tasks, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
|
||||
let ok = tasks.update_field("t1", "status", "done").await.unwrap();
|
||||
assert!(ok);
|
||||
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.status, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_rejects_projects_deleted_at() {
|
||||
// deleted_at 是专用路径列(soft_delete/restore),不进通用白名单 → 拒绝
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
|
||||
let res = projects.update_field("p1", "deleted_at", "123").await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"deleted_at 不在 projects 白名单,应被拒(专用路径才能写)"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user