//! 配置迁移业务逻辑 use sqlx::PgPool; use crate::error::{SaasError, SaasResult}; use crate::common::{PaginatedResponse, normalize_pagination}; use crate::models::{ConfigItemRow, ConfigSyncLogRow}; use super::types::*; use serde::Serialize; // ============ Config Items ============ /// Fetch all config items matching the query (internal use, no pagination). pub(crate) async fn fetch_all_config_items( db: &PgPool, query: &ConfigQuery, ) -> SaasResult> { let sql = match (&query.category, &query.source) { (Some(_), Some(_)) => { "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 AND source = $2 ORDER BY category, key_path" } (Some(_), None) => { "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 ORDER BY key_path" } (None, Some(_)) => { "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE source = $1 ORDER BY category, key_path" } (None, None) => { "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items ORDER BY category, key_path" } }; let mut query_builder = sqlx::query_as::<_, ConfigItemRow>(sql); if let Some(cat) = &query.category { query_builder = query_builder.bind(cat); } if let Some(src) = &query.source { query_builder = query_builder.bind(src); } let rows = query_builder.fetch_all(db).await?; Ok(rows.into_iter().map(|r| { ConfigItemInfo { id: r.id, category: r.category, key_path: r.key_path, value_type: r.value_type, current_value: r.current_value, default_value: r.default_value, source: r.source, description: r.description, requires_restart: r.requires_restart, created_at: r.created_at, updated_at: r.updated_at } }).collect()) } /// Paginated list of config items (HTTP handler entry point). pub async fn list_config_items( db: &PgPool, query: &ConfigQuery, page: Option, page_size: Option, ) -> SaasResult> { let (p, ps, offset) = normalize_pagination(page, page_size); // Static SQL per combination -- no format!() string interpolation let (total, rows) = match (&query.category, &query.source) { (Some(cat), Some(src)) => { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM config_items WHERE category = $1 AND source = $2" ).bind(cat).bind(src).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 AND source = $2 ORDER BY category, key_path LIMIT $3 OFFSET $4" ).bind(cat).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) } (Some(cat), None) => { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM config_items WHERE category = $1" ).bind(cat).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE category = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3" ).bind(cat).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) } (None, Some(src)) => { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM config_items WHERE source = $1" ).bind(src).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE source = $1 ORDER BY category, key_path LIMIT $2 OFFSET $3" ).bind(src).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) } (None, None) => { let total: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM config_items" ).fetch_one(db).await?; let rows = sqlx::query_as::<_, ConfigItemRow>( "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items ORDER BY category, key_path LIMIT $1 OFFSET $2" ).bind(ps as i64).bind(offset).fetch_all(db).await?; (total, rows) } }; let items = rows.into_iter().map(|r| { ConfigItemInfo { id: r.id, category: r.category, key_path: r.key_path, value_type: r.value_type, current_value: r.current_value, default_value: r.default_value, source: r.source, description: r.description, requires_restart: r.requires_restart, created_at: r.created_at, updated_at: r.updated_at } }).collect(); Ok(PaginatedResponse { items, total, page: p, page_size: ps }) } pub async fn get_config_item(db: &PgPool, item_id: &str) -> SaasResult { let row: Option = sqlx::query_as( "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at::TEXT, updated_at::TEXT FROM config_items WHERE id = $1" ) .bind(item_id) .fetch_optional(db) .await?; let r = row.ok_or_else(|| SaasError::NotFound(format!("配置项 {} 不存在", item_id)))?; Ok(ConfigItemInfo { id: r.id, category: r.category, key_path: r.key_path, value_type: r.value_type, current_value: r.current_value, default_value: r.default_value, source: r.source, description: r.description, requires_restart: r.requires_restart, created_at: r.created_at, updated_at: r.updated_at }) } pub async fn create_config_item( db: &PgPool, req: &CreateConfigItemRequest, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now(); let source = req.source.as_deref().unwrap_or("local"); let requires_restart = req.requires_restart.unwrap_or(false); // 检查唯一性 let existing: Option<(String,)> = sqlx::query_as( "SELECT id FROM config_items WHERE category = $1 AND key_path = $2" ) .bind(&req.category).bind(&req.key_path) .fetch_optional(db).await?; if existing.is_some() { return Err(SaasError::AlreadyExists(format!( "配置项 {}:{} 已存在", req.category, req.key_path ))); } sqlx::query( "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)" ) .bind(&id).bind(&req.category).bind(&req.key_path).bind(&req.value_type) .bind(&req.current_value).bind(&req.default_value).bind(source) .bind(&req.description).bind(requires_restart).bind(&now) .execute(db).await?; get_config_item(db, &id).await } pub async fn update_config_item( db: &PgPool, item_id: &str, req: &UpdateConfigItemRequest, ) -> SaasResult { let now = chrono::Utc::now(); // COALESCE pattern: all updatable fields in a single static SQL. // NULL parameters leave the column unchanged. sqlx::query( "UPDATE config_items SET current_value = COALESCE($1, current_value), source = COALESCE($2, source), description = COALESCE($3, description), updated_at = $4 WHERE id = $5" ) .bind(req.current_value.as_deref()) .bind(req.source.as_deref()) .bind(req.description.as_deref()) .bind(&now) .bind(item_id) .execute(db).await?; get_config_item(db, item_id).await } pub async fn delete_config_item(db: &PgPool, item_id: &str) -> SaasResult<()> { let result = sqlx::query("DELETE FROM config_items WHERE id = $1") .bind(item_id).execute(db).await?; if result.rows_affected() == 0 { return Err(SaasError::NotFound(format!("配置项 {} 不存在", item_id))); } Ok(()) } // ============ Config Analysis ============ pub async fn analyze_config(db: &PgPool) -> SaasResult { let items = fetch_all_config_items(db, &ConfigQuery { category: None, source: None, page: None, page_size: None }).await?; let mut categories: std::collections::HashMap = std::collections::HashMap::new(); for item in &items { let entry = categories.entry(item.category.clone()).or_insert((0, 0)); entry.0 += 1; if item.source == "saas" { entry.1 += 1; } } let category_summaries: Vec = categories.into_iter() .map(|(category, (count, saas_managed))| CategorySummary { category, count, saas_managed }) .collect(); Ok(ConfigAnalysis { total_items: items.len() as i64, categories: category_summaries, items, }) } /// 种子默认配置项 pub async fn seed_default_config_items(db: &PgPool) -> SaasResult { let defaults = [ ("server", "server.host", "string", Some("127.0.0.1"), Some("127.0.0.1"), "服务器监听地址"), ("server", "server.port", "integer", Some("4200"), Some("4200"), "服务器端口"), ("server", "server.cors_origins", "array", None, None, "CORS 允许的源"), ("agent", "agent.defaults.default_model", "string", Some("zhipu/glm-4-plus"), Some("zhipu/glm-4-plus"), "默认模型"), ("agent", "agent.defaults.fallback_models", "array", None, None, "回退模型列表"), ("agent", "agent.defaults.max_sessions", "integer", Some("10"), Some("10"), "最大并发会话数"), ("agent", "agent.defaults.heartbeat_interval", "duration", Some("1h"), Some("1h"), "心跳间隔"), ("agent", "agent.defaults.session_timeout", "duration", Some("24h"), Some("24h"), "会话超时"), ("memory", "agent.defaults.memory.max_history_length", "integer", Some("100"), Some("100"), "最大历史长度"), ("memory", "agent.defaults.memory.summarize_threshold", "integer", Some("50"), Some("50"), "摘要阈值"), ("llm", "llm.default_provider", "string", Some("zhipu"), Some("zhipu"), "默认 LLM Provider"), ("llm", "llm.temperature", "float", Some("0.7"), Some("0.7"), "默认温度"), ("llm", "llm.max_tokens", "integer", Some("4096"), Some("4096"), "默认最大 token 数"), // 安全策略配置 ("security", "security.autonomy_level", "string", Some("standard"), Some("standard"), "自主级别: minimal/standard/full"), ("security", "security.max_tokens_per_request", "integer", Some("32768"), Some("32768"), "单次请求最大 Token 数"), ("security", "security.shell_enabled", "boolean", Some("true"), Some("true"), "是否启用 Shell 工具"), ("security", "security.shell_whitelist", "array", Some("[]"), Some("[]"), "Shell 命令白名单 (空=全部禁止)"), ("security", "security.file_write_enabled", "boolean", Some("true"), Some("true"), "是否允许文件写入"), ("security", "security.network_access_enabled", "boolean", Some("true"), Some("true"), "是否允许网络访问"), ("security", "security.browser_enabled", "boolean", Some("true"), Some("true"), "是否启用浏览器自动化"), ("security", "security.max_concurrent_tasks", "integer", Some("3"), Some("3"), "最大并发自主任务数"), ("security", "security.approval_required", "boolean", Some("false"), Some("false"), "高风险操作是否需要审批"), ("security", "security.content_filter_enabled", "boolean", Some("true"), Some("true"), "是否启用内容过滤"), ("security", "security.audit_log_enabled", "boolean", Some("true"), Some("true"), "是否启用审计日志"), ("security", "security.audit_log_max_entries", "integer", Some("500"), Some("500"), "审计日志最大条目数"), ]; let mut created = 0; let now = chrono::Utc::now(); for (category, key_path, value_type, default_value, current_value, description) in defaults { let existing: Option<(String,)> = sqlx::query_as( "SELECT id FROM config_items WHERE category = $1 AND key_path = $2" ) .bind(category).bind(key_path) .fetch_optional(db) .await?; if existing.is_none() { let id = uuid::Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, false, $8, $8)" ) .bind(&id).bind(category).bind(key_path).bind(value_type) .bind(current_value).bind(default_value).bind(description).bind(&now) .execute(db) .await?; created += 1; } } Ok(created) } // ============ Config Sync ============ /// 计算客户端与 SaaS 端的配置差异 pub async fn compute_config_diff( db: &PgPool, req: &SyncConfigRequest, ) -> SaasResult { let saas_items = fetch_all_config_items(db, &ConfigQuery { category: None, source: None, page: None, page_size: None }).await?; let mut items = Vec::new(); let mut conflicts = 0usize; for key in &req.config_keys { let client_val = req.client_values.get(key) .and_then(|v| v.as_str()) .map(|s| s.to_string()); // 查找 SaaS 端的值 let saas_item = saas_items.iter().find(|item| item.key_path == *key); let saas_val = saas_item.and_then(|item| item.current_value.clone()); let conflict = match (&client_val, &saas_val) { (Some(a), Some(b)) => a != b, _ => false, }; if conflict { conflicts += 1; } items.push(ConfigDiffItem { key_path: key.clone(), client_value: client_val, saas_value: saas_val, conflict, }); } Ok(ConfigDiffResponse { total_keys: items.len(), conflicts, items, }) } /// 执行配置同步 (实际写入 config_items) pub async fn sync_config( db: &PgPool, account_id: &str, req: &SyncConfigRequest, ) -> SaasResult { let now = chrono::Utc::now(); let config_keys_str = serde_json::to_string(&req.config_keys)?; let client_values_str = Some(serde_json::to_string(&req.client_values)?); // 获取 SaaS 端的配置值 let saas_items = fetch_all_config_items(db, &ConfigQuery { category: None, source: None, page: None, page_size: None }).await?; let mut updated = 0i64; let mut created = 0i64; let mut skipped = 0i64; let mut conflicts: Vec = Vec::new(); for key in &req.config_keys { let client_val = req.client_values.get(key) .and_then(|v| v.as_str()) .map(|s| s.to_string()); let saas_item = saas_items.iter().find(|item| item.key_path == *key); match req.action.as_str() { "push" => { // 客户端推送 → 覆盖 SaaS 值 (带 CAS 保护) if let Some(val) = &client_val { if let Some(item) = saas_item { // CAS: 如果客户端提供了该 key 的 timestamp,做乐观锁 if let Some(ref client_ts) = req.client_timestamps.get(key) { let result = sqlx::query( "UPDATE config_items SET current_value = $1, source = 'local', updated_at = $2 WHERE id = $3 AND updated_at = $4" ) .bind(val).bind(&now).bind(&item.id).bind(client_ts) .execute(db).await?; if result.rows_affected() == 0 { // SaaS 端已被修改 → 跳过,记录冲突 tracing::warn!( "[ConfigSync] CAS conflict for key '{}': client_ts={}, saas_ts={}", key, client_ts, item.updated_at ); conflicts.push(key.clone()); skipped += 1; } else { updated += 1; } } else { // 无 CAS timestamp → 无条件覆盖 (向后兼容) sqlx::query("UPDATE config_items SET current_value = $1, source = 'local', updated_at = $2 WHERE id = $3") .bind(val).bind(&now).bind(&item.id) .execute(db).await?; updated += 1; } } else { // 推送时 SaaS 不存在该 key → 创建新配置项 let id = uuid::Uuid::new_v4().to_string(); let parts: Vec<&str> = key.splitn(2, '.').collect(); let category = parts.first().unwrap_or(&"general").to_string(); sqlx::query( "INSERT INTO config_items (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) VALUES ($1, $2, $3, 'string', $4, $4, 'local', '客户端推送', false, $5, $5)" ) .bind(&id).bind(&category).bind(key).bind(val).bind(&now) .execute(db).await?; created += 1; } } } "merge" => { // 合并: 客户端有值且 SaaS 无值 → 填入; 都有值 → SaaS 优先保留 if let Some(val) = &client_val { if let Some(item) = saas_item { if item.current_value.is_none() || item.current_value.as_deref() == Some("") { sqlx::query("UPDATE config_items SET current_value = $1, source = 'local', updated_at = $2 WHERE id = $3") .bind(val).bind(&now).bind(&item.id) .execute(db).await?; updated += 1; } else { // 冲突: SaaS 有值 → 保留 SaaS 值 skipped += 1; } } else { // 客户端有但 SaaS 完全没有的 key → 不自动创建 (需要管理员先创建) skipped += 1; } } } _ => { // 默认: 记录日志但不修改 (向后兼容旧行为) } } } // 记录同步日志 let saas_values: serde_json::Value = saas_items.iter() .filter(|item| req.config_keys.contains(&item.key_path)) .map(|item| { serde_json::json!({ "value": item.current_value, "source": item.source, }) }) .collect(); let saas_values_str = Some(serde_json::to_string(&saas_values)?); let resolution = req.action.clone(); sqlx::query( "INSERT INTO config_sync_log (account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)" ) .bind(account_id).bind(&req.client_fingerprint) .bind(&req.action).bind(&config_keys_str).bind(&client_values_str) .bind(&saas_values_str).bind(&resolution).bind(&now) .execute(db) .await?; Ok(ConfigSyncResult { updated, created, skipped, conflicts }) } /// 同步结果 #[derive(Debug, Serialize)] pub struct ConfigSyncResult { pub updated: i64, pub created: i64, pub skipped: i64, /// Keys skipped due to CAS conflict (SaaS was modified after client read) pub conflicts: Vec, } pub async fn list_sync_logs( db: &PgPool, account_id: &str, page: u32, page_size: u32, ) -> SaasResult> { let offset = ((page - 1) * page_size) as i64; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM config_sync_log WHERE account_id = $1" ) .bind(account_id) .fetch_one(db) .await?; let rows: Vec = sqlx::query_as( "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at::TEXT FROM config_sync_log WHERE account_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3" ) .bind(account_id) .bind(page_size as i64) .bind(offset) .fetch_all(db) .await?; let items = rows.into_iter().map(|r| { ConfigSyncLogInfo { id: r.id, account_id: r.account_id, client_fingerprint: r.client_fingerprint, action: r.action, config_keys: r.config_keys, client_values: r.client_values, saas_values: r.saas_values, resolution: r.resolution, created_at: r.created_at } }).collect(); Ok(crate::common::PaginatedResponse { items, total: total.0, page, page_size }) }