mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4d923c46f | ||
|
|
fa9f9146a2 | ||
|
|
dc9409a5a6 | ||
|
|
51aa8dc381 | ||
|
|
5061f4d9fd | ||
|
|
4337af06d4 | ||
|
|
d226d57325 | ||
|
|
9f92c58640 | ||
|
|
8901994644 | ||
|
|
e3ca555df7 | ||
|
|
3b9c96dff8 | ||
|
|
cb94a4260e | ||
|
|
ac9499aa6d | ||
|
|
fc25840f95 | ||
|
|
b409adf9d8 | ||
|
|
b76776d7b0 | ||
|
|
8499992abd | ||
|
|
dc96447d72 | ||
|
|
f5d1c25295 | ||
|
|
95870883a1 | ||
|
|
aa71c58400 |
@@ -60,6 +60,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
||||
|
||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||
- **costInitService.js**: 成本数据初始化服务
|
||||
- **webhookService.js**: Webhook通知服务
|
||||
@@ -185,6 +186,10 @@ npm run service:stop # 停止服务
|
||||
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
||||
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
||||
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
||||
- `USER_MESSAGE_QUEUE_ENABLED`: 启用用户消息串行队列(默认false)
|
||||
- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔(毫秒,默认200)
|
||||
- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认5000,锁持有时间短无需长等待)
|
||||
- `USER_MESSAGE_QUEUE_LOCK_TTL_MS`: 锁TTL(毫秒,默认5000,请求发送后立即释放无需长TTL)
|
||||
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
||||
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
||||
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
||||
@@ -337,6 +342,7 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
||||
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
||||
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
||||
14. **用户消息队列超时**: 优化后锁持有时间已从分钟级降到毫秒级(请求发送后立即释放),默认 `USER_MESSAGE_QUEUE_TIMEOUT_MS=5000` 已足够。如仍有超时,检查网络延迟或禁用此功能(`USER_MESSAGE_QUEUE_ENABLED=false`)
|
||||
|
||||
### 调试工具
|
||||
|
||||
@@ -510,6 +516,9 @@ npm run setup # 自动生成密钥并创建管理员账户
|
||||
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
||||
- **Webhook配置**:
|
||||
- `webhook_config:{id}` - Webhook配置
|
||||
- **用户消息队列**:
|
||||
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁(当前持有者requestId)
|
||||
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
|
||||
- **系统信息**:
|
||||
- `system_info` - 系统状态缓存
|
||||
- `model_pricing` - 模型价格数据(pricingService)
|
||||
|
||||
@@ -203,6 +203,15 @@ const config = {
|
||||
development: {
|
||||
debug: process.env.DEBUG === 'true',
|
||||
hotReload: process.env.HOT_RELOAD === 'true'
|
||||
},
|
||||
|
||||
// 📬 用户消息队列配置
|
||||
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
|
||||
userMessageQueue: {
|
||||
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
|
||||
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 200, // 请求间隔(毫秒)
|
||||
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 5000, // 队列等待超时(毫秒),锁持有时间短,无需长等待
|
||||
lockTtlMs: parseInt(process.env.USER_MESSAGE_QUEUE_LOCK_TTL_MS) || 5000 // 锁TTL(毫秒),5秒足以覆盖请求发送
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
17
src/app.js
17
src/app.js
@@ -625,6 +625,14 @@ class Application {
|
||||
}, 60000) // 每分钟执行一次
|
||||
|
||||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||||
|
||||
// 📬 启动用户消息队列服务
|
||||
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
|
||||
userMessageQueueService.cleanupStaleLocks().then(() => {
|
||||
// 然后启动定时清理任务
|
||||
userMessageQueueService.startCleanupTask()
|
||||
})
|
||||
}
|
||||
|
||||
setupGracefulShutdown() {
|
||||
@@ -661,6 +669,15 @@ class Application {
|
||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
||||
}
|
||||
|
||||
// 停止用户消息队列清理服务
|
||||
try {
|
||||
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||
userMessageQueueService.stopCleanupTask()
|
||||
logger.info('📬 User message queue service stopped')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error stopping user message queue service:', error)
|
||||
}
|
||||
|
||||
// 停止费用排序索引服务
|
||||
try {
|
||||
const costRankService = require('./services/costRankService')
|
||||
|
||||
@@ -2556,4 +2556,217 @@ redisClient.getDateStringInTimezone = getDateStringInTimezone
|
||||
redisClient.getHourInTimezone = getHourInTimezone
|
||||
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
|
||||
|
||||
// ============== 用户消息队列相关方法 ==============
|
||||
|
||||
/**
|
||||
* 尝试获取用户消息队列锁
|
||||
* 使用 Lua 脚本保证原子性
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID
|
||||
* @param {number} lockTtlMs - 锁 TTL(毫秒)
|
||||
* @param {number} delayMs - 请求间隔(毫秒)
|
||||
* @returns {Promise<{acquired: boolean, waitMs: number}>}
|
||||
* - acquired: 是否成功获取锁
|
||||
* - waitMs: 需要等待的毫秒数(-1表示被占用需等待,>=0表示需要延迟的毫秒数)
|
||||
*/
|
||||
redisClient.acquireUserMessageLock = async function (accountId, requestId, lockTtlMs, delayMs) {
|
||||
const lockKey = `user_msg_queue_lock:${accountId}`
|
||||
const lastTimeKey = `user_msg_queue_last:${accountId}`
|
||||
|
||||
const script = `
|
||||
local lockKey = KEYS[1]
|
||||
local lastTimeKey = KEYS[2]
|
||||
local requestId = ARGV[1]
|
||||
local lockTtl = tonumber(ARGV[2])
|
||||
local delayMs = tonumber(ARGV[3])
|
||||
|
||||
-- 检查锁是否空闲
|
||||
local currentLock = redis.call('GET', lockKey)
|
||||
if currentLock == false then
|
||||
-- 检查是否需要延迟
|
||||
local lastTime = redis.call('GET', lastTimeKey)
|
||||
local now = redis.call('TIME')
|
||||
local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
|
||||
|
||||
if lastTime then
|
||||
local elapsed = nowMs - tonumber(lastTime)
|
||||
if elapsed < delayMs then
|
||||
-- 需要等待的毫秒数
|
||||
return {0, delayMs - elapsed}
|
||||
end
|
||||
end
|
||||
|
||||
-- 获取锁
|
||||
redis.call('SET', lockKey, requestId, 'PX', lockTtl)
|
||||
return {1, 0}
|
||||
end
|
||||
|
||||
-- 锁被占用,返回等待
|
||||
return {0, -1}
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await this.client.eval(
|
||||
script,
|
||||
2,
|
||||
lockKey,
|
||||
lastTimeKey,
|
||||
requestId,
|
||||
lockTtlMs,
|
||||
delayMs
|
||||
)
|
||||
return {
|
||||
acquired: result[0] === 1,
|
||||
waitMs: result[1]
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to acquire user message lock for account ${accountId}:`, error)
|
||||
// 返回 redisError 标记,让上层能区分 Redis 故障和正常锁占用
|
||||
return { acquired: false, waitMs: -1, redisError: true, errorMessage: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放用户消息队列锁并记录完成时间
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID
|
||||
* @returns {Promise<boolean>} 是否成功释放
|
||||
*/
|
||||
redisClient.releaseUserMessageLock = async function (accountId, requestId) {
|
||||
const lockKey = `user_msg_queue_lock:${accountId}`
|
||||
const lastTimeKey = `user_msg_queue_last:${accountId}`
|
||||
|
||||
const script = `
|
||||
local lockKey = KEYS[1]
|
||||
local lastTimeKey = KEYS[2]
|
||||
local requestId = ARGV[1]
|
||||
|
||||
-- 验证锁持有者
|
||||
local currentLock = redis.call('GET', lockKey)
|
||||
if currentLock == requestId then
|
||||
-- 记录完成时间
|
||||
local now = redis.call('TIME')
|
||||
local nowMs = tonumber(now[1]) * 1000 + math.floor(tonumber(now[2]) / 1000)
|
||||
redis.call('SET', lastTimeKey, nowMs, 'EX', 60) -- 60秒后过期
|
||||
|
||||
-- 删除锁
|
||||
redis.call('DEL', lockKey)
|
||||
return 1
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await this.client.eval(script, 2, lockKey, lastTimeKey, requestId)
|
||||
return result === 1
|
||||
} catch (error) {
|
||||
logger.error(`Failed to release user message lock for account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制释放用户消息队列锁(用于清理孤儿锁)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Promise<boolean>} 是否成功释放
|
||||
*/
|
||||
redisClient.forceReleaseUserMessageLock = async function (accountId) {
|
||||
const lockKey = `user_msg_queue_lock:${accountId}`
|
||||
|
||||
try {
|
||||
await this.client.del(lockKey)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error(`Failed to force release user message lock for account ${accountId}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户消息队列统计信息(用于调试)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Promise<Object>} 队列统计
|
||||
*/
|
||||
redisClient.getUserMessageQueueStats = async function (accountId) {
|
||||
const lockKey = `user_msg_queue_lock:${accountId}`
|
||||
const lastTimeKey = `user_msg_queue_last:${accountId}`
|
||||
|
||||
try {
|
||||
const [lockHolder, lastTime, lockTtl] = await Promise.all([
|
||||
this.client.get(lockKey),
|
||||
this.client.get(lastTimeKey),
|
||||
this.client.pttl(lockKey)
|
||||
])
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isLocked: !!lockHolder,
|
||||
lockHolder,
|
||||
lockTtlMs: lockTtl > 0 ? lockTtl : 0,
|
||||
lockTtlRaw: lockTtl, // 原始 PTTL 值:>0 有TTL,-1 无过期时间,-2 键不存在
|
||||
lastCompletedAt: lastTime ? new Date(parseInt(lastTime)).toISOString() : null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to get user message queue stats for account ${accountId}:`, error)
|
||||
return {
|
||||
accountId,
|
||||
isLocked: false,
|
||||
lockHolder: null,
|
||||
lockTtlMs: 0,
|
||||
lockTtlRaw: -2,
|
||||
lastCompletedAt: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描所有用户消息队列锁(用于清理任务)
|
||||
* @returns {Promise<string[]>} 账户ID列表
|
||||
*/
|
||||
redisClient.scanUserMessageQueueLocks = async function () {
|
||||
const accountIds = []
|
||||
let cursor = '0'
|
||||
let iterations = 0
|
||||
const MAX_ITERATIONS = 1000 // 防止无限循环
|
||||
|
||||
try {
|
||||
do {
|
||||
const [newCursor, keys] = await this.client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'user_msg_queue_lock:*',
|
||||
'COUNT',
|
||||
100
|
||||
)
|
||||
cursor = newCursor
|
||||
iterations++
|
||||
|
||||
for (const key of keys) {
|
||||
const accountId = key.replace('user_msg_queue_lock:', '')
|
||||
accountIds.push(accountId)
|
||||
}
|
||||
|
||||
// 防止无限循环
|
||||
if (iterations >= MAX_ITERATIONS) {
|
||||
logger.warn(
|
||||
`📬 User message queue: SCAN reached max iterations (${MAX_ITERATIONS}), stopping early`,
|
||||
{ foundLocks: accountIds.length }
|
||||
)
|
||||
break
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
if (accountIds.length > 0) {
|
||||
logger.debug(
|
||||
`📬 User message queue: scanned ${accountIds.length} lock(s) in ${iterations} iteration(s)`
|
||||
)
|
||||
}
|
||||
|
||||
return accountIds
|
||||
} catch (error) {
|
||||
logger.error('Failed to scan user message queue locks:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = redisClient
|
||||
|
||||
@@ -40,7 +40,10 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled,
|
||||
sessionBindingErrorMessage,
|
||||
sessionBindingTtlDays
|
||||
sessionBindingTtlDays,
|
||||
userMessageQueueEnabled,
|
||||
userMessageQueueDelayMs,
|
||||
userMessageQueueTimeoutMs
|
||||
} = req.body
|
||||
|
||||
// 验证输入
|
||||
@@ -78,15 +81,57 @@ router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 验证用户消息队列配置
|
||||
if (userMessageQueueEnabled !== undefined && typeof userMessageQueueEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'userMessageQueueEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
if (userMessageQueueDelayMs !== undefined) {
|
||||
if (
|
||||
typeof userMessageQueueDelayMs !== 'number' ||
|
||||
userMessageQueueDelayMs < 0 ||
|
||||
userMessageQueueDelayMs > 10000
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'userMessageQueueDelayMs must be a number between 0 and 10000' })
|
||||
}
|
||||
}
|
||||
|
||||
if (userMessageQueueTimeoutMs !== undefined) {
|
||||
if (
|
||||
typeof userMessageQueueTimeoutMs !== 'number' ||
|
||||
userMessageQueueTimeoutMs < 1000 ||
|
||||
userMessageQueueTimeoutMs > 300000
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: 'userMessageQueueTimeoutMs must be a number between 1000 and 300000' })
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {}
|
||||
if (claudeCodeOnlyEnabled !== undefined)
|
||||
if (claudeCodeOnlyEnabled !== undefined) {
|
||||
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||
if (globalSessionBindingEnabled !== undefined)
|
||||
}
|
||||
if (globalSessionBindingEnabled !== undefined) {
|
||||
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||
if (sessionBindingErrorMessage !== undefined)
|
||||
}
|
||||
if (sessionBindingErrorMessage !== undefined) {
|
||||
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||
if (sessionBindingTtlDays !== undefined)
|
||||
}
|
||||
if (sessionBindingTtlDays !== undefined) {
|
||||
updateData.sessionBindingTtlDays = sessionBindingTtlDays
|
||||
}
|
||||
if (userMessageQueueEnabled !== undefined) {
|
||||
updateData.userMessageQueueEnabled = userMessageQueueEnabled
|
||||
}
|
||||
if (userMessageQueueDelayMs !== undefined) {
|
||||
updateData.userMessageQueueDelayMs = userMessageQueueDelayMs
|
||||
}
|
||||
if (userMessageQueueTimeoutMs !== undefined) {
|
||||
updateData.userMessageQueueTimeoutMs = userMessageQueueTimeoutMs
|
||||
}
|
||||
|
||||
const updatedConfig = await claudeRelayConfigService.updateConfig(
|
||||
updateData,
|
||||
|
||||
@@ -7,12 +7,13 @@ const express = require('express')
|
||||
const router = express.Router()
|
||||
const redis = require('../../models/redis')
|
||||
const logger = require('../../utils/logger')
|
||||
const { authenticateAdmin } = require('../../middleware/auth')
|
||||
|
||||
/**
|
||||
* GET /admin/concurrency
|
||||
* 获取所有并发状态
|
||||
*/
|
||||
router.get('/concurrency', async (req, res) => {
|
||||
router.get('/concurrency', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const status = await redis.getAllConcurrencyStatus()
|
||||
|
||||
@@ -42,7 +43,7 @@ router.get('/concurrency', async (req, res) => {
|
||||
* GET /admin/concurrency/:apiKeyId
|
||||
* 获取特定 API Key 的并发状态详情
|
||||
*/
|
||||
router.get('/concurrency/:apiKeyId', async (req, res) => {
|
||||
router.get('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.params
|
||||
const status = await redis.getConcurrencyStatus(apiKeyId)
|
||||
@@ -65,7 +66,7 @@ router.get('/concurrency/:apiKeyId', async (req, res) => {
|
||||
* DELETE /admin/concurrency/:apiKeyId
|
||||
* 强制清理特定 API Key 的并发计数
|
||||
*/
|
||||
router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
||||
router.delete('/concurrency/:apiKeyId', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.params
|
||||
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||
@@ -93,7 +94,7 @@ router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
||||
* DELETE /admin/concurrency
|
||||
* 强制清理所有并发计数
|
||||
*/
|
||||
router.delete('/concurrency', async (req, res) => {
|
||||
router.delete('/concurrency', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await redis.forceClearAllConcurrency()
|
||||
|
||||
@@ -118,7 +119,7 @@ router.delete('/concurrency', async (req, res) => {
|
||||
* POST /admin/concurrency/cleanup
|
||||
* 清理过期的并发条目(不影响活跃请求)
|
||||
*/
|
||||
router.post('/concurrency/cleanup', async (req, res) => {
|
||||
router.post('/concurrency/cleanup', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { apiKeyId } = req.body
|
||||
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||
|
||||
@@ -38,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为旧会话(污染的会话)
|
||||
* Claude Code 发送的请求特点:
|
||||
* - messages 数组通常只有 1 个元素
|
||||
* - 历史对话记录嵌套在单个 message 的 content 数组中
|
||||
* - content 数组中包含 <system-reminder> 开头的系统注入内容
|
||||
*
|
||||
* 污染会话的特征:
|
||||
* 1. messages.length > 1
|
||||
* 2. messages.length === 1 但 content 中有多个用户输入
|
||||
* 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools)
|
||||
*
|
||||
* @param {Object} body - 请求体
|
||||
* @returns {boolean} 是否为旧会话
|
||||
*/
|
||||
function isOldSession(body) {
|
||||
const messages = body?.messages
|
||||
const tools = body?.tools
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 1. 多条消息 = 旧会话
|
||||
if (messages.length > 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. 单条消息,分析 content
|
||||
const firstMessage = messages[0]
|
||||
const content = firstMessage?.content
|
||||
|
||||
if (!content) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果 content 是字符串,只有一条输入,需要检查 tools
|
||||
if (typeof content === 'string') {
|
||||
// 有 tools = 正常新会话,无 tools = 可疑
|
||||
return !tools || tools.length === 0
|
||||
}
|
||||
|
||||
// 如果 content 是数组,统计非 system-reminder 的元素
|
||||
if (Array.isArray(content)) {
|
||||
const userInputs = content.filter((item) => {
|
||||
if (item.type !== 'text') {
|
||||
return false
|
||||
}
|
||||
const text = item.text || ''
|
||||
// 剔除以 <system-reminder> 开头的
|
||||
return !text.trimStart().startsWith('<system-reminder>')
|
||||
})
|
||||
|
||||
// 多个用户输入 = 旧会话
|
||||
if (userInputs.length > 1) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Warmup 检测:单个消息 + 无 tools = 旧会话
|
||||
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// 🔧 共享的消息处理函数
|
||||
async function handleMessagesRequest(req, res) {
|
||||
try {
|
||||
@@ -233,19 +300,18 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||
// claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话
|
||||
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||
if (
|
||||
needSessionBinding &&
|
||||
originalSessionIdForBinding &&
|
||||
accountId &&
|
||||
accountType === 'claude-official'
|
||||
) {
|
||||
// 🚫 新会话必须 messages.length === 1
|
||||
const messages = req.body?.messages
|
||||
if (messages && messages.length > 1) {
|
||||
// 🚫 检测旧会话(污染的会话)
|
||||
if (isOldSession(req.body)) {
|
||||
const cfg = await claudeRelayConfigService.getConfig()
|
||||
logger.warn(
|
||||
`🚫 New session with messages.length > 1 rejected: sessionId=${originalSessionIdForBinding}, messages.length=${messages.length}`
|
||||
`🚫 Old session rejected: sessionId=${originalSessionIdForBinding}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
@@ -684,19 +750,18 @@ async function handleMessagesRequest(req, res) {
|
||||
}
|
||||
|
||||
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||
// claude-official 只接受:1) 新会话(messages.length=1) 2) 已绑定的会话
|
||||
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||
if (
|
||||
needSessionBindingNonStream &&
|
||||
originalSessionIdForBindingNonStream &&
|
||||
accountId &&
|
||||
accountType === 'claude-official'
|
||||
) {
|
||||
// 🚫 新会话必须 messages.length === 1
|
||||
const messages = req.body?.messages
|
||||
if (messages && messages.length > 1) {
|
||||
// 🚫 检测旧会话(污染的会话)
|
||||
if (isOldSession(req.body)) {
|
||||
const cfg = await claudeRelayConfigService.getConfig()
|
||||
logger.warn(
|
||||
`🚫 New session with messages.length > 1 rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${messages.length}`
|
||||
`🚫 Old session rejected (non-stream): sessionId=${originalSessionIdForBindingNonStream}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
@@ -1157,6 +1222,41 @@ router.post('/v1/messages/count_tokens', authenticateApiKey, async (req, res) =>
|
||||
})
|
||||
}
|
||||
|
||||
// 🔗 会话绑定验证(与 messages 端点保持一致)
|
||||
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||
const sessionValidation = await claudeRelayConfigService.validateNewSession(
|
||||
req.body,
|
||||
originalSessionId
|
||||
)
|
||||
|
||||
if (!sessionValidation.valid) {
|
||||
logger.warn(
|
||||
`🚫 Session binding validation failed (count_tokens): ${sessionValidation.code} for session ${originalSessionId}`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: sessionValidation.error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 🔗 检测旧会话(污染的会话)- 仅对需要绑定的新会话检查
|
||||
if (sessionValidation.isNewSession && originalSessionId) {
|
||||
if (isOldSession(req.body)) {
|
||||
const cfg = await claudeRelayConfigService.getConfig()
|
||||
logger.warn(
|
||||
`🚫 Old session rejected (count_tokens): sessionId=${originalSessionId}, messages.length=${req.body?.messages?.length}, tools.length=${req.body?.tools?.length || 0}, isOldSession=true`
|
||||
)
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
type: 'session_binding_error',
|
||||
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||
|
||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
|
||||
class BedrockRelayService {
|
||||
constructor() {
|
||||
@@ -69,7 +70,68 @@ class BedrockRelayService {
|
||||
|
||||
// 处理非流式请求
|
||||
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
||||
const accountId = bedrockAccount?.id
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in Bedrock handleNonStreamRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for Bedrock account ${accountId}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
success: false
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||
const region = this._selectRegion(modelId, bedrockAccount)
|
||||
const client = this._getBedrockClient(region, bedrockAccount)
|
||||
@@ -90,6 +152,23 @@ class BedrockRelayService {
|
||||
const response = await client.send(command)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for Bedrock account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const responseBody = JSON.parse(new TextDecoder().decode(response.body))
|
||||
const claudeResponse = this._convertFromBedrockFormat(responseBody)
|
||||
@@ -106,12 +185,93 @@ class BedrockRelayService {
|
||||
} catch (error) {
|
||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||
throw this._handleBedrockError(error)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for Bedrock account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理流式请求
|
||||
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
||||
const accountId = bedrockAccount?.id
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in Bedrock handleStreamRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for Bedrock account ${accountId} (stream)`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
})}\n\n`
|
||||
res.write(errorEvent)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return { success: false, error: errorType }
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for Bedrock account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||
const region = this._selectRegion(modelId, bedrockAccount)
|
||||
const client = this._getBedrockClient(region, bedrockAccount)
|
||||
@@ -131,6 +291,23 @@ class BedrockRelayService {
|
||||
const startTime = Date.now()
|
||||
const response = await client.send(command)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for Bedrock stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置SSE响应头
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -191,6 +368,21 @@ class BedrockRelayService {
|
||||
res.end()
|
||||
|
||||
throw this._handleBedrockError(error)
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for Bedrock stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ const ccrAccountService = require('./ccrAccountService')
|
||||
const logger = require('../utils/logger')
|
||||
const config = require('../../config/config')
|
||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
|
||||
class CcrRelayService {
|
||||
constructor() {
|
||||
@@ -21,8 +22,67 @@ class CcrRelayService {
|
||||
) {
|
||||
let abortController = null
|
||||
let account = null
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in CCR relayRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for CCR account ${accountId}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
accountId
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -162,6 +222,23 @@ class CcrRelayService {
|
||||
)
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for CCR account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除监听器(请求成功完成)
|
||||
if (clientRequest) {
|
||||
clientRequest.removeListener('close', handleClientDisconnect)
|
||||
@@ -233,6 +310,21 @@ class CcrRelayService {
|
||||
)
|
||||
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for CCR account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +340,74 @@ class CcrRelayService {
|
||||
options = {}
|
||||
) {
|
||||
let account = null
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error(
|
||||
'❌ accountId missing for queue lock in CCR relayStreamRequestWithUsageCapture'
|
||||
)
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续<E5908E><E7BBAD>计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for CCR account ${accountId} (stream)`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
})}\n\n`
|
||||
responseStream.write(errorEvent)
|
||||
responseStream.write('data: [DONE]\n\n')
|
||||
responseStream.end()
|
||||
return
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for CCR account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await ccrAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -296,14 +455,53 @@ class CcrRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
options
|
||||
options,
|
||||
// 📬 回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for CCR stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info(
|
||||
`🔌 CCR stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||
)
|
||||
} else {
|
||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for CCR stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,7 +515,8 @@ class CcrRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
requestOptions = {},
|
||||
onResponseHeaderReceived = null
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let aborted = false
|
||||
@@ -380,8 +579,11 @@ class CcrRelayService {
|
||||
// 发送请求
|
||||
const request = axios(requestConfig)
|
||||
|
||||
// 注意:使用 .then(async ...) 模式处理响应
|
||||
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||
request
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
logger.debug(`🌊 CCR stream response status: ${response.status}`)
|
||||
|
||||
// 错误响应处理
|
||||
@@ -431,6 +633,19 @@ class CcrRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
// 📬 收到成功响应头(HTTP 200),调用回调释放队列锁
|
||||
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
|
||||
try {
|
||||
await onResponseHeaderReceived()
|
||||
} catch (callbackError) {
|
||||
logger.error(
|
||||
`❌ Failed to execute onResponseHeaderReceived callback for CCR stream account ${accountId}:`,
|
||||
callbackError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除错误状态
|
||||
ccrAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
sanitizeErrorMessage,
|
||||
isAccountDisabledError
|
||||
} = require('../utils/errorSanitizer')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
|
||||
class ClaudeConsoleRelayService {
|
||||
constructor() {
|
||||
@@ -29,8 +30,68 @@ class ClaudeConsoleRelayService {
|
||||
let account = null
|
||||
const requestId = uuidv4() // 用于并发追踪
|
||||
let concurrencyAcquired = false
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in console relayRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
apiKeyName: apiKeyData.name,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for console account ${accountId}, key: ${apiKeyData.name}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
accountId
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -203,6 +264,23 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
const response = await axios(requestConfig)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for console account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for console account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 移除监听器(请求成功完成)
|
||||
if (clientRequest) {
|
||||
clientRequest.removeListener('close', handleClientDisconnect)
|
||||
@@ -366,6 +444,21 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for console account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,8 +477,68 @@ class ClaudeConsoleRelayService {
|
||||
const requestId = uuidv4() // 用于并发追踪
|
||||
let concurrencyAcquired = false
|
||||
let leaseRefreshInterval = null // 租约刷新定时器
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
|
||||
try {
|
||||
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error(
|
||||
'❌ accountId missing for queue lock in console relayStreamRequestWithUsageCapture'
|
||||
)
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
apiKeyName: apiKeyData.name,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for console account ${accountId} (stream), key: ${apiKeyData.name}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({ type: 'error', error: { type: errorType, code: errorCode, message: errorMessage } })}\n\n`
|
||||
responseStream.write(errorEvent)
|
||||
responseStream.write('data: [DONE]\n\n')
|
||||
responseStream.end()
|
||||
return
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||
if (!account) {
|
||||
@@ -483,16 +636,40 @@ class ClaudeConsoleRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer,
|
||||
options
|
||||
options,
|
||||
// 📬 回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for console stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for console stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 更新最后使用时间
|
||||
await this._updateLastUsedTime(accountId)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||
error
|
||||
)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info(
|
||||
`🔌 Claude Console stream relay ended: Client disconnected (Account: ${account?.name || accountId})`
|
||||
)
|
||||
} else {
|
||||
logger.error(
|
||||
`❌ Claude Console stream relay failed (Account: ${account?.name || accountId}):`,
|
||||
error
|
||||
)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// 🛑 清理租约刷新定时器
|
||||
@@ -517,6 +694,21 @@ class ClaudeConsoleRelayService {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && accountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for console stream account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,7 +722,8 @@ class ClaudeConsoleRelayService {
|
||||
accountId,
|
||||
usageCallback,
|
||||
streamTransformer = null,
|
||||
requestOptions = {}
|
||||
requestOptions = {},
|
||||
onResponseHeaderReceived = null
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let aborted = false
|
||||
@@ -593,8 +786,11 @@ class ClaudeConsoleRelayService {
|
||||
// 发送请求
|
||||
const request = axios(requestConfig)
|
||||
|
||||
// 注意:使用 .then(async ...) 模式处理响应
|
||||
// - 内部的 releaseQueueLock 有独立的 try-catch,不会导致未捕获异常
|
||||
// - queueLockAcquired = false 的赋值会在 finally 执行前完成(JS 单线程保证)
|
||||
request
|
||||
.then((response) => {
|
||||
.then(async (response) => {
|
||||
logger.debug(`🌊 Claude Console Claude stream response status: ${response.status}`)
|
||||
|
||||
// 错误响应处理
|
||||
@@ -701,6 +897,19 @@ class ClaudeConsoleRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
// 📬 收到成功响应头(HTTP 200),调用回调释放队列锁
|
||||
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||
if (onResponseHeaderReceived && typeof onResponseHeaderReceived === 'function') {
|
||||
try {
|
||||
await onResponseHeaderReceived()
|
||||
} catch (callbackError) {
|
||||
logger.error(
|
||||
`❌ Failed to execute onResponseHeaderReceived callback for console stream account ${accountId}:`,
|
||||
callbackError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功响应,检查并移除错误状态
|
||||
claudeConsoleAccountService.isAccountRateLimited(accountId).then((isRateLimited) => {
|
||||
if (isRateLimited) {
|
||||
|
||||
@@ -15,6 +15,11 @@ const DEFAULT_CONFIG = {
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||
// 用户消息队列配置
|
||||
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||
userMessageQueueDelayMs: 200, // 请求间隔(毫秒)
|
||||
userMessageQueueTimeoutMs: 5000, // 队列等待超时(毫秒),优化后锁持有时间短无需长等待
|
||||
userMessageQueueLockTtlMs: 5000, // 锁TTL(毫秒),请求发送后立即释放无需长TTL
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
}
|
||||
@@ -283,12 +288,13 @@ class ClaudeRelayConfigService {
|
||||
|
||||
const account = await accountService.getAccount(accountId)
|
||||
|
||||
if (!account || !account.success || !account.data) {
|
||||
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||
if (!account) {
|
||||
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||
return false
|
||||
}
|
||||
|
||||
const accountData = account.data
|
||||
const accountData = account
|
||||
|
||||
// 检查账户是否激活
|
||||
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||
@@ -315,11 +321,11 @@ class ClaudeRelayConfigService {
|
||||
|
||||
/**
|
||||
* 验证新会话请求
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @param {Object} _requestBody - 请求体(预留参数,当前未使用)
|
||||
* @param {string} originalSessionId - 原始会话ID
|
||||
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||
*/
|
||||
async validateNewSession(requestBody, originalSessionId) {
|
||||
async validateNewSession(_requestBody, originalSessionId) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.globalSessionBindingEnabled) {
|
||||
|
||||
@@ -15,6 +15,7 @@ const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||
const requestIdentityService = require('./requestIdentityService')
|
||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||
const userMessageQueueService = require('./userMessageQueueService')
|
||||
|
||||
class ClaudeRelayService {
|
||||
constructor() {
|
||||
@@ -148,6 +149,9 @@ class ClaudeRelayService {
|
||||
options = {}
|
||||
) {
|
||||
let upstreamRequest = null
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
let selectedAccountId = null
|
||||
|
||||
try {
|
||||
// 调试日志:查看API Key数据
|
||||
@@ -192,11 +196,70 @@ class ClaudeRelayService {
|
||||
}
|
||||
const { accountId } = accountSelection
|
||||
const { accountType } = accountSelection
|
||||
selectedAccountId = accountId
|
||||
|
||||
logger.info(
|
||||
`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
|
||||
)
|
||||
|
||||
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in relayRequest')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
apiKeyName: apiKeyData.name,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for account ${accountId}, key: ${apiKeyData.name}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
return {
|
||||
statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-user-message-queue-error': errorType
|
||||
},
|
||||
body: JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
}),
|
||||
accountId
|
||||
}
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for account ${accountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取账户信息
|
||||
let account = await claudeAccountService.getAccount(accountId)
|
||||
|
||||
@@ -271,6 +334,23 @@ class ClaudeRelayService {
|
||||
options
|
||||
)
|
||||
|
||||
// 📬 请求已发送成功,立即释放队列锁(无需等待响应处理完成)
|
||||
// 因为 Claude API 限流基于请求发送时刻计算(RPM),不是请求完成时刻
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for account ${selectedAccountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for account ${selectedAccountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
response.accountId = accountId
|
||||
response.accountType = accountType
|
||||
|
||||
@@ -539,6 +619,21 @@ class ClaudeRelayService {
|
||||
error.message
|
||||
)
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在请求发送后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for account ${selectedAccountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for account ${selectedAccountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,8 +1152,6 @@ class ClaudeRelayService {
|
||||
timeout: config.requestTimeout || 600000
|
||||
}
|
||||
|
||||
console.log(options.path)
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let responseData = Buffer.alloc(0)
|
||||
|
||||
@@ -1112,7 +1205,6 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
req.on('error', async (error) => {
|
||||
console.error(': ❌ ', error)
|
||||
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
|
||||
code: error.code,
|
||||
errno: error.errno,
|
||||
@@ -1163,6 +1255,10 @@ class ClaudeRelayService {
|
||||
streamTransformer = null,
|
||||
options = {}
|
||||
) {
|
||||
let queueLockAcquired = false
|
||||
let queueRequestId = null
|
||||
let selectedAccountId = null
|
||||
|
||||
try {
|
||||
// 调试日志:查看API Key数据(流式请求)
|
||||
logger.info('🔍 [Stream] API Key data received:', {
|
||||
@@ -1206,6 +1302,70 @@ class ClaudeRelayService {
|
||||
}
|
||||
const { accountId } = accountSelection
|
||||
const { accountType } = accountSelection
|
||||
selectedAccountId = accountId
|
||||
|
||||
// 📬 用户消息队列处理:如果是用户消息请求,需要获取队列锁
|
||||
if (userMessageQueueService.isUserMessageRequest(requestBody)) {
|
||||
// 校验 accountId 非空,避免空值污染队列锁键
|
||||
if (!accountId || accountId === '') {
|
||||
logger.error('❌ accountId missing for queue lock in relayStreamRequestWithUsageCapture')
|
||||
throw new Error('accountId missing for queue lock')
|
||||
}
|
||||
const queueResult = await userMessageQueueService.acquireQueueLock(accountId)
|
||||
if (!queueResult.acquired && !queueResult.skipped) {
|
||||
// 区分 Redis 后端错误和队列超时
|
||||
const isBackendError = queueResult.error === 'queue_backend_error'
|
||||
const errorCode = isBackendError ? 'QUEUE_BACKEND_ERROR' : 'QUEUE_TIMEOUT'
|
||||
const errorType = isBackendError ? 'queue_backend_error' : 'queue_timeout'
|
||||
const errorMessage = isBackendError
|
||||
? 'Queue service temporarily unavailable, please retry later'
|
||||
: 'User message queue wait timeout, please retry later'
|
||||
const statusCode = isBackendError ? 500 : 503
|
||||
|
||||
// 结构化性能日志,用于后续统计
|
||||
logger.performance('user_message_queue_error', {
|
||||
errorType,
|
||||
errorCode,
|
||||
accountId,
|
||||
statusCode,
|
||||
stream: true,
|
||||
apiKeyName: apiKeyData.name,
|
||||
backendError: isBackendError ? queueResult.errorMessage : undefined
|
||||
})
|
||||
|
||||
logger.warn(
|
||||
`📬 User message queue ${errorType} for account ${accountId} (stream), key: ${apiKeyData.name}`,
|
||||
isBackendError ? { backendError: queueResult.errorMessage } : {}
|
||||
)
|
||||
if (!responseStream.headersSent) {
|
||||
responseStream.writeHead(statusCode, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'x-user-message-queue-error': errorType
|
||||
})
|
||||
}
|
||||
const errorEvent = `event: error\ndata: ${JSON.stringify({
|
||||
type: 'error',
|
||||
error: {
|
||||
type: errorType,
|
||||
code: errorCode,
|
||||
message: errorMessage
|
||||
}
|
||||
})}\n\n`
|
||||
responseStream.write(errorEvent)
|
||||
responseStream.write('data: [DONE]\n\n')
|
||||
responseStream.end()
|
||||
return
|
||||
}
|
||||
if (queueResult.acquired && !queueResult.skipped) {
|
||||
queueLockAcquired = true
|
||||
queueRequestId = queueResult.requestId
|
||||
logger.debug(
|
||||
`📬 User message queue lock acquired for account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
|
||||
@@ -1272,11 +1432,48 @@ class ClaudeRelayService {
|
||||
sessionHash,
|
||||
streamTransformer,
|
||||
options,
|
||||
isDedicatedOfficialAccount
|
||||
isDedicatedOfficialAccount,
|
||||
// 📬 新增回调:在收到响应头时释放队列锁
|
||||
async () => {
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||
queueLockAcquired = false // 标记已释放,防止 finally 重复释放
|
||||
logger.debug(
|
||||
`📬 User message queue lock released early for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock early for stream account ${selectedAccountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info(`🔌 Claude stream relay ended: Client disconnected`)
|
||||
} else {
|
||||
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// 📬 释放用户消息队列锁(兜底,正常情况下已在收到响应头后提前释放)
|
||||
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||
try {
|
||||
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||
logger.debug(
|
||||
`📬 User message queue lock released in finally for stream account ${selectedAccountId}, requestId: ${queueRequestId}`
|
||||
)
|
||||
} catch (releaseError) {
|
||||
logger.error(
|
||||
`❌ Failed to release user message queue lock for stream account ${selectedAccountId}:`,
|
||||
releaseError.message
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1293,7 +1490,8 @@ class ClaudeRelayService {
|
||||
sessionHash,
|
||||
streamTransformer = null,
|
||||
requestOptions = {},
|
||||
isDedicatedOfficialAccount = false
|
||||
isDedicatedOfficialAccount = false,
|
||||
onResponseStart = null // 📬 新增:收到响应头时的回调,用于提前释放队列锁
|
||||
) {
|
||||
// 获取账户信息用于统一 User-Agent
|
||||
const account = await claudeAccountService.getAccount(accountId)
|
||||
@@ -1478,7 +1676,6 @@ class ClaudeRelayService {
|
||||
})
|
||||
|
||||
res.on('end', () => {
|
||||
console.error(': ❌ ', errorData)
|
||||
logger.error(
|
||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||
errorData
|
||||
@@ -1540,6 +1737,16 @@ class ClaudeRelayService {
|
||||
return
|
||||
}
|
||||
|
||||
// 📬 收到成功响应头(HTTP 200),立即调用回调释放队列锁
|
||||
// 此时请求已被 Claude API 接受并计入 RPM 配额,无需等待响应完成
|
||||
if (onResponseStart && typeof onResponseStart === 'function') {
|
||||
try {
|
||||
await onResponseStart()
|
||||
} catch (callbackError) {
|
||||
logger.error('❌ Error in onResponseStart callback:', callbackError.message)
|
||||
}
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
const allUsageData = [] // 收集所有的usage事件
|
||||
let currentUsageData = {} // 当前正在收集的usage数据
|
||||
@@ -1950,7 +2157,7 @@ class ClaudeRelayService {
|
||||
responseStream.on('close', () => {
|
||||
logger.debug('🔌 Client disconnected, cleaning up stream')
|
||||
if (!req.destroyed) {
|
||||
req.destroy()
|
||||
req.destroy(new Error('Client disconnected'))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -336,7 +336,12 @@ class DroidRelayService {
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||
// 客户端主动断开连接是正常情况,使用 INFO 级别
|
||||
if (error.message === 'Client disconnected') {
|
||||
logger.info(`🔌 Droid relay ended: Client disconnected`)
|
||||
} else {
|
||||
logger.error(`❌ Droid relay error: ${error.message}`, error)
|
||||
}
|
||||
|
||||
const status = error?.response?.status
|
||||
if (status >= 400 && status < 500) {
|
||||
@@ -634,7 +639,7 @@ class DroidRelayService {
|
||||
// 客户端断开连接时清理
|
||||
clientResponse.on('close', () => {
|
||||
if (req && !req.destroyed) {
|
||||
req.destroy()
|
||||
req.destroy(new Error('Client disconnected'))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -426,9 +426,9 @@ class OpenAIResponsesRelayService {
|
||||
const lines = data.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
if (line.startsWith('data:')) {
|
||||
try {
|
||||
const jsonStr = line.slice(6)
|
||||
const jsonStr = line.slice(5).trim()
|
||||
if (jsonStr === '[DONE]') {
|
||||
continue
|
||||
}
|
||||
|
||||
348
src/services/userMessageQueueService.js
Normal file
348
src/services/userMessageQueueService.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 用户消息队列服务
|
||||
* 为 Claude 账户实现基于消息类型的串行排队机制
|
||||
*
|
||||
* 当请求的最后一条消息是用户输入(role: user)时,
|
||||
* 同一账户的此类请求需要串行等待,并在请求之间添加延迟
|
||||
*/
|
||||
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../models/redis')
|
||||
const config = require('../../config/config')
|
||||
const logger = require('../utils/logger')
|
||||
|
||||
// 清理任务间隔
|
||||
const CLEANUP_INTERVAL_MS = 60000 // 1分钟
|
||||
|
||||
// 轮询等待配置
|
||||
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||
|
||||
class UserMessageQueueService {
|
||||
constructor() {
|
||||
this.cleanupTimer = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测请求是否为真正的用户消息请求
|
||||
* 区分真正的用户输入和 tool_result 消息
|
||||
*
|
||||
* Claude API 消息格式:
|
||||
* - 用户文本消息: { role: 'user', content: 'text' } 或 { role: 'user', content: [{ type: 'text', text: '...' }] }
|
||||
* - 工具结果消息: { role: 'user', content: [{ type: 'tool_result', tool_use_id: '...', content: '...' }] }
|
||||
*
|
||||
* @param {Object} requestBody - 请求体
|
||||
* @returns {boolean} - 是否为真正的用户消息(排除 tool_result)
|
||||
*/
|
||||
isUserMessageRequest(requestBody) {
|
||||
const messages = requestBody?.messages
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return false
|
||||
}
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
|
||||
// 检查 role 是否为 user
|
||||
if (lastMessage?.role !== 'user') {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查 content 是否包含 tool_result 类型
|
||||
const { content } = lastMessage
|
||||
if (Array.isArray(content)) {
|
||||
// 如果 content 数组中任何元素是 tool_result,则不是真正的用户消息
|
||||
const hasToolResult = content.some(
|
||||
(block) => block?.type === 'tool_result' || block?.type === 'tool_use_result'
|
||||
)
|
||||
if (hasToolResult) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// role 是 user 且不包含 tool_result,是真正的用户消息
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前配置(支持 Web 界面配置优先)
|
||||
* @returns {Promise<Object>} 配置对象
|
||||
*/
|
||||
async getConfig() {
|
||||
// 默认配置(防止 config.userMessageQueue 未定义)
|
||||
// 注意:优化后的默认值 - 锁持有时间从分钟级降到毫秒级,无需长等待
|
||||
const queueConfig = config.userMessageQueue || {}
|
||||
const defaults = {
|
||||
enabled: queueConfig.enabled ?? false,
|
||||
delayMs: queueConfig.delayMs ?? 200,
|
||||
timeoutMs: queueConfig.timeoutMs ?? 5000, // 从 60000 降到 5000,因为锁持有时间短
|
||||
lockTtlMs: queueConfig.lockTtlMs ?? 5000 // 从 120000 降到 5000,5秒足以覆盖请求发送
|
||||
}
|
||||
|
||||
// 尝试从 claudeRelayConfigService 获取 Web 界面配置
|
||||
try {
|
||||
const claudeRelayConfigService = require('./claudeRelayConfigService')
|
||||
const webConfig = await claudeRelayConfigService.getConfig()
|
||||
|
||||
return {
|
||||
enabled:
|
||||
webConfig.userMessageQueueEnabled !== undefined
|
||||
? webConfig.userMessageQueueEnabled
|
||||
: defaults.enabled,
|
||||
delayMs:
|
||||
webConfig.userMessageQueueDelayMs !== undefined
|
||||
? webConfig.userMessageQueueDelayMs
|
||||
: defaults.delayMs,
|
||||
timeoutMs:
|
||||
webConfig.userMessageQueueTimeoutMs !== undefined
|
||||
? webConfig.userMessageQueueTimeoutMs
|
||||
: defaults.timeoutMs,
|
||||
lockTtlMs:
|
||||
webConfig.userMessageQueueLockTtlMs !== undefined
|
||||
? webConfig.userMessageQueueLockTtlMs
|
||||
: defaults.lockTtlMs
|
||||
}
|
||||
} catch {
|
||||
// 回退到环境变量配置
|
||||
return defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查功能是否启用
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async isEnabled() {
|
||||
const cfg = await this.getConfig()
|
||||
return cfg.enabled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取账户队列锁(阻塞等待)
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID(可选,会自动生成)
|
||||
* @param {number} timeoutMs - 超时时间(可选,使用配置默认值)
|
||||
* @returns {Promise<{acquired: boolean, requestId: string, error?: string}>}
|
||||
*/
|
||||
async acquireQueueLock(accountId, requestId = null, timeoutMs = null) {
|
||||
const cfg = await this.getConfig()
|
||||
|
||||
if (!cfg.enabled) {
|
||||
return { acquired: true, requestId: requestId || uuidv4(), skipped: true }
|
||||
}
|
||||
|
||||
const reqId = requestId || uuidv4()
|
||||
const timeout = timeoutMs || cfg.timeoutMs
|
||||
const startTime = Date.now()
|
||||
let retryCount = 0
|
||||
|
||||
logger.debug(`📬 User message queue: attempting to acquire lock for account ${accountId}`, {
|
||||
requestId: reqId,
|
||||
timeoutMs: timeout
|
||||
})
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const result = await redis.acquireUserMessageLock(
|
||||
accountId,
|
||||
reqId,
|
||||
cfg.lockTtlMs,
|
||||
cfg.delayMs
|
||||
)
|
||||
|
||||
// 检测 Redis 错误,立即返回系统错误而非继续轮询
|
||||
if (result.redisError) {
|
||||
logger.error(`📬 User message queue: Redis error while acquiring lock`, {
|
||||
accountId,
|
||||
requestId: reqId,
|
||||
errorMessage: result.errorMessage
|
||||
})
|
||||
return {
|
||||
acquired: false,
|
||||
requestId: reqId,
|
||||
error: 'queue_backend_error',
|
||||
errorMessage: result.errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
if (result.acquired) {
|
||||
logger.debug(`📬 User message queue: lock acquired for account ${accountId}`, {
|
||||
requestId: reqId,
|
||||
waitedMs: Date.now() - startTime,
|
||||
retries: retryCount
|
||||
})
|
||||
return { acquired: true, requestId: reqId }
|
||||
}
|
||||
|
||||
// 需要等待
|
||||
if (result.waitMs > 0) {
|
||||
// 需要延迟(上一个请求刚完成)
|
||||
await this._sleep(Math.min(result.waitMs, timeout - (Date.now() - startTime)))
|
||||
} else {
|
||||
// 锁被占用,使用指数退避轮询等待
|
||||
const basePollInterval = Math.min(
|
||||
POLL_INTERVAL_BASE_MS * Math.pow(POLL_BACKOFF_FACTOR, retryCount),
|
||||
POLL_INTERVAL_MAX_MS
|
||||
)
|
||||
// 添加 ±15% 随机抖动,避免高并发下的周期性碰撞
|
||||
const jitter = basePollInterval * (0.85 + Math.random() * 0.3)
|
||||
const pollInterval = Math.min(jitter, POLL_INTERVAL_MAX_MS)
|
||||
await this._sleep(pollInterval)
|
||||
retryCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 超时
|
||||
logger.warn(`📬 User message queue: timeout waiting for lock`, {
|
||||
accountId,
|
||||
requestId: reqId,
|
||||
timeoutMs: timeout
|
||||
})
|
||||
|
||||
return {
|
||||
acquired: false,
|
||||
requestId: reqId,
|
||||
error: 'queue_timeout'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放账户队列锁
|
||||
* @param {string} accountId - 账户ID
|
||||
* @param {string} requestId - 请求ID
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async releaseQueueLock(accountId, requestId) {
|
||||
if (!accountId || !requestId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const released = await redis.releaseUserMessageLock(accountId, requestId)
|
||||
|
||||
if (released) {
|
||||
logger.debug(`📬 User message queue: lock released for account ${accountId}`, {
|
||||
requestId
|
||||
})
|
||||
} else {
|
||||
logger.warn(`📬 User message queue: failed to release lock (not owner?)`, {
|
||||
accountId,
|
||||
requestId
|
||||
})
|
||||
}
|
||||
|
||||
return released
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列统计信息
|
||||
* @param {string} accountId - 账户ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async getQueueStats(accountId) {
|
||||
return await redis.getUserMessageQueueStats(accountId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务启动时清理所有残留的队列锁
|
||||
* 防止服务重启后旧锁阻塞新请求
|
||||
* @returns {Promise<number>} 清理的锁数量
|
||||
*/
|
||||
async cleanupStaleLocks() {
|
||||
try {
|
||||
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||
let cleanedCount = 0
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
try {
|
||||
await redis.forceReleaseUserMessageLock(accountId)
|
||||
cleanedCount++
|
||||
logger.debug(`📬 User message queue: cleaned stale lock for account ${accountId}`)
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`📬 User message queue: failed to clean lock for account ${accountId}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.info(`📬 User message queue: cleaned ${cleanedCount} stale lock(s) on startup`)
|
||||
}
|
||||
|
||||
return cleanedCount
|
||||
} catch (error) {
|
||||
logger.error('📬 User message queue: failed to cleanup stale locks on startup:', error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动定时清理任务
|
||||
* 始终启动,每次执行时检查配置以支持运行时动态启用/禁用
|
||||
*/
|
||||
startCleanupTask() {
|
||||
if (this.cleanupTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
this.cleanupTimer = setInterval(async () => {
|
||||
// 每次运行时检查配置,以便在运行时动态启用/禁用
|
||||
const currentConfig = await this.getConfig()
|
||||
if (!currentConfig.enabled) {
|
||||
logger.debug('📬 User message queue: cleanup skipped (feature disabled)')
|
||||
return
|
||||
}
|
||||
await this._cleanupOrphanLocks()
|
||||
}, CLEANUP_INTERVAL_MS)
|
||||
|
||||
logger.info('📬 User message queue: cleanup task started')
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止定时清理任务
|
||||
*/
|
||||
stopCleanupTask() {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer)
|
||||
this.cleanupTimer = null
|
||||
logger.info('📬 User message queue: cleanup task stopped')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理孤儿锁
|
||||
* 检测异常情况:锁存在但没有设置过期时间(lockTtlRaw === -1)
|
||||
* 正常情况下所有锁都应该有 TTL,Redis 会自动过期
|
||||
* @private
|
||||
*/
|
||||
async _cleanupOrphanLocks() {
|
||||
try {
|
||||
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||
|
||||
for (const accountId of accountIds) {
|
||||
const stats = await redis.getUserMessageQueueStats(accountId)
|
||||
|
||||
// 检测异常情况:锁存在(isLocked=true)但没有过期时间(lockTtlRaw=-1)
|
||||
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
|
||||
if (stats.isLocked && stats.lockTtlRaw === -1) {
|
||||
logger.warn(
|
||||
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
|
||||
{ lockHolder: stats.lockHolder }
|
||||
)
|
||||
await redis.forceReleaseUserMessageLock(accountId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('📬 User message queue: cleanup task error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 睡眠辅助函数
|
||||
* @param {number} ms - 毫秒
|
||||
* @private
|
||||
*/
|
||||
_sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserMessageQueueService()
|
||||
@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
|
||||
|
||||
const logFormat = createLogFormat(false)
|
||||
const consoleFormat = createLogFormat(true)
|
||||
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
|
||||
|
||||
// 📁 确保日志目录存在并设置权限
|
||||
if (!fs.existsSync(config.logging.dirname)) {
|
||||
@@ -159,18 +160,20 @@ const createRotateTransport = (filename, level = null) => {
|
||||
transport.level = level
|
||||
}
|
||||
|
||||
// 监听轮转事件
|
||||
transport.on('rotate', (oldFilename, newFilename) => {
|
||||
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
||||
})
|
||||
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
|
||||
if (!isTestEnv) {
|
||||
transport.on('rotate', (oldFilename, newFilename) => {
|
||||
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
||||
})
|
||||
|
||||
transport.on('new', (newFilename) => {
|
||||
console.log(`📄 New log file created: ${newFilename}`)
|
||||
})
|
||||
transport.on('new', (newFilename) => {
|
||||
console.log(`📄 New log file created: ${newFilename}`)
|
||||
})
|
||||
|
||||
transport.on('archive', (zipFilename) => {
|
||||
console.log(`🗜️ Log archived: ${zipFilename}`)
|
||||
})
|
||||
transport.on('archive', (zipFilename) => {
|
||||
console.log(`🗜️ Log archived: ${zipFilename}`)
|
||||
})
|
||||
}
|
||||
|
||||
return transport
|
||||
}
|
||||
|
||||
434
tests/userMessageQueue.test.js
Normal file
434
tests/userMessageQueue.test.js
Normal file
@@ -0,0 +1,434 @@
|
||||
/**
|
||||
* 用户消息队列服务测试
|
||||
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const userMessageQueueService = require('../src/services/userMessageQueueService')
|
||||
|
||||
describe('UserMessageQueueService', () => {
|
||||
describe('isUserMessageRequest', () => {
|
||||
it('should return true when last message role is user', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' },
|
||||
{ role: 'user', content: 'How are you?' }
|
||||
]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when last message role is assistant', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Hi there' }
|
||||
]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when last message contains tool_result', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hello' },
|
||||
{ role: 'assistant', content: 'Let me check that' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'test-id',
|
||||
content: 'Tool result'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
// tool_result 消息虽然 role 是 user,但不是真正的用户消息
|
||||
// 应该返回 false,不进入用户消息队列
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when last message contains multiple tool_results', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{ role: 'user', content: 'Run multiple tools' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'Result 1'
|
||||
},
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-2',
|
||||
content: 'Result 2'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return true when user message has array content with text type', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello, this is a user message'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return true when user message has mixed text and image content', () => {
|
||||
const requestBody = {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'What is in this image?'
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: '...' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when messages is empty', () => {
|
||||
const requestBody = { messages: [] }
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when messages is not an array', () => {
|
||||
const requestBody = { messages: 'not an array' }
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when messages is undefined', () => {
|
||||
const requestBody = {}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when requestBody is null', () => {
|
||||
expect(userMessageQueueService.isUserMessageRequest(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when requestBody is undefined', () => {
|
||||
expect(userMessageQueueService.isUserMessageRequest(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when last message has no role', () => {
|
||||
const requestBody = {
|
||||
messages: [{ content: 'Hello' }]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle single user message', () => {
|
||||
const requestBody = {
|
||||
messages: [{ role: 'user', content: 'Hello' }]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle single assistant message', () => {
|
||||
const requestBody = {
|
||||
messages: [{ role: 'assistant', content: 'Hello' }]
|
||||
}
|
||||
expect(userMessageQueueService.isUserMessageRequest(requestBody)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('should return config with expected properties', async () => {
|
||||
const config = await userMessageQueueService.getConfig()
|
||||
expect(config).toHaveProperty('enabled')
|
||||
expect(config).toHaveProperty('delayMs')
|
||||
expect(config).toHaveProperty('timeoutMs')
|
||||
expect(config).toHaveProperty('lockTtlMs')
|
||||
expect(typeof config.enabled).toBe('boolean')
|
||||
expect(typeof config.delayMs).toBe('number')
|
||||
expect(typeof config.timeoutMs).toBe('number')
|
||||
expect(typeof config.lockTtlMs).toBe('number')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return boolean', async () => {
|
||||
const enabled = await userMessageQueueService.isEnabled()
|
||||
expect(typeof enabled).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
describe('acquireQueueLock', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should acquire lock immediately when no lock exists', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 30000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||
acquired: true,
|
||||
waitMs: 0
|
||||
})
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||
|
||||
expect(result.acquired).toBe(true)
|
||||
expect(result.requestId).toBe('req-1')
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should skip lock acquisition when queue disabled', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: false,
|
||||
delayMs: 200,
|
||||
timeoutMs: 30000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||
|
||||
expect(result.acquired).toBe(true)
|
||||
expect(result.skipped).toBe(true)
|
||||
expect(acquireSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should generate requestId when not provided', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 30000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||
acquired: true,
|
||||
waitMs: 0
|
||||
})
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||
|
||||
expect(result.acquired).toBe(true)
|
||||
expect(result.requestId).toBeDefined()
|
||||
expect(result.requestId.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should wait and retry when lock is held by another request', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 1000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
|
||||
let callCount = 0
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||
callCount++
|
||||
if (callCount < 3) {
|
||||
return { acquired: false, waitMs: -1 } // lock held
|
||||
}
|
||||
return { acquired: true, waitMs: 0 }
|
||||
})
|
||||
|
||||
// Mock sleep to speed up test
|
||||
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||
|
||||
expect(result.acquired).toBe(true)
|
||||
expect(callCount).toBe(3)
|
||||
})
|
||||
|
||||
it('should respect delay when previous request just completed', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 1000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
|
||||
let callCount = 0
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
|
||||
}
|
||||
return { acquired: true, waitMs: 0 }
|
||||
})
|
||||
|
||||
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||
|
||||
expect(result.acquired).toBe(true)
|
||||
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
|
||||
})
|
||||
|
||||
it('should timeout and return error when wait exceeds timeout', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 100, // very short timeout
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||
acquired: false,
|
||||
waitMs: -1 // always held
|
||||
})
|
||||
|
||||
// Use real timers for timeout test but mock sleep to be instant
|
||||
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
|
||||
// Simulate time passing
|
||||
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||
})
|
||||
|
||||
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
|
||||
|
||||
expect(result.acquired).toBe(false)
|
||||
expect(result.error).toBe('queue_timeout')
|
||||
})
|
||||
})
|
||||
|
||||
describe('releaseQueueLock', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should release lock successfully when holding the lock', async () => {
|
||||
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
|
||||
|
||||
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
|
||||
})
|
||||
|
||||
it('should return false when not holding the lock', async () => {
|
||||
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
|
||||
|
||||
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('should return false when accountId is missing', async () => {
|
||||
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||
|
||||
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(releaseSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return false when requestId is missing', async () => {
|
||||
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||
|
||||
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(releaseSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('queue serialization behavior', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('should allow different accounts to acquire locks simultaneously', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 200,
|
||||
timeoutMs: 30000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||
acquired: true,
|
||||
waitMs: 0
|
||||
})
|
||||
|
||||
const [result1, result2] = await Promise.all([
|
||||
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
|
||||
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
|
||||
])
|
||||
|
||||
expect(result1.acquired).toBe(true)
|
||||
expect(result2.acquired).toBe(true)
|
||||
})
|
||||
|
||||
it('should serialize requests for same account', async () => {
|
||||
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||
enabled: true,
|
||||
delayMs: 50,
|
||||
timeoutMs: 5000,
|
||||
lockTtlMs: 120000
|
||||
})
|
||||
|
||||
const lockState = { held: false, holderId: null }
|
||||
|
||||
jest
|
||||
.spyOn(redis, 'acquireUserMessageLock')
|
||||
.mockImplementation(async (accountId, requestId) => {
|
||||
if (!lockState.held) {
|
||||
lockState.held = true
|
||||
lockState.holderId = requestId
|
||||
return { acquired: true, waitMs: 0 }
|
||||
}
|
||||
return { acquired: false, waitMs: -1 }
|
||||
})
|
||||
|
||||
jest
|
||||
.spyOn(redis, 'releaseUserMessageLock')
|
||||
.mockImplementation(async (accountId, requestId) => {
|
||||
if (lockState.holderId === requestId) {
|
||||
lockState.held = false
|
||||
lockState.holderId = null
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||
|
||||
// First request acquires lock
|
||||
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||
expect(result1.acquired).toBe(true)
|
||||
|
||||
// Second request should fail to acquire (lock held)
|
||||
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
|
||||
|
||||
// Release first lock
|
||||
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||
|
||||
// Now second request should acquire
|
||||
const result2 = await acquirePromise
|
||||
expect(result2.acquired).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1352,7 +1351,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1589,7 +1587,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -3063,15 +3060,13 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -3623,7 +3618,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3770,7 +3764,6 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4035,7 +4028,6 @@
|
||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4533,7 +4525,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4924,7 +4915,6 @@
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -5125,7 +5115,6 @@
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.18",
|
||||
"@vue/compiler-sfc": "3.5.18",
|
||||
|
||||
@@ -2049,7 +2049,7 @@ const bindingCounts = ref({}) // 轻量级绑定计数,用于显示"绑定: X
|
||||
const accountGroups = ref([])
|
||||
const groupFilter = ref('all')
|
||||
const platformFilter = ref('all')
|
||||
const statusFilter = ref('normal') // 状态过滤 (normal/rateLimited/other/all)
|
||||
const statusFilter = ref('all') // 状态过滤 (normal/rateLimited/other/all)
|
||||
const searchKeyword = ref('')
|
||||
const PAGE_SIZE_STORAGE_KEY = 'accountsPageSize'
|
||||
const getInitialPageSize = () => {
|
||||
@@ -2804,11 +2804,12 @@ const loadAccounts = async (forceReload = false) => {
|
||||
let openaiResponsesRaw = []
|
||||
|
||||
const appendAccounts = (platform, data) => {
|
||||
if (!data || data.length === 0) return
|
||||
const list = Array.isArray(data) ? data : []
|
||||
if (list.length === 0) return
|
||||
|
||||
switch (platform) {
|
||||
case 'claude': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.claudeAccountId?.[acc.id] || 0
|
||||
return { ...acc, platform: 'claude', boundApiKeysCount }
|
||||
})
|
||||
@@ -2816,7 +2817,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'claude-console': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.claudeConsoleAccountId?.[acc.id] || 0
|
||||
return { ...acc, platform: 'claude-console', boundApiKeysCount }
|
||||
})
|
||||
@@ -2824,12 +2825,12 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'bedrock': {
|
||||
const items = data.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
|
||||
const items = list.map((acc) => ({ ...acc, platform: 'bedrock', boundApiKeysCount: 0 }))
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
case 'gemini': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.geminiAccountId?.[acc.id] || 0
|
||||
return { ...acc, platform: 'gemini', boundApiKeysCount }
|
||||
})
|
||||
@@ -2837,7 +2838,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'openai': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.openaiAccountId?.[acc.id] || 0
|
||||
return { ...acc, platform: 'openai', boundApiKeysCount }
|
||||
})
|
||||
@@ -2845,7 +2846,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'azure_openai': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.azureOpenaiAccountId?.[acc.id] || 0
|
||||
return { ...acc, platform: 'azure_openai', boundApiKeysCount }
|
||||
})
|
||||
@@ -2853,16 +2854,16 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'openai-responses': {
|
||||
openaiResponsesRaw = data
|
||||
openaiResponsesRaw = list
|
||||
break
|
||||
}
|
||||
case 'ccr': {
|
||||
const items = data.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
|
||||
const items = list.map((acc) => ({ ...acc, platform: 'ccr', boundApiKeysCount: 0 }))
|
||||
allAccounts.push(...items)
|
||||
break
|
||||
}
|
||||
case 'droid': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.droidAccountId?.[acc.id] || acc.boundApiKeysCount || 0
|
||||
return { ...acc, platform: 'droid', boundApiKeysCount }
|
||||
})
|
||||
@@ -2870,7 +2871,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
break
|
||||
}
|
||||
case 'gemini-api': {
|
||||
const items = data.map((acc) => {
|
||||
const items = list.map((acc) => {
|
||||
const boundApiKeysCount = counts.geminiAccountId?.[`api:${acc.id}`] || 0
|
||||
return { ...acc, platform: 'gemini-api', boundApiKeysCount }
|
||||
})
|
||||
|
||||
@@ -720,7 +720,7 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
全局会话绑定
|
||||
强制会话绑定
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
|
||||
@@ -777,7 +777,7 @@
|
||||
@change="saveClaudeConfig"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
当绑定的账户不可用(状态异常、过载等)时,返回给客户端的错误消息
|
||||
当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -794,7 +794,7 @@
|
||||
的请求将自动路由到同一账户。
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
|
||||
<strong>新会话识别:</strong>如果是已存在的绑定会话但请求中
|
||||
<strong>新会话识别:</strong>如果绑定会话历史中没有该sessionId但请求中
|
||||
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||
>messages.length > 1</code
|
||||
>, 系统会认为这是一个污染的会话并拒绝请求。
|
||||
@@ -804,6 +804,100 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户消息串行队列 -->
|
||||
<div
|
||||
class="mb-6 rounded-lg bg-white/80 p-6 shadow-lg backdrop-blur-sm dark:bg-gray-800/80"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="mr-3 flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-cyan-500 to-teal-600 text-white shadow-lg"
|
||||
>
|
||||
<i class="fas fa-list-ol"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||
用户消息串行队列
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
启用后,同一账户的用户消息请求将串行执行,并在请求之间添加延迟,防止触发上游限流
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="claudeConfig.userMessageQueueEnabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<div
|
||||
class="peer h-6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-teal-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-teal-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-teal-800"
|
||||
></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 队列配置详情(仅在启用时显示) -->
|
||||
<div v-if="claudeConfig.userMessageQueueEnabled" class="mt-6 space-y-4">
|
||||
<!-- 请求间隔 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-hourglass-half mr-2 text-gray-400"></i>
|
||||
请求间隔(毫秒)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="claudeConfig.userMessageQueueDelayMs"
|
||||
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
max="10000"
|
||||
min="0"
|
||||
placeholder="200"
|
||||
type="number"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
同一账户的用户消息请求之间的最小间隔时间(0-10000毫秒)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 队列超时 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<i class="fas fa-stopwatch mr-2 text-gray-400"></i>
|
||||
队列超时(毫秒)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="claudeConfig.userMessageQueueTimeoutMs"
|
||||
class="mt-1 block w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-teal-500 focus:outline-none focus:ring-2 focus:ring-teal-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||
max="300000"
|
||||
min="1000"
|
||||
placeholder="30000"
|
||||
type="number"
|
||||
@change="saveClaudeConfig"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
请求在队列中等待的最大时间,超时将返回 503 错误(1000-300000毫秒)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-lg bg-teal-50 p-4 dark:bg-teal-900/20">
|
||||
<div class="flex">
|
||||
<i class="fas fa-info-circle mt-0.5 text-teal-500"></i>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm text-teal-700 dark:text-teal-300">
|
||||
<strong>工作原理:</strong>系统检测请求中最后一条消息的
|
||||
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">role</code>
|
||||
是否为
|
||||
<code class="rounded bg-teal-100 px-1 dark:bg-teal-800">user</code
|
||||
>。用户消息请求需要排队串行执行,而工具调用结果、助手消息续传等不受此限制。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 配置更新信息 -->
|
||||
<div
|
||||
v-if="claudeConfig.updatedAt"
|
||||
@@ -1469,6 +1563,9 @@ const claudeConfig = ref({
|
||||
globalSessionBindingEnabled: false,
|
||||
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: 30,
|
||||
userMessageQueueEnabled: true,
|
||||
userMessageQueueDelayMs: 200,
|
||||
userMessageQueueTimeoutMs: 30000,
|
||||
updatedAt: null,
|
||||
updatedBy: null
|
||||
})
|
||||
@@ -1738,6 +1835,9 @@ const loadClaudeConfig = async () => {
|
||||
sessionBindingErrorMessage:
|
||||
response.config?.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。',
|
||||
sessionBindingTtlDays: response.config?.sessionBindingTtlDays ?? 30,
|
||||
userMessageQueueEnabled: response.config?.userMessageQueueEnabled ?? true,
|
||||
userMessageQueueDelayMs: response.config?.userMessageQueueDelayMs ?? 200,
|
||||
userMessageQueueTimeoutMs: response.config?.userMessageQueueTimeoutMs ?? 30000,
|
||||
updatedAt: response.config?.updatedAt || null,
|
||||
updatedBy: response.config?.updatedBy || null
|
||||
}
|
||||
@@ -1762,7 +1862,10 @@ const saveClaudeConfig = async () => {
|
||||
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
|
||||
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
|
||||
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
|
||||
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays
|
||||
sessionBindingTtlDays: claudeConfig.value.sessionBindingTtlDays,
|
||||
userMessageQueueEnabled: claudeConfig.value.userMessageQueueEnabled,
|
||||
userMessageQueueDelayMs: claudeConfig.value.userMessageQueueDelayMs,
|
||||
userMessageQueueTimeoutMs: claudeConfig.value.userMessageQueueTimeoutMs
|
||||
}
|
||||
|
||||
const response = await apiClient.put('/admin/claude-relay-config', payload, {
|
||||
|
||||
Reference in New Issue
Block a user