mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 09:38:02 +00:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb94a4260e | ||
|
|
ac9499aa6d | ||
|
|
b409adf9d8 | ||
|
|
b76776d7b0 | ||
|
|
8499992abd | ||
|
|
dc96447d72 | ||
|
|
f5d1c25295 | ||
|
|
95870883a1 | ||
|
|
aa71c58400 | ||
|
|
698f3d7daa | ||
|
|
5af5e55d80 | ||
|
|
5a18f54abd | ||
|
|
6d3b51510a | ||
|
|
c79fdc4d71 | ||
|
|
659072075d | ||
|
|
cf93128a96 | ||
|
|
909b5ad37f | ||
|
|
bab7073822 | ||
|
|
0035f8cb4f | ||
|
|
d49cc0cec8 | ||
|
|
c4d6ab97f2 | ||
|
|
7053d5f1ac | ||
|
|
24796fc889 | ||
|
|
201d95c84e | ||
|
|
b978d864e3 | ||
|
|
175c041e5a | ||
|
|
b441506199 | ||
|
|
eb2341fb16 | ||
|
|
e89e2964e7 | ||
|
|
b3e27e9f15 | ||
|
|
d0b397b45a | ||
|
|
195e42e0a5 | ||
|
|
f74f77ef65 | ||
|
|
b63c3217bc |
@@ -60,6 +60,7 @@ Claude Relay Service 是一个多平台 AI API 中转服务,支持 **Claude (
|
|||||||
|
|
||||||
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
- **apiKeyService.js**: API Key管理,验证、限流、使用统计、成本计算
|
||||||
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
- **userService.js**: 用户管理系统,支持用户注册、登录、API Key管理
|
||||||
|
- **userMessageQueueService.js**: 用户消息串行队列,防止同账户并发用户消息触发限流
|
||||||
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
- **pricingService.js**: 定价服务,模型价格管理和成本计算
|
||||||
- **costInitService.js**: 成本数据初始化服务
|
- **costInitService.js**: 成本数据初始化服务
|
||||||
- **webhookService.js**: Webhook通知服务
|
- **webhookService.js**: Webhook通知服务
|
||||||
@@ -185,6 +186,9 @@ npm run service:stop # 停止服务
|
|||||||
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
- `CLAUDE_OVERLOAD_HANDLING_MINUTES`: Claude 529错误处理持续时间(分钟,0表示禁用)
|
||||||
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
- `STICKY_SESSION_TTL_HOURS`: 粘性会话TTL(小时,默认1)
|
||||||
- `STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES`: 粘性会话续期阈值(分钟,默认0)
|
- `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)
|
- `METRICS_WINDOW`: 实时指标统计窗口(分钟,1-60,默认5)
|
||||||
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
- `MAX_API_KEYS_PER_USER`: 每用户最大API Key数量(默认1)
|
||||||
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
- `ALLOW_USER_DELETE_API_KEYS`: 允许用户删除自己的API Keys(默认false)
|
||||||
@@ -337,6 +341,7 @@ npm run setup # 自动生成密钥并创建管理员账户
|
|||||||
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
11. **速率限制未清理**: rateLimitCleanupService每5分钟自动清理过期限流状态
|
||||||
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
12. **成本统计不准确**: 运行 `npm run init:costs` 初始化成本数据,检查pricingService是否正确加载模型价格
|
||||||
13. **缓存命中率低**: 查看缓存监控统计,调整LRU缓存大小配置
|
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实现的并发计数
|
- `concurrency:{accountId}` - Redis Sorted Set实现的并发计数
|
||||||
- **Webhook配置**:
|
- **Webhook配置**:
|
||||||
- `webhook_config:{id}` - Webhook配置
|
- `webhook_config:{id}` - Webhook配置
|
||||||
|
- **用户消息队列**:
|
||||||
|
- `user_msg_queue_lock:{accountId}` - 用户消息队列锁(当前持有者requestId)
|
||||||
|
- `user_msg_queue_last:{accountId}` - 上次请求完成时间戳(用于延迟计算)
|
||||||
- **系统信息**:
|
- **系统信息**:
|
||||||
- `system_info` - 系统状态缓存
|
- `system_info` - 系统状态缓存
|
||||||
- `model_pricing` - 模型价格数据(pricingService)
|
- `model_pricing` - 模型价格数据(pricingService)
|
||||||
|
|||||||
29
Dockerfile
29
Dockerfile
@@ -1,4 +1,17 @@
|
|||||||
# 🎯 前端构建阶段
|
# 🎯 后端依赖阶段 (与前端构建并行)
|
||||||
|
FROM node:18-alpine AS backend-deps
|
||||||
|
|
||||||
|
# 📁 设置工作目录
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 📦 复制 package 文件
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# 🔽 安装依赖 (生产环境) - 使用 BuildKit 缓存加速
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci --only=production
|
||||||
|
|
||||||
|
# 🎯 前端构建阶段 (与后端依赖并行)
|
||||||
FROM node:18-alpine AS frontend-builder
|
FROM node:18-alpine AS frontend-builder
|
||||||
|
|
||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
@@ -7,8 +20,9 @@ WORKDIR /app/web/admin-spa
|
|||||||
# 📦 复制前端依赖文件
|
# 📦 复制前端依赖文件
|
||||||
COPY web/admin-spa/package*.json ./
|
COPY web/admin-spa/package*.json ./
|
||||||
|
|
||||||
# 🔽 安装前端依赖
|
# 🔽 安装前端依赖 - 使用 BuildKit 缓存加速
|
||||||
RUN npm ci
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci
|
||||||
|
|
||||||
# 📋 复制前端源代码
|
# 📋 复制前端源代码
|
||||||
COPY web/admin-spa/ ./
|
COPY web/admin-spa/ ./
|
||||||
@@ -34,17 +48,16 @@ RUN apk add --no-cache \
|
|||||||
# 📁 设置工作目录
|
# 📁 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 📦 复制 package 文件
|
# 📦 复制 package 文件 (用于版本信息等)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# 🔽 安装依赖 (生产环境)
|
# 📦 从后端依赖阶段复制 node_modules (已预装好)
|
||||||
RUN npm ci --only=production && \
|
COPY --from=backend-deps /app/node_modules ./node_modules
|
||||||
npm cache clean --force
|
|
||||||
|
|
||||||
# 📋 复制应用代码
|
# 📋 复制应用代码
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# 📦 从构建阶段复制前端产物
|
# 📦 从前端构建阶段复制前端产物
|
||||||
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
COPY --from=frontend-builder /app/web/admin-spa/dist /app/web/admin-spa/dist
|
||||||
|
|
||||||
# 🔧 复制并设置启动脚本权限
|
# 🔧 复制并设置启动脚本权限
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ const config = {
|
|||||||
development: {
|
development: {
|
||||||
debug: process.env.DEBUG === 'true',
|
debug: process.env.DEBUG === 'true',
|
||||||
hotReload: process.env.HOT_RELOAD === 'true'
|
hotReload: process.env.HOT_RELOAD === 'true'
|
||||||
|
},
|
||||||
|
|
||||||
|
// 📬 用户消息队列配置
|
||||||
|
userMessageQueue: {
|
||||||
|
enabled: process.env.USER_MESSAGE_QUEUE_ENABLED === 'true', // 默认关闭
|
||||||
|
delayMs: parseInt(process.env.USER_MESSAGE_QUEUE_DELAY_MS) || 100, // 请求间隔(毫秒)
|
||||||
|
timeoutMs: parseInt(process.env.USER_MESSAGE_QUEUE_TIMEOUT_MS) || 60000, // 队列等待超时(毫秒)
|
||||||
|
lockTtlMs: 120000 // 锁租约TTL(毫秒),会在请求期间自动续租以防死锁
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
18
src/app.js
18
src/app.js
@@ -625,6 +625,14 @@ class Application {
|
|||||||
}, 60000) // 每分钟执行一次
|
}, 60000) // 每分钟执行一次
|
||||||
|
|
||||||
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
logger.info('🔢 Concurrency cleanup task started (running every 1 minute)')
|
||||||
|
|
||||||
|
// 📬 启动用户消息队列服务
|
||||||
|
const userMessageQueueService = require('./services/userMessageQueueService')
|
||||||
|
// 先清理服务重启后残留的锁,防止旧锁阻塞新请求
|
||||||
|
userMessageQueueService.cleanupStaleLocks().then(() => {
|
||||||
|
// 然后启动定时清理任务
|
||||||
|
userMessageQueueService.startCleanupTask()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setupGracefulShutdown() {
|
setupGracefulShutdown() {
|
||||||
@@ -661,6 +669,16 @@ class Application {
|
|||||||
logger.error('❌ Error stopping rate limit cleanup service:', error)
|
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 {
|
try {
|
||||||
const costRankService = require('./services/costRankService')
|
const costRankService = require('./services/costRankService')
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ const logger = require('../utils/logger')
|
|||||||
const redis = require('../models/redis')
|
const redis = require('../models/redis')
|
||||||
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
// const { RateLimiterRedis } = require('rate-limiter-flexible') // 暂时未使用
|
||||||
const ClientValidator = require('../validators/clientValidator')
|
const ClientValidator = require('../validators/clientValidator')
|
||||||
|
const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
|
|
||||||
const FALLBACK_CONCURRENCY_CONFIG = {
|
const FALLBACK_CONCURRENCY_CONFIG = {
|
||||||
leaseSeconds: 300,
|
leaseSeconds: 300,
|
||||||
@@ -201,6 +203,53 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔒 检查全局 Claude Code 限制(与 API Key 级别是 OR 逻辑)
|
||||||
|
// 仅对 Claude 服务端点生效 (/api/v1/messages 和 /claude/v1/messages)
|
||||||
|
if (!skipKeyRestrictions) {
|
||||||
|
const normalizedPath = (req.originalUrl || req.path || '').toLowerCase()
|
||||||
|
const isClaudeMessagesEndpoint =
|
||||||
|
normalizedPath.includes('/v1/messages') &&
|
||||||
|
(normalizedPath.startsWith('/api') || normalizedPath.startsWith('/claude'))
|
||||||
|
|
||||||
|
if (isClaudeMessagesEndpoint) {
|
||||||
|
try {
|
||||||
|
const globalClaudeCodeOnly = await claudeRelayConfigService.isClaudeCodeOnlyEnabled()
|
||||||
|
|
||||||
|
// API Key 级别的 Claude Code 限制
|
||||||
|
const keyClaudeCodeOnly =
|
||||||
|
validation.keyData.enableClientRestriction &&
|
||||||
|
Array.isArray(validation.keyData.allowedClients) &&
|
||||||
|
validation.keyData.allowedClients.length === 1 &&
|
||||||
|
validation.keyData.allowedClients.includes('claude_code')
|
||||||
|
|
||||||
|
// OR 逻辑:全局开启 或 API Key 级别限制为仅 claude_code
|
||||||
|
if (globalClaudeCodeOnly || keyClaudeCodeOnly) {
|
||||||
|
const isClaudeCode = ClaudeCodeValidator.validate(req)
|
||||||
|
|
||||||
|
if (!isClaudeCode) {
|
||||||
|
const clientIP = req.ip || req.connection?.remoteAddress || 'unknown'
|
||||||
|
logger.api(
|
||||||
|
`❌ Claude Code client validation failed (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly}) from ${clientIP}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'client_validation_error',
|
||||||
|
message: 'This endpoint only accepts requests from Claude Code CLI'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.api(
|
||||||
|
`✅ Claude Code client validated (global: ${globalClaudeCodeOnly}, key: ${keyClaudeCodeOnly})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error checking Claude Code restriction:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 检查并发限制
|
// 检查并发限制
|
||||||
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
const concurrencyLimit = validation.keyData.concurrencyLimit || 0
|
||||||
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
if (!skipKeyRestrictions && concurrencyLimit > 0) {
|
||||||
@@ -226,8 +275,18 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (currentConcurrency > concurrencyLimit) {
|
if (currentConcurrency > concurrencyLimit) {
|
||||||
// 如果超过限制,立即减少计数
|
// 如果超过限制,立即减少计数(添加 try-catch 防止异常导致并发泄漏)
|
||||||
await redis.decrConcurrency(validation.keyData.id, requestId)
|
try {
|
||||||
|
const newCount = await redis.decrConcurrency(validation.keyData.id, requestId)
|
||||||
|
logger.api(
|
||||||
|
`📉 Decremented concurrency (429 rejected) for key: ${validation.keyData.id} (${validation.keyData.name}), new count: ${newCount}`
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to decrement concurrency after limit exceeded for key ${validation.keyData.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
logger.security(
|
logger.security(
|
||||||
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
`🚦 Concurrency limit exceeded for key: ${validation.keyData.id} (${
|
||||||
validation.keyData.name
|
validation.keyData.name
|
||||||
@@ -249,7 +308,38 @@ const authenticateApiKey = async (req, res, next) => {
|
|||||||
let leaseRenewInterval = null
|
let leaseRenewInterval = null
|
||||||
|
|
||||||
if (renewIntervalMs > 0) {
|
if (renewIntervalMs > 0) {
|
||||||
|
// 🔴 关键修复:添加最大刷新次数限制,防止租约永不过期
|
||||||
|
// 默认最大生存时间为 10 分钟,可通过环境变量配置
|
||||||
|
const maxLifetimeMinutes = parseInt(process.env.CONCURRENCY_MAX_LIFETIME_MINUTES) || 10
|
||||||
|
const maxRefreshCount = Math.ceil((maxLifetimeMinutes * 60 * 1000) / renewIntervalMs)
|
||||||
|
let refreshCount = 0
|
||||||
|
|
||||||
leaseRenewInterval = setInterval(() => {
|
leaseRenewInterval = setInterval(() => {
|
||||||
|
refreshCount++
|
||||||
|
|
||||||
|
// 超过最大刷新次数,强制停止并清理
|
||||||
|
if (refreshCount > maxRefreshCount) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ Lease refresh exceeded max count (${maxRefreshCount}) for key ${validation.keyData.id} (${validation.keyData.name}), forcing cleanup after ${maxLifetimeMinutes} minutes`
|
||||||
|
)
|
||||||
|
// 清理定时器
|
||||||
|
if (leaseRenewInterval) {
|
||||||
|
clearInterval(leaseRenewInterval)
|
||||||
|
leaseRenewInterval = null
|
||||||
|
}
|
||||||
|
// 强制减少并发计数(如果还没减少)
|
||||||
|
if (!concurrencyDecremented) {
|
||||||
|
concurrencyDecremented = true
|
||||||
|
redis.decrConcurrency(validation.keyData.id, requestId).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`Failed to decrement concurrency after max refresh for key ${validation.keyData.id}:`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
redis
|
redis
|
||||||
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
.refreshConcurrencyLease(validation.keyData.id, requestId, leaseSeconds)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|||||||
@@ -2096,6 +2096,246 @@ class RedisClient {
|
|||||||
return await this.getConcurrency(compositeKey)
|
return await this.getConcurrency(compositeKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 并发管理方法(用于管理员手动清理)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有并发状态
|
||||||
|
* @returns {Promise<Array>} 并发状态列表
|
||||||
|
*/
|
||||||
|
async getAllConcurrencyStatus() {
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const keys = await client.keys('concurrency:*')
|
||||||
|
const now = Date.now()
|
||||||
|
const results = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
// 提取 apiKeyId(去掉 concurrency: 前缀)
|
||||||
|
const apiKeyId = key.replace('concurrency:', '')
|
||||||
|
|
||||||
|
// 获取所有成员和分数(过期时间)
|
||||||
|
const members = await client.zrangebyscore(key, now, '+inf', 'WITHSCORES')
|
||||||
|
|
||||||
|
// 解析成员和过期时间
|
||||||
|
const activeRequests = []
|
||||||
|
for (let i = 0; i < members.length; i += 2) {
|
||||||
|
const requestId = members[i]
|
||||||
|
const expireAt = parseInt(members[i + 1])
|
||||||
|
const remainingSeconds = Math.max(0, Math.round((expireAt - now) / 1000))
|
||||||
|
activeRequests.push({
|
||||||
|
requestId,
|
||||||
|
expireAt: new Date(expireAt).toISOString(),
|
||||||
|
remainingSeconds
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取过期的成员数量
|
||||||
|
const expiredCount = await client.zcount(key, '-inf', now)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
apiKeyId,
|
||||||
|
key,
|
||||||
|
activeCount: activeRequests.length,
|
||||||
|
expiredCount,
|
||||||
|
activeRequests
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get all concurrency status:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取特定 API Key 的并发状态详情
|
||||||
|
* @param {string} apiKeyId - API Key ID
|
||||||
|
* @returns {Promise<Object>} 并发状态详情
|
||||||
|
*/
|
||||||
|
async getConcurrencyStatus(apiKeyId) {
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const key = `concurrency:${apiKeyId}`
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// 检查 key 是否存在
|
||||||
|
const exists = await client.exists(key)
|
||||||
|
if (!exists) {
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
key,
|
||||||
|
activeCount: 0,
|
||||||
|
expiredCount: 0,
|
||||||
|
activeRequests: [],
|
||||||
|
exists: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有成员和分数
|
||||||
|
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
|
||||||
|
|
||||||
|
const activeRequests = []
|
||||||
|
const expiredRequests = []
|
||||||
|
|
||||||
|
for (let i = 0; i < allMembers.length; i += 2) {
|
||||||
|
const requestId = allMembers[i]
|
||||||
|
const expireAt = parseInt(allMembers[i + 1])
|
||||||
|
const remainingSeconds = Math.round((expireAt - now) / 1000)
|
||||||
|
|
||||||
|
const requestInfo = {
|
||||||
|
requestId,
|
||||||
|
expireAt: new Date(expireAt).toISOString(),
|
||||||
|
remainingSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expireAt > now) {
|
||||||
|
activeRequests.push(requestInfo)
|
||||||
|
} else {
|
||||||
|
expiredRequests.push(requestInfo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
key,
|
||||||
|
activeCount: activeRequests.length,
|
||||||
|
expiredCount: expiredRequests.length,
|
||||||
|
activeRequests,
|
||||||
|
expiredRequests,
|
||||||
|
exists: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get concurrency status for ${apiKeyId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制清理特定 API Key 的并发计数(忽略租约)
|
||||||
|
* @param {string} apiKeyId - API Key ID
|
||||||
|
* @returns {Promise<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
async forceClearConcurrency(apiKeyId) {
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const key = `concurrency:${apiKeyId}`
|
||||||
|
|
||||||
|
// 获取清理前的状态
|
||||||
|
const beforeCount = await client.zcard(key)
|
||||||
|
|
||||||
|
// 删除整个 key
|
||||||
|
await client.del(key)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
apiKeyId,
|
||||||
|
key,
|
||||||
|
clearedCount: beforeCount,
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to force clear concurrency for ${apiKeyId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 强制清理所有并发计数
|
||||||
|
* @returns {Promise<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
async forceClearAllConcurrency() {
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const keys = await client.keys('concurrency:*')
|
||||||
|
|
||||||
|
let totalCleared = 0
|
||||||
|
const clearedKeys = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
const count = await client.zcard(key)
|
||||||
|
await client.del(key)
|
||||||
|
totalCleared += count
|
||||||
|
clearedKeys.push({
|
||||||
|
key,
|
||||||
|
clearedCount: count
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
keysCleared: keys.length,
|
||||||
|
totalEntriesCleared: totalCleared,
|
||||||
|
clearedKeys,
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to force clear all concurrency:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理过期的并发条目(不影响活跃请求)
|
||||||
|
* @param {string} apiKeyId - API Key ID(可选,不传则清理所有)
|
||||||
|
* @returns {Promise<Object>} 清理结果
|
||||||
|
*/
|
||||||
|
async cleanupExpiredConcurrency(apiKeyId = null) {
|
||||||
|
try {
|
||||||
|
const client = this.getClientSafe()
|
||||||
|
const now = Date.now()
|
||||||
|
let keys
|
||||||
|
|
||||||
|
if (apiKeyId) {
|
||||||
|
keys = [`concurrency:${apiKeyId}`]
|
||||||
|
} else {
|
||||||
|
keys = await client.keys('concurrency:*')
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalCleaned = 0
|
||||||
|
const cleanedKeys = []
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
// 只清理过期的条目
|
||||||
|
const cleaned = await client.zremrangebyscore(key, '-inf', now)
|
||||||
|
if (cleaned > 0) {
|
||||||
|
totalCleaned += cleaned
|
||||||
|
cleanedKeys.push({
|
||||||
|
key,
|
||||||
|
cleanedCount: cleaned
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 key 为空,删除它
|
||||||
|
const remaining = await client.zcard(key)
|
||||||
|
if (remaining === 0) {
|
||||||
|
await client.del(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
keysProcessed: keys.length,
|
||||||
|
keysCleaned: cleanedKeys.length,
|
||||||
|
totalEntriesCleaned: totalCleaned,
|
||||||
|
cleanedKeys,
|
||||||
|
success: true
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to cleanup expired concurrency:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 Basic Redis operations wrapper methods for convenience
|
// 🔧 Basic Redis operations wrapper methods for convenience
|
||||||
async get(key) {
|
async get(key) {
|
||||||
const client = this.getClientSafe()
|
const client = this.getClientSafe()
|
||||||
@@ -2316,4 +2556,249 @@ redisClient.getDateStringInTimezone = getDateStringInTimezone
|
|||||||
redisClient.getHourInTimezone = getHourInTimezone
|
redisClient.getHourInTimezone = getHourInTimezone
|
||||||
redisClient.getWeekStringInTimezone = getWeekStringInTimezone
|
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<boolean>} 是否续租成功(只有锁持有者才能续租)
|
||||||
|
*/
|
||||||
|
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<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
|
module.exports = redisClient
|
||||||
|
|||||||
@@ -919,6 +919,62 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
// 去重(避免日数据和月数据重复计算)
|
// 去重(避免日数据和月数据重复计算)
|
||||||
const uniqueKeys = [...new Set(allKeys)]
|
const uniqueKeys = [...new Set(allKeys)]
|
||||||
|
|
||||||
|
// 获取实时限制数据(窗口数据不受时间范围筛选影响,始终获取当前窗口状态)
|
||||||
|
let dailyCost = 0
|
||||||
|
let currentWindowCost = 0
|
||||||
|
let windowRemainingSeconds = null
|
||||||
|
let windowStartTime = null
|
||||||
|
let windowEndTime = null
|
||||||
|
let allTimeCost = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先获取 API Key 配置,判断是否需要查询限制相关数据
|
||||||
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
|
const rateLimitWindow = parseInt(apiKey?.rateLimitWindow) || 0
|
||||||
|
const dailyCostLimit = parseFloat(apiKey?.dailyCostLimit) || 0
|
||||||
|
const totalCostLimit = parseFloat(apiKey?.totalCostLimit) || 0
|
||||||
|
|
||||||
|
// 只在启用了每日费用限制时查询
|
||||||
|
if (dailyCostLimit > 0) {
|
||||||
|
dailyCost = await redis.getDailyCost(keyId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了总费用限制时查询
|
||||||
|
if (totalCostLimit > 0) {
|
||||||
|
const totalCostKey = `usage:cost:total:${keyId}`
|
||||||
|
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只在启用了窗口限制时查询窗口数据
|
||||||
|
if (rateLimitWindow > 0) {
|
||||||
|
const costCountKey = `rate_limit:cost:${keyId}`
|
||||||
|
const windowStartKey = `rate_limit:window_start:${keyId}`
|
||||||
|
|
||||||
|
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
||||||
|
|
||||||
|
// 获取窗口开始时间和计算剩余时间
|
||||||
|
const windowStart = await client.get(windowStartKey)
|
||||||
|
if (windowStart) {
|
||||||
|
const now = Date.now()
|
||||||
|
windowStartTime = parseInt(windowStart)
|
||||||
|
const windowDuration = rateLimitWindow * 60 * 1000 // 转换为毫秒
|
||||||
|
windowEndTime = windowStartTime + windowDuration
|
||||||
|
|
||||||
|
// 如果窗口还有效
|
||||||
|
if (now < windowEndTime) {
|
||||||
|
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
||||||
|
} else {
|
||||||
|
// 窗口已过期
|
||||||
|
windowRemainingSeconds = 0
|
||||||
|
currentWindowCost = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ 获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有使用数据,返回零值但包含窗口数据
|
||||||
if (uniqueKeys.length === 0) {
|
if (uniqueKeys.length === 0) {
|
||||||
return {
|
return {
|
||||||
requests: 0,
|
requests: 0,
|
||||||
@@ -928,7 +984,14 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
cacheCreateTokens: 0,
|
cacheCreateTokens: 0,
|
||||||
cacheReadTokens: 0,
|
cacheReadTokens: 0,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
formattedCost: '$0.00'
|
formattedCost: '$0.00',
|
||||||
|
// 实时限制数据(始终返回,不受时间范围影响)
|
||||||
|
dailyCost,
|
||||||
|
currentWindowCost,
|
||||||
|
windowRemainingSeconds,
|
||||||
|
windowStartTime,
|
||||||
|
windowEndTime,
|
||||||
|
allTimeCost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1029,52 +1092,6 @@ async function calculateKeyStats(keyId, timeRange, startDate, endDate) {
|
|||||||
|
|
||||||
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
const tokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||||
|
|
||||||
// 获取实时限制数据
|
|
||||||
let dailyCost = 0
|
|
||||||
let currentWindowCost = 0
|
|
||||||
let windowRemainingSeconds = null
|
|
||||||
let windowStartTime = null
|
|
||||||
let windowEndTime = null
|
|
||||||
let allTimeCost = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取当日费用
|
|
||||||
dailyCost = await redis.getDailyCost(keyId)
|
|
||||||
|
|
||||||
// 获取历史总费用(用于总费用限制进度条,不受时间范围影响)
|
|
||||||
const totalCostKey = `usage:cost:total:${keyId}`
|
|
||||||
allTimeCost = parseFloat((await client.get(totalCostKey)) || '0')
|
|
||||||
|
|
||||||
// 获取 API Key 配置信息以判断是否需要窗口数据
|
|
||||||
const apiKey = await redis.getApiKey(keyId)
|
|
||||||
if (apiKey && apiKey.rateLimitWindow > 0) {
|
|
||||||
const costCountKey = `rate_limit:cost:${keyId}`
|
|
||||||
const windowStartKey = `rate_limit:window_start:${keyId}`
|
|
||||||
|
|
||||||
currentWindowCost = parseFloat((await client.get(costCountKey)) || '0')
|
|
||||||
|
|
||||||
// 获取窗口开始时间和计算剩余时间
|
|
||||||
const windowStart = await client.get(windowStartKey)
|
|
||||||
if (windowStart) {
|
|
||||||
const now = Date.now()
|
|
||||||
windowStartTime = parseInt(windowStart)
|
|
||||||
const windowDuration = apiKey.rateLimitWindow * 60 * 1000 // 转换为毫秒
|
|
||||||
windowEndTime = windowStartTime + windowDuration
|
|
||||||
|
|
||||||
// 如果窗口还有效
|
|
||||||
if (now < windowEndTime) {
|
|
||||||
windowRemainingSeconds = Math.max(0, Math.floor((windowEndTime - now) / 1000))
|
|
||||||
} else {
|
|
||||||
// 窗口已过期
|
|
||||||
windowRemainingSeconds = 0
|
|
||||||
currentWindowCost = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.debug(`获取实时限制数据失败 (key: ${keyId}):`, error.message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests: totalRequests,
|
requests: totalRequests,
|
||||||
tokens,
|
tokens,
|
||||||
|
|||||||
175
src/routes/admin/claudeRelayConfig.js
Normal file
175
src/routes/admin/claudeRelayConfig.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置 API 路由
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const { authenticateAdmin } = require('../../middleware/auth')
|
||||||
|
const claudeRelayConfigService = require('../../services/claudeRelayConfigService')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
const router = express.Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config
|
||||||
|
* 获取 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const config = await claudeRelayConfigService.getConfig()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
config
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /admin/claude-relay-config
|
||||||
|
* 更新 Claude 转发配置
|
||||||
|
*/
|
||||||
|
router.put('/claude-relay-config', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage,
|
||||||
|
sessionBindingTtlDays,
|
||||||
|
userMessageQueueEnabled,
|
||||||
|
userMessageQueueDelayMs,
|
||||||
|
userMessageQueueTimeoutMs
|
||||||
|
} = req.body
|
||||||
|
|
||||||
|
// 验证输入
|
||||||
|
if (claudeCodeOnlyEnabled !== undefined && typeof claudeCodeOnlyEnabled !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'claudeCodeOnlyEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
globalSessionBindingEnabled !== undefined &&
|
||||||
|
typeof globalSessionBindingEnabled !== 'boolean'
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: 'globalSessionBindingEnabled must be a boolean' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
if (typeof sessionBindingErrorMessage !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'sessionBindingErrorMessage must be a string' })
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage.length > 500) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingErrorMessage must be less than 500 characters' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionBindingTtlDays !== undefined) {
|
||||||
|
if (
|
||||||
|
typeof sessionBindingTtlDays !== 'number' ||
|
||||||
|
sessionBindingTtlDays < 1 ||
|
||||||
|
sessionBindingTtlDays > 365
|
||||||
|
) {
|
||||||
|
return res
|
||||||
|
.status(400)
|
||||||
|
.json({ error: 'sessionBindingTtlDays must be a number between 1 and 365' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户消息队列配置
|
||||||
|
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) {
|
||||||
|
updateData.claudeCodeOnlyEnabled = claudeCodeOnlyEnabled
|
||||||
|
}
|
||||||
|
if (globalSessionBindingEnabled !== undefined) {
|
||||||
|
updateData.globalSessionBindingEnabled = globalSessionBindingEnabled
|
||||||
|
}
|
||||||
|
if (sessionBindingErrorMessage !== undefined) {
|
||||||
|
updateData.sessionBindingErrorMessage = sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
req.admin?.username || 'unknown'
|
||||||
|
)
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Configuration updated successfully',
|
||||||
|
config: updatedConfig
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to update configuration',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/claude-relay-config/session-bindings
|
||||||
|
* 获取会话绑定统计
|
||||||
|
*/
|
||||||
|
router.get('/claude-relay-config/session-bindings', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const stats = await claudeRelayConfigService.getSessionBindingStats()
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
data: stats
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return res.status(500).json({
|
||||||
|
error: 'Failed to get session binding statistics',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
145
src/routes/admin/concurrency.js
Normal file
145
src/routes/admin/concurrency.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
/**
|
||||||
|
* 并发管理 API 路由
|
||||||
|
* 提供并发状态查看和手动清理功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
const express = require('express')
|
||||||
|
const router = express.Router()
|
||||||
|
const redis = require('../../models/redis')
|
||||||
|
const logger = require('../../utils/logger')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency
|
||||||
|
* 获取所有并发状态
|
||||||
|
*/
|
||||||
|
router.get('/concurrency', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const status = await redis.getAllConcurrencyStatus()
|
||||||
|
|
||||||
|
// 计算汇总统计
|
||||||
|
const summary = {
|
||||||
|
totalKeys: status.length,
|
||||||
|
totalActiveRequests: status.reduce((sum, s) => sum + s.activeCount, 0),
|
||||||
|
totalExpiredRequests: status.reduce((sum, s) => sum + s.expiredCount, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
summary,
|
||||||
|
concurrencyStatus: status
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get concurrency status:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get concurrency status',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/concurrency/:apiKeyId
|
||||||
|
* 获取特定 API Key 的并发状态详情
|
||||||
|
*/
|
||||||
|
router.get('/concurrency/:apiKeyId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
const status = await redis.getConcurrencyStatus(apiKeyId)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
concurrencyStatus: status
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get concurrency status for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to get concurrency status',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency/:apiKeyId
|
||||||
|
* 强制清理特定 API Key 的并发计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency/:apiKeyId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.params
|
||||||
|
const result = await redis.forceClearConcurrency(apiKeyId)
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`🧹 Admin ${req.admin?.username || 'unknown'} force cleared concurrency for key ${apiKeyId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Successfully cleared concurrency for API key ${apiKeyId}`,
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to clear concurrency for ${req.params.apiKeyId}:`, error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /admin/concurrency
|
||||||
|
* 强制清理所有并发计数
|
||||||
|
*/
|
||||||
|
router.delete('/concurrency', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await redis.forceClearAllConcurrency()
|
||||||
|
|
||||||
|
logger.warn(`🧹 Admin ${req.admin?.username || 'unknown'} force cleared ALL concurrency`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Successfully cleared all concurrency',
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to clear all concurrency:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to clear all concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/concurrency/cleanup
|
||||||
|
* 清理过期的并发条目(不影响活跃请求)
|
||||||
|
*/
|
||||||
|
router.post('/concurrency/cleanup', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { apiKeyId } = req.body
|
||||||
|
const result = await redis.cleanupExpiredConcurrency(apiKeyId || null)
|
||||||
|
|
||||||
|
logger.info(`🧹 Admin ${req.admin?.username || 'unknown'} cleaned up expired concurrency`)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: apiKeyId
|
||||||
|
? `Successfully cleaned up expired concurrency for API key ${apiKeyId}`
|
||||||
|
: 'Successfully cleaned up all expired concurrency',
|
||||||
|
result
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to cleanup expired concurrency:', error)
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Failed to cleanup expired concurrency',
|
||||||
|
message: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
module.exports = router
|
||||||
@@ -22,6 +22,8 @@ const droidAccountsRoutes = require('./droidAccounts')
|
|||||||
const dashboardRoutes = require('./dashboard')
|
const dashboardRoutes = require('./dashboard')
|
||||||
const usageStatsRoutes = require('./usageStats')
|
const usageStatsRoutes = require('./usageStats')
|
||||||
const systemRoutes = require('./system')
|
const systemRoutes = require('./system')
|
||||||
|
const concurrencyRoutes = require('./concurrency')
|
||||||
|
const claudeRelayConfigRoutes = require('./claudeRelayConfig')
|
||||||
|
|
||||||
// 挂载所有子路由
|
// 挂载所有子路由
|
||||||
// 使用完整路径的模块(直接挂载到根路径)
|
// 使用完整路径的模块(直接挂载到根路径)
|
||||||
@@ -35,6 +37,8 @@ router.use('/', droidAccountsRoutes)
|
|||||||
router.use('/', dashboardRoutes)
|
router.use('/', dashboardRoutes)
|
||||||
router.use('/', usageStatsRoutes)
|
router.use('/', usageStatsRoutes)
|
||||||
router.use('/', systemRoutes)
|
router.use('/', systemRoutes)
|
||||||
|
router.use('/', concurrencyRoutes)
|
||||||
|
router.use('/', claudeRelayConfigRoutes)
|
||||||
|
|
||||||
// 使用相对路径的模块(需要指定基础路径前缀)
|
// 使用相对路径的模块(需要指定基础路径前缀)
|
||||||
router.use('/account-groups', accountGroupsRoutes)
|
router.use('/account-groups', accountGroupsRoutes)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../utils/logger')
|
|||||||
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { getEffectiveModel, parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
const sessionHelper = require('../utils/sessionHelper')
|
const sessionHelper = require('../utils/sessionHelper')
|
||||||
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
const { updateRateLimitCounters } = require('../utils/rateLimitHelper')
|
||||||
|
const claudeRelayConfigService = require('../services/claudeRelayConfigService')
|
||||||
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
const { sanitizeUpstreamError } = require('../utils/errorSanitizer')
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
@@ -37,6 +38,73 @@ function queueRateLimitUpdate(rateLimitInfo, usageSummary, model, context = '')
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否为旧会话(污染的会话)
|
||||||
|
* Claude Code 发送的请求特点:
|
||||||
|
* - messages 数组通常只有 1 个元素
|
||||||
|
* - 历史对话记录嵌套在单个 message 的 content 数组中
|
||||||
|
* - content 数组中包含 <system-reminder> 开头的系统注入内容
|
||||||
|
*
|
||||||
|
* 污染会话的特征:
|
||||||
|
* 1. messages.length > 1
|
||||||
|
* 2. messages.length === 1 但 content 中有多个用户输入
|
||||||
|
* 3. "warmup" 请求:单条简单消息 + 无 tools(真正新会话会带 tools)
|
||||||
|
*
|
||||||
|
* @param {Object} body - 请求体
|
||||||
|
* @returns {boolean} 是否为旧会话
|
||||||
|
*/
|
||||||
|
function isOldSession(body) {
|
||||||
|
const messages = body?.messages
|
||||||
|
const tools = body?.tools
|
||||||
|
|
||||||
|
if (!messages || messages.length === 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 多条消息 = 旧会话
|
||||||
|
if (messages.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 单条消息,分析 content
|
||||||
|
const firstMessage = messages[0]
|
||||||
|
const content = firstMessage?.content
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是字符串,只有一条输入,需要检查 tools
|
||||||
|
if (typeof content === 'string') {
|
||||||
|
// 有 tools = 正常新会话,无 tools = 可疑
|
||||||
|
return !tools || tools.length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 content 是数组,统计非 system-reminder 的元素
|
||||||
|
if (Array.isArray(content)) {
|
||||||
|
const userInputs = content.filter((item) => {
|
||||||
|
if (item.type !== 'text') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const text = item.text || ''
|
||||||
|
// 剔除以 <system-reminder> 开头的
|
||||||
|
return !text.trimStart().startsWith('<system-reminder>')
|
||||||
|
})
|
||||||
|
|
||||||
|
// 多个用户输入 = 旧会话
|
||||||
|
if (userInputs.length > 1) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warmup 检测:单个消息 + 无 tools = 旧会话
|
||||||
|
if (userInputs.length === 1 && (!tools || tools.length === 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 共享的消息处理函数
|
// 🔧 共享的消息处理函数
|
||||||
async function handleMessagesRequest(req, res) {
|
async function handleMessagesRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -141,6 +209,56 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证
|
||||||
|
let forcedAccount = null
|
||||||
|
let needSessionBinding = false
|
||||||
|
let originalSessionIdForBinding = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed: ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已有绑定,使用绑定的账户
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccount = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session ${originalSessionId}: ${forcedAccount.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记需要在调度成功后建立绑定
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBinding = true
|
||||||
|
originalSessionIdForBinding = originalSessionId
|
||||||
|
logger.api(`📝 New session detected, will create binding: ${originalSessionId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check:', error)
|
||||||
|
// 配置服务出错时不阻断请求
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -149,10 +267,21 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccount
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// 处理会话绑定账户不可用的错误
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -170,6 +299,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBinding &&
|
||||||
|
originalSessionIdForBinding &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 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: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBinding,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding:`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务并调用
|
// 根据账号类型选择对应的转发服务并调用
|
||||||
if (accountType === 'claude-official') {
|
if (accountType === 'claude-official') {
|
||||||
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
// 官方Claude账号使用原有的转发服务(会自己选择账号)
|
||||||
@@ -503,6 +666,55 @@ async function handleMessagesRequest(req, res) {
|
|||||||
// 生成会话哈希用于sticky会话
|
// 生成会话哈希用于sticky会话
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|
||||||
|
// 🔒 全局会话绑定验证(非流式)
|
||||||
|
let forcedAccountNonStream = null
|
||||||
|
let needSessionBindingNonStream = false
|
||||||
|
let originalSessionIdForBindingNonStream = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalBindingEnabled = await claudeRelayConfigService.isGlobalSessionBindingEnabled()
|
||||||
|
|
||||||
|
if (globalBindingEnabled) {
|
||||||
|
const originalSessionId = claudeRelayConfigService.extractOriginalSessionId(req.body)
|
||||||
|
|
||||||
|
if (originalSessionId) {
|
||||||
|
const validation = await claudeRelayConfigService.validateNewSession(
|
||||||
|
req.body,
|
||||||
|
originalSessionId
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!validation.valid) {
|
||||||
|
logger.api(
|
||||||
|
`❌ Session binding validation failed (non-stream): ${validation.code} for session ${originalSessionId}`
|
||||||
|
)
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: validation.error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.binding) {
|
||||||
|
forcedAccountNonStream = validation.binding
|
||||||
|
logger.api(
|
||||||
|
`🔗 Using bound account for session (non-stream) ${originalSessionId}: ${forcedAccountNonStream.accountId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validation.isNewSession) {
|
||||||
|
needSessionBindingNonStream = true
|
||||||
|
originalSessionIdForBindingNonStream = originalSessionId
|
||||||
|
logger.api(
|
||||||
|
`📝 New session detected (non-stream), will create binding: ${originalSessionId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Error in global session binding check (non-stream):', error)
|
||||||
|
}
|
||||||
|
|
||||||
// 使用统一调度选择账号(传递请求的模型)
|
// 使用统一调度选择账号(传递请求的模型)
|
||||||
const requestedModel = req.body.model
|
const requestedModel = req.body.model
|
||||||
let accountId
|
let accountId
|
||||||
@@ -511,10 +723,20 @@ async function handleMessagesRequest(req, res) {
|
|||||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||||
req.apiKey,
|
req.apiKey,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
requestedModel
|
requestedModel,
|
||||||
|
forcedAccountNonStream
|
||||||
)
|
)
|
||||||
;({ accountId, accountType } = selection)
|
;({ accountId, accountType } = selection)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error.code === 'SESSION_BINDING_ACCOUNT_UNAVAILABLE') {
|
||||||
|
const errorMessage = await claudeRelayConfigService.getSessionBindingErrorMessage()
|
||||||
|
return res.status(403).json({
|
||||||
|
error: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: errorMessage
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
if (error.code === 'CLAUDE_DEDICATED_RATE_LIMITED') {
|
||||||
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
const limitMessage = claudeRelayService._buildStandardRateLimitMessage(
|
||||||
error.rateLimitEndAt
|
error.rateLimitEndAt
|
||||||
@@ -527,6 +749,40 @@ async function handleMessagesRequest(req, res) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔗 在成功调度后建立会话绑定(非流式,仅 claude-official 类型)
|
||||||
|
// claude-official 只接受:1) 新会话 2) 已绑定的会话
|
||||||
|
if (
|
||||||
|
needSessionBindingNonStream &&
|
||||||
|
originalSessionIdForBindingNonStream &&
|
||||||
|
accountId &&
|
||||||
|
accountType === 'claude-official'
|
||||||
|
) {
|
||||||
|
// 🚫 检测旧会话(污染的会话)
|
||||||
|
if (isOldSession(req.body)) {
|
||||||
|
const cfg = await claudeRelayConfigService.getConfig()
|
||||||
|
logger.warn(
|
||||||
|
`🚫 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: {
|
||||||
|
type: 'session_binding_error',
|
||||||
|
message: cfg.sessionBindingErrorMessage || '你的本地session已污染,请清理后使用。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建绑定
|
||||||
|
try {
|
||||||
|
await claudeRelayConfigService.setOriginalSessionBinding(
|
||||||
|
originalSessionIdForBindingNonStream,
|
||||||
|
accountId,
|
||||||
|
accountType
|
||||||
|
)
|
||||||
|
} catch (bindingError) {
|
||||||
|
logger.warn(`⚠️ Failed to create session binding (non-stream):`, bindingError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 根据账号类型选择对应的转发服务
|
// 根据账号类型选择对应的转发服务
|
||||||
let response
|
let response
|
||||||
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
logger.debug(`[DEBUG] Request query params: ${JSON.stringify(req.query)}`)
|
||||||
@@ -824,7 +1080,8 @@ router.get('/v1/models', authenticateApiKey, async (req, res) => {
|
|||||||
// 可选:根据 API Key 的模型限制过滤
|
// 可选:根据 API Key 的模型限制过滤
|
||||||
let filteredModels = models
|
let filteredModels = models
|
||||||
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
if (req.apiKey.enableModelRestriction && req.apiKey.restrictedModels?.length > 0) {
|
||||||
filteredModels = models.filter((model) => req.apiKey.restrictedModels.includes(model.id))
|
// 将 restrictedModels 视为黑名单:过滤掉受限模型
|
||||||
|
filteredModels = models.filter((model) => !req.apiKey.restrictedModels.includes(model.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@@ -965,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}`)
|
logger.info(`🔢 Processing token count request for key: ${req.apiKey.name}`)
|
||||||
|
|
||||||
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
const sessionHash = sessionHelper.generateSessionHash(req.body)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const {
|
|||||||
const { fromEnv } = require('@aws-sdk/credential-providers')
|
const { fromEnv } = require('@aws-sdk/credential-providers')
|
||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class BedrockRelayService {
|
class BedrockRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -69,7 +70,70 @@ class BedrockRelayService {
|
|||||||
|
|
||||||
// 处理非流式请求
|
// 处理非流式请求
|
||||||
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
async handleNonStreamRequest(requestBody, bedrockAccount = null) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -106,12 +170,95 @@ class BedrockRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('❌ Bedrock非流式请求失败:', error)
|
logger.error('❌ Bedrock非流式请求失败:', error)
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for Bedrock account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理流式请求
|
// 处理流式请求
|
||||||
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
async handleStreamRequest(requestBody, bedrockAccount = null, res) {
|
||||||
|
const accountId = bedrockAccount?.id
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const modelId = this._selectModel(requestBody, bedrockAccount)
|
const modelId = this._selectModel(requestBody, bedrockAccount)
|
||||||
const region = this._selectRegion(modelId, bedrockAccount)
|
const region = this._selectRegion(modelId, bedrockAccount)
|
||||||
const client = this._getBedrockClient(region, bedrockAccount)
|
const client = this._getBedrockClient(region, bedrockAccount)
|
||||||
@@ -191,6 +338,21 @@ class BedrockRelayService {
|
|||||||
res.end()
|
res.end()
|
||||||
|
|
||||||
throw this._handleBedrockError(error)
|
throw this._handleBedrockError(error)
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for Bedrock stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ const ccrAccountService = require('./ccrAccountService')
|
|||||||
const logger = require('../utils/logger')
|
const logger = require('../utils/logger')
|
||||||
const config = require('../../config/config')
|
const config = require('../../config/config')
|
||||||
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
const { parseVendorPrefixedModel } = require('../utils/modelHelper')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class CcrRelayService {
|
class CcrRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -21,8 +22,69 @@ class CcrRelayService {
|
|||||||
) {
|
) {
|
||||||
let abortController = null
|
let abortController = null
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -233,6 +295,21 @@ class CcrRelayService {
|
|||||||
)
|
)
|
||||||
|
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for CCR account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +325,76 @@ class CcrRelayService {
|
|||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
let account = null
|
let account = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await ccrAccountService.getAccount(accountId)
|
account = await ccrAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -304,6 +450,21 @@ class CcrRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
logger.error(`❌ CCR stream relay failed (Account: ${account?.name || accountId}):`, error)
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for CCR stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const {
|
|||||||
sanitizeErrorMessage,
|
sanitizeErrorMessage,
|
||||||
isAccountDisabledError
|
isAccountDisabledError
|
||||||
} = require('../utils/errorSanitizer')
|
} = require('../utils/errorSanitizer')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class ClaudeConsoleRelayService {
|
class ClaudeConsoleRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -29,8 +30,73 @@ class ClaudeConsoleRelayService {
|
|||||||
let account = null
|
let account = null
|
||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -366,6 +432,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +465,73 @@ class ClaudeConsoleRelayService {
|
|||||||
const requestId = uuidv4() // 用于并发追踪
|
const requestId = uuidv4() // 用于并发追踪
|
||||||
let concurrencyAcquired = false
|
let concurrencyAcquired = false
|
||||||
let leaseRefreshInterval = null // 租约刷新定时器
|
let leaseRefreshInterval = null // 租约刷新定时器
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
|
||||||
try {
|
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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for console account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
account = await claudeConsoleAccountService.getAccount(accountId)
|
account = await claudeConsoleAccountService.getAccount(accountId)
|
||||||
if (!account) {
|
if (!account) {
|
||||||
@@ -517,6 +663,21 @@ class ClaudeConsoleRelayService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && accountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(accountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for stream account ${accountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
442
src/services/claudeRelayConfigService.js
Normal file
442
src/services/claudeRelayConfigService.js
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
/**
|
||||||
|
* Claude 转发配置服务
|
||||||
|
* 管理全局 Claude Code 限制和会话绑定配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
const redis = require('../models/redis')
|
||||||
|
const logger = require('../utils/logger')
|
||||||
|
|
||||||
|
const CONFIG_KEY = 'claude_relay_config'
|
||||||
|
const SESSION_BINDING_PREFIX = 'original_session_binding:'
|
||||||
|
|
||||||
|
// 默认配置
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30, // 会话绑定 TTL(天),默认30天
|
||||||
|
// 用户消息队列配置
|
||||||
|
userMessageQueueEnabled: false, // 是否启用用户消息队列(默认关闭)
|
||||||
|
userMessageQueueDelayMs: 100, // 请求间隔(毫秒)
|
||||||
|
userMessageQueueTimeoutMs: 60000, // 队列超时(毫秒)
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 内存缓存(避免频繁 Redis 查询)
|
||||||
|
let configCache = null
|
||||||
|
let configCacheTime = 0
|
||||||
|
const CONFIG_CACHE_TTL = 60000 // 1分钟缓存
|
||||||
|
|
||||||
|
class ClaudeRelayConfigService {
|
||||||
|
/**
|
||||||
|
* 从 metadata.user_id 中提取原始 sessionId
|
||||||
|
* 格式: user_{64位十六进制}_account__session_{uuid}
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @returns {string|null} 原始 sessionId 或 null
|
||||||
|
*/
|
||||||
|
extractOriginalSessionId(requestBody) {
|
||||||
|
if (!requestBody?.metadata?.user_id) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = requestBody.metadata.user_id
|
||||||
|
const match = userId.match(/session_([a-f0-9-]{36})$/i)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取配置(带缓存)
|
||||||
|
* @returns {Promise<Object>} 配置对象
|
||||||
|
*/
|
||||||
|
async getConfig() {
|
||||||
|
try {
|
||||||
|
// 检查缓存
|
||||||
|
if (configCache && Date.now() - configCacheTime < CONFIG_CACHE_TTL) {
|
||||||
|
return configCache
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
logger.warn('⚠️ Redis not connected, using default config')
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await client.get(CONFIG_KEY)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
configCache = { ...DEFAULT_CONFIG, ...JSON.parse(data) }
|
||||||
|
} else {
|
||||||
|
configCache = { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
return configCache
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get Claude relay config:', error)
|
||||||
|
return { ...DEFAULT_CONFIG }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新配置
|
||||||
|
* @param {Object} newConfig - 新配置
|
||||||
|
* @param {string} updatedBy - 更新者
|
||||||
|
* @returns {Promise<Object>} 更新后的配置
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig, updatedBy) {
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const currentConfig = await this.getConfig()
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...currentConfig,
|
||||||
|
...newConfig,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
updatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.set(CONFIG_KEY, JSON.stringify(updatedConfig))
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
configCache = updatedConfig
|
||||||
|
configCacheTime = Date.now()
|
||||||
|
|
||||||
|
logger.info(`✅ Claude relay config updated by ${updatedBy}:`, {
|
||||||
|
claudeCodeOnlyEnabled: updatedConfig.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: updatedConfig.globalSessionBindingEnabled
|
||||||
|
})
|
||||||
|
|
||||||
|
return updatedConfig
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to update Claude relay config:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局 Claude Code 限制
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isClaudeCodeOnlyEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.claudeCodeOnlyEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否启用全局会话绑定
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isGlobalSessionBindingEnabled() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.globalSessionBindingEnabled === true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定错误信息
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingErrorMessage() {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
return cfg.sessionBindingErrorMessage || DEFAULT_CONFIG.sessionBindingErrorMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object|null>} 绑定信息或 null
|
||||||
|
*/
|
||||||
|
async getOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
const data = await client.get(key)
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
return JSON.parse(data)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to get session binding for ${originalSessionId}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型
|
||||||
|
* @returns {Promise<Object>} 绑定信息
|
||||||
|
*/
|
||||||
|
async setOriginalSessionBinding(originalSessionId, accountId, accountType) {
|
||||||
|
if (!originalSessionId || !accountId || !accountType) {
|
||||||
|
throw new Error('Invalid parameters for session binding')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
const binding = {
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
lastUsedAt: new Date().toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding created: ${originalSessionId} -> ${accountId} (${accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
return binding
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to set session binding for ${originalSessionId}:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话绑定的最后使用时间(续期)
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async touchOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
if (!binding) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.lastUsedAt = new Date().toISOString()
|
||||||
|
|
||||||
|
const client = redis.getClientSafe()
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
|
||||||
|
// 使用配置的 TTL(默认30天)
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
const ttlDays = cfg.sessionBindingTtlDays || DEFAULT_CONFIG.sessionBindingTtlDays
|
||||||
|
const ttlSeconds = Math.floor(ttlDays * 24 * 3600)
|
||||||
|
|
||||||
|
await client.set(key, JSON.stringify(binding), 'EX', ttlSeconds)
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`⚠️ Failed to touch session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查原始会话是否已绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async isOriginalSessionBound(originalSessionId) {
|
||||||
|
const binding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
return binding !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证绑定的账户是否可用
|
||||||
|
* @param {Object} binding - 绑定信息
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async validateBoundAccount(binding) {
|
||||||
|
if (!binding || !binding.accountId || !binding.accountType) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { accountType } = binding
|
||||||
|
const { accountId } = binding
|
||||||
|
|
||||||
|
let accountService
|
||||||
|
switch (accountType) {
|
||||||
|
case 'claude-official':
|
||||||
|
accountService = require('./claudeAccountService')
|
||||||
|
break
|
||||||
|
case 'claude-console':
|
||||||
|
accountService = require('./claudeConsoleAccountService')
|
||||||
|
break
|
||||||
|
case 'bedrock':
|
||||||
|
accountService = require('./bedrockAccountService')
|
||||||
|
break
|
||||||
|
case 'ccr':
|
||||||
|
accountService = require('./ccrAccountService')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
logger.warn(`Unknown account type for validation: ${accountType}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountService.getAccount(accountId)
|
||||||
|
|
||||||
|
// getAccount() 直接返回账户数据对象或 null,不是 { success, data } 格式
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding account not found: ${accountId} (${accountType})`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountData = account
|
||||||
|
|
||||||
|
// 检查账户是否激活
|
||||||
|
if (accountData.isActive === false || accountData.isActive === 'false') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account not active: ${accountId} (${accountType}), isActive: ${accountData.isActive}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账户状态(如果存在)
|
||||||
|
if (accountData.status && accountData.status === 'error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding account has error status: ${accountId} (${accountType}), status: ${accountData.status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to validate bound account ${binding.accountId}:`, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证新会话请求
|
||||||
|
* @param {Object} requestBody - 请求体
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
* @returns {Promise<Object>} { valid: boolean, error?: string, binding?: object, isNewSession?: boolean }
|
||||||
|
*/
|
||||||
|
async validateNewSession(requestBody, originalSessionId) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
|
||||||
|
if (!cfg.globalSessionBindingEnabled) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有 sessionId,跳过验证(可能是非 Claude Code 客户端)
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingBinding = await this.getOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 如果会话已存在绑定
|
||||||
|
if (existingBinding) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (existingBinding.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${existingBinding.accountType}`
|
||||||
|
)
|
||||||
|
return { valid: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const accountValid = await this.validateBoundAccount(existingBinding)
|
||||||
|
|
||||||
|
if (!accountValid) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: cfg.sessionBindingErrorMessage,
|
||||||
|
code: 'SESSION_BINDING_INVALID'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 续期
|
||||||
|
await this.touchOriginalSessionBinding(originalSessionId)
|
||||||
|
|
||||||
|
// 已有绑定,允许继续(这是正常的会话延续)
|
||||||
|
return { valid: true, binding: existingBinding }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 没有绑定,是新会话
|
||||||
|
// 注意:messages.length 检查在此处无法执行,因为我们不知道最终会调度到哪种账户类型
|
||||||
|
// 绑定会在调度后创建,仅针对 claude-official 账户
|
||||||
|
return { valid: true, isNewSession: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除原始会话绑定
|
||||||
|
* @param {string} originalSessionId - 原始会话ID
|
||||||
|
*/
|
||||||
|
async deleteOriginalSessionBinding(originalSessionId) {
|
||||||
|
if (!originalSessionId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${SESSION_BINDING_PREFIX}${originalSessionId}`
|
||||||
|
await client.del(key)
|
||||||
|
logger.info(`🗑️ Session binding deleted: ${originalSessionId}`)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ Failed to delete session binding for ${originalSessionId}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话绑定统计
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getSessionBindingStats() {
|
||||||
|
try {
|
||||||
|
const client = redis.getClient()
|
||||||
|
if (!client) {
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = '0'
|
||||||
|
let count = 0
|
||||||
|
|
||||||
|
do {
|
||||||
|
const [newCursor, keys] = await client.scan(
|
||||||
|
cursor,
|
||||||
|
'MATCH',
|
||||||
|
`${SESSION_BINDING_PREFIX}*`,
|
||||||
|
'COUNT',
|
||||||
|
100
|
||||||
|
)
|
||||||
|
cursor = newCursor
|
||||||
|
count += keys.length
|
||||||
|
} while (cursor !== '0')
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalBindings: count
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to get session binding stats:', error)
|
||||||
|
return { totalBindings: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除配置缓存(用于测试或强制刷新)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
configCache = null
|
||||||
|
configCacheTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new ClaudeRelayConfigService()
|
||||||
@@ -15,10 +15,11 @@ const ClaudeCodeValidator = require('../validators/clients/claudeCodeValidator')
|
|||||||
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
const { formatDateWithTimezone } = require('../utils/dateHelper')
|
||||||
const requestIdentityService = require('./requestIdentityService')
|
const requestIdentityService = require('./requestIdentityService')
|
||||||
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
const { createClaudeTestPayload } = require('../utils/testPayloadHelper')
|
||||||
|
const userMessageQueueService = require('./userMessageQueueService')
|
||||||
|
|
||||||
class ClaudeRelayService {
|
class ClaudeRelayService {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.claudeApiUrl = config.claude.apiUrl
|
this.claudeApiUrl = 'https://api.anthropic.com/v1/messages?beta=true'
|
||||||
this.apiVersion = config.claude.apiVersion
|
this.apiVersion = config.claude.apiVersion
|
||||||
this.betaHeader = config.claude.betaHeader
|
this.betaHeader = config.claude.betaHeader
|
||||||
this.systemPrompt = config.claude.systemPrompt
|
this.systemPrompt = config.claude.systemPrompt
|
||||||
@@ -148,6 +149,10 @@ class ClaudeRelayService {
|
|||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
let upstreamRequest = null
|
let upstreamRequest = null
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
let selectedAccountId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调试日志:查看API Key数据
|
// 调试日志:查看API Key数据
|
||||||
@@ -192,11 +197,74 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
const { accountId } = accountSelection
|
const { accountId } = accountSelection
|
||||||
const { accountType } = accountSelection
|
const { accountType } = accountSelection
|
||||||
|
selectedAccountId = accountId
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
|
`📤 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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for account ${accountId}, requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 获取账户信息
|
// 获取账户信息
|
||||||
let account = await claudeAccountService.getAccount(accountId)
|
let account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
@@ -539,6 +607,21 @@ class ClaudeRelayService {
|
|||||||
error.message
|
error.message
|
||||||
)
|
)
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for account ${selectedAccountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -878,11 +961,102 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 🔧 过滤客户端请求头
|
// 🔧 过滤客户端请求头
|
||||||
_filterClientHeaders(clientHeaders) {
|
_filterClientHeaders(clientHeaders) {
|
||||||
// 使用统一的 headerFilter 工具类 - 移除 CDN、浏览器和代理相关 headers
|
// 使用统一的 headerFilter 工具类
|
||||||
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
// 同时伪装成正常的直接客户端请求,避免触发上游 API 的安全检查
|
||||||
return filterForClaude(clientHeaders)
|
return filterForClaude(clientHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔧 准备请求头和 payload(抽离公共逻辑)
|
||||||
|
async _prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
options = {}
|
||||||
|
) {
|
||||||
|
const { account, accountType, sessionHash, requestOptions = {}, isStream = false } = options
|
||||||
|
|
||||||
|
// 获取统一的 User-Agent
|
||||||
|
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
||||||
|
|
||||||
|
// 获取过滤后的客户端 headers
|
||||||
|
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
||||||
|
|
||||||
|
// 判断是否是真实的 Claude Code 请求
|
||||||
|
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
||||||
|
|
||||||
|
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
||||||
|
let finalHeaders = { ...filteredHeaders }
|
||||||
|
let requestPayload = body
|
||||||
|
|
||||||
|
if (!isRealClaudeCode) {
|
||||||
|
// 获取该账号存储的 Claude Code headers
|
||||||
|
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
||||||
|
|
||||||
|
// 只添加客户端没有提供的 headers
|
||||||
|
Object.keys(claudeCodeHeaders).forEach((key) => {
|
||||||
|
const lowerKey = key.toLowerCase()
|
||||||
|
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
||||||
|
finalHeaders[key] = claudeCodeHeaders[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用请求身份转换
|
||||||
|
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
||||||
|
account,
|
||||||
|
accountId,
|
||||||
|
accountType,
|
||||||
|
sessionHash,
|
||||||
|
clientHeaders,
|
||||||
|
requestOptions,
|
||||||
|
isStream
|
||||||
|
})
|
||||||
|
|
||||||
|
if (extensionResult.abortResponse) {
|
||||||
|
return { abortResponse: extensionResult.abortResponse }
|
||||||
|
}
|
||||||
|
|
||||||
|
requestPayload = extensionResult.body
|
||||||
|
finalHeaders = extensionResult.headers
|
||||||
|
|
||||||
|
// 序列化请求体,计算 content-length
|
||||||
|
const bodyString = JSON.stringify(requestPayload)
|
||||||
|
const contentLength = Buffer.byteLength(bodyString, 'utf8')
|
||||||
|
|
||||||
|
// 构建最终请求头(包含认证、版本、User-Agent、Beta 等)
|
||||||
|
const headers = {
|
||||||
|
host: 'api.anthropic.com',
|
||||||
|
connection: 'keep-alive',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': String(contentLength),
|
||||||
|
authorization: `Bearer ${accessToken}`,
|
||||||
|
'anthropic-version': this.apiVersion,
|
||||||
|
...finalHeaders
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
||||||
|
const userAgent = unifiedUA || headers['user-agent'] || 'claude-cli/1.0.119 (external, cli)'
|
||||||
|
const acceptHeader = headers['accept'] || 'application/json'
|
||||||
|
delete headers['user-agent']
|
||||||
|
delete headers['accept']
|
||||||
|
headers['User-Agent'] = userAgent
|
||||||
|
headers['Accept'] = acceptHeader
|
||||||
|
|
||||||
|
logger.info(`🔗 指纹是这个: ${headers['User-Agent']}`)
|
||||||
|
|
||||||
|
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
||||||
|
const modelId = requestPayload?.model || body?.model
|
||||||
|
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
||||||
|
headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
||||||
|
return {
|
||||||
|
requestPayload,
|
||||||
|
bodyString,
|
||||||
|
headers,
|
||||||
|
isRealClaudeCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_applyRequestIdentityTransform(body, headers, context = {}) {
|
_applyRequestIdentityTransform(body, headers, context = {}) {
|
||||||
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
const normalizedHeaders = headers && typeof headers === 'object' ? { ...headers } : {}
|
||||||
|
|
||||||
@@ -928,46 +1102,24 @@ class ClaudeRelayService {
|
|||||||
// 获取账户信息用于统一 User-Agent
|
// 获取账户信息用于统一 User-Agent
|
||||||
const account = await claudeAccountService.getAccount(accountId)
|
const account = await claudeAccountService.getAccount(accountId)
|
||||||
|
|
||||||
// 获取统一的 User-Agent
|
// 使用公共方法准备请求头和 payload
|
||||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
// 获取过滤后的客户端 headers
|
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
|
||||||
|
|
||||||
// 判断是否是真实的 Claude Code 请求
|
|
||||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
|
||||||
|
|
||||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
|
||||||
let finalHeaders = { ...filteredHeaders }
|
|
||||||
let requestPayload = body
|
|
||||||
|
|
||||||
if (!isRealClaudeCode) {
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
|
||||||
|
|
||||||
// 只添加客户端没有提供的 headers
|
|
||||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
|
||||||
finalHeaders[key] = claudeCodeHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
|
||||||
account,
|
|
||||||
accountId,
|
|
||||||
clientHeaders,
|
clientHeaders,
|
||||||
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
account,
|
||||||
requestOptions,
|
requestOptions,
|
||||||
isStream: false
|
isStream: false
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (extensionResult.abortResponse) {
|
if (prepared.abortResponse) {
|
||||||
return extensionResult.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPayload = extensionResult.body
|
const { bodyString, headers } = prepared
|
||||||
finalHeaders = extensionResult.headers
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 支持自定义路径(如 count_tokens)
|
// 支持自定义路径(如 count_tokens)
|
||||||
@@ -981,31 +1133,13 @@ class ClaudeRelayService {
|
|||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port || 443,
|
port: url.port || 443,
|
||||||
path: requestPath,
|
path: requestPath + (url.search || ''),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'anthropic-version': this.apiVersion,
|
|
||||||
...finalHeaders
|
|
||||||
},
|
|
||||||
agent: proxyAgent,
|
agent: proxyAgent,
|
||||||
timeout: config.requestTimeout || 600000
|
timeout: config.requestTimeout || 600000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
|
||||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
|
||||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
|
||||||
options.headers['user-agent'] = userAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
|
||||||
|
|
||||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
|
||||||
const modelId = requestPayload?.model || body?.model
|
|
||||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
|
||||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
|
||||||
|
|
||||||
const req = https.request(options, (res) => {
|
const req = https.request(options, (res) => {
|
||||||
let responseData = Buffer.alloc(0)
|
let responseData = Buffer.alloc(0)
|
||||||
|
|
||||||
@@ -1015,32 +1149,32 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
try {
|
try {
|
||||||
let bodyString = ''
|
let responseBody = ''
|
||||||
|
|
||||||
// 根据Content-Encoding处理响应数据
|
// 根据Content-Encoding处理响应数据
|
||||||
const contentEncoding = res.headers['content-encoding']
|
const contentEncoding = res.headers['content-encoding']
|
||||||
if (contentEncoding === 'gzip') {
|
if (contentEncoding === 'gzip') {
|
||||||
try {
|
try {
|
||||||
bodyString = zlib.gunzipSync(responseData).toString('utf8')
|
responseBody = zlib.gunzipSync(responseData).toString('utf8')
|
||||||
} catch (unzipError) {
|
} catch (unzipError) {
|
||||||
logger.error('❌ Failed to decompress gzip response:', unzipError)
|
logger.error('❌ Failed to decompress gzip response:', unzipError)
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
} else if (contentEncoding === 'deflate') {
|
} else if (contentEncoding === 'deflate') {
|
||||||
try {
|
try {
|
||||||
bodyString = zlib.inflateSync(responseData).toString('utf8')
|
responseBody = zlib.inflateSync(responseData).toString('utf8')
|
||||||
} catch (unzipError) {
|
} catch (unzipError) {
|
||||||
logger.error('❌ Failed to decompress deflate response:', unzipError)
|
logger.error('❌ Failed to decompress deflate response:', unzipError)
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
bodyString = responseData.toString('utf8')
|
responseBody = responseData.toString('utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
headers: res.headers,
|
headers: res.headers,
|
||||||
body: bodyString
|
body: responseBody
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
|
logger.debug(`🔗 Claude API response: ${res.statusCode}`)
|
||||||
@@ -1059,7 +1193,6 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
req.on('error', async (error) => {
|
req.on('error', async (error) => {
|
||||||
console.error(': ❌ ', error)
|
|
||||||
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
|
logger.error(`❌ Claude API request error (Account: ${accountId}):`, error.message, {
|
||||||
code: error.code,
|
code: error.code,
|
||||||
errno: error.errno,
|
errno: error.errno,
|
||||||
@@ -1095,7 +1228,7 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(JSON.stringify(requestPayload))
|
req.write(bodyString)
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1243,11 @@ class ClaudeRelayService {
|
|||||||
streamTransformer = null,
|
streamTransformer = null,
|
||||||
options = {}
|
options = {}
|
||||||
) {
|
) {
|
||||||
|
let queueLockAcquired = false
|
||||||
|
let queueRequestId = null
|
||||||
|
let queueLockRenewalStopper = null
|
||||||
|
let selectedAccountId = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调试日志:查看API Key数据(流式请求)
|
// 调试日志:查看API Key数据(流式请求)
|
||||||
logger.info('🔍 [Stream] API Key data received:', {
|
logger.info('🔍 [Stream] API Key data received:', {
|
||||||
@@ -1153,6 +1291,74 @@ class ClaudeRelayService {
|
|||||||
}
|
}
|
||||||
const { accountId } = accountSelection
|
const { accountId } = accountSelection
|
||||||
const { accountType } = 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
|
||||||
|
queueLockRenewalStopper = await userMessageQueueService.startLockRenewal(
|
||||||
|
accountId,
|
||||||
|
queueRequestId
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
`📬 User message queue lock acquired for account ${accountId} (stream), requestId: ${queueRequestId}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
|
`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId} (${accountType})${sessionHash ? `, session: ${sessionHash}` : ''}`
|
||||||
@@ -1224,6 +1430,21 @@ class ClaudeRelayService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
logger.error(`❌ Claude stream relay with usage capture failed:`, error)
|
||||||
throw error
|
throw error
|
||||||
|
} finally {
|
||||||
|
// 📬 释放用户消息队列锁
|
||||||
|
if (queueLockAcquired && queueRequestId && selectedAccountId) {
|
||||||
|
try {
|
||||||
|
if (queueLockRenewalStopper) {
|
||||||
|
queueLockRenewalStopper()
|
||||||
|
}
|
||||||
|
await userMessageQueueService.releaseQueueLock(selectedAccountId, queueRequestId)
|
||||||
|
} catch (releaseError) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Failed to release user message queue lock for stream account ${selectedAccountId}:`,
|
||||||
|
releaseError.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,79 +1469,39 @@ class ClaudeRelayService {
|
|||||||
const isOpusModelRequest =
|
const isOpusModelRequest =
|
||||||
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
|
typeof body?.model === 'string' && body.model.toLowerCase().includes('opus')
|
||||||
|
|
||||||
// 获取统一的 User-Agent
|
// 使用公共方法准备请求头和 payload
|
||||||
const unifiedUA = await this.captureAndGetUnifiedUserAgent(clientHeaders, account)
|
const prepared = await this._prepareRequestHeadersAndPayload(
|
||||||
|
body,
|
||||||
// 获取过滤后的客户端 headers
|
clientHeaders,
|
||||||
const filteredHeaders = this._filterClientHeaders(clientHeaders)
|
|
||||||
|
|
||||||
// 判断是否是真实的 Claude Code 请求
|
|
||||||
const isRealClaudeCode = this.isRealClaudeCodeRequest(body)
|
|
||||||
|
|
||||||
// 如果不是真实的 Claude Code 请求,需要使用从账户获取的 Claude Code headers
|
|
||||||
let finalHeaders = { ...filteredHeaders }
|
|
||||||
let requestPayload = body
|
|
||||||
|
|
||||||
if (!isRealClaudeCode) {
|
|
||||||
// 获取该账号存储的 Claude Code headers
|
|
||||||
const claudeCodeHeaders = await claudeCodeHeadersService.getAccountHeaders(accountId)
|
|
||||||
|
|
||||||
// 只添加客户端没有提供的 headers
|
|
||||||
Object.keys(claudeCodeHeaders).forEach((key) => {
|
|
||||||
const lowerKey = key.toLowerCase()
|
|
||||||
if (!finalHeaders[key] && !finalHeaders[lowerKey]) {
|
|
||||||
finalHeaders[key] = claudeCodeHeaders[key]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const extensionResult = this._applyRequestIdentityTransform(requestPayload, finalHeaders, {
|
|
||||||
account,
|
|
||||||
accountId,
|
accountId,
|
||||||
|
accessToken,
|
||||||
|
{
|
||||||
|
account,
|
||||||
accountType,
|
accountType,
|
||||||
sessionHash,
|
sessionHash,
|
||||||
clientHeaders,
|
|
||||||
requestOptions,
|
requestOptions,
|
||||||
isStream: true
|
isStream: true
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if (extensionResult.abortResponse) {
|
if (prepared.abortResponse) {
|
||||||
return extensionResult.abortResponse
|
return prepared.abortResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPayload = extensionResult.body
|
const { bodyString, headers } = prepared
|
||||||
finalHeaders = extensionResult.headers
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const url = new URL(this.claudeApiUrl)
|
const url = new URL(this.claudeApiUrl)
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
hostname: url.hostname,
|
hostname: url.hostname,
|
||||||
port: url.port || 443,
|
port: url.port || 443,
|
||||||
path: url.pathname,
|
path: url.pathname + (url.search || ''),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'anthropic-version': this.apiVersion,
|
|
||||||
...finalHeaders
|
|
||||||
},
|
|
||||||
agent: proxyAgent,
|
agent: proxyAgent,
|
||||||
timeout: config.requestTimeout || 600000
|
timeout: config.requestTimeout || 600000
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用统一 User-Agent 或客户端提供的,最后使用默认值
|
|
||||||
if (!options.headers['user-agent'] || unifiedUA !== null) {
|
|
||||||
const userAgent = unifiedUA || 'claude-cli/1.0.119 (external, cli)'
|
|
||||||
options.headers['user-agent'] = userAgent
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`🔗 指纹是这个: ${options.headers['user-agent']}`)
|
|
||||||
// 根据模型和客户端传递的 anthropic-beta 动态设置 header
|
|
||||||
const modelId = body?.model
|
|
||||||
const clientBetaHeader = clientHeaders?.['anthropic-beta']
|
|
||||||
options.headers['anthropic-beta'] = this._getBetaHeader(modelId, clientBetaHeader)
|
|
||||||
|
|
||||||
const req = https.request(options, async (res) => {
|
const req = https.request(options, async (res) => {
|
||||||
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
logger.debug(`🌊 Claude stream response status: ${res.statusCode}`)
|
||||||
|
|
||||||
@@ -1465,7 +1646,6 @@ class ClaudeRelayService {
|
|||||||
})
|
})
|
||||||
|
|
||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
console.error(': ❌ ', errorData)
|
|
||||||
logger.error(
|
logger.error(
|
||||||
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
`❌ Claude API error response (Account: ${account?.name || accountId}):`,
|
||||||
errorData
|
errorData
|
||||||
@@ -1766,15 +1946,15 @@ class ClaudeRelayService {
|
|||||||
|
|
||||||
// 提取5小时会话窗口状态
|
// 提取5小时会话窗口状态
|
||||||
// 使用大小写不敏感的方式获取响应头
|
// 使用大小写不敏感的方式获取响应头
|
||||||
const get5hStatus = (headers) => {
|
const get5hStatus = (resHeaders) => {
|
||||||
if (!headers) {
|
if (!resHeaders) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// HTTP头部名称不区分大小写,需要处理不同情况
|
// HTTP头部名称不区分大小写,需要处理不同情况
|
||||||
return (
|
return (
|
||||||
headers['anthropic-ratelimit-unified-5h-status'] ||
|
resHeaders['anthropic-ratelimit-unified-5h-status'] ||
|
||||||
headers['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
resHeaders['Anthropic-Ratelimit-Unified-5h-Status'] ||
|
||||||
headers['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
resHeaders['ANTHROPIC-RATELIMIT-UNIFIED-5H-STATUS']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1937,12 +2117,12 @@ class ClaudeRelayService {
|
|||||||
responseStream.on('close', () => {
|
responseStream.on('close', () => {
|
||||||
logger.debug('🔌 Client disconnected, cleaning up stream')
|
logger.debug('🔌 Client disconnected, cleaning up stream')
|
||||||
if (!req.destroyed) {
|
if (!req.destroyed) {
|
||||||
req.destroy()
|
req.destroy(new Error('Client disconnected'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 写入请求体
|
// 写入请求体
|
||||||
req.write(JSON.stringify(requestPayload))
|
req.write(bodyString)
|
||||||
req.end()
|
req.end()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,7 +556,8 @@ class DroidAccountService {
|
|||||||
tokenType = 'Bearer',
|
tokenType = 'Bearer',
|
||||||
authenticationMethod = '',
|
authenticationMethod = '',
|
||||||
expiresIn = null,
|
expiresIn = null,
|
||||||
apiKeys = []
|
apiKeys = [],
|
||||||
|
userAgent = '' // 自定义 User-Agent
|
||||||
} = options
|
} = options
|
||||||
|
|
||||||
const accountId = uuidv4()
|
const accountId = uuidv4()
|
||||||
@@ -832,7 +833,8 @@ class DroidAccountService {
|
|||||||
: '',
|
: '',
|
||||||
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
apiKeys: hasApiKeys ? JSON.stringify(apiKeyEntries) : '',
|
||||||
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
apiKeyCount: hasApiKeys ? String(apiKeyEntries.length) : '0',
|
||||||
apiKeyStrategy: hasApiKeys ? 'random_sticky' : ''
|
apiKeyStrategy: hasApiKeys ? 'random_sticky' : '',
|
||||||
|
userAgent: userAgent || '' // 自定义 User-Agent
|
||||||
}
|
}
|
||||||
|
|
||||||
await redis.setDroidAccount(accountId, accountData)
|
await redis.setDroidAccount(accountId, accountData)
|
||||||
@@ -931,6 +933,11 @@ class DroidAccountService {
|
|||||||
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
sanitizedUpdates.endpointType = this._sanitizeEndpointType(sanitizedUpdates.endpointType)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理 userAgent 字段
|
||||||
|
if (typeof sanitizedUpdates.userAgent === 'string') {
|
||||||
|
sanitizedUpdates.userAgent = sanitizedUpdates.userAgent.trim()
|
||||||
|
}
|
||||||
|
|
||||||
const parseProxyConfig = (value) => {
|
const parseProxyConfig = (value) => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class DroidRelayService {
|
|||||||
comm: '/o/v1/chat/completions'
|
comm: '/o/v1/chat/completions'
|
||||||
}
|
}
|
||||||
|
|
||||||
this.userAgent = 'factory-cli/0.19.12'
|
this.userAgent = 'factory-cli/0.32.1'
|
||||||
this.systemPrompt = SYSTEM_PROMPT
|
this.systemPrompt = SYSTEM_PROMPT
|
||||||
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
this.API_KEY_STICKY_PREFIX = 'droid_api_key'
|
||||||
}
|
}
|
||||||
@@ -241,7 +241,8 @@ class DroidRelayService {
|
|||||||
accessToken,
|
accessToken,
|
||||||
normalizedRequestBody,
|
normalizedRequestBody,
|
||||||
normalizedEndpoint,
|
normalizedEndpoint,
|
||||||
clientHeaders
|
clientHeaders,
|
||||||
|
account
|
||||||
)
|
)
|
||||||
|
|
||||||
if (selectedApiKey) {
|
if (selectedApiKey) {
|
||||||
@@ -633,7 +634,7 @@ class DroidRelayService {
|
|||||||
// 客户端断开连接时清理
|
// 客户端断开连接时清理
|
||||||
clientResponse.on('close', () => {
|
clientResponse.on('close', () => {
|
||||||
if (req && !req.destroyed) {
|
if (req && !req.destroyed) {
|
||||||
req.destroy()
|
req.destroy(new Error('Client disconnected'))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -737,6 +738,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
data.usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
data.usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -758,6 +767,14 @@ class DroidRelayService {
|
|||||||
currentUsageData.output_tokens = 0
|
currentUsageData.output_tokens = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture cache tokens from OpenAI Response API format
|
||||||
|
currentUsageData.cache_read_input_tokens =
|
||||||
|
usage.input_tokens_details?.cached_tokens || 0
|
||||||
|
currentUsageData.cache_creation_input_tokens =
|
||||||
|
usage.input_tokens_details?.cache_creation_input_tokens ||
|
||||||
|
usage.cache_creation_input_tokens ||
|
||||||
|
0
|
||||||
|
|
||||||
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
logger.debug('📊 Droid OpenAI response usage:', currentUsageData)
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
@@ -966,11 +983,13 @@ class DroidRelayService {
|
|||||||
/**
|
/**
|
||||||
* 构建请求头
|
* 构建请求头
|
||||||
*/
|
*/
|
||||||
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}) {
|
_buildHeaders(accessToken, requestBody, endpointType, clientHeaders = {}, account = null) {
|
||||||
|
// 使用账户配置的 userAgent 或默认值
|
||||||
|
const userAgent = account?.userAgent || this.userAgent
|
||||||
const headers = {
|
const headers = {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
authorization: `Bearer ${accessToken}`,
|
authorization: `Bearer ${accessToken}`,
|
||||||
'user-agent': this.userAgent,
|
'user-agent': userAgent,
|
||||||
'x-factory-client': 'cli',
|
'x-factory-client': 'cli',
|
||||||
connection: 'keep-alive'
|
connection: 'keep-alive'
|
||||||
}
|
}
|
||||||
@@ -987,10 +1006,16 @@ class DroidRelayService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI 特定头
|
// OpenAI 特定头 - 根据模型动态选择 provider
|
||||||
if (endpointType === 'openai') {
|
if (endpointType === 'openai') {
|
||||||
|
const model = (requestBody?.model || '').toLowerCase()
|
||||||
|
// -max 模型使用 openai provider,其他使用 azure_openai
|
||||||
|
if (model.includes('-max')) {
|
||||||
|
headers['x-api-provider'] = 'openai'
|
||||||
|
} else {
|
||||||
headers['x-api-provider'] = 'azure_openai'
|
headers['x-api-provider'] = 'azure_openai'
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Comm 端点根据模型动态设置 provider
|
// Comm 端点根据模型动态设置 provider
|
||||||
if (endpointType === 'comm') {
|
if (endpointType === 'comm') {
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ const STAINLESS_HEADER_KEYS = [
|
|||||||
'x-stainless-runtime',
|
'x-stainless-runtime',
|
||||||
'x-stainless-runtime-version'
|
'x-stainless-runtime-version'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// 小写 key 到正确大小写格式的映射(用于返回给上游时)
|
||||||
|
const STAINLESS_HEADER_CASE_MAP = {
|
||||||
|
'x-stainless-retry-count': 'X-Stainless-Retry-Count',
|
||||||
|
'x-stainless-timeout': 'X-Stainless-Timeout',
|
||||||
|
'x-stainless-lang': 'X-Stainless-Lang',
|
||||||
|
'x-stainless-package-version': 'X-Stainless-Package-Version',
|
||||||
|
'x-stainless-os': 'X-Stainless-OS',
|
||||||
|
'x-stainless-arch': 'X-Stainless-Arch',
|
||||||
|
'x-stainless-runtime': 'X-Stainless-Runtime',
|
||||||
|
'x-stainless-runtime-version': 'X-Stainless-Runtime-Version'
|
||||||
|
}
|
||||||
const MIN_FINGERPRINT_FIELDS = 4
|
const MIN_FINGERPRINT_FIELDS = 4
|
||||||
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
const REDIS_KEY_PREFIX = 'fmt_claude_req:stainless_headers:'
|
||||||
|
|
||||||
@@ -135,7 +147,9 @@ function applyFingerprintToHeaders(headers, fingerprint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
removeHeaderCaseInsensitive(nextHeaders, key)
|
removeHeaderCaseInsensitive(nextHeaders, key)
|
||||||
nextHeaders[key] = fingerprint[key]
|
// 使用正确的大小写格式返回给上游
|
||||||
|
const properCaseKey = STAINLESS_HEADER_CASE_MAP[key] || key
|
||||||
|
nextHeaders[properCaseKey] = fingerprint[key]
|
||||||
})
|
})
|
||||||
|
|
||||||
return nextHeaders
|
return nextHeaders
|
||||||
|
|||||||
@@ -180,8 +180,56 @@ class UnifiedClaudeScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 统一调度Claude账号(官方和Console)
|
// 🎯 统一调度Claude账号(官方和Console)
|
||||||
async selectAccountForApiKey(apiKeyData, sessionHash = null, requestedModel = null) {
|
async selectAccountForApiKey(
|
||||||
|
apiKeyData,
|
||||||
|
sessionHash = null,
|
||||||
|
requestedModel = null,
|
||||||
|
forcedAccount = null
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
|
// 🔒 如果有强制绑定的账户(全局会话绑定),仅 claude-official 类型受影响
|
||||||
|
if (forcedAccount && forcedAccount.accountId && forcedAccount.accountType) {
|
||||||
|
// ⚠️ 只有 claude-official 类型账户受全局会话绑定限制
|
||||||
|
// 其他类型(bedrock, ccr, claude-console等)忽略绑定,走正常调度
|
||||||
|
if (forcedAccount.accountType !== 'claude-official') {
|
||||||
|
logger.info(
|
||||||
|
`🔗 Session binding ignored for non-official account type: ${forcedAccount.accountType}, proceeding with normal scheduling`
|
||||||
|
)
|
||||||
|
// 不使用 forcedAccount,继续走下面的正常调度逻辑
|
||||||
|
} else {
|
||||||
|
// claude-official 类型需要检查可用性并强制使用
|
||||||
|
logger.info(
|
||||||
|
`🔗 Forced session binding detected: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAvailable = await this._isAccountAvailableForSessionBinding(
|
||||||
|
forcedAccount.accountId,
|
||||||
|
forcedAccount.accountType,
|
||||||
|
requestedModel
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAvailable) {
|
||||||
|
logger.info(
|
||||||
|
`✅ Using forced session binding account: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
accountId: forcedAccount.accountId,
|
||||||
|
accountType: forcedAccount.accountType
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 绑定账户不可用,抛出特定错误(不 fallback)
|
||||||
|
logger.warn(
|
||||||
|
`❌ Forced session binding account unavailable: ${forcedAccount.accountId} (${forcedAccount.accountType})`
|
||||||
|
)
|
||||||
|
const error = new Error('Session binding account unavailable')
|
||||||
|
error.code = 'SESSION_BINDING_ACCOUNT_UNAVAILABLE'
|
||||||
|
error.accountId = forcedAccount.accountId
|
||||||
|
error.accountType = forcedAccount.accountType
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 解析供应商前缀
|
// 解析供应商前缀
|
||||||
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
const { vendor, baseModel } = parseVendorPrefixedModel(requestedModel)
|
||||||
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
const effectiveModel = vendor === 'ccr' ? baseModel : requestedModel
|
||||||
@@ -1711,6 +1759,67 @@ class UnifiedClaudeScheduler {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔒 检查 claude-official 账户是否可用于会话绑定
|
||||||
|
* 注意:此方法仅用于 claude-official 类型账户,其他类型不受会话绑定限制
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} accountType - 账户类型(应为 'claude-official')
|
||||||
|
* @param {string} _requestedModel - 请求的模型(保留参数,当前未使用)
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
async _isAccountAvailableForSessionBinding(accountId, accountType, _requestedModel = null) {
|
||||||
|
try {
|
||||||
|
// 此方法仅处理 claude-official 类型
|
||||||
|
if (accountType !== 'claude-official') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: _isAccountAvailableForSessionBinding called for non-official type: ${accountType}`
|
||||||
|
)
|
||||||
|
return true // 非 claude-official 类型不受限制
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await redis.getClaudeAccount(accountId)
|
||||||
|
if (!account) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} not found`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = account.isActive === 'true' || account.isActive === true
|
||||||
|
const { status } = account
|
||||||
|
|
||||||
|
if (!isActive) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is not active`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' || status === 'temp_error') {
|
||||||
|
logger.warn(
|
||||||
|
`Session binding: Claude OAuth account ${accountId} has error status: ${status}`
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否被限流
|
||||||
|
if (await claudeAccountService.isAccountRateLimited(accountId)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is rate limited`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查临时不可用
|
||||||
|
if (await this.isAccountTemporarilyUnavailable(accountId, accountType)) {
|
||||||
|
logger.warn(`Session binding: Claude OAuth account ${accountId} is temporarily unavailable`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`❌ Error checking account availability for session binding: ${accountId} (${accountType})`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new UnifiedClaudeScheduler()
|
module.exports = new UnifiedClaudeScheduler()
|
||||||
|
|||||||
452
src/services/userMessageQueueService.js
Normal file
452
src/services/userMessageQueueService.js
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务
|
||||||
|
* 为 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分钟
|
||||||
|
|
||||||
|
// 锁续租最大持续时间(从配置读取,与 REQUEST_TIMEOUT 保持一致)
|
||||||
|
const MAX_RENEWAL_DURATION_MS = config.requestTimeout || 10 * 60 * 1000
|
||||||
|
|
||||||
|
// 轮询等待配置
|
||||||
|
const POLL_INTERVAL_BASE_MS = 50 // 基础轮询间隔
|
||||||
|
const POLL_INTERVAL_MAX_MS = 500 // 最大轮询间隔
|
||||||
|
const POLL_BACKOFF_FACTOR = 1.5 // 退避因子
|
||||||
|
|
||||||
|
class UserMessageQueueService {
|
||||||
|
constructor() {
|
||||||
|
this.cleanupTimer = null
|
||||||
|
// 跟踪活跃的续租定时器,用于服务关闭时清理
|
||||||
|
this.activeRenewalTimers = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测请求是否为真正的用户消息请求
|
||||||
|
* 区分真正的用户输入和 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 ?? 100,
|
||||||
|
timeoutMs: queueConfig.timeoutMs ?? 60000,
|
||||||
|
lockTtlMs: queueConfig.lockTtlMs ?? 120000
|
||||||
|
}
|
||||||
|
|
||||||
|
// 尝试从 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: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启动锁续租(防止长连接超过TTL导致锁丢失)
|
||||||
|
* @param {string} accountId - 账户ID
|
||||||
|
* @param {string} requestId - 请求ID
|
||||||
|
* @returns {Promise<Function>} 停止续租的函数
|
||||||
|
*/
|
||||||
|
async startLockRenewal(accountId, requestId) {
|
||||||
|
const cfg = await this.getConfig()
|
||||||
|
if (!cfg.enabled || !accountId || !requestId) {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalMs = Math.max(10000, Math.floor(cfg.lockTtlMs / 2)) // 约一半TTL刷新一次
|
||||||
|
const maxRenewals = Math.ceil(MAX_RENEWAL_DURATION_MS / intervalMs) // 最大续租次数
|
||||||
|
const startTime = Date.now()
|
||||||
|
const timerKey = `${accountId}:${requestId}`
|
||||||
|
|
||||||
|
let stopped = false
|
||||||
|
let renewalCount = 0
|
||||||
|
|
||||||
|
const stopRenewal = () => {
|
||||||
|
if (!stopped) {
|
||||||
|
clearInterval(timer)
|
||||||
|
stopped = true
|
||||||
|
this.activeRenewalTimers.delete(timerKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
if (stopped) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
renewalCount++
|
||||||
|
|
||||||
|
// 检查是否超过最大续租次数或最大持续时间
|
||||||
|
if (renewalCount > maxRenewals || Date.now() - startTime > MAX_RENEWAL_DURATION_MS) {
|
||||||
|
logger.warn(`📬 User message queue: max renewal duration exceeded, stopping renewal`, {
|
||||||
|
accountId,
|
||||||
|
requestId,
|
||||||
|
renewalCount,
|
||||||
|
durationMs: Date.now() - startTime
|
||||||
|
})
|
||||||
|
stopRenewal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshed = await redis.refreshUserMessageLock(accountId, requestId, cfg.lockTtlMs)
|
||||||
|
if (!refreshed) {
|
||||||
|
// 锁可能已被释放或超时,停止续租
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue: failed to refresh lock (possibly lost), stop renewal`,
|
||||||
|
{
|
||||||
|
accountId,
|
||||||
|
requestId,
|
||||||
|
renewalCount
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stopRenewal()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: lock renewal error:', error)
|
||||||
|
}
|
||||||
|
}, intervalMs)
|
||||||
|
|
||||||
|
// 避免阻止进程退出
|
||||||
|
if (typeof timer.unref === 'function') {
|
||||||
|
timer.unref()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跟踪活跃的定时器
|
||||||
|
this.activeRenewalTimers.set(timerKey, { timer, stopRenewal, accountId, requestId, startTime })
|
||||||
|
|
||||||
|
return stopRenewal
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取队列统计信息
|
||||||
|
* @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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止所有活跃的锁续租定时器(服务关闭时调用)
|
||||||
|
*/
|
||||||
|
stopAllRenewalTimers() {
|
||||||
|
const count = this.activeRenewalTimers.size
|
||||||
|
if (count > 0) {
|
||||||
|
for (const [key, { stopRenewal }] of this.activeRenewalTimers) {
|
||||||
|
try {
|
||||||
|
stopRenewal()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`📬 User message queue: failed to stop renewal timer ${key}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeRenewalTimers.clear()
|
||||||
|
logger.info(`📬 User message queue: stopped ${count} active renewal timer(s)`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取活跃续租定时器数量(用于监控)
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
getActiveRenewalCount() {
|
||||||
|
return this.activeRenewalTimers.size
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理孤儿锁
|
||||||
|
* 检测异常情况:锁存在但没有设置过期时间(lockTtlRaw === -1)
|
||||||
|
* 正常情况下所有锁都应该有 TTL,Redis 会自动过期
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
async _cleanupOrphanLocks() {
|
||||||
|
try {
|
||||||
|
const accountIds = await redis.scanUserMessageQueueLocks()
|
||||||
|
|
||||||
|
for (const accountId of accountIds) {
|
||||||
|
const stats = await redis.getUserMessageQueueStats(accountId)
|
||||||
|
|
||||||
|
// 检测异常情况:锁存在(isLocked=true)但没有过期时间(lockTtlRaw=-1)
|
||||||
|
// 正常创建的锁都带有 PX 过期时间,如果没有说明是异常状态
|
||||||
|
if (stats.isLocked && stats.lockTtlRaw === -1) {
|
||||||
|
logger.warn(
|
||||||
|
`📬 User message queue: cleaning up orphan lock without TTL for account ${accountId}`,
|
||||||
|
{ lockHolder: stats.lockHolder }
|
||||||
|
)
|
||||||
|
await redis.forceReleaseUserMessageLock(accountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('📬 User message queue: cleanup task error:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 睡眠辅助函数
|
||||||
|
* @param {number} ms - 毫秒
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new UserMessageQueueService()
|
||||||
@@ -84,6 +84,11 @@ const PROMPT_DEFINITIONS = {
|
|||||||
title: 'Claude Code Compact System Prompt',
|
title: 'Claude Code Compact System Prompt',
|
||||||
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
text: 'You are a helpful AI assistant tasked with summarizing conversations.'
|
||||||
},
|
},
|
||||||
|
exploreAgentSystemPrompt: {
|
||||||
|
category: 'system',
|
||||||
|
title: 'Claude Code Explore Agent System Prompt',
|
||||||
|
text: "You are a file search specialist for Claude Code, Anthropic's official CLI for Claude."
|
||||||
|
},
|
||||||
outputStyleInsightsPrompt: {
|
outputStyleInsightsPrompt: {
|
||||||
category: 'output_style',
|
category: 'output_style',
|
||||||
title: 'Output Style Insights Addendum',
|
title: 'Output Style Insights Addendum',
|
||||||
|
|||||||
@@ -52,50 +52,38 @@ function filterForOpenAI(headers) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 为 Claude/Anthropic API 过滤 headers
|
* 为 Claude/Anthropic API 过滤 headers
|
||||||
* 在原有逻辑基础上添加 CDN headers 到敏感列表
|
* 使用白名单模式,只允许指定的 headers 通过
|
||||||
*/
|
*/
|
||||||
function filterForClaude(headers) {
|
function filterForClaude(headers) {
|
||||||
const sensitiveHeaders = [
|
// 白名单模式:只允许以下 headers
|
||||||
'content-type',
|
const allowedHeaders = [
|
||||||
'user-agent',
|
|
||||||
'x-api-key',
|
|
||||||
'authorization',
|
|
||||||
'x-authorization',
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'proxy-authorization',
|
|
||||||
'content-encoding',
|
|
||||||
'transfer-encoding',
|
|
||||||
...cdnHeaders // 添加 CDN headers
|
|
||||||
]
|
|
||||||
|
|
||||||
const browserHeaders = [
|
|
||||||
'origin',
|
|
||||||
'referer',
|
|
||||||
'sec-fetch-mode',
|
|
||||||
'sec-fetch-site',
|
|
||||||
'sec-fetch-dest',
|
|
||||||
'sec-ch-ua',
|
|
||||||
'sec-ch-ua-mobile',
|
|
||||||
'sec-ch-ua-platform',
|
|
||||||
'accept-language',
|
|
||||||
'accept-encoding',
|
|
||||||
'accept',
|
'accept',
|
||||||
'cache-control',
|
'x-stainless-retry-count',
|
||||||
'pragma',
|
'x-stainless-timeout',
|
||||||
'anthropic-dangerous-direct-browser-access'
|
'x-stainless-lang',
|
||||||
|
'x-stainless-package-version',
|
||||||
|
'x-stainless-os',
|
||||||
|
'x-stainless-arch',
|
||||||
|
'x-stainless-runtime',
|
||||||
|
'x-stainless-runtime-version',
|
||||||
|
'x-stainless-helper-method',
|
||||||
|
'anthropic-dangerous-direct-browser-access',
|
||||||
|
'anthropic-version',
|
||||||
|
'x-app',
|
||||||
|
'anthropic-beta',
|
||||||
|
'accept-language',
|
||||||
|
'sec-fetch-mode',
|
||||||
|
'accept-encoding',
|
||||||
|
'user-agent',
|
||||||
|
'content-type',
|
||||||
|
'connection'
|
||||||
]
|
]
|
||||||
|
|
||||||
const allowedHeaders = ['x-request-id', 'anthropic-version', 'anthropic-beta']
|
|
||||||
|
|
||||||
const filtered = {}
|
const filtered = {}
|
||||||
Object.keys(headers || {}).forEach((key) => {
|
Object.keys(headers || {}).forEach((key) => {
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase()
|
||||||
if (allowedHeaders.includes(lowerKey)) {
|
if (allowedHeaders.includes(lowerKey)) {
|
||||||
filtered[key] = headers[key]
|
filtered[key] = headers[key]
|
||||||
} else if (!sensitiveHeaders.includes(lowerKey) && !browserHeaders.includes(lowerKey)) {
|
|
||||||
filtered[key] = headers[key]
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ const createLogFormat = (colorize = false) => {
|
|||||||
|
|
||||||
const logFormat = createLogFormat(false)
|
const logFormat = createLogFormat(false)
|
||||||
const consoleFormat = createLogFormat(true)
|
const consoleFormat = createLogFormat(true)
|
||||||
|
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID
|
||||||
|
|
||||||
// 📁 确保日志目录存在并设置权限
|
// 📁 确保日志目录存在并设置权限
|
||||||
if (!fs.existsSync(config.logging.dirname)) {
|
if (!fs.existsSync(config.logging.dirname)) {
|
||||||
@@ -159,7 +160,8 @@ const createRotateTransport = (filename, level = null) => {
|
|||||||
transport.level = level
|
transport.level = level
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听轮转事件
|
// 监听轮转事件(测试环境关闭以避免 Jest 退出后输出)
|
||||||
|
if (!isTestEnv) {
|
||||||
transport.on('rotate', (oldFilename, newFilename) => {
|
transport.on('rotate', (oldFilename, newFilename) => {
|
||||||
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
console.log(`📦 Log rotated: ${oldFilename} -> ${newFilename}`)
|
||||||
})
|
})
|
||||||
@@ -171,6 +173,7 @@ const createRotateTransport = (filename, level = null) => {
|
|||||||
transport.on('archive', (zipFilename) => {
|
transport.on('archive', (zipFilename) => {
|
||||||
console.log(`🗜️ Log archived: ${zipFilename}`)
|
console.log(`🗜️ Log archived: ${zipFilename}`)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return transport
|
return transport
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ class ClaudeCodeValidator {
|
|||||||
const { bestScore } = bestSimilarityByTemplates(rawText)
|
const { bestScore } = bestSimilarityByTemplates(rawText)
|
||||||
if (bestScore < threshold) {
|
if (bestScore < threshold) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}, prompt=${rawText}`
|
`Claude system prompt similarity below threshold: score=${bestScore.toFixed(4)}, threshold=${threshold}`
|
||||||
)
|
)
|
||||||
|
logger.warn(`Claude system prompt detail: ${rawText}`)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
516
tests/userMessageQueue.test.js
Normal file
516
tests/userMessageQueue.test.js
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
/**
|
||||||
|
* 用户消息队列服务测试
|
||||||
|
* 测试消息类型检测、队列串行行为、延迟间隔、超时处理和功能开关
|
||||||
|
*/
|
||||||
|
|
||||||
|
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('startLockRenewal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should periodically refresh lock while enabled', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
const refreshSpy = jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(60000) // 半个TTL
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(refreshSpy).toHaveBeenCalledWith('acct-1', 'req-1', 120000)
|
||||||
|
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should no-op when queue disabled', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: false,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
const refreshSpy = jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
|
||||||
|
jest.advanceTimersByTime(120000)
|
||||||
|
await Promise.resolve()
|
||||||
|
|
||||||
|
expect(refreshSpy).not.toHaveBeenCalled()
|
||||||
|
stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track active renewal timer', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
|
||||||
|
|
||||||
|
const stop = await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
|
||||||
|
expect(userMessageQueueService.getActiveRenewalCount()).toBe(1)
|
||||||
|
|
||||||
|
stop()
|
||||||
|
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop all renewal timers on service shutdown', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'refreshUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
await userMessageQueueService.startLockRenewal('acct-1', 'req-1')
|
||||||
|
await userMessageQueueService.startLockRenewal('acct-2', 'req-2')
|
||||||
|
expect(userMessageQueueService.getActiveRenewalCount()).toBe(2)
|
||||||
|
|
||||||
|
userMessageQueueService.stopAllRenewalTimers()
|
||||||
|
expect(userMessageQueueService.getActiveRenewalCount()).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('acquireQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should acquire lock immediately when no lock exists', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBe('req-1')
|
||||||
|
expect(result.error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should skip lock acquisition when queue disabled', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: false,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
const acquireSpy = jest.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.skipped).toBe(true)
|
||||||
|
expect(acquireSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate requestId when not provided', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(result.requestId).toBeDefined()
|
||||||
|
expect(result.requestId.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should wait and retry when lock is held by another request', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount < 3) {
|
||||||
|
return { acquired: false, waitMs: -1 } // lock held
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock sleep to speed up test
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(callCount).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect delay when previous request just completed', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 1000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
let callCount = 0
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockImplementation(async () => {
|
||||||
|
callCount++
|
||||||
|
if (callCount === 1) {
|
||||||
|
return { acquired: false, waitMs: 150 } // need to wait 150ms for delay
|
||||||
|
}
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const sleepSpy = jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(true)
|
||||||
|
expect(sleepSpy).toHaveBeenCalledWith(150) // Should wait for delay
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should timeout and return error when wait exceeds timeout', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 100, // very short timeout
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: false,
|
||||||
|
waitMs: -1 // always held
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use real timers for timeout test but mock sleep to be instant
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockImplementation(async () => {
|
||||||
|
// Simulate time passing
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60))
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1', 100)
|
||||||
|
|
||||||
|
expect(result.acquired).toBe(false)
|
||||||
|
expect(result.error).toBe('queue_timeout')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('releaseQueueLock', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should release lock successfully when holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(true)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(redis.releaseUserMessageLock).toHaveBeenCalledWith('acct-1', 'req-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when not holding the lock', async () => {
|
||||||
|
jest.spyOn(redis, 'releaseUserMessageLock').mockResolvedValue(false)
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when accountId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock(null, 'req-1')
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return false when requestId is missing', async () => {
|
||||||
|
const releaseSpy = jest.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
|
||||||
|
const result = await userMessageQueueService.releaseQueueLock('acct-1', null)
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
expect(releaseSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('queue serialization behavior', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow different accounts to acquire locks simultaneously', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 200,
|
||||||
|
timeoutMs: 30000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
jest.spyOn(redis, 'acquireUserMessageLock').mockResolvedValue({
|
||||||
|
acquired: true,
|
||||||
|
waitMs: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-1', 'req-1'),
|
||||||
|
userMessageQueueService.acquireQueueLock('acct-2', 'req-2')
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should serialize requests for same account', async () => {
|
||||||
|
jest.spyOn(userMessageQueueService, 'getConfig').mockResolvedValue({
|
||||||
|
enabled: true,
|
||||||
|
delayMs: 50,
|
||||||
|
timeoutMs: 5000,
|
||||||
|
lockTtlMs: 120000
|
||||||
|
})
|
||||||
|
|
||||||
|
const lockState = { held: false, holderId: null }
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'acquireUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (!lockState.held) {
|
||||||
|
lockState.held = true
|
||||||
|
lockState.holderId = requestId
|
||||||
|
return { acquired: true, waitMs: 0 }
|
||||||
|
}
|
||||||
|
return { acquired: false, waitMs: -1 }
|
||||||
|
})
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(redis, 'releaseUserMessageLock')
|
||||||
|
.mockImplementation(async (accountId, requestId) => {
|
||||||
|
if (lockState.holderId === requestId) {
|
||||||
|
lockState.held = false
|
||||||
|
lockState.holderId = null
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
jest.spyOn(userMessageQueueService, '_sleep').mockResolvedValue(undefined)
|
||||||
|
|
||||||
|
// First request acquires lock
|
||||||
|
const result1 = await userMessageQueueService.acquireQueueLock('acct-1', 'req-1')
|
||||||
|
expect(result1.acquired).toBe(true)
|
||||||
|
|
||||||
|
// Second request should fail to acquire (lock held)
|
||||||
|
const acquirePromise = userMessageQueueService.acquireQueueLock('acct-1', 'req-2', 200)
|
||||||
|
|
||||||
|
// Release first lock
|
||||||
|
await userMessageQueueService.releaseQueueLock('acct-1', 'req-1')
|
||||||
|
|
||||||
|
// Now second request should acquire
|
||||||
|
const result2 = await acquirePromise
|
||||||
|
expect(result2.acquired).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,7 +1157,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash": "*"
|
"@types/lodash": "*"
|
||||||
}
|
}
|
||||||
@@ -1352,7 +1351,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1589,7 +1587,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"caniuse-lite": "^1.0.30001726",
|
"caniuse-lite": "^1.0.30001726",
|
||||||
"electron-to-chromium": "^1.5.173",
|
"electron-to-chromium": "^1.5.173",
|
||||||
@@ -3063,15 +3060,13 @@
|
|||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-es": {
|
"node_modules/lodash-es": {
|
||||||
"version": "4.17.21",
|
"version": "4.17.21",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash-unified": {
|
"node_modules/lodash-unified": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
@@ -3623,7 +3618,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -3770,7 +3764,6 @@
|
|||||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -4035,7 +4028,6 @@
|
|||||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
},
|
},
|
||||||
@@ -4533,7 +4525,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -4924,7 +4915,6 @@
|
|||||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.21.3",
|
"esbuild": "^0.21.3",
|
||||||
"postcss": "^8.4.43",
|
"postcss": "^8.4.43",
|
||||||
@@ -5125,7 +5115,6 @@
|
|||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vue/compiler-dom": "3.5.18",
|
"@vue/compiler-dom": "3.5.18",
|
||||||
"@vue/compiler-sfc": "3.5.18",
|
"@vue/compiler-sfc": "3.5.18",
|
||||||
|
|||||||
@@ -1944,6 +1944,22 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (OAuth/Manual 模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Key 模式输入 -->
|
<!-- API Key 模式输入 -->
|
||||||
@@ -1989,6 +2005,22 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 -->
|
||||||
|
<div>
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
class="rounded-lg border border-purple-200 bg-white/70 p-3 text-xs text-purple-800 dark:border-purple-700 dark:bg-purple-800/20 dark:text-purple-100"
|
||||||
>
|
>
|
||||||
@@ -3639,6 +3671,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Droid User-Agent 配置 (编辑模式) -->
|
||||||
|
<div v-if="form.platform === 'droid'">
|
||||||
|
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||||
|
>自定义 User-Agent (可选)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="form.userAgent"
|
||||||
|
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
|
||||||
|
placeholder="factory-cli/0.32.1"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
留空使用默认值 factory-cli/0.32.1,可根据需要自定义
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 代理设置 -->
|
<!-- 代理设置 -->
|
||||||
<ProxyConfig v-model="form.proxy" />
|
<ProxyConfig v-model="form.proxy" />
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,18 @@
|
|||||||
<i class="fas fa-bell mr-2"></i>
|
<i class="fas fa-bell mr-2"></i>
|
||||||
通知设置
|
通知设置
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:class="[
|
||||||
|
'border-b-2 pb-2 text-sm font-medium transition-colors',
|
||||||
|
activeSection === 'claude'
|
||||||
|
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400'
|
||||||
|
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300'
|
||||||
|
]"
|
||||||
|
@click="activeSection = 'claude'"
|
||||||
|
>
|
||||||
|
<i class="fas fa-robot mr-2"></i>
|
||||||
|
Claude 转发
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -629,6 +641,276 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Claude 转发配置部分 -->
|
||||||
|
<div v-show="activeSection === 'claude'">
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<div v-if="claudeConfigLoading" class="py-12 text-center">
|
||||||
|
<div class="loading-spinner mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">正在加载配置...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Claude Code 客户端限制 -->
|
||||||
|
<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-orange-500 to-amber-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-terminal"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200">
|
||||||
|
仅允许 Claude Code 客户端
|
||||||
|
</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
启用后,所有
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/api/v1/messages</code
|
||||||
|
>
|
||||||
|
和
|
||||||
|
<code class="rounded bg-gray-100 px-1 dark:bg-gray-700"
|
||||||
|
>/claude/v1/messages</code
|
||||||
|
>
|
||||||
|
端点将强制验证 Claude Code CLI 客户端
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.claudeCodeOnlyEnabled"
|
||||||
|
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-orange-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-orange-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-orange-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 rounded-lg bg-amber-50 p-4 dark:bg-amber-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-info-circle mt-0.5 text-amber-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
此设置与 API Key 级别的客户端限制是 <strong>OR 逻辑</strong>:全局启用或 API
|
||||||
|
Key 设置中启用,都会执行 Claude Code 验证。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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-purple-500 to-indigo-600 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<i class="fas fa-link"></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">
|
||||||
|
启用后,系统会将原始会话 ID 绑定到首次使用的账户,确保上下文的一致性
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
v-model="claudeConfig.globalSessionBindingEnabled"
|
||||||
|
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-purple-500 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-purple-800"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 绑定配置详情(仅在启用时显示) -->
|
||||||
|
<div v-if="claudeConfig.globalSessionBindingEnabled" 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-clock mr-2 text-gray-400"></i>
|
||||||
|
绑定有效期(天)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="claudeConfig.sessionBindingTtlDays"
|
||||||
|
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-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
max="365"
|
||||||
|
min="1"
|
||||||
|
placeholder="30"
|
||||||
|
type="number"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
会话绑定到账户后的有效时间,过期后会自动解除绑定
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误提示消息 -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2 text-gray-400"></i>
|
||||||
|
旧会话污染提示
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
v-model="claudeConfig.sessionBindingErrorMessage"
|
||||||
|
class="mt-1 block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-500 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
placeholder="你的本地session已污染,请清理后使用。"
|
||||||
|
rows="2"
|
||||||
|
@change="saveClaudeConfig"
|
||||||
|
></textarea>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
当检测到为旧的sessionId且未在系统中有调度记录时提示,返回给客户端的错误消息
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 rounded-lg bg-purple-50 p-4 dark:bg-purple-900/20">
|
||||||
|
<div class="flex">
|
||||||
|
<i class="fas fa-lightbulb mt-0.5 text-purple-500"></i>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>工作原理:</strong>系统会提取请求中的原始 session ID (来自
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>metadata.user_id</code
|
||||||
|
>), 并将其与首次调度的账户绑定。后续使用相同 session ID
|
||||||
|
的请求将自动路由到同一账户。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-purple-700 dark:text-purple-300">
|
||||||
|
<strong>新会话识别:</strong>如果绑定会话历史中没有该sessionId但请求中
|
||||||
|
<code class="rounded bg-purple-100 px-1 dark:bg-purple-800"
|
||||||
|
>messages.length > 1</code
|
||||||
|
>, 系统会认为这是一个污染的会话并拒绝请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
class="rounded-lg bg-gray-50 p-4 text-sm text-gray-500 dark:bg-gray-700/50 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
<i class="fas fa-history mr-2"></i>
|
||||||
|
最后更新:{{ formatDateTime(claudeConfig.updatedAt) }}
|
||||||
|
<span v-if="claudeConfig.updatedBy" class="ml-2">
|
||||||
|
由 <strong>{{ claudeConfig.updatedBy }}</strong> 修改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1274,6 +1556,20 @@ const webhookConfig = ref({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Claude 转发配置
|
||||||
|
const claudeConfigLoading = ref(false)
|
||||||
|
const claudeConfig = ref({
|
||||||
|
claudeCodeOnlyEnabled: false,
|
||||||
|
globalSessionBindingEnabled: false,
|
||||||
|
sessionBindingErrorMessage: '你的本地session已污染,请清理后使用。',
|
||||||
|
sessionBindingTtlDays: 30,
|
||||||
|
userMessageQueueEnabled: true,
|
||||||
|
userMessageQueueDelayMs: 200,
|
||||||
|
userMessageQueueTimeoutMs: 30000,
|
||||||
|
updatedAt: null,
|
||||||
|
updatedBy: null
|
||||||
|
})
|
||||||
|
|
||||||
// 平台表单相关
|
// 平台表单相关
|
||||||
const showAddPlatformModal = ref(false)
|
const showAddPlatformModal = ref(false)
|
||||||
const editingPlatform = ref(null)
|
const editingPlatform = ref(null)
|
||||||
@@ -1311,6 +1607,8 @@ const sectionWatcher = watch(activeSection, async (newSection) => {
|
|||||||
if (!isMounted.value) return
|
if (!isMounted.value) return
|
||||||
if (newSection === 'webhook') {
|
if (newSection === 'webhook') {
|
||||||
await loadWebhookConfig()
|
await loadWebhookConfig()
|
||||||
|
} else if (newSection === 'claude') {
|
||||||
|
await loadClaudeConfig()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1522,6 +1820,73 @@ const saveWebhookConfig = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载 Claude 转发配置
|
||||||
|
const loadClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
claudeConfigLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/admin/claude-relay-config', {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
claudeCodeOnlyEnabled: response.config?.claudeCodeOnlyEnabled ?? false,
|
||||||
|
globalSessionBindingEnabled: response.config?.globalSessionBindingEnabled ?? false,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('获取 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
if (isMounted.value) {
|
||||||
|
claudeConfigLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存 Claude 转发配置
|
||||||
|
const saveClaudeConfig = async () => {
|
||||||
|
if (!isMounted.value) return
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
claudeCodeOnlyEnabled: claudeConfig.value.claudeCodeOnlyEnabled,
|
||||||
|
globalSessionBindingEnabled: claudeConfig.value.globalSessionBindingEnabled,
|
||||||
|
sessionBindingErrorMessage: claudeConfig.value.sessionBindingErrorMessage,
|
||||||
|
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, {
|
||||||
|
signal: abortController.value.signal
|
||||||
|
})
|
||||||
|
if (response.success && isMounted.value) {
|
||||||
|
claudeConfig.value = {
|
||||||
|
...claudeConfig.value,
|
||||||
|
updatedAt: response.config?.updatedAt || new Date().toISOString(),
|
||||||
|
updatedBy: response.config?.updatedBy || null
|
||||||
|
}
|
||||||
|
showToast('Claude 转发配置已保存', 'success')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'AbortError') return
|
||||||
|
if (!isMounted.value) return
|
||||||
|
showToast('保存 Claude 转发配置失败', 'error')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证 URL
|
// 验证 URL
|
||||||
const validateUrl = () => {
|
const validateUrl = () => {
|
||||||
// Bark和SMTP平台不需要验证URL
|
// Bark和SMTP平台不需要验证URL
|
||||||
|
|||||||
Reference in New Issue
Block a user