Merge remote-tracking branch 'f3n9/main' into user-management-new

This commit is contained in:
Feng Yue
2025-08-18 15:32:17 +08:00
34 changed files with 2797 additions and 335 deletions

View File

@@ -20,6 +20,7 @@ class ApiKeyService {
claudeConsoleAccountId = null,
geminiAccountId = null,
openaiAccountId = null,
bedrockAccountId = null, // 添加 Bedrock 账号ID支持
permissions = 'all', // 'claude', 'gemini', 'openai', 'all'
isActive = true,
concurrencyLimit = 0,
@@ -52,6 +53,7 @@ class ApiKeyService {
claudeConsoleAccountId: claudeConsoleAccountId || '',
geminiAccountId: geminiAccountId || '',
openaiAccountId: openaiAccountId || '',
bedrockAccountId: bedrockAccountId || '', // 添加 Bedrock 账号ID
permissions: permissions || 'all',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
@@ -86,6 +88,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
@@ -187,6 +190,7 @@ class ApiKeyService {
claudeConsoleAccountId: keyData.claudeConsoleAccountId,
geminiAccountId: keyData.geminiAccountId,
openaiAccountId: keyData.openaiAccountId,
bedrockAccountId: keyData.bedrockAccountId, // 添加 Bedrock 账号ID
permissions: keyData.permissions || 'all',
tokenLimit: parseInt(keyData.tokenLimit),
concurrencyLimit: parseInt(keyData.concurrencyLimit || 0),
@@ -333,6 +337,7 @@ class ApiKeyService {
'claudeConsoleAccountId',
'geminiAccountId',
'openaiAccountId',
'bedrockAccountId', // 添加 Bedrock 账号ID
'permissions',
'expiresAt',
'enableModelRestriction',
@@ -495,6 +500,126 @@ class ApiKeyService {
}
}
// 📊 记录使用情况(新版本,支持详细的缓存类型)
async recordUsageWithDetails(keyId, usageObject, model = 'unknown', accountId = null) {
try {
// 提取 token 数量
const inputTokens = usageObject.input_tokens || 0
const outputTokens = usageObject.output_tokens || 0
const cacheCreateTokens = usageObject.cache_creation_input_tokens || 0
const cacheReadTokens = usageObject.cache_read_input_tokens || 0
const totalTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
// 计算费用(支持详细的缓存类型)- 添加错误处理
let costInfo = { totalCost: 0, ephemeral5mCost: 0, ephemeral1hCost: 0 }
try {
const pricingService = require('./pricingService')
// 确保 pricingService 已初始化
if (!pricingService.pricingData) {
logger.warn('⚠️ PricingService not initialized, initializing now...')
await pricingService.initialize()
}
costInfo = pricingService.calculateCost(usageObject, model)
} catch (pricingError) {
logger.error('❌ Failed to calculate cost:', pricingError)
// 继续执行,不要因为费用计算失败而跳过统计记录
}
// 提取详细的缓存创建数据
let ephemeral5mTokens = 0
let ephemeral1hTokens = 0
if (usageObject.cache_creation && typeof usageObject.cache_creation === 'object') {
ephemeral5mTokens = usageObject.cache_creation.ephemeral_5m_input_tokens || 0
ephemeral1hTokens = usageObject.cache_creation.ephemeral_1h_input_tokens || 0
}
// 记录API Key级别的使用统计 - 这个必须执行
await redis.incrementTokenUsage(
keyId,
totalTokens,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model,
ephemeral5mTokens, // 传递5分钟缓存 tokens
ephemeral1hTokens // 传递1小时缓存 tokens
)
// 记录费用统计
if (costInfo.totalCost > 0) {
await redis.incrementDailyCost(keyId, costInfo.totalCost)
logger.database(
`💰 Recorded cost for ${keyId}: $${costInfo.totalCost.toFixed(6)}, model: ${model}`
)
// 记录详细的缓存费用(如果有)
if (costInfo.ephemeral5mCost > 0 || costInfo.ephemeral1hCost > 0) {
logger.database(
`💰 Cache costs - 5m: $${costInfo.ephemeral5mCost.toFixed(6)}, 1h: $${costInfo.ephemeral1hCost.toFixed(6)}`
)
}
} else {
logger.debug(`💰 No cost recorded for ${keyId} - zero cost for model: ${model}`)
}
// 获取API Key数据以确定关联的账户
const keyData = await redis.getApiKey(keyId)
if (keyData && Object.keys(keyData).length > 0) {
// 更新最后使用时间
keyData.lastUsedAt = new Date().toISOString()
await redis.setApiKey(keyId, keyData)
// 记录账户级别的使用统计(只统计实际处理请求的账户)
if (accountId) {
await redis.incrementAccountUsage(
accountId,
totalTokens,
inputTokens,
outputTokens,
cacheCreateTokens,
cacheReadTokens,
model
)
logger.database(
`📊 Recorded account usage: ${accountId} - ${totalTokens} tokens (API Key: ${keyId})`
)
} else {
logger.debug(
'⚠️ No accountId provided for usage recording, skipping account-level statistics'
)
}
}
const logParts = [`Model: ${model}`, `Input: ${inputTokens}`, `Output: ${outputTokens}`]
if (cacheCreateTokens > 0) {
logParts.push(`Cache Create: ${cacheCreateTokens}`)
// 如果有详细的缓存创建数据,也记录它们
if (usageObject.cache_creation) {
const { ephemeral_5m_input_tokens, ephemeral_1h_input_tokens } =
usageObject.cache_creation
if (ephemeral_5m_input_tokens > 0) {
logParts.push(`5m: ${ephemeral_5m_input_tokens}`)
}
if (ephemeral_1h_input_tokens > 0) {
logParts.push(`1h: ${ephemeral_1h_input_tokens}`)
}
}
}
if (cacheReadTokens > 0) {
logParts.push(`Cache Read: ${cacheReadTokens}`)
}
logParts.push(`Total: ${totalTokens} tokens`)
logger.database(`📊 Recorded usage: ${keyId} - ${logParts.join(', ')}`)
} catch (error) {
logger.error('❌ Failed to record usage:', error)
}
}
// 🔐 生成密钥
_generateSecretKey() {
return crypto.randomBytes(32).toString('hex')

View File

@@ -4,12 +4,28 @@ const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const bedrockRelayService = require('./bedrockRelayService')
const LRUCache = require('../utils/lruCache')
class BedrockAccountService {
constructor() {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'salt'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 Bedrock decrypt cache cleanup completed', this._decryptCache.getStats())
},
10 * 60 * 1000
)
}
// 🏢 创建Bedrock账户
@@ -336,10 +352,22 @@ class BedrockAccountService {
}
}
// 🔑 生成加密密钥(缓存优化)
_generateEncryptionKey() {
if (!this._encryptionKeyCache) {
this._encryptionKeyCache = crypto
.createHash('sha256')
.update(config.security.encryptionKey)
.digest()
logger.info('🔑 Bedrock encryption key derived and cached for performance optimization')
}
return this._encryptionKeyCache
}
// 🔐 加密AWS凭证
_encryptAwsCredentials(credentials) {
try {
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest()
const key = this._generateEncryptionKey()
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
@@ -368,15 +396,35 @@ class BedrockAccountService {
// 检查是否为加密格式 (有 encrypted 和 iv 字段)
if (encryptedData.encrypted && encryptedData.iv) {
// 🎯 检查缓存
const cacheKey = crypto
.createHash('sha256')
.update(JSON.stringify(encryptedData))
.digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
// 加密数据 - 进行解密
const key = crypto.createHash('sha256').update(config.security.encryptionKey).digest()
const key = this._generateEncryptionKey()
const iv = Buffer.from(encryptedData.iv, 'hex')
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedData.encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return JSON.parse(decrypted)
const result = JSON.parse(decrypted)
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, result, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
this._decryptCache.printStats()
}
return result
} else if (encryptedData.accessKeyId) {
// 纯文本数据 - 直接返回 (向后兼容)
logger.warn('⚠️ 发现未加密的AWS凭证建议更新账户以启用加密')

View File

@@ -15,6 +15,7 @@ const {
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
class ClaudeAccountService {
constructor() {
@@ -24,6 +25,22 @@ class ClaudeAccountService {
// 加密相关常量
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
this.ENCRYPTION_SALT = 'salt'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info('🧹 Claude decrypt cache cleanup completed', this._decryptCache.getStats())
},
10 * 60 * 1000
)
}
// 🏢 创建Claude账户
@@ -39,7 +56,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 +86,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 +115,9 @@ class ClaudeAccountService {
lastRefreshAt: '',
status: 'created', // created, active, expired, error
errorMessage: '',
schedulable: schedulable.toString() // 是否可被调度
schedulable: schedulable.toString(), // 是否可被调度
// 手动设置的订阅信息
subscriptionInfo: subscriptionInfo ? JSON.stringify(subscriptionInfo) : ''
}
}
@@ -99,6 +125,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 +232,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 +275,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,
@@ -228,6 +319,21 @@ class ClaudeAccountService {
accountData.status = 'error'
accountData.errorMessage = error.message
await redis.setClaudeAccount(accountId, accountData)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name,
platform: 'claude-oauth',
status: 'error',
errorCode: 'CLAUDE_OAUTH_ERROR',
reason: `Token refresh failed: ${error.message}`
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error)
@@ -343,6 +449,15 @@ class ClaudeAccountService {
lastUsedAt: account.lastUsedAt,
lastRefreshAt: account.lastRefreshAt,
expiresAt: account.expiresAt,
// 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况,避免返回 ['']
scopes: account.scopes && account.scopes.trim() ? account.scopes.split(' ') : [],
// 添加 refreshToken 是否存在的标记(不返回实际值)
hasRefreshToken: !!account.refreshToken,
// 添加套餐信息(如果存在)
subscriptionInfo: account.subscriptionInfo
? JSON.parse(account.subscriptionInfo)
: null,
// 添加限流状态信息
rateLimitStatus: rateLimitInfo
? {
@@ -393,7 +508,8 @@ class ClaudeAccountService {
'claudeAiOauth',
'accountType',
'priority',
'schedulable'
'schedulable',
'subscriptionInfo'
]
const updatedData = { ...accountData }
@@ -408,6 +524,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) {
@@ -453,6 +572,26 @@ class ClaudeAccountService {
updatedData.updatedAt = new Date().toISOString()
// 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === 'false' && accountData.isActive === 'true') {
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: updatedData.name || 'Unknown Account',
platform: 'claude-oauth',
status: 'disabled',
errorCode: 'CLAUDE_OAUTH_MANUALLY_DISABLED',
reason: 'Account manually disabled by administrator'
})
} catch (webhookError) {
logger.error(
'Failed to send webhook notification for manual account disable:',
webhookError
)
}
}
await redis.setClaudeAccount(accountId, updatedData)
logger.success(`📝 Updated Claude account: ${accountId}`)
@@ -482,15 +621,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 +708,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 +729,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')
}
@@ -715,7 +910,16 @@ class ClaudeAccountService {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
let decrypted = ''
// 检查是否是新格式包含IV
if (encryptedData.includes(':')) {
// 新格式iv:encryptedData
@@ -726,8 +930,17 @@ class ClaudeAccountService {
const encrypted = parts[1]
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
this._decryptCache.printStats()
}
return decrypted
}
}
@@ -736,8 +949,12 @@ class ClaudeAccountService {
// 注意在新版本Node.js中这将失败但我们会捕获错误
try {
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey)
let decrypted = decipher.update(encryptedData, 'hex', 'utf8')
decrypted = decipher.update(encryptedData, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 旧格式也存入缓存
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
return decrypted
} catch (oldError) {
// 如果旧方式也失败,返回原数据
@@ -752,7 +969,20 @@ class ClaudeAccountService {
// 🔑 生成加密密钥(辅助方法)
_generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32)
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
// scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解)
// 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用
if (!this._encryptionKeyCache) {
// 只在第一次调用时计算,后续使用缓存
// 由于输入参数固定,派生结果永远相同,不影响数据兼容性
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
logger.info('🔑 Encryption key derived and cached for performance optimization')
}
return this._encryptionKeyCache
}
// 🎭 掩码邮箱地址
@@ -1117,6 +1347,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 {
@@ -1223,6 +1646,21 @@ class ClaudeAccountService {
`⚠️ Account ${accountData.name} (${accountId}) marked as unauthorized and disabled for scheduling`
)
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name,
platform: 'claude-oauth',
status: 'unauthorized',
errorCode: 'CLAUDE_OAUTH_UNAUTHORIZED',
reason: 'Account unauthorized (401 errors detected)'
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to mark account ${accountId} as unauthorized:`, error)

View File

@@ -5,6 +5,7 @@ const { HttpsProxyAgent } = require('https-proxy-agent')
const redis = require('../models/redis')
const logger = require('../utils/logger')
const config = require('../../config/config')
const LRUCache = require('../utils/lruCache')
class ClaudeConsoleAccountService {
constructor() {
@@ -15,6 +16,25 @@ class ClaudeConsoleAccountService {
// Redis键前缀
this.ACCOUNT_KEY_PREFIX = 'claude_console_account:'
this.SHARED_ACCOUNTS_KEY = 'shared_claude_console_accounts'
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
this._encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
this._decryptCache = new LRUCache(500)
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
this._decryptCache.cleanup()
logger.info(
'🧹 Claude Console decrypt cache cleanup completed',
this._decryptCache.getStats()
)
},
10 * 60 * 1000
)
}
// 🏢 创建Claude Console账户
@@ -261,6 +281,26 @@ class ClaudeConsoleAccountService {
updatedData.updatedAt = new Date().toISOString()
// 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === false && existingAccount.isActive === true) {
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: updatedData.name || existingAccount.name || 'Unknown Account',
platform: 'claude-console',
status: 'disabled',
errorCode: 'CLAUDE_CONSOLE_MANUALLY_DISABLED',
reason: 'Account manually disabled by administrator'
})
} catch (webhookError) {
logger.error(
'Failed to send webhook notification for manual account disable:',
webhookError
)
}
}
logger.debug(`[DEBUG] Final updatedData to save: ${JSON.stringify(updatedData, null, 2)}`)
logger.debug(`[DEBUG] Updating Redis key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`)
@@ -403,6 +443,9 @@ class ClaudeConsoleAccountService {
try {
const client = redis.getClientSafe()
// 获取账户信息用于webhook通知
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
const updates = {
status: 'blocked',
errorMessage: reason,
@@ -412,6 +455,24 @@ class ClaudeConsoleAccountService {
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updates)
logger.warn(`🚫 Claude Console account blocked: ${accountId} - ${reason}`)
// 发送Webhook通知
if (accountData && Object.keys(accountData).length > 0) {
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: accountData.name || 'Unknown Account',
platform: 'claude-console',
status: 'blocked',
errorCode: 'CLAUDE_CONSOLE_BLOCKED',
reason
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
}
return { success: true }
} catch (error) {
logger.error(`❌ Failed to block Claude Console account: ${accountId}`, error)
@@ -471,6 +532,13 @@ class ClaudeConsoleAccountService {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
const cached = this._decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':')
@@ -482,6 +550,15 @@ class ClaudeConsoleAccountService {
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
let decrypted = decipher.update(encrypted, 'hex', 'utf8')
decrypted += decipher.final('utf8')
// 💾 存入缓存5分钟过期
this._decryptCache.set(cacheKey, decrypted, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((this._decryptCache.hits + this._decryptCache.misses) % 1000 === 0) {
this._decryptCache.printStats()
}
return decrypted
}
}
@@ -495,7 +572,20 @@ class ClaudeConsoleAccountService {
// 🔑 生成加密密钥
_generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32)
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
// scryptSync 是故意设计为慢速的密钥派生函数(防暴力破解)
// 但在高并发场景下,每次都重新计算会导致 CPU 100% 占用
if (!this._encryptionKeyCache) {
// 只在第一次调用时计算,后续使用缓存
// 由于输入参数固定,派生结果永远相同,不影响数据兼容性
this._encryptionKeyCache = crypto.scryptSync(
config.security.encryptionKey,
this.ENCRYPTION_SALT,
32
)
logger.info('🔑 Console encryption key derived and cached for performance optimization')
}
return this._encryptionKeyCache
}
// 🎭 掩码API URL

View File

@@ -451,6 +451,23 @@ class ClaudeConsoleRelayService {
collectedUsageData.cache_read_input_tokens =
data.message.usage.cache_read_input_tokens || 0
collectedUsageData.model = data.message.model
// 检查是否有详细的 cache_creation 对象
if (
data.message.usage.cache_creation &&
typeof data.message.usage.cache_creation === 'object'
) {
collectedUsageData.cache_creation = {
ephemeral_5m_input_tokens:
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
ephemeral_1h_input_tokens:
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
}
logger.info(
'📊 Collected detailed cache creation data:',
JSON.stringify(collectedUsageData.cache_creation)
)
}
}
if (

View File

@@ -269,17 +269,33 @@ class ClaudeRelayService {
}
}
// 记录成功的API调用
const inputTokens = requestBody.messages
? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4
: 0 // 粗略估算
const outputTokens = response.content
? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4
: 0
// 记录成功的API调用并打印详细的usage数据
let responseBody = null
try {
responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body
} catch (e) {
logger.debug('Failed to parse response body for usage logging')
}
logger.info(
`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens, Output: ~${Math.round(outputTokens)} tokens`
)
if (responseBody && responseBody.usage) {
const { usage } = responseBody
// 打印原始usage数据为JSON字符串
logger.info(
`📊 === Non-Stream Request Usage Summary === Model: ${requestBody.model}, Usage: ${JSON.stringify(usage)}`
)
} else {
// 如果没有usage数据使用估算值
const inputTokens = requestBody.messages
? requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4
: 0
const outputTokens = response.content
? response.content.reduce((sum, content) => sum + (content.text?.length || 0), 0) / 4
: 0
logger.info(
`✅ API request completed - Key: ${apiKeyData.name}, Account: ${accountId}, Model: ${requestBody.model}, Input: ~${Math.round(inputTokens)} tokens (estimated), Output: ~${Math.round(outputTokens)} tokens (estimated)`
)
}
// 在响应中添加accountId以便调用方记录账户级别统计
response.accountId = accountId
@@ -893,8 +909,8 @@ class ClaudeRelayService {
}
let buffer = ''
let finalUsageReported = false // 防止重复统计的标志
const collectedUsageData = {} // 收集来自不同事件的usage数据
const allUsageData = [] // 收集所有的usage事件
let currentUsageData = {} // 当前正在收集的usage数据
let rateLimitDetected = false // 限流检测标志
// 监听数据块解析SSE并寻找usage信息
@@ -931,17 +947,43 @@ class ClaudeRelayService {
// 收集来自不同事件的usage数据
if (data.type === 'message_start' && data.message && data.message.usage) {
// message_start包含input tokens、cache tokens和模型信息
collectedUsageData.input_tokens = data.message.usage.input_tokens || 0
collectedUsageData.cache_creation_input_tokens =
data.message.usage.cache_creation_input_tokens || 0
collectedUsageData.cache_read_input_tokens =
data.message.usage.cache_read_input_tokens || 0
collectedUsageData.model = data.message.model
// 新的消息开始,如果之前有数据,先保存
if (
currentUsageData.input_tokens !== undefined &&
currentUsageData.output_tokens !== undefined
) {
allUsageData.push({ ...currentUsageData })
currentUsageData = {}
}
logger.info(
// message_start包含input tokens、cache tokens和模型信息
currentUsageData.input_tokens = data.message.usage.input_tokens || 0
currentUsageData.cache_creation_input_tokens =
data.message.usage.cache_creation_input_tokens || 0
currentUsageData.cache_read_input_tokens =
data.message.usage.cache_read_input_tokens || 0
currentUsageData.model = data.message.model
// 检查是否有详细的 cache_creation 对象
if (
data.message.usage.cache_creation &&
typeof data.message.usage.cache_creation === 'object'
) {
currentUsageData.cache_creation = {
ephemeral_5m_input_tokens:
data.message.usage.cache_creation.ephemeral_5m_input_tokens || 0,
ephemeral_1h_input_tokens:
data.message.usage.cache_creation.ephemeral_1h_input_tokens || 0
}
logger.debug(
'📊 Collected detailed cache creation data:',
JSON.stringify(currentUsageData.cache_creation)
)
}
logger.debug(
'📊 Collected input/cache data from message_start:',
JSON.stringify(collectedUsageData)
JSON.stringify(currentUsageData)
)
}
@@ -951,18 +993,27 @@ class ClaudeRelayService {
data.usage &&
data.usage.output_tokens !== undefined
) {
collectedUsageData.output_tokens = data.usage.output_tokens || 0
currentUsageData.output_tokens = data.usage.output_tokens || 0
logger.info(
logger.debug(
'📊 Collected output data from message_delta:',
JSON.stringify(collectedUsageData)
JSON.stringify(currentUsageData)
)
// 如果已经收集到了input数据,现在有了output数据可以统计了
if (collectedUsageData.input_tokens !== undefined && !finalUsageReported) {
logger.info('🎯 Complete usage data collected, triggering callback')
usageCallback(collectedUsageData)
finalUsageReported = true
// 如果已经收集到了input数据output数据这是一个完整的usage
if (currentUsageData.input_tokens !== undefined) {
logger.debug(
'🎯 Complete usage data collected for model:',
currentUsageData.model,
'- Input:',
currentUsageData.input_tokens,
'Output:',
currentUsageData.output_tokens
)
// 保存到列表中,但不立即触发回调
allUsageData.push({ ...currentUsageData })
// 重置当前数据,准备接收下一个
currentUsageData = {}
}
}
@@ -1020,11 +1071,73 @@ class ClaudeRelayService {
logger.error('❌ Error processing stream end:', error)
}
// 如果还有未完成的usage数据尝试保存
if (currentUsageData.input_tokens !== undefined) {
if (currentUsageData.output_tokens === undefined) {
currentUsageData.output_tokens = 0 // 如果没有output设为0
}
allUsageData.push(currentUsageData)
}
// 检查是否捕获到usage数据
if (!finalUsageReported) {
if (allUsageData.length === 0) {
logger.warn(
'⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.'
)
} else {
// 打印此次请求的所有usage数据汇总
const totalUsage = allUsageData.reduce(
(acc, usage) => ({
input_tokens: (acc.input_tokens || 0) + (usage.input_tokens || 0),
output_tokens: (acc.output_tokens || 0) + (usage.output_tokens || 0),
cache_creation_input_tokens:
(acc.cache_creation_input_tokens || 0) + (usage.cache_creation_input_tokens || 0),
cache_read_input_tokens:
(acc.cache_read_input_tokens || 0) + (usage.cache_read_input_tokens || 0),
models: [...(acc.models || []), usage.model].filter(Boolean)
}),
{}
)
// 打印原始的usage数据为JSON字符串避免嵌套问题
logger.info(
`📊 === Stream Request Usage Summary === Model: ${body.model}, Total Events: ${allUsageData.length}, Usage Data: ${JSON.stringify(allUsageData)}`
)
// 一般一个请求只会使用一个模型即使有多个usage事件也应该合并
// 计算总的usage
const finalUsage = {
input_tokens: totalUsage.input_tokens,
output_tokens: totalUsage.output_tokens,
cache_creation_input_tokens: totalUsage.cache_creation_input_tokens,
cache_read_input_tokens: totalUsage.cache_read_input_tokens,
model: allUsageData[allUsageData.length - 1].model || body.model // 使用最后一个模型或请求模型
}
// 如果有详细的cache_creation数据合并它们
let totalEphemeral5m = 0
let totalEphemeral1h = 0
allUsageData.forEach((usage) => {
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
totalEphemeral5m += usage.cache_creation.ephemeral_5m_input_tokens || 0
totalEphemeral1h += usage.cache_creation.ephemeral_1h_input_tokens || 0
}
})
// 如果有详细的缓存数据添加到finalUsage
if (totalEphemeral5m > 0 || totalEphemeral1h > 0) {
finalUsage.cache_creation = {
ephemeral_5m_input_tokens: totalEphemeral5m,
ephemeral_1h_input_tokens: totalEphemeral1h
}
logger.info(
'📊 Detailed cache creation breakdown:',
JSON.stringify(finalUsage.cache_creation)
)
}
// 调用一次usageCallback记录合并后的数据
usageCallback(finalUsage)
}
// 处理限流状态

View File

@@ -13,6 +13,7 @@ const {
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const tokenRefreshService = require('./tokenRefreshService')
const LRUCache = require('../utils/lruCache')
// Gemini CLI OAuth 配置 - 这些是公开的 Gemini CLI 凭据
const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'
@@ -24,9 +25,20 @@ const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'gemini-account-salt'
const IV_LENGTH = 16
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
let _encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
const decryptCache = new LRUCache(500)
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
if (!_encryptionKeyCache) {
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
logger.info('🔑 Gemini encryption key derived and cached for performance optimization')
}
return _encryptionKeyCache
}
// Gemini 账户键前缀
@@ -52,6 +64,14 @@ function decrypt(text) {
if (!text) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
@@ -63,13 +83,32 @@ function decrypt(text) {
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
return decrypted.toString()
const result = decrypted.toString()
// 💾 存入缓存5分钟过期
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
decryptCache.printStats()
}
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
decryptCache.cleanup()
logger.info('🧹 Gemini decrypt cache cleanup completed', decryptCache.getStats())
},
10 * 60 * 1000
)
// 创建 OAuth2 客户端
function createOAuth2Client(redirectUri = null) {
// 如果没有提供 redirectUri使用默认值
@@ -291,7 +330,8 @@ async function createAccount(accountData) {
accessToken: accessToken ? encrypt(accessToken) : '',
refreshToken: refreshToken ? encrypt(refreshToken) : '',
expiresAt,
scopes: accountData.scopes || OAUTH_SCOPES.join(' '),
// 只有OAuth方式才有scopes手动添加的没有
scopes: accountData.geminiOauth ? accountData.scopes || OAUTH_SCOPES.join(' ') : '',
// 代理设置
proxy: accountData.proxy ? JSON.stringify(accountData.proxy) : '',
@@ -455,6 +495,23 @@ async function updateAccount(accountId, updates) {
}
}
// 检查是否手动禁用了账号如果是则发送webhook通知
if (updates.isActive === 'false' && existingAccount.isActive !== 'false') {
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: updates.name || existingAccount.name || 'Unknown Account',
platform: 'gemini',
status: 'disabled',
errorCode: 'GEMINI_MANUALLY_DISABLED',
reason: 'Account manually disabled by administrator'
})
} catch (webhookError) {
logger.error('Failed to send webhook notification for manual account disable:', webhookError)
}
}
await client.hset(`${GEMINI_ACCOUNT_KEY_PREFIX}${accountId}`, updates)
logger.info(`Updated Gemini account: ${accountId}`)
@@ -534,6 +591,12 @@ async function getAllAccounts() {
geminiOauth: accountData.geminiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
// 添加 scopes 字段用于判断认证方式
// 处理空字符串和默认值的情况
scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记
hasRefreshToken: !!accountData.refreshToken,
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
@@ -764,6 +827,21 @@ async function refreshAccountToken(accountId) {
status: 'error',
errorMessage: error.message
})
// 发送Webhook通知
try {
const webhookNotifier = require('../utils/webhookNotifier')
await webhookNotifier.sendAccountAnomalyNotification({
accountId,
accountName: account.name,
platform: 'gemini',
status: 'error',
errorCode: 'GEMINI_ERROR',
reason: `Token refresh failed: ${error.message}`
})
} catch (webhookError) {
logger.error('Failed to send webhook notification:', webhookError)
}
} catch (updateError) {
logger.error('Failed to update account status after refresh error:', updateError)
}
@@ -947,7 +1025,12 @@ async function onboardUser(client, tierId, projectId, clientMetadata) {
metadata: clientMetadata
}
logger.info('📋 开始onboardUser API调用', { tierId, projectId })
logger.info('📋 开始onboardUser API调用', {
tierId,
projectId,
hasProjectId: !!projectId,
isFreeTier: tierId === 'free-tier' || tierId === 'FREE'
})
// 轮询onboardUser直到长运行操作完成
let lroRes = await axios({
@@ -1209,6 +1292,10 @@ module.exports = {
getOnboardTier,
onboardUser,
setupUser,
encrypt,
decrypt,
generateEncryptionKey,
decryptCache, // 暴露缓存对象以便测试和监控
countTokens,
generateContent,
generateContentStream,

View File

@@ -1,6 +1,9 @@
const redisClient = require('../models/redis')
const { v4: uuidv4 } = require('uuid')
const crypto = require('crypto')
const axios = require('axios')
const { SocksProxyAgent } = require('socks-proxy-agent')
const { HttpsProxyAgent } = require('https-proxy-agent')
const config = require('../../config/config')
const logger = require('../utils/logger')
// const { maskToken } = require('../utils/tokenMask')
@@ -11,6 +14,7 @@ const {
logTokenUsage,
logRefreshSkipped
} = require('../utils/tokenRefreshLogger')
const LRUCache = require('../utils/lruCache')
// const tokenRefreshService = require('./tokenRefreshService')
// 加密相关常量
@@ -18,9 +22,20 @@ const ALGORITHM = 'aes-256-cbc'
const ENCRYPTION_SALT = 'openai-account-salt'
const IV_LENGTH = 16
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 占用
let _encryptionKeyCache = null
// 🔄 解密结果缓存,提高解密性能
const decryptCache = new LRUCache(500)
// 生成加密密钥(使用与 claudeAccountService 相同的方法)
function generateEncryptionKey() {
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
if (!_encryptionKeyCache) {
_encryptionKeyCache = crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
logger.info('🔑 OpenAI encryption key derived and cached for performance optimization')
}
return _encryptionKeyCache
}
// OpenAI 账户键前缀
@@ -46,6 +61,14 @@ function decrypt(text) {
if (!text) {
return ''
}
// 🎯 检查缓存
const cacheKey = crypto.createHash('sha256').update(text).digest('hex')
const cached = decryptCache.get(cacheKey)
if (cached !== undefined) {
return cached
}
try {
const key = generateEncryptionKey()
// IV 是固定长度的 32 个十六进制字符16 字节)
@@ -57,23 +80,112 @@ function decrypt(text) {
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
let decrypted = decipher.update(encryptedText)
decrypted = Buffer.concat([decrypted, decipher.final()])
return decrypted.toString()
const result = decrypted.toString()
// 💾 存入缓存5分钟过期
decryptCache.set(cacheKey, result, 5 * 60 * 1000)
// 📊 定期打印缓存统计
if ((decryptCache.hits + decryptCache.misses) % 1000 === 0) {
decryptCache.printStats()
}
return result
} catch (error) {
logger.error('Decryption error:', error)
return ''
}
}
// 🧹 定期清理缓存每10分钟
setInterval(
() => {
decryptCache.cleanup()
logger.info('🧹 OpenAI decrypt cache cleanup completed', decryptCache.getStats())
},
10 * 60 * 1000
)
// 刷新访问令牌
async function refreshAccessToken(_refreshToken) {
async function refreshAccessToken(refreshToken, proxy = null) {
try {
// OpenAI OAuth token 刷新实现
// TODO: 实现具体的 OpenAI OAuth token 刷新逻辑
logger.warn('OpenAI token refresh not yet implemented')
return null
// Codex CLI 的官方 CLIENT_ID
const CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
// 准备请求数据
const requestData = new URLSearchParams({
grant_type: 'refresh_token',
client_id: CLIENT_ID,
refresh_token: refreshToken,
scope: 'openid profile email'
}).toString()
// 配置请求选项
const requestOptions = {
method: 'POST',
url: 'https://auth.openai.com/oauth/token',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': requestData.length
},
data: requestData,
timeout: 30000 // 30秒超时
}
// 配置代理(如果有)
if (proxy && proxy.host && proxy.port) {
if (proxy.type === 'socks5') {
const proxyAuth =
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const socksProxy = `socks5://${proxyAuth}${proxy.host}:${proxy.port}`
requestOptions.httpsAgent = new SocksProxyAgent(socksProxy)
} else if (proxy.type === 'http' || proxy.type === 'https') {
const proxyAuth =
proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : ''
const httpProxy = `http://${proxyAuth}${proxy.host}:${proxy.port}`
requestOptions.httpsAgent = new HttpsProxyAgent(httpProxy)
}
}
// 发送请求
const response = await axios(requestOptions)
if (response.status === 200 && response.data) {
const result = response.data
logger.info('✅ Successfully refreshed OpenAI token')
// 返回新的 token 信息
return {
access_token: result.access_token,
id_token: result.id_token,
refresh_token: result.refresh_token || refreshToken, // 如果没有返回新的,保留原来的
expires_in: result.expires_in || 3600,
expiry_date: Date.now() + (result.expires_in || 3600) * 1000 // 计算过期时间
}
} else {
throw new Error(`Failed to refresh token: ${response.status} ${response.statusText}`)
}
} catch (error) {
logger.error('Error refreshing OpenAI access token:', error)
throw error
if (error.response) {
// 服务器响应了错误状态码
logger.error('OpenAI token refresh failed:', {
status: error.response.status,
data: error.response.data,
headers: error.response.headers
})
throw new Error(
`Token refresh failed: ${error.response.status} - ${JSON.stringify(error.response.data)}`
)
} else if (error.request) {
// 请求已发出但没有收到响应
logger.error('OpenAI token refresh no response:', error.message)
throw new Error(`Token refresh failed: No response from server - ${error.message}`)
} else {
// 设置请求时发生错误
logger.error('OpenAI token refresh error:', error.message)
throw new Error(`Token refresh failed: ${error.message}`)
}
}
}
@@ -102,17 +214,41 @@ async function refreshAccountToken(accountId) {
throw new Error('No refresh token available')
}
// 获取代理配置
let proxy = null
if (account.proxy) {
try {
proxy = typeof account.proxy === 'string' ? JSON.parse(account.proxy) : account.proxy
} catch (e) {
logger.warn(`Failed to parse proxy config for account ${accountId}:`, e)
}
}
try {
const newTokens = await refreshAccessToken(refreshToken)
const newTokens = await refreshAccessToken(refreshToken, proxy)
if (!newTokens) {
throw new Error('Failed to refresh token')
}
// 更新账户信息
await updateAccount(accountId, {
// 准备更新数据
const updates = {
accessToken: encrypt(newTokens.access_token),
expiresAt: new Date(newTokens.expiry_date).toISOString()
})
}
// 如果有新的 ID token也更新它
if (newTokens.id_token) {
updates.idToken = encrypt(newTokens.id_token)
}
// 如果返回了新的 refresh token更新它
if (newTokens.refresh_token && newTokens.refresh_token !== refreshToken) {
updates.refreshToken = encrypt(newTokens.refresh_token)
logger.info(`Updated refresh token for account ${accountId}`)
}
// 更新账户信息
await updateAccount(accountId, updates)
logRefreshSuccess(accountId, accountName, 'openai', newTokens.expiry_date)
return newTokens
@@ -374,6 +510,12 @@ async function getAllAccounts() {
openaiOauth: accountData.openaiOauth ? '[ENCRYPTED]' : '',
accessToken: accountData.accessToken ? '[ENCRYPTED]' : '',
refreshToken: accountData.refreshToken ? '[ENCRYPTED]' : '',
// 添加 scopes 字段用于判断认证方式
// 处理空字符串的情况
scopes:
accountData.scopes && accountData.scopes.trim() ? accountData.scopes.split(' ') : [],
// 添加 hasRefreshToken 标记
hasRefreshToken: !!accountData.refreshToken,
// 添加限流状态信息(统一格式)
rateLimitStatus: rateLimitInfo
? {
@@ -590,5 +732,7 @@ module.exports = {
updateAccountUsage,
recordUsage, // 别名指向updateAccountUsage
encrypt,
decrypt
decrypt,
generateEncryptionKey,
decryptCache // 暴露缓存对象以便测试和监控
}

View File

@@ -20,6 +20,41 @@ class PricingService {
this.updateInterval = 24 * 60 * 60 * 1000 // 24小时
this.fileWatcher = null // 文件监听器
this.reloadDebounceTimer = null // 防抖定时器
// 硬编码的 1 小时缓存价格(美元/百万 token
// ephemeral_5m 的价格使用 model_pricing.json 中的 cache_creation_input_token_cost
// ephemeral_1h 的价格需要硬编码
this.ephemeral1hPricing = {
// Opus 系列: $30/MTok
'claude-opus-4-1': 0.00003,
'claude-opus-4-1-20250805': 0.00003,
'claude-opus-4': 0.00003,
'claude-opus-4-20250514': 0.00003,
'claude-3-opus': 0.00003,
'claude-3-opus-latest': 0.00003,
'claude-3-opus-20240229': 0.00003,
// Sonnet 系列: $6/MTok
'claude-3-5-sonnet': 0.000006,
'claude-3-5-sonnet-latest': 0.000006,
'claude-3-5-sonnet-20241022': 0.000006,
'claude-3-5-sonnet-20240620': 0.000006,
'claude-3-sonnet': 0.000006,
'claude-3-sonnet-20240307': 0.000006,
'claude-sonnet-3': 0.000006,
'claude-sonnet-3-5': 0.000006,
'claude-sonnet-3-7': 0.000006,
'claude-sonnet-4': 0.000006,
// Haiku 系列: $1.6/MTok
'claude-3-5-haiku': 0.0000016,
'claude-3-5-haiku-latest': 0.0000016,
'claude-3-5-haiku-20241022': 0.0000016,
'claude-3-haiku': 0.0000016,
'claude-3-haiku-20240307': 0.0000016,
'claude-haiku-3': 0.0000016,
'claude-haiku-3-5': 0.0000016
}
}
// 初始化价格服务
@@ -258,6 +293,40 @@ class PricingService {
return null
}
// 获取 1 小时缓存价格
getEphemeral1hPricing(modelName) {
if (!modelName) {
return 0
}
// 尝试直接匹配
if (this.ephemeral1hPricing[modelName]) {
return this.ephemeral1hPricing[modelName]
}
// 处理各种模型名称变体
const modelLower = modelName.toLowerCase()
// 检查是否是 Opus 系列
if (modelLower.includes('opus')) {
return 0.00003 // $30/MTok
}
// 检查是否是 Sonnet 系列
if (modelLower.includes('sonnet')) {
return 0.000006 // $6/MTok
}
// 检查是否是 Haiku 系列
if (modelLower.includes('haiku')) {
return 0.0000016 // $1.6/MTok
}
// 默认返回 0未知模型
logger.debug(`💰 No 1h cache pricing found for model: ${modelName}`)
return 0
}
// 计算使用费用
calculateCost(usage, modelName) {
const pricing = this.getModelPricing(modelName)
@@ -268,6 +337,8 @@ class PricingService {
outputCost: 0,
cacheCreateCost: 0,
cacheReadCost: 0,
ephemeral5mCost: 0,
ephemeral1hCost: 0,
totalCost: 0,
hasPricing: false
}
@@ -275,23 +346,52 @@ class PricingService {
const inputCost = (usage.input_tokens || 0) * (pricing.input_cost_per_token || 0)
const outputCost = (usage.output_tokens || 0) * (pricing.output_cost_per_token || 0)
const cacheCreateCost =
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
const cacheReadCost =
(usage.cache_read_input_tokens || 0) * (pricing.cache_read_input_token_cost || 0)
// 处理缓存创建费用:
// 1. 如果有详细的 cache_creation 对象,使用它
// 2. 否则使用总的 cache_creation_input_tokens向后兼容
let ephemeral5mCost = 0
let ephemeral1hCost = 0
let cacheCreateCost = 0
if (usage.cache_creation && typeof usage.cache_creation === 'object') {
// 有详细的缓存创建数据
const ephemeral5mTokens = usage.cache_creation.ephemeral_5m_input_tokens || 0
const ephemeral1hTokens = usage.cache_creation.ephemeral_1h_input_tokens || 0
// 5分钟缓存使用标准的 cache_creation_input_token_cost
ephemeral5mCost = ephemeral5mTokens * (pricing.cache_creation_input_token_cost || 0)
// 1小时缓存使用硬编码的价格
const ephemeral1hPrice = this.getEphemeral1hPricing(modelName)
ephemeral1hCost = ephemeral1hTokens * ephemeral1hPrice
// 总的缓存创建费用
cacheCreateCost = ephemeral5mCost + ephemeral1hCost
} else if (usage.cache_creation_input_tokens) {
// 旧格式,所有缓存创建 tokens 都按 5 分钟价格计算(向后兼容)
cacheCreateCost =
(usage.cache_creation_input_tokens || 0) * (pricing.cache_creation_input_token_cost || 0)
ephemeral5mCost = cacheCreateCost
}
return {
inputCost,
outputCost,
cacheCreateCost,
cacheReadCost,
ephemeral5mCost,
ephemeral1hCost,
totalCost: inputCost + outputCost + cacheCreateCost + cacheReadCost,
hasPricing: true,
pricing: {
input: pricing.input_cost_per_token || 0,
output: pricing.output_cost_per_token || 0,
cacheCreate: pricing.cache_creation_input_token_cost || 0,
cacheRead: pricing.cache_read_input_token_cost || 0
cacheRead: pricing.cache_read_input_token_cost || 0,
ephemeral1h: this.getEphemeral1hPricing(modelName)
}
}
}

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

@@ -35,6 +35,28 @@ class UnifiedOpenAIScheduler {
// 普通专属账户
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
// 检查是否被限流
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (isRateLimited) {
const errorMsg = `Dedicated account ${boundAccount.name} is currently rate limited`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
}
// 专属账户可选的模型检查只有明确配置了supportedModels且不为空才检查
if (
requestedModel &&
boundAccount.supportedModels &&
boundAccount.supportedModels.length > 0
) {
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
if (!modelSupported) {
const errorMsg = `Dedicated account ${boundAccount.name} does not support model ${requestedModel}`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
}
}
logger.info(
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId}) for API key ${apiKeyData.name}`
)
@@ -45,9 +67,12 @@ class UnifiedOpenAIScheduler {
accountType: 'openai'
}
} else {
logger.warn(
`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available, falling back to pool`
)
// 专属账户不可用时直接报错,不降级到共享池
const errorMsg = boundAccount
? `Dedicated account ${boundAccount.name} is not available (inactive or error status)`
: `Dedicated account ${apiKeyData.openaiAccountId} not found`
logger.warn(`⚠️ ${errorMsg}`)
throw new Error(errorMsg)
}
}
@@ -90,8 +115,12 @@ class UnifiedOpenAIScheduler {
}
}
// 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -109,7 +138,7 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) with priority ${selectedAccount.priority} for API key ${apiKeyData.name}`
`🎯 Selected account: ${selectedAccount.name} (${selectedAccount.accountId}, ${selectedAccount.accountType}) for API key ${apiKeyData.name}`
)
// 更新账户的最后使用时间
@@ -125,49 +154,12 @@ class UnifiedOpenAIScheduler {
}
}
// 📋 获取所有可用账户
// 📋 获取所有可用账户(仅共享池)
async _getAllAvailableAccounts(apiKeyData, requestedModel = null) {
const availableAccounts = []
// 如果API Key绑定了专属账户优先返回
if (apiKeyData.openaiAccountId) {
const boundAccount = await openaiAccountService.getAccount(apiKeyData.openaiAccountId)
if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') {
const isRateLimited = await this.isAccountRateLimited(boundAccount.id)
if (!isRateLimited) {
// 检查模型支持仅在明确设置了supportedModels且不为空时才检查
// 如果没有设置supportedModels或为空数组则支持所有模型
if (
requestedModel &&
boundAccount.supportedModels &&
boundAccount.supportedModels.length > 0
) {
const modelSupported = boundAccount.supportedModels.includes(requestedModel)
if (!modelSupported) {
logger.warn(
`⚠️ Bound OpenAI account ${boundAccount.name} does not support model ${requestedModel}`
)
return availableAccounts
}
}
logger.info(
`🎯 Using bound dedicated OpenAI account: ${boundAccount.name} (${apiKeyData.openaiAccountId})`
)
return [
{
...boundAccount,
accountId: boundAccount.id,
accountType: 'openai',
priority: parseInt(boundAccount.priority) || 50,
lastUsedAt: boundAccount.lastUsedAt || '0'
}
]
}
} else {
logger.warn(`⚠️ Bound OpenAI account ${apiKeyData.openaiAccountId} is not available`)
}
}
// 注意:专属账户的处理已经在 selectAccountForApiKey 中完成
// 这里只处理共享池账户
// 获取所有OpenAI账户共享池
const openaiAccounts = await openaiAccountService.getAllAccounts()
@@ -221,20 +213,20 @@ class UnifiedOpenAIScheduler {
return availableAccounts
}
// 🔢 按优先级和最后使用时间排序账户
_sortAccountsByPriority(accounts) {
return accounts.sort((a, b) => {
// 首先按优先级排序(数字越小优先级越高)
if (a.priority !== b.priority) {
return a.priority - b.priority
}
// 🔢 按优先级和最后使用时间排序账户(已废弃,改为与 Claude 保持一致,只按最后使用时间排序)
// _sortAccountsByPriority(accounts) {
// return accounts.sort((a, b) => {
// // 首先按优先级排序(数字越小优先级越高)
// if (a.priority !== b.priority) {
// return a.priority - b.priority
// }
// 优先级相同时,按最后使用时间排序(最久未使用的优先)
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed
})
}
// // 优先级相同时,按最后使用时间排序(最久未使用的优先)
// const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
// const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
// return aLastUsed - bLastUsed
// })
// }
// 🔍 检查账户是否可用
async _isAccountAvailable(accountId, accountType) {
@@ -449,8 +441,12 @@ class UnifiedOpenAIScheduler {
throw new Error(`No available accounts in group ${group.name}`)
}
// 按优先级和最后使用时间排序
const sortedAccounts = this._sortAccountsByPriority(availableAccounts)
// 按最后使用时间排序(最久未使用的优先,与 Claude 保持一致)
const sortedAccounts = availableAccounts.sort((a, b) => {
const aLastUsed = new Date(a.lastUsedAt || 0).getTime()
const bLastUsed = new Date(b.lastUsedAt || 0).getTime()
return aLastUsed - bLastUsed // 最久未使用的优先
})
// 选择第一个账户
const selectedAccount = sortedAccounts[0]
@@ -468,7 +464,7 @@ class UnifiedOpenAIScheduler {
}
logger.info(
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId}) with priority ${selectedAccount.priority}`
`🎯 Selected account from group: ${selectedAccount.name} (${selectedAccount.accountId})`
)
// 更新账户的最后使用时间