diff --git a/src/middleware/auth.js b/src/middleware/auth.js index 9c34fd5e..a5568323 100644 --- a/src/middleware/auth.js +++ b/src/middleware/auth.js @@ -6,6 +6,8 @@ const logger = require('../utils/logger') const redis = require('../models/redis') // const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用 const ClientValidator = require('../validators/clientValidator') +const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator') +const claudeRelayConfigService = require('../services/claudeRelayConfigService') const FALLBACK_CONCURRENCY_CONFIG = { leaseSeconds: 300, @@ -201,6 +203,53 @@ const authenticateApiKey = async (req, res, next) => { ) } + // 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑) + // 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages) + if (!skipKeyRestrictions) { + const normalizedPath = (req.originalUrl || req.path || '').toLowerCase() + const isClaudeMessagesEndpoint = + normalizedPath.includes('/v1/messages') && + (normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude')) + + if (isClaudeMessagesEndpoint) { + try { + const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled() + + // API Key 级别的 Claude Code 限制 + const keyClaudeCodeOnly = + validation.keyData.enableClientRestriction && + Array.isArray(validation.keyData.allowedClients) && + validation.keyData.allowedClients.length === 1 && + validation.keyData.allowedClients.includes('claude_code') + + // OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code + if (globalClaudeCodeOnly || keyClaudeCodeOnly) { + const isClaudeCode = ClaudeCodeValidator.validate(req) + + if (!isClaudeCode) { + const clientIP = req.ip || req.connection?.remoteAddress || 'unknown' + logger.api( + `❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}` + ) + return res.status(403).json({ + error: { + type: 'client_validation_error', + message: 'This endpoint only accepts requests from Claude Code CLI' + } + }) + } + + logger.api( + `✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})` + ) + } + } catch (error) { + logger.error('❌ Error checking Claude Code restriction:', error) + // 配置服务出错时不阻断请求 + } + } + } + // 检查并发限制 const concurrencyLimit = validation.keyData.concurrencyLimit || 0 if (!skipKeyRestrictions && concurrencyLimit > 0) { diff --git a/src/routes/admin/claudeRelayConfig.js b/src/routes/admin/claudeRelayConfig.js new file mode 100644 index 00000000..e3c78ef4 --- /dev/null +++ b/src/routes/admin/claudeRelayConfig.js @@ -0,0 +1,130 @@ +/** + * Claude 转发配置 API 路由 + * 管理全局 Claude Code 限制和会话绑定配置 + */ + +const express = require('express') +const { authenticateAdmin } = require('../../middleware/auth') +const claudeRelayConfigService = require('../../services/claudeRelayConfigService') +const logger = require('../../utils/logger') + +const router = express.Router() + +/** + * GET /admin/claude-relay-config + * 获取 Claude 转发配置 + */ +router.get('/claude-relay-config', authenticateAdmin, async (req, res) => { + try { + const config = await claudeRelayConfigService.getConfig() + return res.json({ + success: true, + config + }) + } catch (error) { + logger.error('❌ Failed to get Claude relay config:', error) + return res.status(500).json({ + error: 'Failed to get configuration', + message: error.message + }) + } +}) + +/** + * PUT /admin/claude-relay-config + * 更新 Claude 转发配置 + */ +router.put('/claude-relay-config', authenticateAdmin, async (req, res) => { + try { + const { + claudeCodeOnlyEnabled, + globalSessionBindingEnabled, + sessionBindingErrorMessage, + sessionBindingTtlDays + } = req.body + + // 验证输入 + if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') { + return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' }) + } + + if ( + globalSessionBindingEnabled !== undefined && + typeof globalSessionBindingEnabled !== 'boolean' + ) { + return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' }) + } + + if (sessionBindingErrorMessage !== undefined) { + if (typeof sessionBindingErrorMessage !== 'string') { + return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' }) + } + if (sessionBindingErrorMessage.length > 500) { + return res + .status(400) + .json({ error: 'sessionBindingErrorMessage must be less than 500 characters' }) + } + } + + if (sessionBindingTtlDays !== undefined) { + if ( + typeof sessionBindingTtlDays !== 'number' || + sessionBindingTtlDays < 1 || + sessionBindingTtlDays > 365 + ) { + return res + .status(400) + .json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' }) + } + } + + const updateData = {} + if (claudeCodeOnlyEnabled !== undefined) + updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled + if (globalSessionBindingEnabled !== undefined) + updateData.globalSessionBindingEnabled = globalSessionBindingEnabled + if (sessionBindingErrorMessage !== undefined) + updateData.sessionBindingErrorMessage = sessionBindingErrorMessage + if (sessionBindingTtlDays !== undefined) + updateData.sessionBindingTtlDays = sessionBindingTtlDays + + const updatedConfig = await claudeRelayConfigService.updateConfig( + updateData, + req.admin?.username || 'unknown' + ) + + return res.json({ + success: true, + message: 'Configuration updated successfully', + config: updatedConfig + }) + } catch (error) { + logger.error('❌ Failed to update Claude relay config:', error) + return res.status(500).json({ + error: 'Failed to update configuration', + message: error.message + }) + } +}) + +/** + * GET /admin/claude-relay-config/session-bindings + * 获取会话绑定统计 + */ +router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => { + try { + const stats = await claudeRelayConfigService.getSessionBindingStats() + return res.json({ + success: true, + data: stats + }) + } catch (error) { + logger.error('❌ Failed to get session binding stats:', error) + return res.status(500).json({ + error: 'Failed to get session binding statistics', + message: error.message + }) + } +}) + +module.exports = router diff --git a/src/routes/admin/index.js b/src/routes/admin/index.js index 3e133c50..c91aa5e7 100644 --- a/src/routes/admin/index.js +++ b/src/routes/admin/index.js @@ -23,6 +23,7 @@ const dashboardRoutes = require('./dashboard') const usageStatsRoutes = require('./usageStats') const systemRoutes = require('./system') const concurrencyRoutes = require('./concurrency') +const claudeRelayConfigRoutes = require('./claudeRelayConfig') // 挂载所有子路由 // 使用完整路径的模块(直接挂载到根路径) @@ -37,6 +38,7 @@ router.use('/', dashboardRoutes) router.use('/', usageStatsRoutes) router.use('/', systemRoutes) router.use('/', concurrencyRoutes) +router.use('/', claudeRelayConfigRoutes) // 使用相对路径的模块(需要指定基础路径前缀) router.use('/account-groups', accountGroupsRoutes) diff --git a/src/routes/api.js b/src/routes/api.js index b99cf6cc..8fe0676b 100644 --- a/src/routes/api.js +++ b/src/routes/api.js @@ -11,6 +11,7 @@ const logger = require('../utils/logger') const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper') const sessionHelper = require('../utils/sessionHelper') const { updateRateLimitCounters } = require('../utils/rateLimitHelper') +const claudeRelayConfigService = require('../services/claudeRelayConfigService') const { sanitizeUpstreamError } = require('../utils/errorSanitizer') const router = express.Router() @@ -141,6 +142,56 @@ async function handleMessagesRequest(req, res) { // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(req.body) + // 🔒 全局会话绑定验证 + let forcedAccount = null + let needSessionBinding = false + let originalSessionIdForBinding = null + + try { + const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled() + + if (globalBindingEnabled) { + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + + if (originalSessionId) { + const validation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!validation.valid) { + logger.api( + `❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}` + ) + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: validation.error + } + }) + } + + // 如果已有绑定,使用绑定的账户 + if (validation.binding) { + forcedAccount = validation.binding + logger.api( + `🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}` + ) + } + + // 标记需要在调度成功后建立绑定 + if (validation.isNewSession) { + needSessionBinding = true + originalSessionIdForBinding = originalSessionId + logger.api(`📝 New session detected, will create binding: ${originalSessionId}`) + } + } + } + } catch (error) { + logger.error('❌ Error in global session binding check:', error) + // 配置服务出错时不阻断请求 + } + // 使用统一调度选择账号(传递请求的模型) const requestedModel = req.body.model let accountId @@ -149,10 +200,21 @@ async function handleMessagesRequest(req, res) { const selection = await unifiedClaudeScheduler.selectAccountForApiKey( req.apiKey, sessionHash, - requestedModel + requestedModel, + forcedAccount ) ;({ accountId, accountType } = selection) } catch (error) { + // 处理会话绑定账户不可用的错误 + if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') { + const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage() + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: errorMessage + } + }) + } if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { const limitMessage = claudeRelayService._buildStandardRateLimitMessage( error.rateLimitEndAt @@ -170,6 +232,41 @@ async function handleMessagesRequest(req, res) { throw error } + // 🔗 在成功调度后建立会话绑定(仅 claude-official 类型) + // claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话 + if ( + needSessionBinding && + originalSessionIdForBinding && + accountId && + accountType === 'claude-official' + ) { + // 🚫 新会话必须 messages.length === 1 + const messages = req.body?.messages + if (messages && messages.length > 1) { + const cfg = await claudeRelayConfigService.getConfig() + logger.warn( + `🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。' + } + }) + } + + // 创建绑定 + try { + await claudeRelayConfigService.setOriginalSessionBinding( + originalSessionIdForBinding, + accountId, + accountType + ) + } catch (bindingError) { + logger.warn(`⚠️ Failed to create session binding:`, bindingError) + } + } + // 根据账号类型选择对应的转发服务并调用 if (accountType === 'claude-official') { // 官方Claude账号使用原有的转发服务(会自己选择账号) @@ -503,6 +600,55 @@ async function handleMessagesRequest(req, res) { // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(req.body) + // 🔒 全局会话绑定验证(非流式) + let forcedAccountNonStream = null + let needSessionBindingNonStream = false + let originalSessionIdForBindingNonStream = null + + try { + const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled() + + if (globalBindingEnabled) { + const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body) + + if (originalSessionId) { + const validation = await claudeRelayConfigService.validateNewSession( + req.body, + originalSessionId + ) + + if (!validation.valid) { + logger.api( + `❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}` + ) + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: validation.error + } + }) + } + + if (validation.binding) { + forcedAccountNonStream = validation.binding + logger.api( + `🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}` + ) + } + + if (validation.isNewSession) { + needSessionBindingNonStream = true + originalSessionIdForBindingNonStream = originalSessionId + logger.api( + `📝 New session detected (non-stream), will create binding: ${originalSessionId}` + ) + } + } + } + } catch (error) { + logger.error('❌ Error in global session binding check (non-stream):', error) + } + // 使用统一调度选择账号(传递请求的模型) const requestedModel = req.body.model let accountId @@ -511,10 +657,20 @@ async function handleMessagesRequest(req, res) { const selection = await unifiedClaudeScheduler.selectAccountForApiKey( req.apiKey, sessionHash, - requestedModel + requestedModel, + forcedAccountNonStream ) ;({ accountId, accountType } = selection) } catch (error) { + if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') { + const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage() + return res.status(403).json({ + error: { + type: 'session_binding_error', + message: errorMessage + } + }) + } if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') { const limitMessage = claudeRelayService._buildStandardRateLimitMessage( error.rateLimitEndAt @@ -527,6 +683,41 @@ async function handleMessagesRequest(req, res) { throw error } + // 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型) + // claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话 + if ( + needSessionBindingNonStream && + originalSessionIdForBindingNonStream && + accountId && + accountType === 'claude-official' + ) { + // 🚫 新会话必须 messages.length === 1 + const messages = req.body?.messages + if (messages && messages.length > 1) { + const cfg = await claudeRelayConfigService.getConfig() + logger.warn( + `🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}` + ) + return res.status(400).json({ + error: { + type: 'session_binding_error', + message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。' + } + }) + } + + // 创建绑定 + try { + await claudeRelayConfigService.setOriginalSessionBinding( + originalSessionIdForBindingNonStream, + accountId, + accountType + ) + } catch (bindingError) { + logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError) + } + } + // 根据账号类型选择对应的转发服务 let response logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`) diff --git a/src/services/claudeRelayConfigService.js b/src/services/claudeRelayConfigService.js new file mode 100644 index 00000000..3b9790ac --- /dev/null +++ b/src/services/claudeRelayConfigService.js @@ -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} 配置对象 + */ + 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} 更新后的配置 + */ + 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} + */ + async isClaudeCodeOnlyEnabled() { + const cfg = await this.getConfig() + return cfg.claudeCodeOnlyEnabled === true + } + + /** + * 检查是否启用全局会话绑定 + * @returns {Promise} + */ + async isGlobalSessionBindingEnabled() { + const cfg = await this.getConfig() + return cfg.globalSessionBindingEnabled === true + } + + /** + * 获取会话绑定错误信息 + * @returns {Promise} + */ + async getSessionBindingErrorMessage() { + const cfg = await this.getConfig() + return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage + } + + /** + * 获取原始会话绑定 + * @param {string} originalSessionId - 原始会话ID + * @returns {Promise} 绑定信息或 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} 绑定信息 + */ + 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} + */ + async isOriginalSessionBound(originalSessionId) { + const binding = await this.getOriginalSessionBinding(originalSessionId) + return binding !== null + } + + /** + * 验证绑定的账户是否可用 + * @param {Object} binding - 绑定信息 + * @returns {Promise} + */ + 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} { 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} + */ + 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() diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index aac79971..750a1765 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -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} + */ + 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() diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index ee68ab11..a42ce79b 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -36,6 +36,18 @@ 通知设置 + @@ -629,6 +641,182 @@ + + +
+ +
+
+

正在加载配置...

+
+ +
+ +
+
+
+
+
+ +
+
+

+ 仅允许 Claude Code 客户端 +

+

+ 启用后,所有 + /api/v1/messages + 和 + /claude/v1/messages + 端点将强制验证 Claude Code CLI 客户端 +

+
+
+
+ +
+
+
+ +
+

+ 此设置与 API Key 级别的客户端限制是 OR 逻辑:全局启用或 API + Key 设置中启用,都会执行 Claude Code 验证。 +

+
+
+
+
+ + +
+
+
+
+
+ +
+
+

+ 全局会话绑定 +

+

+ 启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性 +

+
+
+
+ +
+ + +
+ +
+ + +

+ 会话绑定到账户后的有效时间,过期后会自动解除绑定 +

+
+ + +
+ + +

+ 当绑定的账户不可用(状态异常、过载等)时,返回给客户端的错误消息 +

+
+
+ +
+
+ +
+

+ 工作原理:系统会提取请求中的原始 session ID (来自 + metadata.user_id), 并将其与首次调度的账户绑定。后续使用相同 session ID + 的请求将自动路由到同一账户。 +

+

+ 新会话识别:如果是已存在的绑定会话但请求中 + messages.length > 1, 系统会认为这是一个污染的会话并拒绝请求。 +

+
+
+
+
+ + +
+ + 最后更新:{{ formatDateTime(claudeConfig.updatedAt) }} + + 由 {{ claudeConfig.updatedBy }} 修改 + +
+
+
@@ -1274,6 +1462,17 @@ const webhookConfig = ref({ } }) +// Claude 转发配置 +const claudeConfigLoading = ref(false) +const claudeConfig = ref({ + claudeCodeOnlyEnabled: false, + globalSessionBindingEnabled: false, + sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。', + sessionBindingTtlDays: 30, + updatedAt: null, + updatedBy: null +}) + // 平台表单相关 const showAddPlatformModal = ref(false) const editingPlatform = ref(null) @@ -1311,6 +1510,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => { if (!isMounted.value) return if (newSection === 'webhook') { await loadWebhookConfig() + } else if (newSection === 'claude') { + await loadClaudeConfig() } }) @@ -1522,6 +1723,67 @@ const saveWebhookConfig = async () => { } } +// 加载 Claude 转发配置 +const loadClaudeConfig = async () => { + if (!isMounted.value) return + claudeConfigLoading.value = true + try { + const response = await apiClient.get('/admin/claude-relay-config', { + signal: abortController.value.signal + }) + if (response.success && isMounted.value) { + claudeConfig.value = { + claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false, + globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false, + sessionBindingErrorMessage: + response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。', + sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30, + updatedAt: response.config?.updatedAt || null, + updatedBy: response.config?.updatedBy || null + } + } + } catch (error) { + if (error.name === 'AbortError') return + if (!isMounted.value) return + showToast('获取 Claude 转发配置失败', 'error') + console.error(error) + } finally { + if (isMounted.value) { + claudeConfigLoading.value = false + } + } +} + +// 保存 Claude 转发配置 +const saveClaudeConfig = async () => { + if (!isMounted.value) return + try { + const payload = { + claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled, + globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled, + sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage, + sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays + } + + const response = await apiClient.put('/admin/claude-relay-config', payload, { + signal: abortController.value.signal + }) + if (response.success && isMounted.value) { + claudeConfig.value = { + ...claudeConfig.value, + updatedAt: response.config?.updatedAt || new Date().toISOString(), + updatedBy: response.config?.updatedBy || null + } + showToast('Claude 转发配置已保存', 'success') + } + } catch (error) { + if (error.name === 'AbortError') return + if (!isMounted.value) return + showToast('保存 Claude 转发配置失败', 'error') + console.error(error) + } +} + // 验证 URL const validateUrl = () => { // Bark和SMTP平台不需要验证URL