chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -7,16 +7,23 @@ use axum::{
use crate::state::AppState;
use crate::error::SaasResult;
use crate::auth::types::AuthContext;
use crate::auth::handlers::check_permission;
use crate::auth::handlers::{check_permission, log_operation};
use crate::common::PaginatedResponse;
use super::{types::*, service};
/// GET /api/v1/config/items?category=xxx&source=xxx
/// GET /api/v1/config/items?category=xxx&source=xxx&page=1&page_size=20
pub async fn list_config_items(
State(state): State<AppState>,
Query(query): Query<ConfigQuery>,
_ctx: Extension<AuthContext>,
) -> SaasResult<Json<Vec<ConfigItemInfo>>> {
service::list_config_items(&state.db, &query).await.map(Json)
) -> SaasResult<Json<PaginatedResponse<ConfigItemInfo>>> {
let filter_query = ConfigQuery {
category: query.category.clone(),
source: query.source.clone(),
page: None,
page_size: None,
};
service::list_config_items(&state.db, &filter_query, query.page, query.page_size).await.map(Json)
}
/// GET /api/v1/config/items/:id
@@ -36,10 +43,11 @@ pub async fn create_config_item(
) -> SaasResult<(StatusCode, Json<ConfigItemInfo>)> {
check_permission(&ctx, "config:write")?;
let item = service::create_config_item(&state.db, &req).await?;
log_operation(&state.db, &ctx.account_id, "config.create", "config_item", &item.id, None, ctx.client_ip.as_deref()).await?;
Ok((StatusCode::CREATED, Json(item)))
}
/// PUT /api/v1/config/items/:id (admin only)
/// PATCH /api/v1/config/items/:id (admin only)
pub async fn update_config_item(
State(state): State<AppState>,
Path(id): Path<String>,
@@ -47,7 +55,9 @@ pub async fn update_config_item(
Json(req): Json<UpdateConfigItemRequest>,
) -> SaasResult<Json<ConfigItemInfo>> {
check_permission(&ctx, "config:write")?;
service::update_config_item(&state.db, &id, &req).await.map(Json)
let item = service::update_config_item(&state.db, &id, &req).await?;
log_operation(&state.db, &ctx.account_id, "config.update", "config_item", &id, None, ctx.client_ip.as_deref()).await?;
Ok(Json(item))
}
/// DELETE /api/v1/config/items/:id (admin only)
@@ -58,6 +68,7 @@ pub async fn delete_config_item(
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "config:write")?;
service::delete_config_item(&state.db, &id).await?;
log_operation(&state.db, &ctx.account_id, "config.delete", "config_item", &id, None, ctx.client_ip.as_deref()).await?;
Ok(Json(serde_json::json!({"ok": true})))
}
@@ -76,32 +87,95 @@ pub async fn seed_config(
) -> SaasResult<Json<serde_json::Value>> {
check_permission(&ctx, "config:write")?;
let count = service::seed_default_config_items(&state.db).await?;
log_operation(&state.db, &ctx.account_id, "config.seed", "config_item", "batch", Some(serde_json::json!({"count": count})), ctx.client_ip.as_deref()).await?;
Ok(Json(serde_json::json!({"created": count})))
}
/// POST /api/v1/config/sync
/// POST /api/v1/config/sync (需要 config:write 权限)
pub async fn sync_config(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<SyncConfigRequest>,
) -> SaasResult<Json<super::service::ConfigSyncResult>> {
super::service::sync_config(&state.db, &ctx.account_id, &req).await.map(Json)
// 权限检查:仅 config:write 可推送配置
check_permission(&ctx, "config:write")?;
let result = super::service::sync_config(&state.db, &ctx.account_id, &req).await?;
// 审计日志
log_operation(
&state.db,
&ctx.account_id,
"config.sync",
"config",
"batch",
Some(serde_json::json!({
"client_fingerprint": req.client_fingerprint,
"action": req.action,
"config_count": req.config_keys.len(),
})),
ctx.client_ip.as_deref(),
).await.ok();
Ok(Json(result))
}
/// POST /api/v1/config/diff
/// 计算客户端与 SaaS 端的配置差异 (不修改数据)
pub async fn config_diff(
State(state): State<AppState>,
Extension(_ctx): Extension<AuthContext>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<SyncConfigRequest>,
) -> SaasResult<Json<ConfigDiffResponse>> {
// diff 操作虽然不修改数据,但涉及敏感配置信息,仍需认证用户
service::compute_config_diff(&state.db, &req).await.map(Json)
}
/// GET /api/v1/config/sync-logs
/// GET /api/v1/config/sync-logs?page=1&page_size=20
pub async fn list_sync_logs(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<ConfigSyncLogInfo>>> {
service::list_sync_logs(&state.db, &ctx.account_id).await.map(Json)
Query(params): Query<std::collections::HashMap<String, String>>,
) -> SaasResult<Json<crate::common::PaginatedResponse<ConfigSyncLogInfo>>> {
let page: u32 = params.get("page").and_then(|v| v.parse().ok()).unwrap_or(1).max(1);
let page_size: u32 = params.get("page_size").and_then(|v| v.parse().ok()).unwrap_or(20).min(100);
service::list_sync_logs(&state.db, &ctx.account_id, page, page_size).await.map(Json)
}
/// GET /api/v1/config/pull?since=2026-03-28T00:00:00Z
/// 批量拉取配置(供桌面端启动时一次性拉取)
/// 返回扁平的 key-value map可选 since 参数过滤仅返回该时间之后更新的配置
pub async fn pull_config(
State(state): State<AppState>,
_ctx: Extension<AuthContext>,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> SaasResult<Json<serde_json::Value>> {
let since = params.get("since").cloned();
let items = service::fetch_all_config_items(
&state.db,
&ConfigQuery { category: None, source: None, page: None, page_size: None },
).await?;
let mut configs: Vec<serde_json::Value> = Vec::new();
for item in items {
// 如果指定了 since只返回 updated_at > since 的配置
if let Some(ref since_val) = since {
if item.updated_at <= *since_val {
continue;
}
}
configs.push(serde_json::json!({
"key": item.key_path,
"category": item.category,
"value": item.current_value,
"value_type": item.value_type,
"default": item.default_value,
"updated_at": item.updated_at,
}));
}
Ok(Json(serde_json::json!({
"configs": configs,
"pulled_at": chrono::Utc::now().to_rfc3339(),
})))
}

View File

@@ -11,10 +11,11 @@ use crate::state::AppState;
pub fn routes() -> axum::Router<AppState> {
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/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/diff", post(handlers::config_diff))
.route("/api/v1/config/sync-logs", get(handlers::list_sync_logs))
.route("/api/v1/config/pull", get(handlers::pull_config))
}

View File

@@ -1,27 +1,29 @@
//! 配置迁移业务逻辑
use sqlx::SqlitePool;
use sqlx::PgPool;
use crate::error::{SaasError, SaasResult};
use crate::common::{PaginatedResponse, normalize_pagination};
use super::types::*;
use serde::Serialize;
// ============ Config Items ============
pub async fn list_config_items(
db: &SqlitePool, query: &ConfigQuery,
/// 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, updated_at
FROM config_items WHERE category = ?1 AND source = ?2 ORDER BY category, key_path"
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"
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"
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
@@ -44,11 +46,58 @@ pub async fn list_config_items(
}).collect())
}
pub async fn get_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<ConfigItemInfo> {
/// 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);
// Build WHERE clause for count and data queries
let (where_clause, has_category, has_source) = match (&query.category, &query.source) {
(Some(_), Some(_)) => ("WHERE category = $1 AND source = $2", true, true),
(Some(_), None) => ("WHERE category = $1", true, false),
(None, Some(_)) => ("WHERE source = $1", false, true),
(None, None) => ("", false, false),
};
let count_sql = format!("SELECT COUNT(*) FROM config_items {}", where_clause);
let data_sql = format!(
"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 LIMIT {} OFFSET {}",
where_clause, "$p", "$o"
);
// Determine param indices for LIMIT/OFFSET based on filter params
let (limit_idx, offset_idx) = match (has_category, has_source) {
(true, true) => ("$3", "$4"),
(true, false) | (false, true) => ("$2", "$3"),
(false, false) => ("$1", "$2"),
};
let data_sql = data_sql.replace("$p", limit_idx).replace("$o", offset_idx);
let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
if has_category { count_query = count_query.bind(&query.category); }
if has_source { count_query = count_query.bind(&query.source); }
let total: i64 = count_query.fetch_one(db).await?;
let mut data_query = sqlx::query_as::<_, (String, String, String, String, Option<String>, Option<String>, String, Option<String>, bool, String, String)>(&data_sql);
if has_category { data_query = data_query.bind(&query.category); }
if has_source { data_query = data_query.bind(&query.source); }
let rows = data_query.bind(ps as i64).bind(offset).fetch_all(db).await?;
let items = 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();
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<(String, String, String, String, Option<String>, Option<String>, String, Option<String>, 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"
FROM config_items WHERE id = $1"
)
.bind(item_id)
.fetch_optional(db)
@@ -61,7 +110,7 @@ pub async fn get_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<Confi
}
pub async fn create_config_item(
db: &SqlitePool, req: &CreateConfigItemRequest,
db: &PgPool, req: &CreateConfigItemRequest,
) -> SaasResult<ConfigItemInfo> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
@@ -70,7 +119,7 @@ pub async fn create_config_item(
// 检查唯一性
let existing: Option<(String,)> = sqlx::query_as(
"SELECT id FROM config_items WHERE category = ?1 AND key_path = ?2"
"SELECT id FROM config_items WHERE category = $1 AND key_path = $2"
)
.bind(&req.category).bind(&req.key_path)
.fetch_optional(db).await?;
@@ -83,7 +132,7 @@ pub async fn create_config_item(
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)"
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)
@@ -94,25 +143,27 @@ pub async fn create_config_item(
}
pub async fn update_config_item(
db: &SqlitePool, item_id: &str, req: &UpdateConfigItemRequest,
db: &PgPool, item_id: &str, req: &UpdateConfigItemRequest,
) -> SaasResult<ConfigItemInfo> {
let now = chrono::Utc::now().to_rfc3339();
let mut updates = Vec::new();
let mut params: Vec<String> = Vec::new();
let mut param_idx = 1usize;
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 let Some(ref v) = req.current_value { updates.push(format!("current_value = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if let Some(ref v) = req.source { updates.push(format!("source = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if let Some(ref v) = req.description { updates.push(format!("description = ${}", param_idx)); params.push(v.clone()); param_idx += 1; }
if updates.is_empty() {
return get_config_item(db, item_id).await;
}
updates.push("updated_at = ?");
updates.push(format!("updated_at = ${}", param_idx));
params.push(now);
param_idx += 1;
params.push(item_id.to_string());
let sql = format!("UPDATE config_items SET {} WHERE id = ?", updates.join(", "));
let sql = format!("UPDATE config_items SET {} WHERE id = ${}", updates.join(", "), param_idx);
let mut query = sqlx::query(&sql);
for p in &params {
query = query.bind(p);
@@ -122,8 +173,8 @@ pub async fn update_config_item(
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")
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)));
@@ -133,8 +184,8 @@ pub async fn delete_config_item(db: &SqlitePool, item_id: &str) -> SaasResult<()
// ============ Config Analysis ============
pub async fn analyze_config(db: &SqlitePool) -> SaasResult<ConfigAnalysis> {
let items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?;
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 {
@@ -157,7 +208,7 @@ pub async fn analyze_config(db: &SqlitePool) -> SaasResult<ConfigAnalysis> {
}
/// 种子默认配置项
pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
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"), "服务器端口"),
@@ -172,6 +223,19 @@ pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
("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;
@@ -179,7 +243,7 @@ pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
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"
"SELECT id FROM config_items WHERE category = $1 AND key_path = $2"
)
.bind(category).bind(key_path)
.fetch_optional(db)
@@ -189,7 +253,7 @@ pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
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)"
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)
@@ -206,9 +270,9 @@ pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
/// 计算客户端与 SaaS 端的配置差异
pub async fn compute_config_diff(
db: &SqlitePool, req: &SyncConfigRequest,
db: &PgPool, req: &SyncConfigRequest,
) -> SaasResult<ConfigDiffResponse> {
let saas_items = list_config_items(db, &ConfigQuery { category: None, source: None }).await?;
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;
@@ -248,17 +312,18 @@ pub async fn compute_config_diff(
/// 执行配置同步 (实际写入 config_items)
pub async fn sync_config(
db: &SqlitePool, account_id: &str, req: &SyncConfigRequest,
db: &PgPool, account_id: &str, req: &SyncConfigRequest,
) -> SaasResult<ConfigSyncResult> {
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_items = fetch_all_config_items(db, &ConfigQuery { category: None, source: None, page: None, page_size: None }).await?;
let mut updated = 0i64;
let created = 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)
@@ -269,26 +334,55 @@ pub async fn sync_config(
match req.action.as_str() {
"push" => {
// 客户端推送 → 覆盖 SaaS 值
// 客户端推送 → 覆盖 SaaS 值 (带 CAS 保护)
if let Some(val) = &client_val {
if let Some(item) = saas_item {
// 更新已有配置项
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;
// 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,记录跳过
skipped += 1;
// 推送时 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 优先保留
// 合并: 客户端有值且 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")
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;
@@ -296,9 +390,10 @@ pub async fn sync_config(
// 冲突: SaaS 有值 → 保留 SaaS 值
skipped += 1;
}
} else {
// 客户端有但 SaaS 完全没有的 key → 不自动创建 (需要管理员先创建)
skipped += 1;
}
// 客户端有但 SaaS 完全没有的 key → 不自动创建 (需要管理员先创建)
skipped += 1;
}
}
_ => {
@@ -323,7 +418,7 @@ pub async fn sync_config(
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)"
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)
@@ -331,7 +426,7 @@ pub async fn sync_config(
.execute(db)
.await?;
Ok(ConfigSyncResult { updated, created, skipped })
Ok(ConfigSyncResult { updated, created, skipped, conflicts })
}
/// 同步结果
@@ -340,21 +435,36 @@ 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: &SqlitePool, account_id: &str,
) -> SaasResult<Vec<ConfigSyncLogInfo>> {
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<(i64, String, String, String, String, Option<String>, Option<String>, Option<String>, 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"
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?;
Ok(rows.into_iter().map(|(id, account_id, client_fingerprint, action, config_keys, client_values, saas_values, resolution, created_at)| {
let items = 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())
}).collect();
Ok(crate::common::PaginatedResponse { items, total: total.0, page, page_size })
}

View File

@@ -77,6 +77,11 @@ pub struct SyncConfigRequest {
pub action: String,
pub config_keys: Vec<String>,
pub client_values: serde_json::Value,
/// Client-side timestamps per key for optimistic locking (push CAS).
/// Maps `key_path` → `updated_at` as seen by client before this push.
/// Keys present here get `WHERE updated_at = $ts` on UPDATE; absent keys use unconditional overwrite.
#[serde(default)]
pub client_timestamps: std::collections::HashMap<String, String>,
}
fn default_sync_action() -> String { "push".to_string() }
@@ -103,4 +108,6 @@ pub struct ConfigDiffResponse {
pub struct ConfigQuery {
pub category: Option<String>,
pub source: Option<String>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}