mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 18:44:51 +00:00
feat: 增加Claude会话强制绑定
This commit is contained in:
130
src/routes/admin/claudeRelayConfig.js
Normal file
130
src/routes/admin/claudeRelayConfig.js
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
Reference in New Issue
Block a user