feat(usage): 管理员用量统计支持筛选刷新与失败保留旧数据

- 用量统计/聚合接口新增 skipCache 选项与 120s 超时,便于强制绕过缓存
- loadStats 增加 force/preserveOnFailure 选项,背景刷新失败时保留旧数据
- 管理员页面在用户/模型/Provider 筛选变化时强制刷新统计,并将筛选条件传入统计接口
- 手动刷新与自动刷新分离,自动刷新不再重载长期热力图相关聚合
This commit is contained in:
fawney19
2026-05-20 16:51:52 +08:00
parent 70f2882a43
commit 2881ff097a
5 changed files with 180 additions and 36 deletions

View File

@@ -114,4 +114,43 @@ describe('usageApi contract alignment', () => {
},
})
})
it('uses an extended timeout and cache bypass option for admin analytics', async () => {
getMock
.mockResolvedValueOnce({
data: {
total_requests: 7,
total_tokens: 99,
total_cost: 12.34,
avg_response_time: 456,
},
})
.mockResolvedValueOnce({
data: [{ provider: 'OpenAI', request_count: 7 }],
})
await usageApi.getUsageStats({ preset: 'last30days' }, { skipCache: true })
await usageApi.getUsageByProvider({ preset: 'last30days' }, { skipCache: true })
expect(cachedRequestMock).toHaveBeenNthCalledWith(
1,
expect.stringContaining(':fresh'),
expect.any(Function),
0
)
expect(cachedRequestMock).toHaveBeenNthCalledWith(
2,
expect.stringContaining(':fresh'),
expect.any(Function),
0
)
expect(getMock).toHaveBeenNthCalledWith(1, '/api/admin/usage/stats', {
params: { preset: 'last30days' },
timeout: 120000,
})
expect(getMock).toHaveBeenNthCalledWith(2, '/api/admin/usage/aggregation/stats', {
params: { group_by: 'provider', preset: 'last30days' },
timeout: 120000,
})
})
})

View File

@@ -4,6 +4,8 @@ import type { ActivityHeatmap } from '@/types/activity'
import type { ImageProgress } from './requestTrace'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000
const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000
const USAGE_ANALYTICS_REQUEST_TIMEOUT_MS = 120 * 1000
export interface UsageRecord {
id: string // UUID
@@ -117,6 +119,10 @@ export interface UsageFilters {
page_size?: number
}
export interface UsageRequestOptions {
skipCache?: boolean
}
type UsageListResponse = {
records?: unknown
pagination?: {
@@ -362,16 +368,19 @@ export const usageApi = {
return normalizeUsageRecordPage(response.data, pagination)
},
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
// 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', {
params: filters,
timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS,
})
return response.data
},
30000 // 30秒缓存
options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
)
},
@@ -382,36 +391,50 @@ export const usageApi = {
*/
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number }
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
params: { group_by: groupBy, ...filters }
params: { group_by: groupBy, ...filters },
timeout: USAGE_ANALYTICS_REQUEST_TIMEOUT_MS,
})
return response.data
},
30000 // 30秒缓存
options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
)
},
// Shorthand methods using getUsageAggregation
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters)
async getUsageByModel(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters, options)
},
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters)
async getUsageByUser(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters, options)
},
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
async getUsageByProvider(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters, options)
},
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
async getUsageByApiFormat(
filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters, options)
},
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{