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' import type { ImageProgress } from './requestTrace'
const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000 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 { export interface UsageRecord {
id: string // UUID id: string // UUID
@@ -117,6 +119,10 @@ export interface UsageFilters {
page_size?: number page_size?: number
} }
export interface UsageRequestOptions {
skipCache?: boolean
}
type UsageListResponse = { type UsageListResponse = {
records?: unknown records?: unknown
pagination?: { pagination?: {
@@ -362,16 +368,19 @@ export const usageApi = {
return normalizeUsageRecordPage(response.data, pagination) return normalizeUsageRecordPage(response.data, pagination)
}, },
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> { async getUsageStats(filters?: UsageFilters, options?: UsageRequestOptions): Promise<UsageStats> {
// 为统计数据添加30秒缓存 // 为统计数据添加30秒缓存
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}` const cacheKey = `usage-stats-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
return cachedRequest( return cachedRequest(
cacheKey, cacheKey,
async () => { 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 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[]>( async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
groupBy: 'model' | 'user' | 'provider' | 'api_format', groupBy: 'model' | 'user' | 'provider' | 'api_format',
filters?: UsageFilters & { limit?: number } filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<T> { ): Promise<T> {
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}` const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}${options?.skipCache ? ':fresh' : ''}`
return cachedRequest( return cachedRequest(
cacheKey, cacheKey,
async () => { async () => {
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', { 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 return response.data
}, },
30000 // 30秒缓存 options?.skipCache ? 0 : USAGE_ANALYTICS_CACHE_TTL_MS
) )
}, },
// Shorthand methods using getUsageAggregation // Shorthand methods using getUsageAggregation
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> { async getUsageByModel(
return this.getUsageAggregation<UsageByModel[]>('model', filters) filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByModel[]> {
return this.getUsageAggregation<UsageByModel[]>('model', filters, options)
}, },
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> { async getUsageByUser(
return this.getUsageAggregation<UsageByUser[]>('user', filters) filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByUser[]> {
return this.getUsageAggregation<UsageByUser[]>('user', filters, options)
}, },
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> { async getUsageByProvider(
return this.getUsageAggregation<UsageByProvider[]>('provider', filters) filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByProvider[]> {
return this.getUsageAggregation<UsageByProvider[]>('provider', filters, options)
}, },
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> { async getUsageByApiFormat(
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters) filters?: UsageFilters & { limit?: number },
options?: UsageRequestOptions
): Promise<UsageByApiFormat[]> {
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters, options)
}, },
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{ 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(providerStats.value.map(item => item.provider)).toEqual(['OpenAI'])
expect(availableProviders.value).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> isAdminPage: Ref<boolean>
} }
export interface LoadStatsOptions {
force?: boolean
preserveOnFailure?: boolean
}
export interface PaginationParams { export interface PaginationParams {
page: number page: number
pageSize: 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 const requestId = ++loadStatsRequestId
isLoadingStats.value = true isLoadingStats.value = true
currentDateRange.value = dateRange currentDateRange.value = dateRange
@@ -84,14 +89,17 @@ export function useUsageData(options: UseUsageDataOptions) {
try { try {
if (isAdminPage.value) { if (isAdminPage.value) {
// 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。 // 管理员页面顺序加载统计数据,避免刷新使用记录时瞬时打满后端 worker。
stats.value = createDefaultStats() if (!options.preserveOnFailure) {
modelStats.value = [] stats.value = createDefaultStats()
providerStats.value = [] modelStats.value = []
apiFormatStats.value = [] providerStats.value = []
availableModels.value = [] apiFormatStats.value = []
availableProviders.value = [] availableModels.value = []
availableProviders.value = []
}
let hadFailure = false let hadFailure = false
const requestOptions = options.force ? { skipCache: true } : undefined
const markFailure = (error: unknown) => { const markFailure = (error: unknown) => {
hadFailure = true hadFailure = true
if (getErrorStatus(error) !== 403) { if (getErrorStatus(error) !== 403) {
@@ -100,7 +108,7 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
try { try {
const statsData = await usageApi.getUsageStats(dateRange) const statsData = await usageApi.getUsageStats(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return true return true
} }
@@ -127,7 +135,7 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
try { try {
const modelData = await usageApi.getUsageByModel(dateRange) const modelData = await usageApi.getUsageByModel(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return true return true
} }
@@ -158,7 +166,7 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
try { try {
const providerData = await usageApi.getUsageByProvider(dateRange) const providerData = await usageApi.getUsageByProvider(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return true return true
} }
@@ -191,7 +199,7 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
try { try {
const apiFormatData = await usageApi.getUsageByApiFormat(dateRange) const apiFormatData = await usageApi.getUsageByApiFormat(dateRange, requestOptions)
if (requestId !== loadStatsRequestId) { if (requestId !== loadStatsRequestId) {
return true return true
} }
@@ -312,9 +320,9 @@ export function useUsageData(options: UseUsageDataOptions) {
if (getErrorStatus(error) !== 403) { if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error) log.error('加载统计数据失败:', error)
} }
stats.value = createDefaultStats()
modelStats.value = []
if (!isAdminPage.value) { if (!isAdminPage.value) {
stats.value = createDefaultStats()
modelStats.value = []
// 用户页的 records 依赖 stats 一起加载;管理员页的 records 是独立分页,不应被统计失败清空。 // 用户页的 records 依赖 stats 一起加载;管理员页的 records 是独立分页,不应被统计失败清空。
currentRecords.value = [] currentRecords.value = []
totalRecords.value = 0 totalRecords.value = 0

View File

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