mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
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:
@@ -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)
|
||||||
@@ -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
|
||||||
|
)
|
||||||
},
|
},
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, '加载端点失败'), '错误')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
// 配置不存在时使用默认值,无需处理
|
// 配置不存在时使用默认值,无需处理
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 已包含记录加载
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 显示请求详情
|
// 显示请求详情
|
||||||
|
|||||||
@@ -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()
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 ==========
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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": []}
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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 = (
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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 字段
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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("不能删除最后一个管理员账户")
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"], [])
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
# 获取已读状态
|
# 获取已读状态
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 保留)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user