新增: 知识图谱Phase3基础设施(父⑥) — project_services+D10凭证审查+IPC+AI工具

父⑥ Phase 3(对标设计 §2.3):
- V31 迁移:project_services 表(id/project_id FK/name/service_type/endpoint/config_json/environment/remark)+ idx_project_services_project
- ProjectServiceRecord + ProjectServiceRepo(impl_repo! 宏 + insert_validated/update_full_validated/list_by_project/list_by_project_env)
- service_type 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,应用层校验)
- D10 凭证审查:config_json/endpoint/remark 含 password/passwd/secret/api_key/apikey/token 拒绝(不存敏感凭证,凭证走环境变量)
- 4 IPC(add/update/remove/list_project_services,环境过滤)+ lib.rs 注册
- AI 工具 add/list_project_services(Medium/Low)+ 基线 39→41(data 25→27)+ 3 端到端测试

P2 修复(verify agent 发现):
- settings.rs project_services 白名单移除 service_type(对齐注释"不列入"+ status 收口,防 update_field 旁路绕过 validate_service_type)

df-storage 117(105+12) + df-ai 331 + 基线 41 测试全过。
This commit is contained in:
2026-06-27 00:49:09 +08:00
parent 0c348f2311
commit e33e8f86d3
11 changed files with 1231 additions and 12 deletions

View File

@@ -9,7 +9,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
use df_storage::db::Database;
use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
use df_storage::models::{ProjectRecord, ProjectServiceRecord, TaskRecord, IdeaRecord};
use df_types::types::new_id;
@@ -500,6 +500,8 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
/// data 层 24→25。
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置,data 层 25→27。
///
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
@@ -514,6 +516,8 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
/// 该子函数 6→7 工具,data 层 24→25。
/// 知识图谱 Phase 3(2026-06-27):register_task_graph_tools 增 add_project_service(Medium)/
/// list_project_services(Low 只读) 项目基础设施配置(D10 不存凭证),该子函数 7→9 工具,data 层 25→27。
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增,
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
@@ -1148,6 +1152,136 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
})
})},
);
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
// add_project_service 同源)。
//
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
registry.register(
"add_project_service", "为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
df_ai::ai_tools::object_schema(vec![
("project_id", "string", true),
("name", "string", true),
("service_type", "string", true),
("environment", "string", false),
("endpoint", "string", false),
("config_json", "string", false),
("remark", "string", false),
]),
RiskLevel::Medium,
{ 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();
let name = args["name"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 name"))?
.trim()
.to_string();
let service_type = args["service_type"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
if name.is_empty() {
anyhow::bail!("name 不能为空");
}
if service_type.is_empty() {
anyhow::bail!("service_type 不能为空");
}
let endpoint = args.get("endpoint")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let config_json = args.get("config_json")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let environment = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("development")
.to_string();
let remark = args.get("remark")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let record = ProjectServiceRecord {
id: new_id(),
project_id: project_id.clone(),
name,
service_type,
endpoint,
config_json,
environment,
remark,
created_at: now_millis(),
updated_at: now_millis(),
};
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = repo.insert_validated(record).await?;
let inserted = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
Ok(serde_json::to_value(&inserted)?)
})
})},
);
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
registry.register(
"list_project_services", "查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
df_ai::ai_tools::object_schema(vec![
("project_id", "string", true),
("environment", "string", false),
]),
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 env_filter = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
let items = if let Some(env) = &env_filter {
repo.list_by_project_env(&project_id, env).await?
} else {
repo.list_by_project(&project_id).await?
};
let total = items.len();
Ok(serde_json::json!({
"items": items,
"total": total,
"project_id": project_id,
}))
})
})},
);
}
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
@@ -2830,7 +2964,7 @@ mod tests {
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
// ============================================================
/// build_ai_tool_registry 应注册恰好 39 个工具(25 data + 13 file + 1 http),且工具名集合稳定。
/// build_ai_tool_registry 应注册恰好 41 个工具(27 data + 13 file + 1 http),且工具名集合稳定。
///
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
@@ -2843,7 +2977,7 @@ mod tests {
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
// 总量基线:39(25 data + 13 file + 1 http)。拆分前后必须一致。
// 总量基线:41(27 data + 13 file + 1 http)。拆分前后必须一致。
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
@@ -2851,18 +2985,20 @@ mod tests {
// create_task_link/remove_task_link/list_task_links/get_task_tree/move_task_queue/update_content)。
// 知识图谱 Phase 2(2026-06-26): data 层 24→25(新增 get_project_timeline 项目事件流查询,
// register_task_graph_tools 6→7)。
// 知识图谱 Phase 3(2026-06-27): data 层 25→27(新增 add_project_service/list_project_services
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
assert_eq!(
registry.len(),
39,
"工具总数应为 39(25 data + 13 file + 1 http),实际 {}", registry.len()
41,
"工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len()
);
// 工具名集合基线:防 rename / 漏注册 / 误删除。
// data 层 25 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流
// data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
// http 层 1 个(不持 db):http_request
let mut expected: Vec<&str> = vec![
// ── data 层 (25) ──
// ── data 层 (27) ──
"list_projects", "list_tasks", "list_ideas",
"update_project", "create_project", "bind_directory",
"create_task", "update_task", "advance_task",
@@ -2874,6 +3010,8 @@ mod tests {
"get_task_tree", "move_task_queue", "update_content",
// 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
"get_project_timeline",
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
"add_project_service", "list_project_services",
// ── file 层 (13) ──run_command 注册顺序已移至末位降低 LLM 偏好,
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能)
@@ -3565,4 +3703,132 @@ mod tests {
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
}
// ============================================================
// 知识图谱 Phase 3(2026-06-27):add_project_service / list_project_services
// AI 工具 handler 端到端串联测试(经 registry.execute 验证参数装配 → Repo 落库 → 回读)。
// service_type 白名单 + D10 凭证审查下沉 Repo 层(已在 project_service_repo.rs 单元测试覆盖),
// 此处锁定 AI handler 的参数装配 / 调用路径正确(防 handler 拼参错位 / 漏调 insert_validated)。
// ============================================================
/// 造占位项目(project_services.project_id FK 要求 projects 存在)+ registry fixture。
async fn setup_services_registry() -> (Arc<Database>, AiToolRegistry) {
let db = Database::open_in_memory().await.expect("in-memory db");
let db = Arc::new(db);
// 占位 project 满足 FK
let repo = df_storage::crud::ProjectRepo::new(&db);
repo.insert(ProjectRecord {
id: "proj-svc-1".to_string(),
name: "proj-svc-1".to_string(),
description: String::new(),
status: "planning".to_string(),
idea_id: None,
path: None,
stack: None,
created_at: now_millis(),
updated_at: now_millis(),
})
.await
.unwrap();
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
(db, registry)
}
/// add_project_service 正常路径:合法 service_type + 无凭证 → 落库 + 回读完整记录。
#[tokio::test]
async fn test_add_project_service_tool_inserts() {
let (_db, registry) = setup_services_registry().await;
let args = serde_json::json!({
"project_id": "proj-svc-1",
"name": "主库",
"service_type": "mysql",
"environment": "production",
"endpoint": "localhost:3306",
"config_json": "{\"pool_size\":10}",
"remark": "主库"
});
let res = registry
.execute("add_project_service", args)
.await
.expect("add_project_service 执行失败");
assert_eq!(res["project_id"], "proj-svc-1");
assert_eq!(res["name"], "主库");
assert_eq!(res["service_type"], "mysql");
assert_eq!(res["environment"], "production");
assert_eq!(res["endpoint"], "localhost:3306");
assert!(res["id"].as_str().is_some(), "应返回生成的 id");
}
/// add_project_service D10 安全边界:config_json 含 password → handler 返 Err(不落库)。
#[tokio::test]
async fn test_add_project_service_tool_rejects_credential() {
let (db, registry) = setup_services_registry().await;
let args = serde_json::json!({
"project_id": "proj-svc-1",
"name": "主库",
"service_type": "mysql",
"config_json": "{\"password\":\"secret123\"}"
});
let err = registry.execute("add_project_service", args).await;
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
// 未落库
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
let all = repo.list_by_project("proj-svc-1").await.unwrap();
assert!(all.is_empty(), "拒绝后不应有记录");
}
/// list_project_services 查询:插 2 条(不同环境)→ 不带 environment 返回全部,
/// 带 environment 仅返回匹配。
#[tokio::test]
async fn test_list_project_services_tool_filters_environment() {
let (_db, registry) = setup_services_registry().await;
// 插 development + production 各 1 条
for (st, env) in [("mysql", "development"), ("redis", "production")] {
registry
.execute(
"add_project_service",
serde_json::json!({
"project_id": "proj-svc-1",
"name": format!("svc-{env}"),
"service_type": st,
"environment": env,
}),
)
.await
.unwrap();
}
// 不带 environment:返回全部 2 条
let all = registry
.execute(
"list_project_services",
serde_json::json!({ "project_id": "proj-svc-1" }),
)
.await
.unwrap();
assert_eq!(all["total"], 2);
assert_eq!(all["items"].as_array().unwrap().len(), 2);
// 仅 development:1 条
let dev = registry
.execute(
"list_project_services",
serde_json::json!({ "project_id": "proj-svc-1", "environment": "development" }),
)
.await
.unwrap();
assert_eq!(dev["total"], 1);
assert_eq!(dev["items"][0]["environment"], "development");
assert_eq!(dev["items"][0]["service_type"], "mysql");
// 不存在的环境:0 条
let staging = registry
.execute(
"list_project_services",
serde_json::json!({ "project_id": "proj-svc-1", "environment": "staging" }),
)
.await
.unwrap();
assert_eq!(staging["total"], 0);
}
}

View File

@@ -3,10 +3,12 @@
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
pub mod ai;
pub mod events;
pub mod idea;
pub mod knowledge;
pub mod knowledge_timeline;
pub mod project;
pub mod services;
pub mod settings;
pub mod task;
pub mod workflow;

View File

@@ -0,0 +1,285 @@
//! 项目基础设施配置相关命令(知识图谱 Phase 3 V31,对标设计 §2.3 + §五 + D10 安全边界)
//!
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
//!
//! 关键设计(对标 events.rs Phase 2 模式 + project.rs IPC 模式):
//! - **service_type 白名单**:mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
//! 校验下沉 ProjectServiceRepo::insert_validated / update_full_validated(单一真相源)。
//! - **D10 安全边界**:不存敏感凭证。ProjectServiceRepo 在 insert/update 时审查
//! config_json / endpoint / remark 是否含 password/secret/token/api_key 子串,命中即拒。
//! IPC 入参 schema 在此注明,config_json 不含密码 —— 凭证走环境变量。
//! - create/update IPC 返回完整记录(对标 create_project),前端拿回 id 直接用。
use serde::{Deserialize, Serialize};
use tauri::State;
use df_storage::crud::PROJECT_SERVICE_TYPES;
use df_storage::models::ProjectServiceRecord;
use df_types::types::new_id;
use crate::state::AppState;
use super::{err_str, now_millis};
// ============================================================
// 入参 / 出参结构
// ============================================================
/// 新增基础设施配置入参(对标设计 §五 add_project_service + D10 边界)。
///
/// ⚠️ **D10 安全边界**:不存敏感凭证(密码/密钥/Token)。config_json / endpoint / remark
/// 含 password/secret/token/api_key 子串会被 Repo 层审查拒绝。凭证走环境变量,此处只存
/// 连接信息(host/port/数据库名/连接池配置等元数据)。
#[derive(Debug, Deserialize)]
pub struct AddProjectServiceInput {
pub project_id: String,
pub name: String,
/// 基础设施类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other
pub service_type: String,
/// 连接地址(localhost:3306 / URL),可选。不含凭证(query 带 token= 会被拒)
#[serde(default)]
pub endpoint: Option<String>,
/// 类型相关配置 JSON 字符串(如 {"pool_size":10}),可选。不含密码字段
#[serde(default)]
pub config_json: Option<String>,
/// 环境 development/staging/production,默认 development
#[serde(default = "default_environment")]
pub environment: String,
#[serde(default)]
pub remark: Option<String>,
}
/// 整体更新入参(对标 Repo::update_full_validated,保留 id 与 created_at)。
/// 全字段必填(整体替换语义,与 IdeaRepo::update_full 一致)。
#[derive(Debug, Deserialize)]
pub struct UpdateProjectServiceInput {
pub id: String,
pub project_id: String,
pub name: String,
pub service_type: String,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub config_json: Option<String>,
#[serde(default = "default_environment")]
pub environment: String,
#[serde(default)]
pub remark: Option<String>,
}
/// 列表查询入参(对标设计 §五 list_project_services:按项目过滤,可选按环境过滤)。
#[derive(Debug, Deserialize)]
pub struct ListProjectServicesInput {
pub project_id: String,
/// 可选,按环境过滤(development/staging/production)。空/None = 返回所有环境。
#[serde(default)]
pub environment: Option<String>,
}
/// 列表返回(对标 events.rs TimelineResult,外层补 total 便于前端计数)。
#[derive(Debug, Serialize)]
pub struct ListProjectServicesResult {
pub items: Vec<ProjectServiceRecord>,
pub total: usize,
pub project_id: String,
}
fn default_environment() -> String {
"development".to_string()
}
// ============================================================
// IPC 命令
// ============================================================
/// 新增基础设施配置(对标设计 §五 add_project_service)。
///
/// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 AI 工具
/// add_project_service 同源)。返回完整记录。
#[tauri::command]
pub async fn add_project_service(
state: State<'_, AppState>,
input: AddProjectServiceInput,
) -> Result<ProjectServiceRecord, String> {
let project_id = input.project_id.trim().to_string();
let name = input.name.trim().to_string();
let service_type = input.service_type.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
if name.is_empty() {
return Err("name 不能为空".to_string());
}
if service_type.is_empty() {
return Err("service_type 不能为空".to_string());
}
// service_type 白名单 fail-fast(给清晰错误,虽 Repo 层也会校验)
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
return Err(format!(
"非法 service_type: {service_type},合法值: {:?}",
PROJECT_SERVICE_TYPES
));
}
let record = ProjectServiceRecord {
id: new_id(),
project_id,
name,
service_type,
endpoint: input
.endpoint
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
config_json: input
.config_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
environment: input.environment.trim().to_string(),
remark: input
.remark
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// created_at/updated_at 由 Repo 内部覆盖,此处占位
created_at: now_millis(),
updated_at: now_millis(),
};
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = state
.project_services
.insert_validated(record)
.await
.map_err(err_str)?;
state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "插入后回读失败".to_string())
}
/// 整体更新基础设施配置(保留 id 与 created_at,updated_at 由 Repo 内部覆盖)。
///
/// service_type 变更须走此整体更新路径(对标 Repo 注释:update_field 白名单不含 service_type,
/// 防旁路 update_field 改类型)。D10 凭证审查下沉 update_full_validated。
#[tauri::command]
pub async fn update_project_service(
state: State<'_, AppState>,
input: UpdateProjectServiceInput,
) -> Result<ProjectServiceRecord, String> {
let id = input.id.trim().to_string();
let project_id = input.project_id.trim().to_string();
let name = input.name.trim().to_string();
let service_type = input.service_type.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
if project_id.is_empty() || name.is_empty() || service_type.is_empty() {
return Err("project_id / name / service_type 不能为空".to_string());
}
if !PROJECT_SERVICE_TYPES.contains(&service_type.as_str()) {
return Err(format!(
"非法 service_type: {service_type},合法值: {:?}",
PROJECT_SERVICE_TYPES
));
}
// 先校验存在(404 友好错误),再整体更新
let existing = state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| format!("基础设施配置 {id} 不存在"))?;
let record = ProjectServiceRecord {
id: id.clone(),
project_id,
name,
service_type,
endpoint: input
.endpoint
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
config_json: input
.config_json
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
environment: input.environment.trim().to_string(),
remark: input
.remark
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty()),
// 保留原 created_at(update_full_validated 不覆盖 created_at)
created_at: existing.created_at,
updated_at: now_millis(),
};
let hit = state
.project_services
.update_full_validated(&record)
.await
.map_err(err_str)?;
if !hit {
return Err(format!("基础设施配置 {id} 不存在(更新未命中)"));
}
state
.project_services
.get_by_id(&id)
.await
.map_err(err_str)?
.ok_or_else(|| "更新后回读失败".to_string())
}
/// 删除基础设施配置(按 id 物理删,对标 Repo::delete)。
#[tauri::command]
pub async fn remove_project_service(
state: State<'_, AppState>,
id: String,
) -> Result<bool, String> {
let id = id.trim().to_string();
if id.is_empty() {
return Err("id 不能为空".to_string());
}
state.project_services.delete(&id).await.map_err(err_str)
}
/// 查询项目基础设施配置(对标设计 §五 list_project_services)。
///
/// 按项目过滤,可选按环境过滤(production 部署任务只取 production 服务,开发调试取 development,
/// 环境隔离避免误连生产库)。返回时间倒序列表。
#[tauri::command]
pub async fn list_project_services(
state: State<'_, AppState>,
input: ListProjectServicesInput,
) -> Result<ListProjectServicesResult, String> {
let project_id = input.project_id.trim().to_string();
if project_id.is_empty() {
return Err("project_id 不能为空".to_string());
}
let env_filter = input
.environment
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let items = if let Some(env) = &env_filter {
state
.project_services
.list_by_project_env(&project_id, env)
.await
.map_err(err_str)?
} else {
state
.project_services
.list_by_project(&project_id)
.await
.map_err(err_str)?
};
let total = items.len();
Ok(ListProjectServicesResult {
items,
total,
project_id,
})
}

View File

@@ -263,6 +263,11 @@ pub fn run() {
commands::task::get_task_tree,
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
commands::events::get_project_timeline,
// 知识图谱 Phase 3(对标设计 §五 + §2.3 + D10):项目基础设施配置 CRUD
commands::services::add_project_service,
commands::services::update_project_service,
commands::services::remove_project_service,
commands::services::list_project_services,
// 灵感
commands::idea::list_ideas,
commands::idea::create_idea,

View File

@@ -13,7 +13,7 @@ use df_ai::ai_tools::AiToolRegistry;
use df_storage::crud::{
AiConversationRepo, AiMessageRepo, AiProviderRepo, AiToolExecutionRepo, IdeaEvalRepo, IdeaRepo,
KnowledgeEventsRepo, KnowledgeRepo, NodeExecutionRepo, ProjectEventRepo, ProjectRepo,
ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
ProjectServiceRepo, ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
};
use df_storage::db::Database;
use df_workflow::eventbus::EventBus;
@@ -342,6 +342,11 @@ pub struct AppState {
/// 项目统一事件流表 Repo(知识图谱 Phase 2 V30,project_events 追加型审计表)。
/// 埋点 best-effort:事件写入失败不阻断主操作(设计 §10.1),commands 层 hook/after 追加。
pub project_events: ProjectEventRepo,
/// 项目基础设施配置表 Repo(知识图谱 Phase 3 V31,project_services)。
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文
/// (设计 §2.3 + §五 add_project_service/list_project_services)。
/// D10 安全边界:不存敏感凭证,凭证走环境变量(insert/update_validated 应用层审查拒绝)。
pub project_services: ProjectServiceRepo,
/// 发布表 Repo预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
#[allow(dead_code)]
pub releases: ReleaseRepo,
@@ -669,6 +674,7 @@ impl AppState {
tasks: TaskRepo::new(&db),
task_links: TaskLinkRepo::new(&db),
project_events: ProjectEventRepo::new(&db),
project_services: ProjectServiceRepo::new(&db),
releases: ReleaseRepo::new(&db),
workflows: WorkflowRepo::new(&db),
node_executions: NodeExecutionRepo::new(&db),