feat: 新增Claude账号订阅类型设置

1. OAuth可自动判断订阅类型,Setup Token请自行选择。无论那种类型都可以自己改
2. 优化调度,Pro账号不再接受opus模型请求的调度
This commit is contained in:
KevinLiao
2025-08-14 16:43:58 +08:00
parent 1224ade5a7
commit 0e5f4e03c1
7 changed files with 697 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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

View File

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