[](https://opensource.org/licenses/MIT)
@@ -426,6 +431,8 @@ export ANTHROPIC_MODEL="gemini-2.5-pro"
如果该文件不存在,请手动创建。Windows 用户路径为 `C:\Users\你的用户名\.claude\config.json`。
+> 💡 **IntelliJ IDEA 用户推荐**:[Claude Code Plus](https://github.com/touwaeriol/claude-code-plus) - 将 Claude Code 直接集成到 IDE,支持代码理解、文件读写、命令执行。插件市场搜索 `Claude Code Plus` 即可安装。
+
**Gemini CLI 设置环境变量:**
**方式一(推荐):通过 Gemini Assist API 方式访问**
diff --git a/README_EN.md b/README_EN.md
index f9a0e1c5..037d81ac 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -1,5 +1,10 @@
# Claude Relay Service
+> [!CAUTION]
+> **Security Update**: v1.1.240 and below contain a critical admin authentication bypass vulnerability allowing unauthorized access to the admin panel.
+>
+> **Please update to v1.1.241+ immediately**, or migrate to the next-generation project **[CRS 2.0 (sub2api)](https://github.com/Wei-Shaw/sub2api)**
+
[](https://opensource.org/licenses/MIT)
diff --git a/VERSION b/VERSION
index f1bc9377..9c6cacb8 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.235
+1.1.241
diff --git a/config/config.example.js b/config/config.example.js
index 9cf26002..e5e0c340 100644
--- a/config/config.example.js
+++ b/config/config.example.js
@@ -205,6 +205,14 @@ const config = {
hotReload: process.env.HOT_RELOAD === 'true'
},
+ // 💰 账户余额相关配置
+ accountBalance: {
+ // 是否允许执行自定义余额脚本(安全开关)
+ // 说明:脚本能力可发起任意 HTTP 请求并在服务端执行 extractor 逻辑,建议仅在受控环境开启
+ // 默认保持开启;如需禁用请显式设置:BALANCE_SCRIPT_ENABLED=false
+ enableBalanceScript: process.env.BALANCE_SCRIPT_ENABLED !== 'false'
+ },
+
// 📬 用户消息队列配置
// 优化说明:锁在请求发送成功后立即释放(而非请求完成后),因为 Claude API 限流基于请求发送时刻计算
userMessageQueue: {
diff --git a/package-lock.json b/package-lock.json
index 4fa299a4..d9ebcff0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
+ "node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
@@ -7028,6 +7029,15 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-cron": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmmirror.com/node-cron/-/node-cron-4.2.1.tgz",
+ "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz",
diff --git a/package.json b/package.json
index 2b7ffa25..6ef88e60 100644
--- a/package.json
+++ b/package.json
@@ -65,6 +65,7 @@
"ioredis": "^5.3.2",
"ldapjs": "^3.0.7",
"morgan": "^1.10.0",
+ "node-cron": "^4.2.1",
"nodemailer": "^7.0.6",
"ora": "^5.4.1",
"rate-limiter-flexible": "^5.0.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dafee4e7..9e8dc0fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -59,6 +59,9 @@ importers:
morgan:
specifier: ^1.10.0
version: 1.10.1
+ node-cron:
+ specifier: ^4.2.1
+ version: 4.2.1
nodemailer:
specifier: ^7.0.6
version: 7.0.11
@@ -108,6 +111,9 @@ importers:
prettier:
specifier: ^3.6.2
version: 3.7.4
+ prettier-plugin-tailwindcss:
+ specifier: ^0.7.2
+ version: 0.7.2(prettier@3.7.4)
supertest:
specifier: ^6.3.3
version: 6.3.4
@@ -2144,6 +2150,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
+ node-cron@4.2.1:
+ resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
+ engines: {node: '>=6.0.0'}
+
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
@@ -2302,6 +2312,61 @@ packages:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
+ prettier-plugin-tailwindcss@0.7.2:
+ resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==}
+ engines: {node: '>=20.19'}
+ peerDependencies:
+ '@ianvs/prettier-plugin-sort-imports': '*'
+ '@prettier/plugin-hermes': '*'
+ '@prettier/plugin-oxc': '*'
+ '@prettier/plugin-pug': '*'
+ '@shopify/prettier-plugin-liquid': '*'
+ '@trivago/prettier-plugin-sort-imports': '*'
+ '@zackad/prettier-plugin-twig': '*'
+ prettier: ^3.0
+ prettier-plugin-astro: '*'
+ prettier-plugin-css-order: '*'
+ prettier-plugin-jsdoc: '*'
+ prettier-plugin-marko: '*'
+ prettier-plugin-multiline-arrays: '*'
+ prettier-plugin-organize-attributes: '*'
+ prettier-plugin-organize-imports: '*'
+ prettier-plugin-sort-imports: '*'
+ prettier-plugin-svelte: '*'
+ peerDependenciesMeta:
+ '@ianvs/prettier-plugin-sort-imports':
+ optional: true
+ '@prettier/plugin-hermes':
+ optional: true
+ '@prettier/plugin-oxc':
+ optional: true
+ '@prettier/plugin-pug':
+ optional: true
+ '@shopify/prettier-plugin-liquid':
+ optional: true
+ '@trivago/prettier-plugin-sort-imports':
+ optional: true
+ '@zackad/prettier-plugin-twig':
+ optional: true
+ prettier-plugin-astro:
+ optional: true
+ prettier-plugin-css-order:
+ optional: true
+ prettier-plugin-jsdoc:
+ optional: true
+ prettier-plugin-marko:
+ optional: true
+ prettier-plugin-multiline-arrays:
+ optional: true
+ prettier-plugin-organize-attributes:
+ optional: true
+ prettier-plugin-organize-imports:
+ optional: true
+ prettier-plugin-sort-imports:
+ optional: true
+ prettier-plugin-svelte:
+ optional: true
+
prettier@3.7.4:
resolution: {integrity: sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==}
engines: {node: '>=14'}
@@ -5692,6 +5757,8 @@ snapshots:
negotiator@0.6.4: {}
+ node-cron@4.2.1: {}
+
node-domexception@1.0.0: {}
node-fetch@3.3.2:
@@ -5840,6 +5907,10 @@ snapshots:
dependencies:
fast-diff: 1.3.0
+ prettier-plugin-tailwindcss@0.7.2(prettier@3.7.4):
+ dependencies:
+ prettier: 3.7.4
+
prettier@3.7.4: {}
pretty-format@29.7.0:
diff --git a/src/app.js b/src/app.js
index 1ea2f325..f83be464 100644
--- a/src/app.js
+++ b/src/app.js
@@ -52,6 +52,16 @@ class Application {
await redis.connect()
logger.success('✅ Redis connected successfully')
+ // 💳 初始化账户余额查询服务(Provider 注册)
+ try {
+ const accountBalanceService = require('./services/accountBalanceService')
+ const { registerAllProviders } = require('./services/balanceProviders')
+ registerAllProviders(accountBalanceService)
+ logger.info('✅ 账户余额查询服务已初始化')
+ } catch (error) {
+ logger.warn('⚠️ 账户余额查询服务初始化失败:', error.message)
+ }
+
// 💰 初始化价格服务
logger.info('🔄 Initializing pricing service...')
await pricingService.initialize()
@@ -68,6 +78,10 @@ class Application {
logger.info('🔄 Initializing admin credentials...')
await this.initializeAdmin()
+ // 🔒 安全启动:清理无效/伪造的管理员会话
+ logger.info('🔒 Cleaning up invalid admin sessions...')
+ await this.cleanupInvalidSessions()
+
// 💰 初始化费用数据
logger.info('💰 Checking cost data initialization...')
const costInitService = require('./services/costInitService')
@@ -445,6 +459,54 @@ class Application {
}
}
+ // 🔒 清理无效/伪造的管理员会话(安全启动检查)
+ async cleanupInvalidSessions() {
+ try {
+ const client = redis.getClient()
+
+ // 获取所有 session:* 键
+ const sessionKeys = await client.keys('session:*')
+
+ let validCount = 0
+ let invalidCount = 0
+
+ for (const key of sessionKeys) {
+ // 跳过 admin_credentials(系统凭据)
+ if (key === 'session:admin_credentials') {
+ continue
+ }
+
+ const sessionData = await client.hgetall(key)
+
+ // 检查会话完整性:必须有 username 和 loginTime
+ const hasUsername = !!sessionData.username
+ const hasLoginTime = !!sessionData.loginTime
+
+ if (!hasUsername || !hasLoginTime) {
+ // 无效会话 - 可能是漏洞利用创建的伪造会话
+ invalidCount++
+ logger.security(
+ `🔒 Removing invalid session: ${key} (username: ${hasUsername}, loginTime: ${hasLoginTime})`
+ )
+ await client.del(key)
+ } else {
+ validCount++
+ }
+ }
+
+ if (invalidCount > 0) {
+ logger.security(`🔒 Startup security check: Removed ${invalidCount} invalid sessions`)
+ }
+
+ logger.success(
+ `✅ Session cleanup completed: ${validCount} valid, ${invalidCount} invalid removed`
+ )
+ } catch (error) {
+ // 清理失败不应阻止服务启动
+ logger.error('❌ Failed to cleanup invalid sessions:', error.message)
+ }
+ }
+
// 🔍 Redis健康检查
async checkRedisHealth() {
try {
@@ -600,10 +662,11 @@ class Application {
const now = Date.now()
let totalCleaned = 0
+ let legacyCleaned = 0
// 使用 Lua 脚本批量清理所有过期项
for (const key of keys) {
- // 跳过非 Sorted Set 类型的键(这些键有各自的清理逻辑)
+ // 跳过已知非 Sorted Set 类型的键(这些键有各自的清理逻辑)
// - concurrency:queue:stats:* 是 Hash 类型
// - concurrency:queue:wait_times:* 是 List 类型
// - concurrency:queue:* (不含stats/wait_times) 是 String 类型
@@ -618,11 +681,21 @@ class Application {
}
try {
- const cleaned = await redis.client.eval(
+ // 使用原子 Lua 脚本:先检查类型,再执行清理
+ // 返回值:0 = 正常清理无删除,1 = 清理后删除空键,-1 = 遗留键已删除
+ const result = await redis.client.eval(
`
local key = KEYS[1]
local now = tonumber(ARGV[1])
+ -- 先检查键类型,只对 Sorted Set 执行清理
+ local keyType = redis.call('TYPE', key)
+ if keyType.ok ~= 'zset' then
+ -- 非 ZSET 类型的遗留键,直接删除
+ redis.call('DEL', key)
+ return -1
+ end
+
-- 清理过期项
redis.call('ZREMRANGEBYSCORE', key, '-inf', now)
@@ -641,8 +714,10 @@ class Application {
key,
now
)
- if (cleaned === 1) {
+ if (result === 1) {
totalCleaned++
+ } else if (result === -1) {
+ legacyCleaned++
}
} catch (error) {
logger.error(`❌ Failed to clean concurrency key ${key}:`, error)
@@ -652,6 +727,9 @@ class Application {
if (totalCleaned > 0) {
logger.info(`🔢 Concurrency cleanup: cleaned ${totalCleaned} expired keys`)
}
+ if (legacyCleaned > 0) {
+ logger.warn(`🧹 Concurrency cleanup: removed ${legacyCleaned} legacy keys (wrong type)`)
+ }
} catch (error) {
logger.error('❌ Concurrency cleanup task failed:', error)
}
@@ -680,6 +758,19 @@ class Application {
'🚦 Skipping concurrency queue cleanup on startup (CLEAR_CONCURRENCY_QUEUES_ON_STARTUP=false)'
)
}
+
+ // 🧪 启动账户定时测试调度器
+ // 根据配置定期测试账户连通性并保存测试历史
+ const accountTestSchedulerEnabled =
+ process.env.ACCOUNT_TEST_SCHEDULER_ENABLED !== 'false' &&
+ config.accountTestScheduler?.enabled !== false
+ if (accountTestSchedulerEnabled) {
+ const accountTestSchedulerService = require('./services/accountTestSchedulerService')
+ accountTestSchedulerService.start()
+ logger.info('🧪 Account test scheduler service started')
+ } else {
+ logger.info('🧪 Account test scheduler service disabled')
+ }
}
setupGracefulShutdown() {
@@ -734,6 +825,15 @@ class Application {
logger.error('❌ Error stopping cost rank service:', error)
}
+ // 停止账户定时测试调度器
+ try {
+ const accountTestSchedulerService = require('./services/accountTestSchedulerService')
+ accountTestSchedulerService.stop()
+ logger.info('🧪 Account test scheduler service stopped')
+ } catch (error) {
+ logger.error('❌ Error stopping account test scheduler service:', error)
+ }
+
// 🔢 清理所有并发计数(Phase 1 修复:防止重启泄漏)
try {
logger.info('🔢 Cleaning up all concurrency counters...')
diff --git a/src/handlers/geminiHandlers.js b/src/handlers/geminiHandlers.js
index dc7dc676..05e3fd25 100644
--- a/src/handlers/geminiHandlers.js
+++ b/src/handlers/geminiHandlers.js
@@ -87,8 +87,7 @@ function generateSessionHash(req) {
* 检查 API Key 权限
*/
function checkPermissions(apiKeyData, requiredPermission = 'gemini') {
- const permissions = apiKeyData?.permissions || 'all'
- return permissions === 'all' || permissions === requiredPermission
+ return apiKeyService.hasPermission(apiKeyData?.permissions, requiredPermission)
}
/**
diff --git a/src/middleware/auth.js b/src/middleware/auth.js
index 2af4ac4d..44e3cb37 100644
--- a/src/middleware/auth.js
+++ b/src/middleware/auth.js
@@ -1389,6 +1389,18 @@ const authenticateAdmin = async (req, res, next) => {
})
}
+ // 🔒 安全修复:验证会话必须字段(防止伪造会话绕过认证)
+ if (!adminSession.username || !adminSession.loginTime) {
+ logger.security(
+ `🔒 Corrupted admin session from ${req.ip || 'unknown'} - missing required fields (username: ${!!adminSession.username}, loginTime: ${!!adminSession.loginTime})`
+ )
+ await redis.deleteSession(token) // 清理无效/伪造的会话
+ return res.status(401).json({
+ error: 'Invalid session',
+ message: 'Session data corrupted or incomplete'
+ })
+ }
+
// 检查会话活跃性(可选:检查最后活动时间)
const now = new Date()
const lastActivity = new Date(adminSession.lastActivity || adminSession.loginTime)
@@ -1744,9 +1756,13 @@ const requestLogger = (req, res, next) => {
const referer = req.get('Referer') || 'none'
// 记录请求开始
+ const isDebugRoute = req.originalUrl.includes('event_logging')
if (req.originalUrl !== '/health') {
- // 避免健康检查日志过多
- logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
+ if (isDebugRoute) {
+ logger.debug(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
+ } else {
+ logger.info(`▶️ [${requestId}] ${req.method} ${req.originalUrl} | IP: ${clientIP}`)
+ }
}
res.on('finish', () => {
@@ -1778,7 +1794,14 @@ const requestLogger = (req, res, next) => {
logMetadata
)
} else if (req.originalUrl !== '/health') {
- logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
+ if (isDebugRoute) {
+ logger.debug(
+ `🟢 ${req.method} ${req.originalUrl} - ${res.statusCode} (${duration}ms)`,
+ logMetadata
+ )
+ } else {
+ logger.request(req.method, req.originalUrl, res.statusCode, duration, logMetadata)
+ }
}
// API Key相关日志
diff --git a/src/models/redis.js b/src/models/redis.js
index b75c0936..e69ba727 100644
--- a/src/models/redis.js
+++ b/src/models/redis.js
@@ -96,7 +96,25 @@ class RedisClient {
logger.warn('⚠️ Redis connection closed')
})
- await this.client.connect()
+ // 只有在 lazyConnect 模式下才需要手动调用 connect()
+ // 如果 Redis 已经连接或正在连接中,则跳过
+ if (
+ this.client.status !== 'connecting' &&
+ this.client.status !== 'connect' &&
+ this.client.status !== 'ready'
+ ) {
+ await this.client.connect()
+ } else {
+ // 等待 ready 状态
+ await new Promise((resolve, reject) => {
+ if (this.client.status === 'ready') {
+ resolve()
+ } else {
+ this.client.once('ready', resolve)
+ this.client.once('error', reject)
+ }
+ })
+ }
return this.client
} catch (error) {
logger.error('💥 Failed to connect to Redis:', error)
@@ -1503,6 +1521,123 @@ class RedisClient {
return await this.client.del(key)
}
+ // 💰 账户余额缓存(API 查询结果)
+ async setAccountBalance(platform, accountId, balanceData, ttl = 3600) {
+ const key = `account_balance:${platform}:${accountId}`
+
+ const payload = {
+ balance:
+ balanceData && balanceData.balance !== null && balanceData.balance !== undefined
+ ? String(balanceData.balance)
+ : '',
+ currency: balanceData?.currency || 'USD',
+ lastRefreshAt: balanceData?.lastRefreshAt || new Date().toISOString(),
+ queryMethod: balanceData?.queryMethod || 'api',
+ status: balanceData?.status || 'success',
+ errorMessage: balanceData?.errorMessage || balanceData?.error || '',
+ rawData: balanceData?.rawData ? JSON.stringify(balanceData.rawData) : '',
+ quota: balanceData?.quota ? JSON.stringify(balanceData.quota) : ''
+ }
+
+ await this.client.hset(key, payload)
+ await this.client.expire(key, ttl)
+ }
+
+ async getAccountBalance(platform, accountId) {
+ const key = `account_balance:${platform}:${accountId}`
+ const [data, ttlSeconds] = await Promise.all([this.client.hgetall(key), this.client.ttl(key)])
+
+ if (!data || Object.keys(data).length === 0) {
+ return null
+ }
+
+ let rawData = null
+ if (data.rawData) {
+ try {
+ rawData = JSON.parse(data.rawData)
+ } catch (error) {
+ rawData = null
+ }
+ }
+
+ let quota = null
+ if (data.quota) {
+ try {
+ quota = JSON.parse(data.quota)
+ } catch (error) {
+ quota = null
+ }
+ }
+
+ return {
+ balance: data.balance ? parseFloat(data.balance) : null,
+ currency: data.currency || 'USD',
+ lastRefreshAt: data.lastRefreshAt || null,
+ queryMethod: data.queryMethod || null,
+ status: data.status || null,
+ errorMessage: data.errorMessage || '',
+ rawData,
+ quota,
+ ttlSeconds: Number.isFinite(ttlSeconds) ? ttlSeconds : null
+ }
+ }
+
+ // 📊 账户余额缓存(本地统计)
+ async setLocalBalance(platform, accountId, statisticsData, ttl = 300) {
+ const key = `account_balance_local:${platform}:${accountId}`
+
+ await this.client.hset(key, {
+ estimatedBalance: JSON.stringify(statisticsData || {}),
+ lastCalculated: new Date().toISOString()
+ })
+ await this.client.expire(key, ttl)
+ }
+
+ async getLocalBalance(platform, accountId) {
+ const key = `account_balance_local:${platform}:${accountId}`
+ const data = await this.client.hgetall(key)
+
+ if (!data || !data.estimatedBalance) {
+ return null
+ }
+
+ try {
+ return JSON.parse(data.estimatedBalance)
+ } catch (error) {
+ return null
+ }
+ }
+
+ async deleteAccountBalance(platform, accountId) {
+ const key = `account_balance:${platform}:${accountId}`
+ const localKey = `account_balance_local:${platform}:${accountId}`
+ await this.client.del(key, localKey)
+ }
+
+ // 🧩 账户余额脚本配置
+ async setBalanceScriptConfig(platform, accountId, scriptConfig) {
+ const key = `account_balance_script:${platform}:${accountId}`
+ await this.client.set(key, JSON.stringify(scriptConfig || {}))
+ }
+
+ async getBalanceScriptConfig(platform, accountId) {
+ const key = `account_balance_script:${platform}:${accountId}`
+ const raw = await this.client.get(key)
+ if (!raw) {
+ return null
+ }
+ try {
+ return JSON.parse(raw)
+ } catch (error) {
+ return null
+ }
+ }
+
+ async deleteBalanceScriptConfig(platform, accountId) {
+ const key = `account_balance_script:${platform}:${accountId}`
+ return await this.client.del(key)
+ }
+
// 📈 系统统计
async getSystemStats() {
const keys = await Promise.all([
@@ -2122,6 +2257,27 @@ class RedisClient {
const results = []
for (const key of keys) {
+ // 跳过已知非 Sorted Set 类型的键
+ // - concurrency:queue:stats:* 是 Hash 类型
+ // - concurrency:queue:wait_times:* 是 List 类型
+ // - concurrency:queue:* (不含stats/wait_times) 是 String 类型
+ if (
+ key.startsWith('concurrency:queue:stats:') ||
+ key.startsWith('concurrency:queue:wait_times:') ||
+ (key.startsWith('concurrency:queue:') &&
+ !key.includes(':stats:') &&
+ !key.includes(':wait_times:'))
+ ) {
+ continue
+ }
+
+ // 检查键类型,只处理 Sorted Set
+ const keyType = await client.type(key)
+ if (keyType !== 'zset') {
+ logger.debug(`🔢 getAllConcurrencyStatus skipped non-zset key: ${key} (type: ${keyType})`)
+ continue
+ }
+
// 提取 apiKeyId(去掉 concurrency: 前缀)
const apiKeyId = key.replace('concurrency:', '')
@@ -2184,6 +2340,23 @@ class RedisClient {
}
}
+ // 检查键类型,只处理 Sorted Set
+ const keyType = await client.type(key)
+ if (keyType !== 'zset') {
+ logger.warn(
+ `⚠️ getConcurrencyStatus: key ${key} has unexpected type: ${keyType}, expected zset`
+ )
+ return {
+ apiKeyId,
+ key,
+ activeCount: 0,
+ expiredCount: 0,
+ activeRequests: [],
+ exists: true,
+ invalidType: keyType
+ }
+ }
+
// 获取所有成员和分数
const allMembers = await client.zrange(key, 0, -1, 'WITHSCORES')
@@ -2233,20 +2406,36 @@ class RedisClient {
const client = this.getClientSafe()
const key = `concurrency:${apiKeyId}`
- // 获取清理前的状态
- const beforeCount = await client.zcard(key)
+ // 检查键类型
+ const keyType = await client.type(key)
- // 删除整个 key
+ let beforeCount = 0
+ let isLegacy = false
+
+ if (keyType === 'zset') {
+ // 正常的 zset 键,获取条目数
+ beforeCount = await client.zcard(key)
+ } else if (keyType !== 'none') {
+ // 非 zset 且非空的遗留键
+ isLegacy = true
+ logger.warn(
+ `⚠️ forceClearConcurrency: key ${key} has unexpected type: ${keyType}, will be deleted`
+ )
+ }
+
+ // 删除键(无论什么类型)
await client.del(key)
logger.warn(
- `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries`
+ `🧹 Force cleared concurrency for key ${apiKeyId}, removed ${beforeCount} entries${isLegacy ? ' (legacy key)' : ''}`
)
return {
apiKeyId,
key,
clearedCount: beforeCount,
+ type: keyType,
+ legacy: isLegacy,
success: true
}
} catch (error) {
@@ -2265,25 +2454,47 @@ class RedisClient {
const keys = await client.keys('concurrency:*')
let totalCleared = 0
+ let legacyCleared = 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
- })
+ // 跳过 queue 相关的键(它们有各自的清理逻辑)
+ if (key.startsWith('concurrency:queue:')) {
+ continue
+ }
+
+ // 检查键类型
+ const keyType = await client.type(key)
+ if (keyType === 'zset') {
+ const count = await client.zcard(key)
+ await client.del(key)
+ totalCleared += count
+ clearedKeys.push({
+ key,
+ clearedCount: count,
+ type: 'zset'
+ })
+ } else {
+ // 非 zset 类型的遗留键,直接删除
+ await client.del(key)
+ legacyCleared++
+ clearedKeys.push({
+ key,
+ clearedCount: 0,
+ type: keyType,
+ legacy: true
+ })
+ }
}
logger.warn(
- `🧹 Force cleared all concurrency: ${keys.length} keys, ${totalCleared} total entries`
+ `🧹 Force cleared all concurrency: ${clearedKeys.length} keys, ${totalCleared} entries, ${legacyCleared} legacy keys`
)
return {
- keysCleared: keys.length,
+ keysCleared: clearedKeys.length,
totalEntriesCleared: totalCleared,
+ legacyKeysCleared: legacyCleared,
clearedKeys,
success: true
}
@@ -2311,9 +2522,30 @@ class RedisClient {
}
let totalCleaned = 0
+ let legacyCleaned = 0
const cleanedKeys = []
for (const key of keys) {
+ // 跳过 queue 相关的键(它们有各自的清理逻辑)
+ if (key.startsWith('concurrency:queue:')) {
+ continue
+ }
+
+ // 检查键类型
+ const keyType = await client.type(key)
+ if (keyType !== 'zset') {
+ // 非 zset 类型的遗留键,直接删除
+ await client.del(key)
+ legacyCleaned++
+ cleanedKeys.push({
+ key,
+ cleanedCount: 0,
+ type: keyType,
+ legacy: true
+ })
+ continue
+ }
+
// 只清理过期的条目
const cleaned = await client.zremrangebyscore(key, '-inf', now)
if (cleaned > 0) {
@@ -2332,13 +2564,14 @@ class RedisClient {
}
logger.info(
- `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys`
+ `🧹 Cleaned up expired concurrency: ${totalCleaned} entries from ${cleanedKeys.length} keys, ${legacyCleaned} legacy keys removed`
)
return {
keysProcessed: keys.length,
keysCleaned: cleanedKeys.length,
totalEntriesCleaned: totalCleaned,
+ legacyKeysRemoved: legacyCleaned,
cleanedKeys,
success: true
}
@@ -3157,4 +3390,249 @@ redisClient.scanConcurrencyQueueStatsKeys = async function () {
}
}
+// ============================================================================
+// 账户测试历史相关操作
+// ============================================================================
+
+const ACCOUNT_TEST_HISTORY_MAX = 5 // 保留最近5次测试记录
+const ACCOUNT_TEST_HISTORY_TTL = 86400 * 30 // 30天过期
+const ACCOUNT_TEST_CONFIG_TTL = 86400 * 365 // 测试配置保留1年(用户通常长期使用)
+
+/**
+ * 保存账户测试结果
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型 (claude/gemini/openai等)
+ * @param {Object} testResult - 测试结果对象
+ * @param {boolean} testResult.success - 是否成功
+ * @param {string} testResult.message - 测试消息/响应
+ * @param {number} testResult.latencyMs - 延迟毫秒数
+ * @param {string} testResult.error - 错误信息(如有)
+ * @param {string} testResult.timestamp - 测试时间戳
+ */
+redisClient.saveAccountTestResult = async function (accountId, platform, testResult) {
+ const key = `account:test_history:${platform}:${accountId}`
+ try {
+ const record = JSON.stringify({
+ ...testResult,
+ timestamp: testResult.timestamp || new Date().toISOString()
+ })
+
+ // 使用 LPUSH + LTRIM 保持最近5条记录
+ const client = this.getClientSafe()
+ await client.lpush(key, record)
+ await client.ltrim(key, 0, ACCOUNT_TEST_HISTORY_MAX - 1)
+ await client.expire(key, ACCOUNT_TEST_HISTORY_TTL)
+
+ logger.debug(`📝 Saved test result for ${platform} account ${accountId}`)
+ } catch (error) {
+ logger.error(`Failed to save test result for ${accountId}:`, error)
+ }
+}
+
+/**
+ * 获取账户测试历史
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型
+ * @returns {Promise
} 测试历史记录数组(最新在前)
+ */
+redisClient.getAccountTestHistory = async function (accountId, platform) {
+ const key = `account:test_history:${platform}:${accountId}`
+ try {
+ const client = this.getClientSafe()
+ const records = await client.lrange(key, 0, -1)
+ return records.map((r) => JSON.parse(r))
+ } catch (error) {
+ logger.error(`Failed to get test history for ${accountId}:`, error)
+ return []
+ }
+}
+
+/**
+ * 获取账户最新测试结果
+ * @param {string} accountId - 账户ID
+ * @param {string} platform - 平台类型
+ * @returns {Promise