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] =?UTF-8?q?feat:=20=E7=BB=99key=E5=A2=9E=E5=8A=A0=E6=80=BB?= =?UTF-8?q?=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 或留空表示无限制 +
++ 设置此 API Key 的累计总费用限制,达到限制后将拒绝所有后续请求,0 或留空表示无限制 +
+