新增: 项目多工程系统(工程表+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:
2026-06-29 01:39:42 +08:00
parent 3c18dea45b
commit b72df78462
12 changed files with 1016 additions and 3 deletions

View File

@@ -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);

View 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());
}
}

View File

@@ -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)。

View File

@@ -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,
}