From 329904ba7251645863ff18dfc8bff4dfadd42a9a Mon Sep 17 00:00:00 2001 From: shaw Date: Sun, 3 Aug 2025 10:03:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E9=A1=B5=E9=9D=A2=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复模型使用分布和详细统计数据不响应时间筛选的问题 - 后端/admin/model-stats端点添加startDate和endDate参数支持 - 支持自定义时间范围的模型统计数据聚合 - 前端loadModelStats函数添加时间范围参数传递 - 修复小时粒度和自定义时间筛选不生效的问题 现在时间筛选可以正确控制所有数据展示: - Token使用趋势图 ✓ - 模型使用分布 ✓ - 详细统计数据 ✓ - API Keys使用趋势 ✓ 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 135 ++++++++++++++++++-------- web/admin-spa/src/stores/dashboard.js | 52 +++++++++- 2 files changed, 147 insertions(+), 40 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index bf7e566c..e713302c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1496,29 +1496,65 @@ router.get('/usage-stats', authenticateAdmin, async (req, res) => { // 获取按模型的使用统计和费用 router.get('/model-stats', authenticateAdmin, async (req, res) => { try { - const { period = 'daily' } = req.query; // daily, monthly + const { period = 'daily', startDate, endDate } = req.query; // daily, monthly, 支持自定义时间范围 const today = redis.getDateStringInTimezone(); const tzDate = redis.getDateInTimezone(); const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`; - logger.info(`📊 Getting global model stats, period: ${period}, today: ${today}, currentMonth: ${currentMonth}`); + logger.info(`📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`); const client = redis.getClientSafe(); // 获取所有模型的统计数据 - const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`; - logger.info(`📊 Searching pattern: ${pattern}`); + let searchPatterns = []; - const keys = await client.keys(pattern); - logger.info(`📊 Found ${keys.length} matching keys:`, keys); + if (startDate && endDate) { + // 自定义日期范围,生成多个日期的搜索模式 + const start = new Date(startDate); + const end = new Date(endDate); + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }); + } + + // 限制最大范围为31天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1; + if (daysDiff > 31) { + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }); + } + + // 生成日期范围内所有日期的搜索模式 + const currentDate = new Date(start); + while (currentDate <= end) { + const dateStr = redis.getDateStringInTimezone(currentDate); + searchPatterns.push(`usage:model:daily:*:${dateStr}`); + currentDate.setDate(currentDate.getDate() + 1); + } + + logger.info(`📊 Generated ${searchPatterns.length} search patterns for date range`); + } else { + // 使用默认的period + const pattern = period === 'daily' ? `usage:model:daily:*:${today}` : `usage:model:monthly:*:${currentMonth}`; + searchPatterns = [pattern]; + } - const modelStats = []; + logger.info(`📊 Searching patterns:`, searchPatterns); - for (const key of keys) { - const match = key.match(period === 'daily' ? - /usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/ : - /usage:model:monthly:(.+):\d{4}-\d{2}$/ - ); + // 获取所有匹配的keys + const allKeys = []; + for (const pattern of searchPatterns) { + const keys = await client.keys(pattern); + allKeys.push(...keys); + } + + logger.info(`📊 Found ${allKeys.length} matching keys in total`); + + // 聚合相同模型的数据 + const modelStatsMap = new Map(); + + for (const key of allKeys) { + const match = key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/); if (!match) { logger.warn(`📊 Pattern mismatch for key: ${key}`); @@ -1528,41 +1564,62 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { const model = match[1]; const data = await client.hgetall(key); - logger.info(`📊 Model ${model} data:`, data); - if (data && Object.keys(data).length > 0) { - const usage = { - input_tokens: parseInt(data.inputTokens) || 0, - output_tokens: parseInt(data.outputTokens) || 0, - cache_creation_input_tokens: parseInt(data.cacheCreateTokens) || 0, - cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 + const stats = modelStatsMap.get(model) || { + requests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + allTokens: 0 }; - // 计算费用 - const costData = CostCalculator.calculateCost(usage, model); + stats.requests += parseInt(data.requests) || 0; + stats.inputTokens += parseInt(data.inputTokens) || 0; + stats.outputTokens += parseInt(data.outputTokens) || 0; + stats.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0; + stats.cacheReadTokens += parseInt(data.cacheReadTokens) || 0; + stats.allTokens += parseInt(data.allTokens) || 0; - modelStats.push({ - model, - period, - requests: parseInt(data.requests) || 0, + modelStatsMap.set(model, stats); + } + } + + // 转换为数组并计算费用 + const modelStats = []; + + for (const [model, stats] of modelStatsMap) { + const usage = { + input_tokens: stats.inputTokens, + output_tokens: stats.outputTokens, + cache_creation_input_tokens: stats.cacheCreateTokens, + cache_read_input_tokens: stats.cacheReadTokens + }; + + // 计算费用 + const costData = CostCalculator.calculateCost(usage, model); + + modelStats.push({ + model, + period: startDate && endDate ? 'custom' : period, + requests: stats.requests, + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + allTokens: stats.allTokens, + usage: { + requests: stats.requests, inputTokens: usage.input_tokens, outputTokens: usage.output_tokens, cacheCreateTokens: usage.cache_creation_input_tokens, cacheReadTokens: usage.cache_read_input_tokens, - allTokens: parseInt(data.allTokens) || 0, - usage: { - requests: parseInt(data.requests) || 0, - inputTokens: usage.input_tokens, - outputTokens: usage.output_tokens, - cacheCreateTokens: usage.cache_creation_input_tokens, - cacheReadTokens: usage.cache_read_input_tokens, - totalTokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens - }, - costs: costData.costs, - formatted: costData.formatted, - pricing: costData.pricing - }); - } + totalTokens: usage.input_tokens + usage.output_tokens + usage.cache_creation_input_tokens + usage.cache_read_input_tokens + }, + costs: costData.costs, + formatted: costData.formatted, + pricing: costData.pricing + }); } // 按总费用排序 diff --git a/web/admin-spa/src/stores/dashboard.js b/web/admin-spa/src/stores/dashboard.js index 9f91c907..28cd4dd3 100644 --- a/web/admin-spa/src/stores/dashboard.js +++ b/web/admin-spa/src/stores/dashboard.js @@ -263,7 +263,57 @@ export const useDashboardStore = defineStore('dashboard', () => { async function loadModelStats(period = 'daily') { try { - const response = await apiClient.get(`/admin/model-stats?period=${period}`) + let url = `/admin/model-stats?period=${period}` + + // 如果是自定义时间范围或小时粒度,传递具体的时间参数 + if (dateFilter.value.type === 'custom' || trendGranularity.value === 'hour') { + if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) { + // 将系统时区时间转换为UTC + const convertToUTC = (systemTzTimeStr) => { + const systemTz = 8 + const [datePart, timePart] = systemTzTimeStr.split(' ') + const [year, month, day] = datePart.split('-').map(Number) + const [hours, minutes, seconds] = timePart.split(':').map(Number) + + const utcDate = new Date(Date.UTC(year, month - 1, day, hours - systemTz, minutes, seconds)) + return utcDate.toISOString() + } + + url += `&startDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[0]))}` + url += `&endDate=${encodeURIComponent(convertToUTC(dateFilter.value.customRange[1]))}` + } else if (trendGranularity.value === 'hour' && dateFilter.value.type === 'preset') { + // 小时粒度的预设时间范围 + const now = new Date() + let startTime, endTime + + switch (dateFilter.value.preset) { + case 'last24h': + endTime = new Date(now) + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + break + case 'yesterday': + const yesterday = new Date() + yesterday.setDate(yesterday.getDate() - 1) + startTime = getSystemTimezoneDay(yesterday, true) + endTime = getSystemTimezoneDay(yesterday, false) + break + case 'dayBefore': + const dayBefore = new Date() + dayBefore.setDate(dayBefore.getDate() - 2) + startTime = getSystemTimezoneDay(dayBefore, true) + endTime = getSystemTimezoneDay(dayBefore, false) + break + default: + startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000) + endTime = now + } + + url += `&startDate=${encodeURIComponent(startTime.toISOString())}` + url += `&endDate=${encodeURIComponent(endTime.toISOString())}` + } + } + + const response = await apiClient.get(url) if (response.success) { dashboardModelStats.value = response.data }