重构: crud.rs按表拆分(SMELL-P1-9) + F-09批1 PerConvState数据结构

- SMELL-P1-9: crud.rs 2212行→crud/6文件(mod/settings/project_repo/task_repo/conversation_repo/idea_repo)
  re-export pub use *_repo::* 零调用方改动,宏 pub(crate) use + 子模块 use super::impl_repo
  基线测试锁12表白名单+13Repo构造
- F-09 批1: PerConvState struct(9字段对齐AiSession::new)+AiSession.per_conv HashMap+conv()/conv_read()访问器+3单测
  纯新增无行为变化,b-1主代自主裁决采纳,批2迁移承接
主代统一兜底: cargo check --workspace 0 + df-storage 35+11 + devflow 96 passed
This commit is contained in:
2026-06-19 01:34:59 +08:00
parent d817386418
commit 2c8764abee
11 changed files with 2592 additions and 2214 deletions

View File

@@ -0,0 +1,277 @@
//! Repository 模式的 CRUD 操作
//!
//! 为 7 张表各提供一个 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@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
mod conversation_repo;
mod idea_repo;
mod project_repo;
mod settings;
mod task_repo;
pub use conversation_repo::*;
pub use idea_repo::*;
pub use project_repo::*;
pub use settings::*;
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)。
///
/// 宏通过 `#[macro_use]`(见本文件末 `mod` 声明上方的文本注入)对各子模块可见,
/// 子模块 `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())
}
/// 规范化路径用于比较: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",
];
for t in tables {
assert!(
allowed_columns_for(t).is_some(),
"表 {t} 应登记列白名单(防 SQL 注入 + 按表隔离);拆分后白名单单一来源在 settings.rs"
);
}
}
/// 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 _ = 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);
}
}