From 4d7847189131f74b2fa9afd38c98e3a58491b668 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Sep 2025 12:04:15 +0000 Subject: [PATCH 1/5] chore: sync VERSION file with release v1.1.145 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 997fdebe..032f5c72 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.144 +1.1.145 From ec28b66e7f1a4331edfcd0d519827c52d4d9fc3c Mon Sep 17 00:00:00 2001 From: itzhan <2802965114@qq.com> Date: Fri, 19 Sep 2025 21:57:24 +0800 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=E7=BB=99key=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=80=BB=E7=94=A8=E9=87=8F=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test-total-usage-limit.js | 150 ++++++++++++++++++ src/middleware/auth.js | 49 ++++++ src/models/redis.js | 42 +++++ src/routes/admin.js | 61 +++++++ src/routes/apiStats.js | 7 + src/routes/userRoutes.js | 43 ++++- src/services/apiKeyService.js | 99 +++++++++++- .../apikeys/BatchEditApiKeyModal.vue | 18 +++ .../components/apikeys/CreateApiKeyModal.vue | 56 ++++++- .../components/apikeys/EditApiKeyModal.vue | 55 +++++++ .../components/apikeys/UsageDetailModal.vue | 32 ++++ 11 files changed, 604 insertions(+), 8 deletions(-) create mode 100644 scripts/test-total-usage-limit.js diff --git a/scripts/test-total-usage-limit.js b/scripts/test-total-usage-limit.js new file mode 100644 index 00000000..c5c50bdd --- /dev/null +++ b/scripts/test-total-usage-limit.js @@ -0,0 +1,150 @@ +#!/usr/bin/env node + +const path = require('path') +const Module = require('module') + +const originalResolveFilename = Module._resolveFilename +Module._resolveFilename = function resolveConfig(request, parent, isMain, options) { + if (request.endsWith('/config/config')) { + return path.resolve(__dirname, '../config/config.example.js') + } + return originalResolveFilename.call(this, request, parent, isMain, options) +} + +const redis = require('../src/models/redis') +const apiKeyService = require('../src/services/apiKeyService') +const { authenticateApiKey } = require('../src/middleware/auth') + +Module._resolveFilename = originalResolveFilename + +function createMockReq(apiKey) { + return { + headers: { + 'x-api-key': apiKey, + 'user-agent': 'total-usage-limit-test' + }, + query: {}, + body: {}, + ip: '127.0.0.1', + connection: {}, + originalUrl: '/test-total-usage-limit', + once: () => {}, + on: () => {}, + get(header) { + return this.headers[header.toLowerCase()] || '' + } + } +} + +function createMockRes() { + const state = { + status: 200, + body: null + } + + return { + once: () => {}, + on: () => {}, + status(code) { + state.status = code + return this + }, + json(payload) { + state.body = payload + return this + }, + getState() { + return state + } + } +} + +async function runAuth(apiKey) { + const req = createMockReq(apiKey) + const res = createMockRes() + let nextCalled = false + + await authenticateApiKey(req, res, () => { + nextCalled = true + }) + + const result = res.getState() + if (nextCalled && result.status === 200) { + return { status: 200, body: null } + } + return result +} + +async function cleanupKey(keyId) { + const client = redis.getClient() + if (!client) { + return + } + + try { + await redis.deleteApiKey(keyId) + const usageKeys = await client.keys(`usage:*:${keyId}*`) + if (usageKeys.length > 0) { + await client.del(...usageKeys) + } + const costKeys = await client.keys(`usage:cost:*:${keyId}*`) + if (costKeys.length > 0) { + await client.del(...costKeys) + } + await client.del(`usage:${keyId}`) + await client.del(`usage:records:${keyId}`) + await client.del(`usage:cost:total:${keyId}`) + } catch (error) { + console.warn(`Failed to cleanup test key ${keyId}:`, error.message) + } +} + +async function main() { + await redis.connect() + + const testName = `TotalUsageLimitTest-${Date.now()}` + const totalLimit = 1.0 + const newKey = await apiKeyService.generateApiKey({ + name: testName, + permissions: 'all', + totalUsageLimit: totalLimit + }) + + const keyId = newKey.id + const apiKey = newKey.apiKey + + console.log(`➕ Created test API key ${keyId} with total usage limit $${totalLimit}`) + + let authResult = await runAuth(apiKey) + if (authResult.status !== 200) { + throw new Error(`Expected success before any usage, got status ${authResult.status}`) + } + console.log('✅ Authentication succeeds before consuming quota') + + await redis.incrementDailyCost(keyId, 0.6) + authResult = await runAuth(apiKey) + if (authResult.status !== 200) { + throw new Error(`Expected success under quota, got status ${authResult.status}`) + } + console.log('✅ Authentication succeeds while still under quota ($0.60)') + + await redis.incrementDailyCost(keyId, 0.5) + authResult = await runAuth(apiKey) + if (authResult.status !== 429) { + throw new Error(`Expected 429 after exceeding quota, got status ${authResult.status}`) + } + console.log('✅ Authentication returns 429 after exceeding total usage limit ($1.10)') + + await cleanupKey(keyId) + await redis.disconnect() + + console.log('🎉 Total usage limit test completed successfully') +} + +main().catch(async (error) => { + console.error('❌ Total usage limit test failed:', error) + try { + await redis.disconnect() + } catch (_) {} + process.exitCode = 1 +}) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 21512645..474802d3 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -308,6 +308,29 @@ const authenticateApiKey = async (req, res, next) => { } } + // 检查总额度限制(基于累计费用) + const totalUsageLimit = Number(validation.keyData.totalUsageLimit || 0) + if (totalUsageLimit > 0) { + const totalCost = Number(validation.keyData.totalCost || 0) + + if (totalCost >= totalUsageLimit) { + logger.security( + `📉 Total usage limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` + ) + + return res.status(429).json({ + error: 'Total usage limit exceeded', + message: `已达到总额度限制 ($${totalUsageLimit.toFixed(2)})`, + currentCost: totalCost, + costLimit: totalUsageLimit + }) + } + + logger.api( + `📉 Total usage for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` + ) + } + // 检查每日费用限制 const dailyCostLimit = validation.keyData.dailyCostLimit || 0 if (dailyCostLimit > 0) { @@ -333,6 +356,29 @@ const authenticateApiKey = async (req, res, next) => { ) } + // 检查总费用限制 + const totalCostLimit = validation.keyData.totalCostLimit || 0 + if (totalCostLimit > 0) { + const totalCost = validation.keyData.totalCost || 0 + + if (totalCost >= totalCostLimit) { + logger.security( + `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + + return res.status(429).json({ + error: 'Total cost limit exceeded', + message: `已达到总费用限制 ($${totalCostLimit})`, + currentCost: totalCost, + costLimit: totalCostLimit + }) + } + + logger.api( + `💰 Total cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` + ) + } + // 检查 Opus 周费用限制(仅对 Opus 模型生效) const weeklyOpusCostLimit = validation.keyData.weeklyOpusCostLimit || 0 if (weeklyOpusCostLimit > 0) { @@ -394,6 +440,9 @@ const authenticateApiKey = async (req, res, next) => { allowedClients: validation.keyData.allowedClients, dailyCostLimit: validation.keyData.dailyCostLimit, dailyCost: validation.keyData.dailyCost, + totalUsageLimit: validation.keyData.totalUsageLimit, + totalCostLimit: validation.keyData.totalCostLimit, + totalCost: validation.keyData.totalCost, usage: validation.keyData.usage } req.usage = validation.keyData.usage diff --git a/src/models/redis.js b/src/models/redis.js index 7d671162..7e286bf5 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -636,6 +636,48 @@ class RedisClient { } } + async addUsageRecord(keyId, record, maxRecords = 200) { + const listKey = `usage:records:${keyId}` + const client = this.getClientSafe() + + try { + await client + .multi() + .lpush(listKey, JSON.stringify(record)) + .ltrim(listKey, 0, Math.max(0, maxRecords - 1)) + .expire(listKey, 86400 * 90) // 默认保留90天 + .exec() + } catch (error) { + logger.error(`❌ Failed to append usage record for key ${keyId}:`, error) + } + } + + async getUsageRecords(keyId, limit = 50) { + const listKey = `usage:records:${keyId}` + const client = this.getClient() + + if (!client) { + return [] + } + + try { + const rawRecords = await client.lrange(listKey, 0, Math.max(0, limit - 1)) + return rawRecords + .map((entry) => { + try { + return JSON.parse(entry) + } catch (error) { + logger.warn('⚠️ Failed to parse usage record entry:', error) + return null + } + }) + .filter(Boolean) + } catch (error) { + logger.error(`❌ Failed to load usage records for key ${keyId}:`, error) + return [] + } + } + // 💰 获取当日费用 async getDailyCost(keyId) { const today = getDateStringInTimezone() diff --git a/src/routes/admin.js b/src/routes/admin.js index 1b4a93d1..0de4db75 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -533,6 +533,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + totalUsageLimit, + totalCostLimit, weeklyOpusCostLimit, tags, activationDays, // 新增:激活后有效天数 @@ -615,6 +617,35 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'All tags must be non-empty strings' }) } + if ( + totalUsageLimit !== undefined && + totalUsageLimit !== null && + totalUsageLimit !== '' + ) { + const usageLimit = Number(totalUsageLimit) + if (Number.isNaN(usageLimit) || usageLimit < 0) { + return res.status(400).json({ error: 'Total usage limit must be a non-negative number' }) + } + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + // 验证激活相关字段 if (expirationMode && !['fixed', 'activation'].includes(expirationMode)) { return res @@ -660,6 +691,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + totalUsageLimit, + totalCostLimit, weeklyOpusCostLimit, tags, activationDays, @@ -699,6 +732,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + totalUsageLimit, + totalCostLimit, weeklyOpusCostLimit, tags, activationDays, @@ -748,6 +783,8 @@ router.post('/api-keys/batch', authenticateAdmin, async (req, res) => { enableClientRestriction, allowedClients, dailyCostLimit, + totalUsageLimit, + totalCostLimit, weeklyOpusCostLimit, tags, activationDays, @@ -865,6 +902,12 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.dailyCostLimit !== undefined) { finalUpdates.dailyCostLimit = updates.dailyCostLimit } + if (updates.totalUsageLimit !== undefined) { + finalUpdates.totalUsageLimit = updates.totalUsageLimit + } + if (updates.totalCostLimit !== undefined) { + finalUpdates.totalCostLimit = updates.totalCostLimit + } if (updates.weeklyOpusCostLimit !== undefined) { finalUpdates.weeklyOpusCostLimit = updates.weeklyOpusCostLimit } @@ -993,6 +1036,8 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { allowedClients, expiresAt, dailyCostLimit, + totalUsageLimit, + totalCostLimit, weeklyOpusCostLimit, tags, ownerId // 新增:所有者ID字段 @@ -1142,6 +1187,22 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.dailyCostLimit = costLimit } + if (totalCostLimit !== undefined && totalCostLimit !== null && totalCostLimit !== '') { + const costLimit = Number(totalCostLimit) + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Total cost limit must be a non-negative number' }) + } + updates.totalCostLimit = costLimit + } + + if (totalUsageLimit !== undefined && totalUsageLimit !== null && totalUsageLimit !== '') { + const usageLimit = Number(totalUsageLimit) + if (Number.isNaN(usageLimit) || usageLimit < 0) { + return res.status(400).json({ error: 'Total usage limit must be a non-negative number' }) + } + updates.totalUsageLimit = usageLimit + } + // 处理 Opus 周费用限制 if ( weeklyOpusCostLimit !== undefined && diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 6be3bf99..2ebc32b7 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -114,6 +114,7 @@ router.post('/api/user-stats', async (req, res) => { // 获取当日费用统计 const dailyCost = await redis.getDailyCost(keyId) + const costStats = await redis.getCostStats(keyId) // 处理数据格式,与 validateApiKey 返回的格式保持一致 // 解析限制模型数据 @@ -140,7 +141,10 @@ router.post('/api/user-stats', async (req, res) => { rateLimitWindow: parseInt(keyData.rateLimitWindow) || 0, rateLimitRequests: parseInt(keyData.rateLimitRequests) || 0, dailyCostLimit: parseFloat(keyData.dailyCostLimit) || 0, + totalUsageLimit: parseFloat(keyData.totalUsageLimit) || 0, + totalCostLimit: parseFloat(keyData.totalCostLimit) || 0, dailyCost: dailyCost || 0, + totalCost: costStats.total || 0, enableModelRestriction: keyData.enableModelRestriction === 'true', restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', @@ -372,11 +376,14 @@ router.post('/api/user-stats', async (req, res) => { rateLimitRequests: fullKeyData.rateLimitRequests || 0, rateLimitCost: parseFloat(fullKeyData.rateLimitCost) || 0, // 新增:费用限制 dailyCostLimit: fullKeyData.dailyCostLimit || 0, + totalUsageLimit: fullKeyData.totalUsageLimit || 0, + totalCostLimit: fullKeyData.totalCostLimit || 0, // 当前使用量 currentWindowRequests, currentWindowTokens, currentWindowCost, // 新增:当前窗口费用 currentDailyCost, + currentTotalCost: totalCost, // 时间窗口信息 windowStartTime, windowEndTime, diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index 4952dd5e..b6011e54 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -258,6 +258,9 @@ router.get('/api-keys', authenticateUser, async (req, res) => { usage: flatUsage, dailyCost: key.dailyCost, dailyCostLimit: key.dailyCostLimit, + totalUsageLimit: key.totalUsageLimit, + totalCost: key.totalCost, + totalCostLimit: key.totalCostLimit, // 不返回实际的key值,只返回前缀和后几位 keyPreview: key.key ? `${key.key.substring(0, 8)}...${key.key.substring(key.key.length - 4)}` @@ -287,7 +290,15 @@ router.get('/api-keys', authenticateUser, async (req, res) => { // 🔑 创建新的API Key router.post('/api-keys', authenticateUser, async (req, res) => { try { - const { name, description, tokenLimit, expiresAt, dailyCostLimit } = req.body + const { + name, + description, + tokenLimit, + expiresAt, + dailyCostLimit, + totalUsageLimit, + totalCostLimit + } = req.body if (!name || !name.trim()) { return res.status(400).json({ @@ -296,6 +307,32 @@ router.post('/api-keys', authenticateUser, async (req, res) => { }) } + if ( + totalCostLimit !== undefined && + totalCostLimit !== null && + totalCostLimit !== '' && + (Number.isNaN(Number(totalCostLimit)) || Number(totalCostLimit) < 0) + ) { + return res.status(400).json({ + error: 'Invalid total cost limit', + message: 'Total cost limit must be a non-negative number' + }) + } + + if ( + totalUsageLimit !== undefined && + totalUsageLimit !== null && + totalUsageLimit !== '' + ) { + const usageLimit = Number(totalUsageLimit) + if (Number.isNaN(usageLimit) || usageLimit < 0) { + return res.status(400).json({ + error: 'Invalid total usage limit', + message: 'Total usage limit must be a non-negative number' + }) + } + } + // 检查用户API Key数量限制 const userApiKeys = await apiKeyService.getUserApiKeys(req.user.id) if (userApiKeys.length >= config.userManagement.maxApiKeysPerUser) { @@ -314,6 +351,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => { tokenLimit: tokenLimit || null, expiresAt: expiresAt || null, dailyCostLimit: dailyCostLimit || null, + totalUsageLimit: totalUsageLimit || null, + totalCostLimit: totalCostLimit || null, createdBy: 'user', // 设置服务权限为全部服务,确保前端显示“服务权限”为“全部服务”且具备完整访问权限 permissions: 'all' @@ -337,6 +376,8 @@ router.post('/api-keys', authenticateUser, async (req, res) => { tokenLimit: newApiKey.tokenLimit, expiresAt: newApiKey.expiresAt, dailyCostLimit: newApiKey.dailyCostLimit, + totalUsageLimit: newApiKey.totalUsageLimit, + totalCostLimit: newApiKey.totalCostLimit, createdAt: newApiKey.createdAt } }) diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e27aafd5..b46d9ab6 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -33,6 +33,8 @@ class ApiKeyService { enableClientRestriction = false, allowedClients = [], dailyCostLimit = 0, + totalUsageLimit = 0, + totalCostLimit = 0, weeklyOpusCostLimit = 0, tags = [], activationDays = 0, // 新增:激活后有效天数(0表示不使用此功能) @@ -68,6 +70,8 @@ class ApiKeyService { enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), dailyCostLimit: String(dailyCostLimit || 0), + totalUsageLimit: String(totalUsageLimit || 0), + totalCostLimit: String(totalCostLimit || 0), weeklyOpusCostLimit: String(weeklyOpusCostLimit || 0), tags: JSON.stringify(tags || []), activationDays: String(activationDays || 0), // 新增:激活后有效天数 @@ -111,6 +115,8 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: JSON.parse(keyData.allowedClients || '[]'), dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), tags: JSON.parse(keyData.tags || '[]'), activationDays: parseInt(keyData.activationDays || 0), @@ -188,8 +194,12 @@ class ApiKeyService { // 获取使用统计(供返回数据使用) const usage = await redis.getUsageStats(keyData.id) - // 获取当日费用统计 - const dailyCost = await redis.getDailyCost(keyData.id) + // 获取费用统计 + const [dailyCost, costStats] = await Promise.all([ + redis.getDailyCost(keyData.id), + redis.getCostStats(keyData.id) + ]) + const totalCost = costStats?.total || 0 // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) // 注意:lastUsedAt的更新已移至recordUsage方法中 @@ -245,8 +255,11 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), dailyCost: dailyCost || 0, + totalCost, weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, tags, usage @@ -306,7 +319,10 @@ class ApiKeyService { } // 获取当日费用 - const dailyCost = (await redis.getDailyCost(keyData.id)) || 0 + const [dailyCost, costStats] = await Promise.all([ + redis.getDailyCost(keyData.id), + redis.getCostStats(keyData.id) + ]) // 获取使用统计 const usage = await redis.getUsageStats(keyData.id) @@ -365,8 +381,11 @@ class ApiKeyService { enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients, dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0), weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), dailyCost: dailyCost || 0, + totalCost: costStats?.total || 0, weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, tags, usage @@ -405,12 +424,17 @@ class ApiKeyService { key.rateLimitWindow = parseInt(key.rateLimitWindow || 0) key.rateLimitRequests = parseInt(key.rateLimitRequests || 0) key.rateLimitCost = parseFloat(key.rateLimitCost || 0) // 新增:速率限制费用字段 + key.totalUsageLimit = parseFloat(key.totalUsageLimit || 0) + if (Number.isNaN(key.totalUsageLimit)) { + key.totalUsageLimit = 0 + } key.currentConcurrency = await redis.getConcurrency(key.id) key.isActive = key.isActive === 'true' key.enableModelRestriction = key.enableModelRestriction === 'true' key.enableClientRestriction = key.enableClientRestriction === 'true' key.permissions = key.permissions || 'all' // 兼容旧数据 key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0) + key.totalCostLimit = parseFloat(key.totalCostLimit || 0) key.weeklyOpusCostLimit = parseFloat(key.weeklyOpusCostLimit || 0) key.dailyCost = (await redis.getDailyCost(key.id)) || 0 key.weeklyOpusCost = (await redis.getWeeklyOpusCost(key.id)) || 0 @@ -532,6 +556,8 @@ class ApiKeyService { 'enableClientRestriction', 'allowedClients', 'dailyCostLimit', + 'totalUsageLimit', + 'totalCostLimit', 'weeklyOpusCostLimit', 'tags', 'userId', // 新增:用户ID(所有者变更) @@ -827,6 +853,21 @@ class ApiKeyService { } } + // 记录单次请求的使用详情 + const usageCost = costInfo && costInfo.costs ? costInfo.costs.total || 0 : 0 + await redis.addUsageRecord(keyId, { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens, + cost: Number(usageCost.toFixed(6)), + costBreakdown: costInfo && costInfo.costs ? costInfo.costs : undefined + }) + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] if (cacheCreateTokens > 0) { logParts.push(`Cache Create: ${cacheCreateTokens}`) @@ -973,6 +1014,32 @@ class ApiKeyService { } } + const usageRecord = { + timestamp: new Date().toISOString(), + model, + accountId: accountId || null, + accountType: accountType || null, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + ephemeral5mTokens, + ephemeral1hTokens, + totalTokens, + cost: Number((costInfo.totalCost || 0).toFixed(6)), + costBreakdown: { + input: costInfo.inputCost || 0, + output: costInfo.outputCost || 0, + cacheCreate: costInfo.cacheCreateCost || 0, + cacheRead: costInfo.cacheReadCost || 0, + ephemeral5m: costInfo.ephemeral5mCost || 0, + ephemeral1h: costInfo.ephemeral1hCost || 0 + }, + isLongContext: costInfo.isLongContextRequest || false + } + + await redis.addUsageRecord(keyId, usageRecord) + const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`] if (cacheCreateTokens > 0) { logParts.push(`Cache Create: ${cacheCreateTokens}`) @@ -1014,8 +1081,24 @@ class ApiKeyService { } // 📈 获取使用统计 - async getUsageStats(keyId) { - return await redis.getUsageStats(keyId) + async getUsageStats(keyId, options = {}) { + const usageStats = await redis.getUsageStats(keyId) + + // options 可能是字符串(兼容旧接口),仅当为对象时才解析 + const optionObject = + options && typeof options === 'object' && !Array.isArray(options) ? options : {} + + if (optionObject.includeRecords === false) { + return usageStats + } + + const recordLimit = optionObject.recordLimit || 20 + const recentRecords = await redis.getUsageRecords(keyId, recordLimit) + + return { + ...usageStats, + recentRecords + } } // 📊 获取账户使用统计 @@ -1067,6 +1150,8 @@ class ApiKeyService { dailyCost, totalCost: costStats.total, dailyCostLimit: parseFloat(key.dailyCostLimit || 0), + totalUsageLimit: parseFloat(key.totalUsageLimit || 0), + totalCostLimit: parseFloat(key.totalCostLimit || 0), userId: key.userId, userUsername: key.userUsername, createdBy: key.createdBy, @@ -1112,7 +1197,9 @@ class ApiKeyService { userUsername: keyData.userUsername, createdBy: keyData.createdBy, permissions: keyData.permissions, - dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0) + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + totalUsageLimit: parseFloat(keyData.totalUsageLimit || 0), + totalCostLimit: parseFloat(keyData.totalCostLimit || 0) } } catch (error) { logger.error('❌ Failed to get API key by ID:', error) diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue index 284a6f4c..b6e1dce1 100644 --- a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue @@ -218,6 +218,20 @@ /> +
+ + +
+
+
+ +
+
+ + + + +
+ +

+ 设置此 API Key 的累计总费用限制,达到限制后将拒绝所有后续请求,0 或留空表示无限制 +

+
+
+
{ form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + totalUsageLimit: + form.totalUsageLimit !== '' && form.totalUsageLimit !== null + ? parseFloat(form.totalUsageLimit) + : 0, weeklyOpusCostLimit: form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 4da8d9e5..5e804165 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -273,6 +273,55 @@
+
+ +
+
+ + + + +
+ +

+ 设置此 API Key 的累计总费用限制,达到限制后将拒绝所有后续请求,0 或留空表示无限制 +

+
+
+
{ form.dailyCostLimit !== '' && form.dailyCostLimit !== null ? parseFloat(form.dailyCostLimit) : 0, + totalUsageLimit: + form.totalUsageLimit !== '' && form.totalUsageLimit !== null + ? parseFloat(form.totalUsageLimit) + : 0, weeklyOpusCostLimit: form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) @@ -1100,6 +1154,7 @@ onMounted(async () => { form.rateLimitRequests = props.apiKey.rateLimitRequests || '' form.concurrencyLimit = props.apiKey.concurrencyLimit || '' form.dailyCostLimit = props.apiKey.dailyCostLimit || '' + form.totalUsageLimit = props.apiKey.totalUsageLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' form.permissions = props.apiKey.permissions || 'all' // 处理 Claude 账号(区分 OAuth 和 Console) diff --git a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue index 93527223..b4f1199f 100644 --- a/web/admin-spa/src/components/apikeys/UsageDetailModal.vue +++ b/web/admin-spa/src/components/apikeys/UsageDetailModal.vue @@ -190,6 +190,31 @@
+
+
+ 总费用限制 + + ${{ apiKey.totalUsageLimit.toFixed(2) }} + +
+
+
+
+
+ 已使用 {{ totalUsagePercentage.toFixed(1) }}% +
+
+
@@ -250,6 +275,7 @@ const totalTokens = computed(() => props.apiKey.usage?.total?.tokens || 0) const dailyTokens = computed(() => props.apiKey.usage?.daily?.tokens || 0) const totalCost = computed(() => props.apiKey.usage?.total?.cost || 0) const dailyCost = computed(() => props.apiKey.dailyCost || 0) +const totalUsageLimit = computed(() => props.apiKey.totalUsageLimit || 0) const inputTokens = computed(() => props.apiKey.usage?.total?.inputTokens || 0) const outputTokens = computed(() => props.apiKey.usage?.total?.outputTokens || 0) const cacheCreateTokens = computed(() => props.apiKey.usage?.total?.cacheCreateTokens || 0) @@ -260,6 +286,7 @@ const tpm = computed(() => props.apiKey.usage?.averages?.tpm || 0) const hasLimits = computed(() => { return ( props.apiKey.dailyCostLimit > 0 || + props.apiKey.totalUsageLimit > 0 || props.apiKey.concurrencyLimit > 0 || props.apiKey.rateLimitWindow > 0 || props.apiKey.tokenLimit > 0 @@ -271,6 +298,11 @@ const dailyCostPercentage = computed(() => { return (dailyCost.value / props.apiKey.dailyCostLimit) * 100 }) +const totalUsagePercentage = computed(() => { + if (!totalUsageLimit.value || totalUsageLimit.value === 0) return 0 + return (totalCost.value / totalUsageLimit.value) * 100 +}) + // 方法 const formatNumber = (num) => { if (!num && num !== 0) return '0' From 200149b9ee481a3a9daf82a171e392b756ab419c Mon Sep 17 00:00:00 2001 From: itzhan <2802965114@qq.com> Date: Fri, 19 Sep 2025 22:41:46 +0800 Subject: [PATCH 3/5] chore: fix prettier formatting --- src/middleware/auth.js | 40 ++++++++--- src/models/redis.js | 42 ++++++++--- src/routes/admin.js | 127 ++++++++++++++++++++++++---------- src/routes/userRoutes.js | 6 +- src/services/apiKeyService.js | 12 +++- 5 files changed, 163 insertions(+), 64 deletions(-) diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 474802d3..8b64e8d8 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -120,7 +120,9 @@ const authenticateApiKey = async (req, res, next) => { // 如果超过限制,立即减少计数 await redis.decrConcurrency(validation.keyData.id) logger.security( - `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` + `🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: ${currentConcurrency - 1}, limit: ${concurrencyLimit}` ) return res.status(429).json({ error: 'Concurrency limit exceeded', @@ -275,7 +277,9 @@ const authenticateApiKey = async (req, res, next) => { const remainingMinutes = Math.ceil((resetTime - now) / 60000) logger.security( - `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` + `💰 Rate limit exceeded (cost) for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${currentCost.toFixed(2)}/$${rateLimitCost}` ) return res.status(429).json({ @@ -315,7 +319,9 @@ const authenticateApiKey = async (req, res, next) => { if (totalCost >= totalUsageLimit) { logger.security( - `📉 Total usage limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` + `📉 Total usage limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` ) return res.status(429).json({ @@ -327,7 +333,9 @@ const authenticateApiKey = async (req, res, next) => { } logger.api( - `📉 Total usage for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` + `📉 Total usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${totalCost.toFixed(2)}/$${totalUsageLimit.toFixed(2)}` ) } @@ -338,7 +346,9 @@ const authenticateApiKey = async (req, res, next) => { if (dailyCost >= dailyCostLimit) { logger.security( - `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + `💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` ) return res.status(429).json({ @@ -352,7 +362,9 @@ const authenticateApiKey = async (req, res, next) => { // 记录当前费用使用情况 logger.api( - `💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` + `💰 Cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}` ) } @@ -363,7 +375,9 @@ const authenticateApiKey = async (req, res, next) => { if (totalCost >= totalCostLimit) { logger.security( - `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` + `💰 Total cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${totalCost.toFixed(2)}/$${totalCostLimit}` ) return res.status(429).json({ @@ -375,7 +389,9 @@ const authenticateApiKey = async (req, res, next) => { } logger.api( - `💰 Total cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` + `💰 Total cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${totalCost.toFixed(2)}/$${totalCostLimit}` ) } @@ -392,7 +408,9 @@ const authenticateApiKey = async (req, res, next) => { if (weeklyOpusCost >= weeklyOpusCostLimit) { logger.security( - `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + `💰 Weekly Opus cost limit exceeded for key: ${validation.keyData.id} (${ + validation.keyData.name + }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` ) // 计算下周一的重置时间 @@ -414,7 +432,9 @@ const authenticateApiKey = async (req, res, next) => { // 记录当前 Opus 费用使用情况 logger.api( - `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` + `💰 Opus weekly cost usage for key: ${validation.keyData.id} (${ + validation.keyData.name + }), current: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` ) } } diff --git a/src/models/redis.js b/src/models/redis.js index 7e286bf5..65a89b54 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -20,7 +20,9 @@ function getDateInTimezone(date = new Date()) { function getDateStringInTimezone(date = new Date()) { const tzDate = getDateInTimezone(date) // 使用UTC方法获取偏移后的日期部分 - return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + return `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String( + tzDate.getUTCDate() + ).padStart(2, '0')}` } // 获取配置时区的小时 (0-23) @@ -219,7 +221,10 @@ class RedisClient { const now = new Date() const today = getDateStringInTimezone(now) const tzDate = getDateInTimezone(now) - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 新增小时级别 const daily = `usage:daily:${keyId}:${today}` @@ -414,7 +419,10 @@ class RedisClient { const now = new Date() const today = getDateStringInTimezone(now) const tzDate = getDateInTimezone(now) - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}` // 账户级别统计的键 @@ -551,7 +559,10 @@ class RedisClient { const today = getDateStringInTimezone() const dailyKey = `usage:daily:${keyId}:${today}` const tzDate = getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const monthlyKey = `usage:monthly:${keyId}:${currentMonth}` const [total, daily, monthly] = await Promise.all([ @@ -694,7 +705,10 @@ class RedisClient { async incrementDailyCost(keyId, amount) { const today = getDateStringInTimezone() const tzDate = getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` const dailyKey = `usage:cost:daily:${keyId}:${today}` @@ -724,7 +738,10 @@ class RedisClient { async getCostStats(keyId) { const today = getDateStringInTimezone() const tzDate = getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const currentHour = `${today}:${String(getHourInTimezone(new Date())).padStart(2, '0')}` const [daily, monthly, hourly, total] = await Promise.all([ @@ -827,7 +844,10 @@ class RedisClient { const today = getDateStringInTimezone() const accountDailyKey = `account_usage:daily:${accountId}:${today}` const tzDate = getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` const accountMonthlyKey = `account_usage:monthly:${accountId}:${currentMonth}` const [total, daily, monthly] = await Promise.all([ @@ -1463,14 +1483,18 @@ class RedisClient { if (remainingTTL < renewalThreshold) { await this.client.expire(key, fullTTL) logger.debug( - `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)` + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round( + remainingTTL / 60 + )}min, renewed to ${ttlHours}h)` ) return true } // 剩余时间充足,无需续期 logger.debug( - `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)` + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round( + remainingTTL / 60 + )}min)` ) return true } catch (error) { diff --git a/src/routes/admin.js b/src/routes/admin.js index 0de4db75..0ac09757 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -155,7 +155,10 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const currentDate = new Date(start) while (currentDate <= end) { const tzDate = redisClient.getDateInTimezone(currentDate) - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` searchPatterns.push(`usage:daily:*:${dateStr}`) currentDate.setDate(currentDate.getDate() + 1) } @@ -163,7 +166,10 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { // 今日 - 使用时区日期 const redisClient = require('../models/redis') const tzDate = redisClient.getDateInTimezone(now) - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` searchPatterns.push(`usage:daily:*:${dateStr}`) } else if (timeRange === '7days') { // 最近7天 @@ -172,14 +178,20 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const date = new Date(now) date.setDate(date.getDate() - i) const tzDate = redisClient.getDateInTimezone(date) - const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}-${String(tzDate.getUTCDate()).padStart(2, '0')}` searchPatterns.push(`usage:daily:*:${dateStr}`) } } else if (timeRange === 'monthly') { // 本月 const redisClient = require('../models/redis') const tzDate = redisClient.getDateInTimezone(now) - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` searchPatterns.push(`usage:monthly:*:${currentMonth}`) } @@ -299,7 +311,10 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const redisClient = require('../models/redis') const tzToday = redisClient.getDateStringInTimezone(now) const tzDate = redisClient.getDateInTimezone(now) - const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` let modelKeys = [] if (timeRange === 'custom' && startDate && endDate) { @@ -310,7 +325,9 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { while (currentDate <= end) { const tzDateForKey = redisClient.getDateInTimezone(currentDate) - const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` + const dateStr = `${tzDateForKey.getUTCFullYear()}-${String( + tzDateForKey.getUTCMonth() + 1 + ).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) modelKeys = modelKeys.concat(dayKeys) currentDate.setDate(currentDate.getDate() + 1) @@ -320,8 +337,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { timeRange === 'today' ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) : timeRange === '7days' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) - : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) } const modelStatsMap = new Map() @@ -617,11 +634,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: 'All tags must be non-empty strings' }) } - if ( - totalUsageLimit !== undefined && - totalUsageLimit !== null && - totalUsageLimit !== '' - ) { + if (totalUsageLimit !== undefined && totalUsageLimit !== null && totalUsageLimit !== '') { const usageLimit = Number(totalUsageLimit) if (Number.isNaN(usageLimit) || usageLimit < 0) { return res.status(400).json({ error: 'Total usage limit must be a non-negative number' }) @@ -1313,7 +1326,9 @@ router.patch('/api-keys/:keyId/expiration', authenticateAdmin, async (req, res) updates.expiresAt = newExpiresAt.toISOString() logger.success( - `🔓 API key manually activated by admin: ${keyId} (${keyData.name}), expires at ${newExpiresAt.toISOString()}` + `🔓 API key manually activated by admin: ${keyId} (${ + keyData.name + }), expires at ${newExpiresAt.toISOString()}` ) } else { return res.status(400).json({ @@ -1378,7 +1393,11 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => { // 参数验证 if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) { logger.warn( - `🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}` + `🚨 Invalid keyIds: ${JSON.stringify({ + keyIds, + type: typeof keyIds, + isArray: Array.isArray(keyIds) + })}` ) return res.status(400).json({ error: 'Invalid request', @@ -1842,13 +1861,13 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res codeLength: req.body.callbackUrl ? req.body.callbackUrl.length : req.body.authorizationCode - ? req.body.authorizationCode.length - : 0, + ? req.body.authorizationCode.length + : 0, codePrefix: req.body.callbackUrl ? `${req.body.callbackUrl.substring(0, 10)}...` : req.body.authorizationCode - ? `${req.body.authorizationCode.substring(0, 10)}...` - : 'N/A' + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' }) return res .status(500) @@ -1964,13 +1983,13 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy codeLength: req.body.callbackUrl ? req.body.callbackUrl.length : req.body.authorizationCode - ? req.body.authorizationCode.length - : 0, + ? req.body.authorizationCode.length + : 0, codePrefix: req.body.callbackUrl ? `${req.body.callbackUrl.substring(0, 10)}...` : req.body.authorizationCode - ? `${req.body.authorizationCode.substring(0, 10)}...` - : 'N/A' + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' }) return res .status(500) @@ -2399,7 +2418,9 @@ router.put( } logger.success( - `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` + `🔄 Admin toggled Claude account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` ) return res.json({ success: true, schedulable: newSchedulable }) } catch (error) { @@ -2687,7 +2708,9 @@ router.put('/claude-console-accounts/:accountId/toggle', authenticateAdmin, asyn await claudeConsoleAccountService.updateAccount(accountId, { isActive: newStatus }) logger.success( - `🔄 Admin toggled Claude Console account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}` + `🔄 Admin toggled Claude Console account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` ) return res.json({ success: true, isActive: newStatus }) } catch (error) { @@ -2728,7 +2751,9 @@ router.put( } logger.success( - `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` + `🔄 Admin toggled Claude Console account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` ) return res.json({ success: true, schedulable: newSchedulable }) } catch (error) { @@ -3113,7 +3138,9 @@ router.put('/ccr-accounts/:accountId/toggle-schedulable', authenticateAdmin, asy } logger.success( - `🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` + `🔄 Admin toggled CCR account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` ) return res.json({ success: true, schedulable: newSchedulable }) } catch (error) { @@ -3436,7 +3463,9 @@ router.put('/bedrock-accounts/:accountId/toggle', authenticateAdmin, async (req, } logger.success( - `🔄 Admin toggled Bedrock account status: ${accountId} -> ${newStatus ? 'active' : 'inactive'}` + `🔄 Admin toggled Bedrock account status: ${accountId} -> ${ + newStatus ? 'active' : 'inactive' + }` ) return res.json({ success: true, isActive: newStatus }) } catch (error) { @@ -3485,7 +3514,9 @@ router.put( } logger.success( - `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${newSchedulable ? 'schedulable' : 'not schedulable'}` + `🔄 Admin toggled Bedrock account schedulable status: ${accountId} -> ${ + newSchedulable ? 'schedulable' : 'not schedulable' + }` ) return res.json({ success: true, schedulable: newSchedulable }) } catch (error) { @@ -3908,7 +3939,9 @@ router.put( } logger.success( - `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${actualSchedulable ? 'schedulable' : 'not schedulable'}` + `🔄 Admin toggled Gemini account schedulable status: ${accountId} -> ${ + actualSchedulable ? 'schedulable' : 'not schedulable' + }` ) // 返回实际的数据库值,确保前端状态与后端一致 @@ -4383,7 +4416,10 @@ router.get('/model-stats', authenticateAdmin, async (req, res) => { const { period = 'daily', startDate, endDate } = req.query // daily, monthly, 支持自定义时间范围 const today = redis.getDateStringInTimezone() const tzDate = redis.getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` logger.info( `📊 Getting global model stats, period: ${period}, startDate: ${startDate}, endDate: ${endDate}, today: ${today}, currentMonth: ${currentMonth}` @@ -4854,7 +4890,10 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) = const client = redis.getClientSafe() const today = redis.getDateStringInTimezone() const tzDate = redis.getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` let searchPatterns = [] @@ -5385,7 +5424,10 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { const client = redis.getClientSafe() const today = redis.getDateStringInTimezone() const tzDate = redis.getDateInTimezone() - const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` + const currentMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart( + 2, + '0' + )}` let pattern if (period === 'today') { @@ -5401,7 +5443,9 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { const date = new Date() date.setDate(date.getDate() - i) const currentTzDate = redis.getDateInTimezone(date) - const dateStr = `${currentTzDate.getUTCFullYear()}-${String(currentTzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}` + const dateStr = `${currentTzDate.getUTCFullYear()}-${String( + currentTzDate.getUTCMonth() + 1 + ).padStart(2, '0')}-${String(currentTzDate.getUTCDate()).padStart(2, '0')}` const dayPattern = `usage:model:daily:*:${dateStr}` const dayKeys = await client.keys(dayPattern) @@ -5454,7 +5498,9 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { totalCosts.totalCost += costResult.costs.total logger.info( - `💰 Model ${model} (7days): ${usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens} tokens, cost: ${costResult.formatted.total}` + `💰 Model ${model} (7days): ${ + usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` ) // 记录模型费用 @@ -5542,7 +5588,12 @@ router.get('/usage-costs', authenticateAdmin, async (req, res) => { totalCosts.totalCost += costResult.costs.total logger.info( - `💰 Model ${model}: ${usage.inputTokens + usage.outputTokens + usage.cacheCreateTokens + usage.cacheReadTokens} tokens, cost: ${costResult.formatted.total}` + `💰 Model ${model}: ${ + usage.inputTokens + + usage.outputTokens + + usage.cacheCreateTokens + + usage.cacheReadTokens + } tokens, cost: ${costResult.formatted.total}` ) // 记录模型费用 @@ -6905,7 +6956,9 @@ router.post('/azure-openai-accounts', authenticateAdmin, async (req, res) => { // 测试连接 try { - const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${apiVersion || '2024-02-01'}` + const testUrl = `${azureEndpoint}/openai/deployments/${deploymentName}?api-version=${ + apiVersion || '2024-02-01' + }` await axios.get(testUrl, { headers: { 'api-key': apiKey diff --git a/src/routes/userRoutes.js b/src/routes/userRoutes.js index b6011e54..97381340 100644 --- a/src/routes/userRoutes.js +++ b/src/routes/userRoutes.js @@ -319,11 +319,7 @@ router.post('/api-keys', authenticateUser, async (req, res) => { }) } - if ( - totalUsageLimit !== undefined && - totalUsageLimit !== null && - totalUsageLimit !== '' - ) { + if (totalUsageLimit !== undefined && totalUsageLimit !== null && totalUsageLimit !== '') { const usageLimit = Number(totalUsageLimit) if (Number.isNaN(usageLimit) || usageLimit < 0) { return res.status(400).json({ diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index b46d9ab6..868c2186 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -168,7 +168,9 @@ class ApiKeyService { await redis.setApiKey(keyData.id, keyData) logger.success( - `🔓 API key activated: ${keyData.id} (${keyData.name}), will expire in ${activationDays} days at ${expiresAt.toISOString()}` + `🔓 API key activated: ${keyData.id} (${ + keyData.name + }), will expire in ${activationDays} days at ${expiresAt.toISOString()}` ) } @@ -903,7 +905,9 @@ class ApiKeyService { // 记录 Opus 周费用 await redis.incrementWeeklyOpusCost(keyId, cost) logger.database( - `💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed(6)}, model: ${model}, account type: ${accountType}` + `💰 Recorded Opus weekly cost for ${keyId}: $${cost.toFixed( + 6 + )}, model: ${model}, account type: ${accountType}` ) } catch (error) { logger.error('❌ Failed to record Opus cost:', error) @@ -978,7 +982,9 @@ class ApiKeyService { // 记录详细的缓存费用(如果有) if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) { logger.database( - `💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` + `💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed( + 6 + )}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}` ) } } else { From a929ff42429d3e6ede8281918e5fec04d8c2c0d4 Mon Sep 17 00:00:00 2001 From: itzhan <2802965114@qq.com> Date: Sat, 20 Sep 2025 08:27:41 +0800 Subject: [PATCH 4/5] chore: fix prettier formatting --- src/routes/admin.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 0ac09757..2f31a53c 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -337,8 +337,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { timeRange === 'today' ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) : timeRange === '7days' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) - : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) } const modelStatsMap = new Map() @@ -1861,13 +1861,13 @@ router.post('/claude-accounts/exchange-code', authenticateAdmin, async (req, res codeLength: req.body.callbackUrl ? req.body.callbackUrl.length : req.body.authorizationCode - ? req.body.authorizationCode.length - : 0, + ? req.body.authorizationCode.length + : 0, codePrefix: req.body.callbackUrl ? `${req.body.callbackUrl.substring(0, 10)}...` : req.body.authorizationCode - ? `${req.body.authorizationCode.substring(0, 10)}...` - : 'N/A' + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' }) return res .status(500) @@ -1983,13 +1983,13 @@ router.post('/claude-accounts/exchange-setup-token-code', authenticateAdmin, asy codeLength: req.body.callbackUrl ? req.body.callbackUrl.length : req.body.authorizationCode - ? req.body.authorizationCode.length - : 0, + ? req.body.authorizationCode.length + : 0, codePrefix: req.body.callbackUrl ? `${req.body.callbackUrl.substring(0, 10)}...` : req.body.authorizationCode - ? `${req.body.authorizationCode.substring(0, 10)}...` - : 'N/A' + ? `${req.body.authorizationCode.substring(0, 10)}...` + : 'N/A' }) return res .status(500) From df634a45658c2e066e57884a9cafcfec144ba83b Mon Sep 17 00:00:00 2001 From: itzhan <2802965114@qq.com> Date: Sat, 20 Sep 2025 09:20:18 +0800 Subject: [PATCH 5/5] chore: fix lint formatting --- scripts/test-total-usage-limit.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/test-total-usage-limit.js b/scripts/test-total-usage-limit.js index c5c50bdd..c084ccfd 100644 --- a/scripts/test-total-usage-limit.js +++ b/scripts/test-total-usage-limit.js @@ -111,7 +111,7 @@ async function main() { }) const keyId = newKey.id - const apiKey = newKey.apiKey + const { apiKey } = newKey console.log(`➕ Created test API key ${keyId} with total usage limit $${totalLimit}`) @@ -145,6 +145,8 @@ main().catch(async (error) => { console.error('❌ Total usage limit test failed:', error) try { await redis.disconnect() - } catch (_) {} + } catch (_) { + // Ignore disconnect errors during cleanup + } process.exitCode = 1 })