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

@@ -41,7 +41,7 @@ import {
} from '@/components/ui/select'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate } from '@/lib/utils'
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import { useDebounce } from '@/hooks/use-debounce'
@@ -89,7 +89,7 @@ export default function AccountsPage() {
const accounts = data?.items ?? []
const total = data?.total ?? 0
const error = swrError?.message || mutationError
const error = getSwrErrorMessage(swrError) || mutationError
// 编辑 Dialog
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)

View File

@@ -35,7 +35,7 @@ import {
import { api } from '@/lib/api-client'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate } from '@/lib/utils'
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { TokenInfo } from '@/lib/types'
@@ -58,7 +58,7 @@ export default function ApiKeysPage() {
const tokens = data?.items ?? []
const total = data?.total ?? 0
const error = swrError?.message || mutationError
const error = getSwrErrorMessage(swrError) || mutationError
// 创建 Dialog
const [createOpen, setCreateOpen] = useState(false)

View File

@@ -74,7 +74,7 @@ export default function ConfigPage() {
function openEditDialog(config: ConfigItem) {
setEditTarget(config)
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
setEditValue(config.current_value ?? '')
}
async function handleSave() {
@@ -210,7 +210,7 @@ export default function ConfigPage() {
</div>
<div className="space-y-2">
<Label>
{editTarget?.default_value !== undefined && (
{editTarget?.default_value != null && (
<span className="text-xs text-muted-foreground ml-2">
(: {formatValue(editTarget.default_value)})
</span>
@@ -239,7 +239,7 @@ export default function ConfigPage() {
<Button
variant="outline"
onClick={() => {
if (editTarget?.default_value !== undefined) {
if (editTarget?.default_value != null) {
setEditValue(String(editTarget.default_value))
}
}}

View File

@@ -31,20 +31,6 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
/** 从后端获取权限列表(运行时同步) */
async function fetchRolePermissions(role: string): Promise<string[]> {
try {
const res = await fetch('/api/v1/roles/' + role)
if (res.ok) {
const data = await res.json()
return data.permissions || []
}
return ROLE_PERMISSIONS[role] ?? []
} catch {
return ROLE_PERMISSIONS[role] ?? []
}
}
/** 根据 role 获取权限列表 */
function getPermissionsForRole(role: string): string[] {
return ROLE_PERMISSIONS[role] ?? []

View File

@@ -29,7 +29,7 @@ import {
} from '@/components/ui/table'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, formatNumber } from '@/lib/utils'
import { formatDate, formatNumber, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { RelayTask } from '@/lib/types'
@@ -66,7 +66,7 @@ export default function RelayPage() {
const tasks = data?.items ?? []
const total = data?.total ?? 0
const error = swrError?.message
const error = getSwrErrorMessage(swrError)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))

View File

@@ -1,10 +1,11 @@
'use client'
import { useEffect, useState, type ReactNode } from 'react'
import { useEffect, useState, useCallback, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated, getAccount, clearAuth } from '@/lib/auth'
import { api } from '@/lib/api-client'
import { api, ApiRequestError } from '@/lib/api-client'
import type { AccountPublic } from '@/lib/types'
import { AlertTriangle, RefreshCw } from 'lucide-react'
interface AuthGuardProps {
children: ReactNode
@@ -15,28 +16,56 @@ export function AuthGuard({ children }: AuthGuardProps) {
const [authorized, setAuthorized] = useState(false)
const [account, setAccount] = useState<AccountPublic | null>(null)
const [verifying, setVerifying] = useState(true)
const [connectionError, setConnectionError] = useState(false)
useEffect(() => {
async function verifyAuth() {
if (!isAuthenticated()) {
router.replace('/login')
return
}
const verifyAuth = useCallback(async () => {
setVerifying(true)
setConnectionError(false)
try {
const serverAccount = await api.auth.me()
setAccount(serverAccount)
setAuthorized(true)
} catch {
clearAuth()
router.replace('/login')
} finally {
setVerifying(false)
}
if (!isAuthenticated()) {
setVerifying(false)
router.replace('/login')
return
}
// Already authorized? Skip re-verification on remount (e.g. Next.js RSC navigation)
// The token in localStorage is the source of truth; re-verify only on first mount
try {
const serverAccount = await api.auth.me()
setAccount(serverAccount)
setAuthorized(true)
} catch (err) {
// Ignore abort errors — caused by navigation/SWR cancelling in-flight requests
// Keep current authorized state intact
if (err instanceof DOMException && err.name === 'AbortError') {
// If already authorized, stay authorized; otherwise fall through to retry
if (!authorized) {
// First mount was aborted — use cached account from localStorage
const cachedAccount = getAccount()
if (cachedAccount) {
setAccount(cachedAccount)
setAuthorized(true)
}
}
return
}
// Only clear auth on actual authentication failures (401/403)
// Network errors, timeouts should NOT destroy the session
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
clearAuth()
router.replace('/login')
} else {
// Transient error — show retry UI, keep token in localStorage
setConnectionError(true)
}
} finally {
setVerifying(false)
}
}, [router, authorized])
useEffect(() => {
verifyAuth()
}, [router])
}, [verifyAuth])
if (verifying) {
return (
@@ -46,6 +75,23 @@ export function AuthGuard({ children }: AuthGuardProps) {
)
}
if (connectionError) {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background">
<AlertTriangle className="h-12 w-12 text-yellow-500" />
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-sm text-muted-foreground"></p>
<button
onClick={verifyAuth}
className="mt-2 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)
}
if (!authorized) {
return null
}

View File

@@ -94,12 +94,15 @@ async function request<T>(
path: string,
body?: unknown,
_isRetry = false,
externalSignal?: AbortSignal,
): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
// Merge external signal (e.g. from SWR) with a timeout signal
const signals: AbortSignal[] = [AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]
if (externalSignal) signals.push(externalSignal)
const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
try {
const token = getToken()
@@ -114,9 +117,8 @@ async function request<T>(
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
signal,
})
clearTimeout(timeoutId)
// 401: 尝试刷新 Token 后重试
if (res.status === 401 && !_isRetry) {
@@ -148,8 +150,6 @@ async function request<T>(
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeoutId)
// API 错误和外部取消的 AbortError 直接抛出,不重试
if (err instanceof ApiRequestError) throw err
if (err instanceof DOMException && err.name === 'AbortError') throw err

View File

@@ -12,21 +12,25 @@ type SwrKey =
| string
| [string, ...unknown[]]
async function resolveApiCall(key: SwrKey): Promise<unknown> {
/** SWR fetcher 支持 AbortSignal 传递 */
type SwrFetcherArgs = { signal?: AbortSignal } | null
async function resolveApiCall(key: SwrKey, args: SwrFetcherArgs): Promise<unknown> {
if (typeof key === 'string') {
// 简单字符串 key直接 fetch
return fetchGeneric(key)
return fetchGeneric(key, args?.signal)
}
const [path, ...args] = key
return callByPath(path, args)
const [path, ...rest] = key
return callByPath(path, rest, args?.signal)
}
async function fetchGeneric(path: string): Promise<unknown> {
async function fetchGeneric(path: string, signal?: AbortSignal): Promise<unknown> {
const res = await fetch(path, {
headers: {
'Content-Type': 'application/json',
},
signal,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'unknown', message: `请求失败 (${res.status})` }))
@@ -37,7 +41,8 @@ async function fetchGeneric(path: string): Promise<unknown> {
}
/** 根据 path 调用对应的 api 方法 */
async function callByPath(path: string, args: unknown[]): Promise<unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function callByPath(path: string, callArgs: unknown[], signal?: AbortSignal): Promise<unknown> {
const parts = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let target: any = api
@@ -45,11 +50,21 @@ async function callByPath(path: string, args: unknown[]): Promise<unknown> {
target = target[part]
if (!target) throw new Error(`API method not found: ${path}`)
}
return target(...args)
// Append signal as last argument if the target is the request function
// For api.xxx() calls that ultimately use request(), we pass signal through
// The simplest approach: pass signal as part of an options bag
return target(...callArgs, signal ? { signal } : undefined)
}
export const swrFetcher = <T = unknown>(key: SwrKey): Promise<T> =>
resolveApiCall(key) as Promise<T>
/**
* SWR fetcher — 接受 SWR 自动传入的 AbortSignal
*
* 用法: useSWR(key, swrFetcher)
* SWR 会自动在组件卸载或 key 变化时 abort 请求
*/
export function swrFetcher<T = unknown>(key: SwrKey, args: SwrFetcherArgs): Promise<T> {
return resolveApiCall(key, args) as Promise<T>
}
/** 创建 SWR key helper — 类型安全 */
export function createKey<TMethod extends string>(

View File

@@ -3,6 +3,13 @@
import { SWRConfig } from 'swr'
import type { ReactNode } from 'react'
/** 判断是否为请求被中断(页面导航等场景) */
function isAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === 'AbortError') return true
if (err instanceof Error && err.message?.includes('aborted')) return true
return false
}
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
@@ -12,12 +19,17 @@ export function SWRProvider({ children }: { children: ReactNode }) {
errorRetryCount: 2,
errorRetryInterval: 3000,
shouldRetryOnError: (err: unknown) => {
if (isAbortError(err)) return false
if (err && typeof err === 'object' && 'status' in err) {
const status = (err as { status: number }).status
return status !== 401 && status !== 403
}
return true
},
onError: (err: unknown) => {
// 中断错误静默忽略,不展示给用户
if (isAbortError(err)) return
},
}}
>
{children}

View File

@@ -11,6 +11,7 @@ export interface AccountPublic {
role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
@@ -24,6 +25,7 @@ export interface LoginRequest {
/** 登录响应 */
export interface LoginResponse {
token: string
refresh_token: string
account: AccountPublic
}
@@ -50,10 +52,10 @@ export interface Provider {
display_name: string
api_key?: string
base_url: string
api_protocol: 'openai' | 'anthropic'
api_protocol: string
enabled: boolean
rate_limit_rpm?: number
rate_limit_tpm?: number
rate_limit_rpm: number | null
rate_limit_tpm: number | null
created_at: string
updated_at: string
}
@@ -98,15 +100,16 @@ export interface RelayTask {
account_id: string
provider_id: string
model_id: string
status: 'queued' | 'processing' | 'completed' | 'failed'
status: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message?: string
queued_at?: string
started_at?: string
completed_at?: string
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
@@ -131,23 +134,25 @@ export interface ConfigItem {
id: string
category: string
key_path: string
value_type: 'string' | 'number' | 'boolean'
current_value?: string | number | boolean
default_value?: string | number | boolean
source: 'default' | 'env' | 'db'
description?: string
value_type: string
current_value: string | null
default_value: string | null
source: string
description: string | null
requires_restart: boolean
created_at: string
updated_at: string
}
/** 操作日志 */
export interface OperationLog {
id: string
account_id: string
id: number
account_id: string | null
action: string
target_type: string
target_id: string
details?: string
ip_address?: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}

View File

@@ -32,3 +32,14 @@ export function maskApiKey(key?: string): string {
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/** 从 SWR error 中提取用户可见消息,过滤 abort 错误 */
export function getSwrErrorMessage(err: unknown): string | undefined {
if (!err) return undefined
if (err instanceof DOMException && err.name === 'AbortError') return undefined
if (err instanceof Error) {
if (err.name === 'AbortError' || err.message?.includes('aborted')) return undefined
return err.message
}
return String(err)
}