refactor(saas): 架构重构 + 性能优化 — 借鉴 loco-rs 模式
Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究
Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体
Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统
Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行
Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
This commit is contained in:
@@ -8,6 +8,7 @@ use crate::state::AppState;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::auth::types::AuthContext;
|
||||
use crate::auth::handlers::{log_operation, check_permission};
|
||||
use crate::models::{OperationLogRow, DashboardStatsRow, DashboardTodayRow};
|
||||
use super::{types::*, service};
|
||||
|
||||
fn require_admin(ctx: &AuthContext) -> SaasResult<()> {
|
||||
@@ -143,7 +144,7 @@ pub async fn list_operation_logs(
|
||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM operation_logs")
|
||||
.fetch_one(&state.db).await?;
|
||||
|
||||
let rows: Vec<(i64, Option<String>, String, Option<String>, Option<String>, Option<String>, Option<String>, String)> =
|
||||
let rows: Vec<OperationLogRow> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, account_id, action, target_type, target_id, details, ip_address, created_at
|
||||
FROM operation_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2"
|
||||
@@ -153,12 +154,12 @@ pub async fn list_operation_logs(
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.into_iter().map(|(id, account_id, action, target_type, target_id, details, ip_address, created_at)| {
|
||||
let items: Vec<serde_json::Value> = rows.into_iter().map(|r| {
|
||||
serde_json::json!({
|
||||
"id": id, "account_id": account_id, "action": action,
|
||||
"target_type": target_type, "target_id": target_id,
|
||||
"details": details.and_then(|d| serde_json::from_str::<serde_json::Value>(&d).ok()),
|
||||
"ip_address": ip_address, "created_at": created_at,
|
||||
"id": r.id, "account_id": r.account_id, "action": r.action,
|
||||
"target_type": r.target_type, "target_id": r.target_id,
|
||||
"details": r.details.and_then(|d| serde_json::from_str::<serde_json::Value>(&d).ok()),
|
||||
"ip_address": r.ip_address, "created_at": r.created_at,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
@@ -173,33 +174,40 @@ pub async fn dashboard_stats(
|
||||
require_admin(&ctx)?;
|
||||
|
||||
// 查询 1: 账号 + Provider + Model 聚合 (一次查询)
|
||||
let stats_row: (i64, i64, i64, i64) = sqlx::query_as(
|
||||
let stats_row: DashboardStatsRow = sqlx::query_as(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM accounts) as total_accounts,
|
||||
(SELECT COUNT(*) FROM accounts WHERE status = 'active') as active_accounts,
|
||||
(SELECT COUNT(*) FROM providers WHERE enabled = true) as active_providers,
|
||||
(SELECT COUNT(*) FROM models WHERE enabled = true) as active_models"
|
||||
).fetch_one(&state.db).await?;
|
||||
let (total_accounts, active_accounts, active_providers, active_models) = stats_row;
|
||||
|
||||
// 查询 2: 今日中转统计 (一次查询)
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let today_row: (i64, i64, i64) = sqlx::query_as(
|
||||
// 查询 2: 今日中转统计 — 使用范围查询走 B-tree 索引
|
||||
let today_start = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0).unwrap()
|
||||
.and_utc()
|
||||
.to_rfc3339();
|
||||
let tomorrow_start = (chrono::Utc::now() + chrono::Duration::days(1))
|
||||
.date_naive()
|
||||
.and_hms_opt(0, 0, 0).unwrap()
|
||||
.and_utc()
|
||||
.to_rfc3339();
|
||||
let today_row: DashboardTodayRow = sqlx::query_as(
|
||||
"SELECT
|
||||
(SELECT COUNT(*) FROM relay_tasks WHERE SUBSTRING(created_at, 1, 10) = $1) as tasks_today,
|
||||
COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE SUBSTRING(created_at, 1, 10) = $1), 0) as tokens_input,
|
||||
COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE SUBSTRING(created_at, 1, 10) = $1), 0) as tokens_output"
|
||||
).bind(&today).fetch_one(&state.db).await?;
|
||||
let (tasks_today, tokens_today_input, tokens_today_output) = today_row;
|
||||
(SELECT COUNT(*) FROM relay_tasks WHERE created_at >= $1 AND created_at < $2) as tasks_today,
|
||||
COALESCE((SELECT SUM(input_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0) as tokens_input,
|
||||
COALESCE((SELECT SUM(output_tokens) FROM usage_records WHERE created_at >= $1 AND created_at < $2), 0) as tokens_output"
|
||||
).bind(&today_start).bind(&tomorrow_start).fetch_one(&state.db).await?;
|
||||
|
||||
Ok(Json(serde_json::json!({
|
||||
"total_accounts": total_accounts,
|
||||
"active_accounts": active_accounts,
|
||||
"tasks_today": tasks_today,
|
||||
"active_providers": active_providers,
|
||||
"active_models": active_models,
|
||||
"tokens_today_input": tokens_today_input,
|
||||
"tokens_today_output": tokens_today_output,
|
||||
"total_accounts": stats_row.total_accounts,
|
||||
"active_accounts": stats_row.active_accounts,
|
||||
"tasks_today": today_row.tasks_today,
|
||||
"active_providers": stats_row.active_providers,
|
||||
"active_models": stats_row.active_models,
|
||||
"tokens_today_input": today_row.tokens_input,
|
||||
"tokens_today_output": today_row.tokens_output,
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use sqlx::PgPool;
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use crate::common::{PaginatedResponse, normalize_pagination};
|
||||
use crate::models::{AccountRow, ApiTokenRow, DeviceRow};
|
||||
use super::types::*;
|
||||
|
||||
pub async fn list_accounts(
|
||||
@@ -56,7 +57,7 @@ pub async fn list_accounts(
|
||||
FROM accounts {} ORDER BY created_at DESC LIMIT ${} OFFSET ${}",
|
||||
where_sql, limit_idx, offset_idx
|
||||
);
|
||||
let mut data_query = sqlx::query_as::<_, (String, String, String, String, String, String, bool, Option<String>, String)>(&data_sql);
|
||||
let mut data_query = sqlx::query_as::<_, AccountRow>(&data_sql);
|
||||
for p in ¶ms {
|
||||
data_query = data_query.bind(p);
|
||||
}
|
||||
@@ -64,11 +65,11 @@ pub async fn list_accounts(
|
||||
|
||||
let items: Vec<serde_json::Value> = rows
|
||||
.into_iter()
|
||||
.map(|(id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at)| {
|
||||
.map(|r| {
|
||||
serde_json::json!({
|
||||
"id": id, "username": username, "email": email, "display_name": display_name,
|
||||
"role": role, "status": status, "totp_enabled": totp_enabled,
|
||||
"last_login_at": last_login_at, "created_at": created_at,
|
||||
"id": r.id, "username": r.username, "email": r.email, "display_name": r.display_name,
|
||||
"role": r.role, "status": r.status, "totp_enabled": r.totp_enabled,
|
||||
"last_login_at": r.last_login_at, "created_at": r.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -77,7 +78,7 @@ pub async fn list_accounts(
|
||||
}
|
||||
|
||||
pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult<serde_json::Value> {
|
||||
let row: Option<(String, String, String, String, String, String, bool, Option<String>, String)> =
|
||||
let row: Option<AccountRow> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at
|
||||
FROM accounts WHERE id = $1"
|
||||
@@ -86,13 +87,12 @@ pub async fn get_account(db: &PgPool, account_id: &str) -> SaasResult<serde_json
|
||||
.fetch_optional(db)
|
||||
.await?;
|
||||
|
||||
let (id, username, email, display_name, role, status, totp_enabled, last_login_at, created_at) =
|
||||
row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?;
|
||||
let r = row.ok_or_else(|| SaasError::NotFound(format!("账号 {} 不存在", account_id)))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"id": id, "username": username, "email": email, "display_name": display_name,
|
||||
"role": role, "status": status, "totp_enabled": totp_enabled,
|
||||
"last_login_at": last_login_at, "created_at": created_at,
|
||||
"id": r.id, "username": r.username, "email": r.email, "display_name": r.display_name,
|
||||
"role": r.role, "status": r.status, "totp_enabled": r.totp_enabled,
|
||||
"last_login_at": r.last_login_at, "created_at": r.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ pub async fn list_api_tokens(
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let rows: Vec<(String, String, String, String, Option<String>, Option<String>, String)> =
|
||||
let rows: Vec<ApiTokenRow> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, name, token_prefix, permissions, last_used_at, expires_at, created_at
|
||||
FROM api_tokens WHERE account_id = $1 AND revoked_at IS NULL ORDER BY created_at DESC LIMIT $2 OFFSET $3"
|
||||
@@ -223,9 +223,9 @@ pub async fn list_api_tokens(
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
let items = rows.into_iter().map(|(id, name, token_prefix, perms, last_used, expires, created)| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&perms).unwrap_or_default();
|
||||
TokenInfo { id, name, token_prefix, permissions, last_used_at: last_used, expires_at: expires, created_at: created, token: None, }
|
||||
let items = rows.into_iter().map(|r| {
|
||||
let permissions: Vec<String> = serde_json::from_str(&r.permissions).unwrap_or_default();
|
||||
TokenInfo { id: r.id, name: r.name, token_prefix: r.token_prefix, permissions, last_used_at: r.last_used_at, expires_at: r.expires_at, created_at: r.created_at, token: None, }
|
||||
}).collect();
|
||||
|
||||
Ok(PaginatedResponse { items, total: total.0, page: p, page_size: ps })
|
||||
@@ -246,7 +246,7 @@ pub async fn list_devices(
|
||||
.fetch_one(db)
|
||||
.await?;
|
||||
|
||||
let rows: Vec<(String, String, Option<String>, Option<String>, Option<String>, String, String)> =
|
||||
let rows: Vec<DeviceRow> =
|
||||
sqlx::query_as(
|
||||
"SELECT id, device_id, device_name, platform, app_version, last_seen_at, created_at
|
||||
FROM devices WHERE account_id = $1 ORDER BY last_seen_at DESC LIMIT $2 OFFSET $3"
|
||||
@@ -259,9 +259,9 @@ pub async fn list_devices(
|
||||
|
||||
let items: Vec<serde_json::Value> = rows.into_iter().map(|r| {
|
||||
serde_json::json!({
|
||||
"id": r.0, "device_id": r.1,
|
||||
"device_name": r.2, "platform": r.3, "app_version": r.4,
|
||||
"last_seen_at": r.5, "created_at": r.6,
|
||||
"id": r.id, "device_id": r.device_id,
|
||||
"device_name": r.device_name, "platform": r.platform, "app_version": r.app_version,
|
||||
"last_seen_at": r.last_seen_at, "created_at": r.created_at,
|
||||
})
|
||||
}).collect();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user