Files
DevFlow/src-tauri/src/commands/services.rs
绝尘 e33e8f86d3 新增: 知识图谱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 测试全过。
2026-06-27 00:49:09 +08:00

286 lines
10 KiB
Rust

//! 项目基础设施配置相关命令(知识图谱 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,
})
}