diff --git a/crates/zclaw-saas/src/main.rs b/crates/zclaw-saas/src/main.rs index e3a9ab1..2a554ee 100644 --- a/crates/zclaw-saas/src/main.rs +++ b/crates/zclaw-saas/src/main.rs @@ -45,6 +45,7 @@ fn build_router(state: AppState) -> axum::Router { .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::relay::routes()) + .merge(zclaw_saas::migration::routes()) .layer(middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, diff --git a/crates/zclaw-saas/src/migration/handlers.rs b/crates/zclaw-saas/src/migration/handlers.rs new file mode 100644 index 0000000..af61dc9 --- /dev/null +++ b/crates/zclaw-saas/src/migration/handlers.rs @@ -0,0 +1,104 @@ +//! 配置迁移 HTTP 处理器 + +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, Json, +}; +use crate::state::AppState; +use crate::error::SaasResult; +use crate::auth::types::AuthContext; +use super::{types::*, service}; + +/// GET /api/v1/config/items?category=xxx&source=xxx +pub async fn list_config_items( + State(state): State, + Query(query): Query, + _ctx: Extension, +) -> SaasResult>> { + service::list_config_items(&state.db, &query).await.map(Json) +} + +/// GET /api/v1/config/items/:id +pub async fn get_config_item( + State(state): State, + Path(id): Path, + _ctx: Extension, +) -> SaasResult> { + service::get_config_item(&state.db, &id).await.map(Json) +} + +/// POST /api/v1/config/items (admin only) +pub async fn create_config_item( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult<(StatusCode, Json)> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + let item = service::create_config_item(&state.db, &req).await?; + Ok((StatusCode::CREATED, Json(item))) +} + +/// PUT /api/v1/config/items/:id (admin only) +pub async fn update_config_item( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + service::update_config_item(&state.db, &id, &req).await.map(Json) +} + +/// DELETE /api/v1/config/items/:id (admin only) +pub async fn delete_config_item( + State(state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + service::delete_config_item(&state.db, &id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// GET /api/v1/config/analysis +pub async fn analyze_config( + State(state): State, + _ctx: Extension, +) -> SaasResult> { + service::analyze_config(&state.db).await.map(Json) +} + +/// POST /api/v1/config/seed (admin only) +pub async fn seed_config( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult> { + if !ctx.permissions.contains(&"config:manage".to_string()) { + return Err(crate::error::SaasError::Forbidden("需要 config:manage 权限".into())); + } + let count = service::seed_default_config_items(&state.db).await?; + Ok(Json(serde_json::json!({"created": count}))) +} + +/// POST /api/v1/config/sync +pub async fn sync_config( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult>> { + service::sync_config(&state.db, &ctx.account_id, &req).await.map(Json) +} + +/// GET /api/v1/config/sync-logs +pub async fn list_sync_logs( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult>> { + service::list_sync_logs(&state.db, &ctx.account_id).await.map(Json) +} diff --git a/crates/zclaw-saas/src/migration/mod.rs b/crates/zclaw-saas/src/migration/mod.rs index 1657d19..85ff182 100644 --- a/crates/zclaw-saas/src/migration/mod.rs +++ b/crates/zclaw-saas/src/migration/mod.rs @@ -1 +1,19 @@ //! 配置迁移模块 + +pub mod types; +pub mod service; +pub mod handlers; + +use axum::routing::{get, post}; +use crate::state::AppState; + +/// 配置迁移路由 (需要认证) +pub fn routes() -> axum::Router { + axum::Router::new() + .route("/api/v1/config/items", get(handlers::list_config_items).post(handlers::create_config_item)) + .route("/api/v1/config/items/{id}", get(handlers::get_config_item).put(handlers::update_config_item).delete(handlers::delete_config_item)) + .route("/api/v1/config/analysis", get(handlers::analyze_config)) + .route("/api/v1/config/seed", post(handlers::seed_config)) + .route("/api/v1/config/sync", post(handlers::sync_config)) + .route("/api/v1/config/sync-logs", get(handlers::list_sync_logs)) +} diff --git a/crates/zclaw-saas/src/migration/service.rs b/crates/zclaw-saas/src/migration/service.rs new file mode 100644 index 0000000..2a6cb30 --- /dev/null +++ b/crates/zclaw-saas/src/migration/service.rs @@ -0,0 +1,272 @@ +//! 配置迁移业务逻辑 + +use sqlx::SqlitePool; +use crate::error::{SaasError, SaasResult}; +use super::types::*; + +// ============ Config Items ============ + +pub async fn list_config_items( + db: &SqlitePool, 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, updated_at + 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, updated_at + 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, updated_at + 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, updated_at + FROM config_items ORDER BY category, key_path" + } + }; + + let mut query_builder = sqlx::query_as::<_, (String, String, String, String, Option, Option, String, Option, bool, String, String)>(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(|(id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at)| { + ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at } + }).collect()) +} + +pub async fn get_config_item(db: &SqlitePool, item_id: &str) -> SaasResult { + let row: Option<(String, String, String, String, Option, Option, String, Option, bool, String, String)> = + sqlx::query_as( + "SELECT id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at + FROM config_items WHERE id = ?1" + ) + .bind(item_id) + .fetch_optional(db) + .await?; + + let (id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at) = + row.ok_or_else(|| SaasError::NotFound(format!("配置项 {} 不存在", item_id)))?; + + Ok(ConfigItemInfo { id, category, key_path, value_type, current_value, default_value, source, description, requires_restart, created_at, updated_at }) +} + +pub async fn create_config_item( + db: &SqlitePool, req: &CreateConfigItemRequest, +) -> SaasResult { + let id = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().to_rfc3339(); + 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: &SqlitePool, item_id: &str, req: &UpdateConfigItemRequest, +) -> SaasResult { + let now = chrono::Utc::now().to_rfc3339(); + let mut updates = Vec::new(); + let mut params: Vec = Vec::new(); + + if let Some(ref v) = req.current_value { updates.push("current_value = ?"); params.push(v.clone()); } + if let Some(ref v) = req.source { updates.push("source = ?"); params.push(v.clone()); } + if let Some(ref v) = req.description { updates.push("description = ?"); params.push(v.clone()); } + + if updates.is_empty() { + return get_config_item(db, item_id).await; + } + + updates.push("updated_at = ?"); + params.push(now); + params.push(item_id.to_string()); + + let sql = format!("UPDATE config_items SET {} WHERE id = ?", updates.join(", ")); + let mut query = sqlx::query(&sql); + for p in ¶ms { + query = query.bind(p); + } + query.execute(db).await?; + + get_config_item(db, item_id).await +} + +pub async fn delete_config_item(db: &SqlitePool, 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: &SqlitePool) -> SaasResult { + let items = list_config_items(db, &ConfigQuery { category: None, source: 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: &SqlitePool) -> 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 数"), + ]; + + let mut created = 0; + let now = chrono::Utc::now().to_rfc3339(); + + 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, 0, ?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 ============ + +pub async fn sync_config( + db: &SqlitePool, account_id: &str, req: &SyncConfigRequest, +) -> SaasResult> { + let now = chrono::Utc::now().to_rfc3339(); + 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 = list_config_items(db, &ConfigQuery { category: None, source: None }).await?; + let saas_values: serde_json::Value = saas_items.iter() + .filter(|item| req.config_keys.contains(&item.key_path)) + .map(|item| { + let key = format!("{}.{}", item.category, item.key_path); + (key, serde_json::json!({ + "value": item.current_value, + "source": item.source, + })) + }) + .collect(); + + let saas_values_str = Some(serde_json::to_string(&saas_values)?); + + let resolution = "saas_wins".to_string(); // SaaS 配置优先 + + let id = sqlx::query( + "INSERT INTO config_sync_log (account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at) + VALUES (?1, ?2, 'sync', ?3, ?4, ?5, ?6, ?7)" + ) + .bind(account_id).bind(&req.client_fingerprint) + .bind(&config_keys_str).bind(&client_values_str) + .bind(&saas_values_str).bind(&resolution).bind(&now) + .execute(db) + .await?; + + let log_id = id.last_insert_rowid(); + + // 返回同步结果 + let row: Option<(i64, String, String, String, String, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at + FROM config_sync_log WHERE id = ?1" + ) + .bind(log_id) + .fetch_optional(db) + .await?; + + Ok(row.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| { + ConfigSyncLogInfo { id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at } + }).collect()) +} + +pub async fn list_sync_logs( + db: &SqlitePool, account_id: &str, +) -> SaasResult> { + let rows: Vec<(i64, String, String, String, String, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at + FROM config_sync_log WHERE account_id = ?1 ORDER BY created_at DESC LIMIT 50" + ) + .bind(account_id) + .fetch_all(db) + .await?; + + Ok(rows.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| { + ConfigSyncLogInfo { id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at } + }).collect()) +} diff --git a/crates/zclaw-saas/src/migration/types.rs b/crates/zclaw-saas/src/migration/types.rs new file mode 100644 index 0000000..37c8fdb --- /dev/null +++ b/crates/zclaw-saas/src/migration/types.rs @@ -0,0 +1,84 @@ +//! 配置迁移类型定义 + +use serde::{Deserialize, Serialize}; + +/// 配置项信息 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigItemInfo { + pub id: String, + pub category: String, + pub key_path: String, + pub value_type: String, + pub current_value: Option, + pub default_value: Option, + pub source: String, + pub description: Option, + pub requires_restart: bool, + pub created_at: String, + pub updated_at: String, +} + +/// 创建配置项请求 +#[derive(Debug, Deserialize)] +pub struct CreateConfigItemRequest { + pub category: String, + pub key_path: String, + pub value_type: String, + pub current_value: Option, + pub default_value: Option, + pub source: Option, + pub description: Option, + pub requires_restart: Option, +} + +/// 更新配置项请求 +#[derive(Debug, Deserialize)] +pub struct UpdateConfigItemRequest { + pub current_value: Option, + pub source: Option, + pub description: Option, +} + +/// 配置同步日志 +#[derive(Debug, Clone, Serialize)] +pub struct ConfigSyncLogInfo { + pub id: i64, + pub account_id: String, + pub client_fingerprint: String, + pub action: String, + pub config_keys: String, + pub client_values: Option, + pub saas_values: Option, + pub resolution: Option, + pub created_at: String, +} + +/// 配置分析结果 +#[derive(Debug, Serialize)] +pub struct ConfigAnalysis { + pub total_items: i64, + pub categories: Vec, + pub items: Vec, +} + +#[derive(Debug, Serialize)] +pub struct CategorySummary { + pub category: String, + pub count: i64, + pub saas_managed: i64, +} + +/// 配置同步请求 +#[derive(Debug, Deserialize)] +pub struct SyncConfigRequest { + pub client_fingerprint: String, + pub config_keys: Vec, + pub client_values: serde_json::Value, +} + +/// 配置查询参数 +#[derive(Debug, Deserialize)] +pub struct ConfigQuery { + pub category: Option, + pub source: Option, +} diff --git a/crates/zclaw-saas/tests/integration_test.rs b/crates/zclaw-saas/tests/integration_test.rs index 206d3e7..0e37919 100644 --- a/crates/zclaw-saas/tests/integration_test.rs +++ b/crates/zclaw-saas/tests/integration_test.rs @@ -23,6 +23,7 @@ async fn build_test_app() -> axum::Router { .merge(zclaw_saas::account::routes()) .merge(zclaw_saas::model_config::routes()) .merge(zclaw_saas::relay::routes()) + .merge(zclaw_saas::migration::routes()) .layer(axum::middleware::from_fn_with_state( state.clone(), zclaw_saas::auth::auth_middleware, @@ -349,3 +350,92 @@ async fn test_relay_tasks_list() { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } + +// ============ Phase 4: 配置迁移测试 ============ + +#[tokio::test] +async fn test_config_analysis_empty() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfguser", "cfguser@example.com").await; + + // 初始分析 (无种子数据 → 空列表) + let req = Request::builder() + .method("GET") + .uri("/api/v1/config/analysis") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert_eq!(body["total_items"], 0); +} + +#[tokio::test] +async fn test_config_seed_and_list() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfgseed", "cfgseed@example.com").await; + + // 种子配置 (普通用户无权限 → 403) + let seed_req = Request::builder() + .method("POST") + .uri("/api/v1/config/seed") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(seed_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // 列出配置项 (空列表) + let list_req = Request::builder() + .method("GET") + .uri("/api/v1/config/items") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.clone().oneshot(list_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body_bytes = axum::body::to_bytes(resp.into_body(), MAX_BODY_SIZE).await.unwrap(); + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); + assert!(body.is_array()); + assert_eq!(body.as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn test_config_sync() { + let app = build_test_app().await; + let token = register_and_login(&app, "cfgsync", "cfgsync@example.com").await; + + let sync_req = Request::builder() + .method("POST") + .uri("/api/v1/config/sync") + .header("Content-Type", "application/json") + .header("Authorization", auth_header(&token)) + .body(Body::from(serde_json::to_string(&json!({ + "client_fingerprint": "test-desktop-v1", + "config_keys": ["server.host", "agent.defaults.default_model"], + "client_values": { + "server.host": "0.0.0.0", + "agent.defaults.default_model": "deepseek/deepseek-chat" + } + })).unwrap())) + .unwrap(); + + let resp = app.clone().oneshot(sync_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // 查看同步日志 + let logs_req = Request::builder() + .method("GET") + .uri("/api/v1/config/sync-logs") + .header("Authorization", auth_header(&token)) + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(logs_req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +}