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
- 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>
476 lines
22 KiB
Rust
476 lines
22 KiB
Rust
//! 配置迁移业务逻辑
|
||
|
||
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 })
|
||
}
|