From 4cc937a144ba3dca35cc406a9d26a16ee2fee4c9 Mon Sep 17 00:00:00 2001
From: sususu
Date: Fri, 5 Sep 2025 14:58:59 +0800
Subject: [PATCH] =?UTF-8?q?feat(Claude=20Console):=20=E6=B7=BB=E5=8A=A0Cla?=
=?UTF-8?q?ude=20Console=E8=B4=A6=E5=8F=B7=E6=AF=8F=E6=97=A5=E9=85=8D?=
=?UTF-8?q?=E9=A2=9D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
1. 额度检查优先级更高:即使不启用限流机制,超额仍会禁用账户
2. 状态会被覆盖:quota_exceeded 会覆盖 rate_limited
3. 两种恢复时间:
- 限流恢复:分钟级(如60分钟)
- 额度恢复:天级(第二天重置)
4. 独立控制:
- rateLimitDuration = 0:只管理额度,忽略429
- rateLimitDuration > 0:同时管理限流和额度
---
src/routes/admin.js | 58 ++-
src/services/claudeConsoleAccountService.js | 339 +++++++++++++++++-
src/services/claudeConsoleRelayService.js | 13 +
src/services/unifiedClaudeScheduler.js | 39 +-
.../src/components/accounts/AccountForm.vue | 168 ++++++++-
web/admin-spa/src/views/AccountsView.vue | 61 ++++
6 files changed, 656 insertions(+), 22 deletions(-)
diff --git a/src/routes/admin.js b/src/routes/admin.js
index f29a43b9..62540a8c 100644
--- a/src/routes/admin.js
+++ b/src/routes/admin.js
@@ -2292,7 +2292,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
rateLimitDuration,
proxy,
accountType,
- groupId
+ groupId,
+ dailyQuota,
+ quotaResetTime
} = req.body
if (!name || !apiUrl || !apiKey) {
@@ -2327,7 +2329,9 @@ router.post('/claude-console-accounts', authenticateAdmin, async (req, res) => {
rateLimitDuration:
rateLimitDuration !== undefined && rateLimitDuration !== null ? rateLimitDuration : 60,
proxy,
- accountType: accountType || 'shared'
+ accountType: accountType || 'shared',
+ dailyQuota: dailyQuota || 0,
+ quotaResetTime: quotaResetTime || '00:00'
})
// 如果是分组类型,将账户添加到分组
@@ -2506,6 +2510,56 @@ router.put(
}
)
+// 获取Claude Console账户的使用统计
+router.get('/claude-console-accounts/:accountId/usage', authenticateAdmin, async (req, res) => {
+ try {
+ const { accountId } = req.params
+ const usageStats = await claudeConsoleAccountService.getAccountUsageStats(accountId)
+
+ if (!usageStats) {
+ return res.status(404).json({ error: 'Account not found' })
+ }
+
+ return res.json(usageStats)
+ } catch (error) {
+ logger.error('❌ Failed to get Claude Console account usage stats:', error)
+ return res.status(500).json({ error: 'Failed to get usage stats', message: error.message })
+ }
+})
+
+// 手动重置Claude Console账户的每日使用量
+router.post(
+ '/claude-console-accounts/:accountId/reset-usage',
+ authenticateAdmin,
+ async (req, res) => {
+ try {
+ const { accountId } = req.params
+ await claudeConsoleAccountService.resetDailyUsage(accountId)
+
+ logger.success(`✅ Admin manually reset daily usage for Claude Console account: ${accountId}`)
+ return res.json({ success: true, message: 'Daily usage reset successfully' })
+ } catch (error) {
+ logger.error('❌ Failed to reset Claude Console account daily usage:', error)
+ return res.status(500).json({ error: 'Failed to reset daily usage', message: error.message })
+ }
+ }
+)
+
+// 手动重置所有Claude Console账户的每日使用量
+router.post('/claude-console-accounts/reset-all-usage', authenticateAdmin, async (req, res) => {
+ try {
+ await claudeConsoleAccountService.resetAllDailyUsage()
+
+ logger.success('✅ Admin manually reset daily usage for all Claude Console accounts')
+ return res.json({ success: true, message: 'All daily usage reset successfully' })
+ } catch (error) {
+ logger.error('❌ Failed to reset all Claude Console accounts daily usage:', error)
+ return res
+ .status(500)
+ .json({ error: 'Failed to reset all daily usage', message: error.message })
+ }
+})
+
// ☁️ Bedrock 账户管理
// 获取所有Bedrock账户
diff --git a/src/services/claudeConsoleAccountService.js b/src/services/claudeConsoleAccountService.js
index 28be976d..34c9a5c7 100644
--- a/src/services/claudeConsoleAccountService.js
+++ b/src/services/claudeConsoleAccountService.js
@@ -50,7 +50,9 @@ class ClaudeConsoleAccountService {
proxy = null,
isActive = true,
accountType = 'shared', // 'dedicated' or 'shared'
- schedulable = true // 是否可被调度
+ schedulable = true, // 是否可被调度
+ dailyQuota = 0, // 每日额度限制(美元),0表示不限制
+ quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
} = options
// 验证必填字段
@@ -85,7 +87,14 @@ class ClaudeConsoleAccountService {
rateLimitedAt: '',
rateLimitStatus: '',
// 调度控制
- schedulable: schedulable.toString()
+ schedulable: schedulable.toString(),
+ // 额度管理相关
+ dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
+ dailyUsage: '0', // 当日使用金额(美元)
+ // 使用与统计一致的时区日期,避免边界问题
+ lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
+ quotaResetTime, // 额度重置时间
+ quotaStoppedAt: '' // 因额度停用的时间
}
const client = redis.getClientSafe()
@@ -116,7 +125,12 @@ class ClaudeConsoleAccountService {
proxy,
accountType,
status: 'active',
- createdAt: accountData.createdAt
+ createdAt: accountData.createdAt,
+ dailyQuota,
+ dailyUsage: 0,
+ lastResetDate: accountData.lastResetDate,
+ quotaResetTime,
+ quotaStoppedAt: null
}
}
@@ -148,12 +162,18 @@ class ClaudeConsoleAccountService {
isActive: accountData.isActive === 'true',
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
accountType: accountData.accountType || 'shared',
- status: accountData.status,
- errorMessage: accountData.errorMessage,
createdAt: accountData.createdAt,
lastUsedAt: accountData.lastUsedAt,
- rateLimitStatus: rateLimitInfo,
- schedulable: accountData.schedulable !== 'false' // 默认为true,只有明确设置为false才不可调度
+ status: accountData.status || 'active',
+ errorMessage: accountData.errorMessage,
+ rateLimitInfo,
+ schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
+ // 额度管理相关
+ dailyQuota: parseFloat(accountData.dailyQuota || '0'),
+ dailyUsage: parseFloat(accountData.dailyUsage || '0'),
+ lastResetDate: accountData.lastResetDate || '',
+ quotaResetTime: accountData.quotaResetTime || '00:00',
+ quotaStoppedAt: accountData.quotaStoppedAt || null
})
}
}
@@ -267,6 +287,23 @@ class ClaudeConsoleAccountService {
updatedData.schedulable = updates.schedulable.toString()
}
+ // 额度管理相关字段
+ if (updates.dailyQuota !== undefined) {
+ updatedData.dailyQuota = updates.dailyQuota.toString()
+ }
+ if (updates.quotaResetTime !== undefined) {
+ updatedData.quotaResetTime = updates.quotaResetTime
+ }
+ if (updates.dailyUsage !== undefined) {
+ updatedData.dailyUsage = updates.dailyUsage.toString()
+ }
+ if (updates.lastResetDate !== undefined) {
+ updatedData.lastResetDate = updates.lastResetDate
+ }
+ if (updates.quotaStoppedAt !== undefined) {
+ updatedData.quotaStoppedAt = updates.quotaStoppedAt
+ }
+
// 处理账户类型变更
if (updates.accountType && updates.accountType !== existingAccount.accountType) {
updatedData.accountType = updates.accountType
@@ -361,7 +398,16 @@ class ClaudeConsoleAccountService {
const updates = {
rateLimitedAt: new Date().toISOString(),
- rateLimitStatus: 'limited'
+ rateLimitStatus: 'limited',
+ isActive: 'false', // 禁用账户
+ errorMessage: `Rate limited at ${new Date().toISOString()}`
+ }
+
+ // 只有当前状态不是quota_exceeded时才设置为rate_limited
+ // 避免覆盖更重要的配额超限状态
+ const currentStatus = await client.hget(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, 'status')
+ if (currentStatus !== 'quota_exceeded') {
+ updates.status = 'rate_limited'
}
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
@@ -376,7 +422,7 @@ class ClaudeConsoleAccountService {
platform: 'claude-console',
status: 'error',
errorCode: 'CLAUDE_CONSOLE_RATE_LIMITED',
- reason: `Account rate limited (429 error). ${account.rateLimitDuration ? `Will be blocked for ${account.rateLimitDuration} hours` : 'Temporary rate limit'}`,
+ reason: `Account rate limited (429 error) and has been disabled. ${account.rateLimitDuration ? `Will be automatically re-enabled after ${account.rateLimitDuration} minutes` : 'Manual intervention required to re-enable'}`,
timestamp: getISOStringWithTimezone(new Date())
})
} catch (webhookError) {
@@ -397,14 +443,40 @@ class ClaudeConsoleAccountService {
async removeAccountRateLimit(accountId) {
try {
const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
- await client.hdel(
- `${this.ACCOUNT_KEY_PREFIX}${accountId}`,
- 'rateLimitedAt',
- 'rateLimitStatus'
+ // 获取账户当前状态和额度信息
+ const [currentStatus, quotaStoppedAt] = await client.hmget(
+ accountKey,
+ 'status',
+ 'quotaStoppedAt'
)
- logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
+ // 删除限流相关字段
+ await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
+
+ // 根据不同情况决定是否恢复账户
+ if (currentStatus === 'rate_limited') {
+ if (quotaStoppedAt) {
+ // 还有额度限制,改为quota_exceeded状态
+ await client.hset(accountKey, {
+ status: 'quota_exceeded'
+ // isActive保持false
+ })
+ logger.info(`⚠️ Rate limit removed but quota exceeded remains for account: ${accountId}`)
+ } else {
+ // 没有额度限制,完全恢复
+ await client.hset(accountKey, {
+ isActive: 'true',
+ status: 'active',
+ errorMessage: ''
+ })
+ logger.success(`✅ Rate limit removed and account re-enabled: ${accountId}`)
+ }
+ } else {
+ logger.success(`✅ Rate limit removed for Claude Console account: ${accountId}`)
+ }
+
return { success: true }
} catch (error) {
logger.error(`❌ Failed to remove rate limit for Claude Console account: ${accountId}`, error)
@@ -454,6 +526,64 @@ class ClaudeConsoleAccountService {
}
}
+ // 🔍 检查账号是否因额度超限而被停用(懒惰检查)
+ async isAccountQuotaExceeded(accountId) {
+ try {
+ const account = await this.getAccount(accountId)
+ if (!account) {
+ return false
+ }
+
+ // 如果没有设置额度限制,不会超额
+ const dailyQuota = parseFloat(account.dailyQuota || '0')
+ if (isNaN(dailyQuota) || dailyQuota <= 0) {
+ return false
+ }
+
+ // 如果账户没有被额度停用,检查当前使用情况
+ if (!account.quotaStoppedAt) {
+ return false
+ }
+
+ // 检查是否应该重置额度(到了新的重置时间点)
+ if (this._shouldResetQuota(account)) {
+ await this.resetDailyUsage(accountId)
+ return false
+ }
+
+ // 仍在额度超限状态
+ return true
+ } catch (error) {
+ logger.error(
+ `❌ Failed to check quota exceeded status for Claude Console account: ${accountId}`,
+ error
+ )
+ return false
+ }
+ }
+
+ // 🔍 判断是否应该重置账户额度
+ _shouldResetQuota(account) {
+ // 与 Redis 统计一致:按配置时区判断“今天”与时间点
+ const tzNow = redis.getDateInTimezone(new Date())
+ const today = redis.getDateStringInTimezone(tzNow)
+
+ // 如果已经是今天重置过的,不需要重置
+ if (account.lastResetDate === today) {
+ return false
+ }
+
+ // 检查是否到了重置时间点(按配置时区的小时/分钟)
+ const resetTime = account.quotaResetTime || '00:00'
+ const [resetHour, resetMinute] = resetTime.split(':').map((n) => parseInt(n))
+
+ const currentHour = tzNow.getUTCHours()
+ const currentMinute = tzNow.getUTCMinutes()
+
+ // 如果当前时间已过重置时间且不是同一天重置的,应该重置
+ return currentHour > resetHour || (currentHour === resetHour && currentMinute >= resetMinute)
+ }
+
// 🚫 标记账号为未授权状态(401错误)
async markAccountUnauthorized(accountId) {
try {
@@ -820,6 +950,187 @@ class ClaudeConsoleAccountService {
// 返回映射后的模型,如果不存在则返回原模型
return modelMapping[requestedModel] || requestedModel
}
+
+ // 💰 检查账户使用额度(基于实时统计数据)
+ async checkQuotaUsage(accountId) {
+ try {
+ // 获取实时的使用统计(包含费用)
+ const usageStats = await redis.getAccountUsageStats(accountId)
+ const currentDailyCost = usageStats.daily.cost || 0
+
+ // 获取账户配置
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ logger.warn(`Account not found: ${accountId}`)
+ return
+ }
+
+ // 解析额度配置,确保数值有效
+ const dailyQuota = parseFloat(accountData.dailyQuota || '0')
+ if (isNaN(dailyQuota) || dailyQuota <= 0) {
+ // 没有设置有效额度,无需检查
+ return
+ }
+
+ // 检查是否已经因额度停用(避免重复操作)
+ if (!accountData.isActive && accountData.quotaStoppedAt) {
+ return
+ }
+
+ // 检查是否超过额度限制
+ if (currentDailyCost >= dailyQuota) {
+ // 使用原子操作避免竞态条件 - 再次检查是否已设置quotaStoppedAt
+ const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
+
+ // double-check locking pattern - 检查quotaStoppedAt而不是status
+ const existingQuotaStop = await client.hget(accountKey, 'quotaStoppedAt')
+ if (existingQuotaStop) {
+ return // 已经被其他进程处理
+ }
+
+ // 超过额度,停用账户
+ const updates = {
+ isActive: false,
+ quotaStoppedAt: new Date().toISOString(),
+ errorMessage: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ }
+
+ // 只有当前状态是active时才改为quota_exceeded
+ // 如果是rate_limited等其他状态,保持原状态不变
+ const currentStatus = await client.hget(accountKey, 'status')
+ if (currentStatus === 'active') {
+ updates.status = 'quota_exceeded'
+ }
+
+ await this.updateAccount(accountId, updates)
+
+ logger.warn(
+ `💰 Account ${accountId} exceeded daily quota: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ )
+
+ // 发送webhook通知
+ try {
+ const webhookNotifier = require('../utils/webhookNotifier')
+ await webhookNotifier.sendAccountAnomalyNotification({
+ accountId,
+ accountName: accountData.name || 'Unknown Account',
+ platform: 'claude-console',
+ status: 'quota_exceeded',
+ errorCode: 'CLAUDE_CONSOLE_QUOTA_EXCEEDED',
+ reason: `Daily quota exceeded: $${currentDailyCost.toFixed(2)} / $${dailyQuota.toFixed(2)}`
+ })
+ } catch (webhookError) {
+ logger.error('Failed to send webhook notification for quota exceeded:', webhookError)
+ }
+ }
+
+ logger.debug(
+ `💰 Quota check for account ${accountId}: $${currentDailyCost.toFixed(4)} / $${dailyQuota.toFixed(2)}`
+ )
+ } catch (error) {
+ logger.error('Failed to check quota usage:', error)
+ }
+ }
+
+ // 🔄 重置账户每日使用量(恢复因额度停用的账户)
+ async resetDailyUsage(accountId) {
+ try {
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ return
+ }
+
+ const today = redis.getDateStringInTimezone()
+ const updates = {
+ lastResetDate: today
+ }
+
+ // 如果账户是因为超额被停用的,恢复账户
+ // 注意:状态可能是 quota_exceeded 或 rate_limited(如果429错误时也超额了)
+ if (
+ accountData.quotaStoppedAt &&
+ accountData.isActive === false &&
+ (accountData.status === 'quota_exceeded' || accountData.status === 'rate_limited')
+ ) {
+ updates.isActive = true
+ updates.status = 'active'
+ updates.errorMessage = ''
+ updates.quotaStoppedAt = ''
+
+ // 如果是rate_limited状态,也清除限流相关字段
+ if (accountData.status === 'rate_limited') {
+ const client = redis.getClientSafe()
+ const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
+ await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
+ }
+
+ logger.info(
+ `✅ Restored account ${accountId} after daily reset (was ${accountData.status})`
+ )
+ }
+
+ await this.updateAccount(accountId, updates)
+
+ logger.debug(`🔄 Reset daily usage for account ${accountId}`)
+ } catch (error) {
+ logger.error('Failed to reset daily usage:', error)
+ }
+ }
+
+ // 🔄 重置所有账户的每日使用量
+ async resetAllDailyUsage() {
+ try {
+ const accounts = await this.getAllAccounts()
+ // 与统计一致使用配置时区日期
+ const today = redis.getDateStringInTimezone()
+ let resetCount = 0
+
+ for (const account of accounts) {
+ // 只重置需要重置的账户
+ if (account.lastResetDate !== today) {
+ await this.resetDailyUsage(account.id)
+ resetCount += 1
+ }
+ }
+
+ logger.success(`✅ Reset daily usage for ${resetCount} Claude Console accounts`)
+ } catch (error) {
+ logger.error('Failed to reset all daily usage:', error)
+ }
+ }
+
+ // 📊 获取账户使用统计(基于实时数据)
+ async getAccountUsageStats(accountId) {
+ try {
+ // 获取实时的使用统计(包含费用)
+ const usageStats = await redis.getAccountUsageStats(accountId)
+ const currentDailyCost = usageStats.daily.cost || 0
+
+ // 获取账户配置
+ const accountData = await this.getAccount(accountId)
+ if (!accountData) {
+ return null
+ }
+
+ const dailyQuota = parseFloat(accountData.dailyQuota || '0')
+
+ return {
+ dailyQuota,
+ dailyUsage: currentDailyCost, // 使用实时计算的费用
+ remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
+ usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
+ lastResetDate: accountData.lastResetDate,
+ quotaStoppedAt: accountData.quotaStoppedAt,
+ isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
+ // 额外返回完整的使用统计
+ fullUsageStats: usageStats
+ }
+ } catch (error) {
+ logger.error('Failed to get account usage stats:', error)
+ return null
+ }
+ }
}
module.exports = new ClaudeConsoleAccountService()
diff --git a/src/services/claudeConsoleRelayService.js b/src/services/claudeConsoleRelayService.js
index 27920a47..9785bdd0 100644
--- a/src/services/claudeConsoleRelayService.js
+++ b/src/services/claudeConsoleRelayService.js
@@ -181,6 +181,11 @@ class ClaudeConsoleRelayService {
await claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
logger.warn(`🚫 Rate limit detected for Claude Console account ${accountId}`)
+ // 收到429先检查是否因为超过了手动配置的每日额度
+ await claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
+
await claudeConsoleAccountService.markAccountRateLimited(accountId)
} else if (response.status === 529) {
logger.warn(`🚫 Overload error detected for Claude Console account ${accountId}`)
@@ -377,6 +382,10 @@ class ClaudeConsoleRelayService {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
+ // 检查是否因为超过每日额度
+ claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
} else if (response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
@@ -589,6 +598,10 @@ class ClaudeConsoleRelayService {
claudeConsoleAccountService.markAccountUnauthorized(accountId)
} else if (error.response.status === 429) {
claudeConsoleAccountService.markAccountRateLimited(accountId)
+ // 检查是否因为超过每日额度
+ claudeConsoleAccountService.checkQuotaUsage(accountId).catch((err) => {
+ logger.error('❌ Failed to check quota after 429 error:', err)
+ })
} else if (error.response.status === 529) {
claudeConsoleAccountService.markAccountOverloaded(accountId)
}
diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js
index 34e61a81..8b247d97 100644
--- a/src/services/unifiedClaudeScheduler.js
+++ b/src/services/unifiedClaudeScheduler.js
@@ -209,10 +209,20 @@ class UnifiedClaudeScheduler {
boundConsoleAccount.isActive === true &&
boundConsoleAccount.status === 'active'
) {
+ // 主动触发一次额度检查
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(boundConsoleAccount.id)
+ } catch (e) {}
+
+ // 检查限流状态和额度状态
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(
boundConsoleAccount.id
)
- if (!isRateLimited) {
+ const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(
+ boundConsoleAccount.id
+ )
+
+ if (!isRateLimited && !isQuotaExceeded) {
logger.info(
`🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId})`
)
@@ -358,9 +368,16 @@ class UnifiedClaudeScheduler {
}
}
- // 检查是否被限流
+ // 主动触发一次额度检查,确保状态即时生效
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(account.id)
+ } catch (e) {}
+
+ // 检查是否被限流或额度超限
const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited(account.id)
- if (!isRateLimited) {
+ const isQuotaExceeded = await claudeConsoleAccountService.isAccountQuotaExceeded(account.id)
+
+ if (!isRateLimited && !isQuotaExceeded) {
availableAccounts.push({
...account,
accountId: account.id,
@@ -372,7 +389,12 @@ class UnifiedClaudeScheduler {
`✅ Added Claude Console account to available pool: ${account.name} (priority: ${account.priority})`
)
} else {
- logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
+ if (isRateLimited) {
+ logger.warn(`⚠️ Claude Console account ${account.name} is rate limited`)
+ }
+ if (isQuotaExceeded) {
+ logger.warn(`💰 Claude Console account ${account.name} quota exceeded`)
+ }
}
} else {
logger.info(
@@ -475,10 +497,17 @@ class UnifiedClaudeScheduler {
logger.info(`🚫 Claude Console account ${accountId} is not schedulable`)
return false
}
- // 检查是否被限流
+ // 主动触发一次额度检查
+ try {
+ await claudeConsoleAccountService.checkQuotaUsage(accountId)
+ } catch (e) {}
+ // 检查是否被限流或额度超限
if (await claudeConsoleAccountService.isAccountRateLimited(accountId)) {
return false
}
+ if (await claudeConsoleAccountService.isAccountQuotaExceeded(accountId)) {
+ return false
+ }
// 检查是否未授权(401错误)
if (account.status === 'unauthorized') {
return false
diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue
index 19182c8b..0d4c3226 100644
--- a/web/admin-spa/src/components/accounts/AccountForm.vue
+++ b/web/admin-spa/src/components/accounts/AccountForm.vue
@@ -658,6 +658,41 @@
+
+
+
+
+
+
+ 设置每日使用额度,0 表示不限制
+
+
+
+
+
+
+
+ 每日自动重置额度的时间
+
+
+
+
留空表示不更新 API Key
+
+
+
+
+
+
+ 设置每日使用额度,0 表示不限制
+
+
+
+
+
+
+
每日自动重置额度的时间
+
+
+
+
+
+
+
+ 今日使用情况
+
+
+ ${{ calculateCurrentUsage().toFixed(4) }} / ${{ form.dailyQuota.toFixed(2) }}
+
+
+
+
+
+ 剩余: ${{ Math.max(0, form.dailyQuota - calculateCurrentUsage()).toFixed(2) }}
+
+
+ {{ usagePercentage.toFixed(1) }}% 已使用
+
+
+
+
0 : true,
rateLimitDuration: props.account?.rateLimitDuration || 60,
+ // 额度管理字段
+ dailyQuota: props.account?.dailyQuota || 0,
+ dailyUsage: props.account?.dailyUsage || 0,
+ quotaResetTime: props.account?.quotaResetTime || '00:00',
// Bedrock 特定字段
accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '',
@@ -2162,6 +2270,45 @@ const canExchangeSetupToken = computed(() => {
return setupTokenAuthUrl.value && setupTokenAuthCode.value.trim()
})
+// 获取当前使用量(实时)
+const calculateCurrentUsage = () => {
+ // 如果不是编辑模式或没有账户ID,返回0
+ if (!isEdit.value || !props.account?.id) {
+ return 0
+ }
+
+ // 如果已经加载了今日使用数据,直接使用
+ if (typeof form.value.dailyUsage === 'number') {
+ return form.value.dailyUsage
+ }
+
+ return 0
+}
+
+// 计算额度使用百分比
+const usagePercentage = computed(() => {
+ if (!form.value.dailyQuota || form.value.dailyQuota <= 0) {
+ return 0
+ }
+ const currentUsage = calculateCurrentUsage()
+ return (currentUsage / form.value.dailyQuota) * 100
+})
+
+// 加载账户今日使用情况
+const loadAccountUsage = async () => {
+ if (!isEdit.value || !props.account?.id) return
+
+ try {
+ const response = await apiClient.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
+ if (response) {
+ // 更新表单中的使用量数据
+ form.value.dailyUsage = response.dailyUsage || 0
+ }
+ } catch (error) {
+ console.warn('Failed to load account usage:', error)
+ }
+}
+
// // 计算是否可以创建
// const canCreate = computed(() => {
// if (form.value.addType === 'manual') {
@@ -2601,6 +2748,9 @@ const createAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
+ // 额度管理字段
+ data.dailyQuota = form.value.dailyQuota || 0
+ data.quotaResetTime = form.value.quotaResetTime || '00:00'
} else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
data.awsCredentials = {
@@ -2798,6 +2948,9 @@ const updateAccount = async () => {
data.userAgent = form.value.userAgent || null
// 如果不启用限流,传递 0 表示不限流
data.rateLimitDuration = form.value.enableRateLimit ? form.value.rateLimitDuration || 60 : 0
+ // 额度管理字段
+ data.dailyQuota = form.value.dailyQuota || 0
+ data.quotaResetTime = form.value.quotaResetTime || '00:00'
}
// Bedrock 特定更新
@@ -3207,7 +3360,16 @@ watch(
// Azure OpenAI 特定字段
azureEndpoint: newAccount.azureEndpoint || '',
apiVersion: newAccount.apiVersion || '',
- deploymentName: newAccount.deploymentName || ''
+ deploymentName: newAccount.deploymentName || '',
+ // 额度管理字段
+ dailyQuota: newAccount.dailyQuota || 0,
+ dailyUsage: newAccount.dailyUsage || 0,
+ quotaResetTime: newAccount.quotaResetTime || '00:00'
+ }
+
+ // 如果是Claude Console账户,加载实时使用情况
+ if (newAccount.platform === 'claude-console') {
+ loadAccountUsage()
}
// 如果是分组类型,加载分组ID
@@ -3287,6 +3449,10 @@ const clearUnifiedCache = async () => {
onMounted(() => {
// 获取Claude Code统一User-Agent信息
fetchUnifiedUserAgent()
+ // 如果是编辑模式且是Claude Console账户,加载使用情况
+ if (isEdit.value && props.account?.platform === 'claude-console') {
+ loadAccountUsage()
+ }
})
// 监听平台变化,当切换到Claude平台时获取统一User-Agent信息
diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue
index 01fae1c5..9c2ccb7e 100644
--- a/web/admin-spa/src/views/AccountsView.vue
+++ b/web/admin-spa/src/views/AccountsView.vue
@@ -584,6 +584,44 @@
+
+
+
+
+ 额度进度
+
+ {{ getQuotaUsagePercent(account).toFixed(1) }}%
+
+
+
+
+
+ ${{ formatCost(account.usage?.daily?.cost || 0) }} / ${{
+ Number(account.dailyQuota).toFixed(2)
+ }}
+
+
+
+ 剩余 ${{ formatRemainingQuota(account) }}
+ 重置 {{ account.quotaResetTime || '00:00' }}
+
+
+
+
+
+
@@ -1788,6 +1826,29 @@ const formatCost = (cost) => {
return cost.toFixed(2)
}
+// 额度使用百分比(Claude Console)
+const getQuotaUsagePercent = (account) => {
+ const used = Number(account?.usage?.daily?.cost || 0)
+ const quota = Number(account?.dailyQuota || 0)
+ if (!quota || quota <= 0) return 0
+ return (used / quota) * 100
+}
+
+// 额度进度条颜色(Claude Console)
+const getQuotaBarClass = (percent) => {
+ if (percent >= 90) return 'bg-red-500'
+ if (percent >= 70) return 'bg-yellow-500'
+ return 'bg-green-500'
+}
+
+// 剩余额度(Claude Console)
+const formatRemainingQuota = (account) => {
+ const used = Number(account?.usage?.daily?.cost || 0)
+ const quota = Number(account?.dailyQuota || 0)
+ if (!quota || quota <= 0) return '0.00'
+ return Math.max(0, quota - used).toFixed(2)
+}
+
// 计算每日费用(使用后端返回的精确费用数据)
const calculateDailyCost = (account) => {
if (!account.usage || !account.usage.daily) return '0.0000'