feat(saas): P2 增强 — TOTP 2FA、Relay 重试、配置同步升级
- TOTP 2FA: totp-rs v5.7.1 + data-encoding Base32, setup/verify/disable 流程, 登录时 TOTP 验证集成, SaasError::Totp 返回 400 - Relay 重试: 指数退避 (base_delay_ms * 2^attempt), 错误分类 (4xx 不重试), Admin POST /tasks/:id/retry 端点 - 配置同步: push (客户端覆盖) / merge (SaaS 优先) / diff (只读对比), 实际写入 config_items 表 - 集成测试: 27 个测试全部通过 (新增 6 个 P2 测试) - 文档: 更新 SaaS 平台总览 (模块完成度 + API 端点列表)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
use sqlx::SqlitePool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
use serde::Serialize;
|
||||
|
||||
// ============ Config Items ============
|
||||
|
||||
@@ -203,55 +204,142 @@ pub async fn seed_default_config_items(db: &SqlitePool) -> SaasResult<usize> {
|
||||
|
||||
// ============ Config Sync ============
|
||||
|
||||
/// 计算客户端与 SaaS 端的配置差异
|
||||
pub async fn compute_config_diff(
|
||||
db: &SqlitePool, req: &SyncConfigRequest,
|
||||
) -> SaasResult<ConfigDiffResponse> {
|
||||
let saas_items = list_config_items(db, &ConfigQuery { category: None, source: 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: &SqlitePool, account_id: &str, req: &SyncConfigRequest,
|
||||
) -> SaasResult<Vec<ConfigSyncLogInfo>> {
|
||||
) -> 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 mut updated = 0i64;
|
||||
let created = 0i64;
|
||||
let mut skipped = 0i64;
|
||||
|
||||
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 值
|
||||
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;
|
||||
} else {
|
||||
// 推送时如果 SaaS 不存在该 key,记录跳过
|
||||
skipped += 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;
|
||||
}
|
||||
}
|
||||
// 客户端有但 SaaS 完全没有的 key → 不自动创建 (需要管理员先创建)
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 默认: 记录日志但不修改 (向后兼容旧行为)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 记录同步日志
|
||||
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!({
|
||||
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();
|
||||
|
||||
let resolution = "saas_wins".to_string(); // SaaS 配置优先
|
||||
|
||||
let id = sqlx::query(
|
||||
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)"
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
|
||||
)
|
||||
.bind(account_id).bind(&req.client_fingerprint)
|
||||
.bind(&config_keys_str).bind(&client_values_str)
|
||||
.bind(&req.action).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();
|
||||
Ok(ConfigSyncResult { updated, created, skipped })
|
||||
}
|
||||
|
||||
// 返回同步结果
|
||||
let row: Option<(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 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())
|
||||
/// 同步结果
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ConfigSyncResult {
|
||||
pub updated: i64,
|
||||
pub created: i64,
|
||||
pub skipped: i64,
|
||||
}
|
||||
|
||||
pub async fn list_sync_logs(
|
||||
|
||||
Reference in New Issue
Block a user