Files
claude-relay-service/src/routes/admin/dashboard.js

588 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express')
const apiKeyService = require('../../services/apiKeyService')
const claudeAccountService = require('../../services/claudeAccountService')
const claudeConsoleAccountService = require('../../services/claudeConsoleAccountService')
const bedrockAccountService = require('../../services/bedrockAccountService')
const ccrAccountService = require('../../services/ccrAccountService')
const geminiAccountService = require('../../services/geminiAccountService')
const droidAccountService = require('../../services/droidAccountService')
const openaiResponsesAccountService = require('../../services/openaiResponsesAccountService')
const redis = require('../../models/redis')
const { authenticateAdmin } = require('../../middleware/auth')
const logger = require('../../utils/logger')
const CostCalculator = require('../../utils/costCalculator')
const config = require('../../../config/config')
const router = express.Router()
// 📊 系统统计
// 获取系统概览
router.get('/dashboard', authenticateAdmin, async (req, res) => {
try {
// 先检查是否有全局预聚合数据
const globalStats = await redis.getGlobalStats()
// 根据是否有全局统计决定查询策略
let apiKeys = null
let apiKeyCount = null
const [
claudeAccounts,
claudeConsoleAccounts,
geminiAccounts,
bedrockAccountsResult,
openaiAccounts,
ccrAccounts,
openaiResponsesAccounts,
droidAccounts,
todayStats,
systemAverages,
realtimeMetrics
] = await Promise.all([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
redis.getAllOpenAIAccounts(),
ccrAccountService.getAllAccounts(),
openaiResponsesAccountService.getAllAccounts(true),
droidAccountService.getAllAccounts(),
redis.getTodayStats(),
redis.getSystemAverages(),
redis.getRealtimeSystemMetrics()
])
// 有全局统计时只获取计数,否则拉全量
if (globalStats) {
apiKeyCount = await redis.getApiKeyCount()
} else {
apiKeys = await apiKeyService.getAllApiKeysFast()
}
// 处理Bedrock账户数据
const bedrockAccounts = bedrockAccountsResult.success ? bedrockAccountsResult.data : []
const normalizeBoolean = (value) => value === true || value === 'true'
const isRateLimitedFlag = (status) => {
if (!status) {
return false
}
if (typeof status === 'string') {
return status === 'limited'
}
if (typeof status === 'object') {
return status.isRateLimited === true
}
return false
}
// 通用账户统计函数 - 单次遍历完成所有统计
const countAccountStats = (accounts, opts = {}) => {
const { isStringType = false, checkGeminiRateLimit = false } = opts
let normal = 0,
abnormal = 0,
paused = 0,
rateLimited = 0
for (const acc of accounts) {
const isActive = isStringType
? acc.isActive === 'true' ||
acc.isActive === true ||
(!acc.isActive && acc.isActive !== 'false' && acc.isActive !== false)
: acc.isActive
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
const isSchedulable = isStringType
? acc.schedulable !== 'false' && acc.schedulable !== false
: acc.schedulable !== false
const isRateLimited = checkGeminiRateLimit
? acc.rateLimitStatus === 'limited' ||
(acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited)
: acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited
if (!isActive || isBlocked) {
abnormal++
} else if (!isSchedulable) {
paused++
} else if (isRateLimited) {
rateLimited++
} else {
normal++
}
}
return { normal, abnormal, paused, rateLimited }
}
// Droid 账户统计(特殊逻辑)
let normalDroidAccounts = 0,
abnormalDroidAccounts = 0,
pausedDroidAccounts = 0,
rateLimitedDroidAccounts = 0
for (const acc of droidAccounts) {
const isActive = normalizeBoolean(acc.isActive)
const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized'
const isSchedulable = normalizeBoolean(acc.schedulable)
const isRateLimited = isRateLimitedFlag(acc.rateLimitStatus)
if (!isActive || isBlocked) {
abnormalDroidAccounts++
} else if (!isSchedulable) {
pausedDroidAccounts++
} else if (isRateLimited) {
rateLimitedDroidAccounts++
} else {
normalDroidAccounts++
}
}
// 计算使用统计
let totalTokensUsed = 0,
totalRequestsUsed = 0,
totalInputTokensUsed = 0,
totalOutputTokensUsed = 0,
totalCacheCreateTokensUsed = 0,
totalCacheReadTokensUsed = 0,
totalAllTokensUsed = 0,
activeApiKeys = 0,
totalApiKeys = 0
if (globalStats) {
// 使用预聚合数据(快速路径)
totalRequestsUsed = globalStats.requests
totalInputTokensUsed = globalStats.inputTokens
totalOutputTokensUsed = globalStats.outputTokens
totalCacheCreateTokensUsed = globalStats.cacheCreateTokens
totalCacheReadTokensUsed = globalStats.cacheReadTokens
totalAllTokensUsed = globalStats.allTokens
totalTokensUsed = totalAllTokensUsed
totalApiKeys = apiKeyCount.total
activeApiKeys = apiKeyCount.active
} else {
// 回退到遍历(兼容旧数据)
totalApiKeys = apiKeys.length
for (const key of apiKeys) {
const usage = key.usage?.total
if (usage) {
totalTokensUsed += usage.allTokens || 0
totalRequestsUsed += usage.requests || 0
totalInputTokensUsed += usage.inputTokens || 0
totalOutputTokensUsed += usage.outputTokens || 0
totalCacheCreateTokensUsed += usage.cacheCreateTokens || 0
totalCacheReadTokensUsed += usage.cacheReadTokens || 0
totalAllTokensUsed += usage.allTokens || 0
}
if (key.isActive) {
activeApiKeys++
}
}
}
// 各平台账户统计(单次遍历)
const claudeStats = countAccountStats(claudeAccounts)
const claudeConsoleStats = countAccountStats(claudeConsoleAccounts)
const geminiStats = countAccountStats(geminiAccounts, { checkGeminiRateLimit: true })
const bedrockStats = countAccountStats(bedrockAccounts)
const openaiStats = countAccountStats(openaiAccounts, { isStringType: true })
const ccrStats = countAccountStats(ccrAccounts)
const openaiResponsesStats = countAccountStats(openaiResponsesAccounts, { isStringType: true })
const dashboard = {
overview: {
totalApiKeys,
activeApiKeys,
// 总账户统计(所有平台)
totalAccounts:
claudeAccounts.length +
claudeConsoleAccounts.length +
geminiAccounts.length +
bedrockAccounts.length +
openaiAccounts.length +
openaiResponsesAccounts.length +
ccrAccounts.length,
normalAccounts:
claudeStats.normal +
claudeConsoleStats.normal +
geminiStats.normal +
bedrockStats.normal +
openaiStats.normal +
openaiResponsesStats.normal +
ccrStats.normal,
abnormalAccounts:
claudeStats.abnormal +
claudeConsoleStats.abnormal +
geminiStats.abnormal +
bedrockStats.abnormal +
openaiStats.abnormal +
openaiResponsesStats.abnormal +
ccrStats.abnormal +
abnormalDroidAccounts,
pausedAccounts:
claudeStats.paused +
claudeConsoleStats.paused +
geminiStats.paused +
bedrockStats.paused +
openaiStats.paused +
openaiResponsesStats.paused +
ccrStats.paused +
pausedDroidAccounts,
rateLimitedAccounts:
claudeStats.rateLimited +
claudeConsoleStats.rateLimited +
geminiStats.rateLimited +
bedrockStats.rateLimited +
openaiStats.rateLimited +
openaiResponsesStats.rateLimited +
ccrStats.rateLimited +
rateLimitedDroidAccounts,
// 各平台详细统计
accountsByPlatform: {
claude: {
total: claudeAccounts.length,
normal: claudeStats.normal,
abnormal: claudeStats.abnormal,
paused: claudeStats.paused,
rateLimited: claudeStats.rateLimited
},
'claude-console': {
total: claudeConsoleAccounts.length,
normal: claudeConsoleStats.normal,
abnormal: claudeConsoleStats.abnormal,
paused: claudeConsoleStats.paused,
rateLimited: claudeConsoleStats.rateLimited
},
gemini: {
total: geminiAccounts.length,
normal: geminiStats.normal,
abnormal: geminiStats.abnormal,
paused: geminiStats.paused,
rateLimited: geminiStats.rateLimited
},
bedrock: {
total: bedrockAccounts.length,
normal: bedrockStats.normal,
abnormal: bedrockStats.abnormal,
paused: bedrockStats.paused,
rateLimited: bedrockStats.rateLimited
},
openai: {
total: openaiAccounts.length,
normal: openaiStats.normal,
abnormal: openaiStats.abnormal,
paused: openaiStats.paused,
rateLimited: openaiStats.rateLimited
},
ccr: {
total: ccrAccounts.length,
normal: ccrStats.normal,
abnormal: ccrStats.abnormal,
paused: ccrStats.paused,
rateLimited: ccrStats.rateLimited
},
'openai-responses': {
total: openaiResponsesAccounts.length,
normal: openaiResponsesStats.normal,
abnormal: openaiResponsesStats.abnormal,
paused: openaiResponsesStats.paused,
rateLimited: openaiResponsesStats.rateLimited
},
droid: {
total: droidAccounts.length,
normal: normalDroidAccounts,
abnormal: abnormalDroidAccounts,
paused: pausedDroidAccounts,
rateLimited: rateLimitedDroidAccounts
}
},
// 保留旧字段以兼容
activeAccounts:
claudeStats.normal +
claudeConsoleStats.normal +
geminiStats.normal +
bedrockStats.normal +
openaiStats.normal +
openaiResponsesStats.normal +
ccrStats.normal +
normalDroidAccounts,
totalClaudeAccounts: claudeAccounts.length + claudeConsoleAccounts.length,
activeClaudeAccounts: claudeStats.normal + claudeConsoleStats.normal,
rateLimitedClaudeAccounts: claudeStats.rateLimited + claudeConsoleStats.rateLimited,
totalGeminiAccounts: geminiAccounts.length,
activeGeminiAccounts: geminiStats.normal,
rateLimitedGeminiAccounts: geminiStats.rateLimited,
totalTokensUsed,
totalRequestsUsed,
totalInputTokensUsed,
totalOutputTokensUsed,
totalCacheCreateTokensUsed,
totalCacheReadTokensUsed,
totalAllTokensUsed
},
recentActivity: {
apiKeysCreatedToday: todayStats.apiKeysCreatedToday,
requestsToday: todayStats.requestsToday,
tokensToday: todayStats.tokensToday,
inputTokensToday: todayStats.inputTokensToday,
outputTokensToday: todayStats.outputTokensToday,
cacheCreateTokensToday: todayStats.cacheCreateTokensToday || 0,
cacheReadTokensToday: todayStats.cacheReadTokensToday || 0
},
systemAverages: {
rpm: systemAverages.systemRPM,
tpm: systemAverages.systemTPM
},
realtimeMetrics: {
rpm: realtimeMetrics.realtimeRPM,
tpm: realtimeMetrics.realtimeTPM,
windowMinutes: realtimeMetrics.windowMinutes,
isHistorical: realtimeMetrics.windowMinutes === 0 // 标识是否使用了历史数据
},
systemHealth: {
redisConnected: redis.isConnected,
claudeAccountsHealthy: claudeStats.normal + claudeConsoleStats.normal > 0,
geminiAccountsHealthy: geminiStats.normal > 0,
droidAccountsHealthy: normalDroidAccounts > 0,
uptime: process.uptime()
},
systemTimezone: config.system.timezoneOffset || 8
}
return res.json({ success: true, data: dashboard })
} catch (error) {
logger.error('❌ Failed to get dashboard data:', error)
return res.status(500).json({ error: 'Failed to get dashboard data', message: error.message })
}
})
// 获取使用统计
router.get('/usage-stats', authenticateAdmin, async (req, res) => {
try {
const { period = 'daily' } = req.query // daily, monthly
// 获取基础API Key统计
const apiKeys = await apiKeyService.getAllApiKeysFast()
const stats = apiKeys.map((key) => ({
keyId: key.id,
keyName: key.name,
usage: key.usage
}))
return res.json({ success: true, data: { period, stats } })
} catch (error) {
logger.error('❌ Failed to get usage stats:', error)
return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
}
})
// 获取按模型的使用统计和费用
router.get('/model-stats', authenticateAdmin, async (req, res) => {
try {
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}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}`
)
// 收集所有需要扫描的日期
const datePatterns = []
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' })
}
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) {
return res.status(400).json({ error: 'Date range cannot exceed 365 days' })
}
const currentDate = new Date(start)
while (currentDate <= end) {
const dateStr = redis.getDateStringInTimezone(currentDate)
datePatterns.push({ dateStr, pattern: `usage:model:daily:*:${dateStr}` })
currentDate.setDate(currentDate.getDate() + 1)
}
logger.info(`📊 Generated ${datePatterns.length} search patterns for date range`)
} else {
// 使用默认的period
const pattern =
period === 'daily'
? `usage:model:daily:*:${today}`
: `usage:model:monthly:*:${currentMonth}`
datePatterns.push({ dateStr: period === 'daily' ? today : currentMonth, pattern })
}
// 按日期集合扫描,串行避免并行触发多次全库 SCAN
const allResults = []
for (const { pattern } of datePatterns) {
const results = await redis.scanAndGetAllChunked(pattern)
allResults.push(...results)
}
logger.info(`📊 Found ${allResults.length} matching keys in total`)
// 模型名标准化函数与redis.js保持一致
const normalizeModelName = (model) => {
if (!model || model === 'unknown') {
return model
}
// 对于Bedrock模型去掉区域前缀进行统一
if (model.includes('.anthropic.') || model.includes('.claude')) {
let normalized = model.replace(/^[a-z0-9-]+\./, '')
normalized = normalized.replace('anthropic.', '')
normalized = normalized.replace(/-v\d+:\d+$/, '')
return normalized
}
return model.replace(/-v\d+:\d+$|:latest$/, '')
}
// 聚合相同模型的数据
const modelStatsMap = new Map()
for (const { key, data } of allResults) {
// 支持 daily 和 monthly 两种格式
const match =
key.match(/usage:model:daily:(.+):\d{4}-\d{2}-\d{2}$/) ||
key.match(/usage:model:monthly:(.+):\d{4}-\d{2}$/)
if (!match) {
logger.warn(`📊 Pattern mismatch for key: ${key}`)
continue
}
const rawModel = match[1]
const normalizedModel = normalizeModelName(rawModel)
if (data && Object.keys(data).length > 0) {
const stats = modelStatsMap.get(normalizedModel) || {
requests: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
allTokens: 0,
ephemeral5mTokens: 0,
ephemeral1hTokens: 0
}
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
stats.ephemeral5mTokens += parseInt(data.ephemeral5mTokens) || 0
stats.ephemeral1hTokens += parseInt(data.ephemeral1hTokens) || 0
modelStatsMap.set(normalizedModel, 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
}
// 如果有 ephemeral 5m/1h 拆分数据,添加 cache_creation 子对象以实现精确计费
if (stats.ephemeral5mTokens > 0 || stats.ephemeral1hTokens > 0) {
usage.cache_creation = {
ephemeral_5m_input_tokens: stats.ephemeral5mTokens,
ephemeral_1h_input_tokens: stats.ephemeral1hTokens
}
}
// 计算费用
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,
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
})
}
// 按总费用排序
modelStats.sort((a, b) => b.costs.total - a.costs.total)
logger.info(
`📊 Returning ${modelStats.length} global model stats for period ${period}:`,
modelStats
)
return res.json({ success: true, data: modelStats })
} catch (error) {
logger.error('❌ Failed to get model stats:', error)
return res.status(500).json({ error: 'Failed to get model stats', message: error.message })
}
})
// 🔧 系统管理
// 清理过期数据
router.post('/cleanup', authenticateAdmin, async (req, res) => {
try {
const [expiredKeys, errorAccounts] = await Promise.all([
apiKeyService.cleanupExpiredKeys(),
claudeAccountService.cleanupErrorAccounts()
])
await redis.cleanup()
logger.success(
`🧹 Admin triggered cleanup: ${expiredKeys} expired keys, ${errorAccounts} error accounts`
)
return res.json({
success: true,
message: 'Cleanup completed',
data: {
expiredKeysRemoved: expiredKeys,
errorAccountsReset: errorAccounts
}
})
} catch (error) {
logger.error('❌ Cleanup failed:', error)
return res.status(500).json({ error: 'Cleanup failed', message: error.message })
}
})
module.exports = router