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')
|
||||
await claudeAccountService.initializeSessionWindows()
|
||||
|
||||
// 📊 初始化费用排序索引服务
|
||||
logger.info('📊 Initializing cost rank service...')
|
||||
const costRankService = require('./services/costRankService')
|
||||
await costRankService.initialize()
|
||||
|
||||
// 超早期拦截 /admin-next/ 请求 - 在所有中间件之前
|
||||
this.app.use((req, res, next) => {
|
||||
if (req.path === '/admin-next/' && req.method === 'GET') {
|
||||
@@ -656,6 +661,15 @@ class Application {
|
||||
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 修复:防止重启泄漏)
|
||||
try {
|
||||
logger.info('🔢 Cleaning up all concurrency counters...')
|
||||
|
||||
@@ -119,6 +119,10 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
// 排序参数
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
// 费用排序参数
|
||||
costTimeRange = '7days', // 费用排序的时间范围
|
||||
costStartDate = '', // custom 时间范围的开始日期
|
||||
costEndDate = '', // custom 时间范围的结束日期
|
||||
// 兼容旧参数(不再用于费用计算,仅标记)
|
||||
timeRange = 'all'
|
||||
} = req.query
|
||||
@@ -127,8 +131,16 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
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']
|
||||
// 验证排序参数(新增 cost 排序)
|
||||
const validSortFields = [
|
||||
'name',
|
||||
'createdAt',
|
||||
'expiresAt',
|
||||
'lastUsedAt',
|
||||
'isActive',
|
||||
'status',
|
||||
'cost'
|
||||
]
|
||||
const validSortBy = validSortFields.includes(sortBy) ? sortBy : 'createdAt'
|
||||
const validSortOrder = ['asc', 'desc'].includes(sortOrder) ? sortOrder : 'desc'
|
||||
|
||||
@@ -141,17 +153,121 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
await accountNameCacheService.refreshIfNeeded()
|
||||
}
|
||||
|
||||
// 使用优化的分页方法获取数据(bindingAccount搜索现在在Redis层处理)
|
||||
const result = await redis.getApiKeysPaginated({
|
||||
page: pageNum,
|
||||
pageSize: pageSizeNum,
|
||||
searchMode,
|
||||
search,
|
||||
tag,
|
||||
isActive,
|
||||
sortBy: validSortBy,
|
||||
sortOrder: validSortOrder
|
||||
})
|
||||
let result
|
||||
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,
|
||||
pageSize: pageSizeNum,
|
||||
searchMode,
|
||||
search,
|
||||
tag,
|
||||
isActive,
|
||||
sortBy: validSortBy,
|
||||
sortOrder: validSortOrder
|
||||
})
|
||||
}
|
||||
|
||||
// 为每个API Key添加owner的displayName
|
||||
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,
|
||||
data: {
|
||||
items: result.items,
|
||||
@@ -188,13 +304,254 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => {
|
||||
},
|
||||
// 标记当前请求的时间范围(供前端参考)
|
||||
timeRange
|
||||
})
|
||||
}
|
||||
|
||||
// 如果是费用排序,附加排序状态
|
||||
if (costSortStatus) {
|
||||
responseData.data.costSortStatus = costSortStatus
|
||||
}
|
||||
|
||||
return res.json(responseData)
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys:', error)
|
||||
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) => {
|
||||
try {
|
||||
|
||||
@@ -158,6 +158,14 @@ class ApiKeyService {
|
||||
// 保存API Key数据并建立哈希映射
|
||||
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})`)
|
||||
|
||||
return {
|
||||
@@ -756,6 +764,14 @@ class ApiKeyService {
|
||||
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})`)
|
||||
|
||||
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})`)
|
||||
|
||||
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
|
||||
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
|
||||
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>
|
||||
|
||||
<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 { apiClient } from '@/config/api'
|
||||
import { useClientsStore } from '@/stores/clients'
|
||||
@@ -2113,10 +2129,13 @@ const timeRangeDropdownOptions = computed(() => [
|
||||
const activeTab = ref('active')
|
||||
const deletedApiKeys = ref([])
|
||||
const deletedApiKeysLoading = ref(false)
|
||||
const apiKeysSortBy = ref('createdAt') // 修改默认排序为创建时间(移除费用排序支持)
|
||||
const apiKeysSortBy = ref('createdAt') // 默认排序为创建时间
|
||||
const apiKeysSortOrder = ref('desc')
|
||||
const expandedApiKeys = ref({})
|
||||
|
||||
// 费用排序相关状态
|
||||
const costSortStatus = ref({}) // 各时间范围的索引状态
|
||||
|
||||
// 后端分页相关状态
|
||||
const serverPagination = ref({
|
||||
page: 1,
|
||||
@@ -2439,14 +2458,38 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
||||
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)
|
||||
? apiKeysSortBy.value
|
||||
: 'createdAt'
|
||||
params.set('sortBy', effectiveSortBy)
|
||||
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 (
|
||||
globalDateFilter.type === 'custom' &&
|
||||
@@ -2641,14 +2684,102 @@ const loadDeletedApiKeys = async () => {
|
||||
|
||||
// 排序API Keys
|
||||
const sortApiKeys = (field) => {
|
||||
// 费用排序特殊处理
|
||||
if (field === 'cost') {
|
||||
if (!canSortByCost.value) {
|
||||
showToast('费用排序索引正在更新中,请稍后重试', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是 custom 时间范围,提示可能需要等待
|
||||
if (globalDateFilter.type === 'custom') {
|
||||
showToast('正在计算费用排序,可能需要几秒钟...', 'info')
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKeysSortBy.value === field) {
|
||||
apiKeysSortOrder.value = apiKeysSortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
} else {
|
||||
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) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
@@ -3268,6 +3399,23 @@ const calculatePeriodCost = (key) => {
|
||||
// 处理时间范围下拉框变化
|
||||
const handleTimeRangeChange = (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 () => {
|
||||
// 获取费用排序索引状态(不阻塞,会自动调度后续刷新)
|
||||
fetchCostSortStatus()
|
||||
|
||||
// 先加载 API Keys(优先显示列表)
|
||||
await Promise.all([clientsStore.loadSupportedClients(), loadApiKeys()])
|
||||
|
||||
@@ -4511,6 +4662,14 @@ onMounted(async () => {
|
||||
// 异步加载账号数据(不阻塞页面显示)
|
||||
loadAccounts()
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (costSortStatusTimer) {
|
||||
clearTimeout(costSortStatusTimer)
|
||||
costSortStatusTimer = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user