新增: 项目多工程系统(工程表+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 idea_repo;
|
||||||
mod message_repo;
|
mod message_repo;
|
||||||
mod project_event_repo;
|
mod project_event_repo;
|
||||||
|
mod project_module_repo;
|
||||||
mod project_repo;
|
mod project_repo;
|
||||||
mod project_service_repo;
|
mod project_service_repo;
|
||||||
mod settings;
|
mod settings;
|
||||||
@@ -33,6 +34,7 @@ pub use idea_eval_repo::*;
|
|||||||
pub use idea_repo::*;
|
pub use idea_repo::*;
|
||||||
pub use message_repo::*;
|
pub use message_repo::*;
|
||||||
pub use project_event_repo::*;
|
pub use project_event_repo::*;
|
||||||
|
pub use project_module_repo::*;
|
||||||
pub use project_repo::*;
|
pub use project_repo::*;
|
||||||
pub use project_service_repo::*;
|
pub use project_service_repo::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
@@ -305,6 +307,7 @@ mod baseline_tests {
|
|||||||
let _ = TaskLinkRepo::new(&db);
|
let _ = TaskLinkRepo::new(&db);
|
||||||
let _ = ProjectEventRepo::new(&db);
|
let _ = ProjectEventRepo::new(&db);
|
||||||
let _ = ProjectServiceRepo::new(&db);
|
let _ = ProjectServiceRepo::new(&db);
|
||||||
|
let _ = ProjectModuleRepo::new(&db);
|
||||||
let _ = BranchRepo::new(&db);
|
let _ = BranchRepo::new(&db);
|
||||||
let _ = ReleaseRepo::new(&db);
|
let _ = ReleaseRepo::new(&db);
|
||||||
let _ = WorkflowRepo::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"的基础设施上下文。
|
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 33] = [
|
let steps: [(i32, fn(&Connection) -> Result<()>); 34] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -79,6 +79,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(31, migrate_v31),
|
(31, migrate_v31),
|
||||||
(32, migrate_v32),
|
(32, migrate_v32),
|
||||||
(33, migrate_v33),
|
(33, migrate_v33),
|
||||||
|
(34, migrate_v34),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -961,6 +962,39 @@ fn migrate_v33(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
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 表
|
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||||
///
|
///
|
||||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
/// 与 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 context_json: Option<String>, // JSON: 因 event_type 而异
|
||||||
pub timestamp: String,
|
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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
| [AI对话目标丢失诊断-2026-06-26.md](./专项设计/AI对话目标丢失诊断-2026-06-26.md) | 📐 诊断 | AI 对话目标丢失根因分析:上下文漂移 / 意图衰减 |
|
||||||
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
| [AI原生上下文地图与去AI化进化系统-2026-06-26.md](./专项设计/AI原生上下文地图与去AI化进化系统-2026-06-26.md) | 📐 设计 | AI 原生上下文地图:语义索引 / 去 AI 化渐进演进路径 |
|
||||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||||
|
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | 🚧 实施中(Batch 9) | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
189
docs/02-架构设计/专项设计/工程系统设计-2026-06-29.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# 工程系统设计 — 项目多工程 + Git 状态 + 文件浏览
|
||||||
|
|
||||||
|
> 创建: 2026-06-29 | 状态: 实施中(Batch 9)
|
||||||
|
> 关联: [项目知识图谱与任务队列系统-2026-06-26.md](./项目知识图谱与任务队列系统-2026-06-26.md) / ARCHITECTURE.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景
|
||||||
|
|
||||||
|
DevFlow 的项目(Project)当前只能绑定一个目录(projects.path)。现实中的项目经常包含多个工程(Module):Monorepo 多仓库、微服务多服务、前后端分离等。每个工程是独立的代码仓库(各自的 .git),有自己的 Git 远程地址和技术栈。
|
||||||
|
|
||||||
|
引入工程(Module)概念,使 AI 和用户都能以工程为粒度操作代码仓库。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、两级结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目(Project) ← 业务实体("支付平台")
|
||||||
|
├── 工程(Module) ← 代码仓库("后端 API" / "前端 Web")
|
||||||
|
│ ├── 工程目录(path)
|
||||||
|
│ ├── Git 地址(git_url)
|
||||||
|
│ ├── 技术栈(stack)
|
||||||
|
│ └── Git 状态(实时派生,不存表)
|
||||||
|
├── 工程(Module)
|
||||||
|
└── 工程(Module)
|
||||||
|
```
|
||||||
|
|
||||||
|
单仓库项目 = 项目下只有一个工程(退化场景,UI 不显式展示工程层级)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据模型
|
||||||
|
|
||||||
|
### 3.1 project_modules 表(V34 迁移)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE project_modules (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
name TEXT NOT NULL, -- "后端 API"
|
||||||
|
path TEXT NOT NULL, -- 工程目录(绝对路径)
|
||||||
|
git_url TEXT, -- Git 远程地址(可选)
|
||||||
|
stack TEXT, -- 技术栈 JSON(["rust","tokio","postgresql"])
|
||||||
|
auto_detected BOOLEAN NOT NULL DEFAULT FALSE, -- 自动探测创建(重新探测时覆盖,用户编辑后不回写)
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_project_modules_project ON project_modules(project_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 设计原则
|
||||||
|
|
||||||
|
| 字段 | 来源 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| name | 用户/探测 | 工程名称 |
|
||||||
|
| path | 用户 | 工程目录(必填,等于 .git 所在目录) |
|
||||||
|
| git_url | 用户 | Git 远程地址(可选,单机可不填) |
|
||||||
|
| stack | 探测+用户可改 | 技术栈 JSON(探测 Cargo.toml/package.json 填充默认,用户可覆盖) |
|
||||||
|
| auto_detected | 系统 | true=自动探测创建;重新探测时只覆盖 auto_detected=true 的行 |
|
||||||
|
| sort_order | 系统 | 列表排序 |
|
||||||
|
|
||||||
|
**Git 状态实时派生(不存表)**:分支/改动文件/最近提交 → 查询时跑 git 命令返回。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Git 状态查询
|
||||||
|
|
||||||
|
### 4.1 get_module_git_status(module_id) 返回结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"branch": "main",
|
||||||
|
"remote": "origin",
|
||||||
|
"changes": {
|
||||||
|
"modified": 3,
|
||||||
|
"added": 1,
|
||||||
|
"untracked": 2,
|
||||||
|
"total": 6
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
{ "path": "src/main.rs", "status": "M" },
|
||||||
|
{ "path": "Cargo.toml", "status": "M" },
|
||||||
|
{ "path": "README.md", "status": "??" }
|
||||||
|
],
|
||||||
|
"last_commit": {
|
||||||
|
"hash": "abc1234",
|
||||||
|
"message": "feat: add login",
|
||||||
|
"author": "lxy",
|
||||||
|
"date": "2026-06-29T10:00:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 实现方式
|
||||||
|
|
||||||
|
在工程目录(module.path)执行:
|
||||||
|
- `git branch --show-current` → 当前分支
|
||||||
|
- `git remote get-url origin` → 远程地址(与 module.git_url 互补)
|
||||||
|
- `git status --porcelain` → 改动文件列表(解析为结构化)
|
||||||
|
- `git log -1 --format=...` → 最近提交
|
||||||
|
|
||||||
|
所有 git 命令包 10s timeout。无 .git 目录返回空状态。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、文件浏览器(IPC + UI)
|
||||||
|
|
||||||
|
### 5.1 get_module_file_tree IPC
|
||||||
|
|
||||||
|
```rust
|
||||||
|
get_module_file_tree(module_id, sub_path?, depth?) → FileTreeResponse
|
||||||
|
```
|
||||||
|
|
||||||
|
返回文件列表 + Git 状态合并(一次查询)。文件树懒加载:默认展开 2 层,点击展开加载子目录。
|
||||||
|
|
||||||
|
噪音过滤:跳过 node_modules / target / .git / __pycache__ / dist / .vite。
|
||||||
|
|
||||||
|
### 5.2 UI 结构
|
||||||
|
|
||||||
|
```
|
||||||
|
项目详情页 → 文件 Tab
|
||||||
|
┌──────────────┬──────────────────────────────────┐
|
||||||
|
│ 文件树 │ 文件内容预览 │
|
||||||
|
│ │ │
|
||||||
|
│ 📁 src/ │ 1 use std::...; │
|
||||||
|
│ main.rs M │ 2 fn main() { │
|
||||||
|
│ config.rs │ 3 println!("hello"); │
|
||||||
|
│ 📁 tests/ │ 4 } │
|
||||||
|
│ Cargo.toml A │ │
|
||||||
|
│ │ main.rs · 2.1 KB · 已修改 │
|
||||||
|
└──────────────┴──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Git 状态标记:M(橙色=已修改) / A(绿色=已新增) / ??(灰色=未跟踪)。
|
||||||
|
|
||||||
|
### 5.3 单工程退化
|
||||||
|
|
||||||
|
项目只有 1 个工程时不显工程选择器,直接展示文件浏览器。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、创建项目适配
|
||||||
|
|
||||||
|
create_project 时自动建一个工程:
|
||||||
|
- name = 项目名
|
||||||
|
- path = 绑定目录
|
||||||
|
- stack = 探测结果(复用现有 detect_stack)
|
||||||
|
- git_url = 探测(git remote get-url origin,失败为 None)
|
||||||
|
- auto_detected = true
|
||||||
|
|
||||||
|
用户后续可添加更多工程(多工程场景)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、AI 工具
|
||||||
|
|
||||||
|
### 7.1 只读工具(Batch 9 随数据层一起注册)
|
||||||
|
|
||||||
|
| 工具 | 功能 | 风险 |
|
||||||
|
|---|---|---|
|
||||||
|
| list_project_modules | 列出项目工程列表 | Low |
|
||||||
|
| get_module_git_status | 查询工程 Git 状态 | Low |
|
||||||
|
|
||||||
|
### 7.2 Git 工具(后续批次)
|
||||||
|
|
||||||
|
| 工具 | 功能 | 风险 |
|
||||||
|
|---|---|---|
|
||||||
|
| git_status | 工作区状态(结构化) | Low |
|
||||||
|
| git_diff | 未提交改动详情 | Low |
|
||||||
|
| git_log | 提交历史 | Low |
|
||||||
|
| git_commit | 提交改动 | Medium |
|
||||||
|
| git_branch | 分支管理 | Medium |
|
||||||
|
| git_merge | 合并分支 | High |
|
||||||
|
|
||||||
|
安全边界:禁止 push / force / reset --hard。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、实施批次
|
||||||
|
|
||||||
|
| 批次 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| Batch 9 | 工程表 + CRUD IPC + Git 状态查询 + 创建项目适配 + AI 工具注册 |
|
||||||
|
| Batch 10 | 文件浏览器 UI(工程选择 + 文件树 + Git 状态标记 + 内容预览) |
|
||||||
|
| Batch 11 | Git 只读 AI 工具(status/diff/log) |
|
||||||
|
| Batch 12 | Git 写 AI 工具(commit/branch/merge) |
|
||||||
@@ -1270,6 +1270,37 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
|||||||
})
|
})
|
||||||
})},
|
})},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── list_project_modules (Low 只读,工程系统) ──
|
||||||
|
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
|
||||||
|
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
|
||||||
|
registry.register(
|
||||||
|
"list_project_modules", "查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
|
||||||
|
df_ai::ai_tools::object_schema(vec![
|
||||||
|
("project_id", "string", true),
|
||||||
|
]),
|
||||||
|
RiskLevel::Low,
|
||||||
|
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||||
|
let db = db.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
let project_id = args["project_id"].as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if project_id.is_empty() {
|
||||||
|
anyhow::bail!("project_id 不能为空");
|
||||||
|
}
|
||||||
|
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
|
||||||
|
let modules = repo.list_by_project(&project_id).await?;
|
||||||
|
let total = modules.len();
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"modules": modules,
|
||||||
|
"total": total,
|
||||||
|
"project_id": project_id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
})},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||||
@@ -2992,6 +3023,8 @@ mod tests {
|
|||||||
// register_task_graph_tools 6→7)。
|
// register_task_graph_tools 6→7)。
|
||||||
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
|
||||||
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||||||
|
// 工程系统(2026-06-29): data 层 27→28(新增 list_project_modules 工程列表查询,
|
||||||
|
// register_task_graph_tools 9→10)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.len(),
|
registry.len(),
|
||||||
41,
|
41,
|
||||||
@@ -3017,6 +3050,8 @@ mod tests {
|
|||||||
"get_project_timeline",
|
"get_project_timeline",
|
||||||
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||||||
"add_project_service", "list_project_services",
|
"add_project_service", "list_project_services",
|
||||||
|
// 工程系统:项目工程列表查询
|
||||||
|
"list_project_modules",
|
||||||
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
// ── file 层 (13) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod events;
|
|||||||
pub mod idea;
|
pub mod idea;
|
||||||
pub mod knowledge;
|
pub mod knowledge;
|
||||||
pub mod knowledge_timeline;
|
pub mod knowledge_timeline;
|
||||||
|
pub mod module;
|
||||||
pub mod project;
|
pub mod project;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
376
src-tauri/src/commands/module.rs
Normal file
376
src-tauri/src/commands/module.rs
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
//! 工程系统命令(V34,project_modules 表,项目多工程,每个工程独立代码仓库)
|
||||||
|
//!
|
||||||
|
//! 对标 [`services`](super::services) 的 IPC 模式:CRUD 返回完整记录,前端拿回 id 直接用。
|
||||||
|
//!
|
||||||
|
//! 关键设计(对标设计 §五工程系统 + V34 迁移注释):
|
||||||
|
//! - **工程元数据 CRUD**:路径/Git 地址/技术栈存表,create/update 返回完整记录。
|
||||||
|
//! - **Git 状态实时派生**:`get_module_git_status` 在工程目录跑 git 命令(分支/改动/最近提交),
|
||||||
|
//! 10s 超时,无 .git 返回空状态。**前端 IPC 用 std::process::Command**(非 df-execute,
|
||||||
|
//! 因为这是用户读操作,不是 AI 工具调用,不走审批/沙箱)。
|
||||||
|
//! - **路径校验**:trim + 非空校验在前置 IPC 层(给清晰错误)。
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use df_storage::models::ProjectModuleRecord;
|
||||||
|
use df_types::types::new_id;
|
||||||
|
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::{err_str, now_millis};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 入参 / 出参结构
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 新增工程入参(对标设计 §五 add_project_module)。
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct AddProjectModuleInput {
|
||||||
|
pub project_id: String,
|
||||||
|
pub name: String,
|
||||||
|
/// 工程目录绝对路径(本机)
|
||||||
|
pub path: String,
|
||||||
|
/// 远程仓库地址,可选(单仓库本地工程可为空)
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_url: Option<String>,
|
||||||
|
/// 技术栈 JSON 字符串(前端检测后传入),可选
|
||||||
|
#[serde(default)]
|
||||||
|
pub stack: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新工程入参(部分更新语义,仅传入字段被覆盖;对标设计 §五 update_project_module)。
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateProjectModuleInput {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub git_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stack: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 辅助:Optional 字段 trim + 空串归一为 None
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn trim_opt(s: Option<String>) -> Option<String> {
|
||||||
|
s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// IPC 命令 — 工程 CRUD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 新增工程(对标设计 §五 add_project_module)。返回完整记录。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn add_project_module(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
input: AddProjectModuleInput,
|
||||||
|
) -> Result<ProjectModuleRecord, String> {
|
||||||
|
let project_id = input.project_id.trim().to_string();
|
||||||
|
let name = input.name.trim().to_string();
|
||||||
|
let path = input.path.trim().to_string();
|
||||||
|
if project_id.is_empty() {
|
||||||
|
return Err("project_id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("name 不能为空".to_string());
|
||||||
|
}
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("path 不能为空".to_string());
|
||||||
|
}
|
||||||
|
// sort_order 取当前项目下工程数(末尾追加,新工程排末位)
|
||||||
|
let existing = state
|
||||||
|
.project_modules
|
||||||
|
.list_by_project(&project_id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
let sort_order = existing.len() as i32;
|
||||||
|
|
||||||
|
let record = ProjectModuleRecord {
|
||||||
|
id: new_id(),
|
||||||
|
project_id,
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
git_url: trim_opt(input.git_url),
|
||||||
|
stack: trim_opt(input.stack),
|
||||||
|
auto_detected: false,
|
||||||
|
sort_order,
|
||||||
|
// created_at/updated_at 由 Repo 内部覆盖,此处占位
|
||||||
|
created_at: now_millis(),
|
||||||
|
updated_at: now_millis(),
|
||||||
|
};
|
||||||
|
let record_id = record.id.clone();
|
||||||
|
let ok = state
|
||||||
|
.project_modules
|
||||||
|
.insert(record)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
if !ok {
|
||||||
|
return Err("插入工程记录失败".to_string());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.project_modules
|
||||||
|
.get_by_id(&record_id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.ok_or_else(|| "插入后回读失败".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新工程(部分更新语义:仅传入字段被覆盖,其余保留)。返回完整记录。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_project_module(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
input: UpdateProjectModuleInput,
|
||||||
|
) -> Result<ProjectModuleRecord, String> {
|
||||||
|
let id = input.id.trim().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return Err("id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
// 至少一个可更新字段
|
||||||
|
if input.name.is_none()
|
||||||
|
&& input.path.is_none()
|
||||||
|
&& input.git_url.is_none()
|
||||||
|
&& input.stack.is_none()
|
||||||
|
{
|
||||||
|
return Err("至少提供一个待更新字段 (name/path/git_url/stack)".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先校验存在(404 友好错误),再整体更新(保留不可变字段)
|
||||||
|
let mut existing = state
|
||||||
|
.project_modules
|
||||||
|
.get_by_id(&id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.ok_or_else(|| format!("工程 {id} 不存在"))?;
|
||||||
|
|
||||||
|
if let Some(name) = input.name.map(|s| s.trim().to_string()) {
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("name 不能为空".to_string());
|
||||||
|
}
|
||||||
|
existing.name = name;
|
||||||
|
}
|
||||||
|
if let Some(path) = input.path.map(|s| s.trim().to_string()) {
|
||||||
|
if path.is_empty() {
|
||||||
|
return Err("path 不能为空".to_string());
|
||||||
|
}
|
||||||
|
existing.path = path;
|
||||||
|
}
|
||||||
|
existing.git_url = trim_opt(input.git_url);
|
||||||
|
existing.stack = trim_opt(input.stack);
|
||||||
|
|
||||||
|
let hit = state
|
||||||
|
.project_modules
|
||||||
|
.update_full(&existing)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
if !hit {
|
||||||
|
return Err(format!("工程 {id} 不存在(更新未命中)"));
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.project_modules
|
||||||
|
.get_by_id(&id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.ok_or_else(|| "更新后回读失败".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除工程(按 id 硬删,工程不需要软删审计追溯)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn remove_project_module(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||||
|
let id = id.trim().to_string();
|
||||||
|
if id.is_empty() {
|
||||||
|
return Err("id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
state.project_modules.delete(&id).await.map_err(err_str)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询项目全部工程(按 sort_order ASC 返回,前端工程列表稳定顺序)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_project_modules(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
project_id: String,
|
||||||
|
) -> Result<Vec<ProjectModuleRecord>, String> {
|
||||||
|
let project_id = project_id.trim().to_string();
|
||||||
|
if project_id.is_empty() {
|
||||||
|
return Err("project_id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.project_modules
|
||||||
|
.list_by_project(&project_id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// IPC 命令 — Git 状态查询(实时派生,不进表)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// Git 改动文件项(状态码 + 相对路径)。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GitChangedFile {
|
||||||
|
/// `git status --porcelain` 的状态码(xy 两字符,如 " M"、"M "、"??")
|
||||||
|
status: String,
|
||||||
|
/// 相对仓库根的路径
|
||||||
|
path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 最近提交项(简短哈希 + subject)。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GitRecentCommit {
|
||||||
|
hash: String,
|
||||||
|
subject: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Git 状态返回结构(无 .git 时各字段空,前端据此显示"非 Git 仓库")。
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct GitStatus {
|
||||||
|
/// 当前分支名(HEAD 处于分离状态时为空)
|
||||||
|
branch: String,
|
||||||
|
/// 改动文件列表(未提交)
|
||||||
|
changed_files: Vec<GitChangedFile>,
|
||||||
|
/// 最近 10 条提交
|
||||||
|
recent_commits: Vec<GitRecentCommit>,
|
||||||
|
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
||||||
|
is_git_repo: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 默认空状态(无 .git / git 不可用时返回此)。
|
||||||
|
fn empty_status() -> GitStatus {
|
||||||
|
GitStatus {
|
||||||
|
branch: String::new(),
|
||||||
|
changed_files: Vec::new(),
|
||||||
|
recent_commits: Vec::new(),
|
||||||
|
is_git_repo: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询工程目录的 Git 状态(分支/改动文件/最近提交)。
|
||||||
|
///
|
||||||
|
/// 实现选择:**前端 IPC 用 `std::process::Command`**(非 df-execute,因为这是用户读操作,
|
||||||
|
/// 不是 AI 工具调用,不走审批/沙箱)。10s 超时,无 .git 返回空状态(`is_git_repo: false`)。
|
||||||
|
///
|
||||||
|
/// 返回 `serde_json::Value` 以便前端灵活取字段(分支/改动/提交三段)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_module_git_status(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
module_id: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let module_id = module_id.trim().to_string();
|
||||||
|
if module_id.is_empty() {
|
||||||
|
return Err("module_id 不能为空".to_string());
|
||||||
|
}
|
||||||
|
// 取工程记录拿 path
|
||||||
|
let module = state
|
||||||
|
.project_modules
|
||||||
|
.get_by_id(&module_id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?
|
||||||
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||||
|
|
||||||
|
let path = std::path::Path::new(&module.path);
|
||||||
|
// 无 .git → 返回空状态
|
||||||
|
if !path.join(".git").exists() {
|
||||||
|
return Ok(serde_json::to_value(empty_status()).map_err(err_str)?);
|
||||||
|
}
|
||||||
|
|
||||||
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||||
|
let dir = module.path.clone();
|
||||||
|
let status = tokio::task::spawn_blocking(move || run_git_status(&dir))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("git 状态查询任务失败: {e}"))?;
|
||||||
|
|
||||||
|
Ok(serde_json::to_value(status).map_err(err_str)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 在指定目录跑 git 命令采集状态(当前分支 / 改动文件 / 最近提交)。
|
||||||
|
///
|
||||||
|
/// 超时:每个 git 子进程 10s(用户读操作,卡死不应阻塞 UI)。任一命令失败时返回已采集部分。
|
||||||
|
fn run_git_status(dir: &str) -> GitStatus {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let path = std::path::Path::new(dir);
|
||||||
|
if !path.join(".git").exists() {
|
||||||
|
return empty_status();
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = Duration::from_secs(10);
|
||||||
|
|
||||||
|
// 1) 当前分支:`git rev-parse --abbrev-ref HEAD`(分离 HEAD 时返回 "HEAD")
|
||||||
|
let branch = run_git_cmd(path, &["rev-parse", "--abbrev-ref", "HEAD"], timeout)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty() && s != "HEAD")
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// 2) 改动文件:`git status --porcelain`(每行 "XY path",XY 为两字符状态码)
|
||||||
|
let mut changed_files = Vec::new();
|
||||||
|
if let Some(out) = run_git_cmd(path, &["status", "--porcelain"], timeout) {
|
||||||
|
for line in out.lines() {
|
||||||
|
if line.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// porcelain 格式:"XY path"(X/Y 各一字符 + 一空格 + path)
|
||||||
|
let status = line[..2].to_string();
|
||||||
|
let file_path = line[3..].trim().to_string();
|
||||||
|
if !file_path.is_empty() {
|
||||||
|
changed_files.push(GitChangedFile {
|
||||||
|
status,
|
||||||
|
path: file_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) 最近提交:`git log -10 --oneline`(每行 "hash subject")
|
||||||
|
let mut recent_commits = Vec::new();
|
||||||
|
if let Some(out) = run_git_cmd(path, &["log", "-10", "--oneline"], timeout) {
|
||||||
|
for line in out.lines() {
|
||||||
|
// oneline 格式:"<short-hash> <subject>"
|
||||||
|
if let Some((hash, subject)) = line.split_once(' ') {
|
||||||
|
recent_commits.push(GitRecentCommit {
|
||||||
|
hash: hash.to_string(),
|
||||||
|
subject: subject.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GitStatus {
|
||||||
|
branch,
|
||||||
|
changed_files,
|
||||||
|
recent_commits,
|
||||||
|
is_git_repo: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 跑单个 git 子命令,捕获 stdout(成功)或 None(失败/超时)。
|
||||||
|
///
|
||||||
|
/// 用 `wait_timeout` 实现 10s 超时(标准库 `Command::output` 无超时,需手动 wait)。
|
||||||
|
fn run_git_cmd(cwd: &std::path::Path, args: &[&str], timeout: std::time::Duration) -> Option<String> {
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
// 在独立线程跑子进程,主线程 select 超时,避免标准库无超时 API 的痛点。
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let cwd = cwd.to_path_buf();
|
||||||
|
let args = args.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let out = Command::new("git")
|
||||||
|
.args(&args)
|
||||||
|
.current_dir(&cwd)
|
||||||
|
.output();
|
||||||
|
let _ = tx.send(out);
|
||||||
|
});
|
||||||
|
|
||||||
|
match rx.recv_timeout(timeout) {
|
||||||
|
Ok(Ok(output)) if output.status.success() => {
|
||||||
|
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,6 +170,27 @@ async fn create_with_binding(
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
state.projects.insert(record.clone()).await.map_err(err_str)?;
|
||||||
|
// 工程系统:创建项目时自动建一个工程(path = 绑定目录,stack = 探测结果)。
|
||||||
|
// 单仓库项目退化:项目下只有一个工程,用户无感工程概念。
|
||||||
|
// 多仓库场景用户后续可添加更多工程(add_project_module IPC)。
|
||||||
|
if let Some(ref module_path) = record.path {
|
||||||
|
let now_str = df_types::now_millis().to_string();
|
||||||
|
let module = df_storage::models::ProjectModuleRecord {
|
||||||
|
id: df_types::types::new_id(),
|
||||||
|
project_id: record.id.clone(),
|
||||||
|
name: record.name.clone(),
|
||||||
|
path: module_path.clone(),
|
||||||
|
git_url: None, // 探测 git remote 补充(失败为 None,不影响)
|
||||||
|
stack: record.stack.clone(), // 复用项目级技术栈探测结果
|
||||||
|
auto_detected: true, // 自动创建,重新探测时可覆盖
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: now_str.clone(),
|
||||||
|
updated_at: now_str,
|
||||||
|
};
|
||||||
|
if let Err(e) = state.project_modules.insert(module).await {
|
||||||
|
tracing::warn!("创建项目时自动建工程失败(非阻断): {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
|
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
|
||||||
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
|
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
|
||||||
if record.path.is_some() {
|
if record.path.is_some() {
|
||||||
|
|||||||
@@ -319,6 +319,12 @@ pub fn run() {
|
|||||||
commands::services::update_project_service,
|
commands::services::update_project_service,
|
||||||
commands::services::remove_project_service,
|
commands::services::remove_project_service,
|
||||||
commands::services::list_project_services,
|
commands::services::list_project_services,
|
||||||
|
// 工程系统(对标设计 §五 + V34):项目多工程 CRUD + Git 状态查询
|
||||||
|
commands::module::add_project_module,
|
||||||
|
commands::module::update_project_module,
|
||||||
|
commands::module::remove_project_module,
|
||||||
|
commands::module::list_project_modules,
|
||||||
|
commands::module::get_module_git_status,
|
||||||
// 灵感
|
// 灵感
|
||||||
commands::idea::list_ideas,
|
commands::idea::list_ideas,
|
||||||
commands::idea::create_idea,
|
commands::idea::create_idea,
|
||||||
|
|||||||
@@ -37,8 +37,9 @@ use tokio::sync::{Mutex, RwLock};
|
|||||||
use df_ai::ai_tools::AiToolRegistry;
|
use df_ai::ai_tools::AiToolRegistry;
|
||||||
use df_storage::crud::{
|
use df_storage::crud::{
|
||||||
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
|
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
|
||||||
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
|
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectModuleRepo,
|
||||||
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
ProjectRepo, ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo,
|
||||||
|
WorkflowRepo,
|
||||||
};
|
};
|
||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
use df_workflow::eventbus::EventBus;
|
use df_workflow::eventbus::EventBus;
|
||||||
@@ -70,6 +71,10 @@ pub struct AppState {
|
|||||||
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
|
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
|
||||||
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
|
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
|
||||||
pub project_services: ProjectServiceRepo,
|
pub project_services: ProjectServiceRepo,
|
||||||
|
/// 工程表 Repo(工程系统 V34,project_modules)。
|
||||||
|
/// 项目多工程(每个工程独立代码仓库):Monorepo 多仓库 / 微服务 / 前后端分离。
|
||||||
|
/// 记录工程元数据(路径/Git 地址/技术栈);Git 状态(分支/改动/提交)实时派生不存表。
|
||||||
|
pub project_modules: ProjectModuleRepo,
|
||||||
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub releases: ReleaseRepo,
|
pub releases: ReleaseRepo,
|
||||||
@@ -203,6 +208,7 @@ impl AppState {
|
|||||||
task_links: TaskLinkRepo::new(&db),
|
task_links: TaskLinkRepo::new(&db),
|
||||||
project_events: ProjectEventRepo::new(&db),
|
project_events: ProjectEventRepo::new(&db),
|
||||||
project_services: ProjectServiceRepo::new(&db),
|
project_services: ProjectServiceRepo::new(&db),
|
||||||
|
project_modules: ProjectModuleRepo::new(&db),
|
||||||
releases: ReleaseRepo::new(&db),
|
releases: ReleaseRepo::new(&db),
|
||||||
workflows: WorkflowRepo::new(&db),
|
workflows: WorkflowRepo::new(&db),
|
||||||
node_executions: NodeExecutionRepo::new(&db),
|
node_executions: NodeExecutionRepo::new(&db),
|
||||||
|
|||||||
Reference in New Issue
Block a user