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<{

View File

@@ -219,4 +219,51 @@ describe('useUsageData', () => {
expect(providerStats.value.map(item => item.provider)).toEqual(['OpenAI'])
expect(availableProviders.value).toEqual(['OpenAI'])
})
it('keeps previous admin provider stats when a background refresh fails', async () => {
const isAdminPage = ref(true)
const { loadStats, providerStats, availableProviders } = useUsageData({ isAdminPage })
const dateRange = { preset: 'last7days', tz_offset_minutes: 0 }
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 3,
total_tokens: 300,
total_cost: 1,
avg_response_time: 0,
})
getUsageByProviderMock.mockResolvedValueOnce([
{
provider: 'OpenAI',
request_count: 3,
total_tokens: 300,
total_cost: 1.23,
actual_cost: 1.5,
avg_response_time_ms: 1250,
success_rate: 100,
},
])
await loadStats(dateRange)
getUsageStatsMock.mockResolvedValueOnce({
total_requests: 4,
total_tokens: 400,
total_cost: 2,
avg_response_time: 0,
})
getUsageByProviderMock.mockRejectedValueOnce({
response: { status: 500 },
message: 'provider aggregation failed',
})
const hadFailure = await loadStats(dateRange, { preserveOnFailure: true })
expect(hadFailure).toBe(true)
expect(providerStats.value).toHaveLength(1)
expect(providerStats.value[0]).toMatchObject({
provider: 'OpenAI',
requests: 3,
})
expect(availableProviders.value).toEqual(['OpenAI'])
})
})

View File

@@ -18,6 +18,11 @@ export interface UseUsageDataOptions {
isAdminPage: Ref<boolean>
}
export interface LoadStatsOptions {
force?: boolean
preserveOnFailure?: boolean
}
export interface PaginationParams {
page: number
pageSize: number
@@ -76,7 +81,7 @@ export function useUsageData(options: UseUsageDataOptions) {
})
// 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams): Promise<boolean> {
async function loadStats(dateRange?: DateRangeParams, options: LoadStatsOptions = {}): Promise<boolean> {
const requestId = ++loadStatsRequestId
isLoadingStats.value = true
currentDateRange.value = dateRange
@@ -84,14 +89,17 @@ export function useUsageData(options: UseUsageDataOptions) {
try {
if (isAdminPage.value) {
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
stats.value = createDefaultStats()
modelStats.value = []
providerStats.value = []
apiFormatStats.value = []
availableModels.value = []
availableProviders.value = []
if (!options.preserveOnFailure) {
stats.value = createDefaultStats()
modelStats.value = []
providerStats.value = []
apiFormatStats.value = []
availableModels.value = []
availableProviders.value = []
}
let hadFailure = false
const requestOptions = options.force ? { skipCache: true } : undefined
const markFailure = (error: unknown) => {
hadFailure = true
if (getErrorStatus(error) !== 403) {
@@ -100,7 +108,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
try {
const statsData = await usageApi.getUsageStats(dateRange)
const statsData = await usageApi.getUsageStats(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) {
return true
}
@@ -127,7 +135,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
try {
const modelData = await usageApi.getUsageByModel(dateRange)
const modelData = await usageApi.getUsageByModel(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) {
return true
}
@@ -158,7 +166,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
try {
const providerData = await usageApi.getUsageByProvider(dateRange)
const providerData = await usageApi.getUsageByProvider(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) {
return true
}
@@ -191,7 +199,7 @@ export function useUsageData(options: UseUsageDataOptions) {
}
try {
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange)
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) {
return true
}
@@ -312,9 +320,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error)
}
stats.value = createDefaultStats()
modelStats.value = []
if (!isAdminPage.value) {
stats.value = createDefaultStats()
modelStats.value = []
// 用户页的 records 依赖 stats 一起加载;管理员页的 records 是独立分页,不应被统计失败清空。
currentRecords.value = []
totalRecords.value = 0

View File

@@ -111,7 +111,7 @@
@update:current-page="handlePageChange"
@update:page-size="handlePageSizeChange"
@update:auto-refresh="handleAutoRefreshChange"
@refresh="refreshData"
@refresh="handleManualRefresh"
@prefetch-detail="prefetchRequestDetail"
@show-detail="showRequestDetail"
/>
@@ -289,7 +289,7 @@ async function loadAdminUsers() {
}
}
async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
async function refreshAdminAnalytics(options: { force?: boolean; preserveOnFailure?: boolean } = {}) {
if (!isAdminPage.value) return
if (!options.force && !isPageVisible.value) return
@@ -300,13 +300,19 @@ async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
if (!options.force && adminAnalyticsRefreshInFlight) {
return adminAnalyticsRefreshInFlight
}
if (!options.force) {
lastAdminAnalyticsRefreshAt = now
}
const refreshGeneration = ++adminAnalyticsRefreshGeneration
const refreshPromise = (async () => {
let hasSuccessfulRefresh = false
try {
const hadFailure = await loadStats(timeRange.value)
const hadFailure = await loadStats(getCurrentStatsFilters(), {
force: options.force,
preserveOnFailure: options.preserveOnFailure,
})
if (refreshGeneration !== adminAnalyticsRefreshGeneration) {
return
}
@@ -337,6 +343,21 @@ async function refreshAdminAnalytics(options: { force?: boolean } = {}) {
}
}
function getCurrentStatsFilters() {
const filters = getCurrentFilters()
return {
...timeRange.value,
user_id: filters.user_id,
model: filters.model,
provider: filters.provider,
}
}
async function refreshAdminAnalyticsForSelectionChange() {
if (!isAdminPage.value) return
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
}
// 用户页面需要前端筛选
const filteredRecords = computed(() => {
if (!isAdminPage.value) {
@@ -740,7 +761,7 @@ onMounted(async () => {
timeRange.value
)
void (async () => {
await refreshAdminAnalytics({ force: true })
await refreshAdminAnalytics({ force: true, preserveOnFailure: false })
await loadHeatmapData()
await loadAdminUsers()
})()
@@ -772,7 +793,7 @@ async function handleTimeRangeChange(value: DateRangeParams) {
currentPage.value = 1 // 重置到第一页
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalytics({ force: true })
await refreshAdminAnalyticsForSelectionChange()
return
}
await loadStats(timeRange.value)
@@ -829,6 +850,7 @@ async function handleFilterUserChange(value: string) {
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalyticsForSelectionChange()
}
}
@@ -838,6 +860,7 @@ async function handleFilterModelChange(value: string) {
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalyticsForSelectionChange()
}
}
@@ -847,6 +870,7 @@ async function handleFilterProviderChange(value: string) {
if (isAdminPage.value) {
await loadRecords({ page: 1, pageSize: pageSize.value }, getCurrentFilters(), timeRange.value)
await refreshAdminAnalyticsForSelectionChange()
}
}
@@ -889,8 +913,6 @@ async function refreshData() {
getCurrentFilters(),
timeRange.value
)
// 热力图反映长期活跃分布,不跟随自动刷新链路一起重载。
void refreshAdminAnalytics()
return
}
@@ -905,6 +927,11 @@ async function refreshData() {
}
}
async function handleManualRefresh() {
if (!isPageVisible.value) return
await refreshData()
}
// 显示请求详情
function showRequestDetail(id: string) {
if (!isAdminPage.value) return