diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 66fa1387..6fe5dbc4 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -1266,13 +1266,10 @@ const authenticateApiKey = async (req, res, next) => { }), cost: $${weeklyOpusCost.toFixed(2)}/$${weeklyOpusCostLimit}` ) - // 计算下周一的重置时间 - const now = new Date() - const dayOfWeek = now.getDay() - const daysUntilMonday = dayOfWeek === 0 ? 1 : (8 - dayOfWeek) % 7 || 7 - const resetDate = new Date(now) - resetDate.setDate(now.getDate() + daysUntilMonday) - resetDate.setHours(0, 0, 0, 0) + // 计算下次重置时间(基于 API Key 配置的重置日/时) + const resetDay = validation.keyData.weeklyResetDay || 1 + const resetHour = validation.keyData.weeklyResetHour || 0 + const resetDate = redis.getNextResetTime(resetDay, resetHour) // 使用 402 Payment Required 而非 429,避免客户端自动重试 return res.status(402).json({ diff --git a/src/models/redis.js b/src/models/redis.js index 2f139d6a..2445783a 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -50,6 +50,82 @@ function getWeekStringInTimezone(date = new Date()) { return `${year}-W${String(weekNumber).padStart(2, '0')}` } +// 获取基于自定义重置日/时的周期标识符 (YYYY-MM-DDThh 格式) +// resetDay: 1-7 (周一到周日),默认 1 (周一) +// resetHour: 0-23,默认 0 (00:00) +function getPeriodString(resetDay = 1, resetHour = 0, date = new Date()) { + const tzDate = getDateInTimezone(date) + + // 当前时区时间的 ISO 星期几 (1=周一 ... 7=周日) + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + // 计算距上次重置已过的天数 + let daysSinceReset = (currentDay - resetDay + 7) % 7 + // 如果同一天但还没到重置时间,视为上一个周期 + if (daysSinceReset === 0 && currentHour < resetHour) { + daysSinceReset = 7 + } + + // 回退到周期起始日 + const periodStart = new Date(tzDate) + periodStart.setUTCDate(tzDate.getUTCDate() - daysSinceReset) + periodStart.setUTCHours(resetHour, 0, 0, 0) + + const y = periodStart.getUTCFullYear() + const m = String(periodStart.getUTCMonth() + 1).padStart(2, '0') + const d = String(periodStart.getUTCDate()).padStart(2, '0') + const h = String(periodStart.getUTCHours()).padStart(2, '0') + + return `${y}-${m}-${d}T${h}` +} + +// 获取下次重置的真实 UTC 时间(用于 402 响应中的 resetAt) +// resetDay: 1-7 (周一到周日),默认 1 (周一) +// resetHour: 0-23,默认 0 (00:00) +function getNextResetTime(resetDay = 1, resetHour = 0) { + const offset = config.system.timezoneOffset || 8 + const tzDate = getDateInTimezone(new Date()) + + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + let daysUntilReset = (resetDay - currentDay + 7) % 7 + // 如果同一天但已过重置时间,等到下周 + if (daysUntilReset === 0 && currentHour >= resetHour) { + daysUntilReset = 7 + } + + // 构造时区下的重置时间 + const resetTz = new Date(tzDate) + resetTz.setUTCDate(tzDate.getUTCDate() + daysUntilReset) + resetTz.setUTCHours(resetHour, 0, 0, 0) + + // 转换回真实 UTC:减去时区偏移 + const resetUtc = new Date(resetTz.getTime() - offset * 3600000) + return resetUtc +} + +// 获取周期起始日期的 Date 对象(时区下),用于回填时判断日期是否在当前周期内 +// 返回 getDateInTimezone 风格的 Date,可用 getUTC* 获取时区本地值 +function getPeriodStartDate(resetDay = 1, resetHour = 0, date = new Date()) { + const tzDate = getDateInTimezone(date) + + const currentDay = tzDate.getUTCDay() || 7 + const currentHour = tzDate.getUTCHours() + + let daysSinceReset = (currentDay - resetDay + 7) % 7 + if (daysSinceReset === 0 && currentHour < resetHour) { + daysSinceReset = 7 + } + + const periodStart = new Date(tzDate) + periodStart.setUTCDate(tzDate.getUTCDate() - daysSinceReset) + periodStart.setUTCHours(resetHour, 0, 0, 0) + + return periodStart +} + // 并发队列相关常量 const QUEUE_STATS_TTL_SECONDS = 86400 * 7 // 统计计数保留 7 天 const WAIT_TIME_TTL_SECONDS = 86400 // 等待时间样本保留 1 天(滚动窗口,无需长期保留) @@ -1753,31 +1829,31 @@ class RedisClient { } } - // 💰 获取本周 Opus 费用 - async getWeeklyOpusCost(keyId) { - const currentWeek = getWeekStringInTimezone() - const costKey = `usage:opus:weekly:${keyId}:${currentWeek}` + // 💰 获取本周 Opus 费用(支持自定义重置周期) + async getWeeklyOpusCost(keyId, resetDay = 1, resetHour = 0) { + const periodStr = getPeriodString(resetDay, resetHour) + const costKey = `usage:opus:weekly:${keyId}:${periodStr}` const cost = await this.client.get(costKey) const result = parseFloat(cost || 0) logger.debug( - `💰 Getting weekly Opus cost for ${keyId}, week: ${currentWeek}, key: ${costKey}, value: ${cost}, result: ${result}` + `💰 Getting weekly Opus cost for ${keyId}, period: ${periodStr}, key: ${costKey}, value: ${cost}, result: ${result}` ) return result } - // 💰 增加本周 Opus 费用(支持倍率成本和真实成本) + // 💰 增加本周 Opus 费用(支持倍率成本和真实成本,支持自定义重置周期) // amount: 倍率后的成本(用于限额校验) // realAmount: 真实成本(用于对账),如果不传则等于 amount - async incrementWeeklyOpusCost(keyId, amount, realAmount = null) { - const currentWeek = getWeekStringInTimezone() - const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}` + async incrementWeeklyOpusCost(keyId, amount, realAmount = null, resetDay = 1, resetHour = 0) { + const periodStr = getPeriodString(resetDay, resetHour) + const weeklyKey = `usage:opus:weekly:${keyId}:${periodStr}` const totalKey = `usage:opus:total:${keyId}` - const realWeeklyKey = `usage:opus:real:weekly:${keyId}:${currentWeek}` + const realWeeklyKey = `usage:opus:real:weekly:${keyId}:${periodStr}` const realTotalKey = `usage:opus:real:total:${keyId}` const actualRealAmount = realAmount !== null ? realAmount : amount logger.debug( - `💰 Incrementing weekly Opus cost for ${keyId}, week: ${currentWeek}, rated: $${amount}, real: $${actualRealAmount}` + `💰 Incrementing weekly Opus cost for ${keyId}, period: ${periodStr}, rated: $${amount}, real: $${actualRealAmount}` ) // 使用 pipeline 批量执行,提高性能 @@ -1794,13 +1870,13 @@ class RedisClient { logger.debug(`💰 Opus cost incremented successfully, new weekly total: $${results[0][1]}`) } - // 💰 覆盖设置本周 Opus 费用(用于启动回填/迁移) - async setWeeklyOpusCost(keyId, amount, weekString = null) { - const currentWeek = weekString || getWeekStringInTimezone() - const weeklyKey = `usage:opus:weekly:${keyId}:${currentWeek}` + // 💰 覆盖设置本周 Opus 费用(用于启动回填/迁移,支持自定义周期标识) + async setWeeklyOpusCost(keyId, amount, periodString = null, resetDay = 1, resetHour = 0) { + const currentPeriod = periodString || getPeriodString(resetDay, resetHour) + const weeklyKey = `usage:opus:weekly:${keyId}:${currentPeriod}` await this.client.set(weeklyKey, String(amount || 0)) - // 保留 2 周,足够覆盖"当前周 + 上周"查看/回填 + // 保留 2 周,足够覆盖"当前周期 + 上周期"查看/回填 await this.client.expire(weeklyKey, 14 * 24 * 3600) } @@ -3716,6 +3792,9 @@ redisClient.getDateInTimezone = getDateInTimezone redisClient.getDateStringInTimezone = getDateStringInTimezone redisClient.getHourInTimezone = getHourInTimezone redisClient.getWeekStringInTimezone = getWeekStringInTimezone +redisClient.getPeriodString = getPeriodString +redisClient.getNextResetTime = getNextResetTime +redisClient.getPeriodStartDate = getPeriodStartDate // ============== 用户消息队列相关方法 ============== diff --git a/src/routes/admin/apiKeys.js b/src/routes/admin/apiKeys.js index 7bbc4f8a..29a02ef6 100644 --- a/src/routes/admin/apiKeys.js +++ b/src/routes/admin/apiKeys.js @@ -1144,7 +1144,9 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) { // 只在启用了 Claude 周费用限制时查询(字段名沿用 weeklyOpusCostLimit) if (weeklyOpusCostLimit > 0) { - weeklyOpusCost = await redis.getWeeklyOpusCost(keyId) + const resetDay = parseInt(apiKey?.weeklyResetDay || 1) + const resetHour = parseInt(apiKey?.weeklyResetHour || 0) + weeklyOpusCost = await redis.getWeeklyOpusCost(keyId, resetDay, resetHour) } // 只在启用了窗口限制时查询窗口数据(移到早期返回之前,确保窗口数据始终被获取) @@ -1479,7 +1481,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { activationUnit, // 新增:激活时间单位 (hours/days) expirationMode, // 新增:过期模式 icon, // 新增:图标 - serviceRates // API Key 级别服务倍率 + serviceRates, // API Key 级别服务倍率 + weeklyResetDay, // 周费用重置日 (1-7) + weeklyResetHour // 周费用重置时 (0-23) } = req.body // 输入验证 @@ -1612,6 +1616,22 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { return res.status(400).json({ error: serviceRatesError }) } + // 验证周费用重置配置 + if (weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '') { + const day = Number(weeklyResetDay) + if (!Number.isInteger(day) || day < 1 || day > 7) { + return res + .status(400) + .json({ error: 'Weekly reset day must be an integer from 1 (Mon) to 7 (Sun)' }) + } + } + if (weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '') { + const hour = Number(weeklyResetHour) + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return res.status(400).json({ error: 'Weekly reset hour must be an integer from 0 to 23' }) + } + } + const newKey = await apiKeyService.generateApiKey({ name, description, @@ -1640,7 +1660,15 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { activationUnit, expirationMode, icon, - serviceRates + serviceRates, + weeklyResetDay: + weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '' + ? Number(weeklyResetDay) + : 1, + weeklyResetHour: + weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '' + ? Number(weeklyResetHour) + : 0 }) logger.success(`🔑 Admin created new API key: ${name}`) @@ -1901,6 +1929,18 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { if (updates.serviceRates !== undefined) { finalUpdates.serviceRates = updates.serviceRates } + if (updates.weeklyResetDay !== undefined) { + const day = Number(updates.weeklyResetDay) + if (Number.isInteger(day) && day >= 1 && day <= 7) { + finalUpdates.weeklyResetDay = day + } + } + if (updates.weeklyResetHour !== undefined) { + const hour = Number(updates.weeklyResetHour) + if (Number.isInteger(hour) && hour >= 0 && hour <= 23) { + finalUpdates.weeklyResetHour = hour + } + } // 处理账户绑定 if (updates.claudeAccountId !== undefined) { @@ -1956,6 +1996,22 @@ router.put('/api-keys/batch', authenticateAdmin, async (req, res) => { // 执行更新 await apiKeyService.updateApiKey(keyId, finalUpdates) + + // 重置配置变更后触发单 Key 回填 + if ( + finalUpdates.weeklyResetDay !== undefined || + finalUpdates.weeklyResetHour !== undefined + ) { + setImmediate(async () => { + try { + const weeklyInitService = require('../../services/weeklyClaudeCostInitService') + await weeklyInitService.backfillSingleKey(keyId) + } catch (err) { + logger.error(`❌ 批量编辑回填单 Key 周费用失败 (${keyId}):`, err) + } + }) + } + results.successCount++ logger.success(`Batch edit: API key ${keyId} updated successfully`) } catch (error) { @@ -2019,7 +2075,9 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, ownerId, // 新增:所有者ID字段 - serviceRates // API Key 级别服务倍率 + serviceRates, // API Key 级别服务倍率 + weeklyResetDay, // 周费用重置日 (1-7) + weeklyResetHour // 周费用重置时 (0-23) } = req.body // 只允许更新指定字段 @@ -2214,6 +2272,27 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.serviceRates = serviceRates } + // 处理周费用重置配置 + let resetConfigChanged = false + if (weeklyResetDay !== undefined && weeklyResetDay !== null && weeklyResetDay !== '') { + const day = Number(weeklyResetDay) + if (!Number.isInteger(day) || day < 1 || day > 7) { + return res + .status(400) + .json({ error: 'Weekly reset day must be an integer from 1 (Mon) to 7 (Sun)' }) + } + updates.weeklyResetDay = day + resetConfigChanged = true + } + if (weeklyResetHour !== undefined && weeklyResetHour !== null && weeklyResetHour !== '') { + const hour = Number(weeklyResetHour) + if (!Number.isInteger(hour) || hour < 0 || hour > 23) { + return res.status(400).json({ error: 'Weekly reset hour must be an integer from 0 to 23' }) + } + updates.weeklyResetHour = hour + resetConfigChanged = true + } + // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 if (isActive !== undefined) { if (typeof isActive !== 'boolean') { @@ -2263,6 +2342,18 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { await apiKeyService.updateApiKey(keyId, updates) + // 重置配置变更后触发单 Key 回填 + if (resetConfigChanged) { + setImmediate(async () => { + try { + const weeklyInitService = require('../../services/weeklyClaudeCostInitService') + await weeklyInitService.backfillSingleKey(keyId) + } catch (err) { + logger.error(`❌ 回填单 Key 周费用失败 (${keyId}):`, err) + } + }) + } + logger.success(`📝 Admin updated API key: ${keyId}`) return res.json({ success: true, message: 'API key updated successfully' }) } catch (error) { diff --git a/src/routes/apiStats.js b/src/routes/apiStats.js index 481e1154..3c62664c 100644 --- a/src/routes/apiStats.js +++ b/src/routes/apiStats.js @@ -472,13 +472,20 @@ router.post('/api/user-stats', async (req, res) => { dailyCostLimit: fullKeyData.dailyCostLimit || 0, totalCostLimit: fullKeyData.totalCostLimit || 0, weeklyOpusCostLimit: parseFloat(fullKeyData.weeklyOpusCostLimit) || 0, // Opus 周费用限制 + weeklyResetDay: parseInt(fullKeyData.weeklyResetDay) || 1, // 周费用重置日 (1-7) + weeklyResetHour: parseInt(fullKeyData.weeklyResetHour) || 0, // 周费用重置时 (0-23) // 当前使用量 currentWindowRequests, currentWindowTokens, currentWindowCost, // 新增:当前窗口费用 currentDailyCost, currentTotalCost: totalCost, - weeklyOpusCost: (await redis.getWeeklyOpusCost(keyId)) || 0, // 当前 Opus 周费用 + weeklyOpusCost: + (await redis.getWeeklyOpusCost( + keyId, + parseInt(fullKeyData.weeklyResetDay) || 1, + parseInt(fullKeyData.weeklyResetHour) || 0 + )) || 0, // 当前 Opus 周费用 // 时间窗口信息 windowStartTime, windowEndTime, diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index dce98a0b..fd4c3b76 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -161,7 +161,9 @@ class ApiKeyService { activationUnit = 'days', // 新增:激活时间单位 'hours' 或 'days' expirationMode = 'fixed', // 新增:过期模式 'fixed'(固定时间) 或 'activation'(首次使用后激活) icon = '', // 新增:图标(base64编码) - serviceRates = {} // API Key 级别服务倍率覆盖 + serviceRates = {}, // API Key 级别服务倍率覆盖 + weeklyResetDay = 1, // 周费用重置日 (1=周一 ... 7=周日) + weeklyResetHour = 0 // 周费用重置时 (0-23) } = options // 生成简单的API Key (64字符十六进制) @@ -211,7 +213,9 @@ class ApiKeyService { userId: options.userId || '', userUsername: options.userUsername || '', icon: icon || '', // 新增:图标(base64编码) - serviceRates: JSON.stringify(serviceRates || {}) // API Key 级别服务倍率 + serviceRates: JSON.stringify(serviceRates || {}), // API Key 级别服务倍率 + weeklyResetDay: String(weeklyResetDay || 1), // 周费用重置日 (1-7) + weeklyResetHour: String(weeklyResetHour || 0) // 周费用重置时 (0-23) } // 保存API Key数据并建立哈希映射 @@ -373,8 +377,12 @@ class ApiKeyService { costQueries.push(redis.getCostStats(keyData.id).then((v) => ({ totalCost: v?.total || 0 }))) } if (weeklyOpusCostLimit > 0) { + const resetDay = parseInt(keyData.weeklyResetDay || 1) + const resetHour = parseInt(keyData.weeklyResetHour || 0) costQueries.push( - redis.getWeeklyOpusCost(keyData.id).then((v) => ({ weeklyOpusCost: v || 0 })) + redis + .getWeeklyOpusCost(keyData.id, resetDay, resetHour) + .then((v) => ({ weeklyOpusCost: v || 0 })) ) } @@ -449,6 +457,8 @@ class ApiKeyService { dailyCost: costData.dailyCost || 0, totalCost: costData.totalCost || 0, weeklyOpusCost: costData.weeklyOpusCost || 0, + weeklyResetDay: parseInt(keyData.weeklyResetDay || 1), + weeklyResetHour: parseInt(keyData.weeklyResetHour || 0), tags, serviceRates } @@ -577,7 +587,12 @@ class ApiKeyService { weeklyOpusCostLimit: parseFloat(keyData.weeklyOpusCostLimit || 0), dailyCost: dailyCost || 0, totalCost: costStats?.total || 0, - weeklyOpusCost: (await redis.getWeeklyOpusCost(keyData.id)) || 0, + weeklyOpusCost: + (await redis.getWeeklyOpusCost( + keyData.id, + parseInt(keyData.weeklyResetDay || 1), + parseInt(keyData.weeklyResetHour || 0) + )) || 0, tags, usage } @@ -783,7 +798,12 @@ class ApiKeyService { 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 + key.weeklyOpusCost = + (await redis.getWeeklyOpusCost( + key.id, + parseInt(key.weeklyResetDay || 1), + parseInt(key.weeklyResetHour || 0) + )) || 0 key.activationDays = parseInt(key.activationDays || 0) key.activationUnit = key.activationUnit || 'days' key.expirationMode = key.expirationMode || 'fixed' @@ -1215,7 +1235,9 @@ class ApiKeyService { 'userId', // 新增:用户ID(所有者变更) 'userUsername', // 新增:用户名(所有者变更) 'createdBy', // 新增:创建者(所有者变更) - 'serviceRates' // API Key 级别服务倍率 + 'serviceRates', // API Key 级别服务倍率 + 'weeklyResetDay', // 周费用重置日 (1-7) + 'weeklyResetHour' // 周费用重置时 (0-23) ] const updatedData = { ...keyData } @@ -1643,7 +1665,7 @@ class ApiKeyService { } } - // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户) + // 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户,支持自定义重置周期) // ratedCost: 倍率后的成本(用于限额校验) // realCost: 真实成本(用于对账),如果不传则等于 ratedCost async recordOpusCost(keyId, ratedCost, realCost, model, accountType) { @@ -1660,8 +1682,13 @@ class ApiKeyService { return // 不是 claude 账户,直接返回 } + // 获取 key 的重置配置 + const keyData = await redis.getApiKey(keyId) + const resetDay = parseInt(keyData?.weeklyResetDay || 1) + const resetHour = parseInt(keyData?.weeklyResetHour || 0) + // 记录 Opus 周费用(倍率成本和真实成本) - await redis.incrementWeeklyOpusCost(keyId, ratedCost, realCost) + await redis.incrementWeeklyOpusCost(keyId, ratedCost, realCost, resetDay, resetHour) logger.database( `💰 Recorded Opus weekly cost for ${keyId}: rated=$${ratedCost.toFixed(6)}, real=$${realCost.toFixed(6)}, model: ${model}` ) diff --git a/src/services/weeklyClaudeCostInitService.js b/src/services/weeklyClaudeCostInitService.js index 2dfb1470..a70d12a7 100644 --- a/src/services/weeklyClaudeCostInitService.js +++ b/src/services/weeklyClaudeCostInitService.js @@ -9,85 +9,106 @@ function pad2(n) { } // 生成配置时区下的 YYYY-MM-DD 字符串。 -// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的“时区偏移后”的 Date。 +// 注意:入参 date 必须是 redis.getDateInTimezone() 生成的"时区偏移后"的 Date。 function formatTzDateYmd(tzDate) { return `${tzDate.getUTCFullYear()}-${pad2(tzDate.getUTCMonth() + 1)}-${pad2(tzDate.getUTCDate())}` } +// 推断账户类型的辅助函数(与运行时 recordOpusCost 一致,只统计 claude-official/claude-console/ccr) +const OPUS_ACCOUNT_TYPES = ['claude-official', 'claude-console', 'ccr'] + +function inferAccountType(keyData) { + if (keyData?.ccrAccountId) { + return 'ccr' + } + if (keyData?.claudeConsoleAccountId) { + return 'claude-console' + } + if (keyData?.claudeAccountId) { + return 'claude-official' + } + // bedrock/azure/gemini 等不计入周费用 + return null +} + +function toInt(v) { + const n = parseInt(v || '0', 10) + return Number.isFinite(n) ? n : 0 +} + class WeeklyClaudeCostInitService { - _getCurrentWeekDatesInTimezone() { + // 获取最近 7 天的日期字符串数组(覆盖任意重置配置的完整周期) + _getLast7DaysInTimezone() { const tzNow = redis.getDateInTimezone(new Date()) const tzToday = new Date(tzNow) tzToday.setUTCHours(0, 0, 0, 0) - // ISO 周:周一=1 ... 周日=7 - const isoDay = tzToday.getUTCDay() || 7 - const tzMonday = new Date(tzToday) - tzMonday.setUTCDate(tzToday.getUTCDate() - (isoDay - 1)) - const dates = [] - for (let d = new Date(tzMonday); d <= tzToday; d.setUTCDate(d.getUTCDate() + 1)) { + for (let i = 7; i >= 0; i--) { + const d = new Date(tzToday) + d.setUTCDate(tzToday.getUTCDate() - i) dates.push(formatTzDateYmd(d)) } return dates } - _buildWeeklyOpusKey(keyId, weekString) { - return `usage:opus:weekly:${keyId}:${weekString}` + _buildWeeklyOpusKey(keyId, periodString) { + return `usage:opus:weekly:${keyId}:${periodString}` } /** - * 启动回填:把"本周(周一到今天)Claude 全模型"周费用从按日/按模型统计里反算出来, + * 启动回填:从"按日/按模型"统计中反算 Claude 模型费用, + * 根据每个 API Key 的 weeklyResetDay/weeklyResetHour 计算周期, * 写入 `usage:opus:weekly:*`,保证周限额在重启后不归零。 * * 说明: - * - 只回填本周,不做历史回填(符合"只要本周数据"诉求) + * - 回填最近 8 天数据(覆盖任意重置配置的完整 7 天周期) * - 会加分布式锁,避免多实例重复跑 - * - 会写 done 标记:同一周内重启默认不重复回填(需要时可手动删掉 done key) + * - 会写 done 标记:同一天内重启默认不重复回填 */ async backfillCurrentWeekClaudeCosts() { const client = redis.getClientSafe() if (!client) { - logger.warn('⚠️ 本周 Claude 周费用回填跳过:Redis client 不可用') + logger.warn('⚠️ Claude 周费用回填跳过:Redis client 不可用') return { success: false, reason: 'redis_unavailable' } } if (!pricingService || !pricingService.pricingData) { - logger.warn('⚠️ 本周 Claude 周费用回填跳过:pricing service 未初始化') + logger.warn('⚠️ Claude 周费用回填跳过:pricing service 未初始化') return { success: false, reason: 'pricing_uninitialized' } } - const weekString = redis.getWeekStringInTimezone() - const doneKey = `init:weekly_opus_cost:${weekString}:done` + const todayStr = redis.getDateStringInTimezone() + const doneKey = `init:weekly_opus_cost:${todayStr}:done` try { const alreadyDone = await client.get(doneKey) if (alreadyDone) { - logger.info(`ℹ️ 本周 Claude 周费用回填已完成(${weekString}),跳过`) + logger.info(`ℹ️ Claude 周费用回填已完成(${todayStr}),跳过`) return { success: true, skipped: true } } } catch (e) { // 尽力而为:读取失败不阻断启动回填流程。 } - const lockKey = `lock:init:weekly_opus_cost:${weekString}` + const lockKey = `lock:init:weekly_opus_cost:${todayStr}` const lockValue = `${process.pid}:${Date.now()}` const lockTtlMs = 15 * 60 * 1000 const lockAcquired = await redis.setAccountLock(lockKey, lockValue, lockTtlMs) if (!lockAcquired) { - logger.info(`ℹ️ 本周 Claude 周费用回填已在运行(${weekString}),跳过`) + logger.info(`ℹ️ Claude 周费用回填已在运行(${todayStr}),跳过`) return { success: true, skipped: true, reason: 'locked' } } const startedAt = Date.now() try { - logger.info(`💰 开始回填本周 Claude 周费用:${weekString}(仅本周)...`) + logger.info(`💰 开始回填 Claude 周费用(${todayStr})...`) const keyIds = await redis.scanApiKeyIds() - const dates = this._getCurrentWeekDatesInTimezone() + const dates = this._getLast7DaysInTimezone() - // 预加载所有 API Key 数据和全局倍率(避免循环内重复查询) + // 预加载所有 API Key 数据和全局倍率 const keyDataCache = new Map() const globalRateCache = new Map() const batchSize = 500 @@ -107,32 +128,11 @@ class WeeklyClaudeCostInitService { } logger.info(`💰 预加载 ${keyDataCache.size} 个 API Key 数据`) - // 推断账户类型的辅助函数(与运行时 recordOpusCost 一致,只统计 claude-official/claude-console/ccr) - const OPUS_ACCOUNT_TYPES = ['claude-official', 'claude-console', 'ccr'] - const inferAccountType = (keyData) => { - if (keyData?.ccrAccountId) { - return 'ccr' - } - if (keyData?.claudeConsoleAccountId) { - return 'claude-console' - } - if (keyData?.claudeAccountId) { - return 'claude-official' - } - // bedrock/azure/gemini 等不计入周费用 - return null - } - - const costByKeyId = new Map() + // 收集每个 key 每天的费用: Map> + const costByKeyDate = new Map() let scannedKeys = 0 let matchedClaudeKeys = 0 - const toInt = (v) => { - const n = parseInt(v || '0', 10) - return Number.isFinite(n) ? n : 0 - } - - // 扫描“按日 + 按模型”的使用统计 key,并反算 Claude 系列模型的费用。 for (const dateStr of dates) { let cursor = '0' const pattern = `usage:*:model:daily:*:${dateStr}` @@ -144,7 +144,6 @@ class WeeklyClaudeCostInitService { const entries = [] for (const usageKey of keys) { - // usage:{keyId}:model:daily:{model}:{YYYY-MM-DD} const match = usageKey.match(/^usage:([^:]+):model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) if (!match) { continue @@ -155,7 +154,7 @@ class WeeklyClaudeCostInitService { continue } matchedClaudeKeys++ - entries.push({ usageKey, keyId, model }) + entries.push({ usageKey, keyId, model, dateStr }) } if (entries.length === 0) { @@ -207,25 +206,21 @@ class WeeklyClaudeCostInitService { continue } - // 应用倍率:全局倍率 × Key 倍率(使用缓存数据) const keyData = keyDataCache.get(entry.keyId) const accountType = inferAccountType(keyData) - // 与运行时 recordOpusCost 一致:只统计 claude-official/claude-console/ccr 账户 if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) { continue } const service = serviceRatesService.getService(accountType, entry.model) - // 获取全局倍率(带缓存) let globalRate = globalRateCache.get(service) if (globalRate === undefined) { globalRate = await serviceRatesService.getServiceRate(service) globalRateCache.set(service, globalRate) } - // 获取 Key 倍率 let keyRates = {} try { keyRates = JSON.parse(keyData?.serviceRates || '{}') @@ -235,49 +230,227 @@ class WeeklyClaudeCostInitService { const keyRate = keyRates[service] ?? 1.0 const ratedCost = realCost * globalRate * keyRate - costByKeyId.set(entry.keyId, (costByKeyId.get(entry.keyId) || 0) + ratedCost) + // 按 keyId+dateStr 累加 + if (!costByKeyDate.has(entry.keyId)) { + costByKeyDate.set(entry.keyId, new Map()) + } + const dateMap = costByKeyDate.get(entry.keyId) + dateMap.set(entry.dateStr, (dateMap.get(entry.dateStr) || 0) + ratedCost) } } while (cursor !== '0') } - // 为所有 API Key 写入本周 opus:weekly key + // 为每个 API Key 按其重置配置计算当前周期费用 const ttlSeconds = 14 * 24 * 3600 + let filledCount = 0 for (let i = 0; i < keyIds.length; i += batchSize) { const batch = keyIds.slice(i, i + batchSize) const pipeline = client.pipeline() for (const keyId of batch) { - const weeklyKey = this._buildWeeklyOpusKey(keyId, weekString) - const cost = costByKeyId.get(keyId) || 0 - pipeline.set(weeklyKey, String(cost)) + const keyData = keyDataCache.get(keyId) + const resetDay = parseInt(keyData?.weeklyResetDay || 1) + const resetHour = parseInt(keyData?.weeklyResetHour || 0) + + // 获取当前周期的起始日期 + const periodStart = redis.getPeriodStartDate(resetDay, resetHour) + const periodStartDateStr = formatTzDateYmd(periodStart) + const periodString = redis.getPeriodString(resetDay, resetHour) + + // 汇总该 key 在当前周期内的费用 + const dateMap = costByKeyDate.get(keyId) + let periodCost = 0 + if (dateMap) { + for (const [dateStr, cost] of dateMap) { + if (dateStr >= periodStartDateStr) { + periodCost += cost + } + } + } + + if (periodCost > 0) { + filledCount++ + } + + const weeklyKey = this._buildWeeklyOpusKey(keyId, periodString) + pipeline.set(weeklyKey, String(periodCost)) pipeline.expire(weeklyKey, ttlSeconds) } await pipeline.exec() } - // 写入 done 标记(保留略长于 1 周,避免同一周内重启重复回填)。 - await client.set(doneKey, new Date().toISOString(), 'EX', 10 * 24 * 3600) + // 写入 done 标记(保留 2 天,每天重新回填一次) + await client.set(doneKey, new Date().toISOString(), 'EX', 2 * 24 * 3600) const durationMs = Date.now() - startedAt logger.info( - `✅ 本周 Claude 周费用回填完成(${weekString}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${costByKeyId.size}(${durationMs}ms)` + `✅ Claude 周费用回填完成(${todayStr}):keys=${keyIds.length}, scanned=${scannedKeys}, matchedClaude=${matchedClaudeKeys}, filled=${filledCount}(${durationMs}ms)` ) return { success: true, - weekString, + todayStr, keyCount: keyIds.length, scannedKeys, matchedClaudeKeys, - filledKeys: costByKeyId.size, + filledKeys: filledCount, durationMs } } catch (error) { - logger.error(`❌ 本周 Claude 周费用回填失败(${weekString}):`, error) + logger.error(`❌ Claude 周费用回填失败(${todayStr}):`, error) return { success: false, error: error.message } } finally { await redis.releaseAccountLock(lockKey, lockValue) } } + + /** + * 为单个 API Key 回填当前周期费用(重置配置变更后触发) + */ + async backfillSingleKey(keyId) { + const client = redis.getClientSafe() + if (!client) { + logger.warn(`⚠️ 单 Key 回填跳过 (${keyId}):Redis client 不可用`) + return { success: false, reason: 'redis_unavailable' } + } + + if (!pricingService || !pricingService.pricingData) { + try { + await pricingService.initialize() + } catch (e) { + logger.warn(`⚠️ 单 Key 回填跳过 (${keyId}):pricing service 未初始化`) + return { success: false, reason: 'pricing_uninitialized' } + } + } + + try { + const keyData = await redis.getApiKey(keyId) + if (!keyData || Object.keys(keyData).length === 0) { + return { success: false, reason: 'key_not_found' } + } + + const resetDay = parseInt(keyData.weeklyResetDay || 1) + const resetHour = parseInt(keyData.weeklyResetHour || 0) + + const accountType = inferAccountType(keyData) + if (!accountType || !OPUS_ACCOUNT_TYPES.includes(accountType)) { + // 非 Claude 账户,写入 0 即可 + const periodString = redis.getPeriodString(resetDay, resetHour) + await redis.setWeeklyOpusCost(keyId, 0, periodString) + return { success: true, cost: 0, reason: 'non_claude_account' } + } + + const periodStart = redis.getPeriodStartDate(resetDay, resetHour) + const periodStartDateStr = formatTzDateYmd(periodStart) + const periodString = redis.getPeriodString(resetDay, resetHour) + + // 扫描最近 8 天的每日使用数据 + const dates = this._getLast7DaysInTimezone() + const globalRateCache = new Map() + let totalCost = 0 + + for (const dateStr of dates) { + if (dateStr < periodStartDateStr) { + continue + } + + let cursor = '0' + const pattern = `usage:${keyId}:model:daily:*:${dateStr}` + + do { + const [nextCursor, keys] = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 1000) + cursor = nextCursor + + if (keys.length === 0) { + continue + } + + const pipeline = client.pipeline() + const models = [] + for (const usageKey of keys) { + const match = usageKey.match(/^usage:[^:]+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/) + if (!match || !isOpusModel(match[1])) { + continue + } + models.push(match[1]) + pipeline.hgetall(usageKey) + } + + if (models.length === 0) { + continue + } + + const results = await pipeline.exec() + + for (let i = 0; i < models.length; i++) { + const model = models[i] + const [, data] = results[i] || [] + if (!data || Object.keys(data).length === 0) { + continue + } + + const inputTokens = toInt(data.totalInputTokens || data.inputTokens) + const outputTokens = toInt(data.totalOutputTokens || data.outputTokens) + const cacheReadTokens = toInt(data.totalCacheReadTokens || data.cacheReadTokens) + const cacheCreateTokens = toInt(data.totalCacheCreateTokens || data.cacheCreateTokens) + const ephemeral5mTokens = toInt(data.ephemeral5mTokens) + const ephemeral1hTokens = toInt(data.ephemeral1hTokens) + + const cacheCreationTotal = + ephemeral5mTokens > 0 || ephemeral1hTokens > 0 + ? ephemeral5mTokens + ephemeral1hTokens + : cacheCreateTokens + + const usage = { + input_tokens: inputTokens, + output_tokens: outputTokens, + cache_creation_input_tokens: cacheCreationTotal, + cache_read_input_tokens: cacheReadTokens + } + + if (ephemeral5mTokens > 0 || ephemeral1hTokens > 0) { + usage.cache_creation = { + ephemeral_5m_input_tokens: ephemeral5mTokens, + ephemeral_1h_input_tokens: ephemeral1hTokens + } + } + + const costInfo = pricingService.calculateCost(usage, model) + const realCost = costInfo && costInfo.totalCost ? costInfo.totalCost : 0 + if (realCost <= 0) { + continue + } + + const service = serviceRatesService.getService(accountType, model) + + let globalRate = globalRateCache.get(service) + if (globalRate === undefined) { + globalRate = await serviceRatesService.getServiceRate(service) + globalRateCache.set(service, globalRate) + } + + let keyRates = {} + try { + keyRates = JSON.parse(keyData.serviceRates || '{}') + } catch (e) { + keyRates = {} + } + const keyRate = keyRates[service] ?? 1.0 + totalCost += realCost * globalRate * keyRate + } + } while (cursor !== '0') + } + + await redis.setWeeklyOpusCost(keyId, totalCost, periodString) + logger.info( + `💰 单 Key 回填完成 (${keyId}):period=${periodString}, cost=$${totalCost.toFixed(6)}` + ) + + return { success: true, cost: totalCost, periodString } + } catch (error) { + logger.error(`❌ 单 Key 回填失败 (${keyId}):`, error) + return { success: false, error: error.message } + } + } } module.exports = new WeeklyClaudeCostInitService() diff --git a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue index 105ad210..18d6b8c8 100644 --- a/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/BatchEditApiKeyModal.vue @@ -246,8 +246,45 @@ type="number" />

- 设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效 + 设置 Claude 模型的周费用限制,仅对 Claude 模型请求生效

+
+
+ + +
+
+ + +
+
@@ -511,6 +548,8 @@ const form = reactive({ dailyCostLimit: '', totalCostLimit: '', weeklyOpusCostLimit: '', // 新增Claude周费用限制 + weeklyResetDay: '', + weeklyResetHour: '', permissions: '', // 空字符串表示不修改 claudeAccountId: '', geminiAccountId: '', @@ -737,6 +776,12 @@ const batchUpdateApiKeys = async () => { if (form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null) { updates.weeklyOpusCostLimit = parseFloat(form.weeklyOpusCostLimit) } + if (form.weeklyResetDay !== '' && form.weeklyResetDay !== null) { + updates.weeklyResetDay = Number(form.weeklyResetDay) + } + if (form.weeklyResetHour !== '' && form.weeklyResetHour !== null) { + updates.weeklyResetHour = Number(form.weeklyResetHour) + } // 权限设置 if (form.permissions !== '') { diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 5aad42f0..485da15b 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -428,9 +428,43 @@ type="number" />

- 设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0 - 或留空表示无限制 + 设置 Claude 模型的周费用限制,仅对 Claude 模型请求生效,0 或留空表示无限制

+
+
+ + +
+
+ + +
+
@@ -1061,6 +1095,8 @@ const form = reactive({ dailyCostLimit: '', totalCostLimit: '', weeklyOpusCostLimit: '', + weeklyResetDay: 1, + weeklyResetHour: 0, expireDuration: '', customExpireDate: '', expiresAt: null, @@ -1495,6 +1531,8 @@ const createApiKey = async () => { form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) : 0, + weeklyResetDay: form.weeklyResetDay, + weeklyResetHour: form.weeklyResetHour, expiresAt: form.expirationMode === 'fixed' ? form.expiresAt || undefined : undefined, expirationMode: form.expirationMode, activationDays: form.expirationMode === 'activation' ? form.activationDays : undefined, diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index f8b61990..f8215ae4 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -411,9 +411,43 @@ type="number" />

- 设置 Claude 模型的周费用限制(周一到周日),仅对 Claude 模型请求生效,0 - 或留空表示无限制 + 设置 Claude 模型的周费用限制,仅对 Claude 模型请求生效,0 或留空表示无限制

+
+
+ + +
+
+ + +
+
@@ -901,6 +935,8 @@ const form = reactive({ dailyCostLimit: '', totalCostLimit: '', weeklyOpusCostLimit: '', + weeklyResetDay: 1, + weeklyResetHour: 0, permissions: [], // 数组格式,空数组表示全部服务 claudeAccountId: '', geminiAccountId: '', @@ -1033,6 +1069,8 @@ const updateApiKey = async () => { form.weeklyOpusCostLimit !== '' && form.weeklyOpusCostLimit !== null ? parseFloat(form.weeklyOpusCostLimit) : 0, + weeklyResetDay: form.weeklyResetDay, + weeklyResetHour: form.weeklyResetHour, permissions: form.permissions, tags: form.tags } @@ -1355,6 +1393,8 @@ onMounted(async () => { form.dailyCostLimit = props.apiKey.dailyCostLimit || '' form.totalCostLimit = props.apiKey.totalCostLimit || '' form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || '' + form.weeklyResetDay = props.apiKey.weeklyResetDay || 1 + form.weeklyResetHour = props.apiKey.weeklyResetHour || 0 // 处理权限数据,兼容旧格式(字符串)和新格式(数组) // 有效的权限值 const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid'] diff --git a/web/admin-spa/src/components/apistats/LimitConfig.vue b/web/admin-spa/src/components/apistats/LimitConfig.vue index 6b43ed64..1efe9c7f 100644 --- a/web/admin-spa/src/components/apistats/LimitConfig.vue +++ b/web/admin-spa/src/components/apistats/LimitConfig.vue @@ -186,6 +186,17 @@ :style="{ width: getOpusWeeklyCostProgress() + '%' }" /> +

+ 每{{ + ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'][ + statsData.limits.weeklyResetDay || 1 + ] + }} + {{ String(statsData.limits.weeklyResetHour || 0).padStart(2, '0') }}:00 (UTC+8) 重置 +