mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 增加Claude会话强制绑定
This commit is contained in:
437
src/services/claudeRelayConfigService.js
Normal file
437
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* Claude 转发配置服务
|
||||
* 管理全局 Claude Code 限制和会话绑定配置
|
||||
*/
|
||||
|
||||
const redis = require('../models/redis')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
const CONFIG_KEY = 'claude_relay_config'
|
||||
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||
|
||||
// 默认配置
|
||||
const DEFAULT_CONFIG = {
|
||||
claudeCodeOnlyEnabled: false,
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
|
||||
// 内存缓存(避免频繁 Redis 查询)
|
||||
let configCache = null
|
||||
let configCacheTime = 0
|
||||
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||
|
||||
class ClaudeRelayConfigService {
|
||||
/**
|
||||
* 从 metadata.user_id 中提取原始 sessionId
|
||||
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {string|null} 原始 sessionId 或 null
|
||||
*/
|
||||
extractOriginalSessionId(requestBody) {
|
||||
if (!requestBody?.metadata?.user_id) {
|
||||
return null
|
||||
}
|
||||
|
||||
const userId = requestBody.metadata.user_id
|
||||
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置(带缓存)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
try {
|
||||
// 检查缓存
|
||||
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
logger.warn('⚠️ Redis not connected, using default config')
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
const data = await client.get(CONFIG_KEY)
|
||||
|
||||
if (data) {
|
||||
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||
} else {
|
||||
configCache = { ...DEFAULT_CONFIG }
|
||||
}
|
||||
|
||||
configCacheTime = Date.now()
|
||||
return configCache
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude relay config:', error)
|
||||
return { ...DEFAULT_CONFIG }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新配置
|
||||
* @param {Object} newConfig - 新配置
|
||||
* @param {string} updatedBy - 更新者
|
||||
* @returns {Promise<Object>} 更新后的配置
|
||||
*/
|
||||
async updateConfig(newConfig, updatedBy) {
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const currentConfig = await this.getConfig()
|
||||
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
...newConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy
|
||||
}
|
||||
|
||||
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||
|
||||
// 更新缓存
|
||||
configCache = updatedConfig
|
||||
configCacheTime = Date.now()
|
||||
|
||||
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
|
||||
})
|
||||
|
||||
return updatedConfig
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to update Claude relay config:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局 Claude Code 限制
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isClaudeCodeOnlyEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.claudeCodeOnlyEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否启用全局会话绑定
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isGlobalSessionBindingEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.globalSessionBindingEnabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定错误信息
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async getSessionBindingErrorMessage() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||
*/
|
||||
async getOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return null
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
const data = await client.get(key)
|
||||
|
||||
if (data) {
|
||||
return JSON.parse(data)
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} accountType - 账户类型
|
||||
* @returns {Promise<Object>} 绑定信息
|
||||
*/
|
||||
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||
if (!originalSessionId || !accountId || !accountType) {
|
||||
throw new Error('Invalid parameters for session binding')
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
const binding = {
|
||||
accountId,
|
||||
accountType,
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsedAt: new Date().toISOString()
|
||||
}
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
|
||||
logger.info(
|
||||
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||
)
|
||||
|
||||
return binding
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话绑定的最后使用时间(续期)
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async touchOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
if (!binding) {
|
||||
return
|
||||
}
|
||||
|
||||
binding.lastUsedAt = new Date().toISOString()
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
|
||||
// 使用配置的 TTL(默认30天)
|
||||
const cfg = await this.getConfig()
|
||||
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||
|
||||
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||
} catch (error) {
|
||||
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查原始会话是否已绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isOriginalSessionBound(originalSessionId) {
|
||||
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
return binding !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证绑定的账户是否可用
|
||||
* @param {Object} binding - 绑定信息
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async validateBoundAccount(binding) {
|
||||
if (!binding || !binding.accountId || !binding.accountType) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const { accountType } = binding
|
||||
const { accountId } = binding
|
||||
|
||||
let accountService
|
||||
switch (accountType) {
|
||||
case 'claude-official':
|
||||
accountService = require('./claudeAccountService')
|
||||
break
|
||||
case 'claude-console':
|
||||
accountService = require('./claudeConsoleAccountService')
|
||||
break
|
||||
case 'bedrock':
|
||||
accountService = require('./bedrockAccountService')
|
||||
break
|
||||
case 'ccr':
|
||||
accountService = require('./ccrAccountService')
|
||||
break
|
||||
default:
|
||||
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||
return false
|
||||
}
|
||||
|
||||
const account = await accountService.getAccount(accountId)
|
||||
|
||||
if (!account || !account.success || !account.data) {
|
||||
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const accountData = account.data
|
||||
|
||||
// 检查账户是否激活
|
||||
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||
logger.warn(
|
||||
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查账户状态(如果存在)
|
||||
if (accountData.status && accountData.status === 'error') {
|
||||
logger.warn(
|
||||
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证新会话请求
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||
*/
|
||||
async validateNewSession(requestBody, originalSessionId) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.globalSessionBindingEnabled) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||
if (!originalSessionId) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 如果会话已存在绑定
|
||||
if (existingBinding) {
|
||||
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||
if (existingBinding.accountType !== 'claude-official') {
|
||||
logger.info(
|
||||
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||
)
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||
|
||||
if (!accountValid) {
|
||||
return {
|
||||
valid: false,
|
||||
error: cfg.sessionBindingErrorMessage,
|
||||
code: 'SESSION_BINDING_INVALID'
|
||||
}
|
||||
}
|
||||
|
||||
// 续期
|
||||
await this.touchOriginalSessionBinding(originalSessionId)
|
||||
|
||||
// 已有绑定,允许继续(这是正常的会话延续)
|
||||
return { valid: true, binding: existingBinding }
|
||||
}
|
||||
|
||||
// 没有绑定,是新会话
|
||||
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||
return { valid: true, isNewSession: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除原始会话绑定
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
*/
|
||||
async deleteOriginalSessionBinding(originalSessionId) {
|
||||
if (!originalSessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||
await client.del(key)
|
||||
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话绑定统计
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getSessionBindingStats() {
|
||||
try {
|
||||
const client = redis.getClient()
|
||||
if (!client) {
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
|
||||
let cursor = '0'
|
||||
let count = 0
|
||||
|
||||
do {
|
||||
const [newCursor, keys] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
`${SESSION_BINDING_PREFIX}*`,
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
count += keys.length
|
||||
} while (cursor !== '0')
|
||||
|
||||
return {
|
||||
totalBindings: count
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get session binding stats:', error)
|
||||
return { totalBindings: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除配置缓存(用于测试或强制刷新)
|
||||
*/
|
||||
clearCache() {
|
||||
configCache = null
|
||||
configCacheTime = 0
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeRelayConfigService()
|
||||
@@ -180,8 +180,56 @@ class UnifiedClaudeScheduler {
|
||||
}
|
||||
|
||||
// 🎯 统一调度Claude账号(官方和Console)
|
||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
||||
async selectAccountForApiKey(
|
||||
apiKeyData,
|
||||
sessionHash = null,
|
||||
requestedModel = null,
|
||||
forcedAccount = null
|
||||
) {
|
||||
try {
|
||||
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||
if (forcedAccount.accountType !== 'claude-official') {
|
||||
logger.info(
|
||||
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||
)
|
||||
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||
} else {
|
||||
// claude-official 类型需要检查可用性并强制使用
|
||||
logger.info(
|
||||
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
|
||||
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||
forcedAccount.accountId,
|
||||
forcedAccount.accountType,
|
||||
requestedModel
|
||||
)
|
||||
|
||||
if (isAvailable) {
|
||||
logger.info(
|
||||
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
return {
|
||||
accountId: forcedAccount.accountId,
|
||||
accountType: forcedAccount.accountType
|
||||
}
|
||||
} else {
|
||||
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||
logger.warn(
|
||||
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||
)
|
||||
const error = new Error('Session binding account unavailable')
|
||||
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||
error.accountId = forcedAccount.accountId
|
||||
error.accountType = forcedAccount.accountType
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解析供应商前缀
|
||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||
@@ -1711,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||
try {
|
||||
// 此方法仅处理 claude-official 类型
|
||||
if (accountType !== 'claude-official') {
|
||||
logger.warn(
|
||||
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||
)
|
||||
return true // 非 claude-official 类型不受限制
|
||||
}
|
||||
|
||||
const account = await redis.getClaudeAccount(accountId)
|
||||
if (!account) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||
return false
|
||||
}
|
||||
|
||||
const isActive = account.isActive === 'true' || account.isActive === true
|
||||
const { status } = account
|
||||
|
||||
if (!isActive) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (status === 'error' || status === 'temp_error') {
|
||||
logger.warn(
|
||||
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否被限流
|
||||
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查临时不可用
|
||||
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||
error
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UnifiedClaudeScheduler()
|
||||
|
||||
Reference in New Issue
Block a user