From 5af5e55d80504e87ffa19865acd7c71fe419f7d5 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 8 Dec 2025 16:10:09 +0800
Subject: [PATCH 01/38] chore: trigger release [force release]
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude
From 698f3d7daa4ac9289d40b89f998b546f39c7fa7a Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 8 Dec 2025 08:10:44 +0000
Subject: [PATCH 02/38] chore: sync VERSION file with release v1.1.227 [skip
ci]
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index 40ff1f13..e58c88de 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.226
+1.1.227
From aa71c584003be807278378caa6cbbebd1749cfa2 Mon Sep 17 00:00:00 2001
From: shaw
Date: Mon, 8 Dec 2025 21:04:53 +0800
Subject: [PATCH 03/38] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=BC=BA?=
=?UTF-8?q?=E5=88=B6=E4=BC=9A=E8=AF=9D=E7=BB=91=E5=AE=9A=E9=A6=96=E6=AC=A1?=
=?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=9A=84bug?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/routes/api.js | 120 +++++++++++++++++++++--
src/services/claudeRelayConfigService.js | 5 +-
web/admin-spa/package-lock.json | 15 +--
web/admin-spa/src/views/SettingsView.vue | 6 +-
4 files changed, 118 insertions(+), 28 deletions(-)
diff --git a/src/routes/api.js b/src/routes/api.js
index 8fe0676b..4d298716 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -38,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
})
}
+/**
+ * 判断是否为旧会话(污染的会话)
+ * Claude Code 发送的请求特点:
+ * - messages 数组通常只有 1 个元素
+ * - 历史对话记录嵌套在单个 message 的 content 数组中
+ * - content 数组中包含 开头的系统注入内容
+ *
+ * 污染会话的特征:
+ * 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 || ''
+ // 剔除以 开头的
+ return !text.trimStart().startsWith('')
+ })
+
+ // 多个用户输入 = 旧会话
+ 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)
diff --git a/src/services/claudeRelayConfigService.js b/src/services/claudeRelayConfigService.js
index 3b9790ac..5d4c3bd5 100644
--- a/src/services/claudeRelayConfigService.js
+++ b/src/services/claudeRelayConfigService.js
@@ -283,12 +283,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') {
diff --git a/web/admin-spa/package-lock.json b/web/admin-spa/package-lock.json
index 9405609e..481df56a 100644
--- a/web/admin-spa/package-lock.json
+++ b/web/admin-spa/package-lock.json
@@ -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",
diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue
index a42ce79b..c60651e6 100644
--- a/web/admin-spa/src/views/SettingsView.vue
+++ b/web/admin-spa/src/views/SettingsView.vue
@@ -720,7 +720,7 @@
- 全局会话绑定
+ 强制会话绑定
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
@@ -777,7 +777,7 @@
@change="saveClaudeConfig"
>
- 当绑定的账户不可用(状态异常、过载等)时,返回给客户端的错误消息
+ 当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息
@@ -794,7 +794,7 @@
的请求将自动路由到同一账户。
- 新会话识别:如果是已存在的绑定会话但请求中
+ 新会话识别:如果绑定会话历史中没有该sessionId但请求中
messages.length > 1, 系统会认为这是一个污染的会话并拒绝请求。
From 95870883a10e3666377dfc4396289fffbfd2ae79 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 8 Dec 2025 13:05:52 +0000
Subject: [PATCH 04/38] chore: sync VERSION file with release v1.1.228 [skip
ci]
---
VERSION | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/VERSION b/VERSION
index e58c88de..819e4b99 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.227
+1.1.228
From f5d1c25295ea5268eb8fc206f4587280c9793dd5 Mon Sep 17 00:00:00 2001
From: QTom
Date: Tue, 9 Dec 2025 17:04:01 +0800
Subject: [PATCH 05/38] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E6=B6=88=E6=81=AF=E4=B8=B2=E8=A1=8C=E9=98=9F=E5=88=97?=
=?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E9=98=B2=E6=AD=A2=E5=90=8C=E8=B4=A6?=
=?UTF-8?q?=E6=88=B7=E5=B9=B6=E5=8F=91=E8=AF=B7=E6=B1=82=E8=A7=A6=E5=8F=91?=
=?UTF-8?q?=E9=99=90=E6=B5=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 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)
---
CLAUDE.md | 8 +
src/app.js | 18 +
src/models/redis.js | 245 +++++++++++
src/routes/admin/claudeRelayConfig.js | 40 +-
src/services/bedrockRelayService.js | 162 +++++++
src/services/ccrRelayService.js | 161 +++++++
src/services/claudeConsoleRelayService.js | 161 +++++++
src/services/claudeRelayConfigService.js | 4 +
src/services/claudeRelayService.js | 177 +++++++-
src/services/droidRelayService.js | 2 +-
src/services/userMessageQueueService.js | 448 +++++++++++++++++++
src/utils/logger.js | 23 +-
tests/userMessageQueue.test.js | 512 ++++++++++++++++++++++
web/admin-spa/src/views/SettingsView.vue | 105 ++++-
14 files changed, 2048 insertions(+), 18 deletions(-)
create mode 100644 src/services/userMessageQueueService.js
create mode 100644 tests/userMessageQueue.test.js
diff --git a/CLAUDE.md b/CLAUDE.md
index 1eac1b03..c918feef 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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,9 @@ 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`: 启用用户消息串行队列(默认true)
+- `USER_MESSAGE_QUEUE_DELAY_MS`: 用户消息请求间隔(毫秒,默认200)
+- `USER_MESSAGE_QUEUE_TIMEOUT_MS`: 队列等待超时(毫秒,默认30000)
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
@@ -337,6 +341,7 @@ npm run setup # 自动生成密钥并创建管理员账户
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
+14. **用户消息队列超时**: 检查 `USER_MESSAGE_QUEUE_TIMEOUT_MS` 配置是否合理,查看日志中的 `queue_timeout` 错误,可通过 Web 界面或 `USER_MESSAGE_QUEUE_ENABLED=false` 禁用此功能
### 调试工具
@@ -510,6 +515,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)
diff --git a/src/app.js b/src/app.js
index 77047247..2a85850e 100644
--- a/src/app.js
+++ b/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,16 @@ class Application {
logger.error('❌ Error stopping rate limit cleanup service:', error)
}
+ // 停止用户消息队列清理服务和续租定时器
+ try {
+ const userMessageQueueService = require('./services/userMessageQueueService')
+ userMessageQueueService.stopAllRenewalTimers()
+ 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')
diff --git a/src/models/redis.js b/src/models/redis.js
index 2393f3b3..a36a27aa 100644
--- a/src/models/redis.js
+++ b/src/models/redis.js
@@ -2556,4 +2556,249 @@ 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
+ * @param {number} lockTtlMs - 锁 TTL(毫秒)
+ * @returns {Promise} 是否续租成功(只有锁持有者才能续租)
+ */
+redisClient.refreshUserMessageLock = async function (accountId, requestId, lockTtlMs) {
+ const lockKey = `user_msg_queue_lock:${accountId}`
+
+ const script = `
+ local lockKey = KEYS[1]
+ local requestId = ARGV[1]
+ local lockTtl = tonumber(ARGV[2])
+
+ local currentLock = redis.call('GET', lockKey)
+ if currentLock == requestId then
+ redis.call('PEXPIRE', lockKey, lockTtl)
+ return 1
+ end
+ return 0
+ `
+
+ try {
+ const result = await this.client.eval(script, 1, lockKey, requestId, lockTtlMs)
+ return result === 1
+ } catch (error) {
+ logger.error(`Failed to refresh user message lock for account ${accountId}:`, error)
+ return false
+ }
+}
+
+/**
+ * 释放用户消息队列锁并记录完成时间
+ * @param {string} accountId - 账户ID
+ * @param {string} requestId - 请求ID
+ * @returns {Promise} 是否成功释放
+ */
+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} 是否成功释放
+ */
+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