新增: 知识图谱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

@@ -8,6 +8,7 @@
//! - [`mod@task_repo`]:TaskRepo(含 advance_status_atomic 状态机收口)
//! - [`mod@task_link_repo`]:TaskLinkRepo(任务横向关联 task_links,V29,知识图谱 Phase 1)
//! - [`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@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
@@ -22,6 +23,7 @@ mod idea_repo;
mod message_repo;
mod project_event_repo;
mod project_repo;
mod project_service_repo;
mod settings;
mod task_link_repo;
mod task_repo;
@@ -32,6 +34,7 @@ pub use idea_repo::*;
pub use message_repo::*;
pub use project_event_repo::*;
pub use project_repo::*;
pub use project_service_repo::*;
pub use settings::*;
pub use task_link_repo::*;
pub use task_repo::*;
@@ -272,7 +275,7 @@ mod baseline_tests {
let tables = [
"ideas", "projects", "tasks", "releases", "branches", "workflow_executions",
"node_executions", "ai_providers", "ai_conversations", "ai_tool_executions",
"knowledges", "knowledge_events",
"knowledges", "knowledge_events", "project_services",
];
for t in tables {
assert!(
@@ -301,6 +304,7 @@ mod baseline_tests {
let _ = TaskRepo::new(&db);
let _ = TaskLinkRepo::new(&db);
let _ = ProjectEventRepo::new(&db);
let _ = ProjectServiceRepo::new(&db);
let _ = BranchRepo::new(&db);
let _ = ReleaseRepo::new(&db);
let _ = WorkflowRepo::new(&db);

View 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());
}
}

View File

@@ -199,6 +199,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
"knowledge_events" => &[
"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,
})
}

View File

@@ -40,7 +40,10 @@ pub fn run(conn: &Connection) -> Result<()> {
// V30 = 知识图谱 Phase 2 统一事件流(对标设计 §2.4):project_events 追加型审计表,
// 跨实体(idea/task/workflow/knowledge/module/service/project)事件流,AI 精准检索
// 的基础(回答"上周做了什么/这个任务为何 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),
(2, migrate_v2),
(3, migrate_v3),
@@ -71,6 +74,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(28, migrate_v28),
(29, migrate_v29),
(30, migrate_v30),
(31, migrate_v31),
];
for (version, migrate_fn) in steps {
@@ -845,6 +849,59 @@ fn migrate_v30(conn: &Connection) -> Result<()> {
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 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。

View File

@@ -181,6 +181,39 @@ pub struct ProjectEventRecord {
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)]
pub struct BranchRecord {