From ac1e367a695713903c8b4257ff505f64f4ef79fd Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Sun, 27 Jul 2025 14:47:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E6=AF=8F=E6=97=A5?= =?UTF-8?q?=E8=B4=B9=E7=94=A8=E9=99=90=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + src/app.js | 10 ++ src/cli/initCosts.js | 32 ++++++ src/middleware/auth.js | 23 ++++ src/models/redis.js | 69 +++++++++++- src/routes/admin.js | 71 +++++++++++-- src/services/apiKeyService.js | 33 +++++- src/services/costInitService.js | 182 ++++++++++++++++++++++++++++++++ web/admin/app.js | 21 ++-- web/admin/index.html | 49 +++++++++ 10 files changed, 471 insertions(+), 20 deletions(-) create mode 100644 src/cli/initCosts.js create mode 100644 src/services/costInitService.js diff --git a/package.json b/package.json index 4fd9eb4f..0bb9bd01 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "install:web": "cd web && npm install", "setup": "node scripts/setup.js", "cli": "node cli/index.js", + "init:costs": "node src/cli/initCosts.js", "service": "node scripts/manage.js", "service:start": "node scripts/manage.js start", "service:start:daemon": "node scripts/manage.js start -d", diff --git a/src/app.js b/src/app.js index 42168da5..73670861 100644 --- a/src/app.js +++ b/src/app.js @@ -51,6 +51,16 @@ class Application { logger.info('🔄 Initializing admin credentials...'); await this.initializeAdmin(); + // 💰 初始化费用数据 + logger.info('💰 Checking cost data initialization...'); + const costInitService = require('./services/costInitService'); + const needsInit = await costInitService.needsInitialization(); + if (needsInit) { + logger.info('💰 Initializing cost data for all API Keys...'); + const result = await costInitService.initializeAllCosts(); + logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`); + } + // 🛡️ 安全中间件 this.app.use(helmet({ contentSecurityPolicy: false, // 允许内联样式和脚本 diff --git a/src/cli/initCosts.js b/src/cli/initCosts.js new file mode 100644 index 00000000..0dc4aff8 --- /dev/null +++ b/src/cli/initCosts.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node + +const costInitService = require('../services/costInitService'); +const logger = require('../utils/logger'); +const redis = require('../models/redis'); + +async function main() { + try { + // 连接Redis + await redis.connect(); + + console.log('💰 Starting cost data initialization...\n'); + + // 执行初始化 + const result = await costInitService.initializeAllCosts(); + + console.log('\n✅ Cost initialization completed!'); + console.log(` Processed: ${result.processed} API Keys`); + console.log(` Errors: ${result.errors}`); + + // 断开连接 + await redis.disconnect(); + process.exit(0); + } catch (error) { + console.error('\n❌ Cost initialization failed:', error.message); + logger.error('Cost initialization failed:', error); + process.exit(1); + } +} + +// 运行主函数 +main(); \ No newline at end of file diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 11d56e5b..d27add4a 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -239,6 +239,27 @@ const authenticateApiKey = async (req, res, next) => { }; } + // 检查每日费用限制 + const dailyCostLimit = validation.keyData.dailyCostLimit || 0; + if (dailyCostLimit > 0) { + const dailyCost = validation.keyData.dailyCost || 0; + + if (dailyCost >= dailyCostLimit) { + logger.security(`💰 Daily cost limit exceeded for key: ${validation.keyData.id} (${validation.keyData.name}), cost: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); + + return res.status(429).json({ + error: 'Daily cost limit exceeded', + message: `已达到每日费用限制 ($${dailyCostLimit})`, + currentCost: dailyCost, + costLimit: dailyCostLimit, + resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString() // 明天0点重置 + }); + } + + // 记录当前费用使用情况 + logger.api(`💰 Cost usage for key: ${validation.keyData.id} (${validation.keyData.name}), current: $${dailyCost.toFixed(2)}/$${dailyCostLimit}`); + } + // 将验证信息添加到请求对象(只包含必要信息) req.apiKey = { id: validation.keyData.id, @@ -254,6 +275,8 @@ const authenticateApiKey = async (req, res, next) => { restrictedModels: validation.keyData.restrictedModels, enableClientRestriction: validation.keyData.enableClientRestriction, allowedClients: validation.keyData.allowedClients, + dailyCostLimit: validation.keyData.dailyCostLimit, + dailyCost: validation.keyData.dailyCost, usage: validation.keyData.usage }; req.usage = validation.keyData.usage; diff --git a/src/models/redis.js b/src/models/redis.js index f59ba93a..9473724b 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -467,6 +467,66 @@ class RedisClient { }; } + // 💰 获取当日费用 + async getDailyCost(keyId) { + const today = getDateStringInTimezone(); + const costKey = `usage:cost:daily:${keyId}:${today}`; + const cost = await this.client.get(costKey); + const result = parseFloat(cost || 0); + logger.debug(`💰 Getting daily cost for ${keyId}, date: ${today}, key: ${costKey}, value: ${cost}, result: ${result}`); + return result; + } + + // 💰 增加当日费用 + async incrementDailyCost(keyId, amount) { + const today = getDateStringInTimezone(); + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; + + const dailyKey = `usage:cost:daily:${keyId}:${today}`; + const monthlyKey = `usage:cost:monthly:${keyId}:${currentMonth}`; + const hourlyKey = `usage:cost:hourly:${keyId}:${currentHour}`; + const totalKey = `usage:cost:total:${keyId}`; + + logger.debug(`💰 Incrementing cost for ${keyId}, amount: $${amount}, date: ${today}, dailyKey: ${dailyKey}`); + + const results = await Promise.all([ + this.client.incrbyfloat(dailyKey, amount), + this.client.incrbyfloat(monthlyKey, amount), + this.client.incrbyfloat(hourlyKey, amount), + this.client.incrbyfloat(totalKey, amount), + // 设置过期时间 + this.client.expire(dailyKey, 86400 * 30), // 30天 + this.client.expire(monthlyKey, 86400 * 90), // 90天 + this.client.expire(hourlyKey, 86400 * 7) // 7天 + ]); + + logger.debug(`💰 Cost incremented successfully, new daily total: $${results[0]}`); + } + + // 💰 获取费用统计 + async getCostStats(keyId) { + const today = getDateStringInTimezone(); + const tzDate = getDateInTimezone(); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const currentHour = `${today}:${String(getHourInTimezone()).padStart(2, '0')}`; + + const [daily, monthly, hourly, total] = await Promise.all([ + this.client.get(`usage:cost:daily:${keyId}:${today}`), + this.client.get(`usage:cost:monthly:${keyId}:${currentMonth}`), + this.client.get(`usage:cost:hourly:${keyId}:${currentHour}`), + this.client.get(`usage:cost:total:${keyId}`) + ]); + + return { + daily: parseFloat(daily || 0), + monthly: parseFloat(monthly || 0), + hourly: parseFloat(hourly || 0), + total: parseFloat(total || 0) + }; + } + // 📊 获取账户使用统计 async getAccountUsageStats(accountId) { const accountKey = `account_usage:${accountId}`; @@ -1023,4 +1083,11 @@ class RedisClient { } } -module.exports = new RedisClient(); \ No newline at end of file +const redisClient = new RedisClient(); + +// 导出时区辅助函数 +redisClient.getDateInTimezone = getDateInTimezone; +redisClient.getDateStringInTimezone = getDateStringInTimezone; +redisClient.getHourInTimezone = getHourInTimezone; + +module.exports = redisClient; \ No newline at end of file diff --git a/src/routes/admin.js b/src/routes/admin.js index 18f7c9d0..53bb13f4 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -18,6 +18,37 @@ const router = express.Router(); // 🔑 API Keys 管理 +// 调试:获取API Key费用详情 +router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => { + try { + const { keyId } = req.params; + const costStats = await redis.getCostStats(keyId); + const dailyCost = await redis.getDailyCost(keyId); + const today = redis.getDateStringInTimezone(); + const client = redis.getClientSafe(); + + // 获取所有相关的Redis键 + const costKeys = await client.keys(`usage:cost:*:${keyId}:*`); + const keyValues = {}; + + for (const key of costKeys) { + keyValues[key] = await client.get(key); + } + + res.json({ + keyId, + today, + dailyCost, + costStats, + redisKeys: keyValues, + timezone: config.system.timezoneOffset || 8 + }); + } catch (error) { + logger.error('❌ Failed to get cost debug info:', error); + res.status(500).json({ error: 'Failed to get cost debug info', message: error.message }); + } +}); + // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { @@ -29,20 +60,26 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { let searchPatterns = []; if (timeRange === 'today') { - // 今日 - const dateStr = now.toISOString().split('T')[0]; + // 今日 - 使用时区日期 + const redis = require('../models/redis'); + const tzDate = redis.getDateInTimezone(now); + const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; searchPatterns.push(`usage:daily:*:${dateStr}`); } else if (timeRange === '7days') { // 最近7天 + const redis = require('../models/redis'); for (let i = 0; i < 7; i++) { const date = new Date(now); date.setDate(date.getDate() - i); - const dateStr = date.toISOString().split('T')[0]; + const tzDate = redis.getDateInTimezone(date); + const dateStr = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`; searchPatterns.push(`usage:daily:*:${dateStr}`); } } else if (timeRange === 'monthly') { // 本月 - const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; + const redis = require('../models/redis'); + const tzDate = redis.getDateInTimezone(now); + const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; searchPatterns.push(`usage:monthly:*:${currentMonth}`); } @@ -149,11 +186,16 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { // 计算指定时间范围的费用 let totalCost = 0; + const redis = require('../models/redis'); + const tzToday = redis.getDateStringInTimezone(now); + const tzDate = redis.getDateInTimezone(now); + const tzMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`; + const modelKeys = timeRange === 'today' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:${now.toISOString().split('T')[0]}`) + ? 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:*:${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`); + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`); const modelStatsMap = new Map(); @@ -277,7 +319,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { enableModelRestriction, restrictedModels, enableClientRestriction, - allowedClients + allowedClients, + dailyCostLimit } = req.body; // 输入验证 @@ -342,7 +385,8 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { enableModelRestriction, restrictedModels, enableClientRestriction, - allowedClients + allowedClients, + dailyCostLimit }); logger.success(`🔑 Admin created new API key: ${name}`); @@ -357,7 +401,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt } = req.body; + const { tokenLimit, concurrencyLimit, rateLimitWindow, rateLimitRequests, claudeAccountId, geminiAccountId, permissions, enableModelRestriction, restrictedModels, enableClientRestriction, allowedClients, expiresAt, dailyCostLimit } = req.body; // 只允许更新指定字段 const updates = {}; @@ -453,6 +497,15 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { } } + // 处理每日费用限制 + if (dailyCostLimit !== undefined && dailyCostLimit !== null && dailyCostLimit !== '') { + const costLimit = Number(dailyCostLimit); + if (isNaN(costLimit) || costLimit < 0) { + return res.status(400).json({ error: 'Daily cost limit must be a non-negative number' }); + } + updates.dailyCostLimit = costLimit; + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 3cfffe9f..ae78cd99 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -26,7 +26,8 @@ class ApiKeyService { enableModelRestriction = false, restrictedModels = [], enableClientRestriction = false, - allowedClients = [] + allowedClients = [], + dailyCostLimit = 0 } = options; // 生成简单的API Key (64字符十六进制) @@ -51,6 +52,7 @@ class ApiKeyService { restrictedModels: JSON.stringify(restrictedModels || []), enableClientRestriction: String(enableClientRestriction || false), allowedClients: JSON.stringify(allowedClients || []), + dailyCostLimit: String(dailyCostLimit || 0), createdAt: new Date().toISOString(), lastUsedAt: '', expiresAt: expiresAt || '', @@ -79,6 +81,7 @@ class ApiKeyService { restrictedModels: JSON.parse(keyData.restrictedModels), enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: JSON.parse(keyData.allowedClients || '[]'), + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), createdAt: keyData.createdAt, expiresAt: keyData.expiresAt, createdBy: keyData.createdBy @@ -114,6 +117,9 @@ class ApiKeyService { // 获取使用统计(供返回数据使用) const usage = await redis.getUsageStats(keyData.id); + + // 获取当日费用统计 + const dailyCost = await redis.getDailyCost(keyData.id); // 更新最后使用时间(优化:只在实际API调用时更新,而不是验证时) // 注意:lastUsedAt的更新已移至recordUsage方法中 @@ -152,6 +158,8 @@ class ApiKeyService { restrictedModels: restrictedModels, enableClientRestriction: keyData.enableClientRestriction === 'true', allowedClients: allowedClients, + dailyCostLimit: parseFloat(keyData.dailyCostLimit || 0), + dailyCost: dailyCost || 0, usage } }; @@ -178,6 +186,8 @@ class ApiKeyService { key.enableModelRestriction = key.enableModelRestriction === 'true'; key.enableClientRestriction = key.enableClientRestriction === 'true'; key.permissions = key.permissions || 'all'; // 兼容旧数据 + key.dailyCostLimit = parseFloat(key.dailyCostLimit || 0); + key.dailyCost = await redis.getDailyCost(key.id) || 0; try { key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : []; } catch (e) { @@ -207,7 +217,7 @@ class ApiKeyService { } // 允许更新的字段 - const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients']; + const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'rateLimitWindow', 'rateLimitRequests', 'isActive', 'claudeAccountId', 'geminiAccountId', 'permissions', 'expiresAt', 'enableModelRestriction', 'restrictedModels', 'enableClientRestriction', 'allowedClients', 'dailyCostLimit']; const updatedData = { ...keyData }; for (const [field, value] of Object.entries(updates)) { @@ -261,9 +271,26 @@ class ApiKeyService { try { const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens; + // 计算费用 + const CostCalculator = require('../utils/costCalculator'); + const costInfo = CostCalculator.calculateCost({ + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreateTokens, + cache_read_input_tokens: cacheReadTokens + }, model); + // 记录API Key级别的使用统计 await redis.incrementTokenUsage(keyId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); + // 记录费用统计 + if (costInfo.costs.total > 0) { + await redis.incrementDailyCost(keyId, costInfo.costs.total); + logger.database(`💰 Recorded cost for ${keyId}: $${costInfo.costs.total.toFixed(6)}, model: ${model}`); + } else { + logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`); + } + // 获取API Key数据以确定关联的账户 const keyData = await redis.getApiKey(keyId); if (keyData && Object.keys(keyData).length > 0) { @@ -276,7 +303,7 @@ class ApiKeyService { await redis.incrementAccountUsage(accountId, totalTokens, inputTokens, outputTokens, cacheCreateTokens, cacheReadTokens, model); logger.database(`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`); } else { - logger.debug(`⚠️ No accountId provided for usage recording, skipping account-level statistics`); + logger.debug('⚠️ No accountId provided for usage recording, skipping account-level statistics'); } } diff --git a/src/services/costInitService.js b/src/services/costInitService.js new file mode 100644 index 00000000..e61063dd --- /dev/null +++ b/src/services/costInitService.js @@ -0,0 +1,182 @@ +const redis = require('../models/redis'); +const apiKeyService = require('./apiKeyService'); +const CostCalculator = require('../utils/costCalculator'); +const logger = require('../utils/logger'); + +class CostInitService { + /** + * 初始化所有API Key的费用数据 + * 扫描历史使用记录并计算费用 + */ + async initializeAllCosts() { + try { + logger.info('💰 Starting cost initialization for all API Keys...'); + + const apiKeys = await apiKeyService.getAllApiKeys(); + const client = redis.getClientSafe(); + + let processedCount = 0; + let errorCount = 0; + + for (const apiKey of apiKeys) { + try { + await this.initializeApiKeyCosts(apiKey.id, client); + processedCount++; + + if (processedCount % 10 === 0) { + logger.info(`💰 Processed ${processedCount} API Keys...`); + } + } catch (error) { + errorCount++; + logger.error(`❌ Failed to initialize costs for API Key ${apiKey.id}:`, error); + } + } + + logger.success(`💰 Cost initialization completed! Processed: ${processedCount}, Errors: ${errorCount}`); + return { processed: processedCount, errors: errorCount }; + } catch (error) { + logger.error('❌ Failed to initialize costs:', error); + throw error; + } + } + + /** + * 初始化单个API Key的费用数据 + */ + async initializeApiKeyCosts(apiKeyId, client) { + // 获取所有时间的模型使用统计 + const modelKeys = await client.keys(`usage:${apiKeyId}:model:*:*:*`); + + // 按日期分组统计 + const dailyCosts = new Map(); // date -> cost + const monthlyCosts = new Map(); // month -> cost + const hourlyCosts = new Map(); // hour -> cost + + for (const key of modelKeys) { + // 解析key格式: usage:{keyId}:model:{period}:{model}:{date} + const match = key.match(/usage:(.+):model:(daily|monthly|hourly):(.+):(\d{4}-\d{2}(?:-\d{2})?(?::\d{2})?)$/); + if (!match) continue; + + const [, , period, model, dateStr] = match; + + // 获取使用数据 + const data = await client.hgetall(key); + if (!data || Object.keys(data).length === 0) continue; + + // 计算费用 + const usage = { + input_tokens: parseInt(data.totalInputTokens) || parseInt(data.inputTokens) || 0, + output_tokens: parseInt(data.totalOutputTokens) || parseInt(data.outputTokens) || 0, + cache_creation_input_tokens: parseInt(data.totalCacheCreateTokens) || parseInt(data.cacheCreateTokens) || 0, + cache_read_input_tokens: parseInt(data.totalCacheReadTokens) || parseInt(data.cacheReadTokens) || 0 + }; + + const costResult = CostCalculator.calculateCost(usage, model); + const cost = costResult.costs.total; + + // 根据period分组累加费用 + if (period === 'daily') { + const currentCost = dailyCosts.get(dateStr) || 0; + dailyCosts.set(dateStr, currentCost + cost); + } else if (period === 'monthly') { + const currentCost = monthlyCosts.get(dateStr) || 0; + monthlyCosts.set(dateStr, currentCost + cost); + } else if (period === 'hourly') { + const currentCost = hourlyCosts.get(dateStr) || 0; + hourlyCosts.set(dateStr, currentCost + cost); + } + } + + // 将计算出的费用写入Redis + const promises = []; + + // 写入每日费用 + for (const [date, cost] of dailyCosts) { + const key = `usage:cost:daily:${apiKeyId}:${date}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 30) // 30天过期 + ); + } + + // 写入每月费用 + for (const [month, cost] of monthlyCosts) { + const key = `usage:cost:monthly:${apiKeyId}:${month}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 90) // 90天过期 + ); + } + + // 写入每小时费用 + for (const [hour, cost] of hourlyCosts) { + const key = `usage:cost:hourly:${apiKeyId}:${hour}`; + promises.push( + client.set(key, cost.toString()), + client.expire(key, 86400 * 7) // 7天过期 + ); + } + + // 计算总费用 + let totalCost = 0; + for (const cost of dailyCosts.values()) { + totalCost += cost; + } + + // 写入总费用 + if (totalCost > 0) { + const totalKey = `usage:cost:total:${apiKeyId}`; + promises.push(client.set(totalKey, totalCost.toString())); + } + + await Promise.all(promises); + + logger.debug(`💰 Initialized costs for API Key ${apiKeyId}: Daily entries: ${dailyCosts.size}, Total cost: $${totalCost.toFixed(2)}`); + } + + /** + * 检查是否需要初始化费用数据 + */ + async needsInitialization() { + try { + const client = redis.getClientSafe(); + + // 检查是否有任何费用数据 + const costKeys = await client.keys('usage:cost:*'); + + // 如果没有费用数据,需要初始化 + if (costKeys.length === 0) { + logger.info('💰 No cost data found, initialization needed'); + return true; + } + + // 检查是否有使用数据但没有对应的费用数据 + const sampleKeys = await client.keys('usage:*:model:daily:*:*'); + if (sampleKeys.length > 10) { + // 抽样检查 + const sampleSize = Math.min(10, sampleKeys.length); + for (let i = 0; i < sampleSize; i++) { + const usageKey = sampleKeys[Math.floor(Math.random() * sampleKeys.length)]; + const match = usageKey.match(/usage:(.+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/); + if (match) { + const [, keyId, , date] = match; + const costKey = `usage:cost:daily:${keyId}:${date}`; + const hasCost = await client.exists(costKey); + if (!hasCost) { + logger.info(`💰 Found usage without cost data for key ${keyId} on ${date}, initialization needed`); + return true; + } + } + } + } + + logger.info('💰 Cost data appears to be up to date'); + return false; + } catch (error) { + logger.error('❌ Failed to check initialization status:', error); + return false; + } + } +} + +module.exports = new CostInitService(); \ No newline at end of file diff --git a/web/admin/app.js b/web/admin/app.js index 2910ca4b..b4370ab9 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -133,7 +133,8 @@ const app = createApp({ allowedClients: [], expireDuration: '', // 过期时长选择 customExpireDate: '', // 自定义过期日期 - expiresAt: null // 实际的过期时间戳 + expiresAt: null, // 实际的过期时间戳 + dailyCostLimit: '' // 每日费用限制 }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -192,7 +193,8 @@ const app = createApp({ restrictedModels: [], modelInput: '', enableClientRestriction: false, - allowedClients: [] + allowedClients: [], + dailyCostLimit: '' }, // 支持的客户端列表 @@ -2075,7 +2077,8 @@ const app = createApp({ restrictedModels: this.apiKeyForm.restrictedModels, enableClientRestriction: this.apiKeyForm.enableClientRestriction, allowedClients: this.apiKeyForm.allowedClients, - expiresAt: this.apiKeyForm.expiresAt + expiresAt: this.apiKeyForm.expiresAt, + dailyCostLimit: this.apiKeyForm.dailyCostLimit && this.apiKeyForm.dailyCostLimit.trim() ? parseFloat(this.apiKeyForm.dailyCostLimit) : 0 }) }); @@ -2113,7 +2116,8 @@ const app = createApp({ allowedClients: [], expireDuration: '', customExpireDate: '', - expiresAt: null + expiresAt: null, + dailyCostLimit: '' }; // 重新加载API Keys列表 @@ -2280,7 +2284,8 @@ const app = createApp({ restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [], modelInput: '', enableClientRestriction: key.enableClientRestriction || false, - allowedClients: key.allowedClients ? [...key.allowedClients] : [] + allowedClients: key.allowedClients ? [...key.allowedClients] : [], + dailyCostLimit: key.dailyCostLimit || '' }; this.showEditApiKeyModal = true; }, @@ -2301,7 +2306,8 @@ const app = createApp({ restrictedModels: [], modelInput: '', enableClientRestriction: false, - allowedClients: [] + allowedClients: [], + dailyCostLimit: '' }; }, @@ -2321,7 +2327,8 @@ const app = createApp({ enableModelRestriction: this.editApiKeyForm.enableModelRestriction, restrictedModels: this.editApiKeyForm.restrictedModels, enableClientRestriction: this.editApiKeyForm.enableClientRestriction, - allowedClients: this.editApiKeyForm.allowedClients + allowedClients: this.editApiKeyForm.allowedClients, + dailyCostLimit: this.editApiKeyForm.dailyCostLimit && this.editApiKeyForm.dailyCostLimit.toString().trim() !== '' ? parseFloat(this.editApiKeyForm.dailyCostLimit) : 0 }) }); diff --git a/web/admin/index.html b/web/admin/index.html index 2bbae079..046a37cb 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -663,6 +663,13 @@ 费用: {{ calculateApiKeyCost(key.usage) }} + +
+ 今日费用: + + ${{ (key.dailyCost || 0).toFixed(2) }} / ${{ key.dailyCostLimit.toFixed(2) }} + +
并发限制: @@ -2088,6 +2095,27 @@
+
+ +
+
+ + + + +
+ +

设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制

+
+
+
+
+ +
+
+ + + + +
+ +

设置此 API Key 每日的费用限制,超过限制将拒绝请求,0 或留空表示无限制

+
+
+