mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 重新支持apikey费用排序功能
This commit is contained in:
14
src/app.js
14
src/app.js
@@ -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...')
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
591
src/services/costRankService.js
Normal file
591
src/services/costRankService.js
Normal 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()
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user