新增: 知识图谱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:
@@ -8,6 +8,7 @@
|
|||||||
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
|
||||||
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
|
||||||
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
//! - [`mod@project_event_repo`]:ProjectEventRepo(统一事件流 project_events,V30,知识图谱 Phase 2)
|
||||||
|
//! - [`mod@project_service_repo`]:ProjectServiceRepo(基础设施配置 project_services,V31,知识图谱 Phase 3)
|
||||||
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
|
||||||
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
|
||||||
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
|
||||||
@@ -22,6 +23,7 @@ mod idea_repo;
|
|||||||
mod message_repo;
|
mod message_repo;
|
||||||
mod project_event_repo;
|
mod project_event_repo;
|
||||||
mod project_repo;
|
mod project_repo;
|
||||||
|
mod project_service_repo;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod task_link_repo;
|
mod task_link_repo;
|
||||||
mod task_repo;
|
mod task_repo;
|
||||||
@@ -32,6 +34,7 @@ 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_repo::*;
|
pub use project_repo::*;
|
||||||
|
pub use project_service_repo::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
pub use task_link_repo::*;
|
pub use task_link_repo::*;
|
||||||
pub use task_repo::*;
|
pub use task_repo::*;
|
||||||
@@ -272,7 +275,7 @@ mod baseline_tests {
|
|||||||
let tables = [
|
let tables = [
|
||||||
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
|
||||||
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
|
||||||
"knowledges", "knowledge_events",
|
"knowledges", "knowledge_events", "project_services",
|
||||||
];
|
];
|
||||||
for t in tables {
|
for t in tables {
|
||||||
assert!(
|
assert!(
|
||||||
@@ -301,6 +304,7 @@ mod baseline_tests {
|
|||||||
let _ = TaskRepo::new(&db);
|
let _ = TaskRepo::new(&db);
|
||||||
let _ = TaskLinkRepo::new(&db);
|
let _ = TaskLinkRepo::new(&db);
|
||||||
let _ = ProjectEventRepo::new(&db);
|
let _ = ProjectEventRepo::new(&db);
|
||||||
|
let _ = ProjectServiceRepo::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);
|
||||||
|
|||||||
552
crates/df-storage/src/crud/project_service_repo.rs
Normal file
552
crates/df-storage/src/crud/project_service_repo.rs
Normal file
@@ -0,0 +1,552 @@
|
|||||||
|
//! 项目基础设施配置 Repo:ProjectServiceRepo(project_services 表,知识图谱 Phase 3 V31)
|
||||||
|
//!
|
||||||
|
//! 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||||
|
//! "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计
|
||||||
|
//! docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3。
|
||||||
|
//!
|
||||||
|
//! 关键设计(对标 IdeaEvalRepo 走宏 + 专用方法模式):
|
||||||
|
//! - **走 `impl_repo!` 宏**:表有 `updated_at`(基础设施配置可改,非审计不可篡改),
|
||||||
|
//! 故宏生成的 insert/get_by_id/list_all/query/update_field/update_full/delete 全可用,
|
||||||
|
//! 白名单已在 settings.rs 登记(allowed_columns_for("project_services") 返回 Some)。
|
||||||
|
//! - `service_type` 白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other)应用层校验,
|
||||||
|
//! 非进 DB 约束(对标 task_links.link_type / project_events.event_type,保留扩展性)。
|
||||||
|
//! - **敏感凭证审查(D10)**:insert/update_full 时检测 config_json / endpoint / remark 中是否
|
||||||
|
//! 含 password/secret/token/api_key 子串,命中即拒(防密码/密钥误存进表)。审查在应用层
|
||||||
|
//! 非 DB 约束(SQLite CHECK 对 JSON 内容无法表达),best-effort 兜底,非加密级安全。
|
||||||
|
//! - 专用 `list_by_project(project_id)` / `list_by_project_env(project_id, environment)`
|
||||||
|
//! 覆盖最高频查询(命中 idx_project_services_project)。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension, Row};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use df_types::error::Result;
|
||||||
|
|
||||||
|
use crate::db::Database;
|
||||||
|
use crate::models::ProjectServiceRecord;
|
||||||
|
|
||||||
|
use super::impl_repo;
|
||||||
|
use super::{now_millis_str, storage_err, validate_column_name};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// service_type 白名单 + 校验
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// `service_type` 白名单:基础设施类型枚举(对标设计 §2.3)。
|
||||||
|
///
|
||||||
|
/// 防拼写漂移 / 非法值进库。新增类型(如 elasticsearch / kafka)在此数组追加 + 设计文档
|
||||||
|
/// §2.3 同步更新,无需 DB 迁移(应用层校验,保留扩展性)。
|
||||||
|
pub const PROJECT_SERVICE_TYPES: &[&str] = &[
|
||||||
|
"mysql",
|
||||||
|
"postgresql",
|
||||||
|
"sqlite",
|
||||||
|
"redis",
|
||||||
|
"mongodb",
|
||||||
|
"mq",
|
||||||
|
"api",
|
||||||
|
"other",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 校验 `service_type` 在白名单内,否则返回 Err(对标 task_links.validate_link_type)。
|
||||||
|
pub fn validate_service_type(service_type: &str) -> Result<()> {
|
||||||
|
if PROJECT_SERVICE_TYPES.contains(&service_type) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(df_types::error::Error::Storage(format!(
|
||||||
|
"非法 service_type: {service_type},合法值: {:?}",
|
||||||
|
PROJECT_SERVICE_TYPES
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 敏感凭证审查(D10 安全边界)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 敏感字段名子串黑名单(小写匹配)。config_json / endpoint / remark 含任一即视为误存凭证。
|
||||||
|
///
|
||||||
|
/// D10 边界:本表不存密码/密钥/Token,凭证走环境变量。此审查是 best-effort 兜底——检测常见
|
||||||
|
/// 凭证字段名子串拒绝,防用户/AI 误把 `{"password": "xxx"}` 灌进 config_json。非加密级安全,
|
||||||
|
/// 非穷尽(如把密码放在非标准字段名仍可能漏检),核心防御仍依赖调用方纪律 + 环境变量约定。
|
||||||
|
const SENSITIVE_TOKENS: &[&str] = &["password", "passwd", "secret", "api_key", "apikey", "token"];
|
||||||
|
|
||||||
|
/// 审查给定文本(可空)是否含敏感字段名子串(大小写不敏感),命中即返回 Err(D10 边界)。
|
||||||
|
///
|
||||||
|
/// 对 config_json / endpoint / remark 三字段调用。endpoint 含 "token=" query 参数(如
|
||||||
|
/// `?token=abc`)或 config_json 含 `"password":` 都会被拦截。空/None 文本直接放行。
|
||||||
|
fn reject_sensitive(text: Option<&str>, field_name: &str) -> Result<()> {
|
||||||
|
let Some(t) = text else { return Ok(()) };
|
||||||
|
if t.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let lower = t.to_ascii_lowercase();
|
||||||
|
for tok in SENSITIVE_TOKENS {
|
||||||
|
if lower.contains(tok) {
|
||||||
|
return Err(df_types::error::Error::Storage(format!(
|
||||||
|
"project_services.{field_name} 含敏感凭证字段「{tok}」,违反 D10 安全边界:\
|
||||||
|
不存敏感凭证,密码/密钥走环境变量。请改用环境变量引用"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对一条 ProjectServiceRecord 做应用层前置校验(service_type 白名单 + 凭证审查)。
|
||||||
|
/// insert / update_full 共用,保证两条写入路径校验一致。
|
||||||
|
fn validate_record(rec: &ProjectServiceRecord) -> Result<()> {
|
||||||
|
validate_service_type(&rec.service_type)?;
|
||||||
|
reject_sensitive(rec.config_json.as_deref(), "config_json")?;
|
||||||
|
reject_sensitive(rec.endpoint.as_deref(), "endpoint")?;
|
||||||
|
reject_sensitive(rec.remark.as_deref(), "remark")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// from_row 辅助函数
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
fn project_service_from_row(row: &Row<'_>) -> std::result::Result<ProjectServiceRecord, rusqlite::Error> {
|
||||||
|
Ok(ProjectServiceRecord {
|
||||||
|
id: row.get("id")?,
|
||||||
|
project_id: row.get("project_id")?,
|
||||||
|
name: row.get("name")?,
|
||||||
|
service_type: row.get("service_type")?,
|
||||||
|
endpoint: row.get("endpoint")?,
|
||||||
|
config_json: row.get("config_json")?,
|
||||||
|
environment: row.get("environment")?,
|
||||||
|
remark: row.get("remark")?,
|
||||||
|
created_at: row.get("created_at")?,
|
||||||
|
updated_at: row.get("updated_at")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Repo 实现
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
impl_repo!(
|
||||||
|
/// 项目基础设施配置表 CRUD(project_services,V31,可改非审计)。
|
||||||
|
///
|
||||||
|
/// 走 `impl_repo!` 宏:表有 `updated_at`,标准 CRUD 全可用,白名单在 settings.rs 登记。
|
||||||
|
/// 注:宏生成的 `update_field` 经 settings 白名单(不含 service_type)收口——service_type
|
||||||
|
/// 变更须走 `update_full`(过应用层校验 + 凭证审查),不可旁路 update_field 改类型。
|
||||||
|
ProjectServiceRepo,
|
||||||
|
ProjectServiceRecord,
|
||||||
|
"project_services",
|
||||||
|
from_row => |row| project_service_from_row(row),
|
||||||
|
insert => |conn, rec| {
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
rec.id, rec.project_id, rec.name, rec.service_type,
|
||||||
|
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||||
|
rec.created_at, rec.updated_at
|
||||||
|
],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
update => |conn, rec| {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE project_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||||
|
params![
|
||||||
|
rec.project_id, rec.name, rec.service_type,
|
||||||
|
rec.endpoint, rec.config_json, rec.environment, rec.remark,
|
||||||
|
rec.updated_at, rec.id
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
impl ProjectServiceRepo {
|
||||||
|
/// 插入一条基础设施配置(应用层校验 service_type 白名单 + 凭证审查 D10 后再落库)。
|
||||||
|
///
|
||||||
|
/// 校验 fail-fast 在 spawn_blocking 前(非法值不进 DB 层),与 TaskLinkRepo::create_link
|
||||||
|
/// 同款。`created_at` / `updated_at` 由本方法内部取当前毫秒时间戳覆盖(调用方传入的不可信),
|
||||||
|
/// 保证「写入时机即落库时机」(对标 ProjectEventRepo::insert)。
|
||||||
|
pub async fn insert_validated(&self, record: ProjectServiceRecord) -> Result<String> {
|
||||||
|
// 应用层校验先行(fail-fast,非法值/凭证不进 DB 层)
|
||||||
|
validate_record(&record)?;
|
||||||
|
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 id = rec.id.clone();
|
||||||
|
let conn = &*guard;
|
||||||
|
let r = &rec;
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO project_services (id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||||
|
params![
|
||||||
|
r.id, r.project_id, r.name, r.service_type,
|
||||||
|
r.endpoint, r.config_json, r.environment, r.remark,
|
||||||
|
r.created_at, r.updated_at
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(id)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 整体更新(应用层校验 service_type + 凭证审查 D10 后再落库,保留 id 与 created_at)。
|
||||||
|
///
|
||||||
|
/// 对标宏生成的 `update_full`,但在落库前过 `validate_record`(宏无业务前置逻辑)。
|
||||||
|
/// `updated_at` 由本方法内部取当前毫秒时间戳覆盖。
|
||||||
|
pub async fn update_full_validated(&self, record: &ProjectServiceRecord) -> Result<bool> {
|
||||||
|
validate_record(record)?;
|
||||||
|
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_services SET project_id = ?1, name = ?2, service_type = ?3, endpoint = ?4, config_json = ?5, environment = ?6, remark = ?7, updated_at = ?8 WHERE id = ?9",
|
||||||
|
params![
|
||||||
|
r.project_id, r.name, r.service_type,
|
||||||
|
r.endpoint, r.config_json, r.environment, r.remark,
|
||||||
|
r.updated_at, r.id
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按项目查询全部基础设施配置(命中 idx_project_services_project,created_at DESC)。
|
||||||
|
///
|
||||||
|
/// AI 项目上下文注入 / Dashboard 项目基础设施视图用:取某项目的全部服务配置。
|
||||||
|
pub async fn list_by_project(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
) -> Result<Vec<ProjectServiceRecord>> {
|
||||||
|
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, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||||
|
FROM project_services WHERE project_id = ?1 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![project_id], project_service_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)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按项目 + 环境查询基础设施配置(命中 idx_project_services_project,environment 等值过滤)。
|
||||||
|
///
|
||||||
|
/// AI 执行任务时按当前环境取基础设施:production 部署任务只取 production 服务,
|
||||||
|
/// 开发调试取 development 服务。环境隔离避免误连生产库。
|
||||||
|
pub async fn list_by_project_env(
|
||||||
|
&self,
|
||||||
|
project_id: &str,
|
||||||
|
environment: &str,
|
||||||
|
) -> Result<Vec<ProjectServiceRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let project_id = project_id.to_owned();
|
||||||
|
let environment = environment.to_owned();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, project_id, name, service_type, endpoint, config_json, environment, remark, created_at, updated_at \
|
||||||
|
FROM project_services WHERE project_id = ?1 AND environment = ?2 ORDER BY created_at DESC",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(params![project_id, environment], project_service_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)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 单元测试 — ProjectServiceRepo CRUD + 校验 + 环境过滤(内存 DB,
|
||||||
|
// 对标 project_event_repo 测试:先建占位 project 满足 FK,再插服务)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::crud::ProjectRepo;
|
||||||
|
use crate::models::ProjectRecord;
|
||||||
|
|
||||||
|
/// 构造内存 DB + 占位 project(project_services.project_id FK 要求 projects 存在)。
|
||||||
|
async fn setup() -> (crate::db::Database, ProjectServiceRepo) {
|
||||||
|
let db = crate::db::Database::open_in_memory()
|
||||||
|
.await
|
||||||
|
.expect("open_in_memory");
|
||||||
|
let repo = ProjectServiceRepo::new(&db);
|
||||||
|
let project_repo = ProjectRepo::new(&db);
|
||||||
|
project_repo
|
||||||
|
.insert(ProjectRecord {
|
||||||
|
id: "proj-1".to_string(),
|
||||||
|
name: "proj-1".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
status: "active".to_string(),
|
||||||
|
idea_id: None,
|
||||||
|
path: None,
|
||||||
|
stack: None,
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1700000000000".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
(db, repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造一条 ProjectServiceRecord fixture(全字段,环境可指定)。
|
||||||
|
fn srec(id: &str, service_type: &str, environment: &str) -> ProjectServiceRecord {
|
||||||
|
ProjectServiceRecord {
|
||||||
|
id: id.to_string(),
|
||||||
|
project_id: "proj-1".to_string(),
|
||||||
|
name: format!("svc-{id}"),
|
||||||
|
service_type: service_type.to_string(),
|
||||||
|
endpoint: Some("localhost:3306".to_string()),
|
||||||
|
config_json: Some(r#"{"pool_size":10}"#.to_string()),
|
||||||
|
environment: environment.to_string(),
|
||||||
|
remark: Some("主库".to_string()),
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1700000000000".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn validate_service_type_rejects_unknown() {
|
||||||
|
// 白名单内全部通过
|
||||||
|
assert!(validate_service_type("mysql").is_ok());
|
||||||
|
assert!(validate_service_type("postgresql").is_ok());
|
||||||
|
assert!(validate_service_type("sqlite").is_ok());
|
||||||
|
assert!(validate_service_type("redis").is_ok());
|
||||||
|
assert!(validate_service_type("mongodb").is_ok());
|
||||||
|
assert!(validate_service_type("mq").is_ok());
|
||||||
|
assert!(validate_service_type("api").is_ok());
|
||||||
|
assert!(validate_service_type("other").is_ok());
|
||||||
|
// 非法值拒绝
|
||||||
|
assert!(validate_service_type("elasticsearch").is_err());
|
||||||
|
assert!(validate_service_type("").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_validated_and_read_back_full_fields() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let id = repo
|
||||||
|
.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(id, "s1");
|
||||||
|
|
||||||
|
let got = repo.get_by_id("s1").await.unwrap().expect("应存在");
|
||||||
|
// 全字段回读校验
|
||||||
|
assert_eq!(got.id, "s1");
|
||||||
|
assert_eq!(got.project_id, "proj-1");
|
||||||
|
assert_eq!(got.name, "svc-s1");
|
||||||
|
assert_eq!(got.service_type, "mysql");
|
||||||
|
assert_eq!(got.endpoint.as_deref(), Some("localhost:3306"));
|
||||||
|
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||||
|
assert_eq!(got.environment, "development");
|
||||||
|
assert_eq!(got.remark.as_deref(), Some("主库"));
|
||||||
|
// created_at / updated_at 由存储层覆盖为当前毫秒
|
||||||
|
assert!(!got.created_at.is_empty());
|
||||||
|
assert!(!got.updated_at.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_rejects_invalid_service_type() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let err = repo
|
||||||
|
.insert_validated(srec("s1", "elasticsearch", "development"))
|
||||||
|
.await;
|
||||||
|
assert!(err.is_err(), "非法 service_type 应被拒绝");
|
||||||
|
// 未入库
|
||||||
|
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_rejects_sensitive_in_config_json() {
|
||||||
|
// D10 边界:config_json 含 password 应被拒绝
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let mut rec = srec("s1", "mysql", "development");
|
||||||
|
rec.config_json = Some(r#"{"password":"secret123"}"#.to_string());
|
||||||
|
let err = repo.insert_validated(rec).await;
|
||||||
|
assert!(err.is_err(), "config_json 含 password 应被 D10 拒绝");
|
||||||
|
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_rejects_sensitive_in_endpoint() {
|
||||||
|
// endpoint query 参数带 token= 应被拒绝
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let mut rec = srec("s1", "api", "development");
|
||||||
|
rec.endpoint = Some("https://api.example.com?token=abc123".to_string());
|
||||||
|
let err = repo.insert_validated(rec).await;
|
||||||
|
assert!(err.is_err(), "endpoint 含 token 应被 D10 拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_rejects_sensitive_in_remark() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let mut rec = srec("s1", "redis", "development");
|
||||||
|
rec.remark = Some("API_KEY=sk-xxxx".to_string());
|
||||||
|
let err = repo.insert_validated(rec).await;
|
||||||
|
assert!(err.is_err(), "remark 含 api_key 应被 D10 拒绝");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_full_validated_rejects_sensitive() {
|
||||||
|
// 先插一条合法的,update 时灌入凭证应被拒
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut bad = srec("s1", "mysql", "development");
|
||||||
|
bad.config_json = Some(r#"{"password":"new"}"#.to_string());
|
||||||
|
let err = repo.update_full_validated(&bad).await;
|
||||||
|
assert!(err.is_err(), "update 灌凭证应被 D10 拒绝");
|
||||||
|
|
||||||
|
// 原记录未变(config_json 仍是 pool_size)
|
||||||
|
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.config_json.as_deref(), Some(r#"{"pool_size":10}"#));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_full_validated_changes_fields() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut updated = srec("s1", "redis", "production");
|
||||||
|
updated.name = "缓存".to_string();
|
||||||
|
let hit = repo.update_full_validated(&updated).await.unwrap();
|
||||||
|
assert!(hit, "应命中更新");
|
||||||
|
|
||||||
|
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||||
|
assert_eq!(got.name, "缓存");
|
||||||
|
assert_eq!(got.service_type, "redis");
|
||||||
|
assert_eq!(got.environment, "production");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_by_project_returns_all() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert_validated(srec("s2", "redis", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert_validated(srec("s3", "mq", "production"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 按项目查返回全部 3 条(含所有环境)
|
||||||
|
let got = repo.list_by_project("proj-1").await.unwrap();
|
||||||
|
assert_eq!(got.len(), 3);
|
||||||
|
|
||||||
|
// 不存在的项目返回空
|
||||||
|
let empty = repo.list_by_project("no-such").await.unwrap();
|
||||||
|
assert!(empty.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_by_project_env_filters_environment() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert_validated(srec("s2", "redis", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert_validated(srec("s3", "mysql", "production"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 只取 development:2 条(s1/s2)
|
||||||
|
let dev = repo
|
||||||
|
.list_by_project_env("proj-1", "development")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(dev.len(), 2);
|
||||||
|
for s in &dev {
|
||||||
|
assert_eq!(s.environment, "development");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只取 production:1 条(s3)
|
||||||
|
let prod = repo
|
||||||
|
.list_by_project_env("proj-1", "production")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(prod.len(), 1);
|
||||||
|
assert_eq!(prod[0].id, "s3");
|
||||||
|
|
||||||
|
// 不存在的环境返回空
|
||||||
|
let staging = repo
|
||||||
|
.list_by_project_env("proj-1", "staging")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(staging.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_removes_record() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
repo.insert_validated(srec("s1", "mysql", "development"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(repo.delete("s1").await.unwrap());
|
||||||
|
assert!(repo.get_by_id("s1").await.unwrap().is_none());
|
||||||
|
// 再删返回 false
|
||||||
|
assert!(!repo.delete("s1").await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// None 字段(config_json/endpoint/remark)也能正常插入与查询。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_with_null_optional_fields() {
|
||||||
|
let (_db, repo) = setup().await;
|
||||||
|
let rec = ProjectServiceRecord {
|
||||||
|
id: "s1".to_string(),
|
||||||
|
project_id: "proj-1".to_string(),
|
||||||
|
name: "bare-svc".to_string(),
|
||||||
|
service_type: "api".to_string(),
|
||||||
|
endpoint: None,
|
||||||
|
config_json: None,
|
||||||
|
environment: "development".to_string(),
|
||||||
|
remark: None,
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1700000000000".to_string(),
|
||||||
|
};
|
||||||
|
repo.insert_validated(rec).await.unwrap();
|
||||||
|
|
||||||
|
let got = repo.get_by_id("s1").await.unwrap().unwrap();
|
||||||
|
assert!(got.endpoint.is_none());
|
||||||
|
assert!(got.config_json.is_none());
|
||||||
|
assert!(got.remark.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -199,6 +199,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
|
|||||||
"knowledge_events" => &[
|
"knowledge_events" => &[
|
||||||
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
|
"id", "knowledge_id", "event_type", "source_ref", "context_json", "timestamp",
|
||||||
],
|
],
|
||||||
|
// 知识图谱 Phase 3 V31(对标设计 §2.3):project_services 表有 updated_at,走 impl_repo! 宏
|
||||||
|
// 标准 CRUD(insert/get_by_id/list_all/query/update_field/update_full/delete),故登记白名单。
|
||||||
|
// id/created_at 不列入:主键与创建时间不可经通用 update_field 改写(对标 tasks/projects 防护)。
|
||||||
|
// service_type 不列入白名单(类比 status 收口思路):service_type 是类型枚举,变更应走整行
|
||||||
|
// update_full(过 service_type 应用层校验 + 凭证审查),而非 update_field 旁路绕过校验。
|
||||||
|
"project_services" => &[
|
||||||
|
"project_id", "name", "endpoint", "config_json", "environment",
|
||||||
|
"remark", "updated_at",
|
||||||
|
],
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
|
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
|
||||||
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
|
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
|
||||||
// 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。
|
// 的基础(回答"上周做了什么/这个任务为何 blocked/决策何时做出")。
|
||||||
let steps: [(i32, fn(&Connection) -> Result<()>); 30] = [
|
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
||||||
|
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
||||||
|
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||||
|
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
|
||||||
(1, migrate_v1),
|
(1, migrate_v1),
|
||||||
(2, migrate_v2),
|
(2, migrate_v2),
|
||||||
(3, migrate_v3),
|
(3, migrate_v3),
|
||||||
@@ -71,6 +74,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
|||||||
(28, migrate_v28),
|
(28, migrate_v28),
|
||||||
(29, migrate_v29),
|
(29, migrate_v29),
|
||||||
(30, migrate_v30),
|
(30, migrate_v30),
|
||||||
|
(31, migrate_v31),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (version, migrate_fn) in steps {
|
for (version, migrate_fn) in steps {
|
||||||
@@ -845,6 +849,59 @@ fn migrate_v30(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// V31:知识图谱 Phase 3 基础设施数据层 — project_services 表
|
||||||
|
///
|
||||||
|
/// 对标 docs/02-架构设计/专项设计/项目知识图谱与任务队列系统-2026-06-26.md §2.3,
|
||||||
|
/// 为「AI 执行任务时拥有项目基础设施上下文」打数据层地基。AI 知道"这项目用了什么数据库、
|
||||||
|
/// Redis 在哪、有没有 MQ、API 在哪个地址",从而在编码/部署/排障时不必反复问人。
|
||||||
|
///
|
||||||
|
/// **定位**:项目基础设施配置的元数据层(对标设计 §2.3)。
|
||||||
|
/// - `service_type`:基础设施类型(mysql/postgresql/sqlite/redis/mongodb/mq/api/other),
|
||||||
|
/// **应用层校验**(类比 task_links.link_type / project_events.event_type,不进 DB 约束,
|
||||||
|
/// 保留扩展性,新增类型无需迁移)。
|
||||||
|
/// - `endpoint`:连接地址(localhost:3306 / URL),纯连接信息。
|
||||||
|
/// - `config_json`:类型相关配置 JSON 字符串(如数据库连接池参数、MQ topic 列表)。
|
||||||
|
/// - `environment`:环境标识(development/staging/production),DEFAULT 'development'。
|
||||||
|
/// 同一服务可在不同环境各存一行(项目维度 + 环境维度组合定位)。
|
||||||
|
///
|
||||||
|
/// **边界(D10,对标设计 §2.3)**:⚠️ **不存敏感凭证**(密码/密钥/Token)。本表只存连接信息,
|
||||||
|
/// 凭证走环境变量/外部密钥管理。config_json 不应含 password/secret/key 字段——此约束由
|
||||||
|
/// 应用层(ProjectServiceRepo)在 insert/update_full 时做内容审查(检测 password/secret/key/
|
||||||
|
/// token 子串拒绝),DB 层无 CHECK 约束(SQLite CHECK 对 JSON 内容无法表达)。
|
||||||
|
///
|
||||||
|
/// **元数据非运维工具**:不做连接池/健康检查/探活,仅记录"项目用了什么基础设施"这一事实,
|
||||||
|
/// 供 AI 与 Dashboard 检索消费。运维能力是后续独立模块的职责。
|
||||||
|
///
|
||||||
|
/// **CREATE TABLE IF NOT EXISTS 幂等**:新库建表、老库(V31 之前的库)已有则跳过,均安全。
|
||||||
|
/// 索引 `idx_project_services_project(project_id)` 覆盖最高频查询:按项目列其全部基础设施
|
||||||
|
/// (Dashboard 项目视图 / AI 项目上下文注入)。
|
||||||
|
fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS project_services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
project_id TEXT NOT NULL REFERENCES projects(id),
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
service_type TEXT NOT NULL,
|
||||||
|
endpoint TEXT,
|
||||||
|
config_json TEXT,
|
||||||
|
environment TEXT NOT NULL DEFAULT 'development',
|
||||||
|
remark TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_project_services_project \
|
||||||
|
ON project_services(project_id)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
tracing::info!("v31: 建 project_services 表 + 索引(基础设施数据层,知识图谱 Phase 3)");
|
||||||
|
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [31])?;
|
||||||
|
tracing::info!("迁移 v31 完成");
|
||||||
|
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)。
|
||||||
|
|||||||
@@ -181,6 +181,39 @@ pub struct ProjectEventRecord {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 项目基础设施配置记录(project_services 表,知识图谱 Phase 3 V31)。
|
||||||
|
///
|
||||||
|
/// 记录项目依赖的基础设施(数据库/缓存/MQ/API 等),为 AI 执行任务时提供基础设施上下文:
|
||||||
|
/// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。对标设计 docs/02-架构设计/专项设计/
|
||||||
|
/// 项目知识图谱与任务队列系统-2026-06-26.md §2.3。
|
||||||
|
///
|
||||||
|
/// - `project_id`:所属项目(FK projects.id)。
|
||||||
|
/// - `name`:服务名(如"主数据库"/"缓存"/"消息队列",人/AI 可读标识)。
|
||||||
|
/// - `service_type`:基础设施类型白名单(mysql/postgresql/sqlite/redis/mongodb/mq/api/other,
|
||||||
|
/// 应用层校验,不进 DB 约束)。
|
||||||
|
/// - `endpoint`:连接地址(localhost:3306 / URL),可空(部分服务无固定 endpoint,如 serverless API)。
|
||||||
|
/// - `config_json`:类型相关配置 JSON 字符串(如连接池参数/MQ topic 列表),可空。
|
||||||
|
/// - `environment`:环境标识(development/staging/production),同一服务可在不同环境各存一行。
|
||||||
|
/// - `remark`:备注说明,可空。
|
||||||
|
///
|
||||||
|
/// ⚠️ **不存敏感凭证(D10)**:本记录仅存连接信息,密码/密钥/Token 走环境变量/外部密钥管理,
|
||||||
|
/// 不进 config_json。内容审查在 ProjectServiceRepo insert/update_full 时检测敏感字段拒绝。
|
||||||
|
///
|
||||||
|
/// **元数据非运维工具**:不做连接池/健康检查,仅记录"项目用了什么基础设施"这一事实。
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProjectServiceRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub project_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub service_type: String,
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub config_json: Option<String>,
|
||||||
|
pub environment: String,
|
||||||
|
pub remark: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// 分支记录
|
/// 分支记录
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BranchRecord {
|
pub struct BranchRecord {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ graph TD
|
|||||||
P3["父③ AI对话体验<br/>📋待办"]:::todo
|
P3["父③ AI对话体验<br/>📋待办"]:::todo
|
||||||
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
|
P4["父④ F-09 per-conv<br/>✅前端per-conv(accessor)"]:::done
|
||||||
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
|
P5["父⑤ 灵感模块<br/>✅完成(⑤.1/①.4/⑤.2)"]:::done
|
||||||
P6["父⑥ Phase2-5<br/>🔨Phase2✅·3-5待办"]:::doing
|
P6["父⑥ Phase2-5<br/>🔨Phase2-3✅·4-5待办"]:::doing
|
||||||
P7["父⑦ 技术债<br/>📋待办"]:::todo
|
P7["父⑦ 技术债<br/>📋待办"]:::todo
|
||||||
|
|
||||||
P1 -.先行.-> P2
|
P1 -.先行.-> P2
|
||||||
@@ -59,7 +59,7 @@ graph TD
|
|||||||
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
|
| **父③** AI对话体验 | 📋 待办 | ③.1 B-260619-04 ToolCard / ③.2 REFACTOR-260619-04 审批状态机 | — |
|
||||||
| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
|
| **父④** F-09 per-conv | ✅ 前端per-conv | ④.1 streaming/currentText per-conv Map(accessor委派,单会话回归零变化,BUG-260624-01根因清除,vue-tsc 0) | — |
|
||||||
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
|
| **父⑤** 灵感模块 | ✅ 完成 | ⑤.1 软删除✅ / ①.4 雷达图✅ / ⑤.2 #05✅/#06拆const✅/#09/#10表单(逗号tags)✅ / #07 DEC-02保留purge(不改) / 附:priority_from_i32跨层映射修复(对齐前端0=critical) | #07→②.1 |
|
||||||
| **父⑥** Phase2-5 | 🔨 Phase2✅ | ⑥.1事件流✅(V30 project_events+埋点9处+timeline IPC+AI工具) / ⑥.2基础设施待办 / ⑥.3注入(依赖父②+父④) / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 |
|
| **父⑥** Phase2-5 | 🔨 Phase2-3✅ | ⑥.1事件流✅(V30) / ⑥.2基础设施✅(V31 project_services+D10凭证审查+IPC+AI工具,基线41) / ⑥.3注入(依赖全就绪) / ⑥.4前端 | ⑥→父②, ⑥.3→父②+父④+G1 |
|
||||||
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
|
| **父⑦** 技术债 | 📋 待办 | SMELL-P1-6 / conditions / CR缓存 / UX分页 / 审批超时 / miniapp / 双监听器 | — (穿插) |
|
||||||
|
|
||||||
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
|
> **推进路径**:父①先行(攒批提交)→ 父②主线 workflow → 父③/④并行 → 父⑤/⑥/⑦穿插。每父任务一个 workflow 批,子任务相关文件批量读改减少交互。**状态更新约定**:子项完成→父状态 🔨;全子完成→父 ✅;每批提交后同步此表。
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock};
|
|||||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
use df_execute::shell::{execute, ShellRequest};
|
use df_execute::shell::{execute, ShellRequest};
|
||||||
use df_storage::db::Database;
|
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;
|
use df_types::types::new_id;
|
||||||
|
|
||||||
@@ -500,6 +500,8 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
|
|||||||
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
|
/// + 父子树 + 跨池移动 + content 更新),data 层 18→24。
|
||||||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
|
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流查询),
|
||||||
/// data 层 24→25。
|
/// 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 行单函数(数据+文件混合)。
|
/// SMELL-P0-2:抽自原 build_ai_tool_registry 1091 行单函数(数据+文件混合)。
|
||||||
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
|
/// 18 个 register 调用【原样移入】,零行为变更,仅机械搬运。
|
||||||
@@ -514,6 +516,8 @@ fn register_http_tools(registry: &mut AiToolRegistry) {
|
|||||||
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
|
/// - register_task_graph_tools(6):task_link CRUD + 父子树 + 跨池移动 + content 更新
|
||||||
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
|
/// 知识图谱 Phase 2(2026-06-26):register_task_graph_tools 增 get_project_timeline(项目事件流,只读),
|
||||||
/// 该子函数 6→7 工具,data 层 24→25。
|
/// 该子函数 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(注册顺序无关),拆分后工具集合与原一致 + 新增,
|
/// AiToolRegistry 底层 HashMap(注册顺序无关),拆分后工具集合与原一致 + 新增,
|
||||||
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
|
/// 行为零变更(create_task 扩展参数 + 7 新工具,基线测试 test_build_ai_tool_registry_baseline_tool_count 守护)。
|
||||||
fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
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 续拆),【原样移入】,零行为变更。
|
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||||
@@ -2830,7 +2964,7 @@ mod tests {
|
|||||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
// 任一层漏移 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,
|
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||||
@@ -2843,7 +2977,7 @@ mod tests {
|
|||||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||||
let registry = build_ai_tool_registry(&db, &allowed_dirs);
|
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 跨文件内容搜索工具)。
|
// F-260621: file 层 10→11(新增 grep 跨文件内容搜索工具)。
|
||||||
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
// L1 环境感知(设计 §2.1): file 层 11→12(新增 detect_environment 环境探测工具)。
|
||||||
// AST 代码智能(7c2e3b2): file 层 12→13(新增 read_symbol 符号解析,治 read_file 全文回灌 prompt 爆)。
|
// 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)。
|
// 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 项目事件流查询,
|
// 知识图谱 Phase 2(2026-06-26): data 层 24→25(新增 get_project_timeline 项目事件流查询,
|
||||||
// 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
|
||||||
|
// 项目基础设施配置 D10 不存凭证,register_task_graph_tools 7→9)。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.len(),
|
registry.len(),
|
||||||
39,
|
41,
|
||||||
"工具总数应为 39(25 data + 13 file + 1 http),实际 {}", registry.len()
|
"工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||||
// data 层 25 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流
|
// data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置
|
||||||
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
||||||
// http 层 1 个(不持 db):http_request
|
// http 层 1 个(不持 db):http_request
|
||||||
let mut expected: Vec<&str> = vec![
|
let mut expected: Vec<&str> = vec![
|
||||||
// ── data 层 (25) ──
|
// ── data 层 (27) ──
|
||||||
"list_projects", "list_tasks", "list_ideas",
|
"list_projects", "list_tasks", "list_ideas",
|
||||||
"update_project", "create_project", "bind_directory",
|
"update_project", "create_project", "bind_directory",
|
||||||
"create_task", "update_task", "advance_task",
|
"create_task", "update_task", "advance_task",
|
||||||
@@ -2874,6 +3010,8 @@ mod tests {
|
|||||||
"get_task_tree", "move_task_queue", "update_content",
|
"get_task_tree", "move_task_queue", "update_content",
|
||||||
// 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
|
// 知识图谱 Phase 2 项目事件流(register_task_graph_tools 7 个,本工具为第 7)
|
||||||
"get_project_timeline",
|
"get_project_timeline",
|
||||||
|
// 知识图谱 Phase 3 项目基础设施配置(register_task_graph_tools 9 个,9/10 为这 2 工具)
|
||||||
|
"add_project_service", "list_project_services",
|
||||||
// ── 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 代码智能)
|
||||||
@@ -3565,4 +3703,132 @@ mod tests {
|
|||||||
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
|
// 空字符串 pattern 不合法(handler 内 bail,此处锁定 regex 接受空但 handler 显式拒)
|
||||||
assert!(regex::Regex::new("").is_ok(), "regex 接受空串,handler 层显式 bail 空 pattern");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||||
|
|
||||||
pub mod ai;
|
pub mod ai;
|
||||||
|
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 project;
|
pub mod project;
|
||||||
|
pub mod services;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod workflow;
|
pub mod workflow;
|
||||||
|
|||||||
285
src-tauri/src/commands/services.rs
Normal file
285
src-tauri/src/commands/services.rs
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -263,6 +263,11 @@ pub fn run() {
|
|||||||
commands::task::get_task_tree,
|
commands::task::get_task_tree,
|
||||||
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
|
// 知识图谱 Phase 2(对标设计 §五 + §2.4):项目事件流查询
|
||||||
commands::events::get_project_timeline,
|
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::list_ideas,
|
||||||
commands::idea::create_idea,
|
commands::idea::create_idea,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ 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, ProjectRepo,
|
||||||
ReleaseRepo, SettingsRepo, TaskLinkRepo, TaskRepo, WorkflowRepo,
|
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;
|
||||||
@@ -342,6 +342,11 @@ pub struct AppState {
|
|||||||
/// 项目统一事件流表 Repo(知识图谱 Phase 2 V30,project_events 追加型审计表)。
|
/// 项目统一事件流表 Repo(知识图谱 Phase 2 V30,project_events 追加型审计表)。
|
||||||
/// 埋点 best-effort:事件写入失败不阻断主操作(设计 §10.1),commands 层 hook/after 追加。
|
/// 埋点 best-effort:事件写入失败不阻断主操作(设计 §10.1),commands 层 hook/after 追加。
|
||||||
pub project_events: ProjectEventRepo,
|
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 保留)
|
/// 发布表 Repo(预留:ReleaseRepo 持久化已就位·IPC/逻辑未接入·SW-260618-21 b 保留)
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub releases: ReleaseRepo,
|
pub releases: ReleaseRepo,
|
||||||
@@ -669,6 +674,7 @@ impl AppState {
|
|||||||
tasks: TaskRepo::new(&db),
|
tasks: TaskRepo::new(&db),
|
||||||
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),
|
||||||
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