一个项目可含多个工程(Module),每个工程是独立代码仓库, 有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下 只有一个工程,用户无感工程概念。 数据层: - project_modules 表(V34 迁移):工程名/目录/Git 地址/技术栈 - ProjectModuleRepo:完整 CRUD + 按项目列表(排序) 后端接口: - 工程 CRUD:添加/更新/删除/列表 - Git 状态查询:实时跑 git 命令返回当前分支/改动文件/最近提交 (10 秒超时,无 Git 仓库返回空状态) 创建项目适配: - 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果) AI 工具: - list_project_modules:列出项目工程列表(AI 了解项目结构) 同时新增工程系统设计方案文档。
324 lines
14 KiB
Rust
324 lines
14 KiB
Rust
//! Repository 模式的 CRUD 操作
|
|
//!
|
|
//! 为各表提供 Repo 结构体,统一实现 insert / get_by_id / list_all / query / update_field / delete。
|
|
//!
|
|
//! 按表域拆分(SMELL-P1-9,对齐 SMELL-P0-2 拆分思路降 2212 行单文件复杂度):
|
|
//! - [`mod@settings`]:SettingsRepo + 列白名单(allowed_columns_for/validate_column_name/is_allowed_column)
|
|
//! - [`mod@project_repo`]:ProjectRepo/BranchRepo/ReleaseRepo/WorkflowRepo/NodeExecutionRepo
|
|
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
|
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
|
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
|
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
|
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
|
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
|
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
|
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
|
|
//!
|
|
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
|
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
|
|
|
mod conversation_repo;
|
|
mod idea_eval_repo;
|
|
mod idea_repo;
|
|
mod message_repo;
|
|
mod project_event_repo;
|
|
mod project_module_repo;
|
|
mod project_repo;
|
|
mod project_service_repo;
|
|
mod settings;
|
|
mod task_link_repo;
|
|
mod task_repo;
|
|
|
|
pub use conversation_repo::*;
|
|
pub use idea_eval_repo::*;
|
|
pub use idea_repo::*;
|
|
pub use message_repo::*;
|
|
pub use project_event_repo::*;
|
|
pub use project_module_repo::*;
|
|
pub use project_repo::*;
|
|
pub use project_service_repo::*;
|
|
pub use settings::*;
|
|
pub use task_link_repo::*;
|
|
pub use task_repo::*;
|
|
|
|
// ============================================================
|
|
// 辅助宏 — 消除多个 Repo 的重复样板
|
|
// ============================================================
|
|
|
|
/// 一次性为一张表生成完整的 Repo 结构体及其 CRUD 实现。
|
|
///
|
|
/// 用法: `impl_repo!(RepoName, ModelType, "table_name", from_row_body, insert_body, update_body);`
|
|
///
|
|
/// 约束:`$record` 须实现 `Clone`(`update_full` 内部 clone 后丢给 spawn_blocking)。
|
|
///
|
|
/// 宏通过文件末 `pub(crate) use impl_repo;`(Rust 2e textual scope 重导出,非
|
|
/// `#[macro_use]`)对各子模块可见,子模块 `use super::impl_repo;` 后直接调用。
|
|
/// `from_row`/`insert`/`update` 体内引用的 helper
|
|
/// (storage_err/now_millis_str/validate_column_name 及各 from_row)须在调用处可见。
|
|
macro_rules! impl_repo {
|
|
(
|
|
$(#[$meta:meta])*
|
|
$name:ident, $record:ident, $table:literal,
|
|
from_row => |$row:ident| $from_body:expr,
|
|
insert => |$conn:ident, $rec:ident| $insert_body:expr,
|
|
update => |$u_conn:ident, $u_rec:ident| $update_body:expr
|
|
) => {
|
|
$(#[$meta])*
|
|
pub struct $name {
|
|
conn: Arc<Mutex<Connection>>,
|
|
}
|
|
|
|
impl $name {
|
|
pub fn new(db: &Database) -> Self {
|
|
Self { conn: db.conn() }
|
|
}
|
|
|
|
pub async fn insert(&self, record: $record) -> Result<String> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let id = record.id.clone();
|
|
let $conn = &*guard;
|
|
let $rec = &record;
|
|
$insert_body.map_err(storage_err)?;
|
|
Ok(id)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
pub async fn get_by_id(&self, id: &str) -> Result<Option<$record>> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(&format!("SELECT * FROM {} WHERE id = ?1", $table))
|
|
.map_err(storage_err)?;
|
|
let row = stmt
|
|
.query_row(params![id], |$row| $from_body)
|
|
.optional()
|
|
.map_err(storage_err)?;
|
|
Ok(row)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
pub async fn list_all(&self) -> Result<Vec<$record>> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(&format!("SELECT * FROM {} ORDER BY created_at DESC", $table))
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map([], |$row| $from_body)
|
|
.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 query(&self, field: &str, value: &str) -> Result<Vec<$record>> {
|
|
// 白名单校验,防止 SQL 注入
|
|
validate_column_name(field, $table)?;
|
|
let conn = self.conn.clone();
|
|
let sql = format!("SELECT * FROM {} WHERE {} = ?1 ORDER BY created_at DESC", $table, field);
|
|
let value = value.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare(&sql)
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map(params![value], |$row| $from_body)
|
|
.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 update_field(&self, id: &str, field: &str, value: &str) -> Result<bool> {
|
|
validate_column_name(field, $table)?;
|
|
let conn = self.conn.clone();
|
|
let sql = format!("UPDATE {} SET {} = ?1, updated_at = ?2 WHERE id = ?3", $table, 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)?
|
|
}
|
|
|
|
/// 整体更新记录(全可变字段,保留 id 与 created_at),单次原子写
|
|
pub async fn update_full(&self, record: &$record) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let rec = record.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let $u_conn = &*guard;
|
|
let $u_rec = &rec;
|
|
let affected =
|
|
$update_body.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
pub async fn delete(&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(&format!("DELETE FROM {} WHERE id = ?1", $table), params![id])
|
|
.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// 让 impl_repo! 宏对下方声明的子模块可见(textual scope 注入)。
|
|
// 必须放在宏定义之后、`mod` 声明之前——顺序错了子模块拿不到宏。
|
|
pub(crate) use impl_repo;
|
|
|
|
// ============================================================
|
|
// 公共工具(各子模块 use super::* 复用)
|
|
// ============================================================
|
|
|
|
/// rusqlite 错误 → df-types `Error::Storage` 统一包装(R-PD-10 DRY,
|
|
/// 替代散落的 `.map_err(storage_err)`)。
|
|
pub(crate) fn storage_err<E: std::string::ToString>(e: E) -> df_types::error::Error {
|
|
df_types::error::Error::Storage(e.to_string())
|
|
}
|
|
|
|
/// 判定扁平化后的存储错误是否为 SQLite 唯一约束冲突(SQLite extended code 2067 /
|
|
/// SQLITE_CONSTRAINT_UNIQUE)。
|
|
///
|
|
/// 设计取舍:存储层在 [`storage_err`] 已将原始 `rusqlite::Error` 扁平化为 `String`
|
|
/// (Error::Storage(String)),调用方拿不到结构化 `rusqlite::Error::SqliteFailure` 的
|
|
/// `ErrorCode`,无法直接按 code 判定。故在此 flatten 边界集中收口检测逻辑,避免每个
|
|
/// 调用方各自 `e.to_string().contains("UNIQUE constraint failed")` 散落脆弱匹配。
|
|
///
|
|
/// `"UNIQUE constraint failed"` 是 SQLite C 库对 2067 固定输出的文案(大写为 C 库常量,
|
|
/// 不随 SQLite 版本/locale 变化),大小写不敏感匹配兜底未来可能的微小差异。
|
|
pub fn is_unique_constraint_err(err: &df_types::error::Error) -> bool {
|
|
let df_types::error::Error::Storage(msg) = err else { return false };
|
|
msg.to_ascii_lowercase().contains("unique constraint failed")
|
|
}
|
|
|
|
/// 规范化路径用于比较:canonicalize 解析绝对规范路径(失败降级),
|
|
/// 统一正斜杠 + 小写。与 `df_project::scan::normalize_path` **同算法镜像**(df-storage
|
|
/// 不依赖 df-project,故独立实现;改动须同步)。防 `C:\a\b` vs `C:/a/b/` 绕过。
|
|
pub(crate) fn normalize_stored_path(p: &str) -> String {
|
|
match std::path::Path::new(p).canonicalize() {
|
|
Ok(abs) => abs.to_string_lossy().replace('\\', "/").to_lowercase(),
|
|
Err(_) => p
|
|
.trim_end_matches(['\\', '/'])
|
|
.replace('\\', "/")
|
|
.to_lowercase(),
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// 时间工具
|
|
// ============================================================
|
|
|
|
pub(crate) fn now_millis_str() -> String {
|
|
// 转发 df_types::now_millis(DRY:时间获取统一入口在 df-types,避免本 crate 与 commands 层各写一份 SystemTime 调用)
|
|
df_types::now_millis().to_string()
|
|
}
|
|
|
|
// ============================================================
|
|
// 基线测试 — 锁 Repo 数/表名防回归(SMELL-P1-9 对齐 SMELL-P0-2)
|
|
// ============================================================
|
|
//
|
|
// 拆分后,各 Repo 散落 5 个子模块。此基线测试确保:
|
|
// ① 所有 Repo 类型经 mod.rs `pub use` re-export 后在 `df_storage::crud::` 路径下仍可统一访问
|
|
// (调用方 `df_storage::crud::XxxRepo::new(&db)` 路径不破,降回归)。
|
|
// ② 各 Repo::new 在内存 DB 上构造不 panic + 列白名单覆盖每张表
|
|
// (拆分时误删 Repo / 漏 re-export / 表名漂移会被此测试立即捕获)。
|
|
|
|
#[cfg(test)]
|
|
mod baseline_tests {
|
|
use super::*;
|
|
use crate::db::Database;
|
|
|
|
/// 每张表都登记了列白名单(拆分后白名单仍在 settings.rs 单一来源,无表遗漏)。
|
|
/// 新增表须同步登记白名单,否则此测试失败(防遗漏)。
|
|
#[test]
|
|
fn all_known_tables_have_column_whitelist() {
|
|
let tables = [
|
|
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
|
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
|
"knowledges", "knowledge_events", "project_services",
|
|
];
|
|
for t in tables {
|
|
assert!(
|
|
allowed_columns_for(t).is_some(),
|
|
"表 {t} 应登记列白名单(防 SQL 注入 + 按表隔离);拆分后白名单单一来源在 settings.rs"
|
|
);
|
|
}
|
|
// ai_messages 表走全专用 AiMessageRepo(无标准 created_at / 批量语义特殊),
|
|
// 不走通用 query/update_field 路径,故不登记白名单(allowed_columns_for 返回 None)。
|
|
// 此处显式断言其为 None,防止后人误登记破坏"专用 Repo 不进白名单"约定。
|
|
assert!(
|
|
allowed_columns_for("ai_messages").is_none(),
|
|
"ai_messages 走专用 AiMessageRepo,不应登记通用列白名单"
|
|
);
|
|
}
|
|
|
|
/// re-export 完整性:所有 Repo 类型经 `df_storage::crud::XxxRepo` 路径可访问 + new 不 panic。
|
|
/// 若拆分时漏 `pub use` 某子模块,对应 Repo 名在此处编译失败(立即暴露)。
|
|
#[tokio::test]
|
|
async fn all_repos_constructible_in_memory() {
|
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
// 各子模块 Repo:new 仅取 conn 句柄,不触 DB IO,构造成功即证明类型可见 + Database 路径通。
|
|
let _ = SettingsRepo::new(&db);
|
|
let _ = IdeaRepo::new(&db);
|
|
let _ = ProjectRepo::new(&db);
|
|
let _ = TaskRepo::new(&db);
|
|
let _ = TaskLinkRepo::new(&db);
|
|
let _ = ProjectEventRepo::new(&db);
|
|
let _ = ProjectServiceRepo::new(&db);
|
|
let _ = ProjectModuleRepo::new(&db);
|
|
let _ = BranchRepo::new(&db);
|
|
let _ = ReleaseRepo::new(&db);
|
|
let _ = WorkflowRepo::new(&db);
|
|
let _ = NodeExecutionRepo::new(&db);
|
|
let _ = AiProviderRepo::new(&db);
|
|
let _ = AiConversationRepo::new(&db);
|
|
let _ = AiToolExecutionRepo::new(&db);
|
|
let _ = KnowledgeRepo::new(&db);
|
|
let _ = KnowledgeEventsRepo::new(&db);
|
|
let _ = AiMessageRepo::new(&db);
|
|
let _ = IdeaEvalRepo::new(&db);
|
|
}
|
|
}
|