perf: 全栈查询优化、前端缓存去重与页面可见性优化

后端:
- SQL count 查询统一改用 func.count() 子查询替代 query.count()
- Dashboard/Audit 等页面多次独立查询合并为单次聚合查询
- Provider summary 列表改为批量查询消除 N+1 问题
- DailyStats 逐天循环查询改为 CASE 分桶单次查询
- 使用 load_only() 减少不必要的列加载
- cache_decorator 支持嵌套属性路径解析(dotted vary_by)
- 多个管理/公共端点新增 @cache_result 缓存装饰

前端:
- cache.ts 新增 in-flight 请求复用、dedupedRequest、buildCacheKey
- 大量 API 调用添加前端缓存或去重
- 多个页面定时器在标签页隐藏时暂停、可见时恢复
- Auth 检查从 setInterval 改为 storage + visibilitychange 事件驱动
- 请求竞态防护(requestId 模式)

数据库:
- Usage 表新增 idx_usage_status_user_created 复合索引
This commit is contained in:
fawney19
2026-03-03 22:04:40 +08:00
parent 0a60492146
commit 97b0146ce9
66 changed files with 2306 additions and 657 deletions

View File

@@ -0,0 +1,45 @@
"""add_idx_usage_status_user_created
Add composite index on usage(status, user_id, created_at) to speed up
interval timeline and active usage analytics queries.
Revision ID: 5f1d2e3c4b5a
Revises: 0ba031f328de
Create Date: 2026-03-03 17:30:00.000000+00:00
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "5f1d2e3c4b5a"
down_revision = "0ba031f328de"
branch_labels = None
depends_on = None
INDEX_NAME = "idx_usage_status_user_created"
TABLE = "usage"
COLUMNS = ["status", "user_id", "created_at"]
def upgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if result:
return
op.create_index(INDEX_NAME, TABLE, COLUMNS)
def downgrade() -> None:
bind = op.get_bind()
result = bind.execute(
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
{"name": INDEX_NAME},
).fetchone()
if not result:
return
op.drop_index(INDEX_NAME, table_name=TABLE)

View File

@@ -1,4 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
// LDAP 配置导出结构 // LDAP 配置导出结构
export interface LDAPConfigExport { export interface LDAPConfigExport {
@@ -743,10 +744,17 @@ export const adminApi = {
include_inactive?: boolean include_inactive?: boolean
exclude_admin?: boolean exclude_admin?: boolean
}): Promise<LeaderboardResponse> { }): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', { const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
params
})
return response.data
},
20 * 1000
)
}, },
async getLeaderboardApiKeys(params?: { async getLeaderboardApiKeys(params?: {
@@ -764,10 +772,17 @@ export const adminApi = {
include_inactive?: boolean include_inactive?: boolean
exclude_admin?: boolean exclude_admin?: boolean
}): Promise<LeaderboardResponse> { }): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', { const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
params
})
return response.data
},
20 * 1000
)
}, },
async getLeaderboardModels(params?: { async getLeaderboardModels(params?: {
@@ -783,10 +798,17 @@ export const adminApi = {
provider_name?: string provider_name?: string
model?: string model?: string
}): Promise<LeaderboardResponse> { }): Promise<LeaderboardResponse> {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', { const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
params
})
return response.data
},
20 * 1000
)
}, },
async getCostForecast(params?: { async getCostForecast(params?: {
@@ -798,10 +820,17 @@ export const adminApi = {
days?: number days?: number
forecast_days?: number forecast_days?: number
}): Promise<CostForecastResponse> { }): Promise<CostForecastResponse> {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', { const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
params
})
return response.data
},
30 * 1000
)
}, },
async getCostSavings(params?: { async getCostSavings(params?: {
@@ -813,15 +842,28 @@ export const adminApi = {
provider_name?: string provider_name?: string
model?: string model?: string
}): Promise<CostSavingsResponse> { }): Promise<CostSavingsResponse> {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', { const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
params
})
return response.data
},
30 * 1000
)
}, },
async getQuotaUsage(): Promise<QuotaUsageResponse> { async getQuotaUsage(): Promise<QuotaUsageResponse> {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage') return cachedRequest(
return response.data 'admin:stats:providers:quota-usage',
async () => {
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
return response.data
},
30 * 1000
)
}, },
async getPercentiles(params?: { async getPercentiles(params?: {
@@ -831,10 +873,17 @@ export const adminApi = {
timezone?: string timezone?: string
tz_offset_minutes?: number tz_offset_minutes?: number
}): Promise<PercentileItem[]> { }): Promise<PercentileItem[]> {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', { const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
params
})
return response.data
},
20 * 1000
)
}, },
async getErrorDistribution(params?: { async getErrorDistribution(params?: {
@@ -844,10 +893,17 @@ export const adminApi = {
timezone?: string timezone?: string
tz_offset_minutes?: number tz_offset_minutes?: number
}): Promise<ErrorDistributionResponse> { }): Promise<ErrorDistributionResponse> {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', { const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
params
})
return response.data
},
20 * 1000
)
}, },
async getComparison(params: { async getComparison(params: {
@@ -882,8 +938,15 @@ export const adminApi = {
model?: string model?: string
provider_name?: string provider_name?: string
}): Promise<Array<Record<string, unknown>>> { }): Promise<Array<Record<string, unknown>>> {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params }) const cacheKey = buildCacheKey('admin:stats:time-series', params)
return response.data return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
return response.data
},
20 * 1000
)
}, },
} }

View File

@@ -3,6 +3,7 @@
*/ */
import api from './client' import api from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface CacheStats { export interface CacheStats {
scheduler: string scheduler: string
@@ -314,8 +315,15 @@ export const cacheAnalysisApi = {
user_id?: string user_id?: string
include_user_info?: boolean include_user_info?: boolean
}): Promise<IntervalTimelineResponse> { }): Promise<IntervalTimelineResponse> {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) const cacheKey = buildCacheKey('cache-affinity:interval-timeline', params as Record<string, unknown> | undefined)
return response.data return cachedRequest(
cacheKey,
async () => {
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
return response.data
},
30000
)
} }
} }

View File

@@ -1,4 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface DashboardStat { export interface DashboardStat {
name: string name: string
@@ -289,8 +290,15 @@ export interface TimeRangeParams {
export const dashboardApi = { export const dashboardApi = {
// 获取仪表盘统计数据 // 获取仪表盘统计数据
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> { async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params }) const cacheKey = buildCacheKey('dashboard:stats', params)
return response.data return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
return response.data
},
10 * 1000
)
}, },
// 获取最近的请求记录 // 获取最近的请求记录
@@ -303,8 +311,14 @@ export const dashboardApi = {
// 获取提供商状态 // 获取提供商状态
async getProviderStatus(): Promise<ProviderStatus[]> { async getProviderStatus(): Promise<ProviderStatus[]> {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status') return cachedRequest(
return response.data.providers 'dashboard:provider-status',
async () => {
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
return response.data.providers
},
20 * 1000
)
}, },
// 获取请求详情 // 获取请求详情
@@ -316,10 +330,17 @@ export const dashboardApi = {
// 获取每日统计数据 // 获取每日统计数据
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> { async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', { const cacheKey = buildCacheKey('dashboard:daily-stats', params)
params return cachedRequest(
}) cacheKey,
return response.data async () => {
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
params
})
return response.data
},
20 * 1000
)
}, },
// 获取 cURL 命令数据(含明文 API Key // 获取 cURL 命令数据(含明文 API Key

View File

@@ -1,4 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { import type {
GlobalModelCreate, GlobalModelCreate,
GlobalModelUpdate, GlobalModelUpdate,
@@ -27,16 +28,21 @@ export async function getGlobalModels(params?: {
is_active?: boolean is_active?: boolean
search?: string search?: string
}): Promise<GlobalModelListResponse> { }): Promise<GlobalModelListResponse> {
const response = await client.get('/api/admin/models/global', { params }) const key = buildCacheKey('global-models:list', params as Record<string, unknown> | undefined)
return response.data return dedupedRequest(key, async () => {
const response = await client.get('/api/admin/models/global', { params })
return response.data
})
} }
/** /**
* 获取单个 GlobalModel 详情 * 获取单个 GlobalModel 详情
*/ */
export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> { export async function getGlobalModel(id: string): Promise<GlobalModelWithStats> {
const response = await client.get(`/api/admin/models/global/${id}`) return dedupedRequest(`global-models:detail:${id}`, async () => {
return response.data const response = await client.get(`/api/admin/models/global/${id}`)
return response.data
})
} }
/** /**
@@ -112,10 +118,12 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{
providers: ModelCatalogProviderDetail[] providers: ModelCatalogProviderDetail[]
total: number total: number
}> { }> {
const response = await client.get( return dedupedRequest(`global-models:providers:${globalModelId}`, async () => {
`/api/admin/models/global/${globalModelId}/providers` const response = await client.get(
) `/api/admin/models/global/${globalModelId}/providers`
return response.data )
return response.data
})
} }
/** /**

View File

@@ -1,4 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { AllowedModels, ProxyConfig } from './types/provider' import type { AllowedModels, ProxyConfig } from './types/provider'
export interface PoolKeyStatus { export interface PoolKeyStatus {
@@ -175,16 +176,21 @@ export interface PoolBatchAction {
} }
export async function getPoolOverview(): Promise<PoolOverviewResponse> { export async function getPoolOverview(): Promise<PoolOverviewResponse> {
const response = await client.get('/api/admin/pool/overview') return dedupedRequest('pool:overview', async () => {
return response.data const response = await client.get<PoolOverviewResponse>('/api/admin/pool/overview')
return response.data
})
} }
export async function listPoolKeys( export async function listPoolKeys(
providerId: string, providerId: string,
params: PoolKeysQuery = {}, params: PoolKeysQuery = {},
): Promise<PoolKeysPageResponse> { ): Promise<PoolKeysPageResponse> {
const response = await client.get(`/api/admin/pool/${providerId}/keys`, { params }) const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
return response.data return dedupedRequest(key, async () => {
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
return response.data
})
} }
export async function batchActionPoolKeys( export async function batchActionPoolKeys(

View File

@@ -1,4 +1,5 @@
import client from '../client' import client from '../client'
import { dedupedRequest } from '@/utils/cache'
import type { import type {
ClaudeCodeAdvancedConfig, ClaudeCodeAdvancedConfig,
FailoverRulesConfig, FailoverRulesConfig,
@@ -11,16 +12,20 @@ import type {
* 获取 Providers 摘要(包含 Endpoints 统计) * 获取 Providers 摘要(包含 Endpoints 统计)
*/ */
export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> { export async function getProvidersSummary(): Promise<ProviderWithEndpointsSummary[]> {
const response = await client.get('/api/admin/providers/summary') return dedupedRequest('providers:summary', async () => {
return response.data const response = await client.get<ProviderWithEndpointsSummary[]>('/api/admin/providers/summary')
return response.data
})
} }
/** /**
* 获取单个 Provider 的详细信息 * 获取单个 Provider 的详细信息
*/ */
export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> { export async function getProvider(providerId: string): Promise<ProviderWithEndpointsSummary> {
const response = await client.get(`/api/admin/providers/${providerId}/summary`) return dedupedRequest(`providers:detail:${providerId}`, async () => {
return response.data const response = await client.get<ProviderWithEndpointsSummary>(`/api/admin/providers/${providerId}/summary`)
return response.data
})
} }
/** /**
@@ -214,6 +219,8 @@ export interface ProviderMappingPreviewResponse {
export async function getProviderMappingPreview( export async function getProviderMappingPreview(
providerId: string providerId: string
): Promise<ProviderMappingPreviewResponse> { ): Promise<ProviderMappingPreviewResponse> {
const response = await client.get(`/api/admin/providers/${providerId}/mapping-preview`) return dedupedRequest(`providers:mapping-preview:${providerId}`, async () => {
return response.data const response = await client.get<ProviderMappingPreviewResponse>(`/api/admin/providers/${providerId}/mapping-preview`)
return response.data
})
} }

View File

@@ -1,6 +1,7 @@
import apiClient from './client' import apiClient from './client'
import type { ActivityHeatmap } from '@/types/activity' import type { ActivityHeatmap } from '@/types/activity'
import type { TieredPricingConfig } from './endpoints/types' import type { TieredPricingConfig } from './endpoints/types'
import { cachedRequest, buildCacheKey } from '@/utils/cache'
export interface Profile { export interface Profile {
id: string // UUID id: string // UUID
@@ -328,8 +329,15 @@ export const meApi = {
points: Array<{ x: string; y: number; model?: string }> points: Array<{ x: string; y: number; model?: string }>
models?: string[] models?: string[]
}> { }> {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
return response.data return cachedRequest(
cacheKey,
async () => {
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
return response.data
},
30000
)
}, },
/** /**
@@ -337,7 +345,13 @@ export const meApi = {
* 后端已缓存5分钟 * 后端已缓存5分钟
*/ */
async getActivityHeatmap(): Promise<ActivityHeatmap> { async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap') return cachedRequest(
return response.data 'me-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
return response.data
},
60000
)
} }
} }

View File

@@ -1,5 +1,5 @@
import apiClient from './client' import apiClient from './client'
import { cachedRequest } from '@/utils/cache' import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
import type { ActivityHeatmap } from '@/types/activity' import type { ActivityHeatmap } from '@/types/activity'
export interface UsageRecord { export interface UsageRecord {
@@ -187,8 +187,11 @@ export const usageApi = {
limit: number limit: number
offset: number offset: number
}> { }> {
const response = await apiClient.get('/api/admin/usage/records', { params }) const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
return response.data return dedupedRequest(key, async () => {
const response = await apiClient.get('/api/admin/usage/records', { params })
return response.data
})
}, },
/** /**
@@ -226,7 +229,13 @@ export const usageApi = {
* 后端已缓存5分钟 * 后端已缓存5分钟
*/ */
async getActivityHeatmap(): Promise<ActivityHeatmap> { async getActivityHeatmap(): Promise<ActivityHeatmap> {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap') return cachedRequest(
return response.data 'admin-usage-activity-heatmap',
async () => {
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
return response.data
},
60000
)
} }
} }

View File

@@ -1132,6 +1132,8 @@ const endpoints = ref<ProviderEndpointWithKeys[]>([])
const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys const providerKeys = ref<EndpointAPIKey[]>([]) // Provider 级别的 keys
const providerModels = ref<Model[]>([]) // Provider 级别的 models const providerModels = ref<Model[]>([]) // Provider 级别的 models
const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览 const providerMappingPreview = ref<ProviderMappingPreviewResponse | null>(null) // 映射预览
let providerLoadRequestId = 0
let endpointsLoadRequestId = 0
// 系统级格式转换配置 // 系统级格式转换配置
const systemFormatConversionEnabled = ref(false) const systemFormatConversionEnabled = ref(false)
@@ -1281,12 +1283,19 @@ watch(
} }
void autoRefreshQuotaInBackground() void autoRefreshQuotaInBackground()
} else if (!newOpen && oldOpen) { } else if (!newOpen && oldOpen) {
// 使在途请求失效,避免关闭后旧响应回写
providerLoadRequestId += 1
endpointsLoadRequestId += 1
// 停止倒计时定时器 // 停止倒计时定时器
stopCountdownTimer() stopCountdownTimer()
// 重置所有状态 // 重置所有状态
loading.value = false
provider.value = null provider.value = null
endpoints.value = [] endpoints.value = []
providerKeys.value = [] // 清空 Provider 级别的 keys providerKeys.value = [] // 清空 Provider 级别的 keys
providerModels.value = []
providerMappingPreview.value = null
// 重置分页状态 // 重置分页状态
resetKeysPagination() resetKeysPagination()
@@ -2581,6 +2590,7 @@ async function loadSystemFormatConversionConfig() {
// 加载 Provider 信息 // 加载 Provider 信息
async function loadProvider() { async function loadProvider() {
if (!props.providerId) return if (!props.providerId) return
const requestId = ++providerLoadRequestId
try { try {
loading.value = true loading.value = true
@@ -2589,21 +2599,26 @@ async function loadProvider() {
getProvider(props.providerId), getProvider(props.providerId),
loadSystemFormatConversionConfig(), loadSystemFormatConversionConfig(),
]) ])
if (requestId !== providerLoadRequestId) return
provider.value = providerData provider.value = providerData
if (!provider.value) { if (!provider.value) {
throw new Error('Provider 不存在') throw new Error('Provider 不存在')
} }
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== providerLoadRequestId) return
showError(parseApiError(err, '加载失败'), '错误') showError(parseApiError(err, '加载失败'), '错误')
} finally { } finally {
loading.value = false if (requestId === providerLoadRequestId) {
loading.value = false
}
} }
} }
// 加载端点列表 // 加载端点列表
async function loadEndpoints() { async function loadEndpoints() {
if (!props.providerId) return if (!props.providerId) return
const requestId = ++endpointsLoadRequestId
try { try {
// 并行加载端点列表、Provider 级别的 keys、models 和映射预览 // 并行加载端点列表、Provider 级别的 keys、models 和映射预览
@@ -2613,6 +2628,7 @@ async function loadEndpoints() {
getProviderModels(props.providerId).catch(() => []), getProviderModels(props.providerId).catch(() => []),
getProviderMappingPreview(props.providerId).catch(() => null), getProviderMappingPreview(props.providerId).catch(() => null),
]) ])
if (requestId !== endpointsLoadRequestId) return
providerKeys.value = providerKeysResult providerKeys.value = providerKeysResult
providerModels.value = modelsResult providerModels.value = modelsResult
@@ -2627,6 +2643,7 @@ async function loadEndpoints() {
return aIdx - bIdx return aIdx - bIdx
}) })
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== endpointsLoadRequestId) return
showError(parseApiError(err, '加载端点失败'), '错误') showError(parseApiError(err, '加载端点失败'), '错误')
} }
} }

View File

@@ -54,7 +54,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, onMounted, watch } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import ScatterChart from '@/components/charts/ScatterChart.vue' import ScatterChart from '@/components/charts/ScatterChart.vue'
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache' import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
@@ -73,6 +73,10 @@ const props = withDefaults(defineProps<{
const loading = ref(false) const loading = ref(false)
const timelineData = ref<IntervalTimelineResponse | null>(null) const timelineData = ref<IntervalTimelineResponse | null>(null)
const primaryColor = ref('201, 100, 66') // 默认主题色 const primaryColor = ref('201, 100, 66') // 默认主题色
let loadRequestId = 0
const ADMIN_TIMELINE_LIMIT = 1500
const USER_TIMELINE_LIMIT = 1200
// 获取主题色 // 获取主题色
function getPrimaryColor(): string { function getPrimaryColor(): string {
@@ -86,7 +90,7 @@ function getPrimaryColor(): string {
onMounted(() => { onMounted(() => {
primaryColor.value = getPrimaryColor() primaryColor.value = getPrimaryColor()
loadData() void loadData()
}) })
// 预定义的颜色列表(用于区分不同用户/模型) // 预定义的颜色列表(用于区分不同用户/模型)
@@ -278,35 +282,44 @@ const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
})) }))
async function loadData() { async function loadData() {
const requestId = ++loadRequestId
loading.value = true loading.value = true
try { try {
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
if (props.isAdmin) { if (props.isAdmin) {
// 管理员:获取所有用户数据(按比例采样) // 管理员:获取所有用户数据(按比例采样)
timelineData.value = await cacheAnalysisApi.getIntervalTimeline({ const data = await cacheAnalysisApi.getIntervalTimeline({
hours: props.hours, hours: props.hours,
include_user_info: true, include_user_info: true,
limit: 10000, limit,
}) })
if (requestId !== loadRequestId) return
timelineData.value = data
} else { } else {
// 普通用户:获取自己的数据 // 普通用户:获取自己的数据
timelineData.value = await meApi.getIntervalTimeline({ const data = await meApi.getIntervalTimeline({
hours: props.hours, hours: props.hours,
limit: 5000, limit,
}) })
if (requestId !== loadRequestId) return
timelineData.value = data
} }
} catch (error) { } catch (error) {
if (requestId !== loadRequestId) return
log.error('加载请求间隔时间线失败:', error) log.error('加载请求间隔时间线失败:', error)
timelineData.value = null timelineData.value = null
} finally { } finally {
loading.value = false if (requestId === loadRequestId) {
loading.value = false
}
} }
} }
watch(() => props.hours, () => { watch([() => props.hours, () => props.isAdmin], () => {
loadData() void loadData()
}) })
watch(() => props.isAdmin, () => { onBeforeUnmount(() => {
loadData() loadRequestId++
}) })
</script> </script>

View File

@@ -657,7 +657,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed, onBeforeUnmount } from 'vue' import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import { useEscapeKey } from '@/composables/useEscapeKey' import { useEscapeKey } from '@/composables/useEscapeKey'
import { useClipboard } from '@/composables/useClipboard' import { useClipboard } from '@/composables/useClipboard'
@@ -716,10 +716,13 @@ const historicalPricing = ref<{
} | null>(null) } | null>(null)
const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null) const autoRefreshTimer = ref<ReturnType<typeof setInterval> | null>(null)
const autoRefreshing = ref(false) const autoRefreshing = ref(false)
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
const curlCopying = ref(false) const curlCopying = ref(false)
const curlCopied = ref(false) const curlCopied = ref(false)
const replayDialogOpen = ref(false) const replayDialogOpen = ref(false)
const AUTO_REFRESH_INTERVAL_MS = 1000 const AUTO_REFRESH_INTERVAL_MS = 1000
let loadDetailRequestId = 0
let loadDetailInFlight = false
// 监听标签页切换 // 监听标签页切换
watch(activeTab, (newTab) => { watch(activeTab, (newTab) => {
@@ -1097,13 +1100,20 @@ watch(() => props.isOpen, async (isOpen) => {
}) })
async function loadDetail(id: string, silent = false) { async function loadDetail(id: string, silent = false) {
if (silent && loadDetailInFlight) {
return
}
const requestId = ++loadDetailRequestId
loadDetailInFlight = true
if (!silent) { if (!silent) {
loading.value = true loading.value = true
historicalPricing.value = null historicalPricing.value = null
} }
error.value = null error.value = null
try { try {
detail.value = await dashboardApi.getRequestDetail(id) const response = await dashboardApi.getRequestDetail(id)
if (requestId !== loadDetailRequestId) return
detail.value = response
// 首次加载时选择默认 tab // 首次加载时选择默认 tab
if (!silent) { if (!silent) {
@@ -1145,14 +1155,18 @@ async function loadDetail(id: string, silent = false) {
} }
} }
} catch (err) { } catch (err) {
if (requestId !== loadDetailRequestId) return
log.error('Failed to load request detail:', err) log.error('Failed to load request detail:', err)
if (!silent) { if (!silent) {
error.value = '加载请求详情失败' error.value = '加载请求详情失败'
} }
} finally { } finally {
if (!silent) { if (!silent && requestId === loadDetailRequestId) {
loading.value = false loading.value = false
} }
if (requestId === loadDetailRequestId) {
loadDetailInFlight = false
}
} }
} }
@@ -1175,12 +1189,17 @@ function stopAutoRefresh() {
} }
function startAutoRefresh() { function startAutoRefresh() {
if (autoRefreshTimer.value || !props.requestId || !props.isOpen) { if (autoRefreshTimer.value) {
autoRefreshing.value = true
return
}
if (!isPageVisible.value || !props.requestId || !props.isOpen) {
autoRefreshing.value = false
return return
} }
autoRefreshing.value = true autoRefreshing.value = true
autoRefreshTimer.value = setInterval(async () => { autoRefreshTimer.value = setInterval(async () => {
if (!props.requestId || !props.isOpen) { if (!isPageVisible.value || !props.requestId || !props.isOpen) {
stopAutoRefresh() stopAutoRefresh()
return return
} }
@@ -1218,8 +1237,26 @@ async function refreshDetail() {
startAutoRefresh() startAutoRefresh()
} }
function handleVisibilityChange() {
isPageVisible.value = !document.hidden
if (!isPageVisible.value) {
stopAutoRefresh()
return
}
if (props.isOpen && props.requestId && !isRequestCompleted()) {
startAutoRefresh()
}
}
onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
})
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh() stopAutoRefresh()
loadDetailRequestId += 1
loadDetailInFlight = false
}) })
function formatDateTime(dateStr: string | null | undefined): string { function formatDateTime(dateStr: string | null | undefined): string {

View File

@@ -767,7 +767,7 @@ const hasActiveRecords = computed(() => {
// 使用 VueUse 的 useIntervalFn 管理计时器(自动清理) // 使用 VueUse 的 useIntervalFn 管理计时器(自动清理)
const { pause: stopTimer, resume: startTimer } = useIntervalFn( const { pause: stopTimer, resume: startTimer } = useIntervalFn(
() => { now.value = Date.now() }, () => { now.value = Date.now() },
100, 500,
{ immediate: false } { immediate: false }
) )

View File

@@ -52,6 +52,8 @@ export function useUsageData(options: UseUsageDataOptions) {
// 当前的日期范围(用于分页请求) // 当前的日期范围(用于分页请求)
const currentDateRange = ref<DateRangeParams | undefined>(undefined) const currentDateRange = ref<DateRangeParams | undefined>(undefined)
let loadStatsRequestId = 0
let loadRecordsRequestId = 0
// 可用的筛选选项(从统计数据获取,而不是从记录中) // 可用的筛选选项(从统计数据获取,而不是从记录中)
const availableModels = ref<string[]>([]) const availableModels = ref<string[]>([])
@@ -69,6 +71,7 @@ export function useUsageData(options: UseUsageDataOptions) {
// 加载统计数据(不加载记录) // 加载统计数据(不加载记录)
async function loadStats(dateRange?: DateRangeParams) { async function loadStats(dateRange?: DateRangeParams) {
const requestId = ++loadStatsRequestId
isLoadingStats.value = true isLoadingStats.value = true
currentDateRange.value = dateRange currentDateRange.value = dateRange
@@ -82,6 +85,10 @@ export function useUsageData(options: UseUsageDataOptions) {
usageApi.getUsageByApiFormat(dateRange) usageApi.getUsageByApiFormat(dateRange)
]) ])
if (requestId !== loadStatsRequestId) {
return
}
// statsData may contain additional fields not declared in UsageStats // statsData may contain additional fields not declared in UsageStats
const statsRaw = statsData as Record<string, unknown> const statsRaw = statsData as Record<string, unknown>
stats.value = { stats.value = {
@@ -138,6 +145,9 @@ export function useUsageData(options: UseUsageDataOptions) {
} else { } else {
// 用户页面 // 用户页面
const userData = await meApi.getUsage(dateRange) const userData = await meApi.getUsage(dateRange)
if (requestId !== loadStatsRequestId) {
return
}
stats.value = { stats.value = {
total_requests: userData.total_requests || 0, total_requests: userData.total_requests || 0,
@@ -227,6 +237,9 @@ export function useUsageData(options: UseUsageDataOptions) {
.sort((a, b) => b.request_count - a.request_count) .sort((a, b) => b.request_count - a.request_count)
} }
} catch (error: unknown) { } catch (error: unknown) {
if (requestId !== loadStatsRequestId) {
return
}
if (getErrorStatus(error) !== 403) { if (getErrorStatus(error) !== 403) {
log.error('加载统计数据失败:', error) log.error('加载统计数据失败:', error)
} }
@@ -234,7 +247,9 @@ export function useUsageData(options: UseUsageDataOptions) {
modelStats.value = [] modelStats.value = []
currentRecords.value = [] currentRecords.value = []
} finally { } finally {
isLoadingStats.value = false if (requestId === loadStatsRequestId) {
isLoadingStats.value = false
}
} }
} }
@@ -243,6 +258,7 @@ export function useUsageData(options: UseUsageDataOptions) {
pagination: PaginationParams, pagination: PaginationParams,
filters?: FilterParams filters?: FilterParams
): Promise<void> { ): Promise<void> {
const requestId = ++loadRecordsRequestId
isLoadingRecords.value = true isLoadingRecords.value = true
try { try {
@@ -279,22 +295,33 @@ export function useUsageData(options: UseUsageDataOptions) {
} }
const response = await usageApi.getAllUsageRecords(params) const response = await usageApi.getAllUsageRecords(params)
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (response.records || []) as UsageRecord[] const nextRecords = (response.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords) currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = response.total || 0 totalRecords.value = response.total || 0
} else { } else {
// 用户页面:使用用户 API // 用户页面:使用用户 API
const userData = await meApi.getUsage(params) const userData = await meApi.getUsage(params)
if (requestId !== loadRecordsRequestId) {
return
}
const nextRecords = (userData.records || []) as UsageRecord[] const nextRecords = (userData.records || []) as UsageRecord[]
currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords) currentRecords.value = mergeRecordStatus(currentRecords.value, nextRecords)
totalRecords.value = userData.pagination?.total || currentRecords.value.length totalRecords.value = userData.pagination?.total || currentRecords.value.length
} }
} catch (error) { } catch (error) {
if (requestId !== loadRecordsRequestId) {
return
}
log.error('加载记录失败:', error) log.error('加载记录失败:', error)
currentRecords.value = [] currentRecords.value = []
totalRecords.value = 0 totalRecords.value = 0
} finally { } finally {
isLoadingRecords.value = false if (requestId === loadRecordsRequestId) {
isLoadingRecords.value = false
}
} }
} }

View File

@@ -385,7 +385,6 @@ const isDemo = computed(() => isDemoMode())
const showAuthError = ref(false) const showAuthError = ref(false)
const mobileMenuOpen = ref(false) const mobileMenuOpen = ref(false)
let authCheckInterval: number | null = null
// 更新检查相关 // 更新检查相关
const showUpdateDialog = ref(false) const showUpdateDialog = ref(false)
@@ -437,12 +436,35 @@ async function checkForUpdate() {
} }
} }
function syncAuthNotice() {
authStore.syncToken()
showAuthError.value = !!authStore.user && !authStore.token
}
function handleStorageChange(event: StorageEvent) {
if (event.key === null || event.key === 'access_token') {
syncAuthNotice()
}
}
function handleVisibilityChange() {
if (!document.hidden) {
syncAuthNotice()
}
}
watch(
() => [authStore.user, authStore.token] as const,
() => {
showAuthError.value = !!authStore.user && !authStore.token
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
authCheckInterval = setInterval(() => { window.addEventListener('storage', handleStorageChange)
if (authStore.user && !authStore.token) { document.addEventListener('visibilitychange', handleVisibilityChange)
showAuthError.value = true syncAuthNotice()
}
}, 5000)
// 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁) // 管理员预加载模块状态(路由守卫会按需加载,这里提前加载以避免菜单闪烁)
if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) { if (authStore.user?.role === 'admin' && !moduleStore.loaded && !moduleStore.loading) {
@@ -456,10 +478,8 @@ onMounted(() => {
}) })
onUnmounted(() => { onUnmounted(() => {
if (authCheckInterval) { window.removeEventListener('storage', handleStorageChange)
clearInterval(authCheckInterval) document.removeEventListener('visibilitychange', handleVisibilityChange)
authCheckInterval = null
}
}) })
function handleRelogin() { function handleRelogin() {

View File

@@ -11,6 +11,7 @@ interface CacheItem<T> {
class MemoryCache { class MemoryCache {
private cache: Map<string, CacheItem<unknown>> = new Map() private cache: Map<string, CacheItem<unknown>> = new Map()
private inFlight: Map<string, Promise<unknown>> = new Map()
private defaultTTL = 60000 // 默认缓存60秒 private defaultTTL = 60000 // 默认缓存60秒
/** /**
@@ -61,6 +62,7 @@ class MemoryCache {
*/ */
clear(): void { clear(): void {
this.cache.clear() this.cache.clear()
this.inFlight.clear()
} }
/** /**
@@ -81,6 +83,27 @@ class MemoryCache {
size(): number { size(): number {
return this.cache.size return this.cache.size
} }
/**
* 获取进行中的请求
*/
getInFlight<T>(key: string): Promise<T> | null {
return (this.inFlight.get(key) as Promise<T> | undefined) ?? null
}
/**
* 标记进行中的请求
*/
setInFlight<T>(key: string, promise: Promise<T>): void {
this.inFlight.set(key, promise as Promise<unknown>)
}
/**
* 清除进行中的请求
*/
deleteInFlight(key: string): void {
this.inFlight.delete(key)
}
} }
// 创建全局缓存实例 // 创建全局缓存实例
@@ -103,18 +126,62 @@ export async function cachedRequest<T>(
ttl?: number ttl?: number
): Promise<T> { ): Promise<T> {
// 尝试从缓存获取 // 尝试从缓存获取
const cached = cache.get<T>(key) if (ttl !== 0) {
if (cached !== null) { const cached = cache.get<T>(key)
return cached if (cached !== null) {
return cached
}
} }
// 缓存未命中,执行请求 // 命中进行中的同 key 请求,直接复用
const data = await fetcher() const inFlight = cache.getInFlight<T>(key)
if (inFlight) {
return inFlight
}
// 存入缓存 // 缓存未命中,执行请求并登记为 in-flight
cache.set(key, data, ttl) const request = (async () => {
try {
const data = await fetcher()
if (ttl !== 0) {
cache.set(key, data, ttl)
}
return data
} finally {
cache.deleteInFlight(key)
}
})()
return data cache.setInFlight(key, request)
return request
} }
export default cache /**
* 仅做请求去重(不缓存结果)
* 相同 key 的并发请求会复用同一个 Promise
*/
export function dedupedRequest<T>(
key: string,
fetcher: () => Promise<T>,
): Promise<T> {
return cachedRequest(key, fetcher, 0)
}
/**
* 构建归一化的缓存 key
* 将 params 的 key 排序并过滤 undefined 值,确保相同参数生成相同 key
*/
export function buildCacheKey(prefix: string, params?: Record<string, unknown>): string {
if (!params) {
return prefix
}
const normalizedParams = Object.entries(params)
.filter(([, value]) => value !== undefined)
.sort(([a], [b]) => a.localeCompare(b))
if (normalizedParams.length === 0) {
return prefix
}
return `${prefix}:${JSON.stringify(normalizedParams)}`
}
export default cache

View File

@@ -911,6 +911,8 @@ const showDetail = ref(false)
const selectedTask = ref<AsyncTaskDetail | null>(null) const selectedTask = ref<AsyncTaskDetail | null>(null)
const detailAutoRefresh = ref(false) const detailAutoRefresh = ref(false)
let detailRefreshInterval: ReturnType<typeof setInterval> | null = null let detailRefreshInterval: ReturnType<typeof setInterval> | null = null
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
let overviewRefreshInFlight = false
// 使用记录详情抽屉状态 // 使用记录详情抽屉状态
const usageDetailOpen = ref(false) const usageDetailOpen = ref(false)
@@ -953,6 +955,16 @@ async function fetchStats() {
} }
} }
async function refreshOverview() {
if (overviewRefreshInFlight) return
overviewRefreshInFlight = true
try {
await Promise.all([fetchTasks(), fetchStats()])
} finally {
overviewRefreshInFlight = false
}
}
// 打开任务详情 // 打开任务详情
async function openTaskDetail(task: AsyncTaskItem) { async function openTaskDetail(task: AsyncTaskItem) {
try { try {
@@ -993,22 +1005,27 @@ function toggleDetailAutoRefresh() {
// 开始详情自动刷新 // 开始详情自动刷新
function startDetailAutoRefresh() { function startDetailAutoRefresh() {
if (!isPageVisible.value) return
if (detailRefreshInterval) return if (detailRefreshInterval) return
// 立即刷新一次 // 立即刷新一次
refreshTaskDetail() refreshTaskDetail()
detailRefreshInterval = setInterval(() => { detailRefreshInterval = setInterval(() => {
if (selectedTask.value && showDetail.value) { if (isPageVisible.value && selectedTask.value && showDetail.value) {
refreshTaskDetail() refreshTaskDetail()
} }
}, 5000) }, 5000)
} }
// 停止详情自动刷新 function pauseDetailAutoRefresh() {
function stopDetailAutoRefresh() {
if (detailRefreshInterval) { if (detailRefreshInterval) {
clearInterval(detailRefreshInterval) clearInterval(detailRefreshInterval)
detailRefreshInterval = null detailRefreshInterval = null
} }
}
// 停止详情自动刷新
function stopDetailAutoRefresh() {
pauseDetailAutoRefresh()
detailAutoRefresh.value = false detailAutoRefresh.value = false
} }
@@ -1052,8 +1069,7 @@ async function cancelTask(task: AsyncTaskItem | AsyncTaskDetail) {
toast({ toast({
title: '任务已取消', title: '任务已取消',
}) })
fetchTasks() await refreshOverview()
fetchStats()
if (showDetail.value) { if (showDetail.value) {
closeDetail() closeDetail()
} }
@@ -1222,11 +1238,11 @@ let autoRefreshInterval: ReturnType<typeof setInterval> | null = null
const AUTO_REFRESH_INTERVAL = 5000 // 5秒 const AUTO_REFRESH_INTERVAL = 5000 // 5秒
function startAutoRefresh() { function startAutoRefresh() {
if (!isPageVisible.value) return
if (autoRefreshInterval) return if (autoRefreshInterval) return
autoRefreshInterval = setInterval(() => { autoRefreshInterval = setInterval(() => {
if (hasProcessingTasks.value && !loading.value) { if (isPageVisible.value && hasProcessingTasks.value && !loading.value) {
fetchTasks() refreshOverview()
fetchStats()
} }
}, AUTO_REFRESH_INTERVAL) }, AUTO_REFRESH_INTERVAL)
} }
@@ -1240,19 +1256,35 @@ function stopAutoRefresh() {
// 监听是否有进行中的任务,动态启停自动刷新 // 监听是否有进行中的任务,动态启停自动刷新
watch(hasProcessingTasks, (has) => { watch(hasProcessingTasks, (has) => {
if (has) { if (has && isPageVisible.value) {
startAutoRefresh() startAutoRefresh()
} else { } else {
stopAutoRefresh() stopAutoRefresh()
} }
}, { immediate: true }) }, { immediate: true })
function handleVisibilityChange() {
isPageVisible.value = !document.hidden
if (!isPageVisible.value) {
stopAutoRefresh()
pauseDetailAutoRefresh()
return
}
if (hasProcessingTasks.value) {
startAutoRefresh()
}
if (detailAutoRefresh.value && selectedTask.value && showDetail.value) {
startDetailAutoRefresh()
}
}
onMounted(() => { onMounted(() => {
fetchTasks() document.addEventListener('visibilitychange', handleVisibilityChange)
fetchStats() refreshOverview()
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh() stopAutoRefresh()
stopDetailAutoRefresh() stopDetailAutoRefresh()
clearTimeout(filterTimeout) clearTimeout(filterTimeout)

View File

@@ -406,7 +406,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { import {
Card, Card,
Button, Button,
@@ -462,6 +462,7 @@ interface AuditLog {
const loading = ref(false) const loading = ref(false)
const logs = ref<AuditLog[]>([]) const logs = ref<AuditLog[]>([])
const selectedLog = ref<AuditLog | null>(null) const selectedLog = ref<AuditLog | null>(null)
let logsRequestId = 0
// 搜索查询 // 搜索查询
const searchQuery = ref('') const searchQuery = ref('')
@@ -480,9 +481,9 @@ const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const totalRecords = ref(0) const totalRecords = ref(0)
let loadTimeout: number let loadTimeout: number | null = null
const debouncedLoadLogs = () => { const debouncedLoadLogs = () => {
clearTimeout(loadTimeout) if (loadTimeout !== null) clearTimeout(loadTimeout)
loadTimeout = window.setTimeout(resetAndLoad, 500) loadTimeout = window.setTimeout(resetAndLoad, 500)
} }
@@ -493,6 +494,7 @@ const hasActiveFilters = computed(() => {
}) })
async function loadLogs() { async function loadLogs() {
const requestId = ++logsRequestId
loading.value = true loading.value = true
try { try {
const offset = (currentPage.value - 1) * pageSize.value const offset = (currentPage.value - 1) * pageSize.value
@@ -506,14 +508,18 @@ async function loadLogs() {
} }
const data = await auditApi.getAuditLogs(filterParams) const data = await auditApi.getAuditLogs(filterParams)
if (requestId !== logsRequestId) return
logs.value = data.items || [] logs.value = data.items || []
totalRecords.value = data.meta?.total ?? logs.value.length totalRecords.value = data.meta?.total ?? logs.value.length
} catch (error) { } catch (error) {
if (requestId !== logsRequestId) return
log.error('获取审计日志失败:', error) log.error('获取审计日志失败:', error)
logs.value = [] logs.value = []
totalRecords.value = 0 totalRecords.value = 0
} finally { } finally {
loading.value = false if (requestId === logsRequestId) {
loading.value = false
}
} }
} }
@@ -711,4 +717,12 @@ function formatDateTime(dateStr: string): string {
onMounted(() => { onMounted(() => {
loadLogs() loadLogs()
}) })
onBeforeUnmount(() => {
if (loadTimeout !== null) {
clearTimeout(loadTimeout)
loadTimeout = null
}
logsRequestId += 1
})
</script> </script>

View File

@@ -47,6 +47,7 @@ const clearingRowAffinityKey = ref<string | null>(null)
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(20) const pageSize = ref(20)
const currentTime = ref(Math.floor(Date.now() / 1000)) const currentTime = ref(Math.floor(Date.now() / 1000))
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
// ==================== 模型映射缓存 ==================== // ==================== 模型映射缓存 ====================
@@ -236,6 +237,7 @@ function handlePageChange() {
// ==================== 定时器管理 ==================== // ==================== 定时器管理 ====================
function startCountdown() { function startCountdown() {
if (!isPageVisible.value) return
if (countdownTimer) clearInterval(countdownTimer) if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = setInterval(() => { countdownTimer = setInterval(() => {
@@ -260,6 +262,16 @@ function stopCountdown() {
} }
} }
function handleVisibilityChange() {
isPageVisible.value = !document.hidden
if (!isPageVisible.value) {
stopCountdown()
return
}
currentTime.value = Math.floor(Date.now() / 1000)
startCountdown()
}
// ==================== 模型映射缓存方法 ==================== // ==================== 模型映射缓存方法 ====================
async function fetchModelMappingStats() { async function fetchModelMappingStats() {
@@ -431,6 +443,7 @@ watch(tableKeyword, (value) => {
}) })
onMounted(() => { onMounted(() => {
document.addEventListener('visibilitychange', handleVisibilityChange)
fetchCacheStats() fetchCacheStats()
fetchCacheConfig() fetchCacheConfig()
fetchAffinityList() fetchAffinityList()
@@ -441,6 +454,7 @@ onMounted(() => {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
if (searchDebounceTimer) clearTimeout(searchDebounceTimer) if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
stopCountdown() stopCountdown()
}) })

View File

@@ -72,7 +72,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import { TimeRangePicker } from '@/components/common' import { TimeRangePicker } from '@/components/common'
import { CostForecastChart, QuotaProgressCard } from '@/components/stats' import { CostForecastChart, QuotaProgressCard } from '@/components/stats'
@@ -93,6 +93,13 @@ const providerStats = ref<ProviderStatsItem[]>([])
const forecastLoading = ref(false) const forecastLoading = ref(false)
const quotaLoading = ref(false) const quotaLoading = ref(false)
let forecastRequestId = 0
let savingsRequestId = 0
let quotaRequestId = 0
let providerStatsRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
const forecastHistory = computed(() => forecast.value?.history || []) const forecastHistory = computed(() => forecast.value?.history || [])
const forecastFuture = computed(() => forecast.value?.forecast || []) const forecastFuture = computed(() => forecast.value?.forecast || [])
@@ -108,40 +115,93 @@ function buildTimeRangeParams() {
} }
async function loadForecast() { async function loadForecast() {
const requestId = ++forecastRequestId
forecastLoading.value = true forecastLoading.value = true
try { try {
forecast.value = await adminApi.getCostForecast(buildTimeRangeParams()) const data = await adminApi.getCostForecast(buildTimeRangeParams())
if (requestId !== forecastRequestId) return
forecast.value = data
} finally { } finally {
forecastLoading.value = false if (requestId === forecastRequestId) {
forecastLoading.value = false
}
} }
} }
async function loadSavings() { async function loadSavings() {
costSavings.value = await adminApi.getCostSavings(buildTimeRangeParams()) const requestId = ++savingsRequestId
const data = await adminApi.getCostSavings(buildTimeRangeParams())
if (requestId !== savingsRequestId) return
costSavings.value = data
} }
async function loadQuotaUsage() { async function loadQuotaUsage() {
const requestId = ++quotaRequestId
quotaLoading.value = true quotaLoading.value = true
try { try {
const response = await adminApi.getQuotaUsage() const response = await adminApi.getQuotaUsage()
if (requestId !== quotaRequestId) return
quotaProviders.value = response.providers quotaProviders.value = response.providers
} finally { } finally {
quotaLoading.value = false if (requestId === quotaRequestId) {
quotaLoading.value = false
}
} }
} }
async function loadProviderStats() { async function loadProviderStats() {
providerStats.value = await usageApi.getUsageByProvider({ const requestId = ++providerStatsRequestId
const stats = await usageApi.getUsageByProvider({
...buildTimeRangeParams(), ...buildTimeRangeParams(),
limit: 8 limit: 8
}) })
if (requestId !== providerStatsRequestId) return
providerStats.value = stats
} }
async function loadAll() { async function loadAll() {
await Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()]) if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadForecast(), loadSavings(), loadQuotaUsage(), loadProviderStats()])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
if (hasPendingLoadAll) {
hasPendingLoadAll = false
void loadAll()
}
})
return loadAllPromise
} }
watch(timeRange, loadAll, { deep: true }) function scheduleLoadAll() {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
}
loadAllDebounceTimer = setTimeout(() => {
loadAllDebounceTimer = null
void loadAll()
}, 120)
}
onMounted(loadAll) watch(timeRange, scheduleLoadAll, { deep: true })
onMounted(() => {
void loadAll()
})
onUnmounted(() => {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
forecastRequestId += 1
savingsRequestId += 1
quotaRequestId += 1
providerStatsRequestId += 1
})
</script> </script>

View File

@@ -611,7 +611,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { import {
Plus, Plus,
Edit, Edit,
@@ -703,6 +703,11 @@ const editingModel = ref<GlobalModelResponse | null>(null)
const globalModels = ref<GlobalModelResponse[]>([]) const globalModels = ref<GlobalModelResponse[]>([])
const providers = ref<ProviderWithEndpointsSummary[]>([]) const providers = ref<ProviderWithEndpointsSummary[]>([])
const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000 const GLOBAL_MODELS_FETCH_PAGE_SIZE = 1000
let globalModelsRequestId = 0
let modelSelectionRequestId = 0
let modelProvidersRequestId = 0
let providersRequestId = 0
let providerOptionsRequest: Promise<void> | null = null
// 模型目录分页 // 模型目录分页
const catalogCurrentPage = ref(1) const catalogCurrentPage = ref(1)
@@ -1025,19 +1030,27 @@ watch([searchQuery, capabilityFilters], () => {
}, { deep: true }) }, { deep: true })
async function loadGlobalModels() { async function loadGlobalModels() {
const requestId = ++globalModelsRequestId
loading.value = true loading.value = true
try { try {
const allModels: GlobalModelResponse[] = [] const allModels: GlobalModelResponse[] = []
let skip = 0 let skip = 0
let expectedTotal: number | null = null
while (true) { while (true) {
const response = await listGlobalModels({ const response = await listGlobalModels({
skip, skip,
limit: GLOBAL_MODELS_FETCH_PAGE_SIZE, limit: GLOBAL_MODELS_FETCH_PAGE_SIZE,
}) })
if (expectedTotal === null && typeof response.total === 'number') {
expectedTotal = response.total
}
const pageModels = response.models || [] const pageModels = response.models || []
allModels.push(...pageModels) allModels.push(...pageModels)
if (expectedTotal !== null && allModels.length >= expectedTotal) {
break
}
if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) { if (pageModels.length < GLOBAL_MODELS_FETCH_PAGE_SIZE) {
break break
} }
@@ -1045,12 +1058,16 @@ async function loadGlobalModels() {
skip += pageModels.length skip += pageModels.length
} }
if (requestId !== globalModelsRequestId) return
globalModels.value = allModels globalModels.value = allModels
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== globalModelsRequestId) return
log.error('加载模型失败:', err) log.error('加载模型失败:', err)
showError(parseApiError(err, '加载模型失败'), '加载模型失败') showError(parseApiError(err, '加载模型失败'), '加载模型失败')
} finally { } finally {
loading.value = false if (requestId === globalModelsRequestId) {
loading.value = false
}
} }
} }
@@ -1064,6 +1081,7 @@ function handleRowClick(event: MouseEvent, model: GlobalModelResponse) {
} }
async function selectModel(model: GlobalModelResponse) { async function selectModel(model: GlobalModelResponse) {
const requestId = ++modelSelectionRequestId
// 先显示缓存数据,提升响应速度 // 先显示缓存数据,提升响应速度
selectedModel.value = model selectedModel.value = model
detailTab.value = 'basic' detailTab.value = 'basic'
@@ -1078,6 +1096,7 @@ async function selectModel(model: GlobalModelResponse) {
]) ])
// 更新为最新数据(如果获取成功) // 更新为最新数据(如果获取成功)
if (requestId !== modelSelectionRequestId) return
if (latestModel) { if (latestModel) {
selectedModel.value = latestModel selectedModel.value = latestModel
} }
@@ -1096,10 +1115,12 @@ async function refreshSelectedModel() {
// 加载指定模型的关联提供商 // 加载指定模型的关联提供商
async function loadModelProviders(_globalModelId: string) { async function loadModelProviders(_globalModelId: string) {
const requestId = ++modelProvidersRequestId
loadingModelProviders.value = true loadingModelProviders.value = true
try { try {
// 使用新的 API 获取所有关联提供商(包括非活跃的) // 使用新的 API 获取所有关联提供商(包括非活跃的)
const response = await getGlobalModelProviders(_globalModelId) const response = await getGlobalModelProviders(_globalModelId)
if (requestId !== modelProvidersRequestId) return
// 转换为展示格式 // 转换为展示格式
selectedModelProviders.value = response.providers.map(p => ({ selectedModelProviders.value = response.providers.map(p => ({
@@ -1124,11 +1145,14 @@ async function loadModelProviders(_globalModelId: string) {
supports_streaming: p.supports_streaming supports_streaming: p.supports_streaming
})) }))
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== modelProvidersRequestId) return
log.error('加载关联提供商失败:', err) log.error('加载关联提供商失败:', err)
showError(parseApiError(err, '加载关联提供商失败'), '错误') showError(parseApiError(err, '加载关联提供商失败'), '错误')
selectedModelProviders.value = [] selectedModelProviders.value = []
} finally { } finally {
loadingModelProviders.value = false if (requestId === modelProvidersRequestId) {
loadingModelProviders.value = false
}
} }
} }
@@ -1137,14 +1161,25 @@ async function ensureProviderOptions() {
if (providerOptions.value.length > 0 || loadingProviderOptions.value) { if (providerOptions.value.length > 0 || loadingProviderOptions.value) {
return return
} }
if (providerOptionsRequest) {
await providerOptionsRequest
return
}
providerOptionsRequest = (async () => {
try {
loadingProviderOptions.value = true
providerOptions.value = await getProvidersSummary()
} catch (err: unknown) {
const message = parseApiError(err, '加载 Provider 列表失败')
showError(message, '错误')
} finally {
loadingProviderOptions.value = false
}
})()
try { try {
loadingProviderOptions.value = true await providerOptionsRequest
providerOptions.value = await getProvidersSummary()
} catch (err: unknown) {
const message = parseApiError(err, '加载 Provider 列表失败')
showError(message, '错误')
} finally { } finally {
loadingProviderOptions.value = false providerOptionsRequest = null
} }
} }
@@ -1320,6 +1355,8 @@ async function confirmBatchDeleteModels() {
// 抽屉控制函数 // 抽屉控制函数
function handleDrawerOpenChange(value: boolean) { function handleDrawerOpenChange(value: boolean) {
if (!value && !hasBlockingDialogOpen.value) { if (!value && !hasBlockingDialogOpen.value) {
modelSelectionRequestId += 1
modelProvidersRequestId += 1
selectedModel.value = null selectedModel.value = null
} }
} }
@@ -1455,9 +1492,13 @@ async function refreshData() {
} }
async function loadProviders() { async function loadProviders() {
const requestId = ++providersRequestId
try { try {
providers.value = await getProvidersSummary() const nextProviders = await getProvidersSummary()
if (requestId !== providersRequestId) return
providers.value = nextProviders
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== providersRequestId) return
showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败') showError(parseApiError(err, '加载 Provider 列表失败'), '加载 Provider 列表失败')
} }
} }
@@ -1468,6 +1509,13 @@ onMounted(async () => {
loadProviders(), loadProviders(),
]) ])
}) })
onBeforeUnmount(() => {
globalModelsRequestId += 1
modelSelectionRequestId += 1
modelProvidersRequestId += 1
providersRequestId += 1
})
</script> </script>
<style scoped> <style scoped>

View File

@@ -91,7 +91,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import Card from '@/components/ui/card.vue' import Card from '@/components/ui/card.vue'
import { LoadingState, TimeRangePicker } from '@/components/common' import { LoadingState, TimeRangePicker } from '@/components/common'
import { ErrorDistributionChart, PercentileChart } from '@/components/stats' import { ErrorDistributionChart, PercentileChart } from '@/components/stats'
@@ -112,6 +112,12 @@ const errorLoading = ref(false)
const providerStatus = ref<ProviderStatus[]>([]) const providerStatus = ref<ProviderStatus[]>([])
const providerLoading = ref(false) const providerLoading = ref(false)
let percentilesRequestId = 0
let errorsRequestId = 0
let providersRequestId = 0
let loadAllPromise: Promise<void> | null = null
let hasPendingLoadAll = false
let loadAllDebounceTimer: ReturnType<typeof setTimeout> | null = null
function buildTimeRangeParams() { function buildTimeRangeParams() {
return { return {
@@ -124,31 +130,45 @@ function buildTimeRangeParams() {
} }
async function loadPercentiles() { async function loadPercentiles() {
const requestId = ++percentilesRequestId
percentileLoading.value = true percentileLoading.value = true
try { try {
percentiles.value = await adminApi.getPercentiles(buildTimeRangeParams()) const data = await adminApi.getPercentiles(buildTimeRangeParams())
if (requestId !== percentilesRequestId) return
percentiles.value = data
} finally { } finally {
percentileLoading.value = false if (requestId === percentilesRequestId) {
percentileLoading.value = false
}
} }
} }
async function loadErrors() { async function loadErrors() {
const requestId = ++errorsRequestId
errorLoading.value = true errorLoading.value = true
try { try {
const response = await adminApi.getErrorDistribution(buildTimeRangeParams()) const response = await adminApi.getErrorDistribution(buildTimeRangeParams())
if (requestId !== errorsRequestId) return
errorDistribution.value = response.distribution errorDistribution.value = response.distribution
errorTrend.value = response.trend errorTrend.value = response.trend
} finally { } finally {
errorLoading.value = false if (requestId === errorsRequestId) {
errorLoading.value = false
}
} }
} }
async function loadProviders() { async function loadProviders() {
const requestId = ++providersRequestId
providerLoading.value = true providerLoading.value = true
try { try {
providerStatus.value = await dashboardApi.getProviderStatus() const data = await dashboardApi.getProviderStatus()
if (requestId !== providersRequestId) return
providerStatus.value = data
} finally { } finally {
providerLoading.value = false if (requestId === providersRequestId) {
providerLoading.value = false
}
} }
} }
@@ -166,10 +186,47 @@ const errorTrendChartData = computed(() => ({
})) }))
async function loadAll() { async function loadAll() {
await Promise.all([loadPercentiles(), loadErrors(), loadProviders()]) if (loadAllPromise) {
hasPendingLoadAll = true
return loadAllPromise
}
loadAllPromise = Promise.all([loadPercentiles(), loadErrors(), loadProviders()])
.then(() => undefined)
.finally(() => {
loadAllPromise = null
if (hasPendingLoadAll) {
hasPendingLoadAll = false
void loadAll()
}
})
return loadAllPromise
} }
watch(timeRange, loadAll, { deep: true }) function scheduleLoadAll() {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
}
loadAllDebounceTimer = setTimeout(() => {
loadAllDebounceTimer = null
void loadAll()
}, 120)
}
onMounted(loadAll) watch(timeRange, scheduleLoadAll, { deep: true })
onMounted(() => {
void loadAll()
})
onUnmounted(() => {
if (loadAllDebounceTimer) {
clearTimeout(loadAllDebounceTimer)
loadAllDebounceTimer = null
}
hasPendingLoadAll = false
loadAllPromise = null
percentilesRequestId += 1
errorsRequestId += 1
providersRequestId += 1
})
</script> </script>

View File

@@ -1158,7 +1158,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { import {
Search, Search,
Upload, Upload,
@@ -1243,11 +1243,19 @@ const proxyNodesStore = useProxyNodesStore()
// --- Overview --- // --- Overview ---
const poolProviders = ref<PoolOverviewItem[]>([]) const poolProviders = ref<PoolOverviewItem[]>([])
const overviewLoading = ref(true) const overviewLoading = ref(true)
let overviewRequestId = 0
let selectProviderRequestId = 0
let providerDataRequestId = 0
let keysRequestId = 0
let keysSearchDebounceTimer: number | null = null
let suppressFiltersWatch = false
async function loadOverview() { async function loadOverview() {
const requestId = ++overviewRequestId
overviewLoading.value = true overviewLoading.value = true
try { try {
const res = await getPoolOverview() const res = await getPoolOverview()
if (requestId !== overviewRequestId) return
const enabledProviders = res.items.filter(item => item.pool_enabled) const enabledProviders = res.items.filter(item => item.pool_enabled)
poolProviders.value = enabledProviders poolProviders.value = enabledProviders
@@ -1266,9 +1274,12 @@ async function loadOverview() {
} }
} }
} catch (err) { } catch (err) {
if (requestId !== overviewRequestId) return
showError(parseApiError(err)) showError(parseApiError(err))
} finally { } finally {
overviewLoading.value = false if (requestId === overviewRequestId) {
overviewLoading.value = false
}
} }
} }
@@ -1306,6 +1317,7 @@ const showAccountQuotaColumn = computed(() => {
}) })
async function selectProvider(id: string) { async function selectProvider(id: string) {
const requestId = ++selectProviderRequestId
selectedProviderId.value = id selectedProviderId.value = id
editingKeyDetail.value = null editingKeyDetail.value = null
keyPermissionsDialogOpen.value = false keyPermissionsDialogOpen.value = false
@@ -1315,16 +1327,27 @@ async function selectProvider(id: string) {
proxyMobilePopoverOpenKeyId.value = null proxyMobilePopoverOpenKeyId.value = null
schedulingDetailDesktopPopoverOpenKeyId.value = null schedulingDetailDesktopPopoverOpenKeyId.value = null
schedulingDetailMobilePopoverOpenKeyId.value = null schedulingDetailMobilePopoverOpenKeyId.value = null
suppressFiltersWatch = true
currentPage.value = 1 currentPage.value = 1
searchQuery.value = '' searchQuery.value = ''
statusFilter.value = 'all' statusFilter.value = 'all'
suppressFiltersWatch = false
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
await Promise.all([loadKeys(), loadProviderData(id)]) await Promise.all([loadKeys(), loadProviderData(id)])
if (requestId !== selectProviderRequestId) return
} }
async function loadProviderData(id: string) { async function loadProviderData(id: string) {
const requestId = ++providerDataRequestId
try { try {
selectedProviderData.value = await getProvider(id) const providerData = await getProvider(id)
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
selectedProviderData.value = providerData
} catch { } catch {
if (requestId !== providerDataRequestId || selectedProviderId.value !== id) return
selectedProviderData.value = null selectedProviderData.value = null
} }
} }
@@ -1434,25 +1457,52 @@ async function refreshCurrentPage() {
async function loadKeys() { async function loadKeys() {
if (!selectedProviderId.value) return if (!selectedProviderId.value) return
const requestId = ++keysRequestId
const providerId = selectedProviderId.value
const page = currentPage.value
const pageSizeValue = pageSize.value
const search = searchQuery.value || undefined
const status = statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive'
keysLoading.value = true keysLoading.value = true
try { try {
keyPage.value = await listPoolKeys(selectedProviderId.value, { const nextPage = await listPoolKeys(providerId, {
page: currentPage.value, page,
page_size: pageSize.value, page_size: pageSizeValue,
search: searchQuery.value || undefined, search,
status: statusFilter.value as 'all' | 'active' | 'cooldown' | 'inactive', status,
}) })
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
keyPage.value = nextPage
} catch (err) { } catch (err) {
if (requestId !== keysRequestId || selectedProviderId.value !== providerId) return
showError(parseApiError(err)) showError(parseApiError(err))
} finally { } finally {
keysLoading.value = false if (requestId === keysRequestId) {
keysLoading.value = false
}
} }
} }
watch([currentPage, pageSize], () => loadKeys()) watch([currentPage, pageSize], () => {
watch([searchQuery, statusFilter], () => { void loadKeys()
})
watch(statusFilter, () => {
if (suppressFiltersWatch) return
currentPage.value = 1 currentPage.value = 1
loadKeys() void loadKeys()
})
watch(searchQuery, () => {
if (suppressFiltersWatch) return
currentPage.value = 1
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
}
keysSearchDebounceTimer = window.setTimeout(() => {
keysSearchDebounceTimer = null
void loadKeys()
}, 300)
}) })
function normalizeAuthTypeForEdit(authType: string): EndpointAPIKey['auth_type'] { function normalizeAuthTypeForEdit(authType: string): EndpointAPIKey['auth_type'] {
@@ -2224,4 +2274,15 @@ onMounted(async () => {
await loadOverview() await loadOverview()
void refreshCurrentPageQuotaInBackground({ silent: true }) void refreshCurrentPageQuotaInBackground({ silent: true })
}) })
onBeforeUnmount(() => {
if (keysSearchDebounceTimer !== null) {
clearTimeout(keysSearchDebounceTimer)
keysSearchDebounceTimer = null
}
overviewRequestId += 1
selectProviderRequestId += 1
providerDataRequestId += 1
keysRequestId += 1
})
</script> </script>

View File

@@ -226,6 +226,7 @@ const { confirmDanger } = useConfirm()
// 状态 // 状态
const loading = ref(false) const loading = ref(false)
const providers = ref<ProviderWithEndpointsSummary[]>([]) const providers = ref<ProviderWithEndpointsSummary[]>([])
let providersRequestId = 0
const providerDialogOpen = ref(false) const providerDialogOpen = ref(false)
const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null) const providerToEdit = ref<ProviderWithEndpointsSummary | null>(null)
const priorityDialogOpen = ref(false) const priorityDialogOpen = ref(false)
@@ -350,15 +351,21 @@ async function loadGlobalModelList() {
// 加载提供商列表 // 加载提供商列表
async function loadProviders() { async function loadProviders() {
const requestId = ++providersRequestId
loading.value = true loading.value = true
try { try {
providers.value = await getProvidersSummary() const nextProviders = await getProvidersSummary()
if (requestId !== providersRequestId) return
providers.value = nextProviders
// 异步加载配置了 ops 的 provider 的余额数据 // 异步加载配置了 ops 的 provider 的余额数据
loadBalances(providers.value) loadBalances(providers.value)
} catch (err: unknown) { } catch (err: unknown) {
if (requestId !== providersRequestId) return
showError(parseApiError(err, '加载提供商列表失败'), '错误') showError(parseApiError(err, '加载提供商列表失败'), '错误')
} finally { } finally {
loading.value = false if (requestId === providersRequestId) {
loading.value = false
}
} }
} }

View File

@@ -144,7 +144,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui' import { Card, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import LineChart from '@/components/charts/LineChart.vue' import LineChart from '@/components/charts/LineChart.vue'
import { LoadingState, TimeRangePicker } from '@/components/common' import { LoadingState, TimeRangePicker } from '@/components/common'
@@ -184,6 +184,15 @@ const summaryLoading = ref(false)
const series = ref<TimeSeriesItem[]>([]) const series = ref<TimeSeriesItem[]>([])
const comparisonSeries = ref<TimeSeriesItem[]>([]) const comparisonSeries = ref<TimeSeriesItem[]>([])
const seriesLoading = ref(false) const seriesLoading = ref(false)
let leaderboardRequestId = 0
let summaryRequestId = 0
let seriesRequestId = 0
let leaderboardLoadPromise: Promise<void> | null = null
let hasPendingLeaderboardLoad = false
let leaderboardDebounceTimer: ReturnType<typeof setTimeout> | null = null
let userPanelsLoadPromise: Promise<void> | null = null
let hasPendingUserPanelsLoad = false
let userPanelsDebounceTimer: ReturnType<typeof setTimeout> | null = null
function buildTimeRangeParams() { function buildTimeRangeParams() {
return { return {
@@ -204,6 +213,12 @@ async function loadUsers() {
} }
async function loadLeaderboard() { async function loadLeaderboard() {
if (leaderboardLoadPromise) {
hasPendingLeaderboardLoad = true
return leaderboardLoadPromise
}
leaderboardLoadPromise = (async () => {
const requestId = ++leaderboardRequestId
leaderboardLoading.value = true leaderboardLoading.value = true
try { try {
const response = await adminApi.getLeaderboardUsers({ const response = await adminApi.getLeaderboardUsers({
@@ -211,46 +226,90 @@ async function loadLeaderboard() {
metric: metric.value, metric: metric.value,
limit: 10 limit: 10
}) })
if (requestId !== leaderboardRequestId) return
leaderboard.value = response.items leaderboard.value = response.items
} finally { } finally {
leaderboardLoading.value = false if (requestId === leaderboardRequestId) {
leaderboardLoading.value = false
}
} }
})().finally(() => {
leaderboardLoadPromise = null
if (hasPendingLeaderboardLoad) {
hasPendingLeaderboardLoad = false
void loadLeaderboard()
}
})
return leaderboardLoadPromise
} }
async function loadSummary() { async function loadSummary() {
if (!selectedUserId.value) return if (!selectedUserId.value) return
const requestId = ++summaryRequestId
summaryLoading.value = true summaryLoading.value = true
try { try {
userSummary.value = await usageApi.getUsageStats({ const summary = await usageApi.getUsageStats({
...buildTimeRangeParams(), ...buildTimeRangeParams(),
user_id: selectedUserId.value user_id: selectedUserId.value
}) })
if (requestId !== summaryRequestId) return
userSummary.value = summary
} finally { } finally {
summaryLoading.value = false if (requestId === summaryRequestId) {
summaryLoading.value = false
}
} }
} }
async function loadSeries() { async function loadSeries() {
if (!selectedUserId.value) return if (!selectedUserId.value) return
const requestId = ++seriesRequestId
seriesLoading.value = true seriesLoading.value = true
try { try {
series.value = await adminApi.getTimeSeries({ const baseParams = {
...buildTimeRangeParams(), ...buildTimeRangeParams(),
user_id: selectedUserId.value user_id: selectedUserId.value
}) }
const shouldCompare = Boolean(compareUserId.value && compareUserId.value !== '__none__')
comparisonSeries.value = [] const comparePromise: Promise<TimeSeriesItem[]> = shouldCompare
if (compareUserId.value && compareUserId.value !== '__none__') { ? adminApi.getTimeSeries({
comparisonSeries.value = await adminApi.getTimeSeries({
...buildTimeRangeParams(), ...buildTimeRangeParams(),
user_id: compareUserId.value user_id: compareUserId.value
}) })
} : Promise.resolve([])
const [primarySeries, compareSeries] = await Promise.all([
adminApi.getTimeSeries(baseParams),
comparePromise
])
if (requestId !== seriesRequestId) return
series.value = primarySeries
comparisonSeries.value = compareSeries
} finally { } finally {
seriesLoading.value = false if (requestId === seriesRequestId) {
seriesLoading.value = false
}
} }
} }
async function loadUserPanels() {
if (userPanelsLoadPromise) {
hasPendingUserPanelsLoad = true
return userPanelsLoadPromise
}
userPanelsLoadPromise = Promise.all([loadSummary(), loadSeries()])
.then(() => undefined)
.finally(() => {
userPanelsLoadPromise = null
if (hasPendingUserPanelsLoad) {
hasPendingUserPanelsLoad = false
void loadUserPanels()
}
})
return userPanelsLoadPromise
}
const seriesChartData = computed(() => ({ const seriesChartData = computed(() => ({
labels: series.value.map(item => item.date), labels: series.value.map(item => item.date),
datasets: [ datasets: [
@@ -284,16 +343,52 @@ const comparisonChartData = computed(() => ({
] ]
})) }))
watch([timeRange, metric], loadLeaderboard, { deep: true }) function scheduleLeaderboardLoad() {
watch([timeRange, selectedUserId, compareUserId], () => { if (leaderboardDebounceTimer) {
loadSummary() clearTimeout(leaderboardDebounceTimer)
loadSeries() }
}, { deep: true }) leaderboardDebounceTimer = setTimeout(() => {
leaderboardDebounceTimer = null
void loadLeaderboard()
}, 120)
}
function scheduleUserPanelsLoad() {
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
}
userPanelsDebounceTimer = setTimeout(() => {
userPanelsDebounceTimer = null
void loadUserPanels()
}, 120)
}
watch([timeRange, metric], scheduleLeaderboardLoad, { deep: true })
watch([timeRange, selectedUserId, compareUserId], scheduleUserPanelsLoad, { deep: true })
onMounted(async () => { onMounted(async () => {
await loadUsers() await loadUsers()
await loadLeaderboard() await Promise.all([
await loadSummary() loadLeaderboard(),
await loadSeries() loadUserPanels()
])
})
onUnmounted(() => {
if (leaderboardDebounceTimer) {
clearTimeout(leaderboardDebounceTimer)
leaderboardDebounceTimer = null
}
if (userPanelsDebounceTimer) {
clearTimeout(userPanelsDebounceTimer)
userPanelsDebounceTimer = null
}
hasPendingLeaderboardLoad = false
hasPendingUserPanelsLoad = false
leaderboardLoadPromise = null
userPanelsLoadPromise = null
leaderboardRequestId += 1
summaryRequestId += 1
seriesRequestId += 1
}) })
</script> </script>

View File

@@ -793,6 +793,7 @@ const apiKeyInput = ref<HTMLInputElement>()
// 用户统计 // 用户统计
const userStats = ref<Record<string, UsageByUser>>({}) const userStats = ref<Record<string, UsageByUser>>({})
const loadingStats = ref(false) const loadingStats = ref(false)
let userStatsRequestId = 0
const searchQuery = ref('') const searchQuery = ref('')
const filterRole = ref('all') const filterRole = ref('all')
@@ -846,13 +847,17 @@ watch([searchQuery, filterRole, filterStatus], () => {
}) })
onMounted(async () => { onMounted(async () => {
await usersStore.fetchUsers() await Promise.all([
await loadUserStats() usersStore.fetchUsers(),
loadUserStats()
])
}) })
async function refreshUsers() { async function refreshUsers() {
await usersStore.fetchUsers() await Promise.all([
await loadUserStats() usersStore.fetchUsers(),
loadUserStats()
])
} }
function formatDate(dateString: string) { function formatDate(dateString: string) {
@@ -860,9 +865,11 @@ function formatDate(dateString: string) {
} }
async function loadUserStats() { async function loadUserStats() {
const requestId = ++userStatsRequestId
loadingStats.value = true loadingStats.value = true
try { try {
const data = await usageApi.getUsageByUser() const data = await usageApi.getUsageByUser()
if (requestId !== userStatsRequestId) return
userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => { userStats.value = data.reduce((acc: Record<string, UsageByUser>, stat: UsageByUser) => {
acc[stat.user_id] = stat acc[stat.user_id] = stat
return acc return acc
@@ -870,7 +877,9 @@ async function loadUserStats() {
} catch (err) { } catch (err) {
log.error('加载用户统计失败:', err) log.error('加载用户统计失败:', err)
} finally { } finally {
loadingStats.value = false if (requestId === userStatsRequestId) {
loadingStats.value = false
}
} }
} }

View File

@@ -64,7 +64,7 @@
id="compressed-log-retention-days" id="compressed-log-retention-days"
:model-value="compressedLogRetentionDays" :model-value="compressedLogRetentionDays"
type="number" type="number"
placeholder="90" placeholder="30"
class="mt-1" class="mt-1"
@update:model-value="$emit('update:compressedLogRetentionDays', Number($event))" @update:model-value="$emit('update:compressedLogRetentionDays', Number($event))"
/> />
@@ -98,7 +98,7 @@
for="log-retention-days" for="log-retention-days"
class="block text-sm font-medium" class="block text-sm font-medium"
> >
完整记录保天数 请求记录保天数
</Label> </Label>
<Input <Input
id="log-retention-days" id="log-retention-days"

View File

@@ -111,7 +111,7 @@ function createDefaultConfig(): SystemConfig {
// 请求记录清理 // 请求记录清理
enable_auto_cleanup: true, enable_auto_cleanup: true,
detail_log_retention_days: 7, detail_log_retention_days: 7,
compressed_log_retention_days: 90, compressed_log_retention_days: 30,
header_retention_days: 90, header_retention_days: 90,
log_retention_days: 365, log_retention_days: 365,
cleanup_batch_size: 1000, cleanup_batch_size: 1000,
@@ -179,7 +179,7 @@ export function useSystemConfig() {
systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size || systemConfig.value.max_request_body_size !== originalConfig.value.max_request_body_size ||
systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size || systemConfig.value.max_response_body_size !== originalConfig.value.max_response_body_size ||
JSON.stringify(systemConfig.value.sensitive_headers) !== JSON.stringify(systemConfig.value.sensitive_headers) !==
JSON.stringify(originalConfig.value.sensitive_headers) JSON.stringify(originalConfig.value.sensitive_headers)
) )
}) })
@@ -187,14 +187,14 @@ export function useSystemConfig() {
if (!originalConfig.value) return false if (!originalConfig.value) return false
return ( return (
systemConfig.value.detail_log_retention_days !== systemConfig.value.detail_log_retention_days !==
originalConfig.value.detail_log_retention_days || originalConfig.value.detail_log_retention_days ||
systemConfig.value.compressed_log_retention_days !== systemConfig.value.compressed_log_retention_days !==
originalConfig.value.compressed_log_retention_days || originalConfig.value.compressed_log_retention_days ||
systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days || systemConfig.value.header_retention_days !== originalConfig.value.header_retention_days ||
systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days || systemConfig.value.log_retention_days !== originalConfig.value.log_retention_days ||
systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size || systemConfig.value.cleanup_batch_size !== originalConfig.value.cleanup_batch_size ||
systemConfig.value.audit_log_retention_days !== systemConfig.value.audit_log_retention_days !==
originalConfig.value.audit_log_retention_days originalConfig.value.audit_log_retention_days
) )
}) })
@@ -231,7 +231,7 @@ export function useSystemConfig() {
try { try {
const response = await adminApi.getSystemConfig(key) const response = await adminApi.getSystemConfig(key)
if (response.value !== null && response.value !== undefined) { if (response.value !== null && response.value !== undefined) {
;(systemConfig.value as Record<string, unknown>)[key] = response.value ; (systemConfig.value as Record<string, unknown>)[key] = response.value
} }
} catch { } catch {
// 配置不存在时使用默认值,无需处理 // 配置不存在时使用默认值,无需处理

View File

@@ -987,6 +987,10 @@ const dailyTimeRange = ref<DateRangeParams>(getDateRangeFromPeriod('last7days'))
// 统计周期 // 统计周期
const loadingDaily = ref(false) const loadingDaily = ref(false)
const loading = ref(false) const loading = ref(false)
let dailyStatsRequestId = 0
let dailyStatsLoadPromise: Promise<void> | null = null
let hasPendingDailyStatsLoad = false
let dailyStatsDebounceTimer: ReturnType<typeof setTimeout> | null = null
// 公告 // 公告
@@ -1279,7 +1283,8 @@ onMounted(async () => {
} }
await Promise.all([ await Promise.all([
loadDashboardData(), loadDashboardData(),
loadAnnouncements() loadAnnouncements(),
loadDailyStats()
]) ])
await nextTick() await nextTick()
setupTimelineResizeObserver() setupTimelineResizeObserver()
@@ -1298,6 +1303,13 @@ onBeforeUnmount(() => {
statsPanelObserver = null statsPanelObserver = null
announcementsTimelineObserver?.disconnect() announcementsTimelineObserver?.disconnect()
announcementsTimelineObserver = null announcementsTimelineObserver = null
if (dailyStatsDebounceTimer) {
clearTimeout(dailyStatsDebounceTimer)
dailyStatsDebounceTimer = null
}
hasPendingDailyStatsLoad = false
dailyStatsLoadPromise = null
dailyStatsRequestId += 1
}) })
async function loadDashboardData() { async function loadDashboardData() {
@@ -1326,22 +1338,48 @@ async function loadDashboardData() {
} }
async function loadDailyStats() { async function loadDailyStats() {
loadingDaily.value = true if (dailyStatsLoadPromise) {
try { hasPendingDailyStatsLoad = true
const response = await dashboardApi.getDailyStats(dailyTimeRange.value) return dailyStatsLoadPromise
dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch {
dailyStats.value = []
providerSummary.value = []
} finally {
loadingDaily.value = false
} }
const requestId = ++dailyStatsRequestId
loadingDaily.value = true
dailyStatsLoadPromise = (async () => {
try {
const response = await dashboardApi.getDailyStats(dailyTimeRange.value)
if (requestId !== dailyStatsRequestId) return
dailyStats.value = response.daily_stats
providerSummary.value = response.provider_summary || []
} catch {
if (requestId !== dailyStatsRequestId) return
dailyStats.value = []
providerSummary.value = []
} finally {
if (requestId === dailyStatsRequestId) {
loadingDaily.value = false
}
}
})().finally(() => {
dailyStatsLoadPromise = null
if (hasPendingDailyStatsLoad) {
hasPendingDailyStatsLoad = false
void loadDailyStats()
}
})
return dailyStatsLoadPromise
} }
watch(dailyTimeRange, async () => { function scheduleDailyStatsLoad() {
await loadDailyStats() if (dailyStatsDebounceTimer) {
}, { deep: true }) clearTimeout(dailyStatsDebounceTimer)
}
dailyStatsDebounceTimer = setTimeout(() => {
dailyStatsDebounceTimer = null
void loadDailyStats()
}, 120)
}
watch(dailyTimeRange, scheduleDailyStatsLoad, { deep: true })
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
const date = new Date(dateString) const date = new Date(dateString)

View File

@@ -247,14 +247,17 @@ const hasActiveRequests = computed(() => activeRequestIds.value.length > 0)
// 自动刷新定时器 // 自动刷新定时器
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null let globalAutoRefreshTimer: ReturnType<typeof setInterval> | null = null
let refreshInFlight: Promise<void> | null = null
const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次用于活跃请求 const AUTO_REFRESH_INTERVAL = 1000 // 1秒刷新一次用于活跃请求
const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次全局自动刷新 const GLOBAL_AUTO_REFRESH_INTERVAL = 5000 // 5秒刷新一次全局自动刷新
const globalAutoRefresh = ref(false) // 全局自动刷新开关 const globalAutoRefresh = ref(false) // 全局自动刷新开关
const isPageVisible = ref(typeof document === 'undefined' ? true : !document.hidden)
// 轮询活跃请求状态(轻量级,只更新状态变化的记录) // 轮询活跃请求状态(轻量级,只更新状态变化的记录)
let pollInFlight = false let pollInFlight = false
async function pollActiveRequests() { async function pollActiveRequests() {
if (!isPageVisible.value) return
if (!hasActiveRequests.value) return if (!hasActiveRequests.value) return
if (pollInFlight) return if (pollInFlight) return
pollInFlight = true pollInFlight = true
@@ -338,6 +341,7 @@ async function pollActiveRequests() {
// 启动自动刷新 // 启动自动刷新
function startAutoRefresh() { function startAutoRefresh() {
if (!isPageVisible.value) return
if (autoRefreshTimer) return if (autoRefreshTimer) return
autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL) autoRefreshTimer = setInterval(pollActiveRequests, AUTO_REFRESH_INTERVAL)
} }
@@ -353,7 +357,7 @@ function stopAutoRefresh() {
// 监听活跃请求状态,自动启动/停止刷新 // 监听活跃请求状态,自动启动/停止刷新
// 1秒轮询始终用于活跃请求的实时更新不受全局刷新影响 // 1秒轮询始终用于活跃请求的实时更新不受全局刷新影响
watch(hasActiveRequests, (hasActive) => { watch(hasActiveRequests, (hasActive) => {
if (hasActive) { if (hasActive && isPageVisible.value) {
startAutoRefresh() startAutoRefresh()
} else { } else {
stopAutoRefresh() stopAutoRefresh()
@@ -362,6 +366,7 @@ watch(hasActiveRequests, (hasActive) => {
// 启动全局自动刷新 // 启动全局自动刷新
function startGlobalAutoRefresh() { function startGlobalAutoRefresh() {
if (!isPageVisible.value) return
if (globalAutoRefreshTimer) return if (globalAutoRefreshTimer) return
globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL) globalAutoRefreshTimer = setInterval(refreshData, GLOBAL_AUTO_REFRESH_INTERVAL)
} }
@@ -378,15 +383,34 @@ function stopGlobalAutoRefresh() {
function handleAutoRefreshChange(value: boolean) { function handleAutoRefreshChange(value: boolean) {
globalAutoRefresh.value = value globalAutoRefresh.value = value
if (value) { if (value) {
refreshData() // 立即刷新一次 if (isPageVisible.value) {
refreshData() // 立即刷新一次
}
startGlobalAutoRefresh() startGlobalAutoRefresh()
} else { } else {
stopGlobalAutoRefresh() stopGlobalAutoRefresh()
} }
} }
function handleVisibilityChange() {
isPageVisible.value = !document.hidden
if (!isPageVisible.value) {
stopAutoRefresh()
stopGlobalAutoRefresh()
return
}
if (hasActiveRequests.value) {
startAutoRefresh()
}
if (globalAutoRefresh.value) {
refreshData()
startGlobalAutoRefresh()
}
}
// 组件卸载时清理定时器 // 组件卸载时清理定时器
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
stopAutoRefresh() stopAutoRefresh()
stopGlobalAutoRefresh() stopGlobalAutoRefresh()
}) })
@@ -419,6 +443,8 @@ const selectedRequestId = ref<string | null>(null)
// 初始化加载 // 初始化加载
onMounted(async () => { onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
// 所有数据源并行加载stats/heatmap/records/users 之间没有数据依赖) // 所有数据源并行加载stats/heatmap/records/users 之间没有数据依赖)
const statsTask = loadStats(timeRange.value).catch(err => { const statsTask = loadStats(timeRange.value).catch(err => {
log.error('加载统计数据失败:', err) log.error('加载统计数据失败:', err)
@@ -548,11 +574,28 @@ async function handleFilterStatusChange(value: string) {
// 刷新数据 // 刷新数据
async function refreshData() { async function refreshData() {
await loadStats(timeRange.value) if (!isPageVisible.value) return
if (isAdminPage.value) { if (refreshInFlight) return refreshInFlight
await loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
refreshInFlight = (async () => {
if (isAdminPage.value) {
// loadStats 会同步更新 currentDateRange随后 loadRecords 复用同一时间范围
await Promise.all([
loadStats(timeRange.value),
loadRecords({ page: currentPage.value, pageSize: pageSize.value }, getCurrentFilters())
])
return
}
await loadStats(timeRange.value)
// 用户页面loadStats 已包含记录加载
})()
try {
await refreshInFlight
} finally {
refreshInFlight = null
} }
// 用户页面loadStats 已包含记录加载
} }
// 显示请求详情 // 显示请求详情

View File

@@ -11,6 +11,7 @@ from typing import Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
@@ -366,7 +367,7 @@ class AdminListStandaloneKeysAdapter(AdminApiAdapter):
if self.is_active is not None: if self.is_active is not None:
query = query.filter(ApiKey.is_active == self.is_active) query = query.filter(ApiKey.is_active == self.is_active)
total = query.count() total = int(query.with_entities(func.count(ApiKey.id)).scalar() or 0)
api_keys = ( api_keys = (
query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all() query.order_by(ApiKey.created_at.desc()).offset(self.skip).limit(self.limit).all()
) )

View File

@@ -13,6 +13,7 @@ from typing import Any, Literal
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -251,7 +252,7 @@ class BillingRuleListAdapter(AdminApiAdapter):
if self.is_enabled is not None: if self.is_enabled is not None:
q = q.filter(BillingRule.is_enabled == self.is_enabled) q = q.filter(BillingRule.is_enabled == self.is_enabled)
total = q.count() total = int(q.with_entities(func.count(BillingRule.id)).scalar() or 0)
items = ( items = (
q.order_by(BillingRule.updated_at.desc()) q.order_by(BillingRule.updated_at.desc())
.offset((self.page - 1) * self.page_size) .offset((self.page - 1) * self.page_size)
@@ -365,7 +366,7 @@ class DimensionCollectorListAdapter(AdminApiAdapter):
if self.is_enabled is not None: if self.is_enabled is not None:
q = q.filter(DimensionCollector.is_enabled == self.is_enabled) q = q.filter(DimensionCollector.is_enabled == self.is_enabled)
total = q.count() total = int(q.with_entities(func.count(DimensionCollector.id)).scalar() or 0)
items = ( items = (
q.order_by(DimensionCollector.updated_at.desc()) q.order_by(DimensionCollector.updated_at.desc())
.offset((self.page - 1) * self.page_size) .offset((self.page - 1) * self.page_size)

View File

@@ -21,7 +21,7 @@ from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import delete, func from sqlalchemy import delete, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, load_only
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
@@ -106,26 +106,45 @@ async def list_file_mappings(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
query = db.query(GeminiFileMapping) query = db.query(GeminiFileMapping)
count_query = db.query(func.count(GeminiFileMapping.id))
# 过滤过期 # 过滤过期
if not include_expired: if not include_expired:
query = query.filter(GeminiFileMapping.expires_at > now) active_filter = GeminiFileMapping.expires_at > now
query = query.filter(active_filter)
count_query = count_query.filter(active_filter)
# 搜索 # 搜索
if search: if search:
search_pattern = f"%{search}%" search_pattern = f"%{search}%"
query = query.filter( search_filter = (GeminiFileMapping.file_name.ilike(search_pattern)) | (
(GeminiFileMapping.file_name.ilike(search_pattern)) GeminiFileMapping.display_name.ilike(search_pattern)
| (GeminiFileMapping.display_name.ilike(search_pattern))
) )
query = query.filter(search_filter)
count_query = count_query.filter(search_filter)
# 总数 # 总数
total = query.count() total = int(count_query.scalar() or 0)
# 分页 # 分页
offset = (page - 1) * page_size offset = (page - 1) * page_size
mappings = ( mappings = (
query.order_by(GeminiFileMapping.created_at.desc()).offset(offset).limit(page_size).all() query.options(
load_only(
GeminiFileMapping.id,
GeminiFileMapping.file_name,
GeminiFileMapping.key_id,
GeminiFileMapping.user_id,
GeminiFileMapping.display_name,
GeminiFileMapping.mime_type,
GeminiFileMapping.created_at,
GeminiFileMapping.expires_at,
)
)
.order_by(GeminiFileMapping.created_at.desc())
.offset(offset)
.limit(page_size)
.all()
) )
# 获取关联的 Key 和 User 信息 # 获取关联的 Key 和 User 信息
@@ -134,12 +153,22 @@ async def list_file_mappings(
keys_map = {} keys_map = {}
if key_ids: if key_ids:
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.id.in_(key_ids)).all() keys = (
db.query(ProviderAPIKey)
.options(load_only(ProviderAPIKey.id, ProviderAPIKey.name))
.filter(ProviderAPIKey.id.in_(key_ids))
.all()
)
keys_map = {str(k.id): k.name for k in keys} keys_map = {str(k.id): k.name for k in keys}
users_map = {} users_map = {}
if user_ids: if user_ids:
users = db.query(User).filter(User.id.in_(user_ids)).all() users = (
db.query(User)
.options(load_only(User.id, User.username))
.filter(User.id.in_(user_ids))
.all()
)
users_map = {str(u.id): u.username for u in users} users_map = {str(u.id): u.username for u in users}
items = [] items = []
@@ -199,9 +228,11 @@ async def get_file_mapping_stats(
by_mime_type = {(mt or "unknown"): count for mt, count in mime_stats} by_mime_type = {(mt or "unknown"): count for mt, count in mime_stats}
# 有 gemini_files 能力的 Key 数量 # 有 gemini_files 能力的 Key 数量
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.is_active.is_(True)).all() keys = db.query(ProviderAPIKey.capabilities).filter(ProviderAPIKey.is_active.is_(True)).all()
capable_keys_count = sum( capable_keys_count = sum(
1 for key in keys if key.capabilities and key.capabilities.get("gemini_files", False) 1
for (capabilities,) in keys
if isinstance(capabilities, dict) and capabilities.get("gemini_files", False)
) )
return FileMappingStatsResponse( return FileMappingStatsResponse(
@@ -294,15 +325,28 @@ async def list_capable_keys(
"""获取所有具有 gemini_files 能力的 Key 列表""" """获取所有具有 gemini_files 能力的 Key 列表"""
from src.models.database import Provider from src.models.database import Provider
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.is_active.is_(True)).all() key_rows = (
db.query(
ProviderAPIKey.id,
ProviderAPIKey.name,
ProviderAPIKey.provider_id,
ProviderAPIKey.capabilities,
)
.filter(ProviderAPIKey.is_active.is_(True))
.all()
)
capable_keys = [ capable_keys = [
key for key in keys if key.capabilities and key.capabilities.get("gemini_files", False) key
for key in key_rows
if isinstance(key.capabilities, dict) and key.capabilities.get("gemini_files", False)
] ]
# 获取 Provider 名称 # 获取 Provider 名称
provider_ids = {key.provider_id for key in capable_keys} provider_ids = {key.provider_id for key in capable_keys if key.provider_id}
providers = db.query(Provider).filter(Provider.id.in_(provider_ids)).all() provider_map: dict[str, str] = {}
provider_map = {str(p.id): p.name for p in providers} if provider_ids:
providers = db.query(Provider.id, Provider.name).filter(Provider.id.in_(provider_ids)).all()
provider_map = {str(provider_id): provider_name for provider_id, provider_name in providers}
return [ return [
CapableKeyResponse( CapableKeyResponse(
@@ -454,6 +498,15 @@ async def upload_file(
with create_session() as db: with create_session() as db:
keys = ( keys = (
db.query(ProviderAPIKey) db.query(ProviderAPIKey)
.options(
load_only(
ProviderAPIKey.id,
ProviderAPIKey.name,
ProviderAPIKey.api_key,
ProviderAPIKey.capabilities,
ProviderAPIKey.is_active,
)
)
.filter( .filter(
ProviderAPIKey.id.in_(key_id_list), ProviderAPIKey.id.in_(key_id_list),
ProviderAPIKey.is_active.is_(True), ProviderAPIKey.is_active.is_(True),
@@ -483,6 +536,7 @@ async def upload_file(
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
existing = ( existing = (
db.query(GeminiFileMapping) db.query(GeminiFileMapping)
.options(load_only(GeminiFileMapping.key_id, GeminiFileMapping.file_name))
.filter( .filter(
GeminiFileMapping.source_hash == source_hash, GeminiFileMapping.source_hash == source_hash,
GeminiFileMapping.key_id.in_(capable_key_ids), GeminiFileMapping.key_id.in_(capable_key_ids),

View File

@@ -7,6 +7,7 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
@@ -190,7 +191,7 @@ class AdminListManagementTokensAdapter(AdminManagementTokenApiAdapter):
if self.is_active is not None: if self.is_active is not None:
query = query.filter(ManagementToken.is_active == self.is_active) query = query.filter(ManagementToken.is_active == self.is_active)
total = query.count() total = int(query.with_entities(func.count(ManagementToken.id)).scalar() or 0)
tokens = ( tokens = (
query.order_by(ManagementToken.created_at.desc()) query.order_by(ManagementToken.created_at.desc())
.offset(self.skip) .offset(self.skip)

View File

@@ -276,17 +276,24 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
search: str | None search: str | None
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from sqlalchemy import and_, case, func from sqlalchemy import and_, case, func, or_
from src.models.database import Model, Provider from src.models.database import GlobalModel, Model, Provider
models = GlobalModelService.list_global_models( query = context.db.query(GlobalModel)
db=context.db, if self.is_active is not None:
skip=self.skip, query = query.filter(GlobalModel.is_active == self.is_active)
limit=self.limit, if self.search:
is_active=self.is_active, search_pattern = f"%{self.search}%"
search=self.search, query = query.filter(
) or_(
GlobalModel.name.ilike(search_pattern),
GlobalModel.display_name.ilike(search_pattern),
)
)
total = int(query.with_entities(func.count(GlobalModel.id)).scalar() or 0)
models = query.order_by(GlobalModel.name).offset(self.skip).limit(self.limit).all()
# 一次性查询所有 GlobalModel 的 provider_count优化 N+1 问题) # 一次性查询所有 GlobalModel 的 provider_count优化 N+1 问题)
# 用条件聚合同时获取总数和活跃数,减少一次 DB 往返 # 用条件聚合同时获取总数和活跃数,减少一次 DB 往返
@@ -331,7 +338,7 @@ class AdminListGlobalModelsAdapter(AdminApiAdapter):
return GlobalModelListResponse( return GlobalModelListResponse(
models=model_responses, models=model_responses,
total=len(models), total=total,
) )
@@ -349,10 +356,9 @@ class AdminGetGlobalModelAdapter(AdminApiAdapter):
global_model = GlobalModelService.get_global_model(context.db, self.global_model_id) global_model = GlobalModelService.get_global_model(context.db, self.global_model_id)
stats = GlobalModelService.get_global_model_stats(context.db, self.global_model_id) stats = GlobalModelService.get_global_model_stats(context.db, self.global_model_id)
# 查询 provider_count 和 active_provider_count与列表 API 一致) # total_providers 已由 stats 提供,这里只查询活跃 provider 数量
count_row = ( active_count = (
context.db.query( context.db.query(
func.count(func.distinct(Model.provider_id)),
func.count( func.count(
func.distinct( func.distinct(
case( case(
@@ -366,17 +372,17 @@ class AdminGetGlobalModelAdapter(AdminApiAdapter):
else_=None, else_=None,
) )
) )
), )
) )
.join(Provider, Model.provider_id == Provider.id) .join(Provider, Model.provider_id == Provider.id)
.filter(Model.global_model_id == global_model.id) .filter(Model.global_model_id == global_model.id)
.first() .scalar()
or 0
) )
total_count, active_count = count_row if count_row else (0, 0)
response = GlobalModelResponse.model_validate(global_model) response = GlobalModelResponse.model_validate(global_model)
response.provider_count = total_count response.provider_count = stats["total_providers"]
response.active_provider_count = active_count response.active_provider_count = int(active_count)
return GlobalModelWithStats( return GlobalModelWithStats(
**response.model_dump(), **response.model_dump(),

View File

@@ -7,13 +7,14 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pagination import PaginationMeta, build_pagination_payload, paginate_query from src.api.base.pagination import PaginationMeta, build_pagination_payload
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.database import ( from src.models.database import (
@@ -26,6 +27,7 @@ from src.models.database import (
from src.models.database import User as DBUser from src.models.database import User as DBUser
from src.services.health.monitor import HealthMonitor from src.services.health.monitor import HealthMonitor
from src.services.system.audit import audit_service from src.services.system.audit import audit_service
from src.utils.cache_decorator import cache_result
from src.utils.database_helpers import escape_like_pattern from src.utils.database_helpers import escape_like_pattern
router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"]) router = APIRouter(prefix="/api/admin/monitoring", tags=["Admin - Monitoring"])
@@ -223,10 +225,26 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
# 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志 # 查看审计日志本身不应该产生审计记录,避免刷新页面时产生大量无意义的日志
audit_log_enabled: bool = False audit_log_enabled: bool = False
@cache_result(
key_prefix="admin:monitoring:audit-logs",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["username", "event_type", "days", "limit", "offset"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days) cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.days)
count_query = db.query(func.count(AuditLog.id)).filter(AuditLog.created_at >= cutoff_time)
if self.username:
escaped = escape_like_pattern(self.username)
count_query = count_query.outerjoin(DBUser, AuditLog.user_id == DBUser.id).filter(
DBUser.username.ilike(f"%{escaped}%", escape="\\")
)
if self.event_type:
count_query = count_query.filter(AuditLog.event_type == self.event_type)
total = int(count_query.scalar() or 0)
base_query = ( base_query = (
db.query(AuditLog, DBUser) db.query(AuditLog, DBUser)
.outerjoin(DBUser, AuditLog.user_id == DBUser.id) .outerjoin(DBUser, AuditLog.user_id == DBUser.id)
@@ -238,8 +256,12 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
if self.event_type: if self.event_type:
base_query = base_query.filter(AuditLog.event_type == self.event_type) base_query = base_query.filter(AuditLog.event_type == self.event_type)
ordered_query = base_query.order_by(AuditLog.created_at.desc()) logs_with_users = (
total, logs_with_users = paginate_query(ordered_query, self.limit, self.offset) base_query.order_by(AuditLog.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
items = [ items = [
{ {
@@ -287,39 +309,51 @@ class AdminGetAuditLogsAdapter(AdminApiAdapter):
class AdminSystemStatusAdapter(AdminApiAdapter): class AdminSystemStatusAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:monitoring:system-status",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
total_users = db.query(func.count(DBUser.id)).scalar() user_stats = db.query(
active_users = db.query(func.count(DBUser.id)).filter(DBUser.is_active.is_(True)).scalar() func.count(DBUser.id).label("total"),
func.sum(case((DBUser.is_active.is_(True), 1), else_=0)).label("active"),
).first()
total_users = int((user_stats.total if user_stats else 0) or 0)
active_users = int((user_stats.active if user_stats else 0) or 0)
total_providers = db.query(func.count(Provider.id)).scalar() provider_stats = db.query(
active_providers = ( func.count(Provider.id).label("total"),
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() func.sum(case((Provider.is_active.is_(True), 1), else_=0)).label("active"),
) ).first()
total_providers = int((provider_stats.total if provider_stats else 0) or 0)
active_providers = int((provider_stats.active if provider_stats else 0) or 0)
total_api_keys = db.query(func.count(ApiKey.id)).scalar() api_key_stats = db.query(
active_api_keys = ( func.count(ApiKey.id).label("total"),
db.query(func.count(ApiKey.id)).filter(ApiKey.is_active.is_(True)).scalar() func.sum(case((ApiKey.is_active.is_(True), 1), else_=0)).label("active"),
) ).first()
total_api_keys = int((api_key_stats.total if api_key_stats else 0) or 0)
active_api_keys = int((api_key_stats.active if api_key_stats else 0) or 0)
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_requests = ( today_stats = (
db.query(func.count(Usage.id)).filter(Usage.created_at >= today_start).scalar() db.query(
) func.count(Usage.id).label("requests"),
today_tokens = ( func.coalesce(func.sum(Usage.total_tokens), 0).label("tokens"),
db.query(func.sum(Usage.total_tokens)).filter(Usage.created_at >= today_start).scalar() func.coalesce(func.sum(Usage.total_cost_usd), 0.0).label("cost"),
or 0 )
)
today_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(Usage.created_at >= today_start) .filter(Usage.created_at >= today_start)
.scalar() .first()
or 0
) )
today_requests = int((today_stats.requests if today_stats else 0) or 0)
today_tokens = int((today_stats.tokens if today_stats else 0) or 0)
today_cost = float((today_stats.cost if today_stats else 0.0) or 0.0)
recent_errors = ( recent_errors = (
db.query(AuditLog) db.query(func.count(AuditLog.id))
.filter( .filter(
AuditLog.event_type.in_( AuditLog.event_type.in_(
[ [
@@ -329,20 +363,21 @@ class AdminSystemStatusAdapter(AdminApiAdapter):
), ),
AuditLog.created_at >= datetime.now(timezone.utc) - timedelta(hours=1), AuditLog.created_at >= datetime.now(timezone.utc) - timedelta(hours=1),
) )
.count() .scalar()
or 0
) )
context.add_audit_metadata( context.add_audit_metadata(
action="system_status_snapshot", action="system_status_snapshot",
total_users=int(total_users or 0), total_users=total_users,
active_users=int(active_users or 0), active_users=active_users,
total_providers=int(total_providers or 0), total_providers=total_providers,
active_providers=int(active_providers or 0), active_providers=active_providers,
total_api_keys=int(total_api_keys or 0), total_api_keys=total_api_keys,
active_api_keys=int(active_api_keys or 0), active_api_keys=active_api_keys,
today_requests=int(today_requests or 0), today_requests=today_requests,
today_tokens=int(today_tokens or 0), today_tokens=today_tokens,
today_cost=float(today_cost or 0.0), today_cost=today_cost,
recent_errors=int(recent_errors or 0), recent_errors=int(recent_errors or 0),
) )

View File

@@ -617,13 +617,68 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
.all() .all()
) )
# 先批量计算池化 Provider 的 Key 总数/启用数,避免每个 Provider 单独查询N+1
pool_provider_ids: list[str] = []
pool_enabled_map: dict[str, bool] = {}
for p in providers:
pid = str(p.id)
enabled = parse_pool_config(getattr(p, "config", None)) is not None
pool_enabled_map[pid] = enabled
if enabled:
pool_provider_ids.append(pid)
key_ids_by_provider: dict[str, list[str]] = {pid: [] for pid in pool_provider_ids}
key_stats_by_provider: dict[str, dict[str, int]] = {
pid: {"total": 0, "active": 0} for pid in pool_provider_ids
}
if pool_provider_ids:
key_rows = (
db.query(
ProviderAPIKey.provider_id,
ProviderAPIKey.id,
ProviderAPIKey.is_active,
)
.filter(ProviderAPIKey.provider_id.in_(pool_provider_ids))
.all()
)
for provider_id, key_id, is_active in key_rows:
pid = str(provider_id)
kid = str(key_id)
key_ids_by_provider.setdefault(pid, []).append(kid)
stats = key_stats_by_provider.setdefault(pid, {"total": 0, "active": 0})
stats["total"] += 1
if is_active:
stats["active"] += 1
# Redis 冷却状态并发获取,避免逐 Provider 串行等待。
cooldown_count_by_provider: dict[str, int] = {}
cooldown_targets = [
(pid, key_ids) for pid, key_ids in key_ids_by_provider.items() if key_ids
]
if cooldown_targets:
cooldown_results = await asyncio.gather(
*[
pool_redis.batch_get_cooldowns(pid, key_ids)
for pid, key_ids in cooldown_targets
],
return_exceptions=True,
)
for (pid, _key_ids), result in zip(cooldown_targets, cooldown_results, strict=False):
if isinstance(result, Exception):
logger.warning(
"池管理概览读取冷却状态失败",
extra={"provider_id": pid, "error": str(result)},
)
cooldown_count_by_provider[pid] = 0
else:
cooldown_count_by_provider[pid] = sum(
1 for value in result.values() if value is not None
)
items: list[PoolOverviewItem] = [] items: list[PoolOverviewItem] = []
for p in providers: for p in providers:
pid = str(p.id) pid = str(p.id)
pcfg = parse_pool_config(getattr(p, "config", None)) if not pool_enabled_map.get(pid, False):
# Non-pool providers: skip Redis + key queries entirely.
if pcfg is None:
items.append( items.append(
PoolOverviewItem( PoolOverviewItem(
provider_id=pid, provider_id=pid,
@@ -634,22 +689,16 @@ class AdminPoolOverviewAdapter(AdminApiAdapter):
) )
continue continue
keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == pid).all() key_stats = key_stats_by_provider.get(pid, {"total": 0, "active": 0})
key_ids = [str(k.id) for k in keys]
cooldown_count = 0
if key_ids:
cooldowns = await pool_redis.batch_get_cooldowns(pid, key_ids)
cooldown_count = sum(1 for v in cooldowns.values() if v is not None)
items.append( items.append(
PoolOverviewItem( PoolOverviewItem(
provider_id=pid, provider_id=pid,
provider_name=p.name, provider_name=p.name,
provider_type=str(getattr(p, "provider_type", "custom") or "custom"), provider_type=str(getattr(p, "provider_type", "custom") or "custom"),
total_keys=len(keys), total_keys=key_stats["total"],
active_keys=sum(1 for k in keys if k.is_active), active_keys=key_stats["active"],
cooldown_count=cooldown_count, cooldown_count=cooldown_count_by_provider.get(pid, 0),
pool_enabled=True, pool_enabled=True,
) )
) )
@@ -688,7 +737,7 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
q = q.filter(ProviderAPIKey.is_active.is_(False)) q = q.filter(ProviderAPIKey.is_active.is_(False))
# "cooldown" filtering is done post-query (Redis state) # "cooldown" filtering is done post-query (Redis state)
total = q.count() total = 0
# For cooldown filtering we need to fetch all, then filter, then paginate. # For cooldown filtering we need to fetch all, then filter, then paginate.
# Limit scan range to avoid loading the entire table into memory. # Limit scan range to avoid loading the entire table into memory.
@@ -702,6 +751,7 @@ class AdminListPoolKeysAdapter(AdminApiAdapter):
offset = (self.page - 1) * self.page_size offset = (self.page - 1) * self.page_size
keys = all_keys[offset : offset + self.page_size] keys = all_keys[offset : offset + self.page_size]
else: else:
total = int(q.with_entities(func.count(ProviderAPIKey.id)).scalar() or 0)
offset = (self.page - 1) * self.page_size offset = (self.page - 1) * self.page_size
keys = ( keys = (
q.order_by(ProviderAPIKey.created_at.desc()) q.order_by(ProviderAPIKey.created_at.desc())

View File

@@ -9,12 +9,14 @@ from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, ConfigDict, Field, ValidationError from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy.orm import Session from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.enums import ProviderBillingType from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger from src.core.logger import logger
@@ -27,6 +29,7 @@ from src.models.database import GlobalModel, Provider, ProviderAPIKey, ProviderE
from src.models.endpoint_models import ProviderWithEndpointsSummary from src.models.endpoint_models import ProviderWithEndpointsSummary
from src.services.cache.model_cache import ModelCacheService from src.services.cache.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService from src.services.cache.provider_cache import ProviderCacheService
from src.utils.cache_decorator import cache_result
from .summary import _build_provider_summary from .summary import _build_provider_summary
@@ -341,9 +344,24 @@ class AdminListProvidersAdapter(AdminApiAdapter):
self.limit = limit self.limit = limit
self.is_active = is_active self.is_active = is_active
@cache_result(
key_prefix="admin:providers:list",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["skip", "limit", "is_active"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
query = db.query(Provider) query = db.query(Provider).options(
load_only(
Provider.id,
Provider.name,
Provider.provider_priority,
Provider.is_active,
Provider.created_at,
Provider.updated_at,
)
)
if self.is_active is not None: if self.is_active is not None:
query = query.filter(Provider.is_active == self.is_active) query = query.filter(Provider.is_active == self.is_active)
providers = query.offset(self.skip).limit(self.limit).all() providers = query.offset(self.skip).limit(self.limit).all()
@@ -723,11 +741,22 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
def __init__(self, provider_id: str): def __init__(self, provider_id: str):
self.provider_id = provider_id self.provider_id = provider_id
async def handle(self, context: ApiRequestContext) -> ProviderMappingPreviewResponse: # type: ignore[override] @cache_result(
key_prefix="admin:providers:mapping-preview",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["provider_id"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
# 获取 Provider # 获取 Provider
provider = db.query(Provider).filter(Provider.id == self.provider_id).first() provider = (
db.query(Provider)
.options(load_only(Provider.id, Provider.name))
.filter(Provider.id == self.provider_id)
.first()
)
if not provider: if not provider:
raise NotFoundException("提供商不存在", "provider") raise NotFoundException("提供商不存在", "provider")
@@ -735,19 +764,6 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
truncated_keys = 0 truncated_keys = 0
truncated_models = 0 truncated_models = 0
# 获取该 Provider 有白名单配置的 Key 总数(用于截断统计)
from sqlalchemy import func
total_keys_with_allowed_models = (
db.query(func.count(ProviderAPIKey.id))
.filter(
ProviderAPIKey.provider_id == self.provider_id,
ProviderAPIKey.allowed_models.isnot(None),
)
.scalar()
or 0
)
# 获取该 Provider 有白名单配置的 Key只查询需要的字段 # 获取该 Provider 有白名单配置的 Key只查询需要的字段
keys = ( keys = (
db.query( db.query(
@@ -761,25 +777,22 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
ProviderAPIKey.provider_id == self.provider_id, ProviderAPIKey.provider_id == self.provider_id,
ProviderAPIKey.allowed_models.isnot(None), ProviderAPIKey.allowed_models.isnot(None),
) )
.limit(MAPPING_PREVIEW_MAX_KEYS) .limit(MAPPING_PREVIEW_MAX_KEYS + 1)
.all() .all()
) )
# 计算被截断的 Key 数量 if len(keys) > MAPPING_PREVIEW_MAX_KEYS:
if total_keys_with_allowed_models > MAPPING_PREVIEW_MAX_KEYS: keys = keys[:MAPPING_PREVIEW_MAX_KEYS]
truncated_keys = total_keys_with_allowed_models - MAPPING_PREVIEW_MAX_KEYS total_keys_with_allowed_models = (
db.query(func.count(ProviderAPIKey.id))
# 获取有 model_mappings 配置的 GlobalModel 总数(用于截断统计) .filter(
total_models_with_mappings = ( ProviderAPIKey.provider_id == self.provider_id,
db.query(func.count(GlobalModel.id)) ProviderAPIKey.allowed_models.isnot(None),
.filter( )
GlobalModel.config.isnot(None), .scalar()
GlobalModel.config["model_mappings"].isnot(None), or 0
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
) )
.scalar() truncated_keys = total_keys_with_allowed_models - MAPPING_PREVIEW_MAX_KEYS
or 0
)
# 只查询有 model_mappings 配置的 GlobalModel使用 SQLAlchemy JSONB 操作符) # 只查询有 model_mappings 配置的 GlobalModel使用 SQLAlchemy JSONB 操作符)
global_models = ( global_models = (
@@ -795,12 +808,22 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
GlobalModel.config["model_mappings"].isnot(None), GlobalModel.config["model_mappings"].isnot(None),
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0, func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
) )
.limit(MAPPING_PREVIEW_MAX_MODELS) .limit(MAPPING_PREVIEW_MAX_MODELS + 1)
.all() .all()
) )
# 计算被截断的 GlobalModel 数量 if len(global_models) > MAPPING_PREVIEW_MAX_MODELS:
if total_models_with_mappings > MAPPING_PREVIEW_MAX_MODELS: global_models = global_models[:MAPPING_PREVIEW_MAX_MODELS]
total_models_with_mappings = (
db.query(func.count(GlobalModel.id))
.filter(
GlobalModel.config.isnot(None),
GlobalModel.config["model_mappings"].isnot(None),
func.jsonb_array_length(GlobalModel.config["model_mappings"]) > 0,
)
.scalar()
or 0
)
truncated_models = total_models_with_mappings - MAPPING_PREVIEW_MAX_MODELS truncated_models = total_models_with_mappings - MAPPING_PREVIEW_MAX_MODELS
# 构建有映射配置的 GlobalModel 映射 # 构建有映射配置的 GlobalModel 映射
@@ -819,10 +842,10 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
keys=[], keys=[],
total_keys=0, total_keys=0,
total_matches=0, total_matches=0,
truncated=False, truncated=truncated_keys > 0 or truncated_models > 0,
truncated_keys=0, truncated_keys=truncated_keys,
truncated_models=0, truncated_models=truncated_models,
) ).model_dump()
key_infos: list[MappingMatchingKey] = [] key_infos: list[MappingMatchingKey] = []
total_matches = 0 total_matches = 0
@@ -837,18 +860,6 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
if not allowed_models_list: if not allowed_models_list:
continue continue
# 生成脱敏 Key
masked_key = "***"
if key.api_key:
try:
decrypted_key = crypto.decrypt(key.api_key, silent=True)
if len(decrypted_key) > 8:
masked_key = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
else:
masked_key = f"{decrypted_key[:2]}***"
except Exception:
pass
# 查找匹配的 GlobalModel # 查找匹配的 GlobalModel
matching_global_models: list[MappingMatchingGlobalModel] = [] matching_global_models: list[MappingMatchingGlobalModel] = []
@@ -879,6 +890,18 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
total_matches += 1 total_matches += 1
if matching_global_models: if matching_global_models:
# 只有有匹配结果的 key 才做解密脱敏,减少 CPU 开销
masked_key = "***"
if key.api_key:
try:
decrypted_key = crypto.decrypt(key.api_key, silent=True)
if len(decrypted_key) > 8:
masked_key = f"{decrypted_key[:4]}***{decrypted_key[-4:]}"
else:
masked_key = f"{decrypted_key[:2]}***"
except Exception:
pass
key_infos.append( key_infos.append(
MappingMatchingKey( MappingMatchingKey(
key_id=key.id or "", key_id=key.id or "",
@@ -901,7 +924,7 @@ class AdminGetProviderMappingPreviewAdapter(AdminApiAdapter):
truncated=is_truncated, truncated=is_truncated,
truncated_keys=truncated_keys, truncated_keys=truncated_keys,
truncated_models=truncated_models, truncated_models=truncated_models,
) ).model_dump()
# ========== Claude Code Pool Management ========== # ========== Claude Code Pool Management ==========

View File

@@ -8,12 +8,13 @@ from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import case, func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, load_only
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.models_service import invalidate_models_list_cache from src.api.base.models_service import invalidate_models_list_cache
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.enums import ProviderBillingType from src.core.enums import ProviderBillingType
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.logger import logger from src.core.logger import logger
@@ -39,6 +40,7 @@ from src.models.endpoint_models import (
) )
from src.services.cache.model_cache import ModelCacheService from src.services.cache.model_cache import ModelCacheService
from src.services.cache.provider_cache import ProviderCacheService from src.services.cache.provider_cache import ProviderCacheService
from src.utils.cache_decorator import cache_result
router = APIRouter(tags=["Provider Summary"]) router = APIRouter(tags=["Provider Summary"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -130,11 +132,8 @@ async def get_provider_summary(
- `created_at`: 创建时间 - `created_at`: 创建时间
- `updated_at`: 更新时间 - `updated_at`: 更新时间
""" """
provider = db.query(Provider).filter(Provider.id == provider_id).first() adapter = AdminProviderDetailAdapter(provider_id=provider_id)
if not provider: return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
raise NotFoundException(f"Provider {provider_id} not found")
return _build_provider_summary(db, provider)
@router.get("/{provider_id}/health-monitor", response_model=ProviderEndpointHealthMonitorResponse) @router.get("/{provider_id}/health-monitor", response_model=ProviderEndpointHealthMonitorResponse)
@@ -319,12 +318,20 @@ def _extract_failover_rules_from_config(
def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndpointsSummary: def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndpointsSummary:
endpoints = db.query(ProviderEndpoint).filter(ProviderEndpoint.provider_id == provider.id).all() endpoints = (
db.query(ProviderEndpoint)
.options(
load_only(
ProviderEndpoint.id,
ProviderEndpoint.provider_id,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
.filter(ProviderEndpoint.provider_id == provider.id)
.all()
)
total_endpoints = len(endpoints)
active_endpoints = sum(1 for e in endpoints if e.is_active)
# Key 统计(合并为单个查询)
key_stats = ( key_stats = (
db.query( db.query(
func.count(ProviderAPIKey.id).label("total"), func.count(ProviderAPIKey.id).label("total"),
@@ -333,10 +340,9 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
.filter(ProviderAPIKey.provider_id == provider.id) .filter(ProviderAPIKey.provider_id == provider.id)
.first() .first()
) )
total_keys = key_stats.total or 0 total_keys = int(key_stats.total or 0)
active_keys = int(key_stats.active or 0) active_keys = int(key_stats.active or 0)
# Model 统计(合并为单个查询)
model_stats = ( model_stats = (
db.query( db.query(
func.count(Model.id).label("total"), func.count(Model.id).label("total"),
@@ -345,25 +351,62 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
.filter(Model.provider_id == provider.id) .filter(Model.provider_id == provider.id)
.first() .first()
) )
total_models = model_stats.total or 0 total_models = int(model_stats.total or 0)
active_models = int(model_stats.active or 0) active_models = int(model_stats.active or 0)
# 活跃模型关联的全局模型 ID 列表
global_model_ids = [ global_model_ids = [
row[0] row[0]
for row in db.query(Model.global_model_id) for row in db.query(Model.global_model_id)
.filter( .filter(
Model.provider_id == provider.id, Model.provider_id == provider.id,
Model.is_active == True, Model.is_active == True,
Model.global_model_id.isnot(None),
) )
.distinct() .distinct()
.all() .all()
] ]
api_formats = [e.api_format for e in endpoints] all_keys = (
db.query(ProviderAPIKey)
.options(
load_only(
ProviderAPIKey.id,
ProviderAPIKey.provider_id,
ProviderAPIKey.is_active,
ProviderAPIKey.api_formats,
ProviderAPIKey.health_by_format,
)
)
.filter(ProviderAPIKey.provider_id == provider.id)
.all()
)
# 优化: 一次性加载 Provider 的 keys避免 N+1 查询 return _compose_provider_summary(
all_keys = db.query(ProviderAPIKey).filter(ProviderAPIKey.provider_id == provider.id).all() provider=provider,
endpoints=endpoints,
all_keys=all_keys,
total_keys=total_keys,
active_keys=active_keys,
total_models=total_models,
active_models=active_models,
global_model_ids=global_model_ids,
)
def _compose_provider_summary(
*,
provider: Provider,
endpoints: list[ProviderEndpoint],
all_keys: list[ProviderAPIKey],
total_keys: int,
active_keys: int,
total_models: int,
active_models: int,
global_model_ids: list[Any],
) -> ProviderWithEndpointsSummary:
total_endpoints = len(endpoints)
active_endpoints = sum(1 for e in endpoints if e.is_active)
api_formats = [e.api_format for e in endpoints]
# 按 api_formats 分组 keys通过 api_formats 关联) # 按 api_formats 分组 keys通过 api_formats 关联)
format_to_endpoint_id: dict[str, str] = {e.api_format: e.id for e in endpoints} format_to_endpoint_id: dict[str, str] = {e.api_format: e.id for e in endpoints}
@@ -379,9 +422,8 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
for endpoint in endpoints: for endpoint in endpoints:
keys = keys_by_endpoint.get(endpoint.id, []) keys = keys_by_endpoint.get(endpoint.id, [])
if keys: if keys:
# 从 health_by_format 获取对应格式的健康度
api_fmt = endpoint.api_format api_fmt = endpoint.api_format
health_scores = [] health_scores: list[float] = []
for k in keys: for k in keys:
health_by_format = k.health_by_format or {} health_by_format = k.health_by_format or {}
if api_fmt in health_by_format: if api_fmt in health_by_format:
@@ -389,7 +431,7 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
if score is not None: if score is not None:
health_scores.append(float(score)) health_scores.append(float(score))
else: else:
health_scores.append(1.0) # 默认健康度 health_scores.append(1.0)
avg_health = sum(health_scores) / len(health_scores) if health_scores else 1.0 avg_health = sum(health_scores) / len(health_scores) if health_scores else 1.0
endpoint_health_map[endpoint.id] = avg_health endpoint_health_map[endpoint.id] = avg_health
else: else:
@@ -399,7 +441,6 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
avg_health_score = sum(all_health_scores) / len(all_health_scores) if all_health_scores else 1.0 avg_health_score = sum(all_health_scores) / len(all_health_scores) if all_health_scores else 1.0
unhealthy_endpoints = sum(1 for score in all_health_scores if score < 0.5) unhealthy_endpoints = sum(1 for score in all_health_scores if score < 0.5)
# 计算每个端点的活跃密钥数量
active_keys_by_endpoint: dict[str, int] = {} active_keys_by_endpoint: dict[str, int] = {}
for endpoint_id, keys in keys_by_endpoint.items(): for endpoint_id, keys in keys_by_endpoint.items():
active_keys_by_endpoint[endpoint_id] = sum(1 for k in keys if k.is_active) active_keys_by_endpoint[endpoint_id] = sum(1 for k in keys if k.is_active)
@@ -484,6 +525,119 @@ def _build_provider_summary(db: Session, provider: Provider) -> ProviderWithEndp
) )
def _build_provider_summaries_batch(
db: Session, providers: list[Provider]
) -> list[ProviderWithEndpointsSummary]:
if not providers:
return []
provider_ids = [provider.id for provider in providers]
endpoint_rows = (
db.query(ProviderEndpoint)
.options(
load_only(
ProviderEndpoint.id,
ProviderEndpoint.provider_id,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
.all()
)
endpoints_by_provider: dict[str, list[ProviderEndpoint]] = {}
for endpoint in endpoint_rows:
endpoints_by_provider.setdefault(str(endpoint.provider_id), []).append(endpoint)
key_rows = (
db.query(ProviderAPIKey)
.options(
load_only(
ProviderAPIKey.id,
ProviderAPIKey.provider_id,
ProviderAPIKey.is_active,
ProviderAPIKey.api_formats,
ProviderAPIKey.health_by_format,
)
)
.filter(ProviderAPIKey.provider_id.in_(provider_ids))
.all()
)
keys_by_provider: dict[str, list[ProviderAPIKey]] = {}
for key in key_rows:
keys_by_provider.setdefault(str(key.provider_id), []).append(key)
key_stats_rows = (
db.query(
ProviderAPIKey.provider_id.label("provider_id"),
func.count(ProviderAPIKey.id).label("total"),
func.sum(case((ProviderAPIKey.is_active == True, 1), else_=0)).label("active"),
)
.filter(ProviderAPIKey.provider_id.in_(provider_ids))
.group_by(ProviderAPIKey.provider_id)
.all()
)
key_stats_by_provider: dict[str, dict[str, int]] = {
str(row.provider_id): {
"total": int(row.total or 0),
"active": int(row.active or 0),
}
for row in key_stats_rows
}
model_stats_rows = (
db.query(
Model.provider_id.label("provider_id"),
func.count(Model.id).label("total"),
func.sum(case((Model.is_active == True, 1), else_=0)).label("active"),
)
.filter(Model.provider_id.in_(provider_ids))
.group_by(Model.provider_id)
.all()
)
model_stats_by_provider: dict[str, dict[str, int]] = {
str(row.provider_id): {
"total": int(row.total or 0),
"active": int(row.active or 0),
}
for row in model_stats_rows
}
global_model_rows = (
db.query(Model.provider_id, Model.global_model_id)
.filter(
Model.provider_id.in_(provider_ids),
Model.is_active == True,
Model.global_model_id.isnot(None),
)
.distinct()
.all()
)
global_model_ids_by_provider: dict[str, list[Any]] = {}
for provider_id, global_model_id in global_model_rows:
global_model_ids_by_provider.setdefault(str(provider_id), []).append(global_model_id)
summaries: list[ProviderWithEndpointsSummary] = []
for provider in providers:
pid = str(provider.id)
key_stats = key_stats_by_provider.get(pid, {"total": 0, "active": 0})
model_stats = model_stats_by_provider.get(pid, {"total": 0, "active": 0})
summaries.append(
_compose_provider_summary(
provider=provider,
endpoints=endpoints_by_provider.get(pid, []),
all_keys=keys_by_provider.get(pid, []),
total_keys=key_stats["total"],
active_keys=key_stats["active"],
total_models=model_stats["total"],
active_models=model_stats["active"],
global_model_ids=global_model_ids_by_provider.get(pid, []),
)
)
return summaries
# -------- Adapters -------- # -------- Adapters --------
@@ -493,6 +647,12 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
lookback_hours: int lookback_hours: int
per_endpoint_limit: int per_endpoint_limit: int
@cache_result(
key_prefix="admin:providers:health-monitor",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["provider_id", "lookback_hours", "per_endpoint_limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
provider = db.query(Provider).filter(Provider.id == self.provider_id).first() provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
@@ -501,6 +661,14 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
endpoints = ( endpoints = (
db.query(ProviderEndpoint) db.query(ProviderEndpoint)
.options(
load_only(
ProviderEndpoint.id,
ProviderEndpoint.provider_id,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
.filter(ProviderEndpoint.provider_id == self.provider_id) .filter(ProviderEndpoint.provider_id == self.provider_id)
.all() .all()
) )
@@ -508,7 +676,7 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
since = now - timedelta(hours=self.lookback_hours) since = now - timedelta(hours=self.lookback_hours)
endpoint_ids = [endpoint.id for endpoint in endpoints] endpoint_ids = [str(endpoint.id) for endpoint in endpoints]
if not endpoint_ids: if not endpoint_ids:
response = ProviderEndpointHealthMonitorResponse( response = ProviderEndpointHealthMonitorResponse(
provider_id=provider.id, provider_id=provider.id,
@@ -522,46 +690,65 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
endpoint_count=0, endpoint_count=0,
lookback_hours=self.lookback_hours, lookback_hours=self.lookback_hours,
) )
return response return response.model_dump()
limit_rows = max(200, self.per_endpoint_limit * max(1, len(endpoint_ids)) * 2) ranked_attempts_subq = (
attempts_query = (
db.query(RequestCandidate) db.query(RequestCandidate)
.with_entities(
RequestCandidate.endpoint_id.label("endpoint_id"),
RequestCandidate.status.label("status"),
RequestCandidate.status_code.label("status_code"),
RequestCandidate.latency_ms.label("latency_ms"),
RequestCandidate.error_type.label("error_type"),
RequestCandidate.error_message.label("error_message"),
func.coalesce(
RequestCandidate.finished_at,
RequestCandidate.started_at,
RequestCandidate.created_at,
).label("event_timestamp"),
func.row_number()
.over(
partition_by=RequestCandidate.endpoint_id,
order_by=RequestCandidate.created_at.desc(),
)
.label("rn"),
)
.filter( .filter(
RequestCandidate.endpoint_id.in_(endpoint_ids), RequestCandidate.endpoint_id.in_(endpoint_ids),
RequestCandidate.created_at >= since, RequestCandidate.created_at >= since,
) )
.order_by(RequestCandidate.created_at.desc()) .subquery()
)
attempt_rows = (
db.query(ranked_attempts_subq)
.filter(ranked_attempts_subq.c.rn <= self.per_endpoint_limit)
.order_by(
ranked_attempts_subq.c.endpoint_id.asc(),
ranked_attempts_subq.c.event_timestamp.asc(),
)
.all()
) )
attempts = attempts_query.limit(limit_rows).all()
buffered_attempts: dict[str, list[RequestCandidate]] = {eid: [] for eid in endpoint_ids} events_by_endpoint: dict[str, list[EndpointHealthEvent]] = {eid: [] for eid in endpoint_ids}
counters: dict[str, int] = {eid: 0 for eid in endpoint_ids} for row in attempt_rows:
endpoint_id = str(row.endpoint_id) if row.endpoint_id is not None else ""
for attempt in attempts: if not endpoint_id or endpoint_id not in events_by_endpoint:
if not attempt.endpoint_id or attempt.endpoint_id not in buffered_attempts:
continue continue
if counters[attempt.endpoint_id] >= self.per_endpoint_limit: events_by_endpoint[endpoint_id].append(
continue EndpointHealthEvent(
buffered_attempts[attempt.endpoint_id].append(attempt) timestamp=row.event_timestamp,
counters[attempt.endpoint_id] += 1 status=row.status,
status_code=row.status_code,
latency_ms=row.latency_ms,
error_type=row.error_type,
error_message=row.error_message,
)
)
endpoint_monitors: list[EndpointHealthMonitor] = [] endpoint_monitors: list[EndpointHealthMonitor] = []
for endpoint in endpoints: for endpoint in endpoints:
attempt_list = list(reversed(buffered_attempts.get(endpoint.id, []))) endpoint_id = str(endpoint.id)
events: list[EndpointHealthEvent] = [] events = events_by_endpoint.get(endpoint_id, [])
for attempt in attempt_list:
event_timestamp = attempt.finished_at or attempt.started_at or attempt.created_at
events.append(
EndpointHealthEvent(
timestamp=event_timestamp,
status=attempt.status,
status_code=attempt.status_code,
latency_ms=attempt.latency_ms,
error_type=attempt.error_type,
error_message=attempt.error_message,
)
)
success_count = sum(1 for event in events if event.status == "success") success_count = sum(1 for event in events if event.status == "success")
failed_count = sum(1 for event in events if event.status == "failed") failed_count = sum(1 for event in events if event.status == "failed")
@@ -598,10 +785,15 @@ class AdminProviderHealthMonitorAdapter(AdminApiAdapter):
lookback_hours=self.lookback_hours, lookback_hours=self.lookback_hours,
per_endpoint_limit=self.per_endpoint_limit, per_endpoint_limit=self.per_endpoint_limit,
) )
return response return response.model_dump()
class AdminProviderSummaryAdapter(AdminApiAdapter): class AdminProviderSummaryAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:providers:summary",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
providers = ( providers = (
@@ -609,7 +801,25 @@ class AdminProviderSummaryAdapter(AdminApiAdapter):
.order_by(Provider.provider_priority.asc(), Provider.created_at.asc()) .order_by(Provider.provider_priority.asc(), Provider.created_at.asc())
.all() .all()
) )
return [_build_provider_summary(db, provider) for provider in providers] return [item.model_dump() for item in _build_provider_summaries_batch(db, providers)]
@dataclass
class AdminProviderDetailAdapter(AdminApiAdapter):
provider_id: str
@cache_result(
key_prefix="admin:providers:summary:detail",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["provider_id"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
if not provider:
raise NotFoundException(f"Provider {self.provider_id} not found")
return _build_provider_summary(db, provider).model_dump()
@dataclass @dataclass

View File

@@ -10,9 +10,11 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db from src.database import get_db
from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid from src.services.system.stats_aggregator import AggregatedStats, StatsFilter, query_stats_hybrid
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import pipeline from .common import pipeline
@@ -34,6 +36,18 @@ class AdminComparisonAdapter(AdminApiAdapter):
self.timezone_name = timezone_name self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes self.tz_offset_minutes = tz_offset_minutes
@cache_result(
key_prefix="admin:stats:comparison",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"current_start",
"current_end",
"comparison_type",
"timezone_name",
"tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if self.current_start > self.current_end: if self.current_start > self.current_end:
raise HTTPException(status_code=400, detail="current_start must be <= current_end") raise HTTPException(status_code=400, detail="current_start must be <= current_end")

View File

@@ -11,10 +11,12 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db from src.database import get_db
from src.models.database import Usage from src.models.database import Usage
from src.services.system.stats_aggregator import query_time_series from src.services.system.stats_aggregator import query_time_series
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import ( from .common import (
_apply_admin_default_range, _apply_admin_default_range,
@@ -40,11 +42,27 @@ class AdminCostForecastAdapter(AdminApiAdapter):
self.days = days self.days = days
self.forecast_days = forecast_days self.forecast_days = forecast_days
self.timezone_name = timezone_name self.timezone_name = timezone_name
self.tz_offset_minutes = tz_offset_minutes self.fallback_tz_offset_minutes = tz_offset_minutes
@cache_result(
key_prefix="admin:stats:cost:forecast",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"days",
"forecast_days",
"timezone_name",
"fallback_tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
time_range = self.time_range or _build_time_range_from_days( time_range = self.time_range or _build_time_range_from_days(
self.days, self.timezone_name, self.tz_offset_minutes self.days, self.timezone_name, self.fallback_tz_offset_minutes
) )
time_range.granularity = "day" time_range.granularity = "day"
try: try:
@@ -120,6 +138,20 @@ class AdminCostSavingsAdapter(AdminApiAdapter):
self.provider_name = provider_name self.provider_name = provider_name
self.model = model self.model = model
@cache_result(
key_prefix="admin:stats:cost:savings",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"provider_name",
"model",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range: if not self.time_range:
return { return {

View File

@@ -11,9 +11,11 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db from src.database import get_db
from src.models.database import StatsDailyError, Usage from src.models.database import StatsDailyError, Usage
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline from .common import _apply_admin_default_range, _build_time_range_params, pipeline
@@ -24,6 +26,18 @@ class AdminErrorDistributionAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None: def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range) self.time_range = _apply_admin_default_range(time_range)
@cache_result(
key_prefix="admin:stats:errors:distribution",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range: if not self.time_range:
return {"distribution": [], "trend": []} return {"distribution": [], "trend": []}

View File

@@ -10,10 +10,12 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db from src.database import get_db
from src.models.database import StatsDaily from src.models.database import StatsDaily
from src.services.system.stats_aggregator import StatsAggregatorService from src.services.system.stats_aggregator import StatsAggregatorService
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline from .common import _apply_admin_default_range, _build_time_range_params, pipeline
@@ -24,6 +26,18 @@ class AdminPercentilesAdapter(AdminApiAdapter):
def __init__(self, time_range: TimeRangeParams | None) -> None: def __init__(self, time_range: TimeRangeParams | None) -> None:
self.time_range = _apply_admin_default_range(time_range) self.time_range = _apply_admin_default_range(time_range)
@cache_result(
key_prefix="admin:stats:performance:percentiles",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range: if not self.time_range:
return [] return []

View File

@@ -10,9 +10,11 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.core.enums import ProviderBillingType from src.core.enums import ProviderBillingType
from src.database import get_db from src.database import get_db
from src.models.database import Provider from src.models.database import Provider
from src.utils.cache_decorator import cache_result
from .common import pipeline from .common import pipeline
@@ -20,6 +22,11 @@ router = APIRouter()
class AdminQuotaUsageAdapter(AdminApiAdapter): class AdminQuotaUsageAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:stats:providers:quota_usage",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
providers = ( providers = (

View File

@@ -10,9 +10,11 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.config.constants import CacheTTL
from src.database import get_db from src.database import get_db
from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series from src.services.system.stats_aggregator import TimeSeriesFilter, query_time_series
from src.services.system.time_range import TimeRangeParams from src.services.system.time_range import TimeRangeParams
from src.utils.cache_decorator import cache_result
from .common import _apply_admin_default_range, _build_time_range_params, pipeline from .common import _apply_admin_default_range, _build_time_range_params, pipeline
@@ -32,6 +34,22 @@ class AdminTimeSeriesAdapter(AdminApiAdapter):
self.model = model self.model = model
self.provider_name = provider_name self.provider_name = provider_name
@cache_result(
key_prefix="admin:stats:time_series",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"time_range.granularity",
"user_id",
"model",
"provider_name",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
if not self.time_range: if not self.time_range:
return [] return []

View File

@@ -9,11 +9,13 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
@@ -21,6 +23,7 @@ from src.models.api import SystemSettingsRequest, SystemSettingsResponse
from src.models.database import ApiKey, Provider, Usage, User from src.models.database import ApiKey, Provider, Usage, User
from src.services.email.email_template import EmailTemplate from src.services.email.email_template import EmailTemplate
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"]) router = APIRouter(prefix="/api/admin/system", tags=["Admin - System"])
@@ -749,14 +752,27 @@ class AdminDeleteSystemConfigAdapter(AdminApiAdapter):
class AdminSystemStatsAdapter(AdminApiAdapter): class AdminSystemStatsAdapter(AdminApiAdapter):
@cache_result(
key_prefix="admin:system:stats",
ttl=CacheTTL.DASHBOARD_STATS,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
total_users = db.query(User).count() user_stats = db.query(
active_users = db.query(User).filter(User.is_active.is_(True)).count() func.count(User.id).label("total"),
total_providers = db.query(Provider).count() func.sum(case((User.is_active.is_(True), 1), else_=0)).label("active"),
active_providers = db.query(Provider).filter(Provider.is_active.is_(True)).count() ).first()
total_api_keys = db.query(ApiKey).count() provider_stats = db.query(
total_requests = db.query(Usage).count() func.count(Provider.id).label("total"),
func.sum(case((Provider.is_active.is_(True), 1), else_=0)).label("active"),
).first()
total_api_keys = int(db.query(func.count(ApiKey.id)).scalar() or 0)
total_requests = int(db.query(func.count(Usage.id)).scalar() or 0)
total_users = int(user_stats.total or 0) if user_stats else 0
active_users = int(user_stats.active or 0) if user_stats else 0
total_providers = int(provider_stats.total or 0) if provider_stats else 0
active_providers = int(provider_stats.active or 0) if provider_stats else 0
return { return {
"users": {"total": total_users, "active": active_users}, "users": {"total": total_users, "active": active_users},
@@ -776,16 +792,18 @@ class AdminTriggerCleanupAdapter(AdminApiAdapter):
db = context.db db = context.db
# 获取清理前的统计信息 # 获取清理前的统计信息
total_before = db.query(Usage).count() total_before = int(db.query(func.count(Usage.id)).scalar() or 0)
with_body_before = ( with_body_before = (
db.query(Usage) db.query(func.count(Usage.id))
.filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None))) .filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None)))
.count() .scalar()
or 0
) )
with_headers_before = ( with_headers_before = (
db.query(Usage) db.query(func.count(Usage.id))
.filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None))) .filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None)))
.count() .scalar()
or 0
) )
# 触发清理 # 触发清理
@@ -793,16 +811,18 @@ class AdminTriggerCleanupAdapter(AdminApiAdapter):
await maintenance_scheduler._perform_cleanup() await maintenance_scheduler._perform_cleanup()
# 获取清理后的统计信息 # 获取清理后的统计信息
total_after = db.query(Usage).count() total_after = int(db.query(func.count(Usage.id)).scalar() or 0)
with_body_after = ( with_body_after = (
db.query(Usage) db.query(func.count(Usage.id))
.filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None))) .filter((Usage.request_body.isnot(None)) | (Usage.response_body.isnot(None)))
.count() .scalar()
or 0
) )
with_headers_after = ( with_headers_after = (
db.query(Usage) db.query(func.count(Usage.id))
.filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None))) .filter((Usage.request_headers.isnot(None)) | (Usage.response_headers.isnot(None)))
.count() .scalar()
or 0
) )
return { return {
@@ -2402,11 +2422,11 @@ class AdminPurgeConfigAdapter(AdminApiAdapter):
db = context.db db = context.db
# 统计 # 统计
providers_count = db.query(Provider).count() providers_count = int(db.query(func.count(Provider.id)).scalar() or 0)
endpoints_count = db.query(ProviderEndpoint).count() endpoints_count = int(db.query(func.count(ProviderEndpoint.id)).scalar() or 0)
keys_count = db.query(ProviderAPIKey).count() keys_count = int(db.query(func.count(ProviderAPIKey.id)).scalar() or 0)
models_count = db.query(Model).count() models_count = int(db.query(func.count(Model.id)).scalar() or 0)
global_models_count = db.query(GlobalModel).count() global_models_count = int(db.query(func.count(GlobalModel.id)).scalar() or 0)
# VideoTask 的 provider_id/endpoint_id/key_id 无 ondelete置 NULL 保留任务记录 # VideoTask 的 provider_id/endpoint_id/key_id 无 ondelete置 NULL 保留任务记录
db.query(VideoTask).filter( db.query(VideoTask).filter(
@@ -2471,7 +2491,9 @@ class AdminPurgeUsersAdapter(AdminApiAdapter):
) )
# 统计关联 API Keys 数量DB 级别 CASCADE 会随 User 自动删除) # 统计关联 API Keys 数量DB 级别 CASCADE 会随 User 自动删除)
keys_count = db.query(ApiKey).filter(ApiKey.user_id.in_(user_ids)).count() keys_count = int(
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id.in_(user_ids)).scalar() or 0
)
# 将使用记录的 user_id 置空(保留记录) # 将使用记录的 user_id 置空(保留记录)
db.query(Usage).filter(Usage.user_id.in_(user_ids)).update( db.query(Usage).filter(Usage.user_id.in_(user_ids)).update(
@@ -2565,9 +2587,9 @@ class AdminPurgeUsageAdapter(AdminApiAdapter):
db = context.db db = context.db
usage_count = db.query(Usage).count() usage_count = int(db.query(func.count(Usage.id)).scalar() or 0)
candidates_count = db.query(RequestCandidate).count() candidates_count = int(db.query(func.count(RequestCandidate.id)).scalar() or 0)
usage_counts_count = db.query(UserModelUsageCount).count() usage_counts_count = int(db.query(func.count(UserModelUsageCount.id)).scalar() or 0)
# 清空使用记录 # 清空使用记录
db.query(RequestCandidate).delete() db.query(RequestCandidate).delete()
@@ -2594,7 +2616,7 @@ class AdminPurgeAuditLogsAdapter(AdminApiAdapter):
db = context.db db = context.db
count = db.query(AuditLog).count() count = int(db.query(func.count(AuditLog.id)).scalar() or 0)
db.query(AuditLog).delete() db.query(AuditLog).delete()
db.commit() db.commit()
@@ -2612,8 +2634,8 @@ class AdminPurgeRequestBodiesAdapter(AdminApiAdapter):
db = context.db db = context.db
# 统计有 body 的记录数 # 统计有 body 的记录数
with_body = ( with_body = int(
db.query(Usage) db.query(func.count(Usage.id))
.filter( .filter(
(Usage.request_body.isnot(None)) (Usage.request_body.isnot(None))
| (Usage.response_body.isnot(None)) | (Usage.response_body.isnot(None))
@@ -2624,7 +2646,8 @@ class AdminPurgeRequestBodiesAdapter(AdminApiAdapter):
| (Usage.provider_request_body_compressed.isnot(None)) | (Usage.provider_request_body_compressed.isnot(None))
| (Usage.client_response_body_compressed.isnot(None)) | (Usage.client_response_body_compressed.isnot(None))
) )
.count() .scalar()
or 0
) )
# 批量清空所有 body 字段 # 批量清空所有 body 字段

View File

@@ -2117,7 +2117,7 @@ class CacheHitAnalysisAdapter(AdminApiAdapter):
async def get_interval_timeline( async def get_interval_timeline(
request: Request, request: Request,
hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"), hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"),
limit: int = Query(10000, ge=100, le=50000, description="最大返回数据点数量"), limit: int = Query(3000, ge=100, le=50000, description="最大返回数据点数量"),
user_id: str | None = Query(None, description="指定用户 ID"), user_id: str | None = Query(None, description="指定用户 ID"),
include_user_info: bool = Query(False, description="是否包含用户信息(用于管理员多用户视图)"), include_user_info: bool = Query(False, description="是否包含用户信息(用于管理员多用户视图)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -2156,6 +2156,12 @@ class IntervalTimelineAdapter(AdminApiAdapter):
self.user_id = user_id self.user_id = user_id
self.include_user_info = include_user_info self.include_user_info = include_user_info
@cache_result(
key_prefix="admin:usage:interval_timeline",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["hours", "limit", "user_id", "include_user_info"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db

View File

@@ -7,11 +7,13 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy.orm import Session from sqlalchemy import func
from sqlalchemy.orm import Session, load_only
from src.api.base.admin_adapter import AdminApiAdapter from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
@@ -21,6 +23,7 @@ from src.models.database import ApiKey, User, UserRole
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.service import UserService from src.services.user.service import UserService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/admin/users", tags=["Admin - Users"]) router = APIRouter(prefix="/api/admin/users", tags=["Admin - Users"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -282,10 +285,43 @@ class AdminListUsersAdapter(AdminApiAdapter):
self.role = role self.role = role
self.is_active = is_active self.is_active = is_active
@cache_result(
key_prefix="admin:users:list",
ttl=CacheTTL.USER,
user_specific=False,
vary_by=["skip", "limit", "role", "is_active"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
role_enum = UserRole[self.role.upper()] if self.role else None role_enum = None
users = UserService.list_users(db, self.skip, self.limit, role_enum, self.is_active) if self.role:
try:
role_enum = UserRole[self.role.upper()]
except KeyError as exc:
raise InvalidRequestException("角色参数不合法") from exc
query = db.query(User).options(
load_only(
User.id,
User.email,
User.username,
User.role,
User.allowed_providers,
User.allowed_api_formats,
User.allowed_models,
User.quota_usd,
User.used_usd,
User.total_usd,
User.is_active,
User.created_at,
)
)
if role_enum:
query = query.filter(User.role == role_enum)
if self.is_active is not None:
query = query.filter(User.is_active == self.is_active)
users = query.order_by(User.created_at.desc()).offset(self.skip).limit(self.limit).all()
return [ return [
{ {
"id": u.id, "id": u.id,
@@ -416,7 +452,9 @@ class AdminDeleteUserAdapter(AdminApiAdapter):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.role == UserRole.ADMIN: if user.role == UserRole.ADMIN:
admin_count = db.query(User).filter(User.role == UserRole.ADMIN).count() admin_count = int(
db.query(func.count(User.id)).filter(User.role == UserRole.ADMIN).scalar() or 0
)
if admin_count <= 1: if admin_count <= 1:
raise InvalidRequestException("不能删除最后一个管理员账户") raise InvalidRequestException("不能删除最后一个管理员账户")

View File

@@ -18,11 +18,13 @@ from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.api.dashboard.routes import DashboardAdapter from src.api.dashboard.routes import DashboardAdapter
from src.clients.http_client import HTTPClientPool from src.clients.http_client import HTTPClientPool
from src.config.constants import CacheTTL
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.enums import UserRole from src.core.enums import UserRole
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User, VideoTask from src.models.database import Provider, ProviderAPIKey, ProviderEndpoint, User, VideoTask
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/admin/video-tasks", tags=["Admin - Video Tasks"]) router = APIRouter(prefix="/api/admin/video-tasks", tags=["Admin - Video Tasks"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -247,6 +249,12 @@ class VideoTaskListAdapter(DashboardAdapter):
page: int page: int
page_size: int page_size: int
@cache_result(
key_prefix="admin:video_tasks:list",
ttl=min(5, CacheTTL.ADMIN_USAGE_RECORDS),
user_specific=True,
vary_by=["status", "user_id", "model", "page", "page_size"],
)
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
db = context.db db = context.db
user = context.user user = context.user
@@ -270,8 +278,8 @@ class VideoTaskListAdapter(DashboardAdapter):
escaped = self.model.replace("%", "\\%").replace("_", "\\_") escaped = self.model.replace("%", "\\%").replace("_", "\\_")
query = query.filter(VideoTask.model.ilike(f"%{escaped}%")) query = query.filter(VideoTask.model.ilike(f"%{escaped}%"))
# 统计总数 # 统计总数(避免 Query.count() 生成大子查询)
total = query.count() total = int(query.with_entities(func.count(VideoTask.id)).scalar() or 0)
# 分页 # 分页
offset = (self.page - 1) * self.page_size offset = (self.page - 1) * self.page_size
@@ -341,6 +349,11 @@ class VideoTaskListAdapter(DashboardAdapter):
class VideoTaskStatsAdapter(DashboardAdapter): class VideoTaskStatsAdapter(DashboardAdapter):
"""视频任务统计适配器""" """视频任务统计适配器"""
@cache_result(
key_prefix="admin:video_tasks:stats",
ttl=min(5, CacheTTL.ADMIN_USAGE_RECORDS),
user_specific=True,
)
async def handle(self, context: ApiRequestContext) -> Any: async def handle(self, context: ApiRequestContext) -> Any:
db = context.db db = context.db
user = context.user user = context.user
@@ -350,8 +363,8 @@ class VideoTaskStatsAdapter(DashboardAdapter):
if not is_admin: if not is_admin:
base_query = base_query.filter(VideoTask.user_id == user.id) base_query = base_query.filter(VideoTask.user_id == user.id)
# 总数 # 总数(避免 Query.count() 生成大子查询)
total = base_query.count() total = int(base_query.with_entities(func.count(VideoTask.id)).scalar() or 0)
# 按状态分组 # 按状态分组
status_stats = ( status_stats = (
@@ -379,7 +392,12 @@ class VideoTaskStatsAdapter(DashboardAdapter):
# 今日任务数 # 今日任务数
today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0) today = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_count = base_query.filter(VideoTask.created_at >= today).count() today_count = int(
base_query.filter(VideoTask.created_at >= today)
.with_entities(func.count(VideoTask.id))
.scalar()
or 0
)
# 管理员额外统计 # 管理员额外统计
result = { result = {

View File

@@ -2,6 +2,7 @@ from collections.abc import Sequence
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass
from typing import Any, TypeVar from typing import Any, TypeVar
from sqlalchemy import func
from sqlalchemy.orm import Query from sqlalchemy.orm import Query
T = TypeVar("T") T = TypeVar("T")
@@ -22,7 +23,7 @@ def paginate_query(query: Query, limit: int, offset: int) -> tuple[int, list[T]]
""" """
对 SQLAlchemy 查询应用 limit/offset并返回总数与结果列表。 对 SQLAlchemy 查询应用 limit/offset并返回总数与结果列表。
""" """
total = query.order_by(None).count() total = int(query.order_by(None).with_entities(func.count()).scalar() or 0)
records = query.offset(offset).limit(limit).all() records = query.offset(offset).limit(limit).all()
return total, records return total, records

View File

@@ -7,7 +7,7 @@ from datetime import date, datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import and_, func from sqlalchemy import and_, case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.api.base.adapter import ApiAdapter, ApiMode from src.api.base.adapter import ApiAdapter, ApiMode
@@ -661,95 +661,96 @@ class UserDashboardStatsAdapter(DashboardAdapter):
month_start_local = today_local.replace(day=1) month_start_local = today_local.replace(day=1)
month_start = month_start_local.astimezone(timezone.utc) month_start = month_start_local.astimezone(timezone.utc)
user_api_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user.id).scalar() api_key_stats = (
active_keys = (
db.query(func.count(ApiKey.id))
.filter(and_(ApiKey.user_id == user.id, ApiKey.is_active.is_(True)))
.scalar()
)
# 全局 Token 统计
all_time_token_stats = (
db.query( db.query(
func.sum(Usage.input_tokens).label("input_tokens"), func.count(ApiKey.id).label("total"),
func.sum(Usage.output_tokens).label("output_tokens"), func.sum(case((ApiKey.is_active.is_(True), 1), else_=0)).label("active"),
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"), )
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"), .filter(ApiKey.user_id == user.id)
.first()
)
user_api_keys = int(api_key_stats.total or 0) if api_key_stats else 0
active_keys = int(api_key_stats.active or 0) if api_key_stats else 0
# 使用单次聚合查询返回全量 + 本月 + 今日 + 昨日统计
usage_stats = (
db.query(
# 全量 Token 统计
func.sum(Usage.input_tokens).label("all_time_input_tokens"),
func.sum(Usage.output_tokens).label("all_time_output_tokens"),
func.sum(Usage.cache_creation_input_tokens).label("all_time_cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("all_time_cache_read_tokens"),
# 本月
func.sum(case((Usage.created_at >= month_start, 1), else_=0)).label(
"monthly_requests"
),
func.sum(
case((Usage.created_at >= month_start, Usage.total_cost_usd), else_=0.0)
).label("monthly_cost"),
func.sum(
case(
(Usage.created_at >= month_start, Usage.cache_creation_input_tokens),
else_=0,
)
).label("monthly_cache_creation_tokens"),
func.sum(
case((Usage.created_at >= month_start, Usage.cache_read_input_tokens), else_=0)
).label("monthly_cache_read_tokens"),
func.sum(
case((Usage.created_at >= month_start, Usage.input_tokens), else_=0)
).label("monthly_input_tokens"),
# 今日
func.sum(case((Usage.created_at >= today, 1), else_=0)).label("today_requests"),
func.sum(case((Usage.created_at >= today, Usage.total_cost_usd), else_=0.0)).label(
"today_cost"
),
func.sum(case((Usage.created_at >= today, Usage.total_tokens), else_=0)).label(
"today_tokens"
),
func.sum(
case((Usage.created_at >= today, Usage.cache_creation_input_tokens), else_=0)
).label("today_cache_creation_tokens"),
func.sum(
case((Usage.created_at >= today, Usage.cache_read_input_tokens), else_=0)
).label("today_cache_read_tokens"),
# 昨日(用于变化趋势)
func.sum(
case(
(
and_(
Usage.created_at >= yesterday,
Usage.created_at < today,
),
1,
),
else_=0,
)
).label("yesterday_requests"),
) )
.filter(Usage.user_id == user.id) .filter(Usage.user_id == user.id)
.first() .first()
) )
all_time_input_tokens = (
int(all_time_token_stats.input_tokens or 0) if all_time_token_stats else 0 all_time_input_tokens = int(usage_stats.all_time_input_tokens or 0) if usage_stats else 0
) all_time_output_tokens = int(usage_stats.all_time_output_tokens or 0) if usage_stats else 0
all_time_output_tokens = (
int(all_time_token_stats.output_tokens or 0) if all_time_token_stats else 0
)
all_time_cache_creation = ( all_time_cache_creation = (
int(all_time_token_stats.cache_creation_tokens or 0) if all_time_token_stats else 0 int(usage_stats.all_time_cache_creation_tokens or 0) if usage_stats else 0
)
all_time_cache_read = (
int(all_time_token_stats.cache_read_tokens or 0) if all_time_token_stats else 0
) )
all_time_cache_read = int(usage_stats.all_time_cache_read_tokens or 0) if usage_stats else 0
# 本月请求统计 user_requests = int(usage_stats.monthly_requests or 0) if usage_stats else 0
user_requests = ( user_cost = float(usage_stats.monthly_cost or 0.0) if usage_stats else 0.0
db.query(func.count(Usage.id))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
)
user_cost = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.scalar()
or 0
)
# 今日统计 requests_today = int(usage_stats.today_requests or 0) if usage_stats else 0
requests_today = ( cost_today = float(usage_stats.today_cost or 0.0) if usage_stats else 0.0
db.query(func.count(Usage.id)) tokens_today = int(usage_stats.today_tokens or 0) if usage_stats else 0
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today)) requests_yesterday = int(usage_stats.yesterday_requests or 0) if usage_stats else 0
.scalar()
)
cost_today = (
db.query(func.sum(Usage.total_cost_usd))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.scalar()
or 0
)
tokens_today = (
db.query(func.sum(Usage.total_tokens))
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.scalar()
or 0
)
# 昨日统计(用于计算变化) cache_creation_tokens = (
requests_yesterday = ( int(usage_stats.monthly_cache_creation_tokens or 0) if usage_stats else 0
db.query(func.count(Usage.id))
.filter(
and_(
Usage.user_id == user.id,
Usage.created_at >= yesterday,
Usage.created_at < today,
)
)
.scalar()
) )
cache_read_tokens = int(usage_stats.monthly_cache_read_tokens or 0) if usage_stats else 0
# 缓存统计(本月) monthly_input_tokens = int(usage_stats.monthly_input_tokens or 0) if usage_stats else 0
cache_stats = (
db.query(
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
func.sum(Usage.input_tokens).label("total_input_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= month_start))
.first()
)
cache_creation_tokens = int(cache_stats.cache_creation_tokens or 0) if cache_stats else 0
cache_read_tokens = int(cache_stats.cache_read_tokens or 0) if cache_stats else 0
monthly_input_tokens = int(cache_stats.total_input_tokens or 0) if cache_stats else 0
# 计算本月缓存命中率cache_read / (input_tokens + cache_read) # 计算本月缓存命中率cache_read / (input_tokens + cache_read)
# input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的 # input_tokens 是实际发送给模型的输入不含缓存读取cache_read 是从缓存读取的
@@ -762,19 +763,11 @@ class UserDashboardStatsAdapter(DashboardAdapter):
) )
# 今日缓存统计 # 今日缓存统计
cache_stats_today = (
db.query(
func.sum(Usage.cache_creation_input_tokens).label("cache_creation_tokens"),
func.sum(Usage.cache_read_input_tokens).label("cache_read_tokens"),
)
.filter(and_(Usage.user_id == user.id, Usage.created_at >= today))
.first()
)
cache_creation_tokens_today = ( cache_creation_tokens_today = (
int(cache_stats_today.cache_creation_tokens or 0) if cache_stats_today else 0 int(usage_stats.today_cache_creation_tokens or 0) if usage_stats else 0
) )
cache_read_tokens_today = ( cache_read_tokens_today = (
int(cache_stats_today.cache_read_tokens or 0) if cache_stats_today else 0 int(usage_stats.today_cache_read_tokens or 0) if usage_stats else 0
) )
# 配额状态 # 配额状态
@@ -859,6 +852,12 @@ class UserDashboardStatsAdapter(DashboardAdapter):
class DashboardRecentRequestsAdapter(DashboardAdapter): class DashboardRecentRequestsAdapter(DashboardAdapter):
limit: int limit: int
@cache_result(
key_prefix="dashboard:recent:requests",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=True,
vary_by=["limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
user = context.user user = context.user
@@ -1003,28 +1002,53 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
# 补充 unique_models / unique_providers # 补充 unique_models / unique_providers
# query_time_series 使用小时粒度数据,不含这些维度统计 # query_time_series 使用小时粒度数据,不含这些维度统计
# 直接从 Usage 表按本地日 UTC 范围查询,避免 StatsDaily 历史数据未回填的问题 # 使用 CASE 一次性分桶,避免按天循环查询造成 N 次 SQL。
granularity = (self.time_range.granularity or "day").lower() granularity = (self.time_range.granularity or "day").lower()
if formatted and granularity == "day": local_days = self.time_range.get_local_day_hours()
local_days = self.time_range.get_local_day_hours() range_start = local_days[0][1] if local_days else None
enrichment: dict[str, dict] = {} range_end = local_days[-1][2] if local_days else None
day_bucket = (
case(
*[
(
and_(Usage.created_at >= day_start, Usage.created_at < day_end),
local_date.isoformat(),
)
for local_date, day_start, day_end in local_days
],
else_=None,
).label("local_day")
if local_days
else None
)
for local_date, day_start_utc, day_end_utc in local_days: if (
q = db.query( formatted
func.count(func.distinct(Usage.model)).label("um"), and granularity == "day"
func.count(func.distinct(Usage.provider_name)).label("up"), and day_bucket is not None
).filter( and range_start
Usage.created_at >= day_start_utc, and range_end
Usage.created_at < day_end_utc, ):
) enrichment: dict[str, dict[str, int]] = {}
if not is_admin: enrich_query = db.query(
q = q.filter(Usage.user_id == user.id) day_bucket,
row = q.first() func.count(func.distinct(Usage.model)).label("um"),
if row: func.count(func.distinct(Usage.provider_name)).label("up"),
enrichment[local_date.isoformat()] = { ).filter(
"unique_models": row.um or 0, Usage.created_at >= range_start,
"unique_providers": row.up or 0, Usage.created_at < range_end,
} )
if not is_admin:
enrich_query = enrich_query.filter(Usage.user_id == user.id)
enrich_rows = enrich_query.group_by(day_bucket).all()
for local_day, unique_models, unique_providers in enrich_rows:
if not local_day:
continue
enrichment[str(local_day)] = {
"unique_models": int(unique_models or 0),
"unique_providers": int(unique_providers or 0),
}
for item in formatted: for item in formatted:
date_key = item["date"][:10] # YYYY-MM-DD date_key = item["date"][:10] # YYYY-MM-DD
@@ -1062,26 +1086,35 @@ class DashboardDailyStatsAdapter(DashboardAdapter):
# Daily model breakdown (aligned to local days) # Daily model breakdown (aligned to local days)
breakdown_map: dict[str, list[dict]] = {} breakdown_map: dict[str, list[dict]] = {}
for local_date, day_start, day_end in self.time_range.get_local_day_hours(): if granularity == "day" and day_bucket is not None and range_start and range_end:
day_query = db.query( breakdown_query = db.query(
day_bucket,
Usage.model, Usage.model,
func.count(Usage.id).label("requests"), func.count(Usage.id).label("requests"),
func.sum(Usage.total_tokens).label("tokens"), func.sum(Usage.total_tokens).label("tokens"),
func.sum(Usage.total_cost_usd).label("cost"), func.sum(Usage.total_cost_usd).label("cost"),
).filter(Usage.created_at >= day_start, Usage.created_at < day_end) ).filter(
Usage.created_at >= range_start,
Usage.created_at < range_end,
)
if not is_admin: if not is_admin:
day_query = day_query.filter(Usage.user_id == user.id) breakdown_query = breakdown_query.filter(Usage.user_id == user.id)
day_stats = day_query.group_by(Usage.model).all() breakdown_rows = (
breakdown_map[local_date.isoformat()] = [ breakdown_query.group_by(day_bucket, Usage.model)
{ .order_by(day_bucket.asc(), func.sum(Usage.total_cost_usd).desc())
"model": stat.model, .all()
"requests": stat.requests or 0, )
"tokens": int(stat.tokens or 0), for local_day, model_name, requests, tokens, cost in breakdown_rows:
"cost": float(stat.cost or 0), if not local_day or not model_name:
} continue
for stat in day_stats breakdown_map.setdefault(str(local_day), []).append(
if stat.model {
] "model": model_name,
"requests": int(requests or 0),
"tokens": int(tokens or 0),
"cost": float(cost or 0.0),
}
)
for item in formatted: for item in formatted:
item["model_breakdown"] = breakdown_map.get(item["date"], []) item["model_breakdown"] = breakdown_map.get(item["date"], [])

View File

@@ -11,12 +11,13 @@ from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy import and_, or_ from sqlalchemy import and_, func, or_
from sqlalchemy.orm import Session, joinedload from sqlalchemy.orm import Session, joinedload, load_only
from src.api.base.adapter import ApiAdapter, ApiMode from src.api.base.adapter import ApiAdapter, ApiMode
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.models.api import ( from src.models.api import (
@@ -40,6 +41,7 @@ from src.models.endpoint_models import (
) )
from src.services.health.endpoint import EndpointHealthService from src.services.health.endpoint import EndpointHealthService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/public", tags=["System Catalog"]) router = APIRouter(prefix="/api/public", tags=["System Catalog"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -300,33 +302,84 @@ class PublicProvidersAdapter(PublicApiAdapter):
skip: int skip: int
limit: int limit: int
@cache_result(
key_prefix="public:catalog:providers",
ttl=CacheTTL.PROVIDER,
user_specific=False,
vary_by=["is_active", "skip", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug("公共API请求提供商列表") logger.debug("公共API请求提供商列表")
query = db.query(Provider) query = db.query(Provider).options(
load_only(
Provider.id,
Provider.name,
Provider.description,
Provider.is_active,
Provider.provider_priority,
)
)
if self.is_active is not None: if self.is_active is not None:
query = query.filter(Provider.is_active == self.is_active) query = query.filter(Provider.is_active == self.is_active)
else: else:
query = query.filter(Provider.is_active.is_(True)) query = query.filter(Provider.is_active.is_(True))
providers = query.offset(self.skip).limit(self.limit).all() providers = query.offset(self.skip).limit(self.limit).all()
provider_ids = [provider.id for provider in providers]
models_count_map: dict[str, int] = {}
active_models_count_map: dict[str, int] = {}
endpoints_count_map: dict[str, int] = {}
active_endpoints_count_map: dict[str, int] = {}
if provider_ids:
model_counts = (
db.query(Model.provider_id, func.count(Model.id))
.filter(Model.provider_id.in_(provider_ids))
.group_by(Model.provider_id)
.all()
)
models_count_map = {provider_id: int(count) for provider_id, count in model_counts}
active_model_counts = (
db.query(Model.provider_id, func.count(Model.id))
.filter(Model.provider_id.in_(provider_ids), Model.is_active.is_(True))
.group_by(Model.provider_id)
.all()
)
active_models_count_map = {
provider_id: int(count) for provider_id, count in active_model_counts
}
endpoint_counts = (
db.query(ProviderEndpoint.provider_id, func.count(ProviderEndpoint.id))
.filter(ProviderEndpoint.provider_id.in_(provider_ids))
.group_by(ProviderEndpoint.provider_id)
.all()
)
endpoints_count_map = {
provider_id: int(count) for provider_id, count in endpoint_counts
}
active_endpoint_counts = (
db.query(ProviderEndpoint.provider_id, func.count(ProviderEndpoint.id))
.filter(
ProviderEndpoint.provider_id.in_(provider_ids),
ProviderEndpoint.is_active.is_(True),
)
.group_by(ProviderEndpoint.provider_id)
.all()
)
active_endpoints_count_map = {
provider_id: int(count) for provider_id, count in active_endpoint_counts
}
result = [] result = []
for provider in providers: for provider in providers:
models_count = db.query(Model).filter(Model.provider_id == provider.id).count() models_count = models_count_map.get(provider.id, 0)
active_models_count = ( active_models_count = active_models_count_map.get(provider.id, 0)
db.query(Model) endpoints_count = endpoints_count_map.get(provider.id, 0)
.filter( active_endpoints_count = active_endpoints_count_map.get(provider.id, 0)
and_(
Model.provider_id == provider.id,
Model.is_active.is_(True),
)
)
.count()
)
endpoints_count = len(provider.endpoints) if provider.endpoints else 0
active_endpoints_count = (
sum(1 for ep in provider.endpoints if ep.is_active) if provider.endpoints else 0
)
provider_data = PublicProviderResponse( provider_data = PublicProviderResponse(
id=provider.id, id=provider.id,
name=provider.name, name=provider.name,
@@ -351,6 +404,12 @@ class PublicModelsAdapter(PublicApiAdapter):
skip: int skip: int
limit: int limit: int
@cache_result(
key_prefix="public:catalog:models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["provider_id", "is_active", "skip", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug("公共API请求模型列表") logger.debug("公共API请求模型列表")
@@ -407,12 +466,19 @@ class PublicModelsAdapter(PublicApiAdapter):
class PublicStatsAdapter(PublicApiAdapter): class PublicStatsAdapter(PublicApiAdapter):
@cache_result(
key_prefix="public:catalog:stats",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug("公共API请求系统统计信息") logger.debug("公共API请求系统统计信息")
active_providers = db.query(Provider).filter(Provider.is_active.is_(True)).count() active_providers = int(
active_models = ( db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
db.query(Model) )
active_models = int(
db.query(func.count(Model.id))
.join(Provider) .join(Provider)
.filter( .filter(
and_( and_(
@@ -420,12 +486,21 @@ class PublicStatsAdapter(PublicApiAdapter):
Provider.is_active.is_(True), Provider.is_active.is_(True),
) )
) )
.count() .scalar()
or 0
) )
formats = ( formats = (
db.query(Provider.api_format).filter(Provider.is_active.is_(True)).distinct().all() db.query(ProviderEndpoint.api_format)
.join(Provider, ProviderEndpoint.provider_id == Provider.id)
.filter(
ProviderEndpoint.is_active.is_(True),
Provider.is_active.is_(True),
ProviderEndpoint.api_format.isnot(None),
)
.distinct()
.all()
) )
supported_formats = [f.api_format for f in formats if f.api_format] supported_formats = [row[0] for row in formats if row[0]]
stats = ProviderStatsResponse( stats = ProviderStatsResponse(
total_providers=active_providers, total_providers=active_providers,
active_providers=active_providers, active_providers=active_providers,
@@ -443,6 +518,12 @@ class PublicSearchModelsAdapter(PublicApiAdapter):
provider_id: int | None provider_id: int | None
limit: int limit: int
@cache_result(
key_prefix="public:catalog:search_models",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=False,
vary_by=["query", "provider_id", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug(f"公共API搜索模型: {self.query}") logger.debug(f"公共API搜索模型: {self.query}")
@@ -512,6 +593,12 @@ class PublicApiFormatHealthMonitorAdapter(PublicApiAdapter):
lookback_hours: int lookback_hours: int
per_format_limit: int per_format_limit: int
@cache_result(
key_prefix="public:catalog:health_api_formats",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=False,
vary_by=["lookback_hours", "per_format_limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
@@ -657,7 +744,7 @@ class PublicApiFormatHealthMonitorAdapter(PublicApiAdapter):
) )
logger.debug(f"公开健康监控: 返回 {len(monitors)} 个 API 格式的健康数据") logger.debug(f"公开健康监控: 返回 {len(monitors)} 个 API 格式的健康数据")
return response return response.model_dump()
@dataclass @dataclass
@@ -669,6 +756,12 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
is_active: bool | None is_active: bool | None
search: str | None search: str | None
@cache_result(
key_prefix="public:catalog:global_models",
ttl=CacheTTL.MODEL,
user_specific=False,
vary_by=["skip", "limit", "is_active", "search"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
logger.debug("公共API请求 GlobalModel 列表") logger.debug("公共API请求 GlobalModel 列表")
@@ -691,8 +784,8 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
) )
) )
# 统计总数 # 统计总数(避免 Query.count() 生成大子查询)
total = query.count() total = int(query.with_entities(func.count(GlobalModel.id)).scalar() or 0)
# 分页 # 分页
models = query.order_by(GlobalModel.name).offset(self.skip).limit(self.limit).all() models = query.order_by(GlobalModel.name).offset(self.skip).limit(self.limit).all()
@@ -714,4 +807,4 @@ class PublicGlobalModelsAdapter(PublicApiAdapter):
) )
logger.debug(f"返回 {len(model_responses)} 个 GlobalModel") logger.debug(f"返回 {len(model_responses)} 个 GlobalModel")
return PublicGlobalModelListResponse(models=model_responses, total=total) return PublicGlobalModelListResponse(models=model_responses, total=total).model_dump()

View File

@@ -12,15 +12,18 @@ from typing import Any
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, load_only, selectinload
from src.api.handlers.base.request_builder import PassthroughRequestBuilder from src.api.handlers.base.request_builder import (
from src.api.handlers.base.request_builder import build_test_request_body, get_provider_auth PassthroughRequestBuilder,
build_test_request_body,
get_provider_auth,
)
from src.clients.redis_client import get_redis_client from src.clients.redis_client import get_redis_client
from src.core.logger import logger from src.core.logger import logger
from src.database import get_db from src.database import get_db
from src.database.database import get_pool_status from src.database.database import get_pool_status
from src.models.database import Model, Provider, ProviderAPIKey, ProviderEndpoint from src.models.database import GlobalModel, Model, Provider, ProviderAPIKey, ProviderEndpoint
from src.services.provider.transport import build_provider_url from src.services.provider.transport import build_provider_url
from src.utils.ssl_utils import get_ssl_context from src.utils.ssl_utils import get_ssl_context
@@ -85,7 +88,7 @@ def _serialize_provider(
def _select_provider(db: Session, provider_name: str | None) -> Provider | None: def _select_provider(db: Session, provider_name: str | None) -> Provider | None:
"""选择 Provider按 provider_priority 优先级选择)""" """选择 Provider按 provider_priority 优先级选择)"""
query = db.query(Provider).filter(Provider.is_active == True) query = db.query(Provider).filter(Provider.is_active.is_(True))
if provider_name: if provider_name:
provider = query.filter(Provider.name == provider_name).first() provider = query.filter(Provider.name == provider_name).first()
if provider: if provider:
@@ -102,9 +105,9 @@ def _select_provider(db: Session, provider_name: str | None) -> Provider | None:
async def service_health(db: Session = Depends(get_db)) -> Any: async def service_health(db: Session = Depends(get_db)) -> Any:
"""返回服务健康状态与依赖信息""" """返回服务健康状态与依赖信息"""
active_providers = ( active_providers = (
db.query(func.count(Provider.id)).filter(Provider.is_active == True).scalar() or 0 db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
) )
active_models = db.query(func.count(Model.id)).filter(Model.is_active == True).scalar() or 0 active_models = db.query(func.count(Model.id)).filter(Model.is_active.is_(True)).scalar() or 0
redis_info: dict[str, Any] = {"status": "unknown"} redis_info: dict[str, Any] = {"status": "unknown"}
try: try:
@@ -163,11 +166,14 @@ async def root(db: Session = Depends(get_db)) -> Any:
# 按优先级选择最高优先级的提供商 # 按优先级选择最高优先级的提供商
top_provider = ( top_provider = (
db.query(Provider) db.query(Provider)
.filter(Provider.is_active == True) .options(load_only(Provider.id, Provider.name, Provider.provider_priority))
.filter(Provider.is_active.is_(True))
.order_by(Provider.provider_priority.asc()) .order_by(Provider.provider_priority.asc())
.first() .first()
) )
active_providers = db.query(Provider).filter(Provider.is_active == True).count() active_providers = (
db.query(func.count(Provider.id)).filter(Provider.is_active.is_(True)).scalar() or 0
)
return { return {
"message": "AI Proxy with Modular Architecture v4.0.0", "message": "AI Proxy with Modular Architecture v4.0.0",
@@ -193,17 +199,37 @@ async def list_providers(
active_only: bool = Query(True), active_only: bool = Query(True),
) -> Any: ) -> Any:
"""列出所有 Provider""" """列出所有 Provider"""
load_options = [] load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models: if include_models:
load_options.append(selectinload(Provider.models).selectinload(Model.global_model)) load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints: if include_endpoints:
load_options.append(selectinload(Provider.endpoints)) load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider) base_query = db.query(Provider)
if load_options: if load_options:
base_query = base_query.options(*load_options) base_query = base_query.options(*load_options)
if active_only: if active_only:
base_query = base_query.filter(Provider.is_active == True) base_query = base_query.filter(Provider.is_active.is_(True))
base_query = base_query.order_by(Provider.provider_priority.asc(), Provider.name.asc()) base_query = base_query.order_by(Provider.provider_priority.asc(), Provider.name.asc())
providers = base_query.all() providers = base_query.all()
@@ -223,11 +249,31 @@ async def provider_detail(
include_endpoints: bool = Query(False), include_endpoints: bool = Query(False),
) -> Any: ) -> Any:
"""获取单个 Provider 详情""" """获取单个 Provider 详情"""
load_options = [] load_options = [
load_only(Provider.id, Provider.name, Provider.is_active, Provider.provider_priority)
]
if include_models: if include_models:
load_options.append(selectinload(Provider.models).selectinload(Model.global_model)) load_options.append(
selectinload(Provider.models)
.load_only(
Model.id,
Model.provider_model_name,
Model.is_active,
Model.supports_streaming,
Model.global_model_id,
)
.selectinload(Model.global_model)
.load_only(GlobalModel.id, GlobalModel.name, GlobalModel.display_name)
)
if include_endpoints: if include_endpoints:
load_options.append(selectinload(Provider.endpoints)) load_options.append(
selectinload(Provider.endpoints).load_only(
ProviderEndpoint.id,
ProviderEndpoint.base_url,
ProviderEndpoint.api_format,
ProviderEndpoint.is_active,
)
)
base_query = db.query(Provider) base_query = db.query(Provider)
if load_options: if load_options:

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline from src.api.base.pipeline import ApiRequestPipeline
from src.config.constants import CacheTTL
from src.core.crypto import crypto_service from src.core.crypto import crypto_service
from src.core.exceptions import ( from src.core.exceptions import (
ForbiddenException, ForbiddenException,
@@ -45,6 +46,7 @@ from src.services.system.time_range import TimeRangeParams
from src.services.usage.service import UsageService from src.services.usage.service import UsageService
from src.services.user.apikey import ApiKeyService from src.services.user.apikey import ApiKeyService
from src.services.user.preference import PreferenceService from src.services.user.preference import PreferenceService
from src.utils.cache_decorator import cache_result
router = APIRouter(prefix="/api/users/me", tags=["User Profile"]) router = APIRouter(prefix="/api/users/me", tags=["User Profile"])
pipeline = ApiRequestPipeline() pipeline = ApiRequestPipeline()
@@ -259,7 +261,7 @@ async def get_my_active_requests(
async def get_my_interval_timeline( async def get_my_interval_timeline(
request: Request, request: Request,
hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"), hours: int = Query(24, ge=1, le=720, description="分析最近多少小时的数据"),
limit: int = Query(5000, ge=100, le=20000, description="最大返回数据点数量"), limit: int = Query(2000, ge=100, le=20000, description="最大返回数据点数量"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> Any: ) -> Any:
""" """
@@ -767,6 +769,21 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
limit: int = 100 limit: int = 100
offset: int = 0 offset: int = 0
@cache_result(
key_prefix="user:usage:records",
ttl=CacheTTL.ADMIN_USAGE_RECORDS,
user_specific=True,
vary_by=[
"time_range.start_date",
"time_range.end_date",
"time_range.preset",
"time_range.timezone",
"time_range.tz_offset_minutes",
"search",
"limit",
"offset",
],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
from sqlalchemy import or_ from sqlalchemy import or_
from sqlalchemy.orm import load_only from sqlalchemy.orm import load_only
@@ -954,17 +971,18 @@ class GetUsageAdapter(AuthenticatedApiAdapter):
query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all() query.order_by(Usage.created_at.desc()).offset(self.offset).limit(self.limit).all()
) )
avg_resp_query = db.query(func.avg(Usage.response_time_ms)).filter( # 复用 summary 聚合中的成功请求响应时间,避免额外 AVG SQL
Usage.user_id == user.id, total_success_response_time_ms = sum(
Usage.status_code == 200, float(item.get("success_response_time_sum_ms", 0.0) or 0.0) for item in summary_list
Usage.response_time_ms.isnot(None), )
total_success_response_count = sum(
int(item.get("success_response_time_count", 0) or 0) for item in summary_list
)
avg_response_time = (
total_success_response_time_ms / total_success_response_count / 1000.0
if total_success_response_count > 0
else 0.0
) )
if start_utc and end_utc:
avg_resp_query = avg_resp_query.filter(
Usage.created_at >= start_utc, Usage.created_at < end_utc
)
avg_response_ms = avg_resp_query.scalar() or 0
avg_response_time = float(avg_response_ms) / 1000.0 if avg_response_ms else 0
# 构建响应数据 # 构建响应数据
response_data = { response_data = {
@@ -1100,6 +1118,12 @@ class GetMyIntervalTimelineAdapter(AuthenticatedApiAdapter):
hours: int hours: int
limit: int limit: int
@cache_result(
key_prefix="user:usage:interval_timeline",
ttl=CacheTTL.ADMIN_USAGE_AGGREGATION,
user_specific=True,
vary_by=["hours", "limit"],
)
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override] async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
db = context.db db = context.db
user = context.user user = context.user

View File

@@ -302,6 +302,7 @@ class Usage(Base):
__table_args__ = ( __table_args__ = (
# Composite indexes for common query patterns (analytics / list pages) # Composite indexes for common query patterns (analytics / list pages)
Index("idx_usage_user_created", "user_id", "created_at"), Index("idx_usage_user_created", "user_id", "created_at"),
Index("idx_usage_status_user_created", "status", "user_id", "created_at"),
Index("idx_usage_apikey_created", "api_key_id", "created_at"), Index("idx_usage_apikey_created", "api_key_id", "created_at"),
Index("idx_usage_provider_model_created", "provider_name", "model", "created_at"), Index("idx_usage_provider_model_created", "provider_name", "model", "created_at"),
Index("idx_usage_provider_created", "provider_name", "created_at"), Index("idx_usage_provider_created", "provider_name", "created_at"),

View File

@@ -6,6 +6,7 @@ import ipaddress
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -159,7 +160,12 @@ class ManagementTokenService:
ValueError: 如果名称已存在或超过数量限制 ValueError: 如果名称已存在或超过数量限制
""" """
# 检查用户 Token 数量限制 # 检查用户 Token 数量限制
token_count = db.query(ManagementToken).filter(ManagementToken.user_id == user_id).count() token_count = int(
db.query(func.count(ManagementToken.id))
.filter(ManagementToken.user_id == user_id)
.scalar()
or 0
)
max_tokens = config.management_token_max_per_user max_tokens = config.management_token_max_per_user
if token_count >= max_tokens: if token_count >= max_tokens:
raise ValueError(f"已达到 Token 数量上限({max_tokens}") raise ValueError(f"已达到 Token 数量上限({max_tokens}")
@@ -245,7 +251,7 @@ class ManagementTokenService:
if is_active is not None: if is_active is not None:
query = query.filter(ManagementToken.is_active == is_active) query = query.filter(ManagementToken.is_active == is_active)
total = query.count() total = int(query.with_entities(func.count(ManagementToken.id)).scalar() or 0)
tokens = query.order_by(ManagementToken.created_at.desc()).offset(skip).limit(limit).all() tokens = query.order_by(ManagementToken.created_at.desc()).offset(skip).limit(limit).all()
return tokens, total return tokens, total

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import asyncio import asyncio
from sqlalchemy import and_ from sqlalchemy import and_, func
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -255,14 +255,15 @@ class ModelService:
# 检查这是否是该 GlobalModel 的最后一个关联提供商 # 检查这是否是该 GlobalModel 的最后一个关联提供商
if model.global_model_id: if model.global_model_id:
other_implementations = ( other_implementations = int(
db.query(Model) db.query(func.count(Model.id))
.filter( .filter(
Model.global_model_id == model.global_model_id, Model.global_model_id == model.global_model_id,
Model.id != model_id, Model.id != model_id,
Model.is_active == True, Model.is_active == True,
) )
.count() .scalar()
or 0
) )
if other_implementations == 0: if other_implementations == 0:

View File

@@ -14,7 +14,7 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import httpx import httpx
from sqlalchemy import update from sqlalchemy import func, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.exceptions import InvalidRequestException, NotFoundException from src.core.exceptions import InvalidRequestException, NotFoundException
@@ -452,7 +452,7 @@ class ProxyNodeService:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status") raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized)) query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count() total = int(query.with_entities(func.count(ProxyNode.id)).scalar() or 0)
nodes = query.order_by(ProxyNode.name.asc()).offset(skip).limit(limit).all() nodes = query.order_by(ProxyNode.name.asc()).offset(skip).limit(limit).all()
return nodes, total return nodes, total

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from sqlalchemy import or_ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.exceptions import ForbiddenException, NotFoundException from src.core.exceptions import ForbiddenException, NotFoundException
@@ -87,7 +87,7 @@ class AnnouncementService:
) )
# 分页 # 分页
total = query.count() total = int(query.with_entities(func.count(Announcement.id)).scalar() or 0)
announcements = query.offset(offset).limit(limit).all() announcements = query.offset(offset).limit(limit).all()
# 获取已读状态 # 获取已读状态

View File

@@ -338,8 +338,8 @@ class AuditService:
) )
# 获取最近的可疑活动 # 获取最近的可疑活动
recent_suspicious = ( recent_suspicious = int(
db.query(AuditLog) db.query(func.count(AuditLog.id))
.filter( .filter(
AuditLog.user_id == user_id, AuditLog.user_id == user_id,
AuditLog.event_type.in_( AuditLog.event_type.in_(
@@ -350,7 +350,8 @@ class AuditService:
), ),
AuditLog.created_at >= cutoff_time, AuditLog.created_at >= cutoff_time,
) )
.count() .scalar()
or 0
) )
return { return {

View File

@@ -116,8 +116,8 @@ class SystemConfigService:
"description": "详细日志保留天数,超过此天数后压缩 request_body 和 response_body 到压缩字段", "description": "详细日志保留天数,超过此天数后压缩 request_body 和 response_body 到压缩字段",
}, },
"compressed_log_retention_days": { "compressed_log_retention_days": {
"value": 90, "value": 30,
"description": "压缩日志保留天数,超过此天数后删除压缩的 body 字段保留headers和统计", "description": "压缩记录保留天数,超过此天数后删除压缩的 body 字段保留headers和统计",
}, },
"header_retention_days": { "header_retention_days": {
"value": 90, "value": 90,
@@ -125,7 +125,7 @@ class SystemConfigService:
}, },
"log_retention_days": { "log_retention_days": {
"value": 365, "value": 365,
"description": "完整日志保留天数,超过此天数后删除整条记录(保留核心统计)", "description": "请求记录保存天数,超过此天数后删除整条使用记录",
}, },
"enable_auto_cleanup": { "enable_auto_cleanup": {
"value": True, "value": True,

View File

@@ -1052,7 +1052,7 @@ class MaintenanceScheduler:
# 获取配置参数 # 获取配置参数
detail_retention = SystemConfigService.get_config(db, "detail_log_retention_days", 7) detail_retention = SystemConfigService.get_config(db, "detail_log_retention_days", 7)
compressed_retention = SystemConfigService.get_config( compressed_retention = SystemConfigService.get_config(
db, "compressed_log_retention_days", 90 db, "compressed_log_retention_days", 30
) )
header_retention = SystemConfigService.get_config(db, "header_retention_days", 90) header_retention = SystemConfigService.get_config(db, "header_retention_days", 90)
log_retention = SystemConfigService.get_config(db, "log_retention_days", 365) log_retention = SystemConfigService.get_config(db, "log_retention_days", 365)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.api_format.metadata import can_passthrough_endpoint from src.core.api_format.metadata import can_passthrough_endpoint
@@ -104,13 +105,14 @@ class UsageActiveRequestsMixin:
""" """
cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes) cutoff_time = datetime.now(timezone.utc) - timedelta(minutes=timeout_minutes)
return ( return int(
db.query(Usage) db.query(func.count(Usage.id))
.filter( .filter(
Usage.status.in_(["pending", "streaming"]), Usage.status.in_(["pending", "streaming"]),
Usage.created_at < cutoff_time, Usage.created_at < cutoff_time,
) )
.count() .scalar()
or 0
) )
@classmethod @classmethod

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any from typing import Any
from sqlalchemy import func from sqlalchemy import case, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
@@ -239,6 +239,21 @@ class UsageQueryMixin:
func.sum(Usage.total_tokens).label("total_tokens"), func.sum(Usage.total_tokens).label("total_tokens"),
func.sum(Usage.total_cost_usd).label("total_cost_usd"), func.sum(Usage.total_cost_usd).label("total_cost_usd"),
func.avg(Usage.response_time_ms).label("avg_response_time"), func.avg(Usage.response_time_ms).label("avg_response_time"),
func.sum(
case(
(
(Usage.status_code == 200) & Usage.response_time_ms.isnot(None),
Usage.response_time_ms,
),
else_=0,
)
).label("success_response_time_sum"),
func.sum(
case(
((Usage.status_code == 200) & Usage.response_time_ms.isnot(None), 1),
else_=0,
)
).label("success_response_time_count"),
) )
# 过滤掉 pending/streaming 状态的请求(与上方明细查询一致) # 过滤掉 pending/streaming 状态的请求(与上方明细查询一致)
@@ -268,6 +283,8 @@ class UsageQueryMixin:
"avg_response_time_ms": ( "avg_response_time_ms": (
float(row.avg_response_time) if row.avg_response_time else 0 float(row.avg_response_time) if row.avg_response_time else 0
), ),
"success_response_time_sum_ms": float(row.success_response_time_sum or 0.0),
"success_response_time_count": int(row.success_response_time_count or 0),
} }
for row in summary for row in summary
] ]

View File

@@ -266,7 +266,9 @@ class UserService:
db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete( db.query(AnnouncementRead).filter(AnnouncementRead.user_id == user_id).delete(
synchronize_session=False synchronize_session=False
) )
api_key_count = db.query(ApiKey).filter(ApiKey.user_id == user_id).count() api_key_count = int(
db.query(func.count(ApiKey.id)).filter(ApiKey.user_id == user_id).scalar() or 0
)
db.query(ApiKey).filter(ApiKey.user_id == user_id).delete(synchronize_session=False) db.query(ApiKey).filter(ApiKey.user_id == user_id).delete(synchronize_session=False)
# 现在删除用户Usage, AuditLog, RequestAttempt 会通过数据库 SET NULL 保留) # 现在删除用户Usage, AuditLog, RequestAttempt 会通过数据库 SET NULL 保留)

View File

@@ -25,6 +25,22 @@ def _is_api_context(obj: Any) -> bool:
return hasattr(obj, "user") and hasattr(obj, "db") return hasattr(obj, "user") and hasattr(obj, "db")
def _resolve_attr(obj: Any, dotted_name: str) -> tuple[bool, Any]:
"""Resolve a possibly dotted attribute path (e.g. 'time_range.start_date').
Returns (found, value). When any segment along the chain is missing or
the intermediate value is None the lookup stops and returns (False, None).
"""
current = obj
for part in dotted_name.split("."):
if current is None:
return False, None
if not hasattr(current, part):
return False, None
current = getattr(current, part)
return True, current
def _hash_vary(vary: dict[str, Any]) -> str: def _hash_vary(vary: dict[str, Any]) -> str:
"""Build a short stable hash for cache key variations.""" """Build a short stable hash for cache key variations."""
try: try:
@@ -91,8 +107,9 @@ def cache_result(
if vary_by: if vary_by:
vary: dict[str, Any] = {} vary: dict[str, Any] = {}
for attr_name in vary_by: for attr_name in vary_by:
if hasattr(adapter_self, attr_name): found, value = _resolve_attr(adapter_self, attr_name)
vary[attr_name] = getattr(adapter_self, attr_name) if found:
vary[attr_name] = value
if vary: if vary:
cache_key += f":v:{_hash_vary(vary)}" cache_key += f":v:{_hash_vary(vary)}"
else: else: