From 5522967792130a02a7e2fc8257ade6abb54e0f19 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:27:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0claude=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E7=BB=B4=E5=BA=A6=E8=AE=A1=E7=AE=97token=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/models/redis.js | 202 +++++++++++++++++++++++++++++ src/routes/admin.js | 67 ++++++++++ src/routes/api.js | 10 +- src/services/apiKeyService.js | 27 +++- src/services/claudeRelayService.js | 7 +- 5 files changed, 304 insertions(+), 9 deletions(-) diff --git a/src/models/redis.js b/src/models/redis.js index f2664f0e..f59ba93a 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -282,6 +282,104 @@ class RedisClient { ]); } + // 📊 记录账户级别的使用统计 + async incrementAccountUsage(accountId, totalTokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { + const now = new Date(); + const today = getDateStringInTimezone(now); + const tzDate = getDateInTimezone(now); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; + + // 账户级别统计的键 + const accountKey = `account_usage:${accountId}`; + const accountDaily = `account_usage:daily:${accountId}:${today}`; + const accountMonthly = `account_usage:monthly:${accountId}:${currentMonth}`; + const accountHourly = `account_usage:hourly:${accountId}:${currentHour}`; + + // 账户按模型统计的键 + const accountModelDaily = `account_usage:model:daily:${accountId}:${model}:${today}`; + const accountModelMonthly = `account_usage:model:monthly:${accountId}:${model}:${currentMonth}`; + const accountModelHourly = `account_usage:model:hourly:${accountId}:${model}:${currentHour}`; + + // 处理token分配 + const finalInputTokens = inputTokens || 0; + const finalOutputTokens = outputTokens || 0; + const finalCacheCreateTokens = cacheCreateTokens || 0; + const finalCacheReadTokens = cacheReadTokens || 0; + const actualTotalTokens = finalInputTokens + finalOutputTokens + finalCacheCreateTokens + finalCacheReadTokens; + const coreTokens = finalInputTokens + finalOutputTokens; + + await Promise.all([ + // 账户总体统计 + this.client.hincrby(accountKey, 'totalTokens', coreTokens), + this.client.hincrby(accountKey, 'totalInputTokens', finalInputTokens), + this.client.hincrby(accountKey, 'totalOutputTokens', finalOutputTokens), + this.client.hincrby(accountKey, 'totalCacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountKey, 'totalCacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountKey, 'totalAllTokens', actualTotalTokens), + this.client.hincrby(accountKey, 'totalRequests', 1), + + // 账户每日统计 + this.client.hincrby(accountDaily, 'tokens', coreTokens), + this.client.hincrby(accountDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountDaily, 'requests', 1), + + // 账户每月统计 + this.client.hincrby(accountMonthly, 'tokens', coreTokens), + this.client.hincrby(accountMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountMonthly, 'requests', 1), + + // 账户每小时统计 + this.client.hincrby(accountHourly, 'tokens', coreTokens), + this.client.hincrby(accountHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountHourly, 'requests', 1), + + // 账户按模型统计 - 每日 + this.client.hincrby(accountModelDaily, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelDaily, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelDaily, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelDaily, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelDaily, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelDaily, 'requests', 1), + + // 账户按模型统计 - 每月 + this.client.hincrby(accountModelMonthly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelMonthly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelMonthly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelMonthly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelMonthly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelMonthly, 'requests', 1), + + // 账户按模型统计 - 每小时 + this.client.hincrby(accountModelHourly, 'inputTokens', finalInputTokens), + this.client.hincrby(accountModelHourly, 'outputTokens', finalOutputTokens), + this.client.hincrby(accountModelHourly, 'cacheCreateTokens', finalCacheCreateTokens), + this.client.hincrby(accountModelHourly, 'cacheReadTokens', finalCacheReadTokens), + this.client.hincrby(accountModelHourly, 'allTokens', actualTotalTokens), + this.client.hincrby(accountModelHourly, 'requests', 1), + + // 设置过期时间 + this.client.expire(accountDaily, 86400 * 32), // 32天过期 + this.client.expire(accountMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountHourly, 86400 * 7), // 7天过期 + this.client.expire(accountModelDaily, 86400 * 32), // 32天过期 + this.client.expire(accountModelMonthly, 86400 * 365), // 1年过期 + this.client.expire(accountModelHourly, 86400 * 7) // 7天过期 + ]); + } + async getUsageStats(keyId) { const totalKey = `usage:${keyId}`; const today = getDateStringInTimezone(); @@ -369,6 +467,110 @@ class RedisClient { }; } + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + const accountKey = `account_usage:${accountId}`; + const today = getDateStringInTimezone(); + const accountDailyKey = `account_usage:daily:${accountId}:${today}`; + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}`; + + const [total, daily, monthly] = await Promise.all([ + this.client.hgetall(accountKey), + this.client.hgetall(accountDailyKey), + this.client.hgetall(accountMonthlyKey) + ]); + + // 获取账户创建时间来计算平均值 + const accountData = await this.client.hgetall(`claude_account:${accountId}`); + const createdAt = accountData.createdAt ? new Date(accountData.createdAt) : new Date(); + const now = new Date(); + const daysSinceCreated = Math.max(1, Math.ceil((now - createdAt) / (1000 * 60 * 60 * 24))); + + const totalTokens = parseInt(total.totalTokens) || 0; + const totalRequests = parseInt(total.totalRequests) || 0; + + // 计算平均RPM和TPM + const totalMinutes = Math.max(1, daysSinceCreated * 24 * 60); + const avgRPM = totalRequests / totalMinutes; + const avgTPM = totalTokens / totalMinutes; + + // 处理账户统计数据 + const handleAccountData = (data) => { + const tokens = parseInt(data.totalTokens) || parseInt(data.tokens) || 0; + const inputTokens = parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0; + const outputTokens = parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0; + const requests = parseInt(data.totalRequests) || parseInt(data.requests) || 0; + const cacheCreateTokens = parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0; + const cacheReadTokens = parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0; + const allTokens = parseInt(data.totalAllTokens) || parseInt(data.allTokens) || 0; + + const actualAllTokens = allTokens || (inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens); + + return { + tokens: tokens, + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheCreateTokens: cacheCreateTokens, + cacheReadTokens: cacheReadTokens, + allTokens: actualAllTokens, + requests: requests + }; + }; + + const totalData = handleAccountData(total); + const dailyData = handleAccountData(daily); + const monthlyData = handleAccountData(monthly); + + return { + accountId: accountId, + total: totalData, + daily: dailyData, + monthly: monthlyData, + averages: { + rpm: Math.round(avgRPM * 100) / 100, + tpm: Math.round(avgTPM * 100) / 100, + dailyRequests: Math.round((totalRequests / daysSinceCreated) * 100) / 100, + dailyTokens: Math.round((totalTokens / daysSinceCreated) * 100) / 100 + } + }; + } + + // 📈 获取所有账户的使用统计 + async getAllAccountsUsageStats() { + try { + // 获取所有Claude账户 + const accountKeys = await this.client.keys('claude_account:*'); + const accountStats = []; + + for (const accountKey of accountKeys) { + const accountId = accountKey.replace('claude_account:', ''); + const accountData = await this.client.hgetall(accountKey); + + if (accountData.name) { + const stats = await this.getAccountUsageStats(accountId); + accountStats.push({ + id: accountId, + name: accountData.name, + email: accountData.email || '', + status: accountData.status || 'unknown', + isActive: accountData.isActive === 'true', + ...stats + }); + } + } + + // 按当日token使用量排序 + accountStats.sort((a, b) => (b.daily.allTokens || 0) - (a.daily.allTokens || 0)); + + return accountStats; + } catch (error) { + logger.error('❌ Failed to get all accounts usage stats:', error); + return []; + } + } + // 🧹 清空所有API Key的使用统计数据 async resetAllUsageStats() { const client = this.getClientSafe(); diff --git a/src/routes/admin.js b/src/routes/admin.js index 16311a0f..e908dfe1 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -791,6 +791,73 @@ router.post('/gemini-accounts/:accountId/refresh', authenticateAdmin, async (req } }); +// 📊 账户使用统计 + +// 获取所有账户的使用统计 +router.get('/accounts/usage-stats', authenticateAdmin, async (req, res) => { + try { + const accountsStats = await redis.getAllAccountsUsageStats(); + + res.json({ + success: true, + data: accountsStats, + summary: { + totalAccounts: accountsStats.length, + activeToday: accountsStats.filter(account => account.daily.requests > 0).length, + totalDailyTokens: accountsStats.reduce((sum, account) => sum + (account.daily.allTokens || 0), 0), + totalDailyRequests: accountsStats.reduce((sum, account) => sum + (account.daily.requests || 0), 0) + }, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('❌ Failed to get accounts usage stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get accounts usage stats', + message: error.message + }); + } +}); + +// 获取单个账户的使用统计 +router.get('/accounts/:accountId/usage-stats', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params; + const accountStats = await redis.getAccountUsageStats(accountId); + + // 获取账户基本信息 + const accountData = await claudeAccountService.getAccount(accountId); + if (!accountData) { + return res.status(404).json({ + success: false, + error: 'Account not found' + }); + } + + res.json({ + success: true, + data: { + ...accountStats, + accountInfo: { + name: accountData.name, + email: accountData.email, + status: accountData.status, + isActive: accountData.isActive, + createdAt: accountData.createdAt + } + }, + timestamp: new Date().toISOString() + }); + } catch (error) { + logger.error('❌ Failed to get account usage stats:', error); + res.status(500).json({ + success: false, + error: 'Failed to get account usage stats', + message: error.message + }); + } +}); + // 📊 系统统计 // 获取系统概览 diff --git a/src/routes/api.js b/src/routes/api.js index 8f38347b..3027de5e 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -68,8 +68,9 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = usageData.cache_read_input_tokens || 0; const model = usageData.model || 'unknown'; - // 记录真实的token使用量(包含模型信息和所有4种token) - apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model).catch(error => { + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const accountId = usageData.accountId; + apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId).catch(error => { logger.error('❌ Failed to record stream usage:', error); }); @@ -135,8 +136,9 @@ async function handleMessagesRequest(req, res) { const cacheReadTokens = jsonData.usage.cache_read_input_tokens || 0; const model = jsonData.model || req.body.model || 'unknown'; - // 记录真实的token使用量(包含模型信息和所有4种token) - await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + // 记录真实的token使用量(包含模型信息和所有4种token以及账户ID) + const accountId = response.accountId; + await apiKeyService.recordUsage(req.apiKey.id, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model, accountId); // 更新时间窗口内的token计数 if (req.rateLimitInfo) { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 718de0fc..7c397904 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -234,18 +234,27 @@ class ApiKeyService { } } - // 📊 记录使用情况(支持缓存token) - async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { + // 📊 记录使用情况(支持缓存token和账户级别统计) + async recordUsage(keyId, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown', accountId = null) { try { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + + // 记录API Key级别的使用统计 await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - // 更新最后使用时间(性能优化:只在实际使用时更新) + // 获取API Key数据以确定关联的账户 const keyData = await redis.getApiKey(keyId); if (keyData && Object.keys(keyData).length > 0) { + // 更新最后使用时间 keyData.lastUsedAt = new Date().toISOString(); - // 使用记录时不需要重新建立哈希映射 await redis.setApiKey(keyId, keyData); + + // 记录账户级别的使用统计 + const claudeAccountId = accountId || keyData.claudeAccountId; + if (claudeAccountId) { + await redis.incrementAccountUsage(claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage: ${claudeAccountId} - ${totalTokens} tokens`); + } } const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]; @@ -274,6 +283,16 @@ class ApiKeyService { return await redis.getUsageStats(keyId); } + // 📊 获取账户使用统计 + async getAccountUsageStats(accountId) { + return await redis.getAccountUsageStats(accountId); + } + + // 📈 获取所有账户使用统计 + async getAllAccountsUsageStats() { + return await redis.getAllAccountsUsageStats(); + } + // 🧹 清理过期的API Keys async cleanupExpiredKeys() { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 76585547..f246063d 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -181,6 +181,8 @@ class ClaudeRelayService { logger.info(`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`); + // 在响应中添加accountId,以便调用方记录账户级别统计 + response.accountId = accountId; return response; } catch (error) { logger.error(`❌ Claude relay request failed for key: ${apiKeyData.name || apiKeyData.id}:`, error.message); @@ -619,7 +621,10 @@ class ClaudeRelayService { const proxyAgent = await this._getProxyAgent(accountId); // 发送流式请求并捕获usage数据 - return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash, streamTransformer, options); + return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, (usageData) => { + // 在usageCallback中添加accountId + usageCallback({ ...usageData, accountId }); + }, accountId, sessionHash, streamTransformer, options); } catch (error) { logger.error('❌ Claude stream relay with usage capture failed:', error); throw error;