From 0d64d4065429b359c7278668431836bf43f9967a Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Fri, 5 Dec 2025 01:36:59 +0800 Subject: [PATCH 01/32] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E4=B8=8D=E7=A8=B3=E5=AE=9A=E9=94=99=E8=AF=AF=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E8=B4=A6=E6=88=B7=E4=B8=B4=E6=97=B6=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E7=94=A8=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 背景 当上游 API(如 Anthropic、AWS Bedrock 等)出现临时故障时,服务会持续向故障 账户发送请求,导致用户体验下降。需要自动检测上游不稳定状态并临时排除故障账户。 ## 改动内容 ### 新增 unstableUpstreamHelper.js - 检测多种上游不稳定错误模式 - 支持环境变量扩展检测规则 ### 修改 unifiedClaudeScheduler.js - 新增 markAccountTemporarilyUnavailable() 方法:标记账户临时不可用 - 新增 isAccountTemporarilyUnavailable() 方法:检查账户是否临时不可用 - 专属账户检查:claude-official、claude-console、bedrock 临时不可用时自动回退到池 - 池账户选择:跳过临时不可用的账户 ### 修改 claudeRelayService.js - _handleServerError() 方法增加临时不可用标记逻辑 - 5xx 错误时自动标记账户临时不可用(5分钟 TTL) ## 检测的状态码 | 分类 | 状态码 | 说明 | |------|--------|------| | 服务器错误 | 500-599 | 内部错误、服务不可用等 | | 超时类 | 408 | 请求超时 | | 连接类 | 499 | 客户端关闭请求 (Nginx) | | 网关类 | 502, 503, 504 | 网关错误、服务不可用、网关超时 | | CDN类 | 522 | Cloudflare 连接超时 | | 语义类 | error.type = "server_error" | API 级别服务器错误 | ## 环境变量配置 - UNSTABLE_ERROR_TYPES: 额外的错误类型(逗号分隔) - UNSTABLE_ERROR_KEYWORDS: 错误消息关键词(逗号分隔) ## Redis 键 - temp_unavailable:{accountType}:{accountId} - TTL 300秒 --- src/services/claudeRelayService.js | 20 ++- src/services/unifiedClaudeScheduler.js | 168 ++++++++++++++++++++----- src/utils/unstableUpstreamHelper.js | 77 ++++++++++++ 3 files changed, 233 insertions(+), 32 deletions(-) create mode 100644 src/utils/unstableUpstreamHelper.js diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 9feeae0d..166d575b 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -1948,7 +1948,13 @@ class ClaudeRelayService { } // 🛠️ 统一的错误处理方法 - async _handleServerError(accountId, statusCode, _sessionHash = null, context = '') { + async _handleServerError( + accountId, + statusCode, + sessionHash = null, + context = '', + accountType = 'claude-official' + ) { try { await claudeAccountService.recordServerError(accountId, statusCode) const errorCount = await claudeAccountService.getServerErrorCount(accountId) @@ -1962,6 +1968,18 @@ class ClaudeRelayService { `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` ) + // 标记账户为临时不可用(5分钟) + try { + await unifiedClaudeScheduler.markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash, + 300 + ) + } catch (markError) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, markError) + } + if (errorCount > threshold) { const errorTypeLabel = isTimeout ? 'timeout' : '5xx' // ⚠️ 只记录5xx/504告警,不再自动停止调度,避免上游抖动导致误停 diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index e68d607e..73def6a8 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -177,30 +177,41 @@ class UnifiedClaudeScheduler { // 普通专属账户 const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId) if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { - const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) - if (isRateLimited) { - const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) - const error = new Error('Dedicated Claude account is rate limited') - error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' - error.accountId = boundAccount.id - error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null - throw error - } - - if (!this._isSchedulable(boundAccount.schedulable)) { + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundAccount.id, + 'claude-official' + ) + if (isTempUnavailable) { logger.warn( - `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + `⏱️ Bound Claude OAuth account ${boundAccount.id} is temporarily unavailable, falling back to pool` ) } else { - if (isOpusRequest) { - await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + const isRateLimited = await claudeAccountService.isAccountRateLimited(boundAccount.id) + if (isRateLimited) { + const rateInfo = await claudeAccountService.getAccountRateLimitInfo(boundAccount.id) + const error = new Error('Dedicated Claude account is rate limited') + error.code = 'CLAUDE_DEDICATED_RATE_LIMITED' + error.accountId = boundAccount.id + error.rateLimitEndAt = rateInfo?.rateLimitEndAt || boundAccount.rateLimitEndAt || null + throw error } - logger.info( - `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` - ) - return { - accountId: apiKeyData.claudeAccountId, - accountType: 'claude-official' + + if (!this._isSchedulable(boundAccount.schedulable)) { + logger.warn( + `⚠️ Bound Claude OAuth account ${apiKeyData.claudeAccountId} is not schedulable (schedulable: ${boundAccount?.schedulable}), falling back to pool` + ) + } else { + if (isOpusRequest) { + await claudeAccountService.clearExpiredOpusRateLimit(boundAccount.id) + } + logger.info( + `🎯 Using bound dedicated Claude OAuth account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeAccountId, + accountType: 'claude-official' + } } } } else { @@ -221,12 +232,23 @@ class UnifiedClaudeScheduler { boundConsoleAccount.status === 'active' && this._isSchedulable(boundConsoleAccount.schedulable) ) { - logger.info( - `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + boundConsoleAccount.id, + 'claude-console' ) - return { - accountId: apiKeyData.claudeConsoleAccountId, - accountType: 'claude-console' + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Claude Console account ${boundConsoleAccount.id} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Claude Console account: ${boundConsoleAccount.name} (${apiKeyData.claudeConsoleAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.claudeConsoleAccountId, + accountType: 'claude-console' + } } } else { logger.warn( @@ -245,12 +267,23 @@ class UnifiedClaudeScheduler { boundBedrockAccountResult.data.isActive === true && this._isSchedulable(boundBedrockAccountResult.data.schedulable) ) { - logger.info( - `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + apiKeyData.bedrockAccountId, + 'bedrock' ) - return { - accountId: apiKeyData.bedrockAccountId, - accountType: 'bedrock' + if (isTempUnavailable) { + logger.warn( + `⏱️ Bound Bedrock account ${apiKeyData.bedrockAccountId} is temporarily unavailable, falling back to pool` + ) + } else { + logger.info( + `🎯 Using bound dedicated Bedrock account: ${boundBedrockAccountResult.data.name} (${apiKeyData.bedrockAccountId}) for API key ${apiKeyData.name}` + ) + return { + accountId: apiKeyData.bedrockAccountId, + accountType: 'bedrock' + } } } else { logger.warn( @@ -496,6 +529,18 @@ class UnifiedClaudeScheduler { continue } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'claude-official' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Official account ${account.name} - temporarily unavailable` + ) + continue + } + // 检查是否被限流 const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) if (isRateLimited) { @@ -584,6 +629,18 @@ class UnifiedClaudeScheduler { // 继续处理该账号 } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + currentAccount.id, + 'claude-console' + ) + if (isTempUnavailable) { + logger.debug( + `⏭️ Skipping Claude Console account ${currentAccount.name} - temporarily unavailable` + ) + continue + } + // 检查是否被限流 const isRateLimited = await claudeConsoleAccountService.isAccountRateLimited( currentAccount.id @@ -682,7 +739,15 @@ class UnifiedClaudeScheduler { account.accountType === 'shared' && this._isSchedulable(account.schedulable) ) { - // 检查是否可调度 + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable( + account.id, + 'bedrock' + ) + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping Bedrock account ${account.name} - temporarily unavailable`) + continue + } availableAccounts.push({ ...account, @@ -731,6 +796,13 @@ class UnifiedClaudeScheduler { continue } + // 检查是否临时不可用 + const isTempUnavailable = await this.isAccountTemporarilyUnavailable(account.id, 'ccr') + if (isTempUnavailable) { + logger.debug(`⏭️ Skipping CCR account ${account.name} - temporarily unavailable`) + continue + } + // 检查是否被限流 const isRateLimited = await ccrAccountService.isAccountRateLimited(account.id) const isQuotaExceeded = await ccrAccountService.isAccountQuotaExceeded(account.id) @@ -1099,6 +1171,40 @@ class UnifiedClaudeScheduler { } } + // ⏱️ 标记账户为临时不可用状态(用于5xx等临时故障,默认5分钟后自动恢复) + async markAccountTemporarilyUnavailable( + accountId, + accountType, + sessionHash = null, + ttlSeconds = 300 + ) { + try { + const client = redis.getClientSafe() + const key = `temp_unavailable:${accountType}:${accountId}` + await client.setex(key, ttlSeconds, '1') + if (sessionHash) await this._deleteSessionMapping(sessionHash) + logger.warn( + `⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s` + ) + return { success: true } + } catch (error) { + logger.error(`❌ Failed to mark account temporarily unavailable: ${accountId}`, error) + return { success: false } + } + } + + // 🔍 检查账户是否临时不可用 + async isAccountTemporarilyUnavailable(accountId, accountType) { + try { + const client = redis.getClientSafe() + const key = `temp_unavailable:${accountType}:${accountId}` + return (await client.exists(key)) === 1 + } catch (error) { + logger.error(`❌ Failed to check temp unavailable status: ${accountId}`, error) + return false + } + } + // 🚫 标记账户为限流状态 async markAccountRateLimited( accountId, diff --git a/src/utils/unstableUpstreamHelper.js b/src/utils/unstableUpstreamHelper.js new file mode 100644 index 00000000..c233fc3c --- /dev/null +++ b/src/utils/unstableUpstreamHelper.js @@ -0,0 +1,77 @@ +const logger = require('./logger') + +function parseList(envValue) { + if (!envValue) return [] + return envValue + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean) +} + +const unstableTypes = new Set(parseList(process.env.UNSTABLE_ERROR_TYPES)) +const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS) +const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522]) + +function normalizeErrorPayload(payload) { + if (!payload) return {} + + if (typeof payload === 'string') { + try { + return normalizeErrorPayload(JSON.parse(payload)) + } catch (e) { + return { message: payload } + } + } + + if (payload.error && typeof payload.error === 'object') { + return { + type: payload.error.type || payload.error.error || payload.error.code, + code: payload.error.code || payload.error.error || payload.error.type, + message: payload.error.message || payload.error.msg || payload.message || payload.error.error + } + } + + return { + type: payload.type || payload.code, + code: payload.code || payload.type, + message: payload.message || '' + } +} + +function isUnstableUpstreamError(statusCode, payload) { + const normalizedStatus = Number(statusCode) + if (Number.isFinite(normalizedStatus) && normalizedStatus >= 500) { + return true + } + if (Number.isFinite(normalizedStatus) && unstableStatusCodes.has(normalizedStatus)) { + return true + } + + const { type, code, message } = normalizeErrorPayload(payload) + const lowerType = (type || '').toString().toLowerCase() + const lowerCode = (code || '').toString().toLowerCase() + const lowerMessage = (message || '').toString().toLowerCase() + + if (lowerType === 'server_error' || lowerCode === 'server_error') { + return true + } + if (unstableTypes.has(lowerType) || unstableTypes.has(lowerCode)) { + return true + } + if (unstableKeywords.length > 0) { + return unstableKeywords.some((kw) => lowerMessage.includes(kw)) + } + + return false +} + +function logUnstable(accountLabel, statusCode) { + logger.warn( + `Detected unstable upstream error (${statusCode}) for account ${accountLabel}, marking temporarily unavailable` + ) +} + +module.exports = { + isUnstableUpstreamError, + logUnstable +} From 4cf1762467c535b059bcccca0f74810dd4233c70 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Fri, 5 Dec 2025 02:21:30 +0800 Subject: [PATCH 02/32] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20ESLint=20curl?= =?UTF-8?q?y=20=E8=A7=84=E5=88=99=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 if 语句后添加必需的大括号 - 修复 unifiedClaudeScheduler.js (1处) - 修复 unstableUpstreamHelper.js (2处) --- src/services/unifiedClaudeScheduler.js | 4 +++- src/utils/unstableUpstreamHelper.js | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 73def6a8..54446ec7 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -1182,7 +1182,9 @@ class UnifiedClaudeScheduler { const client = redis.getClientSafe() const key = `temp_unavailable:${accountType}:${accountId}` await client.setex(key, ttlSeconds, '1') - if (sessionHash) await this._deleteSessionMapping(sessionHash) + if (sessionHash) { + await this._deleteSessionMapping(sessionHash) + } logger.warn( `⏱️ Account ${accountId} (${accountType}) marked temporarily unavailable for ${ttlSeconds}s` ) diff --git a/src/utils/unstableUpstreamHelper.js b/src/utils/unstableUpstreamHelper.js index c233fc3c..6fa58aca 100644 --- a/src/utils/unstableUpstreamHelper.js +++ b/src/utils/unstableUpstreamHelper.js @@ -1,7 +1,9 @@ const logger = require('./logger') function parseList(envValue) { - if (!envValue) return [] + if (!envValue) { + return [] + } return envValue .split(',') .map((s) => s.trim().toLowerCase()) @@ -13,7 +15,9 @@ const unstableKeywords = parseList(process.env.UNSTABLE_ERROR_KEYWORDS) const unstableStatusCodes = new Set([408, 499, 502, 503, 504, 522]) function normalizeErrorPayload(payload) { - if (!payload) return {} + if (!payload) { + return {} + } if (typeof payload === 'string') { try { From 69a1006f4c63661b8a0ad56078654204a3cd7836 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Wed, 3 Dec 2025 19:35:29 -0800 Subject: [PATCH 03/32] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=E7=9A=84=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=92=8C=E7=BB=9F=E8=AE=A1=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增状态过滤器:支持按正常/异常/全部筛选账户 - 新增限流时间过滤器:支持按1h/5h/12h/1d筛选限流账户 - 新增账户统计弹窗:按平台类型和状态汇总账户数量 - 优化账户列表过滤逻辑,支持组合过滤条件 - 默认状态过滤为'正常',提升用户体验 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- web/admin-spa/src/views/AccountsView.vue | 352 +++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index c2a31013..2f6f132a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -58,6 +58,34 @@ /> + +
+
+ +
+ + +
+
+ +
+
+ +
+ + + +
+
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 平台类型 + + 正常 + + 限流≤1h + + 限流≤5h + + 限流≤12h + + 限流≤1d + + 异常 + + 合计 +
+ {{ stat.platformLabel }} + + {{ stat.normal }} + + {{ stat.rateLimit1h }} + + {{ stat.rateLimit5h }} + + {{ stat.rateLimit12h }} + + {{ stat.rateLimit1d }} + + {{ stat.abnormal }} + + {{ stat.total }} +
合计 + {{ + accountStatsTotal.normal + }} + + {{ + accountStatsTotal.rateLimit1h + }} + + {{ + accountStatsTotal.rateLimit5h + }} + + {{ + accountStatsTotal.rateLimit12h + }} + + {{ + accountStatsTotal.rateLimit1d + }} + + {{ + accountStatsTotal.abnormal + }} + + {{ accountStatsTotal.total }} +
+
+

+ 注:限流时间列表示剩余限流时间在指定范围内的账户数量 +

+
+
@@ -1880,6 +2038,8 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X const accountGroups = ref([]) const groupFilter = ref('all') const platformFilter = ref('all') +const statusFilter = ref('normal') // 新增:状态过滤 (normal/abnormal/all) +const rateLimitFilter = ref('all') // 新增:限流时间过滤 (all/1h/5h/12h/1d) const searchKeyword = ref('') const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize' const getInitialPageSize = () => { @@ -1929,6 +2089,9 @@ const expiryEditModalRef = ref(null) const showAccountTestModal = ref(false) const testingAccount = ref(null) +// 账户统计弹窗状态 +const showAccountStatsModal = ref(false) + // 表格横向滚动检测 const tableContainerRef = ref(null) const needsHorizontalScroll = ref(false) @@ -1963,6 +2126,20 @@ const platformOptions = ref([ { value: 'droid', label: 'Droid', icon: 'fa-robot' } ]) +const statusOptions = ref([ + { value: 'normal', label: '正常', icon: 'fa-check-circle' }, + { value: 'abnormal', label: '异常', icon: 'fa-exclamation-triangle' }, + { value: 'all', label: '全部状态', icon: 'fa-list' } +]) + +const rateLimitOptions = ref([ + { value: 'all', label: '全部限流', icon: 'fa-infinity' }, + { value: '1h', label: '限流≤1小时', icon: 'fa-hourglass-start' }, + { value: '5h', label: '限流≤5小时', icon: 'fa-hourglass-half' }, + { value: '12h', label: '限流≤12小时', icon: 'fa-hourglass-end' }, + { value: '1d', label: '限流≤1天', icon: 'fa-calendar-day' } +]) + const groupOptions = computed(() => { const options = [ { value: 'all', label: '所有账户', icon: 'fa-globe' }, @@ -2199,6 +2376,47 @@ const sortedAccounts = computed(() => { ) } + // 状态过滤 (normal/abnormal/all) + if (statusFilter.value !== 'all') { + sourceAccounts = sourceAccounts.filter((account) => { + const isNormal = + account.isActive && + account.status !== 'blocked' && + account.status !== 'unauthorized' && + account.schedulable !== false && + !isAccountRateLimited(account) + + if (statusFilter.value === 'normal') { + return isNormal + } else if (statusFilter.value === 'abnormal') { + return !isNormal + } + return true + }) + } + + // 限流时间过滤 (all/1h/5h/12h/1d) + if (rateLimitFilter.value !== 'all') { + sourceAccounts = sourceAccounts.filter((account) => { + const rateLimitMinutes = getRateLimitRemainingMinutes(account) + if (!rateLimitMinutes || rateLimitMinutes <= 0) return false + + const minutes = Math.floor(rateLimitMinutes) + switch (rateLimitFilter.value) { + case '1h': + return minutes <= 60 + case '5h': + return minutes <= 300 + case '12h': + return minutes <= 720 + case '1d': + return minutes <= 1440 + default: + return true + } + }) + } + if (!accountsSortBy.value) return sourceAccounts const sorted = [...sourceAccounts].sort((a, b) => { @@ -2242,6 +2460,101 @@ const totalPages = computed(() => { return Math.ceil(total / pageSize.value) || 0 }) +// 账户统计数据(按平台和状态分类) +const accountStats = computed(() => { + const platforms = [ + { value: 'claude', label: 'Claude' }, + { value: 'claude-console', label: 'Claude Console' }, + { value: 'gemini', label: 'Gemini' }, + { value: 'gemini-api', label: 'Gemini API' }, + { value: 'openai', label: 'OpenAI' }, + { value: 'azure_openai', label: 'Azure OpenAI' }, + { value: 'bedrock', label: 'Bedrock' }, + { value: 'openai-responses', label: 'OpenAI-Responses' }, + { value: 'ccr', label: 'CCR' }, + { value: 'droid', label: 'Droid' } + ] + + return platforms + .map((p) => { + const platformAccounts = accounts.value.filter((acc) => acc.platform === p.value) + + const normal = platformAccounts.filter((acc) => { + return ( + acc.isActive && + acc.status !== 'blocked' && + acc.status !== 'unauthorized' && + acc.schedulable !== false && + !isAccountRateLimited(acc) + ) + }).length + + const abnormal = platformAccounts.filter((acc) => { + return !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' + }).length + + const rateLimitedAccounts = platformAccounts.filter((acc) => isAccountRateLimited(acc)) + + const rateLimit1h = rateLimitedAccounts.filter((acc) => { + const minutes = getRateLimitRemainingMinutes(acc) + return minutes > 0 && minutes <= 60 + }).length + + const rateLimit5h = rateLimitedAccounts.filter((acc) => { + const minutes = getRateLimitRemainingMinutes(acc) + return minutes > 0 && minutes <= 300 + }).length + + const rateLimit12h = rateLimitedAccounts.filter((acc) => { + const minutes = getRateLimitRemainingMinutes(acc) + return minutes > 0 && minutes <= 720 + }).length + + const rateLimit1d = rateLimitedAccounts.filter((acc) => { + const minutes = getRateLimitRemainingMinutes(acc) + return minutes > 0 && minutes <= 1440 + }).length + + return { + platform: p.value, + platformLabel: p.label, + normal, + rateLimit1h, + rateLimit5h, + rateLimit12h, + rateLimit1d, + abnormal, + total: platformAccounts.length + } + }) + .filter((stat) => stat.total > 0) // 只显示有账户的平台 +}) + +// 账户统计合计 +const accountStatsTotal = computed(() => { + return accountStats.value.reduce( + (total, stat) => { + total.normal += stat.normal + total.rateLimit1h += stat.rateLimit1h + total.rateLimit5h += stat.rateLimit5h + total.rateLimit12h += stat.rateLimit12h + total.rateLimit1d += stat.rateLimit1d + total.abnormal += stat.abnormal + total.total += stat.total + return total + }, + { + normal: 0, + rateLimit1h: 0, + rateLimit5h: 0, + rateLimit12h: 0, + rateLimit1d: 0, + abnormal: 0, + total: 0 + } + ) +}) + const pageNumbers = computed(() => { const total = totalPages.value const current = currentPage.value @@ -3014,6 +3327,45 @@ const formatRateLimitTime = (minutes) => { } } +// 检查账户是否被限流 +const isAccountRateLimited = (account) => { + if (!account) return false + + // 检查 rateLimitStatus + if (account.rateLimitStatus) { + if (typeof account.rateLimitStatus === 'string' && account.rateLimitStatus === 'limited') { + return true + } + if ( + typeof account.rateLimitStatus === 'object' && + account.rateLimitStatus.isRateLimited === true + ) { + return true + } + } + + return false +} + +// 获取限流剩余时间(分钟) +const getRateLimitRemainingMinutes = (account) => { + if (!account || !account.rateLimitStatus) return 0 + + if (typeof account.rateLimitStatus === 'object' && account.rateLimitStatus.remainingMinutes) { + return account.rateLimitStatus.remainingMinutes + } + + // 如果有 rateLimitUntil 字段,计算剩余时间 + if (account.rateLimitUntil) { + const now = new Date().getTime() + const untilTime = new Date(account.rateLimitUntil).getTime() + const diff = untilTime - now + return diff > 0 ? Math.ceil(diff / 60000) : 0 + } + + return 0 +} + // 打开创建账户模态框 const openCreateAccountModal = () => { newAccountPlatform.value = null // 重置选择的平台 From 81971436e637dc565e55892b4c2e63adc8056896 Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Wed, 3 Dec 2025 19:41:37 -0800 Subject: [PATCH 04/32] =?UTF-8?q?feat:=20=E5=9C=A8=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E6=B7=BB=E5=8A=A0=E4=BD=BF=E7=94=A8=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增后端API端点 /admin/dashboard/usage-records - 支持分页查询所有API Key的使用记录 - 自动关联API Key名称和账户名称 - 按时间倒序排列(最新的在前) - 新增仪表盘使用记录表格 - 显示时间、API Key、账户、模型、输入/输出/缓存创建/缓存读取tokens、成本 - 智能时间格式化(今天显示时分秒,昨天显示时间) - 支持加载更多记录,分页展示 - 响应式设计,支持暗黑模式 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/routes/admin/dashboard.js | 102 +++++++++ web/admin-spa/src/views/DashboardView.vue | 245 +++++++++++++++++++++- 2 files changed, 346 insertions(+), 1 deletion(-) diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js index fe2cb440..56a4718a 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -704,4 +704,106 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { } }) +// 📊 获取最近的使用记录 +router.get('/usage-records', authenticateAdmin, async (req, res) => { + try { + const { limit = 100, offset = 0 } = req.query + const limitNum = Math.min(parseInt(limit) || 100, 500) // 最多500条 + const offsetNum = Math.max(parseInt(offset) || 0, 0) + + // 获取所有API Keys + const apiKeys = await apiKeyService.getAllApiKeys() + if (!apiKeys || apiKeys.length === 0) { + return res.json({ success: true, data: { records: [], total: 0 } }) + } + + // 收集所有API Key的使用记录 + const allRecords = [] + for (const key of apiKeys) { + try { + const records = await redis.getUsageRecords(key.id, 100) // 每个key最多取100条 + if (records && records.length > 0) { + // 为每条记录添加API Key信息 + const enrichedRecords = records.map((record) => ({ + ...record, + apiKeyId: key.id, + apiKeyName: key.name || 'Unnamed Key' + })) + allRecords.push(...enrichedRecords) + } + } catch (error) { + logger.error(`Failed to get usage records for key ${key.id}:`, error) + continue + } + } + + // 按时间戳倒序排序(最新的在前) + allRecords.sort((a, b) => { + const timeA = new Date(a.timestamp).getTime() + const timeB = new Date(b.timestamp).getTime() + return timeB - timeA + }) + + // 分页 + const paginatedRecords = allRecords.slice(offsetNum, offsetNum + limitNum) + + // 获取账户名称映射 + const accountIds = [...new Set(paginatedRecords.map((r) => r.accountId).filter(Boolean))] + const accountNameMap = {} + + // 并发获取所有账户名称 + await Promise.all( + accountIds.map(async (accountId) => { + try { + // 尝试从不同类型的账户中获取 + const claudeAcc = await redis.getAccount(accountId) + if (claudeAcc && claudeAcc.name) { + accountNameMap[accountId] = claudeAcc.name + return + } + + const consoleAcc = await redis.getClaudeConsoleAccount(accountId) + if (consoleAcc && consoleAcc.name) { + accountNameMap[accountId] = consoleAcc.name + return + } + + const geminiAcc = await redis.getGeminiAccount(accountId) + if (geminiAcc && geminiAcc.name) { + accountNameMap[accountId] = geminiAcc.name + return + } + + // 其他平台账户... + accountNameMap[accountId] = accountId // 降级显示ID + } catch (error) { + accountNameMap[accountId] = accountId + } + }) + ) + + // 为记录添加账户名称 + const enrichedRecords = paginatedRecords.map((record) => ({ + ...record, + accountName: record.accountId ? accountNameMap[record.accountId] || record.accountId : '-' + })) + + return res.json({ + success: true, + data: { + records: enrichedRecords, + total: allRecords.length, + limit: limitNum, + offset: offsetNum + } + }) + } catch (error) { + logger.error('❌ Failed to get usage records:', error) + return res.status(500).json({ + error: 'Failed to get usage records', + message: error.message + }) + } +}) + module.exports = router diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 61ac8124..8666b600 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -673,6 +673,158 @@
+ + +
+
+
+

+ 最近使用记录 +

+ +
+ +
+
+

正在加载使用记录...

+
+ +
+

暂无使用记录

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 时间 + + API Key + + 账户 + + 模型 + + 输入 + + 输出 + + 缓存创建 + + 缓存读取 + + 成本 +
+ {{ formatRecordTime(record.timestamp) }} + +
+ {{ record.apiKeyName }} +
+
+
+ {{ record.accountName }} +
+
+
+ {{ record.model }} +
+
+ {{ formatNumber(record.inputTokens) }} + + {{ formatNumber(record.outputTokens) }} + + {{ formatNumber(record.cacheCreateTokens) }} + + {{ formatNumber(record.cacheReadTokens) }} + + ${{ formatCost(record.cost) }} +
+ + +
+ +
+
+
+
@@ -681,12 +833,21 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue' import { storeToRefs } from 'pinia' import { useDashboardStore } from '@/stores/dashboard' import { useThemeStore } from '@/stores/theme' +import { apiClient } from '@/config/api' +import { showToast } from '@/utils/toast' import Chart from 'chart.js/auto' const dashboardStore = useDashboardStore() const themeStore = useThemeStore() const { isDarkMode } = storeToRefs(themeStore) +// 使用记录相关 +const usageRecords = ref([]) +const usageRecordsLoading = ref(false) +const usageRecordsTotal = ref(0) +const usageRecordsOffset = ref(0) +const usageRecordsLimit = ref(50) + const { dashboardData, costsData, @@ -1477,13 +1638,95 @@ watch(accountUsageTrendData, () => { nextTick(() => createAccountUsageTrendChart()) }) +// 加载使用记录 +async function loadUsageRecords(reset = true) { + if (usageRecordsLoading.value) return + + try { + usageRecordsLoading.value = true + if (reset) { + usageRecordsOffset.value = 0 + usageRecords.value = [] + } + + const response = await apiClient.get('/admin/dashboard/usage-records', { + params: { + limit: usageRecordsLimit.value, + offset: usageRecordsOffset.value + } + }) + + if (response.success && response.data) { + if (reset) { + usageRecords.value = response.data.records || [] + } else { + usageRecords.value = [...usageRecords.value, ...(response.data.records || [])] + } + usageRecordsTotal.value = response.data.total || 0 + } + } catch (error) { + console.error('Failed to load usage records:', error) + showToast('加载使用记录失败', 'error') + } finally { + usageRecordsLoading.value = false + } +} + +// 加载更多使用记录 +async function loadMoreUsageRecords() { + usageRecordsOffset.value += usageRecordsLimit.value + await loadUsageRecords(false) +} + +// 格式化记录时间 +function formatRecordTime(timestamp) { + if (!timestamp) return '-' + const date = new Date(timestamp) + const now = new Date() + const diff = now - date + + // 如果是今天 + if (diff < 86400000 && date.getDate() === now.getDate()) { + return date.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + // 如果是昨天 + if (diff < 172800000 && date.getDate() === now.getDate() - 1) { + return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) + } + + // 其他日期 + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) +} + +// 格式化数字(添加千分位) +function formatNumber(num) { + if (!num || num === 0) return '0' + return num.toLocaleString('en-US') +} + +// 格式化成本 +function formatCost(cost) { + if (!cost || cost === 0) return '0.000000' + return cost.toFixed(6) +} + // 刷新所有数据 async function refreshAllData() { if (isRefreshing.value) return isRefreshing.value = true try { - await Promise.all([loadDashboardData(), refreshChartsData()]) + await Promise.all([loadDashboardData(), refreshChartsData(), loadUsageRecords()]) } finally { isRefreshing.value = false } From 3db268fff7dddf78b537e5c1f213da216a351fdc Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Wed, 3 Dec 2025 23:08:44 -0800 Subject: [PATCH 05/32] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86=E5=92=8C=E4=BB=AA=E8=A1=A8=E7=9B=98?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改使用记录API路由路径为 /dashboard/usage-records - 增加对更多账户类型的支持(Bedrock、Azure、Droid、CCR等) - 修复Codex模型识别逻辑,避免 gpt-5-codex 系列被错误归一化 - 在账户管理页面添加状态过滤器(正常/异常) - 在账户管理页面添加限流时间过滤器(≤1h/5h/12h/1d) - 增加账户统计汇总弹窗,按平台分类展示 - 完善仪表盘使用记录展示功能,支持分页加载 - 将 logs1/ 目录添加到 .gitignore 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .gitignore | 1 + src/routes/admin/dashboard.js | 42 +++- src/routes/openaiRoutes.js | 6 +- web/admin-spa/src/views/AccountsView.vue | 273 +++++++++++++--------- web/admin-spa/src/views/DashboardView.vue | 258 +++++++++++++------- 5 files changed, 374 insertions(+), 206 deletions(-) diff --git a/.gitignore b/.gitignore index 10594f73..e4c9e9c1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ redis_data/ # Logs directory logs/ +logs1/ *.log startup.log app.log diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js index 56a4718a..6bcba72e 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -705,7 +705,7 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { }) // 📊 获取最近的使用记录 -router.get('/usage-records', authenticateAdmin, async (req, res) => { +router.get('/dashboard/usage-records', authenticateAdmin, async (req, res) => { try { const { limit = 100, offset = 0 } = req.query const limitNum = Math.min(parseInt(limit) || 100, 500) // 最多500条 @@ -774,8 +774,44 @@ router.get('/usage-records', authenticateAdmin, async (req, res) => { return } - // 其他平台账户... - accountNameMap[accountId] = accountId // 降级显示ID + const bedrockAcc = await redis.getBedrockAccount(accountId) + if (bedrockAcc && bedrockAcc.name) { + accountNameMap[accountId] = bedrockAcc.name + return + } + + const azureAcc = await redis.getAzureOpenaiAccount(accountId) + if (azureAcc && azureAcc.name) { + accountNameMap[accountId] = azureAcc.name + return + } + + const openaiResponsesAcc = await redis.getOpenaiResponsesAccount(accountId) + if (openaiResponsesAcc && openaiResponsesAcc.name) { + accountNameMap[accountId] = openaiResponsesAcc.name + return + } + + const droidAcc = await redis.getDroidAccount(accountId) + if (droidAcc && droidAcc.name) { + accountNameMap[accountId] = droidAcc.name + return + } + + const ccrAcc = await redis.getCcrAccount(accountId) + if (ccrAcc && ccrAcc.name) { + accountNameMap[accountId] = ccrAcc.name + return + } + + const openaiAcc = await redis.getOpenaiAccount(accountId) + if (openaiAcc && openaiAcc.name) { + accountNameMap[accountId] = openaiAcc.name + return + } + + // 降级显示ID + accountNameMap[accountId] = accountId } catch (error) { accountNameMap[accountId] = accountId } diff --git a/src/routes/openaiRoutes.js b/src/routes/openaiRoutes.js index 41fa1977..7faf9e87 100644 --- a/src/routes/openaiRoutes.js +++ b/src/routes/openaiRoutes.js @@ -247,9 +247,11 @@ const handleResponses = async (req, res) => { // 从请求体中提取模型和流式标志 let requestedModel = req.body?.model || null + const isCodexModel = + typeof requestedModel === 'string' && requestedModel.toLowerCase().includes('codex') - // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),则覆盖为 gpt-5 - if (requestedModel && requestedModel.startsWith('gpt-5-') && requestedModel !== 'gpt-5-codex') { + // 如果模型是 gpt-5 开头且后面还有内容(如 gpt-5-2025-08-07),并且不是 Codex 系列,则覆盖为 gpt-5 + if (requestedModel && requestedModel.startsWith('gpt-5-') && !isCodexModel) { logger.info(`📝 Model ${requestedModel} detected, normalizing to gpt-5 for Codex API`) requestedModel = 'gpt-5' req.body.model = 'gpt-5' // 同时更新请求体中的模型 diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 2f6f132a..7a868b26 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -72,20 +72,6 @@ /> - -
-
- -
-
- +
+ + + + + + @@ -2038,8 +2050,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X const accountGroups = ref([]) const groupFilter = ref('all') const platformFilter = ref('all') -const statusFilter = ref('normal') // 新增:状态过滤 (normal/abnormal/all) -const rateLimitFilter = ref('all') // 新增:限流时间过滤 (all/1h/5h/12h/1d) +const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all) const searchKeyword = ref('') const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize' const getInitialPageSize = () => { @@ -2109,7 +2120,8 @@ const sortOptions = ref([ { value: 'dailyTokens', label: '按今日Token排序', icon: 'fa-coins' }, { value: 'dailyRequests', label: '按今日请求数排序', icon: 'fa-chart-line' }, { value: 'totalTokens', label: '按总Token排序', icon: 'fa-database' }, - { value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' } + { value: 'lastUsed', label: '按最后使用排序', icon: 'fa-clock' }, + { value: 'rateLimitTime', label: '按限流时间排序', icon: 'fa-hourglass' } ]) const platformOptions = ref([ @@ -2128,18 +2140,12 @@ const platformOptions = ref([ const statusOptions = ref([ { value: 'normal', label: '正常', icon: 'fa-check-circle' }, - { value: 'abnormal', label: '异常', icon: 'fa-exclamation-triangle' }, + { value: 'unschedulable', label: '不可调度', icon: 'fa-ban' }, + { value: 'rateLimited', label: '限流', icon: 'fa-hourglass-half' }, + { value: 'other', label: '其他', icon: 'fa-exclamation-triangle' }, { value: 'all', label: '全部状态', icon: 'fa-list' } ]) -const rateLimitOptions = ref([ - { value: 'all', label: '全部限流', icon: 'fa-infinity' }, - { value: '1h', label: '限流≤1小时', icon: 'fa-hourglass-start' }, - { value: '5h', label: '限流≤5小时', icon: 'fa-hourglass-half' }, - { value: '12h', label: '限流≤12小时', icon: 'fa-hourglass-end' }, - { value: '1d', label: '限流≤1天', icon: 'fa-calendar-day' } -]) - const groupOptions = computed(() => { const options = [ { value: 'all', label: '所有账户', icon: 'fa-globe' }, @@ -2376,47 +2382,33 @@ const sortedAccounts = computed(() => { ) } - // 状态过滤 (normal/abnormal/all) + // 状态过滤 (normal/unschedulable/rateLimited/other/all) + // 限流: isActive && rate-limited (最高优先级) + // 正常: isActive && !rate-limited && !blocked && schedulable + // 不可调度: isActive && !rate-limited && !blocked && schedulable === false + // 其他: 非限流的(未激活 || 被阻止) if (statusFilter.value !== 'all') { sourceAccounts = sourceAccounts.filter((account) => { - const isNormal = - account.isActive && - account.status !== 'blocked' && - account.status !== 'unauthorized' && - account.schedulable !== false && - !isAccountRateLimited(account) + const isRateLimited = isAccountRateLimited(account) + const isBlocked = account.status === 'blocked' || account.status === 'unauthorized' - if (statusFilter.value === 'normal') { - return isNormal - } else if (statusFilter.value === 'abnormal') { - return !isNormal + if (statusFilter.value === 'rateLimited') { + // 限流: 激活且限流中(优先判断) + return account.isActive && isRateLimited + } else if (statusFilter.value === 'normal') { + // 正常: 激活且非限流且非阻止且可调度 + return account.isActive && !isRateLimited && !isBlocked && account.schedulable !== false + } else if (statusFilter.value === 'unschedulable') { + // 不可调度: 激活且非限流且非阻止但不可调度 + return account.isActive && !isRateLimited && !isBlocked && account.schedulable === false + } else if (statusFilter.value === 'other') { + // 其他: 非限流的异常账户(未激活或被阻止) + return !isRateLimited && (!account.isActive || isBlocked) } return true }) } - // 限流时间过滤 (all/1h/5h/12h/1d) - if (rateLimitFilter.value !== 'all') { - sourceAccounts = sourceAccounts.filter((account) => { - const rateLimitMinutes = getRateLimitRemainingMinutes(account) - if (!rateLimitMinutes || rateLimitMinutes <= 0) return false - - const minutes = Math.floor(rateLimitMinutes) - switch (rateLimitFilter.value) { - case '1h': - return minutes <= 60 - case '5h': - return minutes <= 300 - case '12h': - return minutes <= 720 - case '1d': - return minutes <= 1440 - default: - return true - } - }) - } - if (!accountsSortBy.value) return sourceAccounts const sorted = [...sourceAccounts].sort((a, b) => { @@ -2447,6 +2439,23 @@ const sortedAccounts = computed(() => { bVal = b.isActive ? 1 : 0 } + // 处理限流时间排序: 未限流优先,然后按剩余时间从小到大 + if (accountsSortBy.value === 'rateLimitTime') { + const aIsRateLimited = isAccountRateLimited(a) + const bIsRateLimited = isAccountRateLimited(b) + const aMinutes = aIsRateLimited ? getRateLimitRemainingMinutes(a) : 0 + const bMinutes = bIsRateLimited ? getRateLimitRemainingMinutes(b) : 0 + + // 未限流的排在前面 + if (!aIsRateLimited && bIsRateLimited) return -1 + if (aIsRateLimited && !bIsRateLimited) return 1 + + // 都未限流或都限流时,按剩余时间升序 + if (aMinutes < bMinutes) return -1 + if (aMinutes > bMinutes) return 1 + return 0 + } + if (aVal < bVal) return accountsSortOrder.value === 'asc' ? -1 : 1 if (aVal > bVal) return accountsSortOrder.value === 'asc' ? 1 : -1 return 0 @@ -2479,51 +2488,66 @@ const accountStats = computed(() => { .map((p) => { const platformAccounts = accounts.value.filter((acc) => acc.platform === p.value) - const normal = platformAccounts.filter((acc) => { - return ( - acc.isActive && - acc.status !== 'blocked' && - acc.status !== 'unauthorized' && - acc.schedulable !== false && - !isAccountRateLimited(acc) - ) - }).length - - const abnormal = platformAccounts.filter((acc) => { - return !acc.isActive || acc.status === 'blocked' || acc.status === 'unauthorized' - }).length - + // 先筛选限流账户(优先级最高) const rateLimitedAccounts = platformAccounts.filter((acc) => isAccountRateLimited(acc)) - const rateLimit1h = rateLimitedAccounts.filter((acc) => { + // 正常: 非限流 && 激活 && 非阻止 && 可调度 + const normal = platformAccounts.filter((acc) => { + const isRateLimited = isAccountRateLimited(acc) + const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' + return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable !== false + }).length + + // 不可调度: 非限流 && 激活 && 非阻止 && 不可调度 + const unschedulable = platformAccounts.filter((acc) => { + const isRateLimited = isAccountRateLimited(acc) + const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' + return !isRateLimited && acc.isActive && !isBlocked && acc.schedulable === false + }).length + + // 其他: 非限流的异常账户(未激活或被阻止) + const other = platformAccounts.filter((acc) => { + const isRateLimited = isAccountRateLimited(acc) + const isBlocked = acc.status === 'blocked' || acc.status === 'unauthorized' + return !isRateLimited && (!acc.isActive || isBlocked) + }).length + + const rateLimit0_1h = rateLimitedAccounts.filter((acc) => { const minutes = getRateLimitRemainingMinutes(acc) return minutes > 0 && minutes <= 60 }).length - const rateLimit5h = rateLimitedAccounts.filter((acc) => { + const rateLimit1_5h = rateLimitedAccounts.filter((acc) => { const minutes = getRateLimitRemainingMinutes(acc) - return minutes > 0 && minutes <= 300 + return minutes > 60 && minutes <= 300 }).length - const rateLimit12h = rateLimitedAccounts.filter((acc) => { + const rateLimit5_12h = rateLimitedAccounts.filter((acc) => { const minutes = getRateLimitRemainingMinutes(acc) - return minutes > 0 && minutes <= 720 + return minutes > 300 && minutes <= 720 }).length - const rateLimit1d = rateLimitedAccounts.filter((acc) => { + const rateLimit12_24h = rateLimitedAccounts.filter((acc) => { const minutes = getRateLimitRemainingMinutes(acc) - return minutes > 0 && minutes <= 1440 + return minutes > 720 && minutes <= 1440 + }).length + + const rateLimitOver24h = rateLimitedAccounts.filter((acc) => { + const minutes = getRateLimitRemainingMinutes(acc) + return minutes > 1440 }).length return { platform: p.value, platformLabel: p.label, normal, - rateLimit1h, - rateLimit5h, - rateLimit12h, - rateLimit1d, - abnormal, + unschedulable, + rateLimit0_1h, + rateLimit1_5h, + rateLimit5_12h, + rateLimit12_24h, + rateLimitOver24h, + other, total: platformAccounts.length } }) @@ -2535,21 +2559,25 @@ const accountStatsTotal = computed(() => { return accountStats.value.reduce( (total, stat) => { total.normal += stat.normal - total.rateLimit1h += stat.rateLimit1h - total.rateLimit5h += stat.rateLimit5h - total.rateLimit12h += stat.rateLimit12h - total.rateLimit1d += stat.rateLimit1d - total.abnormal += stat.abnormal + total.unschedulable += stat.unschedulable + total.rateLimit0_1h += stat.rateLimit0_1h + total.rateLimit1_5h += stat.rateLimit1_5h + total.rateLimit5_12h += stat.rateLimit5_12h + total.rateLimit12_24h += stat.rateLimit12_24h + total.rateLimitOver24h += stat.rateLimitOver24h + total.other += stat.other total.total += stat.total return total }, { normal: 0, - rateLimit1h: 0, - rateLimit5h: 0, - rateLimit12h: 0, - rateLimit1d: 0, - abnormal: 0, + unschedulable: 0, + rateLimit0_1h: 0, + rateLimit1_5h: 0, + rateLimit5_12h: 0, + rateLimit12_24h: 0, + rateLimitOver24h: 0, + other: 0, total: 0 } ) @@ -3351,8 +3379,21 @@ const isAccountRateLimited = (account) => { const getRateLimitRemainingMinutes = (account) => { if (!account || !account.rateLimitStatus) return 0 - if (typeof account.rateLimitStatus === 'object' && account.rateLimitStatus.remainingMinutes) { - return account.rateLimitStatus.remainingMinutes + if (typeof account.rateLimitStatus === 'object') { + const status = account.rateLimitStatus + if (Number.isFinite(status.minutesRemaining)) { + return Math.max(0, Math.ceil(status.minutesRemaining)) + } + if (Number.isFinite(status.remainingMinutes)) { + return Math.max(0, Math.ceil(status.remainingMinutes)) + } + if (Number.isFinite(status.remainingSeconds)) { + return Math.max(0, Math.ceil(status.remainingSeconds / 60)) + } + if (status.rateLimitResetAt) { + const diffMs = new Date(status.rateLimitResetAt).getTime() - Date.now() + return diffMs > 0 ? Math.ceil(diffMs / 60000) : 0 + } } // 如果有 rateLimitUntil 字段,计算剩余时间 diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index 8666b600..f27c0c00 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -676,21 +676,19 @@
-
-
-

- 最近使用记录 -

- -
+
+

最近使用记录

+ +
+

正在加载使用记录...

@@ -701,110 +699,94 @@
-
@@ -1914,19 +1900,25 @@ 正常 - 限流≤1h + 不可调度 - 限流≤5h + 限流0-1h - 限流≤12h + 限流1-5h - 限流≤1d + 限流5-12h - 异常 + 限流12-24h + + 限流>24h + + 其他 {{ stat.normal }} - {{ stat.rateLimit1h }} + {{ stat.unschedulable }} - {{ stat.rateLimit5h }} + {{ stat.rateLimit0_1h }} - {{ stat.rateLimit12h }} + {{ stat.rateLimit1_5h }} - {{ stat.rateLimit1d }} + {{ + stat.rateLimit5_12h + }} - {{ stat.abnormal }} + {{ + stat.rateLimit12_24h + }} + + {{ + stat.rateLimitOver24h + }} + + {{ stat.other }} - {{ - accountStatsTotal.rateLimit1h + {{ + accountStatsTotal.unschedulable }} {{ - accountStatsTotal.rateLimit5h + accountStatsTotal.rateLimit0_1h }} {{ - accountStatsTotal.rateLimit12h + accountStatsTotal.rateLimit1_5h }} {{ - accountStatsTotal.rateLimit1d + accountStatsTotal.rateLimit5_12h }} - {{ - accountStatsTotal.abnormal + {{ + accountStatsTotal.rateLimit12_24h }} + {{ + accountStatsTotal.rateLimitOver24h + }} + + {{ accountStatsTotal.other }} + {{ accountStatsTotal.total }}
+
- + - - - - - - - - @@ -813,14 +795,120 @@
时间 API Key 账户 模型 输入 输出 缓存创建 缓存读取 成本
+ {{ formatRecordTime(record.timestamp) }} -
+
+
{{ record.apiKeyName }}
-
+
+
{{ record.accountName }}
-
+
+
{{ record.model }}
+ {{ formatNumber(record.inputTokens) }} + {{ formatNumber(record.outputTokens) }} + {{ formatNumber(record.cacheCreateTokens) }} + {{ formatNumber(record.cacheReadTokens) }} ${{ formatCost(record.cost) }}
-
- +
+ +
+
+ 显示 {{ (usageRecordsCurrentPage - 1) * usageRecordsPageSize + 1 }} - + {{ Math.min(usageRecordsCurrentPage * usageRecordsPageSize, usageRecordsTotal) }} + 条,共 {{ usageRecordsTotal }} 条 +
+
+ 每页显示: + +
+
+ + +
+ + + + + + + + + +
@@ -845,8 +933,9 @@ const { isDarkMode } = storeToRefs(themeStore) const usageRecords = ref([]) const usageRecordsLoading = ref(false) const usageRecordsTotal = ref(0) -const usageRecordsOffset = ref(0) -const usageRecordsLimit = ref(50) +const usageRecordsCurrentPage = ref(1) +const usageRecordsPageSize = ref(20) +const usageRecordsPageSizeOptions = [10, 20, 50, 100] const { dashboardData, @@ -1639,29 +1728,22 @@ watch(accountUsageTrendData, () => { }) // 加载使用记录 -async function loadUsageRecords(reset = true) { +async function loadUsageRecords() { if (usageRecordsLoading.value) return try { usageRecordsLoading.value = true - if (reset) { - usageRecordsOffset.value = 0 - usageRecords.value = [] - } + const offset = (usageRecordsCurrentPage.value - 1) * usageRecordsPageSize.value const response = await apiClient.get('/admin/dashboard/usage-records', { params: { - limit: usageRecordsLimit.value, - offset: usageRecordsOffset.value + limit: usageRecordsPageSize.value, + offset: offset } }) if (response.success && response.data) { - if (reset) { - usageRecords.value = response.data.records || [] - } else { - usageRecords.value = [...usageRecords.value, ...(response.data.records || [])] - } + usageRecords.value = response.data.records || [] usageRecordsTotal.value = response.data.total || 0 } } catch (error) { @@ -1672,12 +1754,24 @@ async function loadUsageRecords(reset = true) { } } -// 加载更多使用记录 -async function loadMoreUsageRecords() { - usageRecordsOffset.value += usageRecordsLimit.value - await loadUsageRecords(false) +// 切换页码 +function handleUsageRecordsPageChange(page) { + usageRecordsCurrentPage.value = page + loadUsageRecords() } +// 切换每页数量 +function handleUsageRecordsPageSizeChange(size) { + usageRecordsPageSize.value = size + usageRecordsCurrentPage.value = 1 // 重置到第一页 + loadUsageRecords() +} + +// 计算总页数 +const usageRecordsTotalPages = computed(() => { + return Math.ceil(usageRecordsTotal.value / usageRecordsPageSize.value) || 1 +}) + // 格式化记录时间 function formatRecordTime(timestamp) { if (!timestamp) return '-' @@ -1708,12 +1802,6 @@ function formatRecordTime(timestamp) { }) } -// 格式化数字(添加千分位) -function formatNumber(num) { - if (!num || num === 0) return '0' - return num.toLocaleString('en-US') -} - // 格式化成本 function formatCost(cost) { if (!cost || cost === 0) return '0.000000' From 0b3cf5112bc7819b2e67817133daa7442be31cab Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Wed, 3 Dec 2025 23:37:17 -0800 Subject: [PATCH 06/32] =?UTF-8?q?refactor:=20=E7=A7=BB=E9=99=A4=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E4=BD=BF=E7=94=A8=E8=AE=B0=E5=BD=95=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BB=A5=E9=81=BF=E5=85=8D=E4=B8=8EPR=20#753=E9=87=8D?= =?UTF-8?q?=E5=8F=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除了仪表盘中的使用记录展示功能,避免与PR #753的API Key详细使用记录功能重叠: - 移除DashboardView.vue中的使用记录表格UI及相关函数 - 移除dashboard.js中的/dashboard/usage-records接口 - 保留核心账户管理功能(账户过滤、限流状态、统计模态框等) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/routes/admin/dashboard.js | 138 --------- web/admin-spa/src/views/DashboardView.vue | 333 +--------------------- 2 files changed, 1 insertion(+), 470 deletions(-) diff --git a/src/routes/admin/dashboard.js b/src/routes/admin/dashboard.js index 6bcba72e..fe2cb440 100644 --- a/src/routes/admin/dashboard.js +++ b/src/routes/admin/dashboard.js @@ -704,142 +704,4 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => { } }) -// 📊 获取最近的使用记录 -router.get('/dashboard/usage-records', authenticateAdmin, async (req, res) => { - try { - const { limit = 100, offset = 0 } = req.query - const limitNum = Math.min(parseInt(limit) || 100, 500) // 最多500条 - const offsetNum = Math.max(parseInt(offset) || 0, 0) - - // 获取所有API Keys - const apiKeys = await apiKeyService.getAllApiKeys() - if (!apiKeys || apiKeys.length === 0) { - return res.json({ success: true, data: { records: [], total: 0 } }) - } - - // 收集所有API Key的使用记录 - const allRecords = [] - for (const key of apiKeys) { - try { - const records = await redis.getUsageRecords(key.id, 100) // 每个key最多取100条 - if (records && records.length > 0) { - // 为每条记录添加API Key信息 - const enrichedRecords = records.map((record) => ({ - ...record, - apiKeyId: key.id, - apiKeyName: key.name || 'Unnamed Key' - })) - allRecords.push(...enrichedRecords) - } - } catch (error) { - logger.error(`Failed to get usage records for key ${key.id}:`, error) - continue - } - } - - // 按时间戳倒序排序(最新的在前) - allRecords.sort((a, b) => { - const timeA = new Date(a.timestamp).getTime() - const timeB = new Date(b.timestamp).getTime() - return timeB - timeA - }) - - // 分页 - const paginatedRecords = allRecords.slice(offsetNum, offsetNum + limitNum) - - // 获取账户名称映射 - const accountIds = [...new Set(paginatedRecords.map((r) => r.accountId).filter(Boolean))] - const accountNameMap = {} - - // 并发获取所有账户名称 - await Promise.all( - accountIds.map(async (accountId) => { - try { - // 尝试从不同类型的账户中获取 - const claudeAcc = await redis.getAccount(accountId) - if (claudeAcc && claudeAcc.name) { - accountNameMap[accountId] = claudeAcc.name - return - } - - const consoleAcc = await redis.getClaudeConsoleAccount(accountId) - if (consoleAcc && consoleAcc.name) { - accountNameMap[accountId] = consoleAcc.name - return - } - - const geminiAcc = await redis.getGeminiAccount(accountId) - if (geminiAcc && geminiAcc.name) { - accountNameMap[accountId] = geminiAcc.name - return - } - - const bedrockAcc = await redis.getBedrockAccount(accountId) - if (bedrockAcc && bedrockAcc.name) { - accountNameMap[accountId] = bedrockAcc.name - return - } - - const azureAcc = await redis.getAzureOpenaiAccount(accountId) - if (azureAcc && azureAcc.name) { - accountNameMap[accountId] = azureAcc.name - return - } - - const openaiResponsesAcc = await redis.getOpenaiResponsesAccount(accountId) - if (openaiResponsesAcc && openaiResponsesAcc.name) { - accountNameMap[accountId] = openaiResponsesAcc.name - return - } - - const droidAcc = await redis.getDroidAccount(accountId) - if (droidAcc && droidAcc.name) { - accountNameMap[accountId] = droidAcc.name - return - } - - const ccrAcc = await redis.getCcrAccount(accountId) - if (ccrAcc && ccrAcc.name) { - accountNameMap[accountId] = ccrAcc.name - return - } - - const openaiAcc = await redis.getOpenaiAccount(accountId) - if (openaiAcc && openaiAcc.name) { - accountNameMap[accountId] = openaiAcc.name - return - } - - // 降级显示ID - accountNameMap[accountId] = accountId - } catch (error) { - accountNameMap[accountId] = accountId - } - }) - ) - - // 为记录添加账户名称 - const enrichedRecords = paginatedRecords.map((record) => ({ - ...record, - accountName: record.accountId ? accountNameMap[record.accountId] || record.accountId : '-' - })) - - return res.json({ - success: true, - data: { - records: enrichedRecords, - total: allRecords.length, - limit: limitNum, - offset: offsetNum - } - }) - } catch (error) { - logger.error('❌ Failed to get usage records:', error) - return res.status(500).json({ - error: 'Failed to get usage records', - message: error.message - }) - } -}) - module.exports = router diff --git a/web/admin-spa/src/views/DashboardView.vue b/web/admin-spa/src/views/DashboardView.vue index f27c0c00..61ac8124 100644 --- a/web/admin-spa/src/views/DashboardView.vue +++ b/web/admin-spa/src/views/DashboardView.vue @@ -673,246 +673,6 @@
- - -
-
-

最近使用记录

- -
- -
-
-
-

正在加载使用记录...

-
- -
-

暂无使用记录

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 时间 - - API Key - - 账户 - - 模型 - - 输入 - - 输出 - - 缓存创建 - - 缓存读取 - - 成本 -
- {{ formatRecordTime(record.timestamp) }} - -
- {{ record.apiKeyName }} -
-
-
- {{ record.accountName }} -
-
-
- {{ record.model }} -
-
- {{ formatNumber(record.inputTokens) }} - - {{ formatNumber(record.outputTokens) }} - - {{ formatNumber(record.cacheCreateTokens) }} - - {{ formatNumber(record.cacheReadTokens) }} - - ${{ formatCost(record.cost) }} -
- - -
- -
-
- 显示 {{ (usageRecordsCurrentPage - 1) * usageRecordsPageSize + 1 }} - - {{ Math.min(usageRecordsCurrentPage * usageRecordsPageSize, usageRecordsTotal) }} - 条,共 {{ usageRecordsTotal }} 条 -
-
- 每页显示: - -
-
- - -
- - - - - - - - - -
-
-
-
-
@@ -921,22 +681,12 @@ import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue' import { storeToRefs } from 'pinia' import { useDashboardStore } from '@/stores/dashboard' import { useThemeStore } from '@/stores/theme' -import { apiClient } from '@/config/api' -import { showToast } from '@/utils/toast' import Chart from 'chart.js/auto' const dashboardStore = useDashboardStore() const themeStore = useThemeStore() const { isDarkMode } = storeToRefs(themeStore) -// 使用记录相关 -const usageRecords = ref([]) -const usageRecordsLoading = ref(false) -const usageRecordsTotal = ref(0) -const usageRecordsCurrentPage = ref(1) -const usageRecordsPageSize = ref(20) -const usageRecordsPageSizeOptions = [10, 20, 50, 100] - const { dashboardData, costsData, @@ -1727,94 +1477,13 @@ watch(accountUsageTrendData, () => { nextTick(() => createAccountUsageTrendChart()) }) -// 加载使用记录 -async function loadUsageRecords() { - if (usageRecordsLoading.value) return - - try { - usageRecordsLoading.value = true - const offset = (usageRecordsCurrentPage.value - 1) * usageRecordsPageSize.value - - const response = await apiClient.get('/admin/dashboard/usage-records', { - params: { - limit: usageRecordsPageSize.value, - offset: offset - } - }) - - if (response.success && response.data) { - usageRecords.value = response.data.records || [] - usageRecordsTotal.value = response.data.total || 0 - } - } catch (error) { - console.error('Failed to load usage records:', error) - showToast('加载使用记录失败', 'error') - } finally { - usageRecordsLoading.value = false - } -} - -// 切换页码 -function handleUsageRecordsPageChange(page) { - usageRecordsCurrentPage.value = page - loadUsageRecords() -} - -// 切换每页数量 -function handleUsageRecordsPageSizeChange(size) { - usageRecordsPageSize.value = size - usageRecordsCurrentPage.value = 1 // 重置到第一页 - loadUsageRecords() -} - -// 计算总页数 -const usageRecordsTotalPages = computed(() => { - return Math.ceil(usageRecordsTotal.value / usageRecordsPageSize.value) || 1 -}) - -// 格式化记录时间 -function formatRecordTime(timestamp) { - if (!timestamp) return '-' - const date = new Date(timestamp) - const now = new Date() - const diff = now - date - - // 如果是今天 - if (diff < 86400000 && date.getDate() === now.getDate()) { - return date.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit' - }) - } - - // 如果是昨天 - if (diff < 172800000 && date.getDate() === now.getDate() - 1) { - return '昨天 ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) - } - - // 其他日期 - return date.toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }) -} - -// 格式化成本 -function formatCost(cost) { - if (!cost || cost === 0) return '0.000000' - return cost.toFixed(6) -} - // 刷新所有数据 async function refreshAllData() { if (isRefreshing.value) return isRefreshing.value = true try { - await Promise.all([loadDashboardData(), refreshChartsData(), loadUsageRecords()]) + await Promise.all([loadDashboardData(), refreshChartsData()]) } finally { isRefreshing.value = false } From 827c0f62075ac5d394d0d60960603ffa63a8b846 Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Fri, 5 Dec 2025 03:31:13 +0800 Subject: [PATCH 07/32] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=B8=A4?= =?UTF-8?q?=E7=BA=A7=E5=B9=B3=E5=8F=B0=E7=AD=9B=E9=80=89=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=88=E6=94=AF=E6=8C=81=E5=B9=B3=E5=8F=B0=E5=88=86=E7=BB=84?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 platformHierarchy 定义平台层级结构(Claude全部、OpenAI全部、Gemini全部、Droid) - 添加 platformGroupMap 映射平台组到具体平台 - 添加 platformRequestHandlers 动态处理平台请求 - 将 platformOptions 从 ref 改为 computed 支持缩进显示 - 优化 loadAccounts 使用动态平台加载替代大型 switch 语句 - 新增 getPlatformsForFilter 辅助函数 功能说明: - 支持选择"Claude(全部)"同时筛选 claude + claude-console + bedrock + ccr - 支持选择"OpenAI(全部)"同时筛选 openai + openai-responses + azure_openai - 支持选择"Gemini(全部)"同时筛选 gemini + gemini-api - 保持向后兼容,仍支持单独选择具体平台 --- web/admin-spa/src/views/AccountsView.vue | 516 ++++++++++------------- 1 file changed, 212 insertions(+), 304 deletions(-) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 7a868b26..01734bc8 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -2124,19 +2124,91 @@ const sortOptions = ref([ { value: 'rateLimitTime', label: '按限流时间排序', icon: 'fa-hourglass' } ]) -const platformOptions = ref([ - { value: 'all', label: '所有平台', icon: 'fa-globe' }, - { value: 'claude', label: 'Claude', icon: 'fa-brain' }, - { value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' }, - { value: 'gemini', label: 'Gemini', icon: 'fab fa-google' }, - { value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' }, - { value: 'openai', label: 'OpenAi', icon: 'fa-openai' }, - { value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' }, - { value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }, - { value: 'openai-responses', label: 'OpenAI-Responses', icon: 'fa-server' }, - { value: 'ccr', label: 'CCR', icon: 'fa-code-branch' }, - { value: 'droid', label: 'Droid', icon: 'fa-robot' } -]) +// 平台层级结构定义 +const platformHierarchy = [ + { + value: 'group-claude', + label: 'Claude(全部)', + icon: 'fa-brain', + children: [ + { value: 'claude', label: 'Claude 官方/OAuth', icon: 'fa-brain' }, + { value: 'claude-console', label: 'Claude Console', icon: 'fa-terminal' }, + { value: 'bedrock', label: 'Bedrock', icon: 'fab fa-aws' }, + { value: 'ccr', label: 'CCR Relay', icon: 'fa-code-branch' } + ] + }, + { + value: 'group-openai', + label: 'Codex / OpenAI(全部)', + icon: 'fa-openai', + children: [ + { value: 'openai', label: 'OpenAI 官方', icon: 'fa-openai' }, + { value: 'openai-responses', label: 'OpenAI-Responses (Codex)', icon: 'fa-server' }, + { value: 'azure_openai', label: 'Azure OpenAI', icon: 'fab fa-microsoft' } + ] + }, + { + value: 'group-gemini', + label: 'Gemini(全部)', + icon: 'fab fa-google', + children: [ + { value: 'gemini', label: 'Gemini OAuth', icon: 'fab fa-google' }, + { value: 'gemini-api', label: 'Gemini API', icon: 'fa-key' } + ] + }, + { + value: 'group-droid', + label: 'Droid(全部)', + icon: 'fa-robot', + children: [{ value: 'droid', label: 'Droid', icon: 'fa-robot' }] + } +] + +// 平台分组映射 +const platformGroupMap = { + 'group-claude': ['claude', 'claude-console', 'bedrock', 'ccr'], + 'group-openai': ['openai', 'openai-responses', 'azure_openai'], + 'group-gemini': ['gemini', 'gemini-api'], + 'group-droid': ['droid'] +} + +// 平台请求处理器 +const platformRequestHandlers = { + claude: (params) => apiClient.get('/admin/claude-accounts', { params }), + 'claude-console': (params) => apiClient.get('/admin/claude-console-accounts', { params }), + bedrock: (params) => apiClient.get('/admin/bedrock-accounts', { params }), + gemini: (params) => apiClient.get('/admin/gemini-accounts', { params }), + openai: (params) => apiClient.get('/admin/openai-accounts', { params }), + azure_openai: (params) => apiClient.get('/admin/azure-openai-accounts', { params }), + 'openai-responses': (params) => apiClient.get('/admin/openai-responses-accounts', { params }), + ccr: (params) => apiClient.get('/admin/ccr-accounts', { params }), + droid: (params) => apiClient.get('/admin/droid-accounts', { params }), + 'gemini-api': (params) => apiClient.get('/admin/gemini-api-accounts', { params }) +} + +const allPlatformKeys = Object.keys(platformRequestHandlers) + +// 根据过滤器获取需要加载的平台列表 +const getPlatformsForFilter = (filter) => { + if (filter === 'all') return allPlatformKeys + if (platformGroupMap[filter]) return platformGroupMap[filter] + if (allPlatformKeys.includes(filter)) return [filter] + return allPlatformKeys +} + +// 平台选项(两级结构) +const platformOptions = computed(() => { + const options = [{ value: 'all', label: '所有平台', icon: 'fa-globe', indent: 0 }] + + platformHierarchy.forEach((group) => { + options.push({ ...group, indent: 0, isGroup: true }) + group.children?.forEach((child) => { + options.push({ ...child, indent: 1, parent: group.value }) + }) + }) + + return options +}) const statusOptions = ref([ { value: 'normal', label: '正常', icon: 'fa-check-circle' }, @@ -2696,190 +2768,14 @@ const loadAccounts = async (forceReload = false) => { try { // 构建查询参数(用于其他筛选情况) const params = {} - if (platformFilter.value !== 'all') { + if (platformFilter.value !== 'all' && !platformGroupMap[platformFilter.value]) { params.platform = platformFilter.value } if (groupFilter.value !== 'all') { params.groupId = groupFilter.value } - // 根据平台筛选决定需要请求哪些接口 - const requests = [] - - if (platformFilter.value === 'all') { - // 请求所有平台 - requests.push( - apiClient.get('/admin/claude-accounts', { params }), - apiClient.get('/admin/claude-console-accounts', { params }), - apiClient.get('/admin/bedrock-accounts', { params }), - apiClient.get('/admin/gemini-accounts', { params }), - apiClient.get('/admin/openai-accounts', { params }), - apiClient.get('/admin/azure-openai-accounts', { params }), - apiClient.get('/admin/openai-responses-accounts', { params }), - apiClient.get('/admin/ccr-accounts', { params }), - apiClient.get('/admin/droid-accounts', { params }), - apiClient.get('/admin/gemini-api-accounts', { params }) - ) - } else { - // 只请求指定平台,其他平台设为null占位 - switch (platformFilter.value) { - case 'claude': - requests.push( - apiClient.get('/admin/claude-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'claude-console': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - apiClient.get('/admin/claude-console-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'bedrock': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - apiClient.get('/admin/bedrock-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'gemini': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - apiClient.get('/admin/gemini-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'openai': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - apiClient.get('/admin/openai-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'azure_openai': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - apiClient.get('/admin/azure-openai-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'openai-responses': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - apiClient.get('/admin/openai-responses-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'ccr': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - apiClient.get('/admin/ccr-accounts', { params }), - Promise.resolve({ success: true, data: [] }), // droid 占位 - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'droid': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - apiClient.get('/admin/droid-accounts', { params }), - Promise.resolve({ success: true, data: [] }) // gemini-api 占位 - ) - break - case 'gemini-api': - requests.push( - Promise.resolve({ success: true, data: [] }), // claude 占位 - Promise.resolve({ success: true, data: [] }), // claude-console 占位 - Promise.resolve({ success: true, data: [] }), // bedrock 占位 - Promise.resolve({ success: true, data: [] }), // gemini 占位 - Promise.resolve({ success: true, data: [] }), // openai 占位 - Promise.resolve({ success: true, data: [] }), // azure-openai 占位 - Promise.resolve({ success: true, data: [] }), // openai-responses 占位 - Promise.resolve({ success: true, data: [] }), // ccr 占位 - Promise.resolve({ success: true, data: [] }), // droid 占位 - apiClient.get('/admin/gemini-api-accounts', { params }) - ) - break - default: - // 默认情况下返回空数组 - requests.push( - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }), - Promise.resolve({ success: true, data: [] }) - ) - break - } - } + const platformsToFetch = getPlatformsForFilter(platformFilter.value) // 使用缓存机制加载绑定计数和分组数据(不再加载完整的 API Keys 数据) await Promise.all([loadBindingCounts(forceReload), loadAccountGroups(forceReload)]) @@ -2887,125 +2783,137 @@ const loadAccounts = async (forceReload = false) => { // 后端账户API已经包含分组信息,不需要单独加载分组成员关系 // await loadGroupMembers(forceReload) - const [ - claudeData, - claudeConsoleData, - bedrockData, - geminiData, - openaiData, - azureOpenaiData, - openaiResponsesData, - ccrData, - droidData, - geminiApiData - ] = await Promise.all(requests) + const platformResults = await Promise.all( + platformsToFetch.map(async (platform) => { + const handler = platformRequestHandlers[platform] + if (!handler) { + return { platform, success: true, data: [] } + } - const allAccounts = [] - - // 获取绑定计数数据 - const counts = bindingCounts.value - - if (claudeData.success) { - const claudeAccounts = (claudeData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'claude', boundApiKeysCount } - }) - allAccounts.push(...claudeAccounts) - } - - if (claudeConsoleData.success) { - const claudeConsoleAccounts = (claudeConsoleData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'claude-console', boundApiKeysCount } - }) - allAccounts.push(...claudeConsoleAccounts) - } - - if (bedrockData.success) { - const bedrockAccounts = (bedrockData.data || []).map((acc) => { - // Bedrock账户暂时不支持直接绑定 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'bedrock', boundApiKeysCount: 0 } - }) - allAccounts.push(...bedrockAccounts) - } - - if (geminiData.success) { - const geminiAccounts = (geminiData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'gemini', boundApiKeysCount } - }) - allAccounts.push(...geminiAccounts) - } - if (openaiData.success) { - const openaiAccounts = (openaiData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'openai', boundApiKeysCount } - }) - allAccounts.push(...openaiAccounts) - } - if (azureOpenaiData && azureOpenaiData.success) { - const azureOpenaiAccounts = (azureOpenaiData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'azure_openai', boundApiKeysCount } - }) - allAccounts.push(...azureOpenaiAccounts) - } - - if (openaiResponsesData && openaiResponsesData.success) { - const openaiResponsesAccounts = (openaiResponsesData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - // OpenAI-Responses账户使用 responses: 前缀 - const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'openai-responses', boundApiKeysCount } - }) - allAccounts.push(...openaiResponsesAccounts) - } - - // CCR 账户 - if (ccrData && ccrData.success) { - const ccrAccounts = (ccrData.data || []).map((acc) => { - // CCR 不支持 API Key 绑定,固定为 0 - return { ...acc, platform: 'ccr', boundApiKeysCount: 0 } - }) - allAccounts.push(...ccrAccounts) - } - - // Droid 账户 - if (droidData && droidData.success) { - const droidAccounts = (droidData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0 - return { - ...acc, - platform: 'droid', - boundApiKeysCount + try { + const res = await handler(params) + return { platform, success: res?.success, data: res?.data } + } catch (error) { + console.debug(`Failed to load ${platform} accounts:`, error) + return { platform, success: false, data: [] } } }) - allAccounts.push(...droidAccounts) + ) + + const allAccounts = [] + const counts = bindingCounts.value || {} + let openaiResponsesRaw = [] + + const appendAccounts = (platform, data) => { + if (!data || data.length === 0) return + + switch (platform) { + case 'claude': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0 + return { ...acc, platform: 'claude', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'claude-console': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0 + return { ...acc, platform: 'claude-console', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'bedrock': { + const items = data.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 })) + allAccounts.push(...items) + break + } + case 'gemini': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0 + return { ...acc, platform: 'gemini', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'openai': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0 + return { ...acc, platform: 'openai', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'azure_openai': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0 + return { ...acc, platform: 'azure_openai', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'openai-responses': { + openaiResponsesRaw = data + break + } + case 'ccr': { + const items = data.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 })) + allAccounts.push(...items) + break + } + case 'droid': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0 + return { ...acc, platform: 'droid', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + case 'gemini-api': { + const items = data.map((acc) => { + const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0 + return { ...acc, platform: 'gemini-api', boundApiKeysCount } + }) + allAccounts.push(...items) + break + } + default: + break + } } - // Gemini API 账户 - if (geminiApiData && geminiApiData.success) { - const geminiApiAccounts = (geminiApiData.data || []).map((acc) => { - // 从绑定计数缓存获取数量 - // Gemini-API账户使用 api: 前缀 - const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0 - // 后端已经包含了groupInfos,直接使用 - return { ...acc, platform: 'gemini-api', boundApiKeysCount } + platformResults.forEach(({ platform, success, data }) => { + if (success) { + appendAccounts(platform, data || []) + } + }) + + if (openaiResponsesRaw.length > 0) { + let autoRecoveryConfigMap = {} + try { + const configsRes = await apiClient.get( + '/admin/openai-responses-accounts/auto-recovery-configs' + ) + if (configsRes.success && Array.isArray(configsRes.data)) { + autoRecoveryConfigMap = configsRes.data.reduce((map, config) => { + if (config?.accountId) { + map[config.accountId] = config + } + return map + }, {}) + } + } catch (error) { + console.debug('Failed to load auto-recovery configs:', error) + } + + const responsesAccounts = openaiResponsesRaw.map((acc) => { + const boundApiKeysCount = counts.openaiAccountId?.[`responses:${acc.id}`] || 0 + const autoRecoveryConfig = autoRecoveryConfigMap[acc.id] || acc.autoRecoveryConfig || null + return { ...acc, platform: 'openai-responses', boundApiKeysCount, autoRecoveryConfig } }) - allAccounts.push(...geminiApiAccounts) + + allAccounts.push(...responsesAccounts) } // 根据分组筛选器过滤账户 From b94bd2b8226662a698186e854c4e28be6e9b4588 Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 07:38:55 +0800 Subject: [PATCH 08/32] =?UTF-8?q?feat(account):=20=E6=94=AF=E6=8C=81=20Pro?= =?UTF-8?q?=20=E8=B4=A6=E5=8F=B7=E4=BD=BF=E7=94=A8=20Opus=204.5+=20?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus 4.5 已对 Claude Pro 用户开放,调整账户模型限制逻辑: - Pro 账号:支持 Opus 4.5+,不支持历史版本 (3.x/4.0/4.1) - Free 账号:不支持任何 Opus 模型 - Max 账号:支持所有 Opus 版本 修改内容: - 新增 isOpus45OrNewer() 函数用于精确识别模型版本 - 更新 claudeAccountService.js 中的账户选择逻辑 - 更新 unifiedClaudeScheduler.js 中的模型支持检查 - 新增测试脚本验证官方模型名称识别 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- scripts/test-official-models.js | 166 +++++++++++++++++++++++++ src/services/claudeAccountService.js | 55 +++++--- src/services/unifiedClaudeScheduler.js | 44 +++++-- src/utils/modelHelper.js | 75 ++++++++++- 4 files changed, 311 insertions(+), 29 deletions(-) create mode 100644 scripts/test-official-models.js diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js new file mode 100644 index 00000000..0a0a328c --- /dev/null +++ b/scripts/test-official-models.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +/** + * 官方模型版本识别测试 - 最终版 v2 + */ + +/** + * 检查模型是否为 Opus 4.5 或更新版本 + * 支持格式: + * - 新格式: claude-opus-{major}[-{minor}][-date] 如 claude-opus-4-5-20251101 + * - 新格式: claude-opus-{major}.{minor} 如 claude-opus-4.5 + * - 旧格式: claude-{version}-opus[-date] 如 claude-3-opus-20240229 + * + * @param {string} modelName - 模型名称 + * @returns {boolean} - 是否为 Opus 4.5+ + */ +function isOpus45OrNewer(modelName) { + if (!modelName) return false + + const lowerModel = modelName.toLowerCase() + if (!lowerModel.includes('opus')) return false + + // 处理 latest 特殊情况 + if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { + return true + } + + // 旧格式: claude-{version}-opus (版本在 opus 前面) + // 例如: claude-3-opus-20240229, claude-3.5-opus + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[\.-](\d+))?[- ]opus/) + if (oldFormatMatch) { + const majorVersion = parseInt(oldFormatMatch[1], 10) + const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 + + // 旧格式的版本号指的是 Claude 大版本 + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 新格式 1: opus-{major}.{minor} (点分隔) + // 例如: claude-opus-4.5, opus-4.5 + const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) + if (dotFormatMatch) { + const majorVersion = parseInt(dotFormatMatch[1], 10) + const minorVersion = parseInt(dotFormatMatch[2], 10) + + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 新格式 2: opus-{major}[-{minor}][-date] (横线分隔) + // 例如: claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 + // 关键:小版本号必须是 1 位数字,且后面紧跟 8 位日期或结束 + // 如果 opus-{major} 后面直接是 8 位日期,则没有小版本号 + + // 提取 opus 后面的部分 + const opusIndex = lowerModel.indexOf('opus') + const afterOpus = lowerModel.substring(opusIndex + 4) // 'opus' 后面的内容 + + // 尝试匹配: -{major}-{minor}-{date} 或 -{major}-{date} 或 -{major} + // 小版本号只能是 1 位数字 (如 1, 5),不会是 2 位以上 + const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d)(?=[- ]\d{8}|$))?/) + + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10) + const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 + + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 其他包含 opus 但无法解析版本的情况,默认认为是旧版本 + return false +} + +// 官方模型 +const officialModels = [ + { name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false }, + { name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false }, + { name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false }, + { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }, +] + +// 非 Opus 模型 +const nonOpusModels = [ + { name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' }, + { name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' }, + { name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' }, + { name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' }, + { name: 'claude-3-haiku-20240307', desc: 'Haiku 3' }, + { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }, +] + +// 其他格式测试 +const otherFormats = [ + { name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' }, + { name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' }, + { name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' }, + { name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' }, + { name: 'opus-latest', expected: true, desc: 'Opus latest' }, + { name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' }, + { name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' }, + { name: 'opus-4.0', expected: false, desc: 'Opus 4.0' }, + { name: 'opus-4.1', expected: false, desc: 'Opus 4.1' }, + { name: 'opus-4.4', expected: false, desc: 'Opus 4.4' }, + { name: 'opus-4', expected: false, desc: 'Opus 4' }, + { name: 'opus-4-0', expected: false, desc: 'Opus 4-0' }, + { name: 'opus-4-1', expected: false, desc: 'Opus 4-1' }, + { name: 'opus-4-4', expected: false, desc: 'Opus 4-4' }, + { name: 'opus', expected: false, desc: '仅 opus' }, + { name: null, expected: false, desc: 'null' }, + { name: '', expected: false, desc: '空字符串' }, +] + +console.log('='.repeat(90)) +console.log('官方模型版本识别测试 - 最终版 v2') +console.log('='.repeat(90)) +console.log() + +let passed = 0 +let failed = 0 + +// 测试官方 Opus 模型 +console.log('📌 官方 Opus 模型:') +for (const m of officialModels) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL' + if (result === m.expectPro) passed++ + else failed++ + const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌' + console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`) +} + +console.log() +console.log('📌 非 Opus 模型 (不受此函数影响):') +for (const m of nonOpusModels) { + const result = isOpus45OrNewer(m.name) + console.log(` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`) + if (result) failed++ // 非 Opus 模型不应返回 true +} + +console.log() +console.log('📌 其他格式测试:') +for (const m of otherFormats) { + const result = isOpus45OrNewer(m.name) + const status = result === m.expected ? '✅ PASS' : '❌ FAIL' + if (result === m.expected) passed++ + else failed++ + const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name + console.log(` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`) +} + +console.log() +console.log('='.repeat(90)) +console.log('测试结果:', passed, '通过,', failed, '失败') +console.log('='.repeat(90)) + +if (failed > 0) { + console.log('\n❌ 有测试失败,请检查函数逻辑') + process.exit(1) +} else { + console.log('\n✅ 所有测试通过!函数可以安全使用') + process.exit(0) +} diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 29a7821e..ec06e0ea 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -16,6 +16,7 @@ const { const tokenRefreshService = require('./tokenRefreshService') const LRUCache = require('../utils/lruCache') const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') +const { isOpus45OrNewer } = require('../utils/modelHelper') class ClaudeAccountService { constructor() { @@ -852,22 +853,32 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // 如果请求的是 Opus 模型,根据账号类型和模型版本过滤 if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + activeAccounts = activeAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus + + // Free 账号不支持任何 Opus 模型 + if (info.accountType === 'claude_free' || info.accountType === 'free') { + return false + } + + // Pro 账号:仅支持 Opus 4.5+ if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + return isNewOpus // 仅新版 Opus 支持 } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + if (info.accountType === 'claude_pro') { + return isNewOpus // 仅新版 Opus 支持 } + + // Max 账号支持所有 Opus 版本 + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // 解析失败,假设为旧数据(Max),默认支持 return true } } @@ -876,7 +887,8 @@ class ClaudeAccountService { }) if (activeAccounts.length === 0) { - throw new Error('No Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No Claude accounts available that support ${modelDesc} model`) } } @@ -970,22 +982,32 @@ class ClaudeAccountService { !this.isSubscriptionExpired(account) ) - // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + // 如果请求的是 Opus 模型,根据账号类型和模型版本过滤 if (modelName && modelName.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(modelName) + sharedAccounts = sharedAccounts.filter((account) => { - // 检查账号的订阅信息 if (account.subscriptionInfo) { try { const info = JSON.parse(account.subscriptionInfo) - // Pro 和 Free 账号不支持 Opus + + // Free 账号不支持任何 Opus 模型 + if (info.accountType === 'claude_free' || info.accountType === 'free') { + return false + } + + // Pro 账号:仅支持 Opus 4.5+ if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return false // Claude Pro 不支持 Opus + return isNewOpus // 仅新版 Opus 支持 } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - return false // 明确标记为 Pro 或 Free 的账号不支持 + if (info.accountType === 'claude_pro') { + return isNewOpus // 仅新版 Opus 支持 } + + // Max 账号支持所有 Opus 版本 + return true } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // 解析失败,假设为旧数据(Max),默认支持 return true } } @@ -994,7 +1016,8 @@ class ClaudeAccountService { }) if (sharedAccounts.length === 0) { - throw new Error('No shared Claude accounts available that support Opus model') + const modelDesc = isNewOpus ? 'Opus 4.5+' : 'legacy Opus (requires Max subscription)' + throw new Error(`No shared Claude accounts available that support ${modelDesc} model`) } } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index e68d607e..99d81336 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -5,7 +5,7 @@ const ccrAccountService = require('./ccrAccountService') const accountGroupService = require('./accountGroupService') const redis = require('../models/redis') const logger = require('../utils/logger') -const { parseVendorPrefixedModel } = require('../utils/modelHelper') +const { parseVendorPrefixedModel, isOpus45OrNewer } = require('../utils/modelHelper') class UnifiedClaudeScheduler { constructor() { @@ -48,6 +48,8 @@ class UnifiedClaudeScheduler { // 2. Opus 模型的订阅级别检查 if (requestedModel.toLowerCase().includes('opus')) { + const isNewOpus = isOpus45OrNewer(requestedModel) + if (account.subscriptionInfo) { try { const info = @@ -55,21 +57,39 @@ class UnifiedClaudeScheduler { ? JSON.parse(account.subscriptionInfo) : account.subscriptionInfo - // Pro 和 Free 账号不支持 Opus + // Free 账号不支持任何 Opus 模型 + if (info.accountType === 'claude_free' || info.accountType === 'free') { + logger.info( + `🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}` + ) + return false + } + + // Pro 账号:仅支持 Opus 4.5+ if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - logger.info( - `🚫 Claude account ${account.name} (Pro) does not support Opus model${context ? ` ${context}` : ''}` - ) - return false + if (!isNewOpus) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}` + ) + return false + } + // Opus 4.5+ 支持 + return true } - if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { - logger.info( - `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model${context ? ` ${context}` : ''}` - ) - return false + if (info.accountType === 'claude_pro') { + if (!isNewOpus) { + logger.info( + `🚫 Claude account ${account.name} (Pro) does not support legacy Opus model${context ? ` ${context}` : ''}` + ) + return false + } + // Opus 4.5+ 支持 + return true } + + // Max 账号支持所有 Opus 版本 } catch (e) { - // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + // 解析失败,假设为旧数据(Max),默认支持 logger.debug( `Account ${account.name} has invalid subscriptionInfo${context ? ` ${context}` : ''}, assuming Max` ) diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index cc954cc2..ac704e5e 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -70,9 +70,82 @@ function getVendorType(modelStr) { return vendor } +/** + * 检查模型是否为 Opus 4.5 或更新版本 + * 支持格式: + * - 新格式: claude-opus-{major}[-{minor}][-date] 如 claude-opus-4-5-20251101 + * - 新格式: claude-opus-{major}.{minor} 如 claude-opus-4.5 + * - 旧格式: claude-{version}-opus[-date] 如 claude-3-opus-20240229 + * + * @param {string} modelName - 模型名称 + * @returns {boolean} - 是否为 Opus 4.5+ + */ +function isOpus45OrNewer(modelName) { + if (!modelName) return false + + const lowerModel = modelName.toLowerCase() + if (!lowerModel.includes('opus')) return false + + // 处理 latest 特殊情况 + if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { + return true + } + + // 旧格式: claude-{version}-opus (版本在 opus 前面) + // 例如: claude-3-opus-20240229, claude-3.5-opus + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[\.-](\d+))?[- ]opus/) + if (oldFormatMatch) { + const majorVersion = parseInt(oldFormatMatch[1], 10) + const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 + + // 旧格式的版本号指的是 Claude 大版本 + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 新格式 1: opus-{major}.{minor} (点分隔) + // 例如: claude-opus-4.5, opus-4.5 + const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) + if (dotFormatMatch) { + const majorVersion = parseInt(dotFormatMatch[1], 10) + const minorVersion = parseInt(dotFormatMatch[2], 10) + + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 新格式 2: opus-{major}[-{minor}][-date] (横线分隔) + // 例如: claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 + // 关键:小版本号必须是 1 位数字,且后面紧跟 8 位日期或结束 + // 如果 opus-{major} 后面直接是 8 位日期,则没有小版本号 + + // 提取 opus 后面的部分 + const opusIndex = lowerModel.indexOf('opus') + const afterOpus = lowerModel.substring(opusIndex + 4) // 'opus' 后面的内容 + + // 尝试匹配: -{major}-{minor}-{date} 或 -{major}-{date} 或 -{major} + // 小版本号只能是 1 位数字 (如 1, 5),不会是 2 位以上 + const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d)(?=[- ]\d{8}|$))?/) + + if (versionMatch) { + const majorVersion = parseInt(versionMatch[1], 10) + const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 + + if (majorVersion > 4) return true + if (majorVersion === 4 && minorVersion >= 5) return true + return false + } + + // 其他包含 opus 但无法解析版本的情况,默认认为是旧版本 + return false +} + module.exports = { parseVendorPrefixedModel, hasVendorPrefix, getEffectiveModel, - getVendorType + getVendorType, + isOpus45OrNewer } From b1dc27b5d77debaaa7abb42ad029796f71b818b6 Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 07:43:15 +0800 Subject: [PATCH 09/32] style: format test-official-models.js with Prettier --- scripts/test-official-models.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js index 0a0a328c..3c73a2b5 100644 --- a/scripts/test-official-models.js +++ b/scripts/test-official-models.js @@ -80,7 +80,7 @@ const officialModels = [ { name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false }, { name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false }, { name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false }, - { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }, + { name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true } ] // 非 Opus 模型 @@ -90,7 +90,7 @@ const nonOpusModels = [ { name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' }, { name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' }, { name: 'claude-3-haiku-20240307', desc: 'Haiku 3' }, - { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }, + { name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' } ] // 其他格式测试 @@ -111,7 +111,7 @@ const otherFormats = [ { name: 'opus-4-4', expected: false, desc: 'Opus 4-4' }, { name: 'opus', expected: false, desc: '仅 opus' }, { name: null, expected: false, desc: 'null' }, - { name: '', expected: false, desc: '空字符串' }, + { name: '', expected: false, desc: '空字符串' } ] console.log('='.repeat(90)) @@ -137,7 +137,9 @@ console.log() console.log('📌 非 Opus 模型 (不受此函数影响):') for (const m of nonOpusModels) { const result = isOpus45OrNewer(m.name) - console.log(` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`) + console.log( + ` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}` + ) if (result) failed++ // 非 Opus 模型不应返回 true } @@ -149,7 +151,9 @@ for (const m of otherFormats) { if (result === m.expected) passed++ else failed++ const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name - console.log(` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`) + console.log( + ` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}` + ) } console.log() From dc868522cfe1524368b37535a53c8f38caefc038 Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 07:49:55 +0800 Subject: [PATCH 10/32] fix: apply ESLint curly rule and remove useless escape chars --- scripts/test-official-models.js | 52 ++++++++++++++++++++++++--------- src/utils/modelHelper.js | 34 +++++++++++++++------ 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js index 3c73a2b5..f7046e5b 100644 --- a/scripts/test-official-models.js +++ b/scripts/test-official-models.js @@ -14,10 +14,14 @@ * @returns {boolean} - 是否为 Opus 4.5+ */ function isOpus45OrNewer(modelName) { - if (!modelName) return false + if (!modelName) { + return false + } const lowerModel = modelName.toLowerCase() - if (!lowerModel.includes('opus')) return false + if (!lowerModel.includes('opus')) { + return false + } // 处理 latest 特殊情况 if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { @@ -26,14 +30,18 @@ function isOpus45OrNewer(modelName) { // 旧格式: claude-{version}-opus (版本在 opus 前面) // 例如: claude-3-opus-20240229, claude-3.5-opus - const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[\.-](\d+))?[- ]opus/) + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) if (oldFormatMatch) { const majorVersion = parseInt(oldFormatMatch[1], 10) const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 // 旧格式的版本号指的是 Claude 大版本 - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } @@ -44,8 +52,12 @@ function isOpus45OrNewer(modelName) { const majorVersion = parseInt(dotFormatMatch[1], 10) const minorVersion = parseInt(dotFormatMatch[2], 10) - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } @@ -66,8 +78,12 @@ function isOpus45OrNewer(modelName) { const majorVersion = parseInt(versionMatch[1], 10) const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } @@ -127,8 +143,11 @@ console.log('📌 官方 Opus 模型:') for (const m of officialModels) { const result = isOpus45OrNewer(m.name) const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL' - if (result === m.expectPro) passed++ - else failed++ + if (result === m.expectPro) { + passed++ + } else { + failed++ + } const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌' console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`) } @@ -140,7 +159,9 @@ for (const m of nonOpusModels) { console.log( ` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}` ) - if (result) failed++ // 非 Opus 模型不应返回 true + if (result) { + failed++ // 非 Opus 模型不应返回 true + } } console.log() @@ -148,8 +169,11 @@ console.log('📌 其他格式测试:') for (const m of otherFormats) { const result = isOpus45OrNewer(m.name) const status = result === m.expected ? '✅ PASS' : '❌ FAIL' - if (result === m.expected) passed++ - else failed++ + if (result === m.expected) { + passed++ + } else { + failed++ + } const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name console.log( ` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}` diff --git a/src/utils/modelHelper.js b/src/utils/modelHelper.js index ac704e5e..d27fea87 100644 --- a/src/utils/modelHelper.js +++ b/src/utils/modelHelper.js @@ -81,10 +81,14 @@ function getVendorType(modelStr) { * @returns {boolean} - 是否为 Opus 4.5+ */ function isOpus45OrNewer(modelName) { - if (!modelName) return false + if (!modelName) { + return false + } const lowerModel = modelName.toLowerCase() - if (!lowerModel.includes('opus')) return false + if (!lowerModel.includes('opus')) { + return false + } // 处理 latest 特殊情况 if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { @@ -93,14 +97,18 @@ function isOpus45OrNewer(modelName) { // 旧格式: claude-{version}-opus (版本在 opus 前面) // 例如: claude-3-opus-20240229, claude-3.5-opus - const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[\.-](\d+))?[- ]opus/) + const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) if (oldFormatMatch) { const majorVersion = parseInt(oldFormatMatch[1], 10) const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 // 旧格式的版本号指的是 Claude 大版本 - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } @@ -111,8 +119,12 @@ function isOpus45OrNewer(modelName) { const majorVersion = parseInt(dotFormatMatch[1], 10) const minorVersion = parseInt(dotFormatMatch[2], 10) - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } @@ -133,8 +145,12 @@ function isOpus45OrNewer(modelName) { const majorVersion = parseInt(versionMatch[1], 10) const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 - if (majorVersion > 4) return true - if (majorVersion === 4 && minorVersion >= 5) return true + if (majorVersion > 4) { + return true + } + if (majorVersion === 4 && minorVersion >= 5) { + return true + } return false } From 12cb841a64ec54d0384dcde53b444d6cf3f988fa Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 07:56:53 +0800 Subject: [PATCH 11/32] refactor: address Copilot review feedback - Import isOpus45OrNewer from modelHelper instead of duplicating code - Remove invalid 'claude_free' check (only 'free' is used in practice) --- scripts/test-official-models.js | 88 +------------------------- src/services/claudeAccountService.js | 4 +- src/services/unifiedClaudeScheduler.js | 2 +- 3 files changed, 4 insertions(+), 90 deletions(-) diff --git a/scripts/test-official-models.js b/scripts/test-official-models.js index f7046e5b..d87953fa 100644 --- a/scripts/test-official-models.js +++ b/scripts/test-official-models.js @@ -3,93 +3,7 @@ * 官方模型版本识别测试 - 最终版 v2 */ -/** - * 检查模型是否为 Opus 4.5 或更新版本 - * 支持格式: - * - 新格式: claude-opus-{major}[-{minor}][-date] 如 claude-opus-4-5-20251101 - * - 新格式: claude-opus-{major}.{minor} 如 claude-opus-4.5 - * - 旧格式: claude-{version}-opus[-date] 如 claude-3-opus-20240229 - * - * @param {string} modelName - 模型名称 - * @returns {boolean} - 是否为 Opus 4.5+ - */ -function isOpus45OrNewer(modelName) { - if (!modelName) { - return false - } - - const lowerModel = modelName.toLowerCase() - if (!lowerModel.includes('opus')) { - return false - } - - // 处理 latest 特殊情况 - if (lowerModel.includes('opus-latest') || lowerModel.includes('opus_latest')) { - return true - } - - // 旧格式: claude-{version}-opus (版本在 opus 前面) - // 例如: claude-3-opus-20240229, claude-3.5-opus - const oldFormatMatch = lowerModel.match(/claude[- ](\d+)(?:[.-](\d+))?[- ]opus/) - if (oldFormatMatch) { - const majorVersion = parseInt(oldFormatMatch[1], 10) - const minorVersion = oldFormatMatch[2] ? parseInt(oldFormatMatch[2], 10) : 0 - - // 旧格式的版本号指的是 Claude 大版本 - if (majorVersion > 4) { - return true - } - if (majorVersion === 4 && minorVersion >= 5) { - return true - } - return false - } - - // 新格式 1: opus-{major}.{minor} (点分隔) - // 例如: claude-opus-4.5, opus-4.5 - const dotFormatMatch = lowerModel.match(/opus[- ]?(\d+)\.(\d+)/) - if (dotFormatMatch) { - const majorVersion = parseInt(dotFormatMatch[1], 10) - const minorVersion = parseInt(dotFormatMatch[2], 10) - - if (majorVersion > 4) { - return true - } - if (majorVersion === 4 && minorVersion >= 5) { - return true - } - return false - } - - // 新格式 2: opus-{major}[-{minor}][-date] (横线分隔) - // 例如: claude-opus-4-5-20251101, claude-opus-4-20250514, claude-opus-4-1-20250805 - // 关键:小版本号必须是 1 位数字,且后面紧跟 8 位日期或结束 - // 如果 opus-{major} 后面直接是 8 位日期,则没有小版本号 - - // 提取 opus 后面的部分 - const opusIndex = lowerModel.indexOf('opus') - const afterOpus = lowerModel.substring(opusIndex + 4) // 'opus' 后面的内容 - - // 尝试匹配: -{major}-{minor}-{date} 或 -{major}-{date} 或 -{major} - // 小版本号只能是 1 位数字 (如 1, 5),不会是 2 位以上 - const versionMatch = afterOpus.match(/^[- ](\d+)(?:[- ](\d)(?=[- ]\d{8}|$))?/) - - if (versionMatch) { - const majorVersion = parseInt(versionMatch[1], 10) - const minorVersion = versionMatch[2] ? parseInt(versionMatch[2], 10) : 0 - - if (majorVersion > 4) { - return true - } - if (majorVersion === 4 && minorVersion >= 5) { - return true - } - return false - } - - // 其他包含 opus 但无法解析版本的情况,默认认为是旧版本 - return false -} +const { isOpus45OrNewer } = require('../src/utils/modelHelper') // 官方模型 const officialModels = [ diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index ec06e0ea..9b00958b 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -863,7 +863,7 @@ class ClaudeAccountService { const info = JSON.parse(account.subscriptionInfo) // Free 账号不支持任何 Opus 模型 - if (info.accountType === 'claude_free' || info.accountType === 'free') { + if (info.accountType === 'free') { return false } @@ -992,7 +992,7 @@ class ClaudeAccountService { const info = JSON.parse(account.subscriptionInfo) // Free 账号不支持任何 Opus 模型 - if (info.accountType === 'claude_free' || info.accountType === 'free') { + if (info.accountType === 'free') { return false } diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 99d81336..3a944180 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -58,7 +58,7 @@ class UnifiedClaudeScheduler { : account.subscriptionInfo // Free 账号不支持任何 Opus 模型 - if (info.accountType === 'claude_free' || info.accountType === 'free') { + if (info.accountType === 'free') { logger.info( `🚫 Claude account ${account.name} (Free) does not support Opus model${context ? ` ${context}` : ''}` ) From 06b18b718605ab0a040736b000421e12fb19083e Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 08:12:51 +0800 Subject: [PATCH 12/32] refactor: extract isProAccount helper for Pro account detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract duplicate Pro account detection logic into a reusable helper function that handles both API-returned (hasClaudePro) and locally configured (accountType) data sources. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/services/claudeAccountService.js | 29 ++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 9b00958b..10e6e86a 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -18,6 +18,21 @@ const LRUCache = require('../utils/lruCache') const { formatDateWithTimezone, getISOStringWithTimezone } = require('../utils/dateHelper') const { isOpus45OrNewer } = require('../utils/modelHelper') +/** + * 判断账号是否为 Pro 账号(非 Max) + * 兼容两种数据来源:API 实时返回的 hasClaudePro 和本地配置的 accountType + * @param {Object} info - 订阅信息对象 + * @returns {boolean} + */ +function isProAccount(info) { + // API 返回的实时状态优先 + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return true + } + // 本地配置的账户类型 + return info.accountType === 'claude_pro' +} + class ClaudeAccountService { constructor() { this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token' @@ -868,11 +883,8 @@ class ClaudeAccountService { } // Pro 账号:仅支持 Opus 4.5+ - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return isNewOpus // 仅新版 Opus 支持 - } - if (info.accountType === 'claude_pro') { - return isNewOpus // 仅新版 Opus 支持 + if (isProAccount(info)) { + return isNewOpus } // Max 账号支持所有 Opus 版本 @@ -997,11 +1009,8 @@ class ClaudeAccountService { } // Pro 账号:仅支持 Opus 4.5+ - if (info.hasClaudePro === true && info.hasClaudeMax !== true) { - return isNewOpus // 仅新版 Opus 支持 - } - if (info.accountType === 'claude_pro') { - return isNewOpus // 仅新版 Opus 支持 + if (isProAccount(info)) { + return isNewOpus } // Max 账号支持所有 Opus 版本 From 675e7b911143f22192697391a4eb28bbfcb122ab Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Dec 2025 00:15:42 +0000 Subject: [PATCH 13/32] chore: sync VERSION file with release v1.1.221 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 62242964..2f3faf60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.220 +1.1.221 From 6ab91c0c75b0a2039031dc61770f476b63499a12 Mon Sep 17 00:00:00 2001 From: lusipad Date: Fri, 5 Dec 2025 08:25:42 +0800 Subject: [PATCH 14/32] chore: revert version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 2f3faf60..62242964 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.221 +1.1.220 From a03753030c79964b3a4a586709fde7d6c762497c Mon Sep 17 00:00:00 2001 From: IanShaw027 <131567472+IanShaw027@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:44:11 +0800 Subject: [PATCH 15/32] =?UTF-8?q?fix:=20CustomDropdown=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=B1=82=E7=BA=A7=E7=BB=93=E6=9E=84=E6=98=BE?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加动态padding支持indent属性(每级缩进16px) - 添加isGroup属性支持,分组项显示为粗体带背景 - 修复暗黑模式下选中图标颜色 - 支持二级平台分类的视觉层级展示 --- .../src/components/common/CustomDropdown.vue | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/web/admin-spa/src/components/common/CustomDropdown.vue b/web/admin-spa/src/components/common/CustomDropdown.vue index 16ae391d..58d729be 100644 --- a/web/admin-spa/src/components/common/CustomDropdown.vue +++ b/web/admin-spa/src/components/common/CustomDropdown.vue @@ -41,19 +41,25 @@
{{ option.label }}
From ff30bfab825f94a18349c4f84ea9097d895092b3 Mon Sep 17 00:00:00 2001 From: atoz03 Date: Fri, 5 Dec 2025 14:23:25 +0800 Subject: [PATCH 16/32] =?UTF-8?q?feat:=20=E8=B4=A6=E6=88=B7=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E7=BA=BF=E8=AF=A6=E6=83=85=E9=A1=B5=E4=B8=8E=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=8C=E5=96=84=20=20=20-=20=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20/admin/accounts/:accountId/usage-records?= =?UTF-8?q?=20=E6=8E=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=E6=8C=89?= =?UTF-8?q?=E8=B4=A6=E6=88=B7=E8=81=9A=E5=90=88=E5=A4=9A=20Key=20=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=B9=B6=E5=88=86=E9=A1=B5=E7=AD=9B=E9=80=89=E3=80=81?= =?UTF-8?q?=E6=B1=87=E6=80=BB=E7=BB=9F=E8=AE=A1=20=20=20-=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20API=20Key=20=E6=97=B6=E9=97=B4=E7=BA=BF=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=AD=9B=E9=80=89=E8=B7=B3=E8=BF=87=E5=B7=B2=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E8=B4=A6=E5=8F=B7=EF=BC=8C=E8=A1=A5=E5=85=85=E8=B4=A6?= =?UTF-8?q?=E6=88=B7/Key=20=E8=BE=85=E5=8A=A9=E8=A7=A3=E6=9E=90=20=20=20-?= =?UTF-8?q?=20=E5=89=8D=E7=AB=AF=E6=96=B0=E5=A2=9E=20AccountUsageRecordsVi?= =?UTF-8?q?ew=E3=80=81=E8=B7=AF=E7=94=B1=E5=8F=8A=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E2=80=9C=E6=97=B6=E9=97=B4=E7=BA=BF=E2=80=9D?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=EF=BC=8C=E6=94=AF=E6=8C=81=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?/API=20Key=20=E7=AD=9B=E9=80=89=E4=B8=8E=20CSV=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=20=20=20-=20=E8=A1=A5=E8=A3=85=20prettier-plugin-tail?= =?UTF-8?q?windcss=20=E5=B9=B6=E5=AE=8C=E6=88=90=E7=9B=B8=E5=85=B3?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package-lock.json | 80 +++ package.json | 1 + src/routes/admin/usageStats.js | 371 ++++++++++- web/admin-spa/src/router/index.js | 13 + .../src/views/AccountUsageRecordsView.vue | 574 ++++++++++++++++++ web/admin-spa/src/views/AccountsView.vue | 33 + 6 files changed, 1053 insertions(+), 19 deletions(-) create mode 100644 web/admin-spa/src/views/AccountUsageRecordsView.vue diff --git a/package-lock.json b/package-lock.json index 551062b7..c6dccd11 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { @@ -7605,6 +7606,85 @@ "node": ">=6.0.0" } }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.7.2.tgz", + "integrity": "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "peerDependencies": { + "@ianvs/prettier-plugin-sort-imports": "*", + "@prettier/plugin-hermes": "*", + "@prettier/plugin-oxc": "*", + "@prettier/plugin-pug": "*", + "@shopify/prettier-plugin-liquid": "*", + "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig": "*", + "prettier": "^3.0", + "prettier-plugin-astro": "*", + "prettier-plugin-css-order": "*", + "prettier-plugin-jsdoc": "*", + "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", + "prettier-plugin-organize-attributes": "*", + "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", + "prettier-plugin-svelte": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmmirror.com/pretty-format/-/pretty-format-29.7.0.tgz", diff --git a/package.json b/package.json index 72ea4720..2b7ffa25 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "jest": "^29.7.0", "nodemon": "^3.0.1", "prettier": "^3.6.2", + "prettier-plugin-tailwindcss": "^0.7.2", "supertest": "^6.3.3" }, "engines": { diff --git a/src/routes/admin/usageStats.js b/src/routes/admin/usageStats.js index 18d6b436..25b8c260 100644 --- a/src/routes/admin/usageStats.js +++ b/src/routes/admin/usageStats.js @@ -16,6 +16,65 @@ const pricingService = require('../../services/pricingService') const router = express.Router() +const accountTypeNames = { + claude: 'Claude官方', + 'claude-console': 'Claude Console', + ccr: 'Claude Console Relay', + openai: 'OpenAI', + 'openai-responses': 'OpenAI Responses', + gemini: 'Gemini', + 'gemini-api': 'Gemini API', + droid: 'Droid', + unknown: '未知渠道' +} + +const resolveAccountByPlatform = async (accountId, platform) => { + const serviceMap = { + claude: claudeAccountService, + 'claude-console': claudeConsoleAccountService, + gemini: geminiAccountService, + 'gemini-api': geminiApiAccountService, + openai: openaiAccountService, + 'openai-responses': openaiResponsesAccountService, + droid: droidAccountService, + ccr: ccrAccountService + } + + if (platform && serviceMap[platform]) { + try { + const account = await serviceMap[platform].getAccount(accountId) + if (account) { + return { ...account, platform } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platform}: ${error.message}`) + } + } + + for (const [platformName, service] of Object.entries(serviceMap)) { + try { + const account = await service.getAccount(accountId) + if (account) { + return { ...account, platform: platformName } + } + } catch (error) { + logger.debug(`⚠️ Failed to get account ${accountId} from ${platformName}: ${error.message}`) + } + } + + return null +} + +const getApiKeyName = async (keyId) => { + try { + const keyData = await redis.getApiKey(keyId) + return keyData?.name || keyData?.label || keyId + } catch (error) { + logger.debug(`⚠️ Failed to get API key name for ${keyId}: ${error.message}`) + return keyId + } +} + // 📊 账户使用统计 // 获取所有账户的使用统计 @@ -1861,18 +1920,6 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const rawRecords = await redis.getUsageRecords(keyId, 5000) - const accountTypeNames = { - claude: 'Claude官方', - 'claude-console': 'Claude Console', - ccr: 'Claude Console Relay', - openai: 'OpenAI', - 'openai-responses': 'OpenAI Responses', - gemini: 'Gemini', - 'gemini-api': 'Gemini API', - droid: 'Droid', - unknown: '未知渠道' - } - const accountServices = [ { type: 'claude', getter: (id) => claudeAccountService.getAccount(id) }, { type: 'claude-console', getter: (id) => claudeConsoleAccountService.getAccount(id) }, @@ -2088,13 +2135,16 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) const accountOptions = [] for (const option of accountOptionMap.values()) { const info = await resolveAccountInfo(option.id, option.accountType) - const resolvedType = info?.type || option.accountType || 'unknown' - accountOptions.push({ - id: option.id, - name: info?.name || option.id, - accountType: resolvedType, - accountTypeName: accountTypeNames[resolvedType] || '未知渠道' - }) + if (info && info.name) { + accountOptions.push({ + id: option.id, + name: info.name, + accountType: info.type, + accountTypeName: accountTypeNames[info.type] || '未知渠道' + }) + } else { + logger.warn(`⚠️ Skipping deleted/invalid account in filter options: ${option.id}`) + } } return res.json({ @@ -2146,4 +2196,287 @@ router.get('/api-keys/:keyId/usage-records', authenticateAdmin, async (req, res) } }) +// 获取账户的请求记录时间线 +router.get('/accounts/:accountId/usage-records', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + const { + platform, + page = 1, + pageSize = 50, + startDate, + endDate, + model, + apiKeyId, + sortOrder = 'desc' + } = req.query + + const pageNumber = Math.max(parseInt(page, 10) || 1, 1) + const pageSizeNumber = Math.min(Math.max(parseInt(pageSize, 10) || 50, 1), 200) + const normalizedSortOrder = sortOrder === 'asc' ? 'asc' : 'desc' + + const startTime = startDate ? new Date(startDate) : null + const endTime = endDate ? new Date(endDate) : null + + if ( + (startDate && Number.isNaN(startTime?.getTime())) || + (endDate && Number.isNaN(endTime?.getTime())) + ) { + return res.status(400).json({ success: false, error: 'Invalid date range' }) + } + + if (startTime && endTime && startTime > endTime) { + return res + .status(400) + .json({ success: false, error: 'Start date must be before or equal to end date' }) + } + + const accountInfo = await resolveAccountByPlatform(accountId, platform) + if (!accountInfo) { + return res.status(404).json({ success: false, error: 'Account not found' }) + } + + const allApiKeys = await apiKeyService.getAllApiKeys(true) + const apiKeyNameCache = new Map( + allApiKeys.map((key) => [key.id, key.name || key.label || key.id]) + ) + + let keysToUse = apiKeyId ? allApiKeys.filter((key) => key.id === apiKeyId) : allApiKeys + if (apiKeyId && keysToUse.length === 0) { + keysToUse = [{ id: apiKeyId }] + } + + const toUsageObject = (record) => ({ + input_tokens: record.inputTokens || 0, + output_tokens: record.outputTokens || 0, + cache_creation_input_tokens: record.cacheCreateTokens || 0, + cache_read_input_tokens: record.cacheReadTokens || 0, + cache_creation: record.cacheCreation || record.cache_creation || null + }) + + const withinRange = (record) => { + if (!record.timestamp) { + return false + } + const ts = new Date(record.timestamp) + if (Number.isNaN(ts.getTime())) { + return false + } + if (startTime && ts < startTime) { + return false + } + if (endTime && ts > endTime) { + return false + } + return true + } + + const filteredRecords = [] + const modelSet = new Set() + const apiKeyOptionMap = new Map() + let earliestTimestamp = null + let latestTimestamp = null + + const batchSize = 10 + for (let i = 0; i < keysToUse.length; i += batchSize) { + const batch = keysToUse.slice(i, i + batchSize) + const batchResults = await Promise.all( + batch.map(async (key) => { + try { + const records = await redis.getUsageRecords(key.id, 5000) + return { keyId: key.id, records: records || [] } + } catch (error) { + logger.debug(`⚠️ Failed to get usage records for key ${key.id}: ${error.message}`) + return { keyId: key.id, records: [] } + } + }) + ) + + for (const { keyId, records } of batchResults) { + const apiKeyName = apiKeyNameCache.get(keyId) || (await getApiKeyName(keyId)) + for (const record of records) { + if (record.accountId !== accountId) { + continue + } + if (!withinRange(record)) { + continue + } + if (model && record.model !== model) { + continue + } + + const accountType = record.accountType || accountInfo.platform || 'unknown' + const normalizedModel = record.model || 'unknown' + + modelSet.add(normalizedModel) + apiKeyOptionMap.set(keyId, { id: keyId, name: apiKeyName }) + + if (record.timestamp) { + const ts = new Date(record.timestamp) + if (!Number.isNaN(ts.getTime())) { + if (!earliestTimestamp || ts < earliestTimestamp) { + earliestTimestamp = ts + } + if (!latestTimestamp || ts > latestTimestamp) { + latestTimestamp = ts + } + } + } + + filteredRecords.push({ + ...record, + model: normalizedModel, + accountType, + apiKeyId: keyId, + apiKeyName + }) + } + } + } + + filteredRecords.sort((a, b) => { + const aTime = new Date(a.timestamp).getTime() + const bTime = new Date(b.timestamp).getTime() + if (Number.isNaN(aTime) || Number.isNaN(bTime)) { + return 0 + } + return normalizedSortOrder === 'asc' ? aTime - bTime : bTime - aTime + }) + + const summary = { + totalRequests: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreateTokens: 0, + cacheReadTokens: 0, + totalTokens: 0, + totalCost: 0 + } + + for (const record of filteredRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + summary.totalRequests += 1 + summary.inputTokens += usage.input_tokens + summary.outputTokens += usage.output_tokens + summary.cacheCreateTokens += usage.cache_creation_input_tokens + summary.cacheReadTokens += usage.cache_read_input_tokens + summary.totalTokens += totalTokens + summary.totalCost += computedCost + } + + const totalRecords = filteredRecords.length + const totalPages = totalRecords > 0 ? Math.ceil(totalRecords / pageSizeNumber) : 0 + const safePage = totalPages > 0 ? Math.min(pageNumber, totalPages) : 1 + const startIndex = (safePage - 1) * pageSizeNumber + const pageRecords = + totalRecords === 0 ? [] : filteredRecords.slice(startIndex, startIndex + pageSizeNumber) + + const enrichedRecords = [] + for (const record of pageRecords) { + const usage = toUsageObject(record) + const costData = CostCalculator.calculateCost(usage, record.model || 'unknown') + const computedCost = + typeof record.cost === 'number' ? record.cost : costData?.costs?.total || 0 + const totalTokens = + record.totalTokens || + usage.input_tokens + + usage.output_tokens + + usage.cache_creation_input_tokens + + usage.cache_read_input_tokens + + enrichedRecords.push({ + timestamp: record.timestamp, + model: record.model || 'unknown', + apiKeyId: record.apiKeyId, + apiKeyName: record.apiKeyName, + accountId, + accountName: accountInfo.name || accountInfo.email || accountId, + accountType: record.accountType, + accountTypeName: accountTypeNames[record.accountType] || '未知渠道', + inputTokens: usage.input_tokens, + outputTokens: usage.output_tokens, + cacheCreateTokens: usage.cache_creation_input_tokens, + cacheReadTokens: usage.cache_read_input_tokens, + ephemeral5mTokens: record.ephemeral5mTokens || 0, + ephemeral1hTokens: record.ephemeral1hTokens || 0, + totalTokens, + isLongContextRequest: record.isLongContext || record.isLongContextRequest || false, + cost: Number(computedCost.toFixed(6)), + costFormatted: + record.costFormatted || + costData?.formatted?.total || + CostCalculator.formatCost(computedCost), + costBreakdown: record.costBreakdown || { + input: costData?.costs?.input || 0, + output: costData?.costs?.output || 0, + cacheCreate: costData?.costs?.cacheWrite || 0, + cacheRead: costData?.costs?.cacheRead || 0, + total: costData?.costs?.total || computedCost + }, + responseTime: record.responseTime || null + }) + } + + return res.json({ + success: true, + data: { + records: enrichedRecords, + pagination: { + currentPage: safePage, + pageSize: pageSizeNumber, + totalRecords, + totalPages, + hasNextPage: totalPages > 0 && safePage < totalPages, + hasPreviousPage: totalPages > 0 && safePage > 1 + }, + filters: { + startDate: startTime ? startTime.toISOString() : null, + endDate: endTime ? endTime.toISOString() : null, + model: model || null, + apiKeyId: apiKeyId || null, + platform: accountInfo.platform, + sortOrder: normalizedSortOrder + }, + accountInfo: { + id: accountId, + name: accountInfo.name || accountInfo.email || accountId, + platform: accountInfo.platform || platform || 'unknown', + status: accountInfo.status ?? accountInfo.isActive ?? null + }, + summary: { + ...summary, + totalCost: Number(summary.totalCost.toFixed(6)), + avgCost: + summary.totalRequests > 0 + ? Number((summary.totalCost / summary.totalRequests).toFixed(6)) + : 0 + }, + availableFilters: { + models: Array.from(modelSet), + apiKeys: Array.from(apiKeyOptionMap.values()), + dateRange: { + earliest: earliestTimestamp ? earliestTimestamp.toISOString() : null, + latest: latestTimestamp ? latestTimestamp.toISOString() : null + } + } + } + }) + } catch (error) { + logger.error('❌ Failed to get account usage records:', error) + return res + .status(500) + .json({ error: 'Failed to get account usage records', message: error.message }) + } +}) + module.exports = router diff --git a/web/admin-spa/src/router/index.js b/web/admin-spa/src/router/index.js index 3375c906..feb67aa1 100644 --- a/web/admin-spa/src/router/index.js +++ b/web/admin-spa/src/router/index.js @@ -13,6 +13,7 @@ const DashboardView = () => import('@/views/DashboardView.vue') const ApiKeysView = () => import('@/views/ApiKeysView.vue') const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue') const AccountsView = () => import('@/views/AccountsView.vue') +const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue') const TutorialView = () => import('@/views/TutorialView.vue') const SettingsView = () => import('@/views/SettingsView.vue') const ApiStatsView = () => import('@/views/ApiStatsView.vue') @@ -110,6 +111,18 @@ const routes = [ } ] }, + { + path: '/accounts/:accountId/usage-records', + component: MainLayout, + meta: { requiresAuth: true }, + children: [ + { + path: '', + name: 'AccountUsageRecords', + component: AccountUsageRecordsView + } + ] + }, { path: '/tutorial', component: MainLayout, diff --git a/web/admin-spa/src/views/AccountUsageRecordsView.vue b/web/admin-spa/src/views/AccountUsageRecordsView.vue new file mode 100644 index 00000000..c813e143 --- /dev/null +++ b/web/admin-spa/src/views/AccountUsageRecordsView.vue @@ -0,0 +1,574 @@ + + + diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index c2a31013..da60e249 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1199,6 +1199,15 @@ 详情 + + +
+ + +
@@ -325,6 +333,7 @@ diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index da60e249..88aa858a 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1199,15 +1199,6 @@ 详情 - - -