759 lines
34 KiB
Rust
759 lines
34 KiB
Rust
//! 项目域 Repo:ProjectRepo / BranchRepo / ReleaseRepo / WorkflowRepo / NodeExecutionRepo
|
|
|
|
use std::sync::Arc;
|
|
|
|
use rusqlite::{params, Connection, OptionalExtension, Row};
|
|
use tokio::sync::Mutex;
|
|
|
|
use df_types::error::Result;
|
|
use df_types::types::ProjectStatus;
|
|
|
|
use crate::db::Database;
|
|
use crate::models::{
|
|
BranchRecord, NodeExecutionRecord, ProjectRecord, ReleaseRecord, WorkflowRecord,
|
|
};
|
|
|
|
use super::impl_repo;
|
|
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
|
|
|
|
// ============================================================
|
|
// 项目查询入参(P2/P3 查询维度补全)
|
|
// ============================================================
|
|
|
|
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
|
|
///
|
|
/// 对齐方案 docs/02-架构设计/专项设计/查询能力补全方案-2026-06-21.md §4.1 统一查询结构。
|
|
/// 项目表无 status 生命周期(P1 跳过),仅软删 deleted_at(由 list_by_query 固定排除)。
|
|
/// 故本结构不含 status,只含 keyword/order_by/limit/offset 四维。
|
|
///
|
|
/// 序列化 derive:命令层经 serde 从前端 IPC 接收(Option 字段缺省 None,旧调用方不传等价全量)。
|
|
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct ProjectQuery {
|
|
/// 关键词:name/description LIKE %kw%(trim 后空字符串视为无关键词)
|
|
#[serde(default)]
|
|
pub keyword: Option<String>,
|
|
/// 排序字段(白名单校验,格式 "col" / "col asc" / "col desc",默认 created_at desc)
|
|
#[serde(default)]
|
|
pub order_by: Option<String>,
|
|
/// 返回上限(钳制 [0,200],None = 不加 LIMIT 全量)
|
|
#[serde(default)]
|
|
pub limit: Option<u32>,
|
|
/// 偏移量(None = 0,配合 limit 分页)
|
|
#[serde(default)]
|
|
pub offset: Option<u32>,
|
|
}
|
|
|
|
/// order_by 白名单(独立于 update_field 白名单,对齐 ideas 的 validate_idea_order_by 模式)。
|
|
///
|
|
/// BE-CMD-3:projects update_field 白名单已剔除 id/created_at(主键与创建时间不可经通用
|
|
/// update_field 改写),但 `created_at` 作为**排序字段**仍合法——故排序白名单单独定义,
|
|
/// 不依赖 update_field 白名单(否则 order_by=created_at 会被误拒,破坏 list_by_query 默认排序)。
|
|
const PROJECT_ORDER_BY_ALLOWED: &[&str] = &["created_at", "updated_at", "name", "status"];
|
|
|
|
/// 解析 order_by 入参为 "col DIR" SQL 片段(列名走白名单校验防注入)。
|
|
///
|
|
/// 接受 "col" / "col asc" / "col desc"(DIR 大小写不敏感)。col 走 `PROJECT_ORDER_BY_ALLOWED`
|
|
/// 白名单校验(列名不可参数化,必须拼字符串,白名单是唯一防注入手段,对齐 impl_repo! 宏)。
|
|
/// 非法列名返回 Err;合法但无 DIR 默认 DESC(与 list_active 一致)。
|
|
fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
|
let Some(raw) = order_by else {
|
|
return Ok("created_at DESC".to_string());
|
|
};
|
|
let raw = raw.trim();
|
|
if raw.is_empty() {
|
|
return Ok("created_at DESC".to_string());
|
|
}
|
|
// 拆 "col [dir]",最多 2 段。
|
|
let parts: Vec<&str> = raw.split_whitespace().collect();
|
|
let col = parts[0];
|
|
let dir = parts.get(1).map(|s| s.to_ascii_uppercase());
|
|
// 排序白名单校验列名(防 SQL 注入:列名拼字符串前必须校验;独立于 update_field 白名单)。
|
|
if !PROJECT_ORDER_BY_ALLOWED.contains(&col) {
|
|
return Err(df_types::error::Error::Storage(format!(
|
|
"非法 order_by 字段名: {col},合法值: {:?}",
|
|
PROJECT_ORDER_BY_ALLOWED
|
|
)));
|
|
}
|
|
match dir.as_deref() {
|
|
None | Some("DESC") => Ok(format!("{col} DESC")),
|
|
Some("ASC") => Ok(format!("{col} ASC")),
|
|
// 其它非法方向词:拒绝(防 "; DROP TABLE" 之类拼串绕过)。
|
|
Some(other) => Err(df_types::error::Error::Storage(format!(
|
|
"非法排序方向: {other}(仅支持 asc/desc)"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
|
///
|
|
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
|
|
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
|
|
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
|
|
///
|
|
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
|
#[derive(Debug, Clone, serde::Serialize)]
|
|
pub struct ProjectActivityRecord {
|
|
/// 项目完整记录(嵌套序列化:record.id / record.name ...)
|
|
#[serde(flatten)]
|
|
pub record: ProjectRecord,
|
|
/// 最近活跃时间(毫秒字符串)。前端独立消费,不混入 record 的 updated_at。
|
|
pub last_active_at: String,
|
|
}
|
|
|
|
// ============================================================
|
|
// from_row 辅助函数
|
|
// ============================================================
|
|
|
|
fn project_from_row(row: &Row<'_>) -> std::result::Result<ProjectRecord, rusqlite::Error> {
|
|
Ok(ProjectRecord {
|
|
id: row.get("id")?,
|
|
name: row.get("name")?,
|
|
description: row.get("description")?,
|
|
status: {
|
|
let s: String = row.get("status")?;
|
|
ProjectStatus::from_db_str(&s).unwrap_or_default()
|
|
},
|
|
idea_id: row.get("idea_id")?,
|
|
path: row.get("path")?,
|
|
stack: row.get("stack")?,
|
|
created_at: row.get("created_at")?,
|
|
updated_at: row.get("updated_at")?,
|
|
})
|
|
}
|
|
|
|
fn branch_from_row(row: &Row<'_>) -> std::result::Result<BranchRecord, rusqlite::Error> {
|
|
Ok(BranchRecord {
|
|
id: row.get("id")?,
|
|
project_id: row.get("project_id")?,
|
|
task_id: row.get("task_id")?,
|
|
name: row.get("name")?,
|
|
base: row.get("base")?,
|
|
status: row.get("status")?,
|
|
created_at: row.get("created_at")?,
|
|
updated_at: row.get("updated_at")?,
|
|
merged_at: row.get("merged_at")?,
|
|
})
|
|
}
|
|
|
|
fn release_from_row(row: &Row<'_>) -> std::result::Result<ReleaseRecord, rusqlite::Error> {
|
|
Ok(ReleaseRecord {
|
|
id: row.get("id")?,
|
|
project_id: row.get("project_id")?,
|
|
version: row.get("version")?,
|
|
status: row.get("status")?,
|
|
task_ids: row.get("task_ids")?,
|
|
changelog: row.get("changelog")?,
|
|
created_at: row.get("created_at")?,
|
|
released_at: row.get("released_at")?,
|
|
})
|
|
}
|
|
|
|
fn workflow_from_row(row: &Row<'_>) -> std::result::Result<WorkflowRecord, rusqlite::Error> {
|
|
Ok(WorkflowRecord {
|
|
id: row.get("id")?,
|
|
name: row.get("name")?,
|
|
dag_json: row.get("dag_json")?,
|
|
status: row.get("status")?,
|
|
triggered_by: row.get("triggered_by")?,
|
|
project_id: row.get("project_id")?,
|
|
task_id: row.get("task_id")?,
|
|
created_at: row.get("created_at")?,
|
|
completed_at: row.get("completed_at")?,
|
|
})
|
|
}
|
|
|
|
fn node_execution_from_row(
|
|
row: &Row<'_>,
|
|
) -> std::result::Result<NodeExecutionRecord, rusqlite::Error> {
|
|
Ok(NodeExecutionRecord {
|
|
id: row.get("id")?,
|
|
workflow_id: row.get("workflow_id")?,
|
|
node_id: row.get("node_id")?,
|
|
node_type: row.get("node_type")?,
|
|
status: row.get("status")?,
|
|
input_json: row.get("input_json")?,
|
|
output_json: row.get("output_json")?,
|
|
error_message: row.get("error_message")?,
|
|
started_at: row.get("started_at")?,
|
|
completed_at: row.get("completed_at")?,
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// Repo 实现
|
|
// ============================================================
|
|
|
|
impl_repo!(
|
|
/// 项目表 CRUD
|
|
ProjectRepo,
|
|
ProjectRecord,
|
|
"projects",
|
|
from_row => |row| project_from_row(row),
|
|
insert => |conn, rec| {
|
|
conn.execute(
|
|
"INSERT INTO projects (id, name, description, status, idea_id, path, stack, created_at, updated_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
params![
|
|
rec.id, rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
|
rec.path, rec.stack, rec.created_at, rec.updated_at
|
|
],
|
|
)
|
|
},
|
|
update => |conn, rec| {
|
|
conn.execute(
|
|
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8",
|
|
params![
|
|
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
|
rec.path, rec.stack, rec.updated_at, rec.id
|
|
],
|
|
)
|
|
}
|
|
);
|
|
|
|
impl ProjectRepo {
|
|
/// 列出未删除项目(deleted_at IS NULL)
|
|
pub async fn list_active(&self) -> Result<Vec<ProjectRecord>> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NULL ORDER BY created_at DESC")
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map([], |row| project_from_row(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)?
|
|
}
|
|
|
|
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
|
|
/// COALESCE 回退 updated_at)。
|
|
///
|
|
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
|
|
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
|
|
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
|
|
///
|
|
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
|
|
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
|
|
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
|
|
///
|
|
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
|
|
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
|
|
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
|
let mut stmt = guard
|
|
.prepare(
|
|
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at, \
|
|
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
|
updated_at) AS last_active_at \
|
|
FROM projects WHERE deleted_at IS NULL \
|
|
ORDER BY last_active_at DESC, created_at DESC",
|
|
)
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map([], |row| {
|
|
Ok(ProjectActivityRecord {
|
|
record: project_from_row(row)?,
|
|
last_active_at: row.get("last_active_at")?,
|
|
})
|
|
})
|
|
.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)?
|
|
}
|
|
|
|
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
|
|
///
|
|
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
|
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
|
|
///
|
|
/// - `keyword`:name/description `LIKE %kw%`(对齐知识库 title/content LIKE)。
|
|
/// - `order_by`:走白名单 `validate_column_name` 防 SQL 注入(列名不可参数化,必须拼字符串,
|
|
/// 白名单是唯一防注入手段)。默认 `created_at DESC`(与 list_active 等价)。
|
|
/// - `limit`:钳制上限 200(对齐 `KnowledgeEventsRepo::list_recent`),防恶意/失误传超大值。
|
|
/// - `offset`:偏移量,配合 limit 做分页。
|
|
///
|
|
/// 向后兼容:全字段 None 时 SQL 退化为
|
|
/// `SELECT ... WHERE deleted_at IS NULL ORDER BY created_at DESC`
|
|
/// 与 `list_active` 完全等价,零破坏。
|
|
pub async fn list_by_query(&self, q: ProjectQuery) -> Result<Vec<ProjectRecord>> {
|
|
// order_by 白名单校验(列名拼字符串前必须校验,防注入)。
|
|
// 校验后再拼方向:允许 "col" / "col asc" / "col desc"(大小写不敏感)。
|
|
let order_clause = build_order_clause(q.order_by.as_deref())?;
|
|
|
|
let conn = self.conn.clone();
|
|
// limit 钳制:None 不加 LIMIT(全量,对齐 list_active);Some 钳到 [0,200]。
|
|
let limit_i: Option<i64> = q.limit.map(|n| n.min(200) as i64);
|
|
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
|
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
// 动态拼 WHERE + 参数。WHERE 1=1 起点 + deleted_at IS NULL 固定基础条件,
|
|
// 后续可选条件 AND 拼接(对齐 KnowledgeRepo::search 的 if-let 分支模式)。
|
|
let mut sql = String::from(
|
|
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at \
|
|
FROM projects WHERE deleted_at IS NULL",
|
|
);
|
|
// keyword 用 ?N 占位两次(name LIKE ? AND description LIKE ? 同 pattern),
|
|
// 对齐 KnowledgeRepo::search 的 `params![pattern, pattern, ...]`。
|
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
|
if let Some(kw) = &q.keyword {
|
|
let trimmed = kw.trim();
|
|
if !trimmed.is_empty() {
|
|
let escaped = trimmed.replace('|', "||").replace('%', "|%").replace('_', "|_");
|
|
// ESCAPE 跟单个 LIKE(不能跟括号分组,否则 near "ESCAPE" syntax error)
|
|
sql.push_str(" AND (name LIKE ? ESCAPE '|' OR description LIKE ? ESCAPE '|')");
|
|
let pattern = format!("%{escaped}%");
|
|
params_vec.push(Box::new(pattern.clone()));
|
|
params_vec.push(Box::new(pattern));
|
|
}
|
|
}
|
|
sql.push_str(" ORDER BY ");
|
|
sql.push_str(&order_clause);
|
|
// 分页:有 limit 才加 LIMIT/OFFSET(全量场景不加,等价 list_active)。
|
|
if let Some(lim) = limit_i {
|
|
sql.push_str(" LIMIT ? OFFSET ?");
|
|
params_vec.push(Box::new(lim));
|
|
params_vec.push(Box::new(offset_i));
|
|
}
|
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
|
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
|
params_vec.iter().map(|p| p.as_ref()).collect();
|
|
let rows = stmt
|
|
.query_map(param_refs.as_slice(), |row| project_from_row(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)?
|
|
}
|
|
|
|
/// 软删:标记 deleted_at(进回收站,可恢复)。仅作用于未删项目,返回是否命中。
|
|
pub async fn soft_delete(&self, id: &str) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
let now = now_millis_str();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let affected = guard
|
|
.execute(
|
|
"UPDATE projects SET deleted_at = ?1, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
|
|
params![now, id],
|
|
)
|
|
.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 恢复:清 deleted_at(从回收站还原)
|
|
pub async fn restore(&self, id: &str) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
let now = now_millis_str();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let affected = guard
|
|
.execute(
|
|
"UPDATE projects SET deleted_at = NULL, updated_at = ?1 WHERE id = ?2 AND deleted_at IS NOT NULL",
|
|
params![now, id],
|
|
)
|
|
.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 查找已绑定该规范化路径的项目(排除 exclude_id 自身)。无冲突返回 None。
|
|
///
|
|
/// DRY(R-PD-11):统一 project.rs::find_binding_conflict 与 tool_registry.rs::bind_dir_to_project
|
|
/// 的「列项目→排除自身→按规范化路径比较」逻辑。本 crate 不依赖 df-project,故 norm_path 须由
|
|
/// 调用方先经 `df_project::scan::normalize_path`(内含 canonicalize,防 `C:\a\b` vs `C:/a/b/` 绕过)。
|
|
pub async fn find_path_conflict(
|
|
&self,
|
|
norm_path: &str,
|
|
exclude_id: Option<&str>,
|
|
) -> Result<Option<ProjectRecord>> {
|
|
let projects = self.list_active().await?;
|
|
Ok(projects.into_iter().find(|p| {
|
|
let excluded = exclude_id.is_some_and(|eid| p.id == eid);
|
|
!excluded && p.path.as_ref().is_some_and(|pp| {
|
|
// 路径规范化由调用方完成目标侧;这里为已存项目路径补规范化
|
|
// (历史路径写法可能不规范,统一规范化比较避免误判/漏判)
|
|
normalize_stored_path(pp) == norm_path
|
|
})
|
|
}))
|
|
}
|
|
|
|
/// 列出回收站(deleted_at IS NOT NULL),按更新时间(≈删除时间)降序
|
|
pub async fn list_deleted(&self) -> Result<Vec<ProjectRecord>> {
|
|
let conn = self.conn.clone();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let mut stmt = guard
|
|
.prepare("SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at FROM projects WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC")
|
|
.map_err(storage_err)?;
|
|
let rows = stmt
|
|
.query_map([], |row| project_from_row(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)?
|
|
}
|
|
|
|
/// 原子乐观更新(CAS 版 [`update_full`]):整体更新记录,但仅当 DB 当前 `updated_at`
|
|
/// 与 `expected_updated_at` 一致时才写入(`WHERE id=? AND updated_at=?expected`)。
|
|
///
|
|
/// 关闭读-改-写跨进程 TOCTOU(devflow-mcp 多进程缺陷 P0-1):MCP 进程先 `get_by_id`
|
|
/// 读 `existing.updated_at` 作 expected,再调本方法单条原子条件写。并发端(GUI 进程)
|
|
/// 若已改动该记录,`affected==0` 返回 `false`,调用方据此报「记录已被其他端修改」
|
|
/// 而非静默覆盖(旧 `update_full` 无条件覆盖,存在跨进程互相覆盖竞态)。
|
|
///
|
|
/// 不动 GUI 的无条件 [`update_full`](无 expected 语义,保持现状);本方法仅服务
|
|
/// 需要乐观锁版本的调用方(df-mcp update_project)。`Ok(false)` = id 不存在或版本冲突。
|
|
pub async fn update_full_cas(
|
|
&self,
|
|
record: &ProjectRecord,
|
|
expected_updated_at: &str,
|
|
) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let rec = record.clone();
|
|
let expected = expected_updated_at.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let guard = conn.blocking_lock();
|
|
let affected = guard
|
|
.execute(
|
|
"UPDATE projects SET name = ?1, description = ?2, status = ?3, idea_id = ?4, path = ?5, stack = ?6, updated_at = ?7 WHERE id = ?8 AND updated_at = ?9",
|
|
params![
|
|
rec.name, rec.description, rec.status.as_str(), rec.idea_id,
|
|
rec.path, rec.stack, rec.updated_at, rec.id, expected
|
|
],
|
|
)
|
|
.map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
|
|
/// 更新单字段,**仅作用于未软删记录**(`WHERE id AND deleted_at IS NULL`)。
|
|
///
|
|
/// LW-6(BE-CMD-5):通用 [`update_field`] 不过滤软删(回收站项目仍可改字段),
|
|
/// 本方法收口软删防护,供命令层 `update_project` 使用——软删项目(回收站)返回 `false`,
|
|
/// 调用方据此报「已删除」。字段名走同款 [`validate_column_name`] 白名单防注入。
|
|
pub async fn update_field_active(&self, id: &str, field: &str, value: &str) -> Result<bool> {
|
|
validate_column_name(field, "projects")?;
|
|
let conn = self.conn.clone();
|
|
let sql = format!(
|
|
"UPDATE projects SET {} = ?1, updated_at = ?2 WHERE id = ?3 AND deleted_at IS NULL",
|
|
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)?
|
|
}
|
|
|
|
/// 彻底删除:事务级联删全部关联子表→projects(不可恢复)
|
|
///
|
|
/// SQLite 已开 PRAGMA foreign_keys=ON 但表无 ON DELETE CASCADE,ALTER 改不了 FK 约束,
|
|
/// 故应用层级联:子表先于父表删,单事务保证一致性。
|
|
///
|
|
/// **级联范围**(G1.2 补全):V1-V35 全部 `REFERENCES projects(id)` 表——
|
|
/// branches / releases / tasks / workflow_executions(V2) + 知识图谱 V29-V35 新增
|
|
/// task_links / project_events / project_services / project_modules / module_dependencies,
|
|
/// 以及经它们间接到项目的 node_executions(REFERENCES workflow_executions)/
|
|
/// module_dependencies(REFERENCES project_modules)。此前只删四表,带 modules/事件/服务的
|
|
/// 工程 purge 在 foreign_keys=ON 下必 FK 违例回滚(latent bug)。
|
|
///
|
|
/// **删除顺序 = FK 拓扑 最深子表→父**(先删被引用方,防 foreign_keys=ON 下 FK 违例):
|
|
/// node_executions → task_links → branches → module_dependencies → workflow_executions
|
|
/// → tasks → project_events → project_services → project_modules → releases → projects。
|
|
///
|
|
/// 表存在性守卫:逐表先查 `sqlite_master` 存在才 DELETE(防老库缺 V29-V35 新表时
|
|
/// `no such table` 中断整个事务回滚)。
|
|
pub async fn purge_with_descendants(&self, id: &str) -> Result<bool> {
|
|
let conn = self.conn.clone();
|
|
let id = id.to_owned();
|
|
tokio::task::spawn_blocking(move || {
|
|
let mut guard = conn.blocking_lock();
|
|
let tx = guard.transaction().map_err(storage_err)?;
|
|
|
|
// (表名, 删除 SQL)。表名/SQL 均为编译期常量,无注入风险。
|
|
// node_executions / task_links 无 project_id 列,经其父表子查询收敛到本项目;
|
|
// workflow_executions 兼删 task_id 命中(project_id 可空,补齐 task_id 关联路径)。
|
|
const CASCADE: &[(&str, &str)] = &[
|
|
// node_executions REFERENCES workflow_executions(id):先于 workflow_executions 删
|
|
(
|
|
"node_executions",
|
|
"DELETE FROM node_executions WHERE workflow_id IN \
|
|
(SELECT id FROM workflow_executions WHERE project_id = ?1 \
|
|
OR task_id IN (SELECT id FROM tasks WHERE project_id = ?1))",
|
|
),
|
|
// task_links REFERENCES tasks(id):先于 tasks 删(source/target 任一端属本项目)
|
|
(
|
|
"task_links",
|
|
"DELETE FROM task_links WHERE source_id IN \
|
|
(SELECT id FROM tasks WHERE project_id = ?1) OR target_id IN \
|
|
(SELECT id FROM tasks WHERE project_id = ?1)",
|
|
),
|
|
// branches REFERENCES tasks(id) + projects(id):先于 tasks 删
|
|
("branches", "DELETE FROM branches WHERE project_id = ?1"),
|
|
// module_dependencies REFERENCES project_modules(id) + projects(id):先于 project_modules 删
|
|
("module_dependencies", "DELETE FROM module_dependencies WHERE project_id = ?1"),
|
|
// workflow_executions REFERENCES projects(id)(可空,兼删 task_id 关联)
|
|
(
|
|
"workflow_executions",
|
|
"DELETE FROM workflow_executions WHERE project_id = ?1 \
|
|
OR task_id IN (SELECT id FROM tasks WHERE project_id = ?1)",
|
|
),
|
|
// tasks REFERENCES projects(id)(自引用 parent_id 同语句删,单语句末校验不违例)
|
|
("tasks", "DELETE FROM tasks WHERE project_id = ?1"),
|
|
// 以下直接 REFERENCES projects(id),无子表依赖,顺序任意
|
|
("project_events", "DELETE FROM project_events WHERE project_id = ?1"),
|
|
("project_services", "DELETE FROM project_services WHERE project_id = ?1"),
|
|
("project_modules", "DELETE FROM project_modules WHERE project_id = ?1"),
|
|
("releases", "DELETE FROM releases WHERE project_id = ?1"),
|
|
];
|
|
|
|
for (table, sql) in CASCADE {
|
|
// 表存在性守卫:老库可能缺 V29-V35 新表,缺表时 DELETE 报 no such table
|
|
// 中断事务回滚;探测存在才删,防老库 purge 失败。
|
|
let exists: bool = tx
|
|
.query_row(
|
|
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
|
params![*table],
|
|
|_| Ok(true),
|
|
)
|
|
.optional()
|
|
.map_err(storage_err)?
|
|
.unwrap_or(false);
|
|
if exists {
|
|
tx.execute(sql, params![id]).map_err(storage_err)?;
|
|
}
|
|
}
|
|
|
|
let affected = tx
|
|
.execute("DELETE FROM projects WHERE id = ?1", params![id])
|
|
.map_err(storage_err)?;
|
|
tx.commit().map_err(storage_err)?;
|
|
Ok(affected > 0)
|
|
})
|
|
.await
|
|
.map_err(storage_err)?
|
|
}
|
|
}
|
|
|
|
impl_repo!(
|
|
/// 分支表 CRUD
|
|
BranchRepo,
|
|
BranchRecord,
|
|
"branches",
|
|
from_row => |row| branch_from_row(row),
|
|
insert => |conn, rec| {
|
|
conn.execute(
|
|
"INSERT INTO branches (id, project_id, task_id, name, base, status, created_at, updated_at, merged_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
params![
|
|
rec.id, rec.project_id, rec.task_id, rec.name, rec.base,
|
|
rec.status, rec.created_at, rec.updated_at, rec.merged_at
|
|
],
|
|
)
|
|
},
|
|
update => |conn, rec| {
|
|
conn.execute(
|
|
"UPDATE branches SET project_id = ?1, task_id = ?2, name = ?3, base = ?4, status = ?5, updated_at = ?6, merged_at = ?7 WHERE id = ?8",
|
|
params![
|
|
rec.project_id, rec.task_id, rec.name, rec.base,
|
|
rec.status, rec.updated_at, rec.merged_at, rec.id
|
|
],
|
|
)
|
|
}
|
|
);
|
|
|
|
impl_repo!(
|
|
/// 发布表 CRUD
|
|
ReleaseRepo,
|
|
ReleaseRecord,
|
|
"releases",
|
|
from_row => |row| release_from_row(row),
|
|
insert => |conn, rec| {
|
|
conn.execute(
|
|
"INSERT INTO releases (id, project_id, version, status, task_ids, changelog, created_at, released_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
params![
|
|
rec.id, rec.project_id, rec.version, rec.status, rec.task_ids,
|
|
rec.changelog, rec.created_at, rec.released_at
|
|
],
|
|
)
|
|
},
|
|
update => |conn, rec| {
|
|
conn.execute(
|
|
"UPDATE releases SET project_id = ?1, version = ?2, status = ?3, task_ids = ?4, changelog = ?5, released_at = ?6 WHERE id = ?7",
|
|
params![
|
|
rec.project_id, rec.version, rec.status, rec.task_ids,
|
|
rec.changelog, rec.released_at, rec.id
|
|
],
|
|
)
|
|
}
|
|
);
|
|
|
|
impl_repo!(
|
|
/// 工作流执行表 CRUD
|
|
WorkflowRepo,
|
|
WorkflowRecord,
|
|
"workflow_executions",
|
|
from_row => |row| workflow_from_row(row),
|
|
insert => |conn, rec| {
|
|
conn.execute(
|
|
"INSERT INTO workflow_executions (id, name, dag_json, status, triggered_by, project_id, task_id, created_at, completed_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
params![
|
|
rec.id, rec.name, rec.dag_json, rec.status, rec.triggered_by,
|
|
rec.project_id, rec.task_id, rec.created_at, rec.completed_at
|
|
],
|
|
)
|
|
},
|
|
update => |conn, rec| {
|
|
conn.execute(
|
|
"UPDATE workflow_executions SET name = ?1, dag_json = ?2, status = ?3, triggered_by = ?4, project_id = ?5, task_id = ?6, completed_at = ?7 WHERE id = ?8",
|
|
params![
|
|
rec.name, rec.dag_json, rec.status, rec.triggered_by,
|
|
rec.project_id, rec.task_id, rec.completed_at, rec.id
|
|
],
|
|
)
|
|
}
|
|
);
|
|
|
|
impl_repo!(
|
|
/// 节点执行表 CRUD
|
|
NodeExecutionRepo,
|
|
NodeExecutionRecord,
|
|
"node_executions",
|
|
from_row => |row| node_execution_from_row(row),
|
|
insert => |conn, rec| {
|
|
conn.execute(
|
|
"INSERT INTO node_executions (id, workflow_id, node_id, node_type, status, input_json, output_json, error_message, started_at, completed_at)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
|
params![
|
|
rec.id, rec.workflow_id, rec.node_id, rec.node_type, rec.status,
|
|
rec.input_json, rec.output_json, rec.error_message, rec.started_at, rec.completed_at
|
|
],
|
|
)
|
|
},
|
|
update => |conn, rec| {
|
|
conn.execute(
|
|
"UPDATE node_executions SET workflow_id = ?1, node_id = ?2, node_type = ?3, status = ?4, input_json = ?5, output_json = ?6, error_message = ?7, started_at = ?8, completed_at = ?9 WHERE id = ?10",
|
|
params![
|
|
rec.workflow_id, rec.node_id, rec.node_type, rec.status,
|
|
rec.input_json, rec.output_json, rec.error_message, rec.started_at, rec.completed_at, rec.id
|
|
],
|
|
)
|
|
}
|
|
);
|
|
|
|
// ============================================================
|
|
// 单元测试 — update_full_cas 原子乐观更新(devflow-mcp P0-1 TOCTOU 关闭)
|
|
// 锁定:① expected 与 DB updated_at 一致 → 写入 true;② expected 旧版本(并发已改)
|
|
// → 不写入返回 false 且原值保留(不覆盖他人修改)。
|
|
// ============================================================
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db::Database;
|
|
|
|
fn prec(id: &str, name: &str) -> ProjectRecord {
|
|
ProjectRecord {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
description: String::new(),
|
|
status: ProjectStatus::Planning,
|
|
idea_id: None,
|
|
path: None,
|
|
stack: None,
|
|
created_at: "1700000000000".to_string(),
|
|
updated_at: "1700000000000".to_string(),
|
|
}
|
|
}
|
|
|
|
async fn setup() -> ProjectRepo {
|
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
ProjectRepo::new(&db)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_full_cas_success_when_expected_matches() {
|
|
let repo = setup().await;
|
|
repo.insert(prec("p1", "原项目")).await.unwrap();
|
|
let current = repo.get_by_id("p1").await.unwrap().unwrap();
|
|
// 本地构建新版本(name + updated_at 递增)
|
|
let mut rec = current.clone();
|
|
rec.name = "新项目".to_string();
|
|
rec.updated_at = "1800000000000".to_string();
|
|
let ok = repo
|
|
.update_full_cas(&rec, ¤t.updated_at)
|
|
.await
|
|
.unwrap();
|
|
assert!(ok, "expected 与 DB 一致应写入");
|
|
let after = repo.get_by_id("p1").await.unwrap().unwrap();
|
|
assert_eq!(after.name, "新项目");
|
|
assert_eq!(after.updated_at, "1800000000000");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_full_cas_conflict_when_expected_stale() {
|
|
let repo = setup().await;
|
|
repo.insert(prec("p1", "原项目")).await.unwrap();
|
|
// 另一进程(如 GUI)先无条件覆盖:updated_at 从 1700 变 1800
|
|
let mut other = repo.get_by_id("p1").await.unwrap().unwrap();
|
|
other.name = "他人已改".to_string();
|
|
other.updated_at = "1800000000000".to_string();
|
|
assert!(repo.update_full(&other).await.unwrap());
|
|
|
|
// 本地持旧版本 expected(1700)→ CAS 应拒绝,不覆盖他人修改
|
|
let mut mine = repo.get_by_id("p1").await.unwrap().unwrap();
|
|
mine.name = "我的修改".to_string();
|
|
mine.updated_at = "1900000000000".to_string();
|
|
let ok = repo.update_full_cas(&mine, "1700000000000").await.unwrap();
|
|
assert!(!ok, "expected 过期(并发已改)应返回 false");
|
|
let after = repo.get_by_id("p1").await.unwrap().unwrap();
|
|
assert_eq!(after.name, "他人已改", "CAS 冲突不得覆盖他人修改");
|
|
assert_eq!(after.updated_at, "1800000000000");
|
|
}
|
|
}
|