diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 3a275faf..516cf23d 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1195,12 +1195,16 @@ const authenticateApiKey = async (req, res, next) => { }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` ) - return res.status(429).json({ - error: 'Daily cost limit exceeded', - message: `已达到每日费用限制 ($${dailyCostLimit})`, + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到每日费用限制 ($${dailyCostLimit})`, + code: 'daily_cost_limit_exceeded' + }, currentCost: dailyCost, costLimit: dailyCostLimit, - resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置 + resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() }) } @@ -1224,9 +1228,13 @@ const authenticateApiKey = async (req, res, next) => { }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` ) - return res.status(429).json({ - error: 'Total cost limit exceeded', - message: `已达到总费用限制 ($${totalCostLimit})`, + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到总费用限制 ($${totalCostLimit})`, + code: 'total_cost_limit_exceeded' + }, currentCost: totalCost, costLimit: totalCostLimit }) @@ -1265,12 +1273,16 @@ const authenticateApiKey = async (req, res, next) => { resetDate.setDate(now.getDate() + daysUntilMonday) resetDate.setHours(0, 0, 0, 0) - return res.status(429).json({ - error: 'Weekly Opus cost limit exceeded', - message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, + // 使用 402 Payment Required 而非 429,避免客户端自动重试 + return res.status(402).json({ + error: { + type: 'insufficient_quota', + message: `已达到 Opus 模型周费用限制 ($${weeklyOpusCostLimit})`, + code: 'weekly_opus_cost_limit_exceeded' + }, currentCost: weeklyOpusCost, costLimit: weeklyOpusCostLimit, - resetAt: resetDate.toISOString() // 下周一重置 + resetAt: resetDate.toISOString() }) } diff --git a/src/models/redis.js b/src/models/redis.js index e841ba56..e6dcfdea 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -734,6 +734,18 @@ class RedisClient { } } + // 对象字段(JSON 解析) + const objectFields = ['serviceRates'] + for (const field of objectFields) { + if (parsed[field]) { + try { + parsed[field] = JSON.parse(parsed[field]) + } catch (e) { + parsed[field] = {} + } + } + } + return parsed } @@ -964,7 +976,9 @@ class RedisClient { model = 'unknown', ephemeral5mTokens = 0, // 新增:5分钟缓存 tokens ephemeral1hTokens = 0, // 新增:1小时缓存 tokens - isLongContextRequest = false // 新增:是否为 1M 上下文请求(超过200k) + isLongContextRequest = false, // 新增:是否为 1M 上下文请求(超过200k) + realCost = 0, // 真实费用(官方API费用) + ratedCost = 0 // 计费费用(应用倍率后) ) { const key = `usage:${keyId}` const now = new Date() @@ -1089,6 +1103,13 @@ class RedisClient { // 详细缓存类型统计 pipeline.hincrby(keyModelDaily, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(keyModelDaily, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计(使用整数存储,单位:微美元,1美元=1000000微美元) + if (realCost > 0) { + pipeline.hincrby(keyModelDaily, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelDaily, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } // API Key级别的模型统计 - 每月 pipeline.hincrby(keyModelMonthly, 'inputTokens', finalInputTokens) @@ -1100,6 +1121,13 @@ class RedisClient { // 详细缓存类型统计 pipeline.hincrby(keyModelMonthly, 'ephemeral5mTokens', ephemeral5mTokens) pipeline.hincrby(keyModelMonthly, 'ephemeral1hTokens', ephemeral1hTokens) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelMonthly, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelMonthly, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } // API Key级别的模型统计 - 所有时间(无 TTL) const keyModelAlltime = `usage:${keyId}:model:alltime:${normalizedModel}` @@ -1108,6 +1136,13 @@ class RedisClient { pipeline.hincrby(keyModelAlltime, 'cacheCreateTokens', finalCacheCreateTokens) pipeline.hincrby(keyModelAlltime, 'cacheReadTokens', finalCacheReadTokens) pipeline.hincrby(keyModelAlltime, 'requests', 1) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelAlltime, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelAlltime, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } // 小时级别统计 pipeline.hincrby(hourly, 'tokens', coreTokens) @@ -1133,6 +1168,13 @@ class RedisClient { pipeline.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens) pipeline.hincrby(keyModelHourly, 'allTokens', totalTokens) pipeline.hincrby(keyModelHourly, 'requests', 1) + // 费用统计 + if (realCost > 0) { + pipeline.hincrby(keyModelHourly, 'realCostMicro', Math.round(realCost * 1000000)) + } + if (ratedCost > 0) { + pipeline.hincrby(keyModelHourly, 'ratedCostMicro', Math.round(ratedCost * 1000000)) + } // 新增:系统级分钟统计 pipeline.hincrby(systemMinuteKey, 'requests', 1) @@ -1385,6 +1427,8 @@ class RedisClient { /** * 获取使用了指定模型的 Key IDs(OR 逻辑) + * 使用 EXISTS + pipeline 批量检查 alltime 键,避免 KEYS 全量扫描 + * 支持分批处理和 fallback 到 SCAN 模式 */ async getKeyIdsWithModels(keyIds, models) { if (!keyIds.length || !models.length) { @@ -1393,16 +1437,67 @@ class RedisClient { const client = this.getClientSafe() const result = new Set() + const BATCH_SIZE = 1000 + + // 构建所有需要检查的 key + const checkKeys = [] + const keyIdMap = new Map() - // 批量检查每个 keyId 是否使用过任意一个指定模型 for (const keyId of keyIds) { for (const model of models) { - // 检查是否有该模型的使用记录(daily 或 monthly) - const pattern = `usage:${keyId}:model:*:${model}:*` - const keys = await client.keys(pattern) - if (keys.length > 0) { - result.add(keyId) - break // 找到一个就够了(OR 逻辑) + const key = `usage:${keyId}:model:alltime:${model}` + checkKeys.push(key) + keyIdMap.set(key, keyId) + } + } + + // 分批 EXISTS 检查(避免单个 pipeline 过大) + for (let i = 0; i < checkKeys.length; i += BATCH_SIZE) { + const batch = checkKeys.slice(i, i + BATCH_SIZE) + const pipeline = client.pipeline() + for (const key of batch) { + pipeline.exists(key) + } + const results = await pipeline.exec() + + for (let j = 0; j < batch.length; j++) { + const [err, exists] = results[j] + if (!err && exists) { + result.add(keyIdMap.get(batch[j])) + } + } + } + + // Fallback: 如果 alltime 键全部不存在,回退到 SCAN 模式 + if (result.size === 0 && keyIds.length > 0) { + // 多抽样检查:抽取最多 3 个 keyId 检查是否有 alltime 数据 + const sampleIndices = new Set() + sampleIndices.add(0) // 始终包含第一个 + if (keyIds.length > 1) sampleIndices.add(keyIds.length - 1) // 包含最后一个 + if (keyIds.length > 2) sampleIndices.add(Math.floor(keyIds.length / 2)) // 包含中间一个 + + let hasAnyAlltimeData = false + for (const idx of sampleIndices) { + const samplePattern = `usage:${keyIds[idx]}:model:alltime:*` + const sampleKeys = await this.scanKeys(samplePattern) + if (sampleKeys.length > 0) { + hasAnyAlltimeData = true + break + } + } + + if (!hasAnyAlltimeData) { + // alltime 数据不存在,回退到旧扫描逻辑 + logger.warn('⚠️ alltime 模型数据不存在,回退到 SCAN 模式(建议运行迁移脚本)') + for (const keyId of keyIds) { + for (const model of models) { + const pattern = `usage:${keyId}:model:*:${model}:*` + const keys = await this.scanKeys(pattern) + if (keys.length > 0) { + result.add(keyId) + break + } + } } } } @@ -1830,10 +1925,10 @@ class RedisClient { return costMap } - // 💰 回退方法:使用 KEYS 命令计算单个账户的每日费用 + // 💰 回退方法:计算单个账户的每日费用(使用 scanKeys 替代 keys) async getAccountDailyCostFallback(accountId, today, CostCalculator) { const pattern = `account_usage:model:daily:${accountId}:*:${today}` - const modelKeys = await this.client.keys(pattern) + const modelKeys = await this.scanKeys(pattern) if (!modelKeys || modelKeys.length === 0) { return 0 @@ -1898,8 +1993,11 @@ class RedisClient { } else if (accountType === 'openai-responses') { accountData = await this.client.hgetall(`openai_responses_account:${accountId}`) } else { - // 尝试多个前缀 - accountData = await this.client.hgetall(`claude_account:${accountId}`) + // 尝试多个前缀(优先 claude:account:) + accountData = await this.client.hgetall(`claude:account:${accountId}`) + if (!accountData.createdAt) { + accountData = await this.client.hgetall(`claude_account:${accountId}`) + } if (!accountData.createdAt) { accountData = await this.client.hgetall(`openai:account:${accountId}`) } @@ -1978,15 +2076,24 @@ class RedisClient { // 📈 获取所有账户的使用统计 async getAllAccountsUsageStats() { try { - // 获取所有Claude账户 - const accountKeys = await this.client.keys('claude_account:*') + // 使用 getAllIdsByIndex 获取账户 ID(自动处理索引/SCAN 回退) + const accountIds = await this.getAllIdsByIndex( + 'claude:account:index', + 'claude:account:*', + /^claude:account:(.+)$/ + ) + + if (accountIds.length === 0) { + return [] + } + const accountStats = [] - for (const accountKey of accountKeys) { - const accountId = accountKey.replace('claude_account:', '') + for (const accountId of accountIds) { + const accountKey = `claude:account:${accountId}` const accountData = await this.client.hgetall(accountKey) - if (accountData.name) { + if (accountData && accountData.name) { const stats = await this.getAccountUsageStats(accountId) accountStats.push({ id: accountId, @@ -2009,7 +2116,7 @@ class RedisClient { } } - // 🧹 清空所有API Key的使用统计数据 + // 🧹 清空所有API Key的使用统计数据(使用 scanKeys + batchDelChunked 优化) async resetAllUsageStats() { const client = this.getClientSafe() const stats = { @@ -2020,56 +2127,53 @@ class RedisClient { } try { - // 获取所有API Key ID - const apiKeyIds = [] - const apiKeyKeys = await client.keys('apikey:*') + // 1. 获取所有 API Key ID(使用 scanKeys) + const apiKeyKeys = await this.scanKeys('apikey:*') + const apiKeyIds = apiKeyKeys + .filter((k) => k !== 'apikey:hash_map' && k.split(':').length === 2) + .map((k) => k.replace('apikey:', '')) - for (const key of apiKeyKeys) { - if (key === 'apikey:hash_map') { - continue - } // 跳过哈希映射表 - const keyId = key.replace('apikey:', '') - apiKeyIds.push(keyId) - } + // 2. 批量删除总体使用统计 + const usageKeys = apiKeyIds.map((id) => `usage:${id}`) + stats.deletedKeys = await this.batchDelChunked(usageKeys) - // 清空每个API Key的使用统计 - for (const keyId of apiKeyIds) { - // 删除总体使用统计 - const usageKey = `usage:${keyId}` - const deleted = await client.del(usageKey) - if (deleted > 0) { - stats.deletedKeys++ + // 3. 使用 scanKeys 获取并批量删除 daily 统计 + const dailyKeys = await this.scanKeys('usage:daily:*') + stats.deletedDailyKeys = await this.batchDelChunked(dailyKeys) + + // 4. 使用 scanKeys 获取并批量删除 monthly 统计 + const monthlyKeys = await this.scanKeys('usage:monthly:*') + stats.deletedMonthlyKeys = await this.batchDelChunked(monthlyKeys) + + // 5. 批量重置 lastUsedAt(仅对存在的 key 操作,避免重建空 hash) + const BATCH_SIZE = 500 + for (let i = 0; i < apiKeyIds.length; i += BATCH_SIZE) { + const batch = apiKeyIds.slice(i, i + BATCH_SIZE) + const existsPipeline = client.pipeline() + for (const keyId of batch) { + existsPipeline.exists(`apikey:${keyId}`) } + const existsResults = await existsPipeline.exec() - // 删除该API Key的每日统计(使用精确的keyId匹配) - const dailyKeys = await client.keys(`usage:daily:${keyId}:*`) - if (dailyKeys.length > 0) { - await client.del(...dailyKeys) - stats.deletedDailyKeys += dailyKeys.length + const updatePipeline = client.pipeline() + let updateCount = 0 + for (let j = 0; j < batch.length; j++) { + const [err, exists] = existsResults[j] + if (!err && exists) { + updatePipeline.hset(`apikey:${batch[j]}`, 'lastUsedAt', '') + updateCount++ + } } - - // 删除该API Key的每月统计(使用精确的keyId匹配) - const monthlyKeys = await client.keys(`usage:monthly:${keyId}:*`) - if (monthlyKeys.length > 0) { - await client.del(...monthlyKeys) - stats.deletedMonthlyKeys += monthlyKeys.length - } - - // 重置API Key的lastUsedAt字段 - const keyData = await client.hgetall(`apikey:${keyId}`) - if (keyData && Object.keys(keyData).length > 0) { - keyData.lastUsedAt = '' - await client.hset(`apikey:${keyId}`, keyData) - stats.resetApiKeys++ + if (updateCount > 0) { + await updatePipeline.exec() + stats.resetApiKeys += updateCount } } - // 额外清理:删除所有可能遗漏的usage相关键 - const allUsageKeys = await client.keys('usage:*') - if (allUsageKeys.length > 0) { - await client.del(...allUsageKeys) - stats.deletedKeys += allUsageKeys.length - } + // 6. 清理所有 usage 相关键(使用 scanKeys + batchDelChunked) + const allUsageKeys = await this.scanKeys('usage:*') + const additionalDeleted = await this.batchDelChunked(allUsageKeys) + stats.deletedKeys += additionalDeleted return stats } catch (error) { @@ -2406,16 +2510,21 @@ class RedisClient { return await this.client.del(key) } - // 📈 系统统计 + // 📈 系统统计(使用 scanKeys 替代 keys) async getSystemStats() { const keys = await Promise.all([ - this.client.keys('apikey:*'), - this.client.keys('claude:account:*'), - this.client.keys('usage:*') + this.scanKeys('apikey:*'), + this.scanKeys('claude:account:*'), + this.scanKeys('usage:*') ]) + // 过滤 apikey 索引键,只统计实际的 apikey + const apiKeyCount = keys[0].filter( + (k) => k !== 'apikey:hash_map' && k.split(':').length === 2 + ).length + return { - totalApiKeys: keys[0].length, + totalApiKeys: apiKeyCount, totalClaudeAccounts: keys[1].length, totalUsageRecords: keys[2].length } @@ -2771,13 +2880,13 @@ class RedisClient { return await this.client.del(key) } - // 🧹 清理过期数据 + // 🧹 清理过期数据(使用 scanKeys 替代 keys) async cleanup() { try { const patterns = ['usage:daily:*', 'ratelimit:*', 'session:*', 'sticky_session:*', 'oauth:*'] for (const pattern of patterns) { - const keys = await this.client.keys(pattern) + const keys = await this.scanKeys(pattern) const pipeline = this.client.pipeline() for (const key of keys) { @@ -3039,13 +3148,13 @@ class RedisClient { // 🔧 并发管理方法(用于管理员手动清理) /** - * 获取所有并发状态 + * 获取所有并发状态(使用 scanKeys 替代 keys) * @returns {Promise} 并发状态列表 */ async getAllConcurrencyStatus() { try { const client = this.getClientSafe() - const keys = await client.keys('concurrency:*') + const keys = await this.scanKeys('concurrency:*') const now = Date.now() const results = [] @@ -3238,13 +3347,13 @@ class RedisClient { } /** - * 强制清理所有并发计数 + * 强制清理所有并发计数(使用 scanKeys 替代 keys) * @returns {Promise} 清理结果 */ async forceClearAllConcurrency() { try { const client = this.getClientSafe() - const keys = await client.keys('concurrency:*') + const keys = await this.scanKeys('concurrency:*') let totalCleared = 0 let legacyCleared = 0 @@ -3298,7 +3407,7 @@ class RedisClient { } /** - * 清理过期的并发条目(不影响活跃请求) + * 清理过期的并发条目(不影响活跃请求,使用 scanKeys 替代 keys) * @param {string} apiKeyId - API Key ID(可选,不传则清理所有) * @returns {Promise} 清理结果 */ @@ -3311,7 +3420,7 @@ class RedisClient { if (apiKeyId) { keys = [`concurrency:${apiKeyId}`] } else { - keys = await client.keys('concurrency:*') + keys = await this.scanKeys('concurrency:*') } let totalCleaned = 0 @@ -4890,7 +4999,9 @@ redisClient.ensureMonthlyMonthsIndex = async function () { if (missingMonths.length > 0) { await this.client.sadd('usage:model:monthly:months', ...missingMonths) - logger.info(`📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})`) + logger.info( + `📅 补充月份索引: ${missingMonths.length} 个月份 (${missingMonths.sort().join(', ')})` + ) } } diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index 5b3df468..f2149ee8 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -45,6 +45,27 @@ function validatePermissions(permissions) { return `Permissions must be an array. Valid values are: ${VALID_PERMISSIONS.join(', ')}` } +/** + * 验证 serviceRates 格式 + * @param {any} serviceRates - 服务倍率对象 + * @returns {string|null} - 返回错误消息,null 表示验证通过 + */ +function validateServiceRates(serviceRates) { + if (serviceRates === undefined || serviceRates === null) { + return null + } + if (typeof serviceRates !== 'object' || Array.isArray(serviceRates)) { + return 'Service rates must be an object' + } + for (const [service, rate] of Object.entries(serviceRates)) { + const numRate = Number(rate) + if (!Number.isFinite(numRate) || numRate < 0) { + return `Invalid rate for service "${service}": must be a non-negative number` + } + } + return null +} + // 👥 用户管理 (用于API Key分配) // 获取所有用户列表(用于API Key分配) @@ -814,7 +835,9 @@ router.put('/api-keys/tags/:tagName', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: result.error }) } - logger.info(`🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys`) + logger.info( + `🏷️ Renamed tag "${decodedTagName}" to "${trimmedNewName}" in ${result.affectedCount} API keys` + ) return res.json({ success: true, message: `Tag renamed in ${result.affectedCount} API keys`, @@ -1426,7 +1449,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { activationDays, // 新增:激活后有效天数 activationUnit, // 新增:激活时间单位 (hours/days) expirationMode, // 新增:过期模式 - icon // 新增:图标 + icon, // 新增:图标 + serviceRates // API Key 级别服务倍率 } = req.body // 输入验证 @@ -1553,6 +1577,12 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: permissionsError }) } + // 验证服务倍率 + const serviceRatesError = validateServiceRates(serviceRates) + if (serviceRatesError) { + return res.status(400).json({ error: serviceRatesError }) + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -1580,7 +1610,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { activationDays, activationUnit, expirationMode, - icon + icon, + serviceRates }) logger.success(`🔑 Admin created new API key: ${name}`) @@ -1622,7 +1653,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { activationDays, activationUnit, expirationMode, - icon + icon, + serviceRates } = req.body // 输入验证 @@ -1646,6 +1678,12 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: batchPermissionsError }) } + // 验证服务倍率 + const batchServiceRatesError = validateServiceRates(serviceRates) + if (batchServiceRatesError) { + return res.status(400).json({ error: batchServiceRatesError }) + } + // 生成批量API Keys const createdKeys = [] const errors = [] @@ -1680,7 +1718,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { activationDays, activationUnit, expirationMode, - icon + icon, + serviceRates }) // 保留原始 API Key 供返回 @@ -1754,6 +1793,14 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { } } + // 验证服务倍率 + if (updates.serviceRates !== undefined) { + const updateServiceRatesError = validateServiceRates(updates.serviceRates) + if (updateServiceRatesError) { + return res.status(400).json({ error: updateServiceRatesError }) + } + } + logger.info( `🔄 Admin batch editing ${keyIds.length} API keys with updates: ${JSON.stringify(updates)}` ) @@ -1822,6 +1869,9 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.enabled !== undefined) { finalUpdates.enabled = updates.enabled } + if (updates.serviceRates !== undefined) { + finalUpdates.serviceRates = updates.serviceRates + } // 处理账户绑定 if (updates.claudeAccountId !== undefined) { @@ -1939,7 +1989,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { totalCostLimit, weeklyOpusCostLimit, tags, - ownerId // 新增:所有者ID字段 + ownerId, // 新增:所有者ID字段 + serviceRates // API Key 级别服务倍率 } = req.body // 只允许更新指定字段 @@ -2125,6 +2176,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.tags = tags } + // 处理服务倍率 + if (serviceRates !== undefined) { + const singleServiceRatesError = validateServiceRates(serviceRates) + if (singleServiceRatesError) { + return res.status(400).json({ error: singleServiceRatesError }) + } + updates.serviceRates = serviceRates + } + // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 if (isActive !== undefined) { if (typeof isActive !== 'boolean') { diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 7eee8e58..6ee11318 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -792,7 +792,10 @@ router.post('/api/batch-model-stats', async (req, res) => { outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, - allTokens: 0 + allTokens: 0, + realCostMicro: 0, + ratedCostMicro: 0, + hasStoredCost: false }) } @@ -803,12 +806,18 @@ router.post('/api/batch-model-stats', async (req, res) => { modelUsage.cacheCreateTokens += parseInt(data.cacheCreateTokens) || 0 modelUsage.cacheReadTokens += parseInt(data.cacheReadTokens) || 0 modelUsage.allTokens += parseInt(data.allTokens) || 0 + modelUsage.realCostMicro += parseInt(data.realCostMicro) || 0 + modelUsage.ratedCostMicro += parseInt(data.ratedCostMicro) || 0 + // 检查 Redis 数据是否包含成本字段 + if ('realCostMicro' in data || 'ratedCostMicro' in data) { + modelUsage.hasStoredCost = true + } } } }) ) - // 转换为数组并计算费用 + // 转换为数组并处理费用 const modelStats = [] for (const [model, usage] of modelUsageMap) { const usageData = { @@ -818,8 +827,18 @@ router.post('/api/batch-model-stats', async (req, res) => { cache_read_input_tokens: usage.cacheReadTokens } + // 优先使用存储的费用,否则回退到重新计算 + const hasStoredCost = usage.hasStoredCost const costData = CostCalculator.calculateCost(usageData, model) + // 如果有存储的费用,覆盖计算的费用 + if (hasStoredCost) { + costData.costs.real = (usage.realCostMicro || 0) / 1000000 + costData.costs.rated = (usage.ratedCostMicro || 0) / 1000000 + costData.costs.total = costData.costs.real // 保持兼容 + costData.formatted.total = `$${costData.costs.real.toFixed(6)}` + } + modelStats.push({ model, requests: usage.requests, @@ -830,7 +849,8 @@ router.post('/api/batch-model-stats', async (req, res) => { allTokens: usage.allTokens, costs: costData.costs, formatted: costData.formatted, - pricing: costData.pricing + pricing: costData.pricing, + isLegacy: !hasStoredCost }) } @@ -1343,8 +1363,21 @@ router.post('/api/user-model-stats', async (req, res) => { cache_read_input_tokens: parseInt(data.cacheReadTokens) || 0 } + // 优先使用存储的费用,否则回退到重新计算 + // 检查字段是否存在(而非 > 0),以支持真正的零成本场景 + const realCostMicro = parseInt(data.realCostMicro) || 0 + const ratedCostMicro = parseInt(data.ratedCostMicro) || 0 + const hasStoredCost = 'realCostMicro' in data || 'ratedCostMicro' in data const costData = CostCalculator.calculateCost(usage, model) + // 如果有存储的费用,覆盖计算的费用 + if (hasStoredCost) { + costData.costs.real = realCostMicro / 1000000 + costData.costs.rated = ratedCostMicro / 1000000 + costData.costs.total = costData.costs.real + costData.formatted.total = `$${costData.costs.real.toFixed(6)}` + } + // alltime 键不存储 allTokens,需要计算 const allTokens = period === 'alltime' @@ -1364,7 +1397,8 @@ router.post('/api/user-model-stats', async (req, res) => { allTokens, costs: costData.costs, formatted: costData.formatted, - pricing: costData.pricing + pricing: costData.pricing, + isLegacy: !hasStoredCost }) } } diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index b640bd6c..9bcf8b9b 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -602,9 +602,7 @@ class ApiKeyService { const globalTags = await redis.getGlobalTags() // 过滤空值和空格 return [ - ...new Set( - [...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t) - ) + ...new Set([...indexTags, ...globalTags].map((t) => (t ? t.trim() : '')).filter((t) => t)) ].sort() } @@ -724,7 +722,9 @@ class ApiKeyService { const strTags = tags.filter((t) => typeof t === 'string') if (strTags.some((t) => t.trim() === normalizedOld)) { foundInKeys = true - const newTags = [...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t)))] + const newTags = [ + ...new Set(strTags.map((t) => (t.trim() === normalizedOld ? normalizedNew : t))) + ] await this.updateApiKey(key.id, { tags: newTags }) affectedCount++ } @@ -732,7 +732,9 @@ class ApiKeyService { // 检查全局集合是否有该标签 const globalTags = await redis.getGlobalTags() - const foundInGlobal = globalTags.some((t) => typeof t === 'string' && t.trim() === normalizedOld) + const foundInGlobal = globalTags.some( + (t) => typeof t === 'string' && t.trim() === normalizedOld + ) if (!foundInKeys && !foundInGlobal) { return { affectedCount: 0, error: '标签不存在' } @@ -1537,7 +1539,16 @@ class ApiKeyService { isLongContextRequest = totalInputTokens > 200000 } - // 记录API Key级别的使用统计 + // 计算费用(应用服务倍率) + const realCost = costInfo.costs.total + let ratedCost = realCost + if (realCost > 0) { + const serviceRatesService = require('./serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCost = await this.calculateRatedCost(keyId, service, realCost) + } + + // 记录API Key级别的使用统计(包含费用) await redis.incrementTokenUsage( keyId, totalTokens, @@ -1548,20 +1559,16 @@ class ApiKeyService { model, 0, // ephemeral5mTokens - 暂时为0,后续处理 0, // ephemeral1hTokens - 暂时为0,后续处理 - isLongContextRequest + isLongContextRequest, + realCost, + ratedCost ) - // 记录费用统计(应用服务倍率) - const realCost = costInfo.costs.total - let ratedCost = realCost + // 记录费用统计到每日/每月汇总 if (realCost > 0) { - const serviceRatesService = require('./serviceRatesService') - const service = serviceRatesService.getService(accountType, model) - ratedCost = await this.calculateRatedCost(keyId, service, realCost) - await redis.incrementDailyCost(keyId, ratedCost, realCost) logger.database( - `💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}, service: ${service}` + `💰 Recorded cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}` ) // 记录 Opus 周费用(如果适用) @@ -1744,7 +1751,16 @@ class ApiKeyService { ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0 } - // 记录API Key级别的使用统计 - 这个必须执行 + // 计算费用(应用服务倍率)- 需要在 incrementTokenUsage 之前计算 + const realCostWithDetails = costInfo.totalCost || 0 + let ratedCostWithDetails = realCostWithDetails + if (realCostWithDetails > 0) { + const serviceRatesService = require('./serviceRatesService') + const service = serviceRatesService.getService(accountType, model) + ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails) + } + + // 记录API Key级别的使用统计(包含费用) await redis.incrementTokenUsage( keyId, totalTokens, @@ -1753,27 +1769,29 @@ class ApiKeyService { cacheCreateTokens, cacheReadTokens, model, - ephemeral5mTokens, // 传递5分钟缓存 tokens - ephemeral1hTokens, // 传递1小时缓存 tokens - costInfo.isLongContextRequest || false // 传递 1M 上下文请求标记 + ephemeral5mTokens, + ephemeral1hTokens, + costInfo.isLongContextRequest || false, + realCostWithDetails, + ratedCostWithDetails ) - // 记录费用统计(应用服务倍率) - const realCostWithDetails = costInfo.totalCost || 0 - let ratedCostWithDetails = realCostWithDetails + // 记录费用到每日/每月汇总 if (realCostWithDetails > 0) { - const serviceRatesService = require('./serviceRatesService') - const service = serviceRatesService.getService(accountType, model) - ratedCostWithDetails = await this.calculateRatedCost(keyId, service, realCostWithDetails) - // 记录倍率成本和真实成本 await redis.incrementDailyCost(keyId, ratedCostWithDetails, realCostWithDetails) logger.database( - `💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}, service: ${service}` + `💰 Recorded cost for ${keyId}: rated=$${ratedCostWithDetails.toFixed(6)}, real=$${realCostWithDetails.toFixed(6)}, model: ${model}` ) // 记录 Opus 周费用(如果适用,也应用倍率) - await this.recordOpusCost(keyId, ratedCostWithDetails, realCostWithDetails, model, accountType) + await this.recordOpusCost( + keyId, + ratedCostWithDetails, + realCostWithDetails, + model, + accountType + ) // 记录详细的缓存费用(如果有) if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { @@ -2529,7 +2547,12 @@ class ApiKeyService { logger.success(`💸 Deducted $${actualDeducted} from key ${keyId}, new limit: $${newLimit}`) - return { success: true, previousLimit: currentLimit, newTotalCostLimit: newLimit, actualDeducted } + return { + success: true, + previousLimit: currentLimit, + newTotalCostLimit: newLimit, + actualDeducted + } } catch (error) { logger.error('❌ Failed to deduct total cost limit:', error) throw error diff --git a/src/services/claudeCodeHeadersService.js b/src/services/claudeCodeHeadersService.js index 4c016494..f9999c94 100644 --- a/src/services/claudeCodeHeadersService.js +++ b/src/services/claudeCodeHeadersService.js @@ -214,12 +214,12 @@ class ClaudeCodeHeadersService { } /** - * 获取所有账号的 headers 信息 + * 获取所有账号的 headers 信息(使用 scanKeys 替代 keys) */ async getAllAccountHeaders() { try { const pattern = 'claude_code_headers:*' - const keys = await redis.getClient().keys(pattern) + const keys = await redis.scanKeys(pattern) const results = {} for (const key of keys) { diff --git a/web/admin-spa/src/components/apistats/ModelUsageStats.vue b/web/admin-spa/src/components/apistats/ModelUsageStats.vue index 98775997..f8a6baca 100644 --- a/web/admin-spa/src/components/apistats/ModelUsageStats.vue +++ b/web/admin-spa/src/components/apistats/ModelUsageStats.vue @@ -125,6 +125,14 @@ const getServiceFromModel = (model) => { // 计算 CC 扣费 const calculateCcCost = (model) => { + // 使用 isLegacy 判断是否有存储的计费费用 + if (!model.isLegacy && model.costs?.rated !== undefined) { + const ccCost = model.costs.rated + if (ccCost >= 1) return '$' + ccCost.toFixed(2) + if (ccCost >= 0.01) return '$' + ccCost.toFixed(4) + return '$' + ccCost.toFixed(6) + } + // 回退到重新计算(历史数据) const cost = model.costs?.total || 0 if (!cost || !serviceRates.value?.rates) return '$0.00' const service = getServiceFromModel(model.model) diff --git a/web/admin-spa/src/components/apistats/ServiceCostCards.vue b/web/admin-spa/src/components/apistats/ServiceCostCards.vue index a1914682..3a69e240 100644 --- a/web/admin-spa/src/components/apistats/ServiceCostCards.vue +++ b/web/admin-spa/src/components/apistats/ServiceCostCards.vue @@ -158,12 +158,13 @@ const serviceStats = computed(() => { outputTokens: 0, cacheCreateTokens: 0, cacheReadTokens: 0, - cost: 0, + realCost: 0, + ratedCost: 0, pricing: null } }) - // 聚合模型数据 + // 聚合模型数据 - 按模型逐个计算计费费用 modelStats.value.forEach((model) => { const service = getServiceFromModel(model.model) if (stats[service]) { @@ -171,24 +172,35 @@ const serviceStats = computed(() => { stats[service].outputTokens += model.outputTokens || 0 stats[service].cacheCreateTokens += model.cacheCreateTokens || 0 stats[service].cacheReadTokens += model.cacheReadTokens || 0 - stats[service].cost += model.costs?.total || 0 + // 累加官方费用 + const modelRealCost = model.costs?.real ?? model.costs?.total ?? 0 + stats[service].realCost += modelRealCost + // 按模型判断:有存储费用用存储的,否则用当前倍率计算 + const globalRate = serviceRates.value.rates[service] || 1.0 + const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0) + const modelRatedCost = + !model.isLegacy && model.costs?.rated !== undefined + ? model.costs.rated + : modelRealCost * globalRate * keyRate + stats[service].ratedCost += modelRatedCost if (!stats[service].pricing && model.pricing) { stats[service].pricing = model.pricing } } }) - // 转换为数组并计算计费费用 + // 转换为数组 return Object.entries(stats) .filter( ([, data]) => - data.inputTokens > 0 || data.outputTokens > 0 || data.cacheCreateTokens > 0 || data.cost > 0 + data.inputTokens > 0 || + data.outputTokens > 0 || + data.cacheCreateTokens > 0 || + data.realCost > 0 ) .map(([service, data]) => { const globalRate = serviceRates.value.rates[service] || 1.0 - // 批量模式下不使用 Key 倍率 const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0) - const ccCostValue = data.cost * globalRate * keyRate const p = data.pricing return { name: service, @@ -199,14 +211,14 @@ const serviceStats = computed(() => { outputTokens: data.outputTokens, cacheCreateTokens: data.cacheCreateTokens, cacheReadTokens: data.cacheReadTokens, - officialCost: formatCost(data.cost), - ccCost: formatCost(ccCostValue), + officialCost: formatCost(data.realCost), + ccCost: formatCost(data.ratedCost), pricing: p ? { - input: formatCost(p.input * 1e6), - output: formatCost(p.output * 1e6), - cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate * 1e6) : null, - cacheRead: p.cacheRead ? formatCost(p.cacheRead * 1e6) : null + input: formatCost(p.input), + output: formatCost(p.output), + cacheCreate: p.cacheCreate ? formatCost(p.cacheCreate) : null, + cacheRead: p.cacheRead ? formatCost(p.cacheRead) : null } : null }