Files
zclaw_openfang/crates/zclaw-saas/src/migration/service.rs
iven 7de486bfca
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
test(saas): Phase 1 integration tests — billing + scheduled_task + knowledge (68 tests)
- Fix TIMESTAMPTZ decode errors: add ::TEXT cast to all SELECT queries
  where Row structs use String for TIMESTAMPTZ columns (~22 locations)
- Fix Axum 0.7 route params: {id} → :id in billing/knowledge/scheduled_task routes
- Fix JSONB bind: scheduled_task INSERT uses ::jsonb cast for input_payload
- Add billing_test.rs (14 tests): plans, subscription, usage, payments, invoices
- Add scheduled_task_test.rs (12 tests): CRUD, validation, isolation
- Add knowledge_test.rs (20 tests): categories, items, versions, search, analytics, permissions
- Fix auth test regression: 6 tests were failing due to TIMESTAMPTZ type mismatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:25:34 +08:00

476 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 配置迁移业务逻辑
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<Vec<ConfigItemInfo>> {
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<u32>, page_size: Option<u32>,
) -> SaasResult<PaginatedResponse<ConfigItemInfo>> {
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<ConfigItemInfo> {
let row: Option<ConfigItemRow> =
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<ConfigItemInfo> {
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<ConfigItemInfo> {
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<ConfigAnalysis> {
let items = fetch_all_config_items(db, &ConfigQuery { category: None, source: None, page: None, page_size: None }).await?;
let mut categories: std::collections::HashMap<String, (i64, i64)> = 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<CategorySummary> = 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<usize> {
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<ConfigDiffResponse> {
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<ConfigSyncResult> {
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<String> = 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<String>,
}
pub async fn list_sync_logs(
db: &PgPool, account_id: &str, page: u32, page_size: u32,
) -> SaasResult<crate::common::PaginatedResponse<ConfigSyncLogInfo>> {
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<ConfigSyncLogRow> =
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 })
}