mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 添加 CCR (Claude Code Router) 账户类型支持
实现通过供应商前缀语法进行 CCR 后端路由的完整支持。 用户现在可以在 Claude Code 中使用 `/model ccr,model_name` 将请求路由到 CCR 后端。 暂时没有实现`/v1/messages/count_tokens`,因为这需要在CCR后端支持。 CCR类型的账户也暂时没有考虑模型的支持情况 ## 核心实现 ### 供应商前缀路由 - 添加 modelHelper 工具用于解析模型名称中的 `ccr,` 供应商前缀 - 检测到前缀时自动路由到 CCR 账户池 - 转发到 CCR 后端前移除供应商前缀 ### 账户管理 - 创建 ccrAccountService 实现 CCR 账户的完整 CRUD 操作 - 支持账户属性:名称、API URL、API Key、代理、优先级、配额 - 实现账户状态:active、rate_limited、unauthorized、overloaded - 支持模型映射和支持模型配置 ### 请求转发 - 实现 ccrRelayService 处理 CCR 后端通信 - 支持流式和非流式请求 - 从 SSE 流中解析和捕获使用数据 - 支持 Bearer 和 x-api-key 两种认证格式 ### 统一调度 - 将 CCR 账户集成到 unifiedClaudeScheduler - 添加 \_selectCcrAccount 方法用于 CCR 特定账户选择 - 支持 CCR 账户的会话粘性 - 防止跨类型会话映射(CCR 会话仅用于 CCR 请求) ### 错误处理 - 实现全面的错误状态管理 - 处理 401(未授权)、429(速率限制)、529(过载)错误 - 成功请求后自动从错误状态恢复 - 支持可配置的速率限制持续时间 ### Web 管理界面 - 添加 CcrAccountForm 组件用于创建/编辑 CCR 账户 - 将 CCR 账户集成到 AccountsView 中,提供完整管理功能 - 支持账户切换、重置和使用统计 - 在界面中显示账户状态和错误信息 ### API 端点 - POST /admin/ccr-accounts - 创建 CCR 账户 - GET /admin/ccr-accounts - 列出所有 CCR 账户 - PUT /admin/ccr-accounts/:id - 更新 CCR 账户 - DELETE /admin/ccr-accounts/:id - 删除 CCR 账户 - PUT /admin/ccr-accounts/:id/toggle - 切换账户启用状态 - PUT /admin/ccr-accounts/:id/toggle-schedulable - 切换可调度状态 - POST /admin/ccr-accounts/:id/reset-usage - 重置每日使用量 - POST /admin/ccr-accounts/:id/reset-status - 重置错误状态 ## 技术细节 - CCR 账户使用 'ccr' 作为 accountType 标识符 - 带有 `ccr,` 前缀的请求绕过普通账户池 - 转发到 CCR 后端前清理模型名称内的`ccr,` - 从流式和非流式响应中捕获使用数据 - 支持缓存令牌跟踪(创建和读取)
This commit is contained in:
903
src/services/ccrAccountService.js
Normal file
903
src/services/ccrAccountService.js
Normal file
@@ -0,0 +1,903 @@
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const crypto = require('crypto')
|
||||
const ProxyHelper = require('../utils/proxyHelper')
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const LRUCache = require('../utils/lruCache')
|
||||
|
||||
class CcrAccountService {
|
||||
constructor() {
|
||||
// 加密相关常量
|
||||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc'
|
||||
this.ENCRYPTION_SALT = 'ccr-account-salt'
|
||||
|
||||
// Redis键前缀
|
||||
this.ACCOUNT_KEY_PREFIX = 'ccr_account:'
|
||||
this.SHARED_ACCOUNTS_KEY = 'shared_ccr_accounts'
|
||||
|
||||
// 🚀 性能优化:缓存派生的加密密钥,避免每次重复计算
|
||||
// scryptSync 是 CPU 密集型操作,缓存可以减少 95%+ 的 CPU 密集型操作
|
||||
this._encryptionKeyCache = null
|
||||
|
||||
// 🔄 解密结果缓存,提高解密性能
|
||||
this._decryptCache = new LRUCache(500)
|
||||
|
||||
// 🧹 定期清理缓存(每10分钟)
|
||||
setInterval(
|
||||
() => {
|
||||
this._decryptCache.cleanup()
|
||||
logger.info('🧹 CCR account decrypt cache cleanup completed', this._decryptCache.getStats())
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
}
|
||||
|
||||
// 🏢 创建CCR账户
|
||||
async createAccount(options = {}) {
|
||||
const {
|
||||
name = 'CCR Account',
|
||||
description = '',
|
||||
apiUrl = '',
|
||||
apiKey = '',
|
||||
priority = 50, // 默认优先级50(1-100)
|
||||
supportedModels = [], // 支持的模型列表或映射表,空数组/对象表示支持所有
|
||||
userAgent = 'claude-relay-service/1.0.0',
|
||||
rateLimitDuration = 60, // 限流时间(分钟)
|
||||
proxy = null,
|
||||
isActive = true,
|
||||
accountType = 'shared', // 'dedicated' or 'shared'
|
||||
schedulable = true, // 是否可被调度
|
||||
dailyQuota = 0, // 每日额度限制(美元),0表示不限制
|
||||
quotaResetTime = '00:00' // 额度重置时间(HH:mm格式)
|
||||
} = options
|
||||
|
||||
// 验证必填字段
|
||||
if (!apiUrl || !apiKey) {
|
||||
throw new Error('API URL and API Key are required for CCR account')
|
||||
}
|
||||
|
||||
const accountId = uuidv4()
|
||||
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(supportedModels)
|
||||
|
||||
const accountData = {
|
||||
id: accountId,
|
||||
platform: 'ccr',
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
apiKey: this._encryptSensitiveData(apiKey),
|
||||
priority: priority.toString(),
|
||||
supportedModels: JSON.stringify(processedModels),
|
||||
userAgent,
|
||||
rateLimitDuration: rateLimitDuration.toString(),
|
||||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||||
isActive: isActive.toString(),
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: '',
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
// 限流相关
|
||||
rateLimitedAt: '',
|
||||
rateLimitStatus: '',
|
||||
// 调度控制
|
||||
schedulable: schedulable.toString(),
|
||||
// 额度管理相关
|
||||
dailyQuota: dailyQuota.toString(), // 每日额度限制(美元)
|
||||
dailyUsage: '0', // 当日使用金额(美元)
|
||||
// 使用与统计一致的时区日期,避免边界问题
|
||||
lastResetDate: redis.getDateStringInTimezone(), // 最后重置日期(按配置时区)
|
||||
quotaResetTime, // 额度重置时间
|
||||
quotaStoppedAt: '' // 因额度停用的时间
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(
|
||||
`[DEBUG] Saving CCR account data to Redis with key: ${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Account data to save: ${JSON.stringify(accountData, null, 2)}`)
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, accountData)
|
||||
|
||||
// 如果是共享账户,添加到共享账户集合
|
||||
if (accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
|
||||
logger.success(`🏢 Created CCR account: ${name} (${accountId})`)
|
||||
|
||||
return {
|
||||
id: accountId,
|
||||
name,
|
||||
description,
|
||||
apiUrl,
|
||||
priority,
|
||||
supportedModels,
|
||||
userAgent,
|
||||
rateLimitDuration,
|
||||
isActive,
|
||||
proxy,
|
||||
accountType,
|
||||
status: 'active',
|
||||
createdAt: accountData.createdAt,
|
||||
dailyQuota,
|
||||
dailyUsage: 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime,
|
||||
quotaStoppedAt: null
|
||||
}
|
||||
}
|
||||
|
||||
// 📋 获取所有CCR账户
|
||||
async getAllAccounts() {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const keys = await client.keys(`${this.ACCOUNT_KEY_PREFIX}*`)
|
||||
const accounts = []
|
||||
|
||||
for (const key of keys) {
|
||||
const accountData = await client.hgetall(key)
|
||||
if (accountData && Object.keys(accountData).length > 0) {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = this._getRateLimitInfo(accountData)
|
||||
|
||||
accounts.push({
|
||||
id: accountData.id,
|
||||
platform: accountData.platform,
|
||||
name: accountData.name,
|
||||
description: accountData.description,
|
||||
apiUrl: accountData.apiUrl,
|
||||
priority: parseInt(accountData.priority) || 50,
|
||||
supportedModels: JSON.parse(accountData.supportedModels || '[]'),
|
||||
userAgent: accountData.userAgent,
|
||||
rateLimitDuration: Number.isNaN(parseInt(accountData.rateLimitDuration))
|
||||
? 60
|
||||
: parseInt(accountData.rateLimitDuration),
|
||||
isActive: accountData.isActive === 'true',
|
||||
proxy: accountData.proxy ? JSON.parse(accountData.proxy) : null,
|
||||
accountType: accountData.accountType || 'shared',
|
||||
createdAt: accountData.createdAt,
|
||||
lastUsedAt: accountData.lastUsedAt,
|
||||
status: accountData.status || 'active',
|
||||
errorMessage: accountData.errorMessage,
|
||||
rateLimitInfo,
|
||||
schedulable: accountData.schedulable !== 'false', // 默认为true,只有明确设置为false才不可调度
|
||||
// 额度管理相关
|
||||
dailyQuota: parseFloat(accountData.dailyQuota || '0'),
|
||||
dailyUsage: parseFloat(accountData.dailyUsage || '0'),
|
||||
lastResetDate: accountData.lastResetDate || '',
|
||||
quotaResetTime: accountData.quotaResetTime || '00:00',
|
||||
quotaStoppedAt: accountData.quotaStoppedAt || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR accounts:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 获取单个账户(内部使用,包含敏感信息)
|
||||
async getAccount(accountId) {
|
||||
const client = redis.getClientSafe()
|
||||
logger.debug(`[DEBUG] Getting CCR account data for ID: ${accountId}`)
|
||||
const accountData = await client.hgetall(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (!accountData || Object.keys(accountData).length === 0) {
|
||||
logger.debug(`[DEBUG] No CCR account data found for ID: ${accountId}`)
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug(`[DEBUG] Raw CCR account data keys: ${Object.keys(accountData).join(', ')}`)
|
||||
logger.debug(`[DEBUG] Raw supportedModels value: ${accountData.supportedModels}`)
|
||||
|
||||
// 解密敏感字段(只解密apiKey,apiUrl不加密)
|
||||
const decryptedKey = this._decryptSensitiveData(accountData.apiKey)
|
||||
logger.debug(
|
||||
`[DEBUG] URL exists: ${!!accountData.apiUrl}, Decrypted key exists: ${!!decryptedKey}`
|
||||
)
|
||||
|
||||
accountData.apiKey = decryptedKey
|
||||
|
||||
// 解析JSON字段
|
||||
const parsedModels = JSON.parse(accountData.supportedModels || '[]')
|
||||
logger.debug(`[DEBUG] Parsed supportedModels: ${JSON.stringify(parsedModels)}`)
|
||||
|
||||
accountData.supportedModels = parsedModels
|
||||
accountData.priority = parseInt(accountData.priority) || 50
|
||||
{
|
||||
const _parsedDuration = parseInt(accountData.rateLimitDuration)
|
||||
accountData.rateLimitDuration = Number.isNaN(_parsedDuration) ? 60 : _parsedDuration
|
||||
}
|
||||
accountData.isActive = accountData.isActive === 'true'
|
||||
accountData.schedulable = accountData.schedulable !== 'false' // 默认为true
|
||||
|
||||
if (accountData.proxy) {
|
||||
accountData.proxy = JSON.parse(accountData.proxy)
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[DEBUG] Final CCR account data - name: ${accountData.name}, hasApiUrl: ${!!accountData.apiUrl}, hasApiKey: ${!!accountData.apiKey}, supportedModels: ${JSON.stringify(accountData.supportedModels)}`
|
||||
)
|
||||
|
||||
return accountData
|
||||
}
|
||||
|
||||
// 📝 更新账户
|
||||
async updateAccount(accountId, updates) {
|
||||
try {
|
||||
const existingAccount = await this.getAccount(accountId)
|
||||
if (!existingAccount) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const updatedData = {}
|
||||
|
||||
// 处理各个字段的更新
|
||||
logger.debug(
|
||||
`[DEBUG] CCR update request received with fields: ${Object.keys(updates).join(', ')}`
|
||||
)
|
||||
logger.debug(`[DEBUG] CCR Updates content: ${JSON.stringify(updates, null, 2)}`)
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updatedData.name = updates.name
|
||||
}
|
||||
if (updates.description !== undefined) {
|
||||
updatedData.description = updates.description
|
||||
}
|
||||
if (updates.apiUrl !== undefined) {
|
||||
updatedData.apiUrl = updates.apiUrl
|
||||
}
|
||||
if (updates.apiKey !== undefined) {
|
||||
updatedData.apiKey = this._encryptSensitiveData(updates.apiKey)
|
||||
}
|
||||
if (updates.priority !== undefined) {
|
||||
updatedData.priority = updates.priority.toString()
|
||||
}
|
||||
if (updates.supportedModels !== undefined) {
|
||||
logger.debug(`[DEBUG] Updating supportedModels: ${JSON.stringify(updates.supportedModels)}`)
|
||||
// 处理 supportedModels,确保向后兼容
|
||||
const processedModels = this._processModelMapping(updates.supportedModels)
|
||||
updatedData.supportedModels = JSON.stringify(processedModels)
|
||||
}
|
||||
if (updates.userAgent !== undefined) {
|
||||
updatedData.userAgent = updates.userAgent
|
||||
}
|
||||
if (updates.rateLimitDuration !== undefined) {
|
||||
updatedData.rateLimitDuration = updates.rateLimitDuration.toString()
|
||||
}
|
||||
if (updates.proxy !== undefined) {
|
||||
updatedData.proxy = updates.proxy ? JSON.stringify(updates.proxy) : ''
|
||||
}
|
||||
if (updates.isActive !== undefined) {
|
||||
updatedData.isActive = updates.isActive.toString()
|
||||
}
|
||||
if (updates.schedulable !== undefined) {
|
||||
updatedData.schedulable = updates.schedulable.toString()
|
||||
}
|
||||
if (updates.dailyQuota !== undefined) {
|
||||
updatedData.dailyQuota = updates.dailyQuota.toString()
|
||||
}
|
||||
if (updates.quotaResetTime !== undefined) {
|
||||
updatedData.quotaResetTime = updates.quotaResetTime
|
||||
}
|
||||
|
||||
await client.hset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, updatedData)
|
||||
|
||||
// 处理共享账户集合变更
|
||||
if (updates.accountType !== undefined) {
|
||||
updatedData.accountType = updates.accountType
|
||||
if (updates.accountType === 'shared') {
|
||||
await client.sadd(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
} else {
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`📝 Updated CCR account: ${accountId}`)
|
||||
return await this.getAccount(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to update CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🗑️ 删除账户
|
||||
async deleteAccount(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 从共享账户集合中移除
|
||||
await client.srem(this.SHARED_ACCOUNTS_KEY, accountId)
|
||||
|
||||
// 删除账户数据
|
||||
const result = await client.del(`${this.ACCOUNT_KEY_PREFIX}${accountId}`)
|
||||
|
||||
if (result === 0) {
|
||||
throw new Error('CCR Account not found or already deleted')
|
||||
}
|
||||
|
||||
logger.success(`🗑️ Deleted CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete CCR account ${accountId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为限流状态
|
||||
async markAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
// 如果限流时间设置为 0,表示不启用限流机制,直接返回
|
||||
if (account.rateLimitDuration === 0) {
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${account.name} (${accountId}) has rate limiting disabled, skipping rate limit`
|
||||
)
|
||||
return { success: true, skipped: true }
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'rate_limited',
|
||||
rateLimitedAt: now,
|
||||
rateLimitStatus: 'active',
|
||||
errorMessage: 'Rate limited by upstream service'
|
||||
})
|
||||
|
||||
logger.warn(`⏱️ Marked CCR account as rate limited: ${account.name} (${accountId})`)
|
||||
return { success: true, rateLimitedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as rate limited: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户限流状态
|
||||
async removeAccountRateLimit(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 获取账户当前状态和额度信息
|
||||
const [, quotaStoppedAt] = await client.hmget(accountKey, 'status', 'quotaStoppedAt')
|
||||
|
||||
// 删除限流相关字段
|
||||
await client.hdel(accountKey, 'rateLimitedAt', 'rateLimitStatus')
|
||||
|
||||
// 根据不同情况决定是否恢复账户
|
||||
let newStatus = 'active'
|
||||
let errorMessage = ''
|
||||
|
||||
// 如果因额度问题停用,不要自动激活
|
||||
if (quotaStoppedAt) {
|
||||
newStatus = 'quota_exceeded'
|
||||
errorMessage = 'Account stopped due to quota exceeded'
|
||||
logger.info(
|
||||
`ℹ️ CCR account ${accountId} rate limit removed but remains stopped due to quota exceeded`
|
||||
)
|
||||
} else {
|
||||
logger.success(`✅ Removed rate limit for CCR account: ${accountId}`)
|
||||
}
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: newStatus,
|
||||
errorMessage
|
||||
})
|
||||
|
||||
return { success: true, newStatus }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove rate limit for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否被限流
|
||||
async isAccountRateLimited(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const [rateLimitedAt, rateLimitDuration] = await client.hmget(
|
||||
accountKey,
|
||||
'rateLimitedAt',
|
||||
'rateLimitDuration'
|
||||
)
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const duration = parseInt(rateLimitDuration) || 60
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + duration * 60 * 1000)
|
||||
|
||||
if (now < expireTime) {
|
||||
return true
|
||||
} else {
|
||||
// 限流时间已过,自动移除限流状态
|
||||
await this.removeAccountRateLimit(accountId)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check rate limit status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 标记账户为过载状态
|
||||
async markAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'overloaded',
|
||||
overloadedAt: now,
|
||||
errorMessage: 'Account overloaded'
|
||||
})
|
||||
|
||||
logger.warn(`🔥 Marked CCR account as overloaded: ${account.name} (${accountId})`)
|
||||
return { success: true, overloadedAt: now }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as overloaded: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ 移除账户过载状态
|
||||
async removeAccountOverload(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
// 删除过载相关字段
|
||||
await client.hdel(accountKey, 'overloadedAt')
|
||||
|
||||
await client.hmset(accountKey, {
|
||||
status: 'active',
|
||||
errorMessage: ''
|
||||
})
|
||||
|
||||
logger.success(`✅ Removed overload status for CCR account: ${accountId}`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to remove overload status for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔍 检查账户是否过载
|
||||
async isAccountOverloaded(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
const status = await client.hget(accountKey, 'status')
|
||||
return status === 'overloaded'
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check overload status for CCR account: ${accountId}`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 标记账户为未授权状态
|
||||
async markAccountUnauthorized(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
throw new Error('CCR Account not found')
|
||||
}
|
||||
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'unauthorized',
|
||||
errorMessage: 'API key invalid or unauthorized'
|
||||
})
|
||||
|
||||
logger.warn(`🚫 Marked CCR account as unauthorized: ${account.name} (${accountId})`)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to mark CCR account as unauthorized: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 处理模型映射
|
||||
_processModelMapping(supportedModels) {
|
||||
// 如果是空值,返回空对象(支持所有模型)
|
||||
if (!supportedModels || (Array.isArray(supportedModels) && supportedModels.length === 0)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// 如果已经是对象格式(新的映射表格式),直接返回
|
||||
if (typeof supportedModels === 'object' && !Array.isArray(supportedModels)) {
|
||||
return supportedModels
|
||||
}
|
||||
|
||||
// 如果是数组格式(旧格式),转换为映射表
|
||||
if (Array.isArray(supportedModels)) {
|
||||
const mapping = {}
|
||||
supportedModels.forEach((model) => {
|
||||
if (model && typeof model === 'string') {
|
||||
mapping[model] = model // 默认映射:原模型名 -> 原模型名
|
||||
}
|
||||
})
|
||||
return mapping
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
// 🔍 检查模型是否被支持
|
||||
isModelSupported(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,支持所有模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return true
|
||||
}
|
||||
// 检查请求的模型是否在映射表的键中
|
||||
return Object.prototype.hasOwnProperty.call(modelMapping, requestedModel)
|
||||
}
|
||||
|
||||
// 🔄 获取映射后的模型名称
|
||||
getMappedModel(modelMapping, requestedModel) {
|
||||
// 如果映射表为空,返回原模型
|
||||
if (!modelMapping || Object.keys(modelMapping).length === 0) {
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
// 返回映射后的模型名,如果不存在映射则返回原模型名
|
||||
return modelMapping[requestedModel] || requestedModel
|
||||
}
|
||||
|
||||
// 🔐 加密敏感数据
|
||||
_encryptSensitiveData(data) {
|
||||
if (!data) {
|
||||
return ''
|
||||
}
|
||||
try {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = crypto.randomBytes(16)
|
||||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv)
|
||||
let encrypted = cipher.update(data, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return `${iv.toString('hex')}:${encrypted}`
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR encryption error:', error)
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
// 🔓 解密敏感数据
|
||||
_decryptSensitiveData(encryptedData) {
|
||||
if (!encryptedData) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// 🎯 检查缓存
|
||||
const cacheKey = crypto.createHash('sha256').update(encryptedData).digest('hex')
|
||||
const cached = this._decryptCache.get(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const parts = encryptedData.split(':')
|
||||
if (parts.length === 2) {
|
||||
const key = this._generateEncryptionKey()
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const encrypted = parts[1]
|
||||
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)
|
||||
|
||||
return decrypted
|
||||
} else {
|
||||
logger.error('❌ Invalid CCR encrypted data format')
|
||||
return encryptedData
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ CCR decryption error:', error)
|
||||
return encryptedData
|
||||
}
|
||||
}
|
||||
|
||||
// 🔑 生成加密密钥
|
||||
_generateEncryptionKey() {
|
||||
// 性能优化:缓存密钥派生结果,避免重复的 CPU 密集计算
|
||||
if (!this._encryptionKeyCache) {
|
||||
this._encryptionKeyCache = crypto.scryptSync(
|
||||
config.security.encryptionKey,
|
||||
this.ENCRYPTION_SALT,
|
||||
32
|
||||
)
|
||||
}
|
||||
return this._encryptionKeyCache
|
||||
}
|
||||
|
||||
// 🔍 获取限流状态信息
|
||||
_getRateLimitInfo(accountData) {
|
||||
const { rateLimitedAt } = accountData
|
||||
const rateLimitDuration = parseInt(accountData.rateLimitDuration) || 60
|
||||
|
||||
if (rateLimitedAt) {
|
||||
const limitTime = new Date(rateLimitedAt)
|
||||
const now = new Date()
|
||||
const expireTime = new Date(limitTime.getTime() + rateLimitDuration * 60 * 1000)
|
||||
const remainingMs = expireTime.getTime() - now.getTime()
|
||||
|
||||
return {
|
||||
isRateLimited: remainingMs > 0,
|
||||
rateLimitedAt,
|
||||
rateLimitExpireAt: expireTime.toISOString(),
|
||||
remainingTimeMs: Math.max(0, remainingMs),
|
||||
remainingTimeMinutes: Math.max(0, Math.ceil(remainingMs / (60 * 1000)))
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isRateLimited: false,
|
||||
rateLimitedAt: null,
|
||||
rateLimitExpireAt: null,
|
||||
remainingTimeMs: 0,
|
||||
remainingTimeMinutes: 0
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 创建代理客户端
|
||||
_createProxyAgent(proxy) {
|
||||
return ProxyHelper.createProxyAgent(proxy)
|
||||
}
|
||||
|
||||
// 💰 检查配额使用情况(可选实现)
|
||||
async checkQuotaUsage(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要重置每日使用量
|
||||
const today = redis.getDateStringInTimezone()
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(accountId)
|
||||
return false // 刚重置,不会超额
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(
|
||||
`💰 CCR account ${account.name} (${accountId}) quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`
|
||||
)
|
||||
|
||||
// 发送 Webhook 通知
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: account.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'quota_exceeded',
|
||||
errorCode: 'QUOTA_EXCEEDED',
|
||||
reason: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR quota exceeded:', webhookError)
|
||||
}
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota usage for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置每日使用量(可选实现)
|
||||
async resetDailyUsage(accountId) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
dailyUsage: '0',
|
||||
lastResetDate: redis.getDateStringInTimezone(),
|
||||
quotaStoppedAt: ''
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset daily usage for CCR account: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 🚫 检查账户是否超额
|
||||
async isAccountQuotaExceeded(accountId) {
|
||||
try {
|
||||
const account = await this.getAccount(accountId)
|
||||
if (!account) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(account.dailyQuota || '0')
|
||||
// 如果未设置额度限制,则不限制
|
||||
if (dailyQuota <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 获取当日使用统计
|
||||
const usageStats = await this.getAccountUsageStats(accountId)
|
||||
if (!usageStats) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dailyUsage = usageStats.dailyUsage || 0
|
||||
const isExceeded = dailyUsage >= dailyQuota
|
||||
|
||||
if (isExceeded && !account.quotaStoppedAt) {
|
||||
// 标记账户因额度停用
|
||||
const client = redis.getClientSafe()
|
||||
await client.hmset(`${this.ACCOUNT_KEY_PREFIX}${accountId}`, {
|
||||
status: 'quota_exceeded',
|
||||
errorMessage: `Daily quota exceeded: $${dailyUsage.toFixed(2)} / $${dailyQuota.toFixed(2)}`,
|
||||
quotaStoppedAt: new Date().toISOString()
|
||||
})
|
||||
logger.warn(`💰 CCR account ${account.name} (${accountId}) quota exceeded`)
|
||||
}
|
||||
|
||||
return isExceeded
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to check quota for CCR account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置所有CCR账户的每日使用量
|
||||
async resetAllDailyUsage() {
|
||||
try {
|
||||
const accounts = await this.getAllAccounts()
|
||||
const today = redis.getDateStringInTimezone()
|
||||
let resetCount = 0
|
||||
|
||||
for (const account of accounts) {
|
||||
if (account.lastResetDate !== today) {
|
||||
await this.resetDailyUsage(account.id)
|
||||
resetCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
logger.success(`✅ Reset daily usage for ${resetCount} CCR accounts`)
|
||||
return { success: true, resetCount }
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to reset all CCR daily usage:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 📊 获取CCR账户使用统计(含每日费用)
|
||||
async getAccountUsageStats(accountId) {
|
||||
try {
|
||||
// 使用统一的 Redis 统计
|
||||
const usageStats = await redis.getAccountUsageStats(accountId)
|
||||
|
||||
// 叠加账户自身的额度配置
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
return null
|
||||
}
|
||||
|
||||
const dailyQuota = parseFloat(accountData.dailyQuota || '0')
|
||||
const currentDailyCost = usageStats?.daily?.cost || 0
|
||||
|
||||
return {
|
||||
dailyQuota,
|
||||
dailyUsage: currentDailyCost,
|
||||
remainingQuota: dailyQuota > 0 ? Math.max(0, dailyQuota - currentDailyCost) : null,
|
||||
usagePercentage: dailyQuota > 0 ? (currentDailyCost / dailyQuota) * 100 : 0,
|
||||
lastResetDate: accountData.lastResetDate,
|
||||
quotaResetTime: accountData.quotaResetTime,
|
||||
quotaStoppedAt: accountData.quotaStoppedAt,
|
||||
isQuotaExceeded: dailyQuota > 0 && currentDailyCost >= dailyQuota,
|
||||
fullUsageStats: usageStats
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get CCR account usage stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 🔄 重置CCR账户所有异常状态
|
||||
async resetAccountStatus(accountId) {
|
||||
try {
|
||||
const accountData = await this.getAccount(accountId)
|
||||
if (!accountData) {
|
||||
throw new Error('Account not found')
|
||||
}
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const accountKey = `${this.ACCOUNT_KEY_PREFIX}${accountId}`
|
||||
|
||||
const updates = {
|
||||
status: 'active',
|
||||
errorMessage: '',
|
||||
schedulable: 'true',
|
||||
isActive: 'true'
|
||||
}
|
||||
|
||||
const fieldsToDelete = [
|
||||
'rateLimitedAt',
|
||||
'rateLimitStatus',
|
||||
'unauthorizedAt',
|
||||
'unauthorizedCount',
|
||||
'overloadedAt',
|
||||
'overloadStatus',
|
||||
'blockedAt',
|
||||
'quotaStoppedAt'
|
||||
]
|
||||
|
||||
await client.hset(accountKey, updates)
|
||||
await client.hdel(accountKey, ...fieldsToDelete)
|
||||
|
||||
logger.success(`✅ Reset all error status for CCR account ${accountId}`)
|
||||
|
||||
// 异步发送 Webhook 通知(忽略错误)
|
||||
try {
|
||||
const webhookNotifier = require('../utils/webhookNotifier')
|
||||
await webhookNotifier.sendAccountAnomalyNotification({
|
||||
accountId,
|
||||
accountName: accountData.name || accountId,
|
||||
platform: 'ccr',
|
||||
status: 'recovered',
|
||||
errorCode: 'STATUS_RESET',
|
||||
reason: 'Account status manually reset',
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
} catch (webhookError) {
|
||||
logger.warn('Failed to send webhook notification for CCR status reset:', webhookError)
|
||||
}
|
||||
|
||||
return { success: true, accountId }
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to reset CCR account status: ${accountId}`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CcrAccountService()
|
||||
Reference in New Issue
Block a user