feat: add per-API-key weekly cost reset day/hour configuration

Allow each API key to configure its own weekly reset day (1-7, Mon-Sun)
and hour (0-23) so the relay service's weekly limit tracking can align
with upstream Claude account reset schedules instead of using a fixed
Monday 00:00 boundary for all keys.
This commit is contained in:
yptse123
2026-02-22 17:56:42 +08:00
parent d6ced986b6
commit 955b2af08e
10 changed files with 612 additions and 104 deletions

View File

@@ -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({

View File

@@ -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
// ============== 用户消息队列相关方法 ==============

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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}`
)

View File

@@ -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<keyId, Map<dateStr, ratedCost>>
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()

View File

@@ -246,8 +246,45 @@
type="number"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
设置 Claude 模型的周费用限制周一到周日仅对 Claude 模型请求生效
设置 Claude 模型的周费用限制仅对 Claude 模型请求生效
</p>
<div
v-if="form.weeklyOpusCostLimit && Number(form.weeklyOpusCostLimit) > 0"
class="mt-2 flex gap-3"
>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置日</label
>
<select
v-model="form.weeklyResetDay"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option value="">不修改</option>
<option :value="1">周一</option>
<option :value="2">周二</option>
<option :value="3">周三</option>
<option :value="4">周四</option>
<option :value="5">周五</option>
<option :value="6">周六</option>
<option :value="7">周日</option>
</select>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置时间 (UTC+8)</label
>
<select
v-model="form.weeklyResetHour"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option value="">不修改</option>
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
</select>
</div>
</div>
</div>
<!-- 并发限制 -->
@@ -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 !== '') {

View File

@@ -428,9 +428,43 @@
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Claude 模型的周费用限制周一到周日仅对 Claude 模型请求生效0
或留空表示无限制
设置 Claude 模型的周费用限制仅对 Claude 模型请求生效0 或留空表示无限制
</p>
<div
v-if="form.weeklyOpusCostLimit && Number(form.weeklyOpusCostLimit) > 0"
class="mt-2 flex gap-3"
>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置日</label
>
<select
v-model="form.weeklyResetDay"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option :value="1">周一</option>
<option :value="2">周二</option>
<option :value="3">周三</option>
<option :value="4">周四</option>
<option :value="5">周五</option>
<option :value="6">周六</option>
<option :value="7">周日</option>
</select>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置时间 (UTC+8)</label
>
<select
v-model="form.weeklyResetHour"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
</select>
</div>
</div>
</div>
</div>
@@ -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,

View File

@@ -411,9 +411,43 @@
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">
设置 Claude 模型的周费用限制周一到周日仅对 Claude 模型请求生效0
或留空表示无限制
设置 Claude 模型的周费用限制仅对 Claude 模型请求生效0 或留空表示无限制
</p>
<div
v-if="form.weeklyOpusCostLimit && Number(form.weeklyOpusCostLimit) > 0"
class="mt-3 flex gap-3"
>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置日</label
>
<select
v-model="form.weeklyResetDay"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option :value="1">周一</option>
<option :value="2">周二</option>
<option :value="3">周三</option>
<option :value="4">周四</option>
<option :value="5">周五</option>
<option :value="6">周六</option>
<option :value="7">周日</option>
</select>
</div>
<div class="flex-1">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400"
>重置时间 (UTC+8)</label
>
<select
v-model="form.weeklyResetHour"
class="form-input w-full border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
>
<option v-for="h in 24" :key="h - 1" :value="h - 1">
{{ String(h - 1).padStart(2, '0') }}:00
</option>
</select>
</div>
</div>
</div>
</div>
@@ -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']

View File

@@ -186,6 +186,17 @@
:style="{ width: getOpusWeeklyCostProgress() + '%' }"
/>
</div>
<p
v-if="statsData.limits.weeklyResetDay"
class="mt-1 text-xs text-gray-400 dark:text-gray-500"
>
{{
['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'][
statsData.limits.weeklyResetDay || 1
]
}}
{{ String(statsData.limits.weeklyResetHour || 0).padStart(2, '0') }}:00 (UTC+8) 重置
</p>
</div>
<!-- 时间窗口限制 -->