mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-03-30 02:31:33 +00:00
Merge pull request #996 from yptse123/feat/per-key-weekly-reset-time [skip ci]
feat: add per-API-key weekly cost reset day/hour configuration
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 天(滚动窗口,无需长期保留)
|
||||
@@ -1805,31 +1881,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 批量执行,提高性能
|
||||
@@ -1846,13 +1922,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)
|
||||
}
|
||||
|
||||
@@ -3805,6 +3881,9 @@ redisClient.getDateInTimezone = getDateInTimezone
|
||||
redisClient.getDateStringInTimezone = getDateStringInTimezone
|
||||
redisClient.getHourInTimezone = getHourInTimezone
|
||||
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
|
||||
redisClient.getPeriodString = getPeriodString
|
||||
redisClient.getNextResetTime = getNextResetTime
|
||||
redisClient.getPeriodStartDate = getPeriodStartDate
|
||||
|
||||
// ============== 用户消息队列相关方法 ==============
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 只在启用了窗口限制时查询窗口数据(移到早期返回之前,确保窗口数据始终被获取)
|
||||
@@ -1492,7 +1494,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
|
||||
|
||||
// 输入验证
|
||||
@@ -1625,6 +1629,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,
|
||||
@@ -1653,7 +1673,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}`)
|
||||
@@ -1914,6 +1942,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) {
|
||||
@@ -1969,6 +2009,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) {
|
||||
@@ -2032,7 +2088,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
|
||||
|
||||
// 只允许更新指定字段
|
||||
@@ -2227,6 +2285,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') {
|
||||
@@ -2276,6 +2355,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) {
|
||||
|
||||
@@ -500,13 +500,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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -1645,7 +1667,7 @@ class ApiKeyService {
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户)
|
||||
// 📊 记录 Opus 模型费用(仅限 claude 和 claude-console 账户,支持自定义重置周期)
|
||||
// ratedCost: 倍率后的成本(用于限额校验)
|
||||
// realCost: 真实成本(用于对账),如果不传则等于 ratedCost
|
||||
async recordOpusCost(keyId, ratedCost, realCost, model, accountType) {
|
||||
@@ -1662,8 +1684,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}`
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 !== '') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 时间窗口限制 -->
|
||||
|
||||
Reference in New Issue
Block a user