feat: 重新支持apikey费用排序功能

This commit is contained in:
shaw
2025-11-28 15:32:50 +08:00
parent d9476230c6
commit 28caa93d99
5 changed files with 1165 additions and 20 deletions

View File

@@ -85,6 +85,11 @@ class Application {
const claudeAccountService = require('./services/claudeAccountService') const claudeAccountService = require('./services/claudeAccountService')
await claudeAccountService.initializeSessionWindows() await claudeAccountService.initializeSessionWindows()
// 📊 初始化费用排序索引服务
logger.info('📊 Initializing cost rank service...')
const costRankService = require('./services/costRankService')
await costRankService.initialize()
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前 // 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
this.app.use((req, res, next) => { this.app.use((req, res, next) => {
if (req.path === '/admin-next/' && req.method === 'GET') { if (req.path === '/admin-next/' && req.method === 'GET') {
@@ -656,6 +661,15 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error) logger.error('❌ Error stopping rate limit cleanup service:', error)
} }
// 停止费用排序索引服务
try {
const costRankService = require('./services/costRankService')
costRankService.shutdown()
logger.info('📊 Cost rank service stopped')
} catch (error) {
logger.error('❌ Error stopping cost rank service:', error)
}
// 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏) // 🔢 清理所有并发计数Phase 1 修复:防止重启泄漏)
try { try {
logger.info('🔢 Cleaning up all concurrency counters...') logger.info('🔢 Cleaning up all concurrency counters...')

View File

@@ -119,6 +119,10 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
// 排序参数 // 排序参数
sortBy = 'createdAt', sortBy = 'createdAt',
sortOrder = 'desc', sortOrder = 'desc',
// 费用排序参数
costTimeRange = '7days', // 费用排序的时间范围
costStartDate = '', // custom 时间范围的开始日期
costEndDate = '', // custom 时间范围的结束日期
// 兼容旧参数(不再用于费用计算,仅标记) // 兼容旧参数(不再用于费用计算,仅标记)
timeRange = 'all' timeRange = 'all'
} = req.query } = req.query
@@ -127,8 +131,16 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
const pageNum = Math.max(1, parseInt(page) || 1) const pageNum = Math.max(1, parseInt(page) || 1)
const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20 const pageSizeNum = [10, 20, 50, 100].includes(parseInt(pageSize)) ? parseInt(pageSize) : 20
// 验证排序参数(移除费用相关排序) // 验证排序参数(新增 cost 排序)
const validSortFields = ['name', 'createdAt', 'expiresAt', 'lastUsedAt', 'isActive', 'status'] const validSortFields = [
'name',
'createdAt',
'expiresAt',
'lastUsedAt',
'isActive',
'status',
'cost'
]
const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt' const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt'
const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc' const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'
@@ -141,8 +153,111 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
await accountNameCacheService.refreshIfNeeded() await accountNameCacheService.refreshIfNeeded()
} }
// 使用优化的分页方法获取数据bindingAccount搜索现在在Redis层处理 let result
const result = await redis.getApiKeysPaginated({ let costSortStatus = null
// 如果是费用排序
if (validSortBy === 'cost') {
const costRankService = require('../../services/costRankService')
// 验证费用排序的时间范围
const validCostTimeRanges = ['today', '7days', '30days', 'all', 'custom']
const effectiveCostTimeRange = validCostTimeRanges.includes(costTimeRange)
? costTimeRange
: '7days'
// 如果是 custom 时间范围,使用实时计算
if (effectiveCostTimeRange === 'custom') {
// 验证日期参数
if (!costStartDate || !costEndDate) {
return res.status(400).json({
success: false,
error: 'INVALID_DATE_RANGE',
message: '自定义时间范围需要提供 costStartDate 和 costEndDate 参数'
})
}
const start = new Date(costStartDate)
const end = new Date(costEndDate)
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
return res.status(400).json({
success: false,
error: 'INVALID_DATE_FORMAT',
message: '日期格式无效'
})
}
if (start > end) {
return res.status(400).json({
success: false,
error: 'INVALID_DATE_RANGE',
message: '开始日期不能晚于结束日期'
})
}
// 限制最大范围为 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_TOO_LARGE',
message: '日期范围不能超过365天'
})
}
logger.info(`📊 Cost sort with custom range: ${costStartDate} to ${costEndDate}`)
// 实时计算费用排序
result = await getApiKeysSortedByCostCustom({
page: pageNum,
pageSize: pageSizeNum,
sortOrder: validSortOrder,
startDate: costStartDate,
endDate: costEndDate,
search,
searchMode,
tag,
isActive
})
costSortStatus = {
status: 'ready',
isRealTimeCalculation: true
}
} else {
// 使用预计算索引
const rankStatus = await costRankService.getRankStatus()
costSortStatus = rankStatus[effectiveCostTimeRange]
// 检查索引是否就绪
if (!costSortStatus || costSortStatus.status !== 'ready') {
return res.status(503).json({
success: false,
error: 'RANK_NOT_READY',
message: `费用排序索引 (${effectiveCostTimeRange}) 正在更新中,请稍后重试`,
costSortStatus: costSortStatus || { status: 'unknown' }
})
}
logger.info(`📊 Cost sort using precomputed index: ${effectiveCostTimeRange}`)
// 使用预计算索引排序
result = await getApiKeysSortedByCostPrecomputed({
page: pageNum,
pageSize: pageSizeNum,
sortOrder: validSortOrder,
costTimeRange: effectiveCostTimeRange,
search,
searchMode,
tag,
isActive
})
costSortStatus.isRealTimeCalculation = false
}
} else {
// 原有的非费用排序逻辑
result = await redis.getApiKeysPaginated({
page: pageNum, page: pageNum,
pageSize: pageSizeNum, pageSize: pageSizeNum,
searchMode, searchMode,
@@ -152,6 +267,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
sortBy: validSortBy, sortBy: validSortBy,
sortOrder: validSortOrder sortOrder: validSortOrder
}) })
}
// 为每个API Key添加owner的displayName // 为每个API Key添加owner的displayName
for (const apiKey of result.items) { for (const apiKey of result.items) {
@@ -179,7 +295,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
} }
// 返回分页数据 // 返回分页数据
return res.json({ const responseData = {
success: true, success: true,
data: { data: {
items: result.items, items: result.items,
@@ -188,13 +304,254 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
}, },
// 标记当前请求的时间范围(供前端参考) // 标记当前请求的时间范围(供前端参考)
timeRange timeRange
}) }
// 如果是费用排序,附加排序状态
if (costSortStatus) {
responseData.data.costSortStatus = costSortStatus
}
return res.json(responseData)
} catch (error) { } catch (error) {
logger.error('❌ Failed to get API keys:', error) logger.error('❌ Failed to get API keys:', error)
return res.status(500).json({ error: 'Failed to get API keys', message: error.message }) return res.status(500).json({ error: 'Failed to get API keys', message: error.message })
} }
}) })
/**
* 使用预计算索引进行费用排序的分页查询
*/
async function getApiKeysSortedByCostPrecomputed(options) {
const { page, pageSize, sortOrder, costTimeRange, search, searchMode, tag, isActive } = options
const costRankService = require('../../services/costRankService')
// 1. 获取排序后的全量 keyId 列表
const rankedKeyIds = await costRankService.getSortedKeyIds(costTimeRange, sortOrder)
if (rankedKeyIds.length === 0) {
return {
items: [],
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
availableTags: []
}
}
// 2. 批量获取 API Key 基础数据
const allKeys = await redis.batchGetApiKeys(rankedKeyIds)
// 3. 保持排序顺序(使用 Map 优化查找)
const keyMap = new Map(allKeys.map((k) => [k.id, k]))
let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted)
// 4. 应用筛选条件
// 状态筛选
if (isActive !== '' && isActive !== undefined && isActive !== null) {
const activeValue = isActive === 'true' || isActive === true
orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue)
}
// 标签筛选
if (tag) {
orderedKeys = orderedKeys.filter((k) => {
const tags = Array.isArray(k.tags) ? k.tags : []
return tags.includes(tag)
})
}
// 搜索筛选
if (search) {
const lowerSearch = search.toLowerCase().trim()
if (searchMode === 'apiKey') {
orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch))
} else if (searchMode === 'bindingAccount') {
const accountNameCacheService = require('../../services/accountNameCacheService')
orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch)
}
}
// 5. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
if (!key.isDeleted) {
const tags = Array.isArray(key.tags) ? key.tags : []
tags.forEach((t) => allTags.add(t))
}
}
const availableTags = [...allTags].sort()
// 6. 分页
const total = orderedKeys.length
const totalPages = Math.ceil(total / pageSize) || 1
const validPage = Math.min(Math.max(1, page), totalPages)
const start = (validPage - 1) * pageSize
const items = orderedKeys.slice(start, start + pageSize)
// 7. 为当前页的 Keys 附加费用数据
const keyCosts = await costRankService.getBatchKeyCosts(
costTimeRange,
items.map((k) => k.id)
)
for (const key of items) {
key._cost = keyCosts.get(key.id) || 0
}
return {
items,
pagination: {
page: validPage,
pageSize,
total,
totalPages
},
availableTags
}
}
/**
* 使用实时计算进行 custom 时间范围的费用排序
*/
async function getApiKeysSortedByCostCustom(options) {
const { page, pageSize, sortOrder, startDate, endDate, search, searchMode, tag, isActive } =
options
const costRankService = require('../../services/costRankService')
// 1. 实时计算所有 Keys 的费用
const costs = await costRankService.calculateCustomRangeCosts(startDate, endDate)
if (costs.size === 0) {
return {
items: [],
pagination: { page: 1, pageSize, total: 0, totalPages: 1 },
availableTags: []
}
}
// 2. 转换为数组并排序
const sortedEntries = [...costs.entries()].sort((a, b) => {
return sortOrder === 'desc' ? b[1] - a[1] : a[1] - b[1]
})
const rankedKeyIds = sortedEntries.map(([keyId]) => keyId)
// 3. 批量获取 API Key 基础数据
const allKeys = await redis.batchGetApiKeys(rankedKeyIds)
// 4. 保持排序顺序
const keyMap = new Map(allKeys.map((k) => [k.id, k]))
let orderedKeys = rankedKeyIds.map((id) => keyMap.get(id)).filter((k) => k && !k.isDeleted)
// 5. 应用筛选条件
// 状态筛选
if (isActive !== '' && isActive !== undefined && isActive !== null) {
const activeValue = isActive === 'true' || isActive === true
orderedKeys = orderedKeys.filter((k) => k.isActive === activeValue)
}
// 标签筛选
if (tag) {
orderedKeys = orderedKeys.filter((k) => {
const tags = Array.isArray(k.tags) ? k.tags : []
return tags.includes(tag)
})
}
// 搜索筛选
if (search) {
const lowerSearch = search.toLowerCase().trim()
if (searchMode === 'apiKey') {
orderedKeys = orderedKeys.filter((k) => k.name && k.name.toLowerCase().includes(lowerSearch))
} else if (searchMode === 'bindingAccount') {
const accountNameCacheService = require('../../services/accountNameCacheService')
orderedKeys = accountNameCacheService.searchByBindingAccount(orderedKeys, lowerSearch)
}
}
// 6. 收集所有可用标签
const allTags = new Set()
for (const key of allKeys) {
if (!key.isDeleted) {
const tags = Array.isArray(key.tags) ? key.tags : []
tags.forEach((t) => allTags.add(t))
}
}
const availableTags = [...allTags].sort()
// 7. 分页
const total = orderedKeys.length
const totalPages = Math.ceil(total / pageSize) || 1
const validPage = Math.min(Math.max(1, page), totalPages)
const start = (validPage - 1) * pageSize
const items = orderedKeys.slice(start, start + pageSize)
// 8. 为当前页的 Keys 附加费用数据
for (const key of items) {
key._cost = costs.get(key.id) || 0
}
return {
items,
pagination: {
page: validPage,
pageSize,
total,
totalPages
},
availableTags
}
}
// 获取费用排序索引状态
router.get('/api-keys/cost-sort-status', authenticateAdmin, async (req, res) => {
try {
const costRankService = require('../../services/costRankService')
const status = await costRankService.getRankStatus()
return res.json({ success: true, data: status })
} catch (error) {
logger.error('❌ Failed to get cost sort status:', error)
return res.status(500).json({
success: false,
error: 'Failed to get cost sort status',
message: error.message
})
}
})
// 强制刷新费用排序索引
router.post('/api-keys/cost-sort-refresh', authenticateAdmin, async (req, res) => {
try {
const { timeRange } = req.body
const costRankService = require('../../services/costRankService')
// 验证时间范围
if (timeRange) {
const validTimeRanges = ['today', '7days', '30days', 'all']
if (!validTimeRanges.includes(timeRange)) {
return res.status(400).json({
success: false,
error: 'INVALID_TIME_RANGE',
message: '无效的时间范围可选值today, 7days, 30days, all'
})
}
}
// 异步刷新,不等待完成
costRankService.forceRefresh(timeRange || null).catch((err) => {
logger.error('❌ Failed to refresh cost rank:', err)
})
return res.json({
success: true,
message: timeRange ? `费用排序索引 (${timeRange}) 刷新已开始` : '所有费用排序索引刷新已开始'
})
} catch (error) {
logger.error('❌ Failed to trigger cost sort refresh:', error)
return res.status(500).json({
success: false,
error: 'Failed to trigger refresh',
message: error.message
})
}
})
// 获取支持的客户端列表(使用新的验证器) // 获取支持的客户端列表(使用新的验证器)
router.get('/supported-clients', authenticateAdmin, async (req, res) => { router.get('/supported-clients', authenticateAdmin, async (req, res) => {
try { try {

View File

@@ -158,6 +158,14 @@ class ApiKeyService {
// 保存API Key数据并建立哈希映射 // 保存API Key数据并建立哈希映射
await redis.setApiKey(keyId, keyData, hashedKey) await redis.setApiKey(keyId, keyData, hashedKey)
// 同步添加到费用排序索引
try {
const costRankService = require('./costRankService')
await costRankService.addKeyToIndexes(keyId)
} catch (err) {
logger.warn(`Failed to add key ${keyId} to cost rank indexes:`, err.message)
}
logger.success(`🔑 Generated new API key: ${name} (${keyId})`) logger.success(`🔑 Generated new API key: ${name} (${keyId})`)
return { return {
@@ -756,6 +764,14 @@ class ApiKeyService {
await redis.deleteApiKeyHash(keyData.apiKey) await redis.deleteApiKeyHash(keyData.apiKey)
} }
// 从费用排序索引中移除
try {
const costRankService = require('./costRankService')
await costRankService.removeKeyFromIndexes(keyId)
} catch (err) {
logger.warn(`Failed to remove key ${keyId} from cost rank indexes:`, err.message)
}
logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`) logger.success(`🗑️ Soft deleted API key: ${keyId} by ${deletedBy} (${deletedByType})`)
return { success: true } return { success: true }
@@ -807,6 +823,14 @@ class ApiKeyService {
}) })
} }
// 重新添加到费用排序索引
try {
const costRankService = require('./costRankService')
await costRankService.addKeyToIndexes(keyId)
} catch (err) {
logger.warn(`Failed to add restored key ${keyId} to cost rank indexes:`, err.message)
}
logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`) logger.success(`✅ Restored API key: ${keyId} by ${restoredBy} (${restoredByType})`)
return { success: true, apiKey: updatedData } return { success: true, apiKey: updatedData }

View File

@@ -0,0 +1,591 @@
/**
* 费用排序索引服务
*
* 为 API Keys 提供按费用排序的功能,使用 Redis Sorted Set 预计算排序索引
* 支持 today/7days/30days/all 四种固定时间范围的预计算索引
* 支持 custom 时间范围的实时计算
*
* 设计原则:
* - 只计算未删除的 API Key
* - 使用原子操作避免竞态条件
* - 提供增量更新接口供 API Key 创建/删除时调用
*/
const redis = require('../models/redis')
const logger = require('../utils/logger')
// ============================================================================
// 常量配置
// ============================================================================
/** 时间范围更新间隔配置(省资源模式) */
const UPDATE_INTERVALS = {
today: 10 * 60 * 1000, // 10分钟
'7days': 30 * 60 * 1000, // 30分钟
'30days': 60 * 60 * 1000, // 1小时
all: 2 * 60 * 60 * 1000 // 2小时
}
/** 支持的时间范围列表 */
const VALID_TIME_RANGES = ['today', '7days', '30days', 'all']
/** 分布式锁超时时间(秒) */
const LOCK_TTL = 300
/** 批处理大小 */
const BATCH_SIZE = 100
// ============================================================================
// Redis Key 生成器(集中管理 key 格式)
// ============================================================================
const RedisKeys = {
/** 费用排序索引 Sorted Set */
rankKey: (timeRange) => `cost_rank:${timeRange}`,
/** 临时索引 key用于原子替换 */
tempRankKey: (timeRange) => `cost_rank:${timeRange}:temp:${Date.now()}`,
/** 索引元数据 Hash */
metaKey: (timeRange) => `cost_rank_meta:${timeRange}`,
/** 更新锁 */
lockKey: (timeRange) => `cost_rank_lock:${timeRange}`,
/** 每日费用 */
dailyCost: (keyId, date) => `usage:cost:daily:${keyId}:${date}`,
/** 总费用 */
totalCost: (keyId) => `usage:cost:total:${keyId}`
}
// ============================================================================
// CostRankService 类
// ============================================================================
class CostRankService {
constructor() {
this.timers = {}
this.isInitialized = false
}
// --------------------------------------------------------------------------
// 生命周期管理
// --------------------------------------------------------------------------
/**
* 初始化服务:启动定时任务
* 幂等设计:多次调用只会初始化一次
*/
async initialize() {
// 先清理可能存在的旧定时器(支持热重载)
this._clearAllTimers()
if (this.isInitialized) {
logger.warn('CostRankService already initialized, re-initializing...')
}
logger.info('🔄 Initializing CostRankService...')
try {
// 启动时立即更新所有索引(异步,不阻塞启动)
this.updateAllRanks().catch((err) => {
logger.error('Failed to initialize cost ranks:', err)
})
// 设置定时更新
for (const [timeRange, interval] of Object.entries(UPDATE_INTERVALS)) {
this.timers[timeRange] = setInterval(() => {
this.updateRank(timeRange).catch((err) => {
logger.error(`Failed to update cost rank for ${timeRange}:`, err)
})
}, interval)
}
this.isInitialized = true
logger.success('✅ CostRankService initialized')
} catch (error) {
logger.error('❌ Failed to initialize CostRankService:', error)
throw error
}
}
/**
* 关闭服务:清理定时器
*/
shutdown() {
this._clearAllTimers()
this.isInitialized = false
logger.info('CostRankService shutdown')
}
/**
* 清理所有定时器
* @private
*/
_clearAllTimers() {
for (const timer of Object.values(this.timers)) {
clearInterval(timer)
}
this.timers = {}
}
// --------------------------------------------------------------------------
// 索引更新(全量)
// --------------------------------------------------------------------------
/**
* 更新所有时间范围的索引
*/
async updateAllRanks() {
for (const timeRange of VALID_TIME_RANGES) {
try {
await this.updateRank(timeRange)
} catch (error) {
logger.error(`Failed to update rank for ${timeRange}:`, error)
}
}
}
/**
* 更新指定时间范围的排序索引
* @param {string} timeRange - 时间范围
*/
async updateRank(timeRange) {
const client = redis.getClient()
if (!client) {
logger.warn('Redis client not available, skipping cost rank update')
return
}
const lockKey = RedisKeys.lockKey(timeRange)
const rankKey = RedisKeys.rankKey(timeRange)
const metaKey = RedisKeys.metaKey(timeRange)
// 获取分布式锁
const acquired = await client.set(lockKey, '1', 'NX', 'EX', LOCK_TTL)
if (!acquired) {
logger.debug(`Skipping ${timeRange} rank update - another update in progress`)
return
}
const startTime = Date.now()
try {
// 标记为更新中
await client.hset(metaKey, 'status', 'updating')
// 1. 获取所有未删除的 API Key IDs
const keyIds = await this._getActiveApiKeyIds()
if (keyIds.length === 0) {
// 无数据时清空索引
await client.del(rankKey)
await this._updateMeta(client, metaKey, startTime, 0)
return
}
// 2. 计算日期范围
const dateRange = this._getDateRange(timeRange)
// 3. 分批计算费用
const costs = await this._calculateCostsInBatches(keyIds, dateRange)
// 4. 原子更新索引(使用临时 key + RENAME 避免竞态条件)
await this._atomicUpdateIndex(client, rankKey, costs)
// 5. 更新元数据
await this._updateMeta(client, metaKey, startTime, keyIds.length)
logger.info(
`📊 Updated cost rank for ${timeRange}: ${keyIds.length} keys in ${Date.now() - startTime}ms`
)
} catch (error) {
await client.hset(metaKey, 'status', 'failed')
logger.error(`Failed to update cost rank for ${timeRange}:`, error)
throw error
} finally {
await client.del(lockKey)
}
}
/**
* 原子更新索引(避免竞态条件)
* @private
*/
async _atomicUpdateIndex(client, rankKey, costs) {
if (costs.size === 0) {
await client.del(rankKey)
return
}
// 使用临时 key 构建新索引
const tempKey = `${rankKey}:temp:${Date.now()}`
try {
// 构建 ZADD 参数
const members = []
costs.forEach((cost, keyId) => {
members.push(cost, keyId)
})
// 写入临时 key
await client.zadd(tempKey, ...members)
// 原子替换RENAME 是原子操作)
await client.rename(tempKey, rankKey)
} catch (error) {
// 清理临时 key
await client.del(tempKey).catch(() => {})
throw error
}
}
/**
* 更新元数据
* @private
*/
async _updateMeta(client, metaKey, startTime, keyCount) {
await client.hmset(metaKey, {
lastUpdate: new Date().toISOString(),
keyCount: keyCount.toString(),
status: 'ready',
updateDuration: (Date.now() - startTime).toString()
})
}
// --------------------------------------------------------------------------
// 索引增量更新(供外部调用)
// --------------------------------------------------------------------------
/**
* 添加 API Key 到所有索引(创建 API Key 时调用)
* @param {string} keyId - API Key ID
*/
async addKeyToIndexes(keyId) {
const client = redis.getClient()
if (!client) {
return
}
try {
const pipeline = client.pipeline()
// 将新 Key 添加到所有索引,初始分数为 0
for (const timeRange of VALID_TIME_RANGES) {
pipeline.zadd(RedisKeys.rankKey(timeRange), 0, keyId)
}
await pipeline.exec()
logger.debug(`Added key ${keyId} to cost rank indexes`)
} catch (error) {
logger.error(`Failed to add key ${keyId} to cost rank indexes:`, error)
}
}
/**
* 从所有索引中移除 API Key删除 API Key 时调用)
* @param {string} keyId - API Key ID
*/
async removeKeyFromIndexes(keyId) {
const client = redis.getClient()
if (!client) {
return
}
try {
const pipeline = client.pipeline()
// 从所有索引中移除
for (const timeRange of VALID_TIME_RANGES) {
pipeline.zrem(RedisKeys.rankKey(timeRange), keyId)
}
await pipeline.exec()
logger.debug(`Removed key ${keyId} from cost rank indexes`)
} catch (error) {
logger.error(`Failed to remove key ${keyId} from cost rank indexes:`, error)
}
}
// --------------------------------------------------------------------------
// 查询接口
// --------------------------------------------------------------------------
/**
* 获取排序后的 keyId 列表
* @param {string} timeRange - 时间范围
* @param {string} sortOrder - 排序方向 'asc' | 'desc'
* @param {number} offset - 偏移量
* @param {number} limit - 限制数量,-1 表示全部
* @returns {Promise<string[]>} keyId 列表
*/
async getSortedKeyIds(timeRange, sortOrder = 'desc', offset = 0, limit = -1) {
const client = redis.getClient()
if (!client) {
throw new Error('Redis client not available')
}
const rankKey = RedisKeys.rankKey(timeRange)
const end = limit === -1 ? -1 : offset + limit - 1
if (sortOrder === 'desc') {
return await client.zrevrange(rankKey, offset, end)
} else {
return await client.zrange(rankKey, offset, end)
}
}
/**
* 获取 Key 的费用分数
* @param {string} timeRange - 时间范围
* @param {string} keyId - API Key ID
* @returns {Promise<number>} 费用
*/
async getKeyCost(timeRange, keyId) {
const client = redis.getClient()
if (!client) {
return 0
}
const score = await client.zscore(RedisKeys.rankKey(timeRange), keyId)
return score ? parseFloat(score) : 0
}
/**
* 批量获取多个 Key 的费用分数
* @param {string} timeRange - 时间范围
* @param {string[]} keyIds - API Key ID 列表
* @returns {Promise<Map<string, number>>} keyId -> cost
*/
async getBatchKeyCosts(timeRange, keyIds) {
const client = redis.getClient()
if (!client || keyIds.length === 0) {
return new Map()
}
const rankKey = RedisKeys.rankKey(timeRange)
const costs = new Map()
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
pipeline.zscore(rankKey, keyId)
})
const results = await pipeline.exec()
keyIds.forEach((keyId, index) => {
const [err, score] = results[index]
costs.set(keyId, err || !score ? 0 : parseFloat(score))
})
return costs
}
/**
* 获取所有排序索引的状态
* @returns {Promise<Object>} 各时间范围的状态
*/
async getRankStatus() {
const client = redis.getClient()
if (!client) {
return {}
}
const status = {}
for (const timeRange of VALID_TIME_RANGES) {
const meta = await client.hgetall(RedisKeys.metaKey(timeRange))
status[timeRange] = {
lastUpdate: meta.lastUpdate || null,
keyCount: parseInt(meta.keyCount || 0),
status: meta.status || 'unknown',
updateDuration: parseInt(meta.updateDuration || 0)
}
}
return status
}
/**
* 强制刷新指定时间范围的索引
* @param {string} timeRange - 时间范围,不传则刷新全部
*/
async forceRefresh(timeRange = null) {
if (timeRange) {
await this.updateRank(timeRange)
} else {
await this.updateAllRanks()
}
}
// --------------------------------------------------------------------------
// Custom 时间范围实时计算
// --------------------------------------------------------------------------
/**
* 计算 custom 时间范围的费用(实时计算,排除已删除的 Key
* @param {string} startDate - 开始日期 YYYY-MM-DD
* @param {string} endDate - 结束日期 YYYY-MM-DD
* @returns {Promise<Map<string, number>>} keyId -> cost
*/
async calculateCustomRangeCosts(startDate, endDate) {
const client = redis.getClient()
if (!client) {
throw new Error('Redis client not available')
}
logger.info(`📊 Calculating custom range costs: ${startDate} to ${endDate}`)
const startTime = Date.now()
// 1. 获取所有未删除的 API Key IDs
const keyIds = await this._getActiveApiKeyIds()
if (keyIds.length === 0) {
return new Map()
}
// 2. 分批计算费用
const costs = await this._calculateCostsInBatches(keyIds, { startDate, endDate })
const duration = Date.now() - startTime
logger.info(`📊 Custom range costs calculated: ${keyIds.length} keys in ${duration}ms`)
return costs
}
// --------------------------------------------------------------------------
// 私有辅助方法
// --------------------------------------------------------------------------
/**
* 获取所有未删除的 API Key IDs
* @private
* @returns {Promise<string[]>}
*/
async _getActiveApiKeyIds() {
// 使用现有的 scanApiKeyIds 获取所有 ID
const allKeyIds = await redis.scanApiKeyIds()
if (allKeyIds.length === 0) {
return []
}
// 批量获取 API Key 数据,过滤已删除的
const allKeys = await redis.batchGetApiKeys(allKeyIds)
return allKeys.filter((k) => !k.isDeleted).map((k) => k.id)
}
/**
* 分批计算费用
* @private
*/
async _calculateCostsInBatches(keyIds, dateRange) {
const costs = new Map()
for (let i = 0; i < keyIds.length; i += BATCH_SIZE) {
const batch = keyIds.slice(i, i + BATCH_SIZE)
const batchCosts = await this._calculateBatchCosts(batch, dateRange)
batchCosts.forEach((cost, keyId) => costs.set(keyId, cost))
}
return costs
}
/**
* 批量计算费用
* @private
*/
async _calculateBatchCosts(keyIds, dateRange) {
const client = redis.getClient()
const costs = new Map()
if (dateRange.useTotal) {
// 'all' 时间范围:直接读取 total cost
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
pipeline.get(RedisKeys.totalCost(keyId))
})
const results = await pipeline.exec()
keyIds.forEach((keyId, index) => {
const [err, value] = results[index]
costs.set(keyId, err ? 0 : parseFloat(value || 0))
})
} else {
// 特定日期范围:汇总每日费用
const dates = this._getDatesBetween(dateRange.startDate, dateRange.endDate)
const pipeline = client.pipeline()
keyIds.forEach((keyId) => {
dates.forEach((date) => {
pipeline.get(RedisKeys.dailyCost(keyId, date))
})
})
const results = await pipeline.exec()
let resultIndex = 0
keyIds.forEach((keyId) => {
let totalCost = 0
dates.forEach(() => {
const [err, value] = results[resultIndex++]
if (!err && value) {
totalCost += parseFloat(value)
}
})
costs.set(keyId, totalCost)
})
}
return costs
}
/**
* 获取日期范围配置
* @private
*/
_getDateRange(timeRange) {
const now = new Date()
const today = redis.getDateStringInTimezone(now)
switch (timeRange) {
case 'today':
return { startDate: today, endDate: today }
case '7days': {
const d7 = new Date(now)
d7.setDate(d7.getDate() - 6)
return { startDate: redis.getDateStringInTimezone(d7), endDate: today }
}
case '30days': {
const d30 = new Date(now)
d30.setDate(d30.getDate() - 29)
return { startDate: redis.getDateStringInTimezone(d30), endDate: today }
}
case 'all':
return { useTotal: true }
default:
throw new Error(`Invalid time range: ${timeRange}`)
}
}
/**
* 获取两个日期之间的所有日期
* @private
*/
_getDatesBetween(startDate, endDate) {
const dates = []
const current = new Date(startDate)
const end = new Date(endDate)
while (current <= end) {
dates.push(
`${current.getFullYear()}-${String(current.getMonth() + 1).padStart(2, '0')}-${String(current.getDate()).padStart(2, '0')}`
)
current.setDate(current.getDate() + 1)
}
return dates
}
}
module.exports = new CostRankService()

View File

@@ -311,8 +311,24 @@
</th> </th>
<th <th
class="w-[4%] min-w-[40px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[4%] min-w-[40px] px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
:class="{
'cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600': canSortByCost,
'cursor-not-allowed opacity-60': !canSortByCost
}"
:title="costSortTooltip"
@click="sortApiKeys('cost')"
> >
费用 费用
<i
v-if="apiKeysSortBy === 'cost'"
:class="[
'fas',
apiKeysSortOrder === 'asc' ? 'fa-sort-up' : 'fa-sort-down',
'ml-1'
]"
/>
<i v-else-if="canSortByCost" class="fas fa-sort ml-1 text-gray-400" />
<i v-else class="fas fa-clock ml-1 text-gray-400" title="索引更新中" />
</th> </th>
<th <th
class="w-[14%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" class="w-[14%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
@@ -2042,7 +2058,7 @@
</template> </template>
<script setup> <script setup>
import { ref, reactive, computed, onMounted, watch } from 'vue' import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { apiClient } from '@/config/api' import { apiClient } from '@/config/api'
import { useClientsStore } from '@/stores/clients' import { useClientsStore } from '@/stores/clients'
@@ -2113,10 +2129,13 @@ const timeRangeDropdownOptions = computed(() => [
const activeTab = ref('active') const activeTab = ref('active')
const deletedApiKeys = ref([]) const deletedApiKeys = ref([])
const deletedApiKeysLoading = ref(false) const deletedApiKeysLoading = ref(false)
const apiKeysSortBy = ref('createdAt') // 修改默认排序为创建时间(移除费用排序支持) const apiKeysSortBy = ref('createdAt') // 默认排序为创建时间
const apiKeysSortOrder = ref('desc') const apiKeysSortOrder = ref('desc')
const expandedApiKeys = ref({}) const expandedApiKeys = ref({})
// 费用排序相关状态
const costSortStatus = ref({}) // 各时间范围的索引状态
// 后端分页相关状态 // 后端分页相关状态
const serverPagination = ref({ const serverPagination = ref({
page: 1, page: 1,
@@ -2439,14 +2458,38 @@ const loadApiKeys = async (clearStatsCache = true) => {
params.set('tag', selectedTagFilter.value) params.set('tag', selectedTagFilter.value)
} }
// 排序参数(支持费用字段 // 排序参数(支持费用排序
const validSortFields = ['name', 'createdAt', 'expiresAt', 'lastUsedAt', 'isActive', 'status'] const validSortFields = [
'name',
'createdAt',
'expiresAt',
'lastUsedAt',
'isActive',
'status',
'cost'
]
const effectiveSortBy = validSortFields.includes(apiKeysSortBy.value) const effectiveSortBy = validSortFields.includes(apiKeysSortBy.value)
? apiKeysSortBy.value ? apiKeysSortBy.value
: 'createdAt' : 'createdAt'
params.set('sortBy', effectiveSortBy) params.set('sortBy', effectiveSortBy)
params.set('sortOrder', apiKeysSortOrder.value) params.set('sortOrder', apiKeysSortOrder.value)
// 如果是费用排序,添加费用相关参数
if (effectiveSortBy === 'cost') {
if (
globalDateFilter.type === 'custom' &&
globalDateFilter.customStart &&
globalDateFilter.customEnd
) {
params.set('costTimeRange', 'custom')
params.set('costStartDate', globalDateFilter.customStart)
params.set('costEndDate', globalDateFilter.customEnd)
} else {
// 使用当前的时间范围预设
params.set('costTimeRange', globalDateFilter.preset || '7days')
}
}
// 时间范围(用于标记,不用于费用计算) // 时间范围(用于标记,不用于费用计算)
if ( if (
globalDateFilter.type === 'custom' && globalDateFilter.type === 'custom' &&
@@ -2641,14 +2684,102 @@ const loadDeletedApiKeys = async () => {
// 排序API Keys // 排序API Keys
const sortApiKeys = (field) => { const sortApiKeys = (field) => {
// 费用排序特殊处理
if (field === 'cost') {
if (!canSortByCost.value) {
showToast('费用排序索引正在更新中,请稍后重试', 'warning')
return
}
// 如果是 custom 时间范围,提示可能需要等待
if (globalDateFilter.type === 'custom') {
showToast('正在计算费用排序,可能需要几秒钟...', 'info')
}
}
if (apiKeysSortBy.value === field) { if (apiKeysSortBy.value === field) {
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc' apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
} else { } else {
apiKeysSortBy.value = field apiKeysSortBy.value = field
apiKeysSortOrder.value = 'asc' // 费用排序默认降序(高费用在前)
apiKeysSortOrder.value = field === 'cost' ? 'desc' : 'asc'
} }
} }
// 计算是否可以进行费用排序
const canSortByCost = computed(() => {
// custom 时间范围始终允许(实时计算)
if (globalDateFilter.type === 'custom') {
return true
}
// 检查对应时间范围的索引状态
const timeRange = globalDateFilter.preset
const status = costSortStatus.value[timeRange]
return status?.status === 'ready'
})
// 费用排序提示文字
const costSortTooltip = computed(() => {
if (globalDateFilter.type === 'custom') {
return '点击按费用排序(实时计算,可能需要几秒钟)'
}
const timeRange = globalDateFilter.preset
const status = costSortStatus.value[timeRange]
if (!status) {
return '费用排序索引未初始化'
}
if (status.status === 'updating') {
return '费用排序索引正在更新中...'
}
if (status.status === 'ready') {
const lastUpdate = status.lastUpdate ? new Date(status.lastUpdate).toLocaleString() : '未知'
return `点击按费用排序(索引更新于: ${lastUpdate}`
}
return '费用排序索引状态未知'
})
// 费用排序索引状态刷新定时器
let costSortStatusTimer = null
// 获取费用排序索引状态
const fetchCostSortStatus = async () => {
try {
const data = await apiClient.get('/admin/api-keys/cost-sort-status')
if (data.success) {
costSortStatus.value = data.data || {}
// 根据索引状态动态调整刷新间隔
scheduleNextCostSortStatusRefresh()
}
} catch (error) {
console.error('Failed to fetch cost sort status:', error)
}
}
// 智能调度下次状态刷新
const scheduleNextCostSortStatusRefresh = () => {
// 清除旧的定时器
if (costSortStatusTimer) {
clearTimeout(costSortStatusTimer)
}
// 检查是否有任何索引正在更新中
const hasUpdating = Object.values(costSortStatus.value).some(
(status) => status?.status === 'updating'
)
// 如果有索引正在更新使用短间隔10秒否则使用长间隔60秒
const interval = hasUpdating ? 10000 : 60000
costSortStatusTimer = setTimeout(fetchCostSortStatus, interval)
}
// 格式化数字 // 格式化数字
const formatNumber = (num) => { const formatNumber = (num) => {
if (!num && num !== 0) return '0' if (!num && num !== 0) return '0'
@@ -3268,6 +3399,23 @@ const calculatePeriodCost = (key) => {
// 处理时间范围下拉框变化 // 处理时间范围下拉框变化
const handleTimeRangeChange = (value) => { const handleTimeRangeChange = (value) => {
setGlobalDateFilterPreset(value) setGlobalDateFilterPreset(value)
// 如果当前是费用排序,检查新时间范围的索引是否就绪
if (apiKeysSortBy.value === 'cost') {
// custom 时间范围始终允许(实时计算)
if (value === 'custom') {
return
}
// 检查新时间范围的索引状态
const status = costSortStatus.value[value]
if (!status || status.status !== 'ready') {
// 索引未就绪,回退到默认排序
apiKeysSortBy.value = 'createdAt'
apiKeysSortOrder.value = 'desc'
showToast('当前时间范围的费用排序索引未就绪,已切换到默认排序', 'info')
}
}
} }
// 设置全局日期预设 // 设置全局日期预设
@@ -4502,6 +4650,9 @@ watch(apiKeys, () => {
}) })
onMounted(async () => { onMounted(async () => {
// 获取费用排序索引状态(不阻塞,会自动调度后续刷新)
fetchCostSortStatus()
// 先加载 API Keys优先显示列表 // 先加载 API Keys优先显示列表
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()]) await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
@@ -4511,6 +4662,14 @@ onMounted(async () => {
// 异步加载账号数据(不阻塞页面显示) // 异步加载账号数据(不阻塞页面显示)
loadAccounts() loadAccounts()
}) })
// 组件卸载时清理定时器
onUnmounted(() => {
if (costSortStatusTimer) {
clearTimeout(costSortStatusTimer)
costSortStatusTimer = null
}
})
</script> </script>
<style scoped> <style scoped>