From 5522967792130a02a7e2fc8257ade6abb54e0f19 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:27:17 +0800 Subject: [PATCH 1/7] =?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; From 1cf70a627f553db8fa5bacb384e7036501e81ba2 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:36:17 +0800 Subject: [PATCH 2/7] =?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/routes/admin.js | 42 ++++++++++++++++++++++++++++++++++++++++-- web/admin/app.js | 33 +++++++++++++++++++++++++++++++++ web/admin/index.html | 38 ++++++++++++++++++++++++++++++++------ 3 files changed, 105 insertions(+), 8 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index e908dfe1..7280af03 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -531,7 +531,34 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res router.get('/claude-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await claudeAccountService.getAllAccounts(); - res.json({ success: true, data: accounts }); + + // 为每个账户添加使用统计信息 + const accountsWithStats = await Promise.all(accounts.map(async (account) => { + try { + const usageStats = await redis.getAccountUsageStats(account.id); + return { + ...account, + usage: { + daily: usageStats.daily, + total: usageStats.total, + averages: usageStats.averages + } + }; + } catch (statsError) { + logger.warn(`⚠️ Failed to get usage stats for account ${account.id}:`, statsError.message); + // 如果获取统计失败,返回空统计 + return { + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + }; + } + })); + + res.json({ success: true, data: accountsWithStats }); } catch (error) { logger.error('❌ Failed to get Claude accounts:', error); res.status(500).json({ error: 'Failed to get Claude accounts', message: error.message }); @@ -718,7 +745,18 @@ router.post('/gemini-accounts/exchange-code', authenticateAdmin, async (req, res router.get('/gemini-accounts', authenticateAdmin, async (req, res) => { try { const accounts = await geminiAccountService.getAllAccounts(); - res.json({ success: true, data: accounts }); + + // 为Gemini账户添加空的使用统计(暂时) + const accountsWithStats = accounts.map(account => ({ + ...account, + usage: { + daily: { tokens: 0, requests: 0, allTokens: 0 }, + total: { tokens: 0, requests: 0, allTokens: 0 }, + averages: { rpm: 0, tpm: 0 } + } + })); + + res.json({ success: true, data: accountsWithStats }); } catch (error) { logger.error('❌ Failed to get Gemini accounts:', error); res.status(500).json({ error: 'Failed to get accounts', message: error.message }); diff --git a/web/admin/app.js b/web/admin/app.js index be8421cd..41a4bbb1 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -192,6 +192,7 @@ const app = createApp({ // 账户 accounts: [], accountsLoading: false, + accountSortBy: 'dailyTokens', // 默认按今日Token排序 showCreateAccountModal: false, createAccountLoading: false, accountForm: { @@ -1868,6 +1869,9 @@ const app = createApp({ account.boundApiKeysCount = this.apiKeys.filter(key => key.geminiAccountId === account.id).length; } }); + + // 加载完成后自动排序 + this.sortAccounts(); } catch (error) { console.error('Failed to load accounts:', error); } finally { @@ -1875,6 +1879,35 @@ const app = createApp({ } }, + // 账户排序 + sortAccounts() { + if (!this.accounts || this.accounts.length === 0) return; + + this.accounts.sort((a, b) => { + switch (this.accountSortBy) { + case 'name': + return a.name.localeCompare(b.name); + case 'dailyTokens': + const aTokens = (a.usage && a.usage.daily && a.usage.daily.allTokens) || 0; + const bTokens = (b.usage && b.usage.daily && b.usage.daily.allTokens) || 0; + return bTokens - aTokens; // 降序 + case 'dailyRequests': + const aRequests = (a.usage && a.usage.daily && a.usage.daily.requests) || 0; + const bRequests = (b.usage && b.usage.daily && b.usage.daily.requests) || 0; + return bRequests - aRequests; // 降序 + case 'totalTokens': + const aTotalTokens = (a.usage && a.usage.total && a.usage.total.allTokens) || 0; + const bTotalTokens = (b.usage && b.usage.total && b.usage.total.allTokens) || 0; + return bTotalTokens - aTotalTokens; // 降序 + case 'lastUsed': + const aLastUsed = a.lastUsedAt ? new Date(a.lastUsedAt) : new Date(0); + const bLastUsed = b.lastUsedAt ? new Date(b.lastUsedAt) : new Date(0); + return bLastUsed - aLastUsed; // 降序(最近使用的在前) + default: + return 0; + } + }); + }, async loadModelStats() { this.modelStatsLoading = true; diff --git a/web/admin/index.html b/web/admin/index.html index 8003b1b3..656e8f39 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -922,12 +922,21 @@

账户管理

管理您的 Claude 和 Gemini 账户及代理配置

- +
+ + +
@@ -952,6 +961,7 @@ 类型 状态 代理 + 今日使用 最后使用 操作 @@ -1024,6 +1034,22 @@
无代理
+ +
+
+
+ {{ account.usage.daily.requests || 0 }} 次 +
+
+
+ {{ formatNumber(account.usage.daily.allTokens || 0) }} tokens +
+
+ 平均 {{ account.usage.averages.rpm.toFixed(2) }} RPM +
+
+
暂无数据
+ {{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }} From 53e0577e1933c69bbdc0a8a599956c67d30b4921 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 21:48:54 +0800 Subject: [PATCH 3/7] =?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?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/openaiClaudeRoutes.js | 6 ++++-- src/services/apiKeyService.js | 15 ++++++++++----- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/routes/openaiClaudeRoutes.js b/src/routes/openaiClaudeRoutes.js index b72c1257..247fc127 100644 --- a/src/routes/openaiClaudeRoutes.js +++ b/src/routes/openaiClaudeRoutes.js @@ -258,7 +258,8 @@ async function handleChatCompletion(req, res, apiKeyData) { outputTokens, cacheCreateTokens, cacheReadTokens, - model + model, + accountId ).catch(error => { logger.error('❌ Failed to record usage:', error); }); @@ -327,7 +328,8 @@ async function handleChatCompletion(req, res, apiKeyData) { usage.output_tokens || 0, usage.cache_creation_input_tokens || 0, usage.cache_read_input_tokens || 0, - claudeRequest.model + claudeRequest.model, + accountId ).catch(error => { logger.error('❌ Failed to record usage:', error); }); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 7c397904..e53976c2 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -249,11 +249,16 @@ class ApiKeyService { 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`); + // 记录账户级别的使用统计(只统计实际处理请求的账户) + if (accountId) { + await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); + } else if (keyData.claudeAccountId) { + // 如果没有传入accountId,但API Key绑定了专属账户,也记录统计 + await redis.incrementAccountUsage(keyData.claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + logger.database(`📊 Recorded account usage (from API Key binding): ${keyData.claudeAccountId} - ${totalTokens} tokens (API Key: ${keyId})`); + } else { + logger.debug(`⚠️ No accountId provided and API Key not bound to account, skipping account-level statistics`); } } From 578d3ca34bd4b299007b56ea6271b43fa4441031 Mon Sep 17 00:00:00 2001 From: leslie Date: Fri, 25 Jul 2025 22:08:30 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E9=87=8D=E5=A4=8D=E8=AE=A1=E6=AC=A1?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/apiKeyService.js | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e53976c2..052aef6e 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -253,12 +253,8 @@ class ApiKeyService { if (accountId) { await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); - } else if (keyData.claudeAccountId) { - // 如果没有传入accountId,但API Key绑定了专属账户,也记录统计 - await redis.incrementAccountUsage(keyData.claudeAccountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); - logger.database(`📊 Recorded account usage (from API Key binding): ${keyData.claudeAccountId} - ${totalTokens} tokens (API Key: ${keyId})`); } else { - logger.debug(`⚠️ No accountId provided and API Key not bound to account, skipping account-level statistics`); + logger.debug(`⚠️ No accountId provided for usage recording, skipping account-level statistics`); } } From fb306242c24adc1bf4e9c823ad71451270bcfbf3 Mon Sep 17 00:00:00 2001 From: csdbit Date: Sat, 26 Jul 2025 01:53:23 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E7=BB=99API=20Keys=E5=92=8C=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=85=B3=E9=94=AE=E5=AD=97=E6=AE=B5=E6=8E=92=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/app.js | 118 +++++++++++++++++++++++++++++++++++++++++++ web/admin/index.html | 61 ++++++++++++++++++---- 2 files changed, 168 insertions(+), 11 deletions(-) diff --git a/web/admin/app.js b/web/admin/app.js index be8421cd..3390dd13 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -112,6 +112,8 @@ const app = createApp({ apiKeys: [], apiKeysLoading: false, apiKeyStatsTimeRange: 'all', // API Key统计时间范围:all, 7days, monthly + apiKeysSortBy: '', // 当前排序字段 + apiKeysSortOrder: 'asc', // 排序顺序 'asc' 或 'desc' showCreateApiKeyModal: false, createApiKeyLoading: false, apiKeyForm: { @@ -192,6 +194,8 @@ const app = createApp({ // 账户 accounts: [], accountsLoading: false, + accountsSortBy: '', // 当前排序字段 + accountsSortOrder: 'asc', // 排序顺序 'asc' 或 'desc' showCreateAccountModal: false, createAccountLoading: false, accountForm: { @@ -295,6 +299,83 @@ const app = createApp({ return `${window.location.protocol}//${window.location.host}/api/`; }, + // 排序后的账户列表 + sortedAccounts() { + if (!this.accountsSortBy) { + return this.accounts; + } + + return [...this.accounts].sort((a, b) => { + let aValue = a[this.accountsSortBy]; + let bValue = b[this.accountsSortBy]; + + // 特殊处理状态字段 + if (this.accountsSortBy === 'status') { + aValue = a.isActive ? 1 : 0; + bValue = b.isActive ? 1 : 0; + } + + // 处理字符串比较 + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + + // 排序 + if (this.accountsSortOrder === 'asc') { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }); + }, + + // 排序后的API Keys列表 + sortedApiKeys() { + if (!this.apiKeysSortBy) { + return this.apiKeys; + } + + return [...this.apiKeys].sort((a, b) => { + let aValue, bValue; + + // 特殊处理不同字段 + switch (this.apiKeysSortBy) { + case 'status': + aValue = a.isActive ? 1 : 0; + bValue = b.isActive ? 1 : 0; + break; + case 'cost': + // 计算费用,转换为数字比较 + aValue = this.calculateApiKeyCostNumber(a.usage); + bValue = this.calculateApiKeyCostNumber(b.usage); + break; + case 'createdAt': + case 'expiresAt': + // 日期比较 + aValue = a[this.apiKeysSortBy] ? new Date(a[this.apiKeysSortBy]).getTime() : 0; + bValue = b[this.apiKeysSortBy] ? new Date(b[this.apiKeysSortBy]).getTime() : 0; + break; + default: + aValue = a[this.apiKeysSortBy]; + bValue = b[this.apiKeysSortBy]; + + // 处理字符串比较 + if (typeof aValue === 'string' && typeof bValue === 'string') { + aValue = aValue.toLowerCase(); + bValue = bValue.toLowerCase(); + } + } + + // 排序 + if (this.apiKeysSortOrder === 'asc') { + return aValue > bValue ? 1 : aValue < bValue ? -1 : 0; + } else { + return aValue < bValue ? 1 : aValue > bValue ? -1 : 0; + } + }); + }, + // 获取专属账号列表 dedicatedAccounts() { return this.accounts.filter(account => @@ -399,6 +480,30 @@ const app = createApp({ }, methods: { + // 账户列表排序 + sortAccounts(field) { + if (this.accountsSortBy === field) { + // 如果点击的是当前排序字段,切换排序顺序 + this.accountsSortOrder = this.accountsSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // 如果点击的是新字段,设置为升序 + this.accountsSortBy = field; + this.accountsSortOrder = 'asc'; + } + }, + + // API Keys列表排序 + sortApiKeys(field) { + if (this.apiKeysSortBy === field) { + // 如果点击的是当前排序字段,切换排序顺序 + this.apiKeysSortOrder = this.apiKeysSortOrder === 'asc' ? 'desc' : 'asc'; + } else { + // 如果点击的是新字段,设置为升序 + this.apiKeysSortBy = field; + this.apiKeysSortOrder = 'asc'; + } + }, + // 从URL读取tab参数并设置activeTab initializeTabFromUrl() { const urlParams = new URLSearchParams(window.location.search); @@ -3150,6 +3255,19 @@ const app = createApp({ // 如果没有后端费用数据,返回默认值 return '$0.000000'; }, + + // 计算API Key费用数值(用于排序) + calculateApiKeyCostNumber(usage) { + if (!usage || !usage.total) return 0; + + // 使用后端返回的准确费用数据 + if (usage.total.cost) { + return usage.total.cost; + } + + // 如果没有后端费用数据,返回0 + return 0; + }, // 初始化日期筛选器 initializeDateFilter() { diff --git a/web/admin/index.html b/web/admin/index.html index 8003b1b3..e5b9d7cf 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -575,17 +575,40 @@ - + - - - - + + + + -
名称 + 名称 + + + API Key状态使用统计创建时间过期时间 + 状态 + + + + 使用统计 + + (费用 + + ) + + + 创建时间 + + + + 过期时间 + + + 操作