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,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

View File

@@ -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)

View File

@@ -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)}`)