//! 项目基础设施配置相关命令(知识图谱 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, /// 类型相关配置 JSON 字符串(如 {"pool_size":10}),可选。不含密码字段 #[serde(default)] pub config_json: Option, /// 环境 development/staging/production,默认 development #[serde(default = "default_environment")] pub environment: String, #[serde(default)] pub remark: Option, } /// 整体更新入参(对标 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, #[serde(default)] pub config_json: Option, #[serde(default = "default_environment")] pub environment: String, #[serde(default)] pub remark: Option, } /// 列表查询入参(对标设计 §五 list_project_services:按项目过滤,可选按环境过滤)。 #[derive(Debug, Deserialize)] pub struct ListProjectServicesInput { pub project_id: String, /// 可选,按环境过滤(development/staging/production)。空/None = 返回所有环境。 #[serde(default)] pub environment: Option, } /// 列表返回(对标 events.rs TimelineResult,外层补 total 便于前端计数)。 #[derive(Debug, Serialize)] pub struct ListProjectServicesResult { pub items: Vec, 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 { 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 { 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 { 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 { 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, }) }