feat: 增加Claude会话强制绑定

This commit is contained in:
shaw
2025-12-08 16:06:23 +08:00
parent 659072075d
commit c79fdc4d71
7 changed files with 1183 additions and 3 deletions

View 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()