refactor(workspace): enforce layered crate boundaries

This commit is contained in:
elky
2026-07-15 23:47:19 +08:00
parent a728c090a9
commit 8616fe6ee2
969 changed files with 40187 additions and 27240 deletions
@@ -0,0 +1,85 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { getMock, cachedRequestMock, buildCacheKeyMock } = vi.hoisted(() => ({
getMock: vi.fn(),
cachedRequestMock: vi.fn(async (_key: string, fetcher: () => Promise<unknown>) => fetcher()),
buildCacheKeyMock: vi.fn((prefix: string) => prefix),
}))
vi.mock('@/api/client', () => ({
default: {
get: getMock,
},
}))
vi.mock('@/utils/cache', () => ({
cache: {
clear: vi.fn(),
delete: vi.fn(),
},
cachedRequest: cachedRequestMock,
buildCacheKey: buildCacheKeyMock,
}))
import { adminApi } from '@/api/admin'
describe('adminApi analytics cache options', () => {
const params = {
start_date: '2026-07-01',
end_date: '2026-07-15',
preset: 'custom',
timezone: 'Asia/Shanghai',
tz_offset_minutes: 480,
}
beforeEach(() => {
getMock.mockReset()
getMock.mockResolvedValue({ data: {} })
cachedRequestMock.mockClear()
buildCacheKeyMock.mockClear()
})
it('keeps the existing 20-second cache TTL by default', async () => {
await adminApi.getTimeSeries(params)
await adminApi.getPercentiles(params)
await adminApi.getProviderPerformance(params)
await adminApi.getErrorDistribution(params)
for (let call = 1; call <= 4; call += 1) {
expect(cachedRequestMock).toHaveBeenNthCalledWith(
call,
expect.any(String),
expect.any(Function),
20 * 1000
)
}
})
it('uses a zero TTL when an analytics request skips the cache', async () => {
const options = { skipCache: true }
const providerParams = { ...params, include_timeline: false }
await adminApi.getTimeSeries(params, options)
await adminApi.getPercentiles(params, options)
await adminApi.getProviderPerformance(providerParams, options)
await adminApi.getErrorDistribution(params, options)
for (let call = 1; call <= 4; call += 1) {
expect(cachedRequestMock).toHaveBeenNthCalledWith(
call,
expect.any(String),
expect.any(Function),
0
)
}
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/stats/time-series', { params })
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/stats/performance/percentiles', {
params,
})
expect(getMock).toHaveBeenNthCalledWith(3, '/api/admin/stats/performance/providers', {
params: providerParams,
})
expect(getMock).toHaveBeenNthCalledWith(4, '/api/admin/stats/errors/distribution', { params })
})
})
+37
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios'
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
import { cache, cachedRequest } from '@/utils/cache'
type TestableApiClient = typeof apiClient & {
client: AxiosInstance
@@ -34,6 +35,42 @@ describe('apiClient auth state change event', () => {
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
})
it('clears cached API data whenever the authentication identity changes', () => {
apiClient.setToken('first-token')
cache.set('dashboard', { owner: 'first-user' }, 30_000)
apiClient.setToken('second-token')
expect(cache.get('dashboard')).toBeNull()
})
it('does not share or restore an in-flight cached response across token changes', async () => {
let resolveFirst!: (value: string) => void
let resolveSecond!: (value: string) => void
const firstResponse = new Promise<string>((resolve) => {
resolveFirst = resolve
})
const secondResponse = new Promise<string>((resolve) => {
resolveSecond = resolve
})
const secondFetcher = vi.fn(() => secondResponse)
apiClient.setToken('first-token')
const firstRequest = cachedRequest('dashboard', () => firstResponse, 30_000)
apiClient.setToken('second-token')
const secondRequest = cachedRequest('dashboard', secondFetcher, 30_000)
expect(secondFetcher).toHaveBeenCalledTimes(1)
resolveFirst('first-user-data')
await expect(firstRequest).resolves.toBe('first-user-data')
expect(cache.get('dashboard')).toBeNull()
resolveSecond('second-user-data')
await expect(secondRequest).resolves.toBe('second-user-data')
expect(cache.get('dashboard')).toBe('second-user-data')
})
it('sends auth refresh without a request body', async () => {
const rawClient = apiClient as TestableApiClient
const previousAdapter = rawClient.client.defaults.adapter
+88 -49
View File
@@ -1,11 +1,19 @@
import apiClient from './client'
import type { ModelTestCapabilities } from './endpoints/types'
import axios, { type AxiosRequestConfig } from 'axios'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
import { cache, cachedRequest, buildCacheKey } from '@/utils/cache'
import type { BillingSummary } from './auth'
import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli } from './me'
const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000
const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs'
export interface AdminSystemConfigItem {
key: string
value: unknown
description?: string
is_set?: boolean
}
export interface SystemDataImportOptions {
onUploadProgress?: AxiosRequestConfig['onUploadProgress']
@@ -736,6 +744,10 @@ export interface QuotaUsageResponse {
providers: QuotaUsageProvider[]
}
export interface AdminAnalyticsRequestOptions {
skipCache?: boolean
}
export interface PercentileItem {
date: string
p50_response_time_ms?: number | null
@@ -947,9 +959,17 @@ export const adminApi = {
// 系统配置相关
// 获取所有系统配置
async getAllSystemConfigs(): Promise<Array<{ key: string; value: unknown; description?: string }>> {
const response = await apiClient.get<Array<{ key: string; value: unknown; description?: string }>>('/api/admin/system/configs')
return response.data
async getAllSystemConfigs(
options: { cacheTtlMs?: number } = {},
): Promise<AdminSystemConfigItem[]> {
return cachedRequest(
ALL_SYSTEM_CONFIGS_CACHE_KEY,
async () => {
const response = await apiClient.get<AdminSystemConfigItem[]>('/api/admin/system/configs')
return response.data
},
options.cacheTtlMs ?? 0,
)
},
// 获取特定系统配置
@@ -983,6 +1003,8 @@ export const adminApi = {
{ value, description },
requestConfig,
)
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
cache.delete(buildCacheKey('admin:system:config', { key }))
return response.data
},
@@ -991,6 +1013,8 @@ export const adminApi = {
const response = await apiClient.delete<{ message: string }>(
`/api/admin/system/configs/${key}`
)
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
cache.delete(buildCacheKey('admin:system:config', { key }))
return response.data
},
@@ -1021,6 +1045,7 @@ export const adminApi = {
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
cache.clear()
return response.data
},
@@ -1053,6 +1078,7 @@ export const adminApi = {
data,
{ timeout: SYSTEM_DATA_IMPORT_TIMEOUT_MS, ...options }
)
cache.clear()
return response.data
},
@@ -1380,13 +1406,16 @@ export const adminApi = {
)
},
async getPercentiles(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<PercentileItem[]> {
async getPercentiles(
params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
},
options?: AdminAnalyticsRequestOptions
): Promise<PercentileItem[]> {
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
return cachedRequest(
cacheKey,
@@ -1396,26 +1425,30 @@ export const adminApi = {
})
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
async getProviderPerformance(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
granularity?: 'day' | 'hour'
limit?: number
provider_id?: string
model?: string
api_format?: string
endpoint_kind?: string
is_stream?: boolean
has_format_conversion?: boolean
slow_threshold_ms?: number
}): Promise<ProviderPerformanceResponse> {
async getProviderPerformance(
params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
granularity?: 'day' | 'hour'
include_timeline?: boolean
limit?: number
provider_id?: string
model?: string
api_format?: string
endpoint_kind?: string
is_stream?: boolean
has_format_conversion?: boolean
slow_threshold_ms?: number
},
options?: AdminAnalyticsRequestOptions
): Promise<ProviderPerformanceResponse> {
const cacheKey = buildCacheKey('admin:stats:performance:providers', params)
return cachedRequest(
cacheKey,
@@ -1425,17 +1458,20 @@ export const adminApi = {
})
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
async getErrorDistribution(params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> {
async getErrorDistribution(
params?: {
start_date?: string
end_date?: string
preset?: string
timezone?: string
tz_offset_minutes?: number
},
options?: AdminAnalyticsRequestOptions
): Promise<ErrorDistributionResponse> {
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
return cachedRequest(
cacheKey,
@@ -1445,7 +1481,7 @@ export const adminApi = {
})
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
@@ -1535,17 +1571,20 @@ export const adminApi = {
return response.data
},
async getTimeSeries(params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
}): Promise<Array<Record<string, unknown>>> {
async getTimeSeries(
params?: {
start_date?: string
end_date?: string
preset?: string
granularity?: 'hour' | 'day' | 'week' | 'month'
timezone?: string
tz_offset_minutes?: number
user_id?: string
model?: string
provider_name?: string
},
options?: AdminAnalyticsRequestOptions
): Promise<Array<Record<string, unknown>>> {
const cacheKey = buildCacheKey('admin:stats:time-series', params)
return cachedRequest(
cacheKey,
@@ -1553,7 +1592,7 @@ export const adminApi = {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
return response.data
},
20 * 1000
options?.skipCache ? 0 : 20 * 1000
)
},
+30 -10
View File
@@ -2,15 +2,30 @@ import axios, { getAdapter } from 'axios'
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig, AxiosAdapter } from 'axios'
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
import { isDemoMode } from '@/config/demo'
import { handleMockRequest, setMockUserToken } from '@/mocks'
import { getClientDeviceId } from '@/utils/deviceId'
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
import { log } from '@/utils/logger'
import { cache } from '@/utils/cache'
// 在开发环境下使用代理,生产环境使用环境变量
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
type MockRuntime = typeof import('@/mocks')
let mockRuntimePromise: Promise<MockRuntime> | null = null
let currentMockUserToken: string | null = null
function loadMockRuntime(): Promise<MockRuntime> {
if (!mockRuntimePromise) {
mockRuntimePromise = import('@/mocks').catch((error) => {
mockRuntimePromise = null
throw error
})
}
return mockRuntimePromise
}
/**
* 判断请求是否为公共端点
*/
@@ -53,7 +68,9 @@ function createDemoAdapter(defaultAdapter: AxiosAdapter) {
return async (config: InternalAxiosRequestConfig): Promise<AxiosResponse> => {
if (isDemoMode()) {
try {
const mockResponse = await handleMockRequest({
const mockRuntime = await loadMockRuntime()
mockRuntime.setMockUserToken(currentMockUserToken)
const mockResponse = await mockRuntime.handleMockRequest({
method: config.method?.toUpperCase(),
url: config.url,
data: config.data,
@@ -290,30 +307,33 @@ class ApiClient {
}
private syncTokenState(token: string | null): void {
this.token = token
if (isDemoMode()) {
setMockUserToken(token)
if (this.token !== token) {
cache.clear()
}
this.token = token
currentMockUserToken = token
}
setToken(token: string): void {
if (this.token === token) {
cache.clear()
}
this.syncTokenState(token)
localStorage.setItem('access_token', token)
}
getToken(): string | null {
if (!this.token) {
this.token = localStorage.getItem('access_token')
// 页面刷新时,从 localStorage 恢复 token 到 mock handler
if (this.token && isDemoMode()) {
setMockUserToken(this.token)
}
this.syncTokenState(localStorage.getItem('access_token'))
}
return this.token
}
clearAuth(): void {
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
if (hadAuth && this.token === null) {
cache.clear()
}
this.syncTokenState(null)
localStorage.removeItem('access_token')
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
+2 -2
View File
@@ -413,7 +413,7 @@ export const dashboardApi = {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
10 * 1000
30 * 1000
)
},
@@ -476,7 +476,7 @@ export const dashboardApi = {
})
return response.data
},
20 * 1000
60 * 1000
)
},
+28 -15
View File
@@ -1,6 +1,8 @@
import apiClient from './client'
import { buildCacheKey, cache, cachedRequest } from '@/utils/cache'
const MODULE_MANAGEMENT_ORDER_CONFIG_KEY = 'module_management.extension_order'
const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs'
export interface ModuleStatus {
name: string
@@ -160,19 +162,30 @@ function normalizePlaceholderPrefix(value: unknown): string {
: CHAT_PII_REDACTION_DEFAULT_CONFIG.placeholder_prefix
}
async function getSystemConfigValue(key: string): Promise<unknown> {
const response = await apiClient.get<{ key: string; value: unknown }>(`/api/admin/system/configs/${key}`)
return response.data.value
}
async function updateSystemConfigValue(key: string, value: unknown, description: string) {
const response = await apiClient.put<{ key: string; value: unknown; description?: string }>(
`/api/admin/system/configs/${key}`,
{ value, description },
)
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
cache.delete(buildCacheKey('admin:system:config', { key }))
return response.data.value
}
async function getAllSystemConfigValues(): Promise<Map<string, unknown>> {
const configs = await cachedRequest(
ALL_SYSTEM_CONFIGS_CACHE_KEY,
async () => {
const response = await apiClient.get<Array<{ key: string; value: unknown }>>(
'/api/admin/system/configs'
)
return response.data
},
30_000,
)
return new Map(configs.map(config => [config.key, config.value]))
}
export const modulesApi = {
/**
* 获取所有模块状态(管理员)
@@ -202,6 +215,7 @@ export const modulesApi = {
`/api/admin/modules/status/${moduleName}/enabled`,
{ enabled }
)
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
return response.data
},
@@ -227,22 +241,21 @@ export const modulesApi = {
description: '模块管理扩展模块展示顺序',
},
)
cache.delete(ALL_SYSTEM_CONFIGS_CACHE_KEY)
cache.delete(buildCacheKey('admin:system:config', {
key: MODULE_MANAGEMENT_ORDER_CONFIG_KEY,
}))
return normalizeModuleManagementOrder(response.data.value)
},
async getChatPiiRedactionConfig(): Promise<ChatPiiRedactionConfig> {
const [enabled, rules, cacheTtlSeconds, placeholderPrefix] = await Promise.all([
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.rules),
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
getSystemConfigValue(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix),
])
const configsByKey = await getAllSystemConfigValues()
return normalizeChatPiiRedactionConfig({
enabled,
rules,
cache_ttl_seconds: cacheTtlSeconds,
placeholder_prefix: placeholderPrefix,
enabled: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.enabled),
rules: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.rules),
cache_ttl_seconds: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.cache_ttl_seconds),
placeholder_prefix: configsByKey.get(CHAT_PII_REDACTION_CONFIG_KEYS.placeholder_prefix),
})
},
+2
View File
@@ -44,6 +44,8 @@ export interface UsageStats {
total_cost: number
total_actual_cost?: number
avg_response_time: number
error_count?: number
error_rate?: number
today?: {
requests: number
tokens: number