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:
iven
2026-03-29 19:21:48 +08:00
parent 5fdf96c3f5
commit 8b9d506893
64 changed files with 3348 additions and 520 deletions

View File

@@ -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 &params {
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();