//! 工程系统 — project_modules 表 CRUD(V34,项目多工程,每个工程独立代码仓库) //! //! 对标 [`project_service_repo`] 的风格(手写 Repo,与设计 §五工程系统 IPC 对齐)。 //! //! 关键设计: //! - **不存 Git 状态**:分支/改动/最近提交是实时派生的(查 git 命令),不进表; //! 表只存工程元数据(路径/Git 地址/技术栈)。 //! - **硬删**:`delete` / `delete_by_project` 物理删除(工程不需要软删审计追溯)。 //! - **sort_order 排序**:`list_by_project` 按 `sort_order ASC` 返回,前端工程列表稳定顺序。 //! - **auto_detected**:技术栈自动检测标志(扫描目录结构推断),人工覆盖时置 false。 use std::sync::Arc; use rusqlite::{params, Row}; use tokio::sync::Mutex; use crate::db::Database; use crate::models::ProjectModuleRecord; use super::{now_millis_str, storage_err}; // ============================================================ // from_row 辅助函数 // ============================================================ fn project_module_from_row(row: &Row<'_>) -> std::result::Result { Ok(ProjectModuleRecord { id: row.get("id")?, project_id: row.get("project_id")?, name: row.get("name")?, path: row.get("path")?, git_url: row.get("git_url")?, stack: row.get("stack")?, auto_detected: row.get("auto_detected")?, sort_order: row.get("sort_order")?, created_at: row.get("created_at")?, updated_at: row.get("updated_at")?, }) } // ============================================================ // Repo 实现(手写,参考 project_service_repo.rs 风格) // ============================================================ /// 工程系统 CRUD(project_modules,V34,可改非审计)。 /// /// 与 [`ProjectServiceRepo`](super::ProjectServiceRepo) 同款手写 Repo: /// 表有 `updated_at`,但工程无字段级白名单收口需求(无敏感字段、无枚举类型), /// 故不强制走 `impl_repo!` 宏的 `update_field` 路径——`update_full` 整体替换足够。 pub struct ProjectModuleRepo { conn: Arc>, } impl ProjectModuleRepo { pub fn new(db: &Database) -> Self { Self { conn: db.conn() } } /// 插入一条工程记录。`created_at` / `updated_at` 由本方法内部取当前毫秒覆盖。 /// 返回插入是否成功(affected > 0)。 pub async fn insert(&self, record: ProjectModuleRecord) -> Result { let conn = self.conn.clone(); let now = now_millis_str(); let mut rec = record; rec.created_at = now.clone(); rec.updated_at = now; tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let conn = &*guard; let r = &rec; let affected = conn .execute( "INSERT INTO project_modules \ (id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", params![ r.id, r.project_id, r.name, r.path, r.git_url, r.stack, r.auto_detected, r.sort_order, r.created_at, r.updated_at, ], ) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } /// 按 id 查询工程记录。 pub async fn get_by_id(&self, id: &str) -> Result, df_types::error::Error> { 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("SELECT * FROM project_modules WHERE id = ?1") .map_err(storage_err)?; let row = stmt .query_row(params![id], project_module_from_row) .optional() .map_err(storage_err)?; Ok(row) }) .await .map_err(storage_err)? } /// 按项目查询全部工程(命中 idx_project_modules_project,按 sort_order ASC 稳定排序)。 pub async fn list_by_project( &self, project_id: &str, ) -> Result, df_types::error::Error> { let conn = self.conn.clone(); let project_id = project_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let mut stmt = guard .prepare( "SELECT id, project_id, name, path, git_url, stack, auto_detected, sort_order, created_at, updated_at \ FROM project_modules WHERE project_id = ?1 ORDER BY sort_order ASC", ) .map_err(storage_err)?; let rows = stmt .query_map(params![project_id], project_module_from_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 与 created_at,updated_at 内部覆盖)。 /// 返回是否命中(affected > 0)。 pub async fn update_full(&self, record: &ProjectModuleRecord) -> Result { let conn = self.conn.clone(); let mut rec = record.clone(); rec.updated_at = now_millis_str(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let conn = &*guard; let r = &rec; let affected = conn .execute( "UPDATE project_modules SET \ project_id = ?1, name = ?2, path = ?3, git_url = ?4, stack = ?5, \ auto_detected = ?6, sort_order = ?7, updated_at = ?8 \ WHERE id = ?9", params![ r.project_id, r.name, r.path, r.git_url, r.stack, r.auto_detected, r.sort_order, r.updated_at, r.id, ], ) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } /// 按 id 硬删工程(工程不需要软删审计追溯)。 pub async fn delete(&self, id: &str) -> Result { let conn = self.conn.clone(); let id = id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let affected = guard .execute( "DELETE FROM project_modules WHERE id = ?1", params![id], ) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } /// 按项目删除全部工程(删项目时级联清理,硬删)。 pub async fn delete_by_project(&self, project_id: &str) -> Result { let conn = self.conn.clone(); let project_id = project_id.to_owned(); tokio::task::spawn_blocking(move || { let guard = conn.blocking_lock(); let affected = guard .execute( "DELETE FROM project_modules WHERE project_id = ?1", params![project_id], ) .map_err(storage_err)?; Ok(affected > 0) }) .await .map_err(storage_err)? } } // 引入 OptionalExtension 用于 query_row().optional()(避免每次写完整路径) use rusqlite::OptionalExtension; // ============================================================ // 单元测试 — ProjectModuleRepo CRUD(内存 DB,对标 project_service_repo 测试风格) // ============================================================ #[cfg(test)] mod tests { use super::*; use crate::db::Database; /// 建库 + 建占位 project 满足 FK 约束 + 返回 repo(对标 project_service_repo::setup)。 async fn setup() -> (Database, ProjectModuleRepo, String) { let db = Database::open_in_memory().await.expect("open_in_memory"); let project_id = "proj-test".to_string(); db.conn() .blocking_lock() .execute( "INSERT INTO projects (id, name, status, path, stack, created_at, updated_at) \ VALUES (?1, ?2, 'active', '/tmp', 'rust', '0', '0')", params![project_id, "Test Project"], ) .expect("insert placeholder project"); let repo = ProjectModuleRepo::new(&db); (db, repo, project_id) } fn mrec(project_id: &str, name: &str, path: &str) -> ProjectModuleRecord { ProjectModuleRecord { id: format!("mod-{name}"), project_id: project_id.to_string(), name: name.to_string(), path: path.to_string(), git_url: None, stack: None, auto_detected: false, sort_order: 0, created_at: "0".to_string(), updated_at: "0".to_string(), } } #[tokio::test] async fn insert_and_get_by_id_reads_back_full_fields() { let (_db, repo, pid) = setup().await; let mut rec = mrec(&pid, "frontend", "/p/frontend"); rec.git_url = Some("https://example.com/f.git".to_string()); rec.stack = Some(r#"{"lang":"ts"}"#.to_string()); rec.auto_detected = true; rec.sort_order = 2; let ok = repo.insert(rec.clone()).await.expect("insert"); assert!(ok); let got = repo.get_by_id(&rec.id).await.expect("get").expect("found"); assert_eq!(got.name, "frontend"); assert_eq!(got.path, "/p/frontend"); assert_eq!(got.git_url.as_deref(), Some("https://example.com/f.git")); assert_eq!(got.stack.as_deref(), Some(r#"{"lang":"ts"}"#)); assert!(got.auto_detected); assert_eq!(got.sort_order, 2); } #[tokio::test] async fn list_by_project_orders_by_sort_order_asc() { let (_db, repo, pid) = setup().await; let mut a = mrec(&pid, "a", "/p/a"); a.sort_order = 5; let mut b = mrec(&pid, "b", "/p/b"); b.sort_order = 1; let mut c = mrec(&pid, "c", "/p/c"); c.sort_order = 3; repo.insert(a).await.unwrap(); repo.insert(b).await.unwrap(); repo.insert(c).await.unwrap(); let list = repo.list_by_project(&pid).await.expect("list"); assert_eq!(list.len(), 3); // sort_order ASC:b(1) → c(3) → a(5) assert_eq!(list[0].name, "b"); assert_eq!(list[1].name, "c"); assert_eq!(list[2].name, "a"); } #[tokio::test] async fn update_full_changes_fields_and_preserves_created_at() { let (_db, repo, pid) = setup().await; let rec = mrec(&pid, "frontend", "/p/old"); repo.insert(rec.clone()).await.unwrap(); let original = repo.get_by_id(&rec.id).await.unwrap().unwrap(); // 整体更新:path 变了,sort_order 变了 let mut updated = original.clone(); updated.path = "/p/new".to_string(); updated.sort_order = 9; let hit = repo.update_full(&updated).await.expect("update"); assert!(hit); let got = repo.get_by_id(&rec.id).await.unwrap().unwrap(); assert_eq!(got.path, "/p/new"); assert_eq!(got.sort_order, 9); // created_at 保留不变(update_full 不覆盖 created_at) assert_eq!(got.created_at, original.created_at); } #[tokio::test] async fn delete_removes_record() { let (_db, repo, pid) = setup().await; let rec = mrec(&pid, "frontend", "/p/f"); repo.insert(rec.clone()).await.unwrap(); let hit = repo.delete(&rec.id).await.expect("delete"); assert!(hit); assert!(repo.get_by_id(&rec.id).await.unwrap().is_none()); } #[tokio::test] async fn delete_by_project_removes_all() { let (_db, repo, pid) = setup().await; repo.insert(mrec(&pid, "a", "/p/a")).await.unwrap(); repo.insert(mrec(&pid, "b", "/p/b")).await.unwrap(); let hit = repo.delete_by_project(&pid).await.expect("delete_by_project"); assert!(hit); assert!(repo.list_by_project(&pid).await.unwrap().is_empty()); } }