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

@@ -5,6 +5,7 @@ use std::net::SocketAddr;
use secrecy::ExposeSecret;
use crate::state::AppState;
use crate::error::{SaasError, SaasResult};
use crate::models::{AccountAuthRow, AccountLoginRow};
use super::{
jwt::{create_token, create_refresh_token, verify_token, verify_token_skip_expiry},
password::{hash_password, verify_password},
@@ -79,7 +80,7 @@ pub async fn register(
log_operation(&state.db, &account_id, "account.create", "account", &account_id, None, Some(&client_ip)).await?;
// 注册成功后自动签发 JWT + Refresh Token
let permissions = get_role_permissions(&state.db, &role).await?;
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &role).await?;
let config = state.config.read().await;
let token = create_token(
&account_id, &role, permissions.clone(),
@@ -120,46 +121,33 @@ pub async fn login(
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(req): Json<LoginRequest>,
) -> SaasResult<Json<LoginResponse>> {
let row: Option<(String, String, String, String, String, String, bool, String)> =
// 一次查询获取用户信息 + password_hash + totp_secret合并原来的 3 次查询)
let row: Option<AccountLoginRow> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
"SELECT id, username, email, display_name, role, status, totp_enabled,
password_hash, totp_secret, created_at
FROM accounts WHERE username = $1 OR email = $1"
)
.bind(&req.username)
.fetch_optional(&state.db)
.await?;
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
row.ok_or_else(|| SaasError::AuthError("用户名或密码错误".into()))?;
let r = row.ok_or_else(|| SaasError::AuthError("用户名或密码错误".into()))?;
if status != "active" {
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", status)));
if r.status != "active" {
return Err(SaasError::Forbidden(format!("账号已{},请联系管理员", r.status)));
}
let (password_hash,): (String,) = sqlx::query_as(
"SELECT password_hash FROM accounts WHERE id = $1"
)
.bind(&id)
.fetch_one(&state.db)
.await?;
if !verify_password(&req.password, &password_hash)? {
if !verify_password(&req.password, &r.password_hash)? {
return Err(SaasError::AuthError("用户名或密码错误".into()));
}
// TOTP 验证: 如果用户已启用 2FA必须提供有效 TOTP 码
if totp_enabled {
if r.totp_enabled {
let code = req.totp_code.as_deref()
.ok_or_else(|| SaasError::Totp("此账号已启用双因素认证,请提供 TOTP 码".into()))?;
let (totp_secret,): (Option<String>,) = sqlx::query_as(
"SELECT totp_secret FROM accounts WHERE id = $1"
)
.bind(&id)
.fetch_one(&state.db)
.await?;
let secret = totp_secret.ok_or_else(|| {
let secret = r.totp_secret.clone().ok_or_else(|| {
SaasError::Internal("TOTP 已启用但密钥丢失,请联系管理员".into())
})?;
@@ -174,15 +162,15 @@ pub async fn login(
}
}
let permissions = get_role_permissions(&state.db, &role).await?;
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &r.role).await?;
let config = state.config.read().await;
let token = create_token(
&id, &role, permissions.clone(),
&r.id, &r.role, permissions.clone(),
state.jwt_secret.expose_secret(),
config.auth.jwt_expiration_hours,
)?;
let refresh_token = create_refresh_token(
&id, &role, permissions,
&r.id, &r.role, permissions,
state.jwt_secret.expose_secret(),
config.auth.refresh_token_hours,
)?;
@@ -190,13 +178,13 @@ pub async fn login(
let now = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET last_login_at = $1 WHERE id = $2")
.bind(&now).bind(&id)
.bind(&now).bind(&r.id)
.execute(&state.db).await?;
let client_ip = addr.ip().to_string();
log_operation(&state.db, &id, "account.login", "account", &id, None, Some(&client_ip)).await?;
log_operation(&state.db, &r.id, "account.login", "account", &r.id, None, Some(&client_ip)).await?;
store_refresh_token(
&state.db, &id, &refresh_token,
&state.db, &r.id, &refresh_token,
state.jwt_secret.expose_secret(), 168,
).await?;
@@ -204,7 +192,8 @@ pub async fn login(
token,
refresh_token,
account: AccountPublic {
id, username, email, display_name, role, status, totp_enabled, 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, created_at: r.created_at,
},
}))
}
@@ -260,7 +249,7 @@ pub async fn refresh(
.await?
.ok_or_else(|| SaasError::AuthError("账号不存在或已禁用".into()))?;
let permissions = get_role_permissions(&state.db, &role).await?;
let permissions = get_role_permissions(&state.db, &state.role_permissions_cache, &role).await?;
// 7. 创建新的 access token + refresh token
let config = state.config.read().await;
@@ -289,8 +278,8 @@ pub async fn refresh(
.bind(sha256_hex(&new_refresh)).bind(&refresh_expires).bind(&now)
.execute(&state.db).await?;
// 9. 清理过期/已使用的 refresh tokens (异步, 不阻塞)
cleanup_expired_refresh_tokens(&state.db).await?;
// 9. 清理过期/已使用的 refresh tokens 已迁移到 Scheduler 定期执行
// 不再在每次 refresh 时阻塞请求
Ok(Json(serde_json::json!({
"token": new_access,
@@ -303,7 +292,7 @@ pub async fn me(
State(state): State<AppState>,
axum::extract::Extension(ctx): axum::extract::Extension<AuthContext>,
) -> SaasResult<Json<AccountPublic>> {
let row: Option<(String, String, String, String, String, String, bool, String)> =
let row: Option<AccountAuthRow> =
sqlx::query_as(
"SELECT id, username, email, display_name, role, status, totp_enabled, created_at
FROM accounts WHERE id = $1"
@@ -312,11 +301,11 @@ pub async fn me(
.fetch_optional(&state.db)
.await?;
let (id, username, email, display_name, role, status, totp_enabled, created_at) =
row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?;
let r = row.ok_or_else(|| SaasError::NotFound("账号不存在".into()))?;
Ok(Json(AccountPublic {
id, username, email, display_name, role, status, totp_enabled, 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, created_at: r.created_at,
}))
}
@@ -359,7 +348,16 @@ pub async fn change_password(
Ok(Json(serde_json::json!({"ok": true, "message": "密码修改成功"})))
}
pub(crate) async fn get_role_permissions(db: &sqlx::PgPool, role: &str) -> SaasResult<Vec<String>> {
pub(crate) async fn get_role_permissions(
db: &sqlx::PgPool,
cache: &dashmap::DashMap<String, Vec<String>>,
role: &str,
) -> SaasResult<Vec<String>> {
// Check cache first
if let Some(cached) = cache.get(role) {
return Ok(cached.clone());
}
let row: Option<(String,)> = sqlx::query_as(
"SELECT permissions FROM roles WHERE id = $1"
)
@@ -372,6 +370,7 @@ pub(crate) async fn get_role_permissions(db: &sqlx::PgPool, role: &str) -> SaasR
.0;
let permissions: Vec<String> = serde_json::from_str(&permissions_str)?;
cache.insert(role.to_string(), permissions.clone());
Ok(permissions)
}
@@ -438,6 +437,8 @@ async fn store_refresh_token(
}
/// 清理过期和已使用的 refresh tokens
/// 注意: 现已迁移到 Worker/Scheduler 定期执行,此函数保留作为备用
#[allow(dead_code)]
async fn cleanup_expired_refresh_tokens(db: &sqlx::PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();
// 删除过期超过 30 天的已使用 token (减少 DB 膨胀)

View File

@@ -58,7 +58,7 @@ async fn verify_api_token(state: &AppState, raw_token: &str, client_ip: Option<S
.ok_or(SaasError::Unauthorized)?;
// 合并 token 权限与角色权限(去重)
let role_permissions = handlers::get_role_permissions(&state.db, &role).await?;
let role_permissions = handlers::get_role_permissions(&state.db, &state.role_permissions_cache, &role).await?;
let token_permissions: Vec<String> = serde_json::from_str(&permissions_json).unwrap_or_default();
let mut permissions = role_permissions;
for p in token_permissions {