Compare commits

...

19 Commits

Author SHA1 Message Date
github-actions[bot]
c4d923c46f chore: sync VERSION file with release v1.1.232 [skip ci] 2025-12-11 02:46:31 +00:00
Wesley Liddick
fa9f9146a2 Merge pull request #793 from qq790716890/main
fix:修复codex统计token问题
2025-12-10 21:46:15 -05:00
Wesley Liddick
dc9409a5a6 Merge pull request #788 from atoz03/main [skip ci]
fix: 账户列表默认显示限额/限流账号并加固加载健壮性
2025-12-10 21:44:55 -05:00
LZY
51aa8dc381 fix:修复codex统计token问题 2025-12-10 22:56:25 +08:00
github-actions[bot]
5061f4d9fd chore: sync VERSION file with release v1.1.231 [skip ci] 2025-12-10 12:11:39 +00:00
Wesley Liddick
4337af06d4 Merge pull request #791 from DaydreamCoding/feature/log-opt
fix: improve logging for client disconnections in relay services
2025-12-10 07:11:24 -05:00
Wesley Liddick
d226d57325 Merge pull request #790 from DaydreamCoding/patch-4 [skip ci]
fix(security): add authenticateAdmin middleware to concurrency routes
2025-12-10 07:11:07 -05:00
Wesley Liddick
9f92c58640 Merge pull request #789 from DaydreamCoding/feature/user-message-queue-optimize [skip ci]
feat(queue): 优化用户消息队列锁释放时机
2025-12-10 07:10:38 -05:00
QTom
8901994644 fix: improve logging for client disconnections in relay services
当客户端主动断开连接时,改为使用 INFO 级别记录而不是 ERROR 级别,
因为这是正常情况而非错误。

- ccrRelayService: 区分客户端断开与实际错误
- claudeConsoleRelayService: 区分客户端断开与实际错误
- claudeRelayService: 区分客户端断开与实际错误
- droidRelayService: 区分客户端断开与实际错误
2025-12-10 14:18:44 +08:00
QTom
e3ca555df7 fix(security): add authenticateAdmin middleware to concurrency routes
fix(security): add authenticateAdmin middleware to concurrency routes

All concurrency management endpoints were missing authentication,
allowing unauthenticated access to view and clear concurrency data.
2025-12-10 13:59:25 +08:00
QTom
3b9c96dff8 feat(queue): 优化用户消息队列锁释放时机
将队列锁释放时机从"请求完成后"提前到"请求发送后",因为 Claude API
限流(RPM)基于请求发送时刻计算,无需等待响应完成。

主要变更:
- 移除锁续租机制(startLockRenewal、refreshUserMessageLock)
- 所有 relay 服务在请求发送成功后立即释放锁
- 流式请求通过 onResponseStart 回调在收到响应头时释放
- 调整默认配置:timeoutMs 60s→5s,lockTtlMs 120s→5s
- 新增 USER_MESSAGE_QUEUE_LOCK_TTL_MS 环境变量支持
2025-12-10 01:26:00 +08:00
github-actions[bot]
cb94a4260e chore: sync VERSION file with release v1.1.230 [skip ci] 2025-12-09 10:59:05 +00:00
Wesley Liddick
ac9499aa6d Merge pull request #787 from DaydreamCoding/feature/user-message-queue-fix
feat: 修复 userMessageQueue 配置缺失导致的 500 错误
2025-12-09 05:58:35 -05:00
atoz03
fc25840f95 fix: 账户列表默认显示限额/限流账号并加固加载健壮性
- 将账户页状态筛选默认值从 normal 改为 all,额度满/限流/异常账号默认可见
  - appendAccounts 使用 Array.isArray 兜底接口响应,避免空/异常数据导致“加载账户失败”
  - 便于在额度耗尽场景查看并处理账号
2025-12-09 18:49:57 +08:00
QTom
b409adf9d8 feat: 修复 userMessageQueue 配置缺失导致的 500 错误
- 在 config.example.js 添加缺失的 userMessageQueue 配置段
  - 在 userMessageQueueService.js 添加防御性代码,当配置未定义时使用默认值

  修复 #783 合并后新用户安装报错:
  Cannot read properties of undefined (reading 'enabled')
2025-12-09 18:41:13 +08:00
github-actions[bot]
b76776d7b0 chore: sync VERSION file with release v1.1.229 [skip ci] 2025-12-09 09:49:01 +00:00
Wesley Liddick
8499992abd Merge pull request #783 from DaydreamCoding/feature/user-message-queue
feat: 添加用户手动输入的消息串行队列功能,防止同账户并发请求触发限流&封号
2025-12-09 04:48:49 -05:00
QTom
dc96447d72 style: 格式化文件以符合 Prettier 规范 2025-12-09 17:18:43 +08:00
QTom
f5d1c25295 feat: 添加用户消息串行队列功能,防止同账户并发请求触发限流
- 新增 userMessageQueueService.js 实现基于 Redis 的队列锁机制
- 在 claudeRelayService、claudeConsoleRelayService、bedrockRelayService、ccrRelayService 中集成队列锁
- 添加 Redis 原子性 Lua 脚本:acquireUserMessageLock、releaseUserMessageLock、refreshUserMessageLock
- 支持锁续租机制,防止长时间请求锁过期
- 添加可配置参数:USER_MESSAGE_QUEUE_ENABLED、USER_MESSAGE_QUEUE_DELAY_MS、USER_MESSAGE_QUEUE_TIMEOUT_MS
- 添加 Web 管理界面配置入口
- 添加 logger.performance 方法用于结构化性能日志
- 添加完整单元测试 (tests/userMessageQueue.test.js)
2025-12-09 17:04:01 +08:00
19 changed files with 2075 additions and 59 deletions

View File

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

View File

@@ -1 +1 @@
1.1.228
1.1.232

View File

@@ -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秒足以覆盖请求发送
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
}
@@ -316,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) {

View File

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

View File

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

View File

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

View 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 降到 50005秒足以覆盖请求发送
}
// 尝试从 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
* 正常情况下所有锁都应该有 TTLRedis 会自动过期
* @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()

View File

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

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

View File

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

View File

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