From 0e5f4e03c10bf7eadef0a705799d128a39a453b3 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Thu, 14 Aug 2025 16:43:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9EClaude=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=AE=A2=E9=98=85=E7=B1=BB=E5=9E=8B=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=201.=20OAuth=E5=8F=AF=E8=87=AA=E5=8A=A8=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E7=B1=BB=E5=9E=8B=EF=BC=8CSetup=20Token?= =?UTF-8?q?=E8=AF=B7=E8=87=AA=E8=A1=8C=E9=80=89=E6=8B=A9=E3=80=82=E6=97=A0?= =?UTF-8?q?=E8=AE=BA=E9=82=A3=E7=A7=8D=E7=B1=BB=E5=9E=8B=E9=83=BD=E5=8F=AF?= =?UTF-8?q?=E4=BB=A5=E8=87=AA=E5=B7=B1=E6=94=B9=202.=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E8=B0=83=E5=BA=A6=EF=BC=8CPro=E8=B4=A6=E5=8F=B7=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8E=A5=E5=8F=97opus=E6=A8=A1=E5=9E=8B=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=9A=84=E8=B0=83=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 40 ++ src/services/claudeAccountService.js | 351 +++++++++++++++++- src/services/unifiedClaudeScheduler.js | 29 ++ src/utils/logger.js | 43 ++- src/utils/oauthHelper.js | 84 ++++- .../src/components/accounts/AccountForm.vue | 120 +++++- web/admin-spa/src/views/AccountsView.vue | 52 ++- 7 files changed, 697 insertions(+), 22 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index 997bef0b..3ecf71ff 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -1376,6 +1376,46 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) } }) +// 更新单个Claude账户的Profile信息 +router.post('/claude-accounts/:accountId/update-profile', authenticateAdmin, async (req, res) => { + try { + const { accountId } = req.params + + const profileInfo = await claudeAccountService.fetchAndUpdateAccountProfile(accountId) + + logger.success(`✅ Updated profile for Claude account: ${accountId}`) + return res.json({ + success: true, + message: 'Account profile updated successfully', + data: profileInfo + }) + } catch (error) { + logger.error('❌ Failed to update account profile:', error) + return res + .status(500) + .json({ error: 'Failed to update account profile', message: error.message }) + } +}) + +// 批量更新所有Claude账户的Profile信息 +router.post('/claude-accounts/update-all-profiles', authenticateAdmin, async (req, res) => { + try { + const result = await claudeAccountService.updateAllAccountProfiles() + + logger.success('✅ Batch profile update completed') + return res.json({ + success: true, + message: 'Batch profile update completed', + data: result + }) + } catch (error) { + logger.error('❌ Failed to update all account profiles:', error) + return res + .status(500) + .json({ error: 'Failed to update all account profiles', message: error.message }) + } +}) + // 刷新Claude账户token router.post('/claude-accounts/:accountId/refresh', authenticateAdmin, async (req, res) => { try { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 2029957b..46b65c07 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -39,7 +39,8 @@ class ClaudeAccountService { isActive = true, accountType = 'shared', // 'dedicated' or 'shared' priority = 50, // 调度优先级 (1-100,数字越小优先级越高) - schedulable = true // 是否可被调度 + schedulable = true, // 是否可被调度 + subscriptionInfo = null // 手动设置的订阅信息 } = options const accountId = uuidv4() @@ -68,7 +69,13 @@ class ClaudeAccountService { lastRefreshAt: '', status: 'active', // 有OAuth数据的账户直接设为active errorMessage: '', - schedulable: schedulable.toString() // 是否可被调度 + schedulable: schedulable.toString(), // 是否可被调度 + // 优先使用手动设置的订阅信息,否则使用OAuth数据中的,否则默认为空 + subscriptionInfo: subscriptionInfo + ? JSON.stringify(subscriptionInfo) + : claudeAiOauth.subscriptionInfo + ? JSON.stringify(claudeAiOauth.subscriptionInfo) + : '' } } else { // 兼容旧格式 @@ -91,7 +98,9 @@ class ClaudeAccountService { lastRefreshAt: '', status: 'created', // created, active, expired, error errorMessage: '', - schedulable: schedulable.toString() // 是否可被调度 + schedulable: schedulable.toString(), // 是否可被调度 + // 手动设置的订阅信息 + subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : '' } } @@ -99,6 +108,24 @@ class ClaudeAccountService { logger.success(`🏢 Created Claude account: ${name} (${accountId})`) + // 如果有 OAuth 数据和 accessToken,且包含 user:profile 权限,尝试获取 profile 信息 + if (claudeAiOauth && claudeAiOauth.accessToken) { + // 检查是否有 user:profile 权限(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = claudeAiOauth.scopes && claudeAiOauth.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + const agent = this._createProxyAgent(proxy) + await this.fetchAndUpdateAccountProfile(accountId, claudeAiOauth.accessToken, agent) + logger.info(`📊 Successfully fetched profile info for new account: ${name}`) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info for new account: ${profileError.message}`) + } + } else { + logger.info(`⏩ Skipping profile fetch for account ${name} (no user:profile scope)`) + } + } + return { id: accountId, name, @@ -188,8 +215,39 @@ class ClaudeAccountService { ) if (response.status === 200) { + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Token refresh response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Token refresh response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + const { access_token, refresh_token, expires_in } = response.data + // 检查是否有套餐信息 + if ( + response.data.subscription || + response.data.plan || + response.data.tier || + response.data.account_type + ) { + const subscriptionInfo = { + subscription: response.data.subscription, + plan: response.data.plan, + tier: response.data.tier, + accountType: response.data.account_type, + features: response.data.features, + limits: response.data.limits + } + logger.info('🎯 Found subscription info in refresh response:', subscriptionInfo) + + // 将套餐信息存储在账户数据中 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + } + // 更新账户数据 accountData.accessToken = this._encryptSensitiveData(access_token) accountData.refreshToken = this._encryptSensitiveData(refresh_token) @@ -200,6 +258,22 @@ class ClaudeAccountService { await redis.setClaudeAccount(accountId, accountData) + // 刷新成功后,如果有 user:profile 权限,尝试获取账号 profile 信息 + // 检查账户的 scopes 是否包含 user:profile(标准 OAuth 有,Setup Token 没有) + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + + if (hasProfileScope) { + try { + await this.fetchAndUpdateAccountProfile(accountId, access_token, agent) + } catch (profileError) { + logger.warn(`⚠️ Failed to fetch profile info after refresh: ${profileError.message}`) + } + } else { + logger.debug( + `⏩ Skipping profile fetch after refresh for account ${accountId} (no user:profile scope)` + ) + } + // 记录刷新成功 logRefreshSuccess(accountId, accountData.name, 'claude', { accessToken: access_token, @@ -343,6 +417,10 @@ class ClaudeAccountService { lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, expiresAt: account.expiresAt, + // 添加套餐信息(如果存在) + subscriptionInfo: account.subscriptionInfo + ? JSON.parse(account.subscriptionInfo) + : null, // 添加限流状态信息 rateLimitStatus: rateLimitInfo ? { @@ -393,7 +471,8 @@ class ClaudeAccountService { 'claudeAiOauth', 'accountType', 'priority', - 'schedulable' + 'schedulable', + 'subscriptionInfo' ] const updatedData = { ...accountData } @@ -408,6 +487,9 @@ class ClaudeAccountService { updatedData[field] = value ? JSON.stringify(value) : '' } else if (field === 'priority') { updatedData[field] = value.toString() + } else if (field === 'subscriptionInfo') { + // 处理订阅信息更新 + updatedData[field] = typeof value === 'string' ? value : JSON.stringify(value) } else if (field === 'claudeAiOauth') { // 更新 Claude AI OAuth 数据 if (value) { @@ -482,15 +564,43 @@ class ClaudeAccountService { } } - // 🎯 智能选择可用账户(支持sticky会话) - async selectAvailableAccount(sessionHash = null) { + // 🎯 智能选择可用账户(支持sticky会话和模型过滤) + async selectAvailableAccount(sessionHash = null, modelName = null) { try { const accounts = await redis.getAllClaudeAccounts() - const activeAccounts = accounts.filter( + let activeAccounts = accounts.filter( (account) => account.isActive === 'true' && account.status !== 'error' ) + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + activeAccounts = activeAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (activeAccounts.length === 0) { + throw new Error('No Claude accounts available that support Opus model') + } + } + if (activeAccounts.length === 0) { throw new Error('No active Claude accounts available') } @@ -541,8 +651,8 @@ class ClaudeAccountService { } } - // 🎯 基于API Key选择账户(支持专属绑定和共享池) - async selectAccountForApiKey(apiKeyData, sessionHash = null) { + // 🎯 基于API Key选择账户(支持专属绑定、共享池和模型过滤) + async selectAccountForApiKey(apiKeyData, sessionHash = null, modelName = null) { try { // 如果API Key绑定了专属账户,优先使用 if (apiKeyData.claudeAccountId) { @@ -562,13 +672,41 @@ class ClaudeAccountService { // 如果没有绑定账户或绑定账户不可用,从共享池选择 const accounts = await redis.getAllClaudeAccounts() - const sharedAccounts = accounts.filter( + let sharedAccounts = accounts.filter( (account) => account.isActive === 'true' && account.status !== 'error' && (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 ) + // 如果请求的是 Opus 模型,过滤掉 Pro 和 Free 账号 + if (modelName && modelName.toLowerCase().includes('opus')) { + sharedAccounts = sharedAccounts.filter((account) => { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = JSON.parse(account.subscriptionInfo) + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + return false // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + return false // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + return true + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + return true + }) + + if (sharedAccounts.length === 0) { + throw new Error('No shared Claude accounts available that support Opus model') + } + } + if (sharedAccounts.length === 0) { throw new Error('No active shared Claude accounts available') } @@ -1117,6 +1255,199 @@ class ClaudeAccountService { } } + // 📊 获取账号 Profile 信息并更新账号类型 + async fetchAndUpdateAccountProfile(accountId, accessToken = null, agent = null) { + try { + const accountData = await redis.getClaudeAccount(accountId) + if (!accountData || Object.keys(accountData).length === 0) { + throw new Error('Account not found') + } + + // 检查账户是否有 user:profile 权限 + const hasProfileScope = accountData.scopes && accountData.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.warn( + `⚠️ Account ${accountId} does not have user:profile scope, cannot fetch profile` + ) + throw new Error('Account does not have user:profile permission') + } + + // 如果没有提供 accessToken,使用账号存储的 token + if (!accessToken) { + accessToken = this._decryptSensitiveData(accountData.accessToken) + if (!accessToken) { + throw new Error('No access token available') + } + } + + // 如果没有提供 agent,创建代理 + if (!agent) { + agent = this._createProxyAgent(accountData.proxy) + } + + logger.info(`📊 Fetching profile info for account: ${accountData.name} (${accountId})`) + + // 请求 profile 接口 + const response = await axios.get('https://api.anthropic.com/api/oauth/profile', { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'claude-cli/1.0.56 (external, cli)', + 'Accept-Language': 'en-US,en;q=0.9' + }, + httpsAgent: agent, + timeout: 15000 + }) + + if (response.status === 200 && response.data) { + const profileData = response.data + + logger.info('✅ Successfully fetched profile data:', { + email: profileData.account?.email, + hasClaudeMax: profileData.account?.has_claude_max, + hasClaudePro: profileData.account?.has_claude_pro, + organizationType: profileData.organization?.organization_type + }) + + // 构建订阅信息 + const subscriptionInfo = { + // 账号信息 + email: profileData.account?.email, + fullName: profileData.account?.full_name, + displayName: profileData.account?.display_name, + hasClaudeMax: profileData.account?.has_claude_max || false, + hasClaudePro: profileData.account?.has_claude_pro || false, + accountUuid: profileData.account?.uuid, + + // 组织信息 + organizationName: profileData.organization?.name, + organizationUuid: profileData.organization?.uuid, + billingType: profileData.organization?.billing_type, + rateLimitTier: profileData.organization?.rate_limit_tier, + organizationType: profileData.organization?.organization_type, + + // 账号类型(基于 has_claude_max 和 has_claude_pro 判断) + accountType: + profileData.account?.has_claude_max === true + ? 'claude_max' + : profileData.account?.has_claude_pro === true + ? 'claude_pro' + : 'free', + + // 更新时间 + profileFetchedAt: new Date().toISOString() + } + + // 更新账户数据 + accountData.subscriptionInfo = JSON.stringify(subscriptionInfo) + accountData.profileUpdatedAt = new Date().toISOString() + + // 如果提供了邮箱,更新邮箱字段 + if (profileData.account?.email) { + accountData.email = this._encryptSensitiveData(profileData.account.email) + } + + await redis.setClaudeAccount(accountId, accountData) + + logger.success( + `✅ Updated account profile for ${accountData.name} (${accountId}) - Type: ${subscriptionInfo.accountType}` + ) + + return subscriptionInfo + } else { + throw new Error(`Failed to fetch profile with status: ${response.status}`) + } + } catch (error) { + if (error.response?.status === 401) { + logger.warn(`⚠️ Profile API returned 401 for account ${accountId} - token may be invalid`) + } else if (error.response?.status === 403) { + logger.warn( + `⚠️ Profile API returned 403 for account ${accountId} - insufficient permissions` + ) + } else { + logger.error(`❌ Failed to fetch profile for account ${accountId}:`, error.message) + } + throw error + } + } + + // 🔄 手动更新所有账号的 Profile 信息 + async updateAllAccountProfiles() { + try { + logger.info('🔄 Starting batch profile update for all accounts...') + + const accounts = await redis.getAllClaudeAccounts() + let successCount = 0 + let failureCount = 0 + const results = [] + + for (const account of accounts) { + // 跳过未激活或错误状态的账号 + if (account.isActive !== 'true' || account.status === 'error') { + logger.info(`⏩ Skipping inactive/error account: ${account.name} (${account.id})`) + continue + } + + // 跳过没有 user:profile 权限的账号(Setup Token 账号) + const hasProfileScope = account.scopes && account.scopes.includes('user:profile') + if (!hasProfileScope) { + logger.info( + `⏩ Skipping account without user:profile scope: ${account.name} (${account.id})` + ) + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: 'No user:profile permission (Setup Token account)' + }) + continue + } + + try { + // 获取有效的 access token + const accessToken = await this.getValidAccessToken(account.id) + if (accessToken) { + const profileInfo = await this.fetchAndUpdateAccountProfile(account.id, accessToken) + successCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: true, + accountType: profileInfo.accountType + }) + } + } catch (error) { + failureCount++ + results.push({ + accountId: account.id, + accountName: account.name, + success: false, + error: error.message + }) + logger.warn( + `⚠️ Failed to update profile for account ${account.name} (${account.id}): ${error.message}` + ) + } + + // 添加延迟以避免触发限流 + await new Promise((resolve) => setTimeout(resolve, 1000)) + } + + logger.success(`✅ Profile update completed: ${successCount} success, ${failureCount} failed`) + + return { + totalAccounts: accounts.length, + successCount, + failureCount, + results + } + } catch (error) { + logger.error('❌ Failed to update account profiles:', error) + throw error + } + } + // 🔄 初始化所有账户的会话窗口(从历史数据恢复) async initializeSessionWindows(forceRecalculate = false) { try { diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 47ee1499..287bb465 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -267,6 +267,35 @@ class UnifiedClaudeScheduler { ) { // 检查是否可调度 + // 检查模型支持(如果请求的是 Opus 模型) + if (requestedModel && requestedModel.toLowerCase().includes('opus')) { + // 检查账号的订阅信息 + if (account.subscriptionInfo) { + try { + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // Pro 和 Free 账号不支持 Opus + if (info.hasClaudePro === true && info.hasClaudeMax !== true) { + logger.info(`🚫 Claude account ${account.name} (Pro) does not support Opus model`) + continue // Claude Pro 不支持 Opus + } + if (info.accountType === 'claude_pro' || info.accountType === 'claude_free') { + logger.info( + `🚫 Claude account ${account.name} (${info.accountType}) does not support Opus model` + ) + continue // 明确标记为 Pro 或 Free 的账号不支持 + } + } catch (e) { + // 解析失败,假设为旧数据,默认支持(兼容旧数据为 Max) + logger.debug(`Account ${account.name} has invalid subscriptionInfo, assuming Max`) + } + } + // 没有订阅信息的账号,默认当作支持(兼容旧数据) + } + // 检查是否被限流 const isRateLimited = await claudeAccountService.isAccountRateLimited(account.id) if (!isRateLimited) { diff --git a/src/utils/logger.js b/src/utils/logger.js index 29045620..9de2ec8f 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -6,11 +6,13 @@ const fs = require('fs') const os = require('os') // 安全的 JSON 序列化函数,处理循环引用 -const safeStringify = (obj, maxDepth = 3) => { +const safeStringify = (obj, maxDepth = 3, fullDepth = false) => { const seen = new WeakSet() + // 如果是fullDepth模式,增加深度限制 + const actualMaxDepth = fullDepth ? 10 : maxDepth const replacer = (key, value, depth = 0) => { - if (depth > maxDepth) { + if (depth > actualMaxDepth) { return '[Max Depth Reached]' } @@ -152,6 +154,21 @@ const securityLogger = winston.createLogger({ silent: false }) +// 🔐 创建专门的认证详细日志记录器(记录完整的认证响应) +const authDetailLogger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf(({ level, message, timestamp, data }) => { + // 使用更深的深度和格式化的JSON输出 + const jsonData = data ? JSON.stringify(data, null, 2) : '{}' + return `[${timestamp}] ${level.toUpperCase()}: ${message}\n${jsonData}\n${'='.repeat(80)}` + }) + ), + transports: [createRotateTransport('claude-relay-auth-detail-%DATE%.log', 'info')], + silent: false +}) + // 🌟 增强的 Winston logger const logger = winston.createLogger({ level: process.env.LOG_LEVEL || config.logging.level, @@ -327,6 +344,28 @@ logger.healthCheck = () => { } } +// 🔐 记录认证详细信息的方法 +logger.authDetail = (message, data = {}) => { + try { + // 记录到主日志(简化版) + logger.info(`🔐 ${message}`, { + type: 'auth-detail', + summary: { + hasAccessToken: !!data.access_token, + hasRefreshToken: !!data.refresh_token, + scopes: data.scope || data.scopes, + organization: data.organization?.name, + account: data.account?.email_address + } + }) + + // 记录到专门的认证详细日志文件(完整数据) + authDetailLogger.info(message, { data }) + } catch (error) { + logger.error('Failed to log auth detail:', error) + } +} + // 🎬 启动日志记录系统 logger.start('Logger initialized', { level: process.env.LOG_LEVEL || config.logging.level, diff --git a/src/utils/oauthHelper.js b/src/utils/oauthHelper.js index 008eb4cc..36cb48aa 100644 --- a/src/utils/oauthHelper.js +++ b/src/utils/oauthHelper.js @@ -16,7 +16,7 @@ const OAUTH_CONFIG = { CLIENT_ID: '9d1c250a-e61b-44d9-88ed-5944d1962f5e', REDIRECT_URI: 'https://console.anthropic.com/oauth/code/callback', SCOPES: 'org:create_api_key user:profile user:inference', - SCOPES_SETUP: 'user:inference' + SCOPES_SETUP: 'user:inference' // Setup Token 只需要推理权限 } /** @@ -203,23 +203,55 @@ async function exchangeCodeForTokens(authorizationCode, codeVerifier, state, pro timeout: 30000 }) + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('OAuth token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 OAuth token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + logger.success('✅ OAuth token exchange successful', { status: response.status, hasAccessToken: !!response.data?.access_token, hasRefreshToken: !!response.data?.refresh_token, - scopes: response.data?.scope + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits }) const { data } = response - // 返回Claude格式的token数据 - return { + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], isMax: true } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in OAuth response:', result.subscriptionInfo) + } + + return result } catch (error) { // 处理axios错误响应 if (error.response) { @@ -340,7 +372,7 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr redirect_uri: OAUTH_CONFIG.REDIRECT_URI, code_verifier: codeVerifier, state, - expires_in: 31536000 + expires_in: 31536000 // Setup Token 可以设置较长的过期时间 } // 创建代理agent @@ -368,16 +400,54 @@ async function exchangeSetupTokenCode(authorizationCode, codeVerifier, state, pr timeout: 30000 }) + // 记录完整的响应数据到专门的认证详细日志 + logger.authDetail('Setup Token exchange response', response.data) + + // 记录简化版本到主日志 + logger.info('📊 Setup Token exchange response (analyzing for subscription info):', { + status: response.status, + hasData: !!response.data, + dataKeys: response.data ? Object.keys(response.data) : [] + }) + + logger.success('✅ Setup Token exchange successful', { + status: response.status, + hasAccessToken: !!response.data?.access_token, + scopes: response.data?.scope, + // 尝试提取可能的套餐信息字段 + subscription: response.data?.subscription, + plan: response.data?.plan, + tier: response.data?.tier, + accountType: response.data?.account_type, + features: response.data?.features, + limits: response.data?.limits + }) + const { data } = response - // 返回Claude格式的token数据 - return { + // 返回Claude格式的token数据,包含可能的套餐信息 + const result = { accessToken: data.access_token, refreshToken: '', expiresAt: (Math.floor(Date.now() / 1000) + data.expires_in) * 1000, scopes: data.scope ? data.scope.split(' ') : ['user:inference', 'user:profile'], isMax: true } + + // 如果响应中包含套餐信息,添加到返回结果中 + if (data.subscription || data.plan || data.tier || data.account_type) { + result.subscriptionInfo = { + subscription: data.subscription, + plan: data.plan, + tier: data.tier, + accountType: data.account_type, + features: data.features, + limits: data.limits + } + logger.info('🎯 Found subscription info in Setup Token response:', result.subscriptionInfo) + } + + return result } catch (error) { // 使用与标准OAuth相同的错误处理逻辑 if (error.response) { diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 14f1a215..4f833da3 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -555,6 +555,44 @@ + +
+ +
+ + + +
+

+ + Pro 和 Free 账号不支持 Claude Opus 4 模型 +

+
+
Google Cloud/Workspace 账号可能需要提供项目 ID

+ +
+ +
+ + + +
+

+ + Pro 和 Free 账号不支持 Claude Opus 4 模型 +

+
+
Oauth
- Claude + {{ + getClaudeAccountType(account) + }} {{ account.scopes && account.scopes.length > 0 ? 'OAuth' : '传统' }}
+
+ + 未知 +
@@ -1378,6 +1387,45 @@ const handleEditSuccess = () => { loadAccounts() } +// 获取 Claude 账号类型显示 +const getClaudeAccountType = (account) => { + // 如果有订阅信息 + if (account.subscriptionInfo) { + try { + // 如果 subscriptionInfo 是字符串,尝试解析 + const info = + typeof account.subscriptionInfo === 'string' + ? JSON.parse(account.subscriptionInfo) + : account.subscriptionInfo + + // 添加调试日志 + console.log('Account subscription info:', { + accountName: account.name, + subscriptionInfo: info, + hasClaudeMax: info.hasClaudeMax, + hasClaudePro: info.hasClaudePro + }) + + // 根据 has_claude_max 和 has_claude_pro 判断 + if (info.hasClaudeMax === true) { + return 'Claude Max' + } else if (info.hasClaudePro === true) { + return 'Claude Pro' + } else { + return 'Claude Free' + } + } catch (e) { + // 解析失败,返回默认值 + console.error('Failed to parse subscription info:', e) + return 'Claude' + } + } + + // 没有订阅信息,保持原有显示 + console.log('No subscription info for account:', account.name) + return 'Claude' +} + // 获取账户状态文本 const getAccountStatusText = (account) => { // 检查是否被封锁