新增: 项目多工程系统(工程表+Git 状态查询+AI 工具)
一个项目可含多个工程(Module),每个工程是独立代码仓库, 有自己的目录、Git 地址和技术栈。单仓库项目退化:项目下 只有一个工程,用户无感工程概念。 数据层: - project_modules 表(V34 迁移):工程名/目录/Git 地址/技术栈 - ProjectModuleRepo:完整 CRUD + 按项目列表(排序) 后端接口: - 工程 CRUD:添加/更新/删除/列表 - Git 状态查询:实时跑 git 命令返回当前分支/改动文件/最近提交 (10 秒超时,无 Git 仓库返回空状态) 创建项目适配: - 新建项目时自动创建一个工程(目录=绑定路径,技术栈=探测结果) AI 工具: - list_project_modules:列出项目工程列表(AI 了解项目结构) 同时新增工程系统设计方案文档。
This commit is contained in:
@@ -22,6 +22,7 @@ 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;
|
||||
@@ -33,6 +34,7 @@ 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::*;
|
||||
@@ -305,6 +307,7 @@ mod baseline_tests {
|
||||
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);
|
||||
|
||||
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
322
crates/df-storage/src/crud/project_module_repo.rs
Normal file
@@ -0,0 +1,322 @@
|
||||
//! 工程系统 — 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<ProjectModuleRecord, rusqlite::Error> {
|
||||
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<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
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<bool, df_types::error::Error> {
|
||||
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<Option<ProjectModuleRecord>, 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<Vec<ProjectModuleRecord>, 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<bool, df_types::error::Error> {
|
||||
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<bool, 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 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<bool, 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 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());
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -79,6 +79,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(31, migrate_v31),
|
||||
(32, migrate_v32),
|
||||
(33, migrate_v33),
|
||||
(34, migrate_v34),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -961,6 +962,39 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V34:工程系统—project_modules 表(项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(path)、Git 地址(git_url)、技术栈(stack)。
|
||||
/// 单仓库项目退化:项目下只有一个工程(path = 绑定目录)。
|
||||
///
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
fn migrate_v34(conn: &Connection) -> Result<()> {
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS project_modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||
name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
git_url TEXT,
|
||||
stack TEXT,
|
||||
auto_detected BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_modules_project ON project_modules(project_id)",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v34: 建 project_modules 表 + 索引(工程系统)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [34])?;
|
||||
tracing::info!("迁移 v34 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -464,3 +464,22 @@ pub struct KnowledgeEventRecord {
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
/// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||
///
|
||||
/// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。
|
||||
/// 每个工程有独立的目录(`path`)、Git 地址(`git_url`)、技术栈(`stack`)。
|
||||
/// Git 状态(分支/改动/提交)是实时派生的(查 git 命令),不存表。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectModuleRecord {
|
||||
pub id: String,
|
||||
pub project_id: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub git_url: Option<String>,
|
||||
pub stack: Option<String>, // 技术栈 JSON 字符串
|
||||
pub auto_detected: bool,
|
||||
pub sort_order: i32,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user