mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
- Fix admin panel cost display for "all time" period using permanent Redis key - Fix user statistics total cost limit to show complete history - Fix restricted models list overflow with scrollable container Backend changes: - src/routes/admin/apiKeys.js: Use allTimeCost for timeRange='all' instead of scanning TTL keys - src/routes/apiStats.js: Prioritize permanent usage:cost:total key over monthly keys Frontend changes: - web/admin-spa/src/components/apistats/LimitConfig.vue: Add overflow-visible and scrolling to model list 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1022 lines
34 KiB
JavaScript
1022 lines
34 KiB
JavaScript
const express = require('express')
|
||
const redis = require('../models/redis')
|
||
const logger = require('../utils/logger')
|
||
const apiKeyService = require('../services/apiKeyService')
|
||
const CostCalculator = require('../utils/costCalculator')
|
||
const claudeAccountService = require('../services/claudeAccountService')
|
||
const openaiAccountService = require('../services/openaiAccountService')
|
||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||
|
||
const router = express.Router()
|
||
|
||
// 🏠 重定向页面请求到新版 admin-spa
|
||
router.get('/', (req, res) => {
|
||
res.redirect(301, '/admin-next/api-stats')
|
||
})
|
||
|
||
// 🔑 获取 API Key 对应的 ID
|
||
router.post('/api/get-key-id', async (req, res) => {
|
||
try {
|
||
const { apiKey } = req.body
|
||
|
||
if (!apiKey) {
|
||
return res.status(400).json({
|
||
error: 'API Key is required',
|
||
message: 'Please provide your API Key'
|
||
})
|
||
}
|
||
|
||
// 基本API Key格式验证
|
||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||
return res.status(400).json({
|
||
error: 'Invalid API key format',
|
||
message: 'API key format is invalid'
|
||
})
|
||
}
|
||
|
||
// 验证API Key(使用不触发激活的验证方法)
|
||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||
|
||
if (!validation.valid) {
|
||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||
logger.security(`🔒 Invalid API key in get-key-id: ${validation.error} from ${clientIP}`)
|
||
return res.status(401).json({
|
||
error: 'Invalid API key',
|
||
message: validation.error
|
||
})
|
||
}
|
||
|
||
const { keyData } = validation
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
id: keyData.id
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get API key ID:', error)
|
||
return res.status(500).json({
|
||
error: 'Internal server error',
|
||
message: 'Failed to retrieve API key ID'
|
||
})
|
||
}
|
||
})
|
||
|
||
// 📊 用户API Key统计查询接口 - 安全的自查询接口
|
||
router.post('/api/user-stats', async (req, res) => {
|
||
try {
|
||
const { apiKey, apiId } = req.body
|
||
|
||
let keyData
|
||
let keyId
|
||
|
||
if (apiId) {
|
||
// 通过 apiId 查询
|
||
if (
|
||
typeof apiId !== 'string' ||
|
||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||
) {
|
||
return res.status(400).json({
|
||
error: 'Invalid API ID format',
|
||
message: 'API ID must be a valid UUID'
|
||
})
|
||
}
|
||
|
||
// 直接通过 ID 获取 API Key 数据
|
||
keyData = await redis.getApiKey(apiId)
|
||
|
||
if (!keyData || Object.keys(keyData).length === 0) {
|
||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||
return res.status(404).json({
|
||
error: 'API key not found',
|
||
message: 'The specified API key does not exist'
|
||
})
|
||
}
|
||
|
||
// 检查是否激活
|
||
if (keyData.isActive !== 'true') {
|
||
const keyName = keyData.name || 'Unknown'
|
||
return res.status(403).json({
|
||
error: 'API key is disabled',
|
||
message: `API Key "${keyName}" 已被禁用`,
|
||
keyName
|
||
})
|
||
}
|
||
|
||
// 检查是否过期
|
||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||
const keyName = keyData.name || 'Unknown'
|
||
return res.status(403).json({
|
||
error: 'API key has expired',
|
||
message: `API Key "${keyName}" 已过期`,
|
||
keyName
|
||
})
|
||
}
|
||
|
||
keyId = apiId
|
||
|
||
// 获取使用统计
|
||
const usage = await redis.getUsageStats(keyId)
|
||
|
||
// 获取当日费用统计
|
||
const dailyCost = await redis.getDailyCost(keyId)
|
||
const costStats = await redis.getCostStats(keyId)
|
||
|
||
// 处理数据格式,与 validateApiKey 返回的格式保持一致
|
||
// 解析限制模型数据
|
||
let restrictedModels = []
|
||
try {
|
||
restrictedModels = keyData.restrictedModels ? JSON.parse(keyData.restrictedModels) : []
|
||
} catch (e) {
|
||
restrictedModels = []
|
||
}
|
||
|
||
// 解析允许的客户端数据
|
||
let allowedClients = []
|
||
try {
|
||
allowedClients = keyData.allowedClients ? JSON.parse(keyData.allowedClients) : []
|
||
} catch (e) {
|
||
allowedClients = []
|
||
}
|
||
|
||
// 格式化 keyData
|
||
keyData = {
|
||
...keyData,
|
||
tokenLimit: parseInt(keyData.tokenLimit) || 0,
|
||
concurrencyLimit: parseInt(keyData.concurrencyLimit) || 0,
|
||
rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0,
|
||
rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0,
|
||
dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0,
|
||
totalCostLimit: parseFloat(keyData.totalCostLimit) || 0,
|
||
dailyCost: dailyCost || 0,
|
||
totalCost: costStats.total || 0,
|
||
enableModelRestriction: keyData.enableModelRestriction === 'true',
|
||
restrictedModels,
|
||
enableClientRestriction: keyData.enableClientRestriction === 'true',
|
||
allowedClients,
|
||
permissions: keyData.permissions || 'all',
|
||
// 添加激活相关字段
|
||
expirationMode: keyData.expirationMode || 'fixed',
|
||
isActivated: keyData.isActivated === 'true',
|
||
activationDays: parseInt(keyData.activationDays || 0),
|
||
activatedAt: keyData.activatedAt || null,
|
||
usage // 使用完整的 usage 数据,而不是只有 total
|
||
}
|
||
} else if (apiKey) {
|
||
// 通过 apiKey 查询(保持向后兼容)
|
||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||
logger.security(`🔒 Invalid API key format in user stats query from ${req.ip || 'unknown'}`)
|
||
return res.status(400).json({
|
||
error: 'Invalid API key format',
|
||
message: 'API key format is invalid'
|
||
})
|
||
}
|
||
|
||
// 验证API Key(使用不触发激活的验证方法)
|
||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||
|
||
if (!validation.valid) {
|
||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||
logger.security(
|
||
`🔒 Invalid API key in user stats query: ${validation.error} from ${clientIP}`
|
||
)
|
||
return res.status(401).json({
|
||
error: 'Invalid API key',
|
||
message: validation.error
|
||
})
|
||
}
|
||
|
||
const { keyData: validatedKeyData } = validation
|
||
keyData = validatedKeyData
|
||
keyId = keyData.id
|
||
} else {
|
||
logger.security(`🔒 Missing API key or ID in user stats query from ${req.ip || 'unknown'}`)
|
||
return res.status(400).json({
|
||
error: 'API Key or ID is required',
|
||
message: 'Please provide your API Key or API ID'
|
||
})
|
||
}
|
||
|
||
// 记录合法查询
|
||
logger.api(
|
||
`📊 User stats query from key: ${keyData.name} (${keyId}) from ${req.ip || 'unknown'}`
|
||
)
|
||
|
||
// 获取验证结果中的完整keyData(包含isActive状态和cost信息)
|
||
const fullKeyData = keyData
|
||
|
||
// 🔧 FIX: 使用 allTimeCost 而不是扫描月度键
|
||
// 计算总费用 - 优先使用持久化的总费用计数器
|
||
let totalCost = 0
|
||
let formattedCost = '$0.000000'
|
||
|
||
try {
|
||
const client = redis.getClientSafe()
|
||
|
||
// 读取累积的总费用(没有 TTL 的持久键)
|
||
const totalCostKey = `usage:cost:total:${keyId}`
|
||
const allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||
|
||
if (allTimeCost > 0) {
|
||
totalCost = allTimeCost
|
||
formattedCost = CostCalculator.formatCost(allTimeCost)
|
||
logger.debug(`📊 使用 allTimeCost 计算用户统计: ${allTimeCost}`)
|
||
} else {
|
||
// Fallback: 如果 allTimeCost 为空(旧键),尝试月度键
|
||
const allModelKeys = await client.keys(`usage:${keyId}:model:monthly:*:*`)
|
||
const modelUsageMap = new Map()
|
||
|
||
for (const key of allModelKeys) {
|
||
const modelMatch = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/)
|
||
if (!modelMatch) {
|
||
continue
|
||
}
|
||
|
||
const model = modelMatch[1]
|
||
const data = await client.hgetall(key)
|
||
|
||
if (data && Object.keys(data).length > 0) {
|
||
if (!modelUsageMap.has(model)) {
|
||
modelUsageMap.set(model, {
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0
|
||
})
|
||
}
|
||
|
||
const modelUsage = modelUsageMap.get(model)
|
||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||
}
|
||
}
|
||
|
||
// 按模型计算费用并汇总
|
||
for (const [model, usage] of modelUsageMap) {
|
||
const usageData = {
|
||
input_tokens: usage.inputTokens,
|
||
output_tokens: usage.outputTokens,
|
||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||
cache_read_input_tokens: usage.cacheReadTokens
|
||
}
|
||
|
||
const costResult = CostCalculator.calculateCost(usageData, model)
|
||
totalCost += costResult.costs.total
|
||
}
|
||
|
||
// 如果没有模型级别的详细数据,回退到总体数据计算
|
||
if (modelUsageMap.size === 0 && fullKeyData.usage?.total?.allTokens > 0) {
|
||
const usage = fullKeyData.usage.total
|
||
const costUsage = {
|
||
input_tokens: usage.inputTokens || 0,
|
||
output_tokens: usage.outputTokens || 0,
|
||
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
||
cache_read_input_tokens: usage.cacheReadTokens || 0
|
||
}
|
||
|
||
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
||
totalCost = costResult.costs.total
|
||
}
|
||
|
||
formattedCost = CostCalculator.formatCost(totalCost)
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`Failed to calculate cost for key ${keyId}:`, error)
|
||
// 回退到简单计算
|
||
if (fullKeyData.usage?.total?.allTokens > 0) {
|
||
const usage = fullKeyData.usage.total
|
||
const costUsage = {
|
||
input_tokens: usage.inputTokens || 0,
|
||
output_tokens: usage.outputTokens || 0,
|
||
cache_creation_input_tokens: usage.cacheCreateTokens || 0,
|
||
cache_read_input_tokens: usage.cacheReadTokens || 0
|
||
}
|
||
|
||
const costResult = CostCalculator.calculateCost(costUsage, 'claude-3-5-sonnet-20241022')
|
||
totalCost = costResult.costs.total
|
||
formattedCost = costResult.formatted.total
|
||
}
|
||
}
|
||
|
||
// 获取当前使用量
|
||
let currentWindowRequests = 0
|
||
let currentWindowTokens = 0
|
||
let currentWindowCost = 0 // 新增:当前窗口费用
|
||
let currentDailyCost = 0
|
||
let windowStartTime = null
|
||
let windowEndTime = null
|
||
let windowRemainingSeconds = null
|
||
|
||
try {
|
||
// 获取当前时间窗口的请求次数、Token使用量和费用
|
||
if (fullKeyData.rateLimitWindow > 0) {
|
||
const client = redis.getClientSafe()
|
||
const requestCountKey = `rate_limit:requests:${keyId}`
|
||
const tokenCountKey = `rate_limit:tokens:${keyId}`
|
||
const costCountKey = `rate_limit:cost:${keyId}` // 新增:费用计数key
|
||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||
|
||
currentWindowRequests = parseInt((await client.get(requestCountKey)) || '0')
|
||
currentWindowTokens = parseInt((await client.get(tokenCountKey)) || '0')
|
||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0') // 新增:获取当前窗口费用
|
||
|
||
// 获取窗口开始时间和计算剩余时间
|
||
const windowStart = await client.get(windowStartKey)
|
||
if (windowStart) {
|
||
const now = Date.now()
|
||
windowStartTime = parseInt(windowStart)
|
||
const windowDuration = fullKeyData.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||
windowEndTime = windowStartTime + windowDuration
|
||
|
||
// 如果窗口还有效
|
||
if (now < windowEndTime) {
|
||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||
} else {
|
||
// 窗口已过期,下次请求会重置
|
||
windowStartTime = null
|
||
windowEndTime = null
|
||
windowRemainingSeconds = 0
|
||
// 重置计数为0,因为窗口已过期
|
||
currentWindowRequests = 0
|
||
currentWindowTokens = 0
|
||
currentWindowCost = 0 // 新增:重置窗口费用
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取当日费用
|
||
currentDailyCost = (await redis.getDailyCost(keyId)) || 0
|
||
} catch (error) {
|
||
logger.warn(`Failed to get current usage for key ${keyId}:`, error)
|
||
}
|
||
|
||
const boundAccountDetails = {}
|
||
|
||
const accountDetailTasks = []
|
||
|
||
if (fullKeyData.claudeAccountId) {
|
||
accountDetailTasks.push(
|
||
(async () => {
|
||
try {
|
||
const overview = await claudeAccountService.getAccountOverview(
|
||
fullKeyData.claudeAccountId
|
||
)
|
||
|
||
if (overview && overview.accountType === 'dedicated') {
|
||
boundAccountDetails.claude = overview
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`⚠️ Failed to load Claude account overview for key ${keyId}:`, error)
|
||
}
|
||
})()
|
||
)
|
||
}
|
||
|
||
if (fullKeyData.openaiAccountId) {
|
||
accountDetailTasks.push(
|
||
(async () => {
|
||
try {
|
||
const overview = await openaiAccountService.getAccountOverview(
|
||
fullKeyData.openaiAccountId
|
||
)
|
||
|
||
if (overview && overview.accountType === 'dedicated') {
|
||
boundAccountDetails.openai = overview
|
||
}
|
||
} catch (error) {
|
||
logger.warn(`⚠️ Failed to load OpenAI account overview for key ${keyId}:`, error)
|
||
}
|
||
})()
|
||
)
|
||
}
|
||
|
||
if (accountDetailTasks.length > 0) {
|
||
await Promise.allSettled(accountDetailTasks)
|
||
}
|
||
|
||
// 构建响应数据(只返回该API Key自己的信息,确保不泄露其他信息)
|
||
const responseData = {
|
||
id: keyId,
|
||
name: fullKeyData.name,
|
||
description: fullKeyData.description || keyData.description || '',
|
||
isActive: true, // 如果能通过validateApiKey验证,说明一定是激活的
|
||
createdAt: fullKeyData.createdAt || keyData.createdAt,
|
||
expiresAt: fullKeyData.expiresAt || keyData.expiresAt,
|
||
// 添加激活相关字段
|
||
expirationMode: fullKeyData.expirationMode || 'fixed',
|
||
isActivated: fullKeyData.isActivated === true || fullKeyData.isActivated === 'true',
|
||
activationDays: parseInt(fullKeyData.activationDays || 0),
|
||
activatedAt: fullKeyData.activatedAt || null,
|
||
permissions: fullKeyData.permissions,
|
||
|
||
// 使用统计(使用验证结果中的完整数据)
|
||
usage: {
|
||
total: {
|
||
...(fullKeyData.usage?.total || {
|
||
requests: 0,
|
||
tokens: 0,
|
||
allTokens: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0
|
||
}),
|
||
cost: totalCost,
|
||
formattedCost
|
||
}
|
||
},
|
||
|
||
// 限制信息(显示配置和当前使用量)
|
||
limits: {
|
||
tokenLimit: fullKeyData.tokenLimit || 0,
|
||
concurrencyLimit: fullKeyData.concurrencyLimit || 0,
|
||
rateLimitWindow: fullKeyData.rateLimitWindow || 0,
|
||
rateLimitRequests: fullKeyData.rateLimitRequests || 0,
|
||
rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制
|
||
dailyCostLimit: fullKeyData.dailyCostLimit || 0,
|
||
totalCostLimit: fullKeyData.totalCostLimit || 0,
|
||
weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制
|
||
// 当前使用量
|
||
currentWindowRequests,
|
||
currentWindowTokens,
|
||
currentWindowCost, // 新增:当前窗口费用
|
||
currentDailyCost,
|
||
currentTotalCost: totalCost,
|
||
weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, // 当前 Opus 周费用
|
||
// 时间窗口信息
|
||
windowStartTime,
|
||
windowEndTime,
|
||
windowRemainingSeconds
|
||
},
|
||
|
||
// 绑定的账户信息(只显示ID,不显示敏感信息)
|
||
accounts: {
|
||
claudeAccountId:
|
||
fullKeyData.claudeAccountId && fullKeyData.claudeAccountId !== ''
|
||
? fullKeyData.claudeAccountId
|
||
: null,
|
||
geminiAccountId:
|
||
fullKeyData.geminiAccountId && fullKeyData.geminiAccountId !== ''
|
||
? fullKeyData.geminiAccountId
|
||
: null,
|
||
openaiAccountId:
|
||
fullKeyData.openaiAccountId && fullKeyData.openaiAccountId !== ''
|
||
? fullKeyData.openaiAccountId
|
||
: null,
|
||
details: Object.keys(boundAccountDetails).length > 0 ? boundAccountDetails : null
|
||
},
|
||
|
||
// 模型和客户端限制信息
|
||
restrictions: {
|
||
enableModelRestriction: fullKeyData.enableModelRestriction || false,
|
||
restrictedModels: fullKeyData.restrictedModels || [],
|
||
enableClientRestriction: fullKeyData.enableClientRestriction || false,
|
||
allowedClients: fullKeyData.allowedClients || []
|
||
}
|
||
}
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: responseData
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to process user stats query:', error)
|
||
return res.status(500).json({
|
||
error: 'Internal server error',
|
||
message: 'Failed to retrieve API key statistics'
|
||
})
|
||
}
|
||
})
|
||
|
||
// 📊 批量查询统计数据接口
|
||
router.post('/api/batch-stats', async (req, res) => {
|
||
try {
|
||
const { apiIds } = req.body
|
||
|
||
// 验证输入
|
||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||
return res.status(400).json({
|
||
error: 'Invalid input',
|
||
message: 'API IDs array is required'
|
||
})
|
||
}
|
||
|
||
// 限制最多查询 30 个
|
||
if (apiIds.length > 30) {
|
||
return res.status(400).json({
|
||
error: 'Too many keys',
|
||
message: 'Maximum 30 API keys can be queried at once'
|
||
})
|
||
}
|
||
|
||
// 验证所有 ID 格式
|
||
const uuidRegex = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i
|
||
const invalidIds = apiIds.filter((id) => !uuidRegex.test(id))
|
||
if (invalidIds.length > 0) {
|
||
return res.status(400).json({
|
||
error: 'Invalid API ID format',
|
||
message: `Invalid API IDs: ${invalidIds.join(', ')}`
|
||
})
|
||
}
|
||
|
||
const individualStats = []
|
||
const aggregated = {
|
||
totalKeys: apiIds.length,
|
||
activeKeys: 0,
|
||
usage: {
|
||
requests: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
allTokens: 0,
|
||
cost: 0,
|
||
formattedCost: '$0.000000'
|
||
},
|
||
dailyUsage: {
|
||
requests: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
allTokens: 0,
|
||
cost: 0,
|
||
formattedCost: '$0.000000'
|
||
},
|
||
monthlyUsage: {
|
||
requests: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
allTokens: 0,
|
||
cost: 0,
|
||
formattedCost: '$0.000000'
|
||
}
|
||
}
|
||
|
||
// 并行查询所有 API Key 数据(复用单key查询逻辑)
|
||
const results = await Promise.allSettled(
|
||
apiIds.map(async (apiId) => {
|
||
const keyData = await redis.getApiKey(apiId)
|
||
|
||
if (!keyData || Object.keys(keyData).length === 0) {
|
||
return { error: 'Not found', apiId }
|
||
}
|
||
|
||
// 检查是否激活
|
||
if (keyData.isActive !== 'true') {
|
||
return { error: 'Disabled', apiId }
|
||
}
|
||
|
||
// 检查是否过期
|
||
if (keyData.expiresAt && new Date() > new Date(keyData.expiresAt)) {
|
||
return { error: 'Expired', apiId }
|
||
}
|
||
|
||
// 复用单key查询的逻辑:获取使用统计
|
||
const usage = await redis.getUsageStats(apiId)
|
||
|
||
// 获取费用统计(与单key查询一致)
|
||
const costStats = await redis.getCostStats(apiId)
|
||
|
||
return {
|
||
apiId,
|
||
name: keyData.name,
|
||
description: keyData.description || '',
|
||
isActive: true,
|
||
createdAt: keyData.createdAt,
|
||
usage: usage.total || {},
|
||
dailyStats: {
|
||
...usage.daily,
|
||
cost: costStats.daily
|
||
},
|
||
monthlyStats: {
|
||
...usage.monthly,
|
||
cost: costStats.monthly
|
||
},
|
||
totalCost: costStats.total
|
||
}
|
||
})
|
||
)
|
||
|
||
// 处理结果并聚合
|
||
results.forEach((result) => {
|
||
if (result.status === 'fulfilled' && result.value && !result.value.error) {
|
||
const stats = result.value
|
||
aggregated.activeKeys++
|
||
|
||
// 聚合总使用量
|
||
if (stats.usage) {
|
||
aggregated.usage.requests += stats.usage.requests || 0
|
||
aggregated.usage.inputTokens += stats.usage.inputTokens || 0
|
||
aggregated.usage.outputTokens += stats.usage.outputTokens || 0
|
||
aggregated.usage.cacheCreateTokens += stats.usage.cacheCreateTokens || 0
|
||
aggregated.usage.cacheReadTokens += stats.usage.cacheReadTokens || 0
|
||
aggregated.usage.allTokens += stats.usage.allTokens || 0
|
||
}
|
||
|
||
// 聚合总费用
|
||
aggregated.usage.cost += stats.totalCost || 0
|
||
|
||
// 聚合今日使用量
|
||
aggregated.dailyUsage.requests += stats.dailyStats.requests || 0
|
||
aggregated.dailyUsage.inputTokens += stats.dailyStats.inputTokens || 0
|
||
aggregated.dailyUsage.outputTokens += stats.dailyStats.outputTokens || 0
|
||
aggregated.dailyUsage.cacheCreateTokens += stats.dailyStats.cacheCreateTokens || 0
|
||
aggregated.dailyUsage.cacheReadTokens += stats.dailyStats.cacheReadTokens || 0
|
||
aggregated.dailyUsage.allTokens += stats.dailyStats.allTokens || 0
|
||
aggregated.dailyUsage.cost += stats.dailyStats.cost || 0
|
||
|
||
// 聚合本月使用量
|
||
aggregated.monthlyUsage.requests += stats.monthlyStats.requests || 0
|
||
aggregated.monthlyUsage.inputTokens += stats.monthlyStats.inputTokens || 0
|
||
aggregated.monthlyUsage.outputTokens += stats.monthlyStats.outputTokens || 0
|
||
aggregated.monthlyUsage.cacheCreateTokens += stats.monthlyStats.cacheCreateTokens || 0
|
||
aggregated.monthlyUsage.cacheReadTokens += stats.monthlyStats.cacheReadTokens || 0
|
||
aggregated.monthlyUsage.allTokens += stats.monthlyStats.allTokens || 0
|
||
aggregated.monthlyUsage.cost += stats.monthlyStats.cost || 0
|
||
|
||
// 添加到个体统计
|
||
individualStats.push({
|
||
apiId: stats.apiId,
|
||
name: stats.name,
|
||
isActive: true,
|
||
usage: stats.usage,
|
||
dailyUsage: {
|
||
...stats.dailyStats,
|
||
formattedCost: CostCalculator.formatCost(stats.dailyStats.cost || 0)
|
||
},
|
||
monthlyUsage: {
|
||
...stats.monthlyStats,
|
||
formattedCost: CostCalculator.formatCost(stats.monthlyStats.cost || 0)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
// 格式化费用显示
|
||
aggregated.usage.formattedCost = CostCalculator.formatCost(aggregated.usage.cost)
|
||
aggregated.dailyUsage.formattedCost = CostCalculator.formatCost(aggregated.dailyUsage.cost)
|
||
aggregated.monthlyUsage.formattedCost = CostCalculator.formatCost(aggregated.monthlyUsage.cost)
|
||
|
||
logger.api(`📊 Batch stats query for ${apiIds.length} keys from ${req.ip || 'unknown'}`)
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: {
|
||
aggregated,
|
||
individual: individualStats
|
||
}
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to process batch stats query:', error)
|
||
return res.status(500).json({
|
||
error: 'Internal server error',
|
||
message: 'Failed to retrieve batch statistics'
|
||
})
|
||
}
|
||
})
|
||
|
||
// 📊 批量模型统计查询接口
|
||
router.post('/api/batch-model-stats', async (req, res) => {
|
||
try {
|
||
const { apiIds, period = 'daily' } = req.body
|
||
|
||
// 验证输入
|
||
if (!apiIds || !Array.isArray(apiIds) || apiIds.length === 0) {
|
||
return res.status(400).json({
|
||
error: 'Invalid input',
|
||
message: 'API IDs array is required'
|
||
})
|
||
}
|
||
|
||
// 限制最多查询 30 个
|
||
if (apiIds.length > 30) {
|
||
return res.status(400).json({
|
||
error: 'Too many keys',
|
||
message: 'Maximum 30 API keys can be queried at once'
|
||
})
|
||
}
|
||
|
||
const client = redis.getClientSafe()
|
||
const tzDate = redis.getDateInTimezone()
|
||
const today = redis.getDateStringInTimezone()
|
||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||
|
||
const modelUsageMap = new Map()
|
||
|
||
// 并行查询所有 API Key 的模型统计
|
||
await Promise.all(
|
||
apiIds.map(async (apiId) => {
|
||
const pattern =
|
||
period === 'daily'
|
||
? `usage:${apiId}:model:daily:*:${today}`
|
||
: `usage:${apiId}:model:monthly:*:${currentMonth}`
|
||
|
||
const keys = await client.keys(pattern)
|
||
|
||
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}$/
|
||
)
|
||
|
||
if (!match) {
|
||
continue
|
||
}
|
||
|
||
const model = match[1]
|
||
const data = await client.hgetall(key)
|
||
|
||
if (data && Object.keys(data).length > 0) {
|
||
if (!modelUsageMap.has(model)) {
|
||
modelUsageMap.set(model, {
|
||
requests: 0,
|
||
inputTokens: 0,
|
||
outputTokens: 0,
|
||
cacheCreateTokens: 0,
|
||
cacheReadTokens: 0,
|
||
allTokens: 0
|
||
})
|
||
}
|
||
|
||
const modelUsage = modelUsageMap.get(model)
|
||
modelUsage.requests += parseInt(data.requests) || 0
|
||
modelUsage.inputTokens += parseInt(data.inputTokens) || 0
|
||
modelUsage.outputTokens += parseInt(data.outputTokens) || 0
|
||
modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0
|
||
modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0
|
||
modelUsage.allTokens += parseInt(data.allTokens) || 0
|
||
}
|
||
}
|
||
})
|
||
)
|
||
|
||
// 转换为数组并计算费用
|
||
const modelStats = []
|
||
for (const [model, usage] of modelUsageMap) {
|
||
const usageData = {
|
||
input_tokens: usage.inputTokens,
|
||
output_tokens: usage.outputTokens,
|
||
cache_creation_input_tokens: usage.cacheCreateTokens,
|
||
cache_read_input_tokens: usage.cacheReadTokens
|
||
}
|
||
|
||
const costData = CostCalculator.calculateCost(usageData, model)
|
||
|
||
modelStats.push({
|
||
model,
|
||
requests: usage.requests,
|
||
inputTokens: usage.inputTokens,
|
||
outputTokens: usage.outputTokens,
|
||
cacheCreateTokens: usage.cacheCreateTokens,
|
||
cacheReadTokens: usage.cacheReadTokens,
|
||
allTokens: usage.allTokens,
|
||
costs: costData.costs,
|
||
formatted: costData.formatted,
|
||
pricing: costData.pricing
|
||
})
|
||
}
|
||
|
||
// 按总 token 数降序排列
|
||
modelStats.sort((a, b) => b.allTokens - a.allTokens)
|
||
|
||
logger.api(`📊 Batch model stats query for ${apiIds.length} keys, period: ${period}`)
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: modelStats,
|
||
period
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to process batch model stats query:', error)
|
||
return res.status(500).json({
|
||
error: 'Internal server error',
|
||
message: 'Failed to retrieve batch model statistics'
|
||
})
|
||
}
|
||
})
|
||
|
||
// 🧪 API Key 端点测试接口 - 测试API Key是否能正常访问服务
|
||
router.post('/api-key/test', async (req, res) => {
|
||
const config = require('../../config/config')
|
||
const { sendStreamTestRequest } = require('../utils/testPayloadHelper')
|
||
|
||
try {
|
||
const { apiKey, model = 'claude-sonnet-4-5-20250929' } = req.body
|
||
|
||
if (!apiKey) {
|
||
return res.status(400).json({
|
||
error: 'API Key is required',
|
||
message: 'Please provide your API Key'
|
||
})
|
||
}
|
||
|
||
if (typeof apiKey !== 'string' || apiKey.length < 10 || apiKey.length > 512) {
|
||
return res.status(400).json({
|
||
error: 'Invalid API key format',
|
||
message: 'API key format is invalid'
|
||
})
|
||
}
|
||
|
||
const validation = await apiKeyService.validateApiKeyForStats(apiKey)
|
||
if (!validation.valid) {
|
||
return res.status(401).json({
|
||
error: 'Invalid API key',
|
||
message: validation.error
|
||
})
|
||
}
|
||
|
||
logger.api(`🧪 API Key test started for: ${validation.keyData.name} (${validation.keyData.id})`)
|
||
|
||
const port = config.server.port || 3000
|
||
const apiUrl = `http://127.0.0.1:${port}/api/v1/messages?beta=true`
|
||
|
||
await sendStreamTestRequest({
|
||
apiUrl,
|
||
authorization: apiKey,
|
||
responseStream: res,
|
||
payload: createClaudeTestPayload(model, { stream: true }),
|
||
timeout: 60000,
|
||
extraHeaders: { 'x-api-key': apiKey }
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ API Key test failed:', error)
|
||
|
||
if (!res.headersSent) {
|
||
return res.status(500).json({
|
||
error: 'Test failed',
|
||
message: error.message || 'Internal server error'
|
||
})
|
||
}
|
||
|
||
res.write(
|
||
`data: ${JSON.stringify({ type: 'error', error: error.message || 'Test failed' })}\n\n`
|
||
)
|
||
res.end()
|
||
}
|
||
})
|
||
|
||
// 📊 用户模型统计查询接口 - 安全的自查询接口
|
||
router.post('/api/user-model-stats', async (req, res) => {
|
||
try {
|
||
const { apiKey, apiId, period = 'monthly' } = req.body
|
||
|
||
let keyData
|
||
let keyId
|
||
|
||
if (apiId) {
|
||
// 通过 apiId 查询
|
||
if (
|
||
typeof apiId !== 'string' ||
|
||
!apiId.match(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i)
|
||
) {
|
||
return res.status(400).json({
|
||
error: 'Invalid API ID format',
|
||
message: 'API ID must be a valid UUID'
|
||
})
|
||
}
|
||
|
||
// 直接通过 ID 获取 API Key 数据
|
||
keyData = await redis.getApiKey(apiId)
|
||
|
||
if (!keyData || Object.keys(keyData).length === 0) {
|
||
logger.security(`🔒 API key not found for ID: ${apiId} from ${req.ip || 'unknown'}`)
|
||
return res.status(404).json({
|
||
error: 'API key not found',
|
||
message: 'The specified API key does not exist'
|
||
})
|
||
}
|
||
|
||
// 检查是否激活
|
||
if (keyData.isActive !== 'true') {
|
||
const keyName = keyData.name || 'Unknown'
|
||
return res.status(403).json({
|
||
error: 'API key is disabled',
|
||
message: `API Key "${keyName}" 已被禁用`,
|
||
keyName
|
||
})
|
||
}
|
||
|
||
keyId = apiId
|
||
|
||
// 获取使用统计
|
||
const usage = await redis.getUsageStats(keyId)
|
||
keyData.usage = { total: usage.total }
|
||
} else if (apiKey) {
|
||
// 通过 apiKey 查询(保持向后兼容)
|
||
// 验证API Key
|
||
const validation = await apiKeyService.validateApiKey(apiKey)
|
||
|
||
if (!validation.valid) {
|
||
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||
logger.security(
|
||
`🔒 Invalid API key in user model stats query: ${validation.error} from ${clientIP}`
|
||
)
|
||
return res.status(401).json({
|
||
error: 'Invalid API key',
|
||
message: validation.error
|
||
})
|
||
}
|
||
|
||
const { keyData: validatedKeyData } = validation
|
||
keyData = validatedKeyData
|
||
keyId = keyData.id
|
||
} else {
|
||
logger.security(
|
||
`🔒 Missing API key or ID in user model stats query from ${req.ip || 'unknown'}`
|
||
)
|
||
return res.status(400).json({
|
||
error: 'API Key or ID is required',
|
||
message: 'Please provide your API Key or API ID'
|
||
})
|
||
}
|
||
|
||
logger.api(
|
||
`📊 User model stats query from key: ${keyData.name} (${keyId}) for period: ${period}`
|
||
)
|
||
|
||
// 重用管理后台的模型统计逻辑,但只返回该API Key的数据
|
||
const client = redis.getClientSafe()
|
||
// 使用与管理页面相同的时区处理逻辑
|
||
const tzDate = redis.getDateInTimezone()
|
||
const today = redis.getDateStringInTimezone()
|
||
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`
|
||
|
||
const pattern =
|
||
period === 'daily'
|
||
? `usage:${keyId}:model:daily:*:${today}`
|
||
: `usage:${keyId}:model:monthly:*:${currentMonth}`
|
||
|
||
const keys = await client.keys(pattern)
|
||
const modelStats = []
|
||
|
||
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}$/
|
||
)
|
||
|
||
if (!match) {
|
||
continue
|
||
}
|
||
|
||
const model = match[1]
|
||
const data = await client.hgetall(key)
|
||
|
||
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 costData = CostCalculator.calculateCost(usage, model)
|
||
|
||
modelStats.push({
|
||
model,
|
||
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,
|
||
allTokens: parseInt(data.allTokens) || 0,
|
||
costs: costData.costs,
|
||
formatted: costData.formatted,
|
||
pricing: costData.pricing
|
||
})
|
||
}
|
||
}
|
||
|
||
// 如果没有详细的模型数据,不显示历史数据以避免混淆
|
||
// 只有在查询特定时间段时返回空数组,表示该时间段确实没有数据
|
||
if (modelStats.length === 0) {
|
||
logger.info(`📊 No model stats found for key ${keyId} in period ${period}`)
|
||
}
|
||
|
||
// 按总token数降序排列
|
||
modelStats.sort((a, b) => b.allTokens - a.allTokens)
|
||
|
||
return res.json({
|
||
success: true,
|
||
data: modelStats,
|
||
period
|
||
})
|
||
} catch (error) {
|
||
logger.error('❌ Failed to process user model stats query:', error)
|
||
return res.status(500).json({
|
||
error: 'Internal server error',
|
||
message: 'Failed to retrieve model statistics'
|
||
})
|
||
}
|
||
})
|
||
|
||
module.exports = router
|