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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user