fix: 优化apikeys页面加载速度

This commit is contained in:
shaw
2025-11-25 14:59:58 +08:00
parent 82e63ef55b
commit 22fbabbc47
5 changed files with 1305 additions and 579 deletions

View File

@@ -166,6 +166,224 @@ class RedisClient {
return apiKeys
}
/**
* 使用 SCAN 获取所有 API Key ID避免 KEYS 命令阻塞)
* @returns {Promise<string[]>} API Key ID 列表
*/
async scanApiKeyIds() {
const keyIds = []
let cursor = '0'
do {
const [newCursor, keys] = await this.client.scan(cursor, 'MATCH', 'apikey:*', 'COUNT', 100)
cursor = newCursor
for (const key of keys) {
if (key !== 'apikey:hash_map') {
keyIds.push(key.replace('apikey:', ''))
}
}
} while (cursor !== '0')
return keyIds
}
/**
* 批量获取 API Key 数据(使用 Pipeline 优化)
* @param {string[]} keyIds - API Key ID 列表
* @returns {Promise<Object[]>} API Key 数据列表
*/
async batchGetApiKeys(keyIds) {
if (!keyIds || keyIds.length === 0) {
return []
}
const pipeline = this.client.pipeline()
for (const keyId of keyIds) {
pipeline.hgetall(`apikey:${keyId}`)
}
const results = await pipeline.exec()
const apiKeys = []
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
if (!err && data && Object.keys(data).length > 0) {
apiKeys.push({ id: keyIds[i], ...this._parseApiKeyData(data) })
}
}
return apiKeys
}
/**
* 解析 API Key 数据,将字符串转换为正确的类型
* @param {Object} data - 原始数据
* @returns {Object} 解析后的数据
*/
_parseApiKeyData(data) {
if (!data) {
return data
}
const parsed = { ...data }
// 布尔字段
const boolFields = ['isActive', 'enableModelRestriction', 'isDeleted']
for (const field of boolFields) {
if (parsed[field] !== undefined) {
parsed[field] = parsed[field] === 'true'
}
}
// 数字字段
const numFields = [
'tokenLimit',
'dailyCostLimit',
'totalCostLimit',
'rateLimitRequests',
'rateLimitTokens',
'rateLimitWindow',
'rateLimitCost',
'maxConcurrency',
'activationDuration'
]
for (const field of numFields) {
if (parsed[field] !== undefined && parsed[field] !== '') {
parsed[field] = parseFloat(parsed[field]) || 0
}
}
// 数组字段JSON 解析)
const arrayFields = ['tags', 'restrictedModels', 'allowedClients']
for (const field of arrayFields) {
if (parsed[field]) {
try {
parsed[field] = JSON.parse(parsed[field])
} catch (e) {
parsed[field] = []
}
}
}
return parsed
}
/**
* 获取 API Keys 分页数据(不含费用,用于优化列表加载)
* @param {Object} options - 分页和筛选选项
* @returns {Promise<{items: Object[], pagination: Object, availableTags: string[]}>}
*/
async getApiKeysPaginated(options = {}) {
const {
page = 1,
pageSize = 20,
searchMode = 'apiKey',
search = '',
tag = '',
isActive = '',
sortBy = 'createdAt',
sortOrder = 'desc',
excludeDeleted = true // 默认排除已删除的 API Keys
} = options
// 1. 使用 SCAN 获取所有 apikey:* 的 ID 列表(避免阻塞)
const keyIds = await this.scanApiKeyIds()
// 2. 使用 Pipeline 批量获取基础数据
const apiKeys = await this.batchGetApiKeys(keyIds)
// 3. 应用筛选条件
let filteredKeys = apiKeys
// 排除已删除的 API Keys默认行为
if (excludeDeleted) {
filteredKeys = filteredKeys.filter((k) => !k.isDeleted)
}
// 状态筛选
if (isActive !== '' && isActive !== undefined && isActive !== null) {
const activeValue = isActive === 'true' || isActive === true
filteredKeys = filteredKeys.filter((k) => k.isActive === activeValue)
}
// 标签筛选
if (tag) {
filteredKeys = filteredKeys.filter((k) => {
const tags = Array.isArray(k.tags) ? k.tags : []
return tags.includes(tag)
})
}
// 搜索apiKey 模式在这里处理bindingAccount 模式在路由层处理)
if (search && searchMode === 'apiKey') {
const lowerSearch = search.toLowerCase().trim()
filteredKeys = filteredKeys.filter(
(k) =>
(k.name && k.name.toLowerCase().includes(lowerSearch)) ||
(k.ownerDisplayName && k.ownerDisplayName.toLowerCase().includes(lowerSearch))
)
}
// 4. 排序
filteredKeys.sort((a, b) => {
let aVal = a[sortBy]
let bVal = b[sortBy]
// 日期字段转时间戳
if (['createdAt', 'expiresAt', 'lastUsedAt'].includes(sortBy)) {
aVal = aVal ? new Date(aVal).getTime() : 0
bVal = bVal ? new Date(bVal).getTime() : 0
}
// 布尔字段转数字
if (sortBy === 'isActive' || sortBy === 'status') {
aVal = aVal ? 1 : 0
bVal = bVal ? 1 : 0
}
// 字符串字段
if (sortBy === 'name') {
aVal = (aVal || '').toLowerCase()
bVal = (bVal || '').toLowerCase()
}
if (aVal < bVal) {
return sortOrder === 'asc' ? -1 : 1
}
if (aVal > bVal) {
return sortOrder === 'asc' ? 1 : -1
}
return 0
})
// 5. 收集所有可用标签(在分页之前)
const allTags = new Set()
for (const key of apiKeys) {
const tags = Array.isArray(key.tags) ? key.tags : []
tags.forEach((t) => allTags.add(t))
}
const availableTags = [...allTags].sort()
// 6. 分页
const total = filteredKeys.length
const totalPages = Math.ceil(total / pageSize) || 1
const validPage = Math.min(Math.max(1, page), totalPages)
const start = (validPage - 1) * pageSize
const items = filteredKeys.slice(start, start + pageSize)
return {
items,
pagination: {
page: validPage,
pageSize,
total,
totalPages
},
availableTags
}
}
// 🔍 通过哈希值查找API Key性能优化
async findApiKeyByHash(hashedKey) {
// 使用反向映射表hash -> keyId

View File

@@ -193,323 +193,88 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) =>
// 获取所有API Keys
router.get('/api-keys', authenticateAdmin, async (req, res) => {
try {
const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom
const apiKeys = await apiKeyService.getAllApiKeys()
const {
// 分页参数
page = 1,
pageSize = 20,
// 搜索参数
searchMode = 'apiKey',
search = '',
// 筛选参数
tag = '',
isActive = '',
// 排序参数
sortBy = 'createdAt',
sortOrder = 'desc',
// 兼容旧参数(不再用于费用计算,仅标记)
timeRange = 'all'
} = req.query
// 验证分页参数
const pageNum = Math.max(1, parseInt(page) || 1)
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
// 验证排序参数(移除费用相关排序)
const validSortFields = ['name', 'createdAt', 'expiresAt', 'lastUsedAt', 'isActive', 'status']
const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt'
const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'
// 获取用户服务来补充owner信息
const userService = require('../services/userService')
// 根据时间范围计算查询模式
const now = new Date()
const searchPatterns = []
// 使用优化的分页方法获取数据
let result = await redis.getApiKeysPaginated({
page: pageNum,
pageSize: pageSizeNum,
searchMode,
search: searchMode === 'apiKey' ? search : '', // apiKey 模式的搜索在 redis 层处理
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
})
if (timeRange === 'custom' && startDate && endDate) {
// 自定义日期范围
const redisClient = require('../models/redis')
const start = new Date(startDate)
const end = new Date(endDate)
// 如果是绑定账号搜索模式,需要在这里处理
if (searchMode === 'bindingAccount' && search) {
const accountNameCacheService = require('../services/accountNameCacheService')
await accountNameCacheService.refreshIfNeeded()
// 确保日期范围有效
if (start > end) {
return res.status(400).json({ error: 'Start date must be before or equal to end date' })
}
// 获取所有数据进行绑定账号搜索
const allResult = await redis.getApiKeysPaginated({
page: 1,
pageSize: 10000, // 获取所有数据
searchMode: 'apiKey',
search: '',
tag,
isActive,
sortBy: validSortBy,
sortOrder: validSortOrder
})
// 限制最大范围为365天
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 filteredKeys = accountNameCacheService.searchByBindingAccount(allResult.items, search)
// 生成日期范围内每天的搜索模式
const currentDate = new Date(start)
while (currentDate <= end) {
const tzDate = redisClient.getDateInTimezone(currentDate)
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
searchPatterns.push(`usage:daily:*:${dateStr}`)
currentDate.setDate(currentDate.getDate() + 1)
}
} else if (timeRange === 'today') {
// 今日 - 使用时区日期
const redisClient = require('../models/redis')
const tzDate = redisClient.getDateInTimezone(now)
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
searchPatterns.push(`usage:daily:*:${dateStr}`)
} else if (timeRange === '7days') {
// 最近7天
const redisClient = require('../models/redis')
for (let i = 0; i < 7; i++) {
const date = new Date(now)
date.setDate(date.getDate() - i)
const tzDate = redisClient.getDateInTimezone(date)
const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}-${String(tzDate.getUTCDate()).padStart(2, '0')}`
searchPatterns.push(`usage:daily:*:${dateStr}`)
}
} else if (timeRange === 'monthly') {
// 本月
const redisClient = require('../models/redis')
const tzDate = redisClient.getDateInTimezone(now)
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
searchPatterns.push(`usage:monthly:*:${currentMonth}`)
}
// 重新分页
const total = filteredKeys.length
const totalPages = Math.ceil(total / pageSizeNum) || 1
const validPage = Math.min(Math.max(1, pageNum), totalPages)
const start = (validPage - 1) * pageSizeNum
const items = filteredKeys.slice(start, start + pageSizeNum)
// 为每个API Key计算准确的费用和统计数据
for (const apiKey of apiKeys) {
const client = redis.getClientSafe()
if (timeRange === 'all') {
// 全部时间:保持原有逻辑
if (apiKey.usage && apiKey.usage.total) {
// 使用与展开模型统计相同的数据源
// 获取所有时间的模型统计数据
const monthlyKeys = await client.keys(`usage:${apiKey.id}:model:monthly:*:*`)
const modelStatsMap = new Map()
// 汇总所有月份的数据
for (const key of monthlyKeys) {
const match = key.match(/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 (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const stats = modelStatsMap.get(model)
stats.inputTokens +=
parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
stats.outputTokens +=
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
stats.cacheCreateTokens +=
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
stats.cacheReadTokens +=
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
}
}
let totalCost = 0
// 计算每个模型的费用
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 costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total
}
// 如果没有详细的模型数据,使用总量数据和默认模型计算
if (modelStatsMap.size === 0) {
const usage = {
input_tokens: apiKey.usage.total.inputTokens || 0,
output_tokens: apiKey.usage.total.outputTokens || 0,
cache_creation_input_tokens: apiKey.usage.total.cacheCreateTokens || 0,
cache_read_input_tokens: apiKey.usage.total.cacheReadTokens || 0
}
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022')
totalCost = costResult.costs.total
}
// 添加格式化的费用到响应数据
apiKey.usage.total.cost = totalCost
apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost)
}
} else {
// 7天、本月或自定义日期范围重新计算统计数据
const tempUsage = {
requests: 0,
tokens: 0,
allTokens: 0, // 添加allTokens字段
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
}
// 获取指定时间范围的统计数据
for (const pattern of searchPatterns) {
const keys = await client.keys(pattern.replace('*', apiKey.id))
for (const key of keys) {
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
// 使用与 redis.js incrementTokenUsage 中相同的字段名
tempUsage.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
tempUsage.tokens += parseInt(data.totalTokens) || parseInt(data.tokens) || 0
tempUsage.allTokens += parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0 // 读取包含所有Token的字段
tempUsage.inputTokens +=
parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
tempUsage.outputTokens +=
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
tempUsage.cacheCreateTokens +=
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
tempUsage.cacheReadTokens +=
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
}
}
}
// 计算指定时间范围的费用
let totalCost = 0
const redisClient = require('../models/redis')
const tzToday = redisClient.getDateStringInTimezone(now)
const tzDate = redisClient.getDateInTimezone(now)
const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(
2,
'0'
)}`
let modelKeys = []
if (timeRange === 'custom' && startDate && endDate) {
// 自定义日期范围:获取范围内所有日期的模型统计
const start = new Date(startDate)
const end = new Date(endDate)
const currentDate = new Date(start)
while (currentDate <= end) {
const tzDateForKey = redisClient.getDateInTimezone(currentDate)
const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(
tzDateForKey.getUTCMonth() + 1
).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}`
const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`)
modelKeys = modelKeys.concat(dayKeys)
currentDate.setDate(currentDate.getDate() + 1)
}
} else {
modelKeys =
timeRange === 'today'
? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`)
: timeRange === '7days'
? await client.keys(`usage:${apiKey.id}:model:daily:*:*`)
: await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`)
}
const modelStatsMap = new Map()
// 过滤和汇总相应时间范围的模型数据
for (const key of modelKeys) {
if (timeRange === '7days') {
// 检查是否在最近7天内
const dateMatch = key.match(/\d{4}-\d{2}-\d{2}$/)
if (dateMatch) {
const keyDate = new Date(dateMatch[0])
const daysDiff = Math.floor((now - keyDate) / (1000 * 60 * 60 * 24))
if (daysDiff > 6) {
continue
}
}
} else if (timeRange === 'today' || timeRange === 'custom') {
// today和custom选项已经在查询时过滤了不需要额外处理
}
const modelMatch = key.match(
/usage:.+:model:(?:daily|monthly):(.+):\d{4}-\d{2}(?:-\d{2})?$/
)
if (!modelMatch) {
continue
}
const model = modelMatch[1]
const data = await client.hgetall(key)
if (data && Object.keys(data).length > 0) {
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0
})
}
const stats = modelStatsMap.get(model)
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
stats.outputTokens +=
parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
stats.cacheCreateTokens +=
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
stats.cacheReadTokens +=
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
}
}
// 计算费用
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 costResult = CostCalculator.calculateCost(usage, model)
totalCost += costResult.costs.total
}
// 如果没有模型数据,使用临时统计数据计算
if (modelStatsMap.size === 0 && tempUsage.tokens > 0) {
const usage = {
input_tokens: tempUsage.inputTokens,
output_tokens: tempUsage.outputTokens,
cache_creation_input_tokens: tempUsage.cacheCreateTokens,
cache_read_input_tokens: tempUsage.cacheReadTokens
}
const costResult = CostCalculator.calculateCost(usage, 'claude-3-5-haiku-20241022')
totalCost = costResult.costs.total
}
// 使用从Redis读取的allTokens如果没有则计算
const allTokens =
tempUsage.allTokens ||
tempUsage.inputTokens +
tempUsage.outputTokens +
tempUsage.cacheCreateTokens +
tempUsage.cacheReadTokens
// 更新API Key的usage数据为指定时间范围的数据
apiKey.usage[timeRange] = {
...tempUsage,
tokens: allTokens, // 使用包含所有Token的总数
allTokens,
cost: totalCost,
formattedCost: CostCalculator.formatCost(totalCost)
}
// 为了保持兼容性也更新total字段
apiKey.usage.total = apiKey.usage[timeRange]
result = {
items,
pagination: {
page: validPage,
pageSize: pageSizeNum,
total,
totalPages
},
availableTags: allResult.availableTags
}
}
// 为每个API Key添加owner的displayName
for (const apiKey of apiKeys) {
// 如果API Key有关联的用户ID获取用户信息
for (const apiKey of result.items) {
if (apiKey.userId) {
try {
const user = await userService.getUserById(apiKey.userId, false)
@@ -523,13 +288,27 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
apiKey.ownerDisplayName = 'Unknown User'
}
} else {
// 如果没有userId使用createdBy字段或默认为Admin
apiKey.ownerDisplayName =
apiKey.createdBy === 'admin' ? 'Admin' : apiKey.createdBy || 'Admin'
}
// 初始化空的 usage 对象(费用通过 batch-stats 接口获取)
if (!apiKey.usage) {
apiKey.usage = { total: { requests: 0, tokens: 0, cost: 0, formattedCost: '$0.00' } }
}
}
return res.json({ success: true, data: apiKeys })
// 返回分页数据
return res.json({
success: true,
data: {
items: result.items,
pagination: result.pagination,
availableTags: result.availableTags
},
// 标记当前请求的时间范围(供前端参考)
timeRange
})
} catch (error) {
logger.error('❌ Failed to get API keys:', error)
return res.status(500).json({ error: 'Failed to get API keys', message: error.message })
@@ -589,6 +368,382 @@ router.get('/api-keys/tags', authenticateAdmin, async (req, res) => {
}
})
/**
* 获取账户绑定的 API Key 数量统计
* GET /admin/accounts/binding-counts
*
* 返回每种账户类型的绑定数量统计,用于账户列表页面显示"绑定: X 个API Key"
* 这是一个轻量级接口,只返回计数而不是完整的 API Key 数据
*/
router.get('/accounts/binding-counts', authenticateAdmin, async (req, res) => {
try {
// 使用优化的分页方法获取所有非删除的 API Keys只需要绑定字段
const result = await redis.getApiKeysPaginated({
page: 1,
pageSize: 10000, // 获取所有
excludeDeleted: true
})
const apiKeys = result.items
// 初始化统计对象
const bindingCounts = {
claudeAccountId: {},
claudeConsoleAccountId: {},
geminiAccountId: {},
openaiAccountId: {},
azureOpenaiAccountId: {},
bedrockAccountId: {},
droidAccountId: {},
ccrAccountId: {}
}
// 遍历一次,统计每个账户的绑定数量
for (const key of apiKeys) {
// Claude 账户
if (key.claudeAccountId) {
const id = key.claudeAccountId
bindingCounts.claudeAccountId[id] = (bindingCounts.claudeAccountId[id] || 0) + 1
}
// Claude Console 账户
if (key.claudeConsoleAccountId) {
const id = key.claudeConsoleAccountId
bindingCounts.claudeConsoleAccountId[id] =
(bindingCounts.claudeConsoleAccountId[id] || 0) + 1
}
// Gemini 账户(包括 api: 前缀的 Gemini-API 账户)
if (key.geminiAccountId) {
const id = key.geminiAccountId
bindingCounts.geminiAccountId[id] = (bindingCounts.geminiAccountId[id] || 0) + 1
}
// OpenAI 账户(包括 responses: 前缀的 OpenAI-Responses 账户)
if (key.openaiAccountId) {
const id = key.openaiAccountId
bindingCounts.openaiAccountId[id] = (bindingCounts.openaiAccountId[id] || 0) + 1
}
// Azure OpenAI 账户
if (key.azureOpenaiAccountId) {
const id = key.azureOpenaiAccountId
bindingCounts.azureOpenaiAccountId[id] = (bindingCounts.azureOpenaiAccountId[id] || 0) + 1
}
// Bedrock 账户
if (key.bedrockAccountId) {
const id = key.bedrockAccountId
bindingCounts.bedrockAccountId[id] = (bindingCounts.bedrockAccountId[id] || 0) + 1
}
// Droid 账户
if (key.droidAccountId) {
const id = key.droidAccountId
bindingCounts.droidAccountId[id] = (bindingCounts.droidAccountId[id] || 0) + 1
}
// CCR 账户
if (key.ccrAccountId) {
const id = key.ccrAccountId
bindingCounts.ccrAccountId[id] = (bindingCounts.ccrAccountId[id] || 0) + 1
}
}
logger.debug(`📊 Account binding counts calculated from ${apiKeys.length} API keys`)
return res.json({ success: true, data: bindingCounts })
} catch (error) {
logger.error('❌ Failed to get account binding counts:', error)
return res.status(500).json({
error: 'Failed to get account binding counts',
message: error.message
})
}
})
/**
* 批量获取指定 Keys 的统计数据和费用
* POST /admin/api-keys/batch-stats
*
* 用于 API Keys 列表页面异步加载统计数据
*/
router.post('/api-keys/batch-stats', authenticateAdmin, async (req, res) => {
try {
const {
keyIds, // 必需API Key ID 数组
timeRange = 'all', // 时间范围all, today, 7days, monthly, custom
startDate, // custom 时必需
endDate // custom 时必需
} = req.body
// 参数验证
if (!Array.isArray(keyIds) || keyIds.length === 0) {
return res.status(400).json({
success: false,
error: 'keyIds is required and must be a non-empty array'
})
}
// 限制单次最多处理 100 个 Key
if (keyIds.length > 100) {
return res.status(400).json({
success: false,
error: 'Max 100 keys per request'
})
}
// 验证 custom 时间范围的参数
if (timeRange === 'custom') {
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
error: 'startDate and endDate are required for custom time range'
})
}
const start = new Date(startDate)
const end = new Date(endDate)
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return res.status(400).json({
success: false,
error: 'Invalid date format'
})
}
if (start > end) {
return res.status(400).json({
success: false,
error: 'startDate must be before or equal to endDate'
})
}
// 限制最大范围为 365 天
const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1
if (daysDiff > 365) {
return res.status(400).json({
success: false,
error: 'Date range cannot exceed 365 days'
})
}
}
logger.info(
`📊 Batch stats request: ${keyIds.length} keys, timeRange=${timeRange}`,
timeRange === 'custom' ? `, ${startDate} to ${endDate}` : ''
)
const stats = {}
// 并行计算每个 Key 的统计数据
await Promise.all(
keyIds.map(async (keyId) => {
try {
stats[keyId] = await calculateKeyStats(keyId, timeRange, startDate, endDate)
} catch (error) {
logger.error(`❌ Failed to calculate stats for key ${keyId}:`, error)
stats[keyId] = {
requests: 0,
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: 0,
formattedCost: '$0.00',
error: error.message
}
}
})
)
return res.json({ success: true, data: stats })
} catch (error) {
logger.error('❌ Failed to calculate batch stats:', error)
return res.status(500).json({
success: false,
error: 'Failed to calculate stats',
message: error.message
})
}
})
/**
* 计算单个 Key 的统计数据
* @param {string} keyId - API Key ID
* @param {string} timeRange - 时间范围
* @param {string} startDate - 开始日期 (custom 模式)
* @param {string} endDate - 结束日期 (custom 模式)
* @returns {Object} 统计数据
*/
async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
const client = redis.getClientSafe()
const tzDate = redis.getDateInTimezone()
const today = redis.getDateStringInTimezone()
// 构建搜索模式
const searchPatterns = []
if (timeRange === 'custom' && startDate && endDate) {
// 自定义日期范围
const start = new Date(startDate)
const end = new Date(endDate)
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const dateStr = redis.getDateStringInTimezone(d)
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
}
} else if (timeRange === 'today') {
searchPatterns.push(`usage:${keyId}:model:daily:*:${today}`)
} else if (timeRange === '7days') {
// 最近7天
for (let i = 0; i < 7; i++) {
const d = new Date(tzDate)
d.setDate(d.getDate() - i)
const dateStr = redis.getDateStringInTimezone(d)
searchPatterns.push(`usage:${keyId}:model:daily:*:${dateStr}`)
}
} else if (timeRange === 'monthly') {
// 当月
const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}`
searchPatterns.push(`usage:${keyId}:model:monthly:*:${currentMonth}`)
} else {
// all - 获取所有数据(日和月数据都查)
searchPatterns.push(`usage:${keyId}:model:daily:*`)
searchPatterns.push(`usage:${keyId}:model:monthly:*`)
}
// 使用 SCAN 收集所有匹配的 keys
const allKeys = []
for (const pattern of searchPatterns) {
let cursor = '0'
do {
const [newCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 100)
cursor = newCursor
allKeys.push(...keys)
} while (cursor !== '0')
}
// 去重(避免日数据和月数据重复计算)
const uniqueKeys = [...new Set(allKeys)]
if (uniqueKeys.length === 0) {
return {
requests: 0,
tokens: 0,
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
cost: 0,
formattedCost: '$0.00'
}
}
// 使用 Pipeline 批量获取数据
const pipeline = client.pipeline()
for (const key of uniqueKeys) {
pipeline.hgetall(key)
}
const results = await pipeline.exec()
// 汇总计算
const modelStatsMap = new Map()
let totalRequests = 0
// 用于去重:只统计日数据,避免与月数据重复
const dailyKeyPattern = /usage:.+:model:daily:(.+):\d{4}-\d{2}-\d{2}$/
const monthlyKeyPattern = /usage:.+:model:monthly:(.+):\d{4}-\d{2}$/
// 检查是否有日数据
const hasDailyData = uniqueKeys.some((key) => dailyKeyPattern.test(key))
for (let i = 0; i < results.length; i++) {
const [err, data] = results[i]
if (err || !data || Object.keys(data).length === 0) {
continue
}
const key = uniqueKeys[i]
let model = null
let isMonthly = false
// 提取模型名称
const dailyMatch = key.match(dailyKeyPattern)
const monthlyMatch = key.match(monthlyKeyPattern)
if (dailyMatch) {
model = dailyMatch[1]
} else if (monthlyMatch) {
model = monthlyMatch[1]
isMonthly = true
}
if (!model) {
continue
}
// 如果有日数据,则跳过月数据以避免重复
if (hasDailyData && isMonthly) {
continue
}
if (!modelStatsMap.has(model)) {
modelStatsMap.set(model, {
inputTokens: 0,
outputTokens: 0,
cacheCreateTokens: 0,
cacheReadTokens: 0,
requests: 0
})
}
const stats = modelStatsMap.get(model)
stats.inputTokens += parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0
stats.outputTokens += parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0
stats.cacheCreateTokens +=
parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0
stats.cacheReadTokens +=
parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0
stats.requests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
totalRequests += parseInt(data.totalRequests) || parseInt(data.requests) || 0
}
// 计算费用
let totalCost = 0
let inputTokens = 0
let outputTokens = 0
let cacheCreateTokens = 0
let cacheReadTokens = 0
for (const [model, stats] of modelStatsMap) {
inputTokens += stats.inputTokens
outputTokens += stats.outputTokens
cacheCreateTokens += stats.cacheCreateTokens
cacheReadTokens += stats.cacheReadTokens
const costResult = CostCalculator.calculateCost(
{
input_tokens: stats.inputTokens,
output_tokens: stats.outputTokens,
cache_creation_input_tokens: stats.cacheCreateTokens,
cache_read_input_tokens: stats.cacheReadTokens
},
model
)
totalCost += costResult.costs.total
}
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
return {
requests: totalRequests,
tokens,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
cost: totalCost,
formattedCost: CostCalculator.formatCost(totalCost)
}
}
// 创建新的API Key
router.post('/api-keys', authenticateAdmin, async (req, res) => {
try {

View File

@@ -0,0 +1,286 @@
/**
* 账户名称缓存服务
* 用于加速绑定账号搜索,避免每次搜索都查询所有账户
*/
const logger = require('../utils/logger')
class AccountNameCacheService {
constructor() {
// 账户名称缓存accountId -> { name, platform }
this.accountCache = new Map()
// 账户组名称缓存groupId -> { name, platform }
this.groupCache = new Map()
// 缓存过期时间
this.lastRefresh = 0
this.refreshInterval = 5 * 60 * 1000 // 5分钟
this.isRefreshing = false
}
/**
* 刷新缓存(如果过期)
*/
async refreshIfNeeded() {
if (Date.now() - this.lastRefresh < this.refreshInterval) {
return
}
if (this.isRefreshing) {
// 等待正在进行的刷新完成
let waitCount = 0
while (this.isRefreshing && waitCount < 50) {
await new Promise((resolve) => setTimeout(resolve, 100))
waitCount++
}
return
}
await this.refresh()
}
/**
* 强制刷新缓存
*/
async refresh() {
if (this.isRefreshing) {
return
}
this.isRefreshing = true
try {
const newAccountCache = new Map()
const newGroupCache = new Map()
// 延迟加载服务,避免循环依赖
const claudeAccountService = require('./claudeAccountService')
const claudeConsoleAccountService = require('./claudeConsoleAccountService')
const geminiAccountService = require('./geminiAccountService')
const openaiAccountService = require('./openaiAccountService')
const azureOpenaiAccountService = require('./azureOpenaiAccountService')
const bedrockAccountService = require('./bedrockAccountService')
const droidAccountService = require('./droidAccountService')
const ccrAccountService = require('./ccrAccountService')
const accountGroupService = require('./accountGroupService')
// 可选服务(可能不存在)
let geminiApiAccountService = null
let openaiResponsesAccountService = null
try {
geminiApiAccountService = require('./geminiApiAccountService')
} catch (e) {
// 服务不存在,忽略
}
try {
openaiResponsesAccountService = require('./openaiResponsesAccountService')
} catch (e) {
// 服务不存在,忽略
}
// 并行加载所有账户类型
const results = await Promise.allSettled([
claudeAccountService.getAllAccounts(),
claudeConsoleAccountService.getAllAccounts(),
geminiAccountService.getAllAccounts(),
geminiApiAccountService?.getAllAccounts() || Promise.resolve([]),
openaiAccountService.getAllAccounts(),
openaiResponsesAccountService?.getAllAccounts() || Promise.resolve([]),
azureOpenaiAccountService.getAllAccounts(),
bedrockAccountService.getAllAccounts(),
droidAccountService.getAllAccounts(),
ccrAccountService.getAllAccounts(),
accountGroupService.getAllGroups()
])
// 提取结果
const claudeAccounts = results[0].status === 'fulfilled' ? results[0].value : []
const claudeConsoleAccounts = results[1].status === 'fulfilled' ? results[1].value : []
const geminiAccounts = results[2].status === 'fulfilled' ? results[2].value : []
const geminiApiAccounts = results[3].status === 'fulfilled' ? results[3].value : []
const openaiAccounts = results[4].status === 'fulfilled' ? results[4].value : []
const openaiResponsesAccounts = results[5].status === 'fulfilled' ? results[5].value : []
const azureOpenaiAccounts = results[6].status === 'fulfilled' ? results[6].value : []
const bedrockResult = results[7].status === 'fulfilled' ? results[7].value : { accounts: [] }
const droidAccounts = results[8].status === 'fulfilled' ? results[8].value : []
const ccrAccounts = results[9].status === 'fulfilled' ? results[9].value : []
const groups = results[10].status === 'fulfilled' ? results[10].value : []
// Bedrock 返回格式特殊处理
const bedrockAccounts = Array.isArray(bedrockResult)
? bedrockResult
: bedrockResult.accounts || []
// 填充账户缓存的辅助函数
const addAccounts = (accounts, platform, prefix = '') => {
if (!Array.isArray(accounts)) {
return
}
for (const acc of accounts) {
if (acc && acc.id && acc.name) {
const key = prefix ? `${prefix}${acc.id}` : acc.id
newAccountCache.set(key, { name: acc.name, platform })
// 同时存储不带前缀的版本,方便查找
if (prefix) {
newAccountCache.set(acc.id, { name: acc.name, platform })
}
}
}
}
addAccounts(claudeAccounts, 'claude')
addAccounts(claudeConsoleAccounts, 'claude-console')
addAccounts(geminiAccounts, 'gemini')
addAccounts(geminiApiAccounts, 'gemini-api', 'api:')
addAccounts(openaiAccounts, 'openai')
addAccounts(openaiResponsesAccounts, 'openai-responses', 'responses:')
addAccounts(azureOpenaiAccounts, 'azure-openai')
addAccounts(bedrockAccounts, 'bedrock')
addAccounts(droidAccounts, 'droid')
addAccounts(ccrAccounts, 'ccr')
// 填充账户组缓存
if (Array.isArray(groups)) {
for (const group of groups) {
if (group && group.id && group.name) {
newGroupCache.set(group.id, { name: group.name, platform: group.platform })
}
}
}
this.accountCache = newAccountCache
this.groupCache = newGroupCache
this.lastRefresh = Date.now()
logger.debug(
`账户名称缓存已刷新: ${newAccountCache.size} 个账户, ${newGroupCache.size} 个分组`
)
} catch (error) {
logger.error('刷新账户名称缓存失败:', error)
} finally {
this.isRefreshing = false
}
}
/**
* 获取账户显示名称
* @param {string} accountId - 账户ID可能带前缀
* @param {string} _fieldName - 字段名(如 claudeAccountId保留用于将来扩展
* @returns {string} 显示名称
*/
getAccountDisplayName(accountId, _fieldName) {
if (!accountId) {
return null
}
// 处理账户组
if (accountId.startsWith('group:')) {
const groupId = accountId.substring(6)
const group = this.groupCache.get(groupId)
if (group) {
return `分组-${group.name}`
}
return `分组-${groupId.substring(0, 8)}`
}
// 直接查找(包括带前缀的 api:xxx, responses:xxx
const cached = this.accountCache.get(accountId)
if (cached) {
return cached.name
}
// 尝试去掉前缀查找
let realId = accountId
if (accountId.startsWith('api:')) {
realId = accountId.substring(4)
} else if (accountId.startsWith('responses:')) {
realId = accountId.substring(10)
}
if (realId !== accountId) {
const cached2 = this.accountCache.get(realId)
if (cached2) {
return cached2.name
}
}
// 未找到,返回 ID 前缀
return `${accountId.substring(0, 8)}...`
}
/**
* 获取 API Key 的所有绑定账户显示名称
* @param {Object} apiKey - API Key 对象
* @returns {Array<{field: string, platform: string, name: string, accountId: string}>}
*/
getBindingDisplayNames(apiKey) {
const bindings = []
const bindingFields = [
{ field: 'claudeAccountId', platform: 'Claude' },
{ field: 'claudeConsoleAccountId', platform: 'Claude Console' },
{ field: 'geminiAccountId', platform: 'Gemini' },
{ field: 'openaiAccountId', platform: 'OpenAI' },
{ field: 'azureOpenaiAccountId', platform: 'Azure OpenAI' },
{ field: 'bedrockAccountId', platform: 'Bedrock' },
{ field: 'droidAccountId', platform: 'Droid' },
{ field: 'ccrAccountId', platform: 'CCR' }
]
for (const { field, platform } of bindingFields) {
const accountId = apiKey[field]
if (accountId) {
const name = this.getAccountDisplayName(accountId, field)
bindings.push({ field, platform, name, accountId })
}
}
return bindings
}
/**
* 搜索绑定账号
* @param {Array} apiKeys - API Key 列表
* @param {string} keyword - 搜索关键词
* @returns {Array} 匹配的 API Key 列表
*/
searchByBindingAccount(apiKeys, keyword) {
const lowerKeyword = keyword.toLowerCase().trim()
if (!lowerKeyword) {
return apiKeys
}
return apiKeys.filter((key) => {
const bindings = this.getBindingDisplayNames(key)
// 无绑定时,匹配"共享池"
if (bindings.length === 0) {
return '共享池'.includes(lowerKeyword) || 'shared'.includes(lowerKeyword)
}
// 匹配任一绑定账户
return bindings.some((binding) => {
// 匹配账户名称
if (binding.name && binding.name.toLowerCase().includes(lowerKeyword)) {
return true
}
// 匹配平台名称
if (binding.platform.toLowerCase().includes(lowerKeyword)) {
return true
}
// 匹配账户 ID
if (binding.accountId.toLowerCase().includes(lowerKeyword)) {
return true
}
return false
})
})
}
/**
* 清除缓存(用于测试或强制刷新)
*/
clearCache() {
this.accountCache.clear()
this.groupCache.clear()
this.lastRefresh = 0
}
}
// 单例导出
module.exports = new AccountNameCacheService()