@@ -1,27 +1,29 @@
//! 配置迁移业务逻辑
use sqlx ::Sqlite Pool;
use sqlx ::Pg Pool;
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 : & Sqlite Pool, req : & CreateConfigItemRequest ,
db : & Pg Pool, 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 : & Sqlite Pool, item_id : & str , req : & UpdateConfigItemRequest ,
db : & Pg Pool, 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 = 1 usize ;
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 : & Sqlite Pool, item_id : & str ) -> SaasResult < ( ) > {
let result = sqlx ::query ( " DELETE FROM config_items WHERE id = ? 1 " )
pub async fn delete_config_item ( db : & Pg Pool, 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 : & Sqlite Pool) -> SaasResult < ConfigAnalysis > {
let items = list _config_items( db , & ConfigQuery { category : None , source : None } ) . await ? ;
pub async fn analyze_config ( db : & Pg Pool) -> 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 : & Sqlite Pool) -> SaasResult < usize > {
pub async fn seed_default_config_items ( db : & Pg Pool) -> 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 : & Sqlite Pool, req : & SyncConfigRequest ,
db : & Pg Pool, 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 = 0 usize ;
@@ -248,17 +312,18 @@ pub async fn compute_config_diff(
/// 执行配置同步 (实际写入 config_items)
pub async fn sync_config (
db : & Sqlite Pool, account_id : & str , req : & SyncConfigRequest ,
db : & Pg Pool, 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 = 0 i64 ;
let created = 0 i64 ;
let mut created = 0 i64 ;
let mut skipped = 0 i64 ;
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,记录跳过
skippe d + = 1 ;
// 推送时 SaaS 不存在该 key → 创建新配置项
let i d = 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 : & Sqlite Pool, account_id : & str ,
) -> SaasResult < Vec < ConfigSyncLogInfo > > {
db : & Pg Pool, 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 } )
}