From 723f13eb2bcdd705646100136d88f6090feab3c0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Sep 2025 08:10:25 +0000 Subject: [PATCH 01/21] chore: sync VERSION file with release v1.1.129 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 48cd0cc3..1e9370f3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.128 +1.1.129 From cd2ccef5a17c77bbb202cee497804a4481c21a6d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Sep 2025 11:10:46 +0000 Subject: [PATCH 02/21] chore: sync VERSION file with release v1.1.130 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 1e9370f3..6683f49f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.129 +1.1.130 From 4e67e597b0c57d5a4afeec0312d2b496495493c9 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Sat, 6 Sep 2025 22:03:22 +0800 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20API=20Keys=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=A8=E9=83=A8=E6=97=B6=E9=97=B4=E9=80=89?= =?UTF-8?q?=E9=A1=B9=E5=92=8CUI=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加"全部时间"选项到时间范围下拉菜单,可查看所有历史使用数据 - 统一费用显示列,根据选择的时间范围动态显示对应标签 - 支持自定义日期范围查询(最多31天) - 优化日期选择器高度与其他控件对齐(38px) - 使用更通用的标签名称(累计费用、总费用等) - 移除调试console.log语句 后端改进: - 添加自定义日期范围查询支持 - 日期范围验证和31天限制 - 支持all时间范围查询 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 63 +++- web/admin-spa/src/views/ApiKeysView.vue | 395 ++++++++++++++++++++---- 2 files changed, 384 insertions(+), 74 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index c26e613c..fe1ffd8b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -122,7 +122,7 @@ router.get('/api-keys/:keyId/cost-debug', authenticateAdmin, async (req, res) => // 获取所有API Keys router.get('/api-keys', authenticateAdmin, async (req, res) => { try { - const { timeRange = 'all' } = req.query // all, 7days, monthly + const { timeRange = 'all', startDate, endDate } = req.query // all, 7days, monthly, custom const apiKeys = await apiKeyService.getAllApiKeys() // 获取用户服务来补充owner信息 @@ -132,7 +132,32 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const now = new Date() const searchPatterns = [] - if (timeRange === 'today') { + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围 + const redisClient = require('../models/redis') + const start = new Date(startDate) + const end = new Date(endDate) + + // 确保日期范围有效 + if (start > end) { + return res.status(400).json({ error: 'Start date must be before or equal to end date' }) + } + + // 限制最大范围为31天 + const daysDiff = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1 + if (daysDiff > 31) { + return res.status(400).json({ error: 'Date range cannot exceed 31 days' }) + } + + // 生成日期范围内每天的搜索模式 + const currentDate = new Date(start) + while (currentDate <= end) { + const tzDate = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDate.getUTCDate()).padStart(2, '0')}` + searchPatterns.push(`usage:daily:*:${dateStr}`) + currentDate.setDate(currentDate.getDate() + 1) + } + } else if (timeRange === 'today') { // 今日 - 使用时区日期 const redisClient = require('../models/redis') const tzDate = redisClient.getDateInTimezone(now) @@ -233,7 +258,7 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { apiKey.usage.total.formattedCost = CostCalculator.formatCost(totalCost) } } else { - // 7天或本月:重新计算统计数据 + // 7天、本月或自定义日期范围:重新计算统计数据 const tempUsage = { requests: 0, tokens: 0, @@ -274,12 +299,28 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { const tzDate = redisClient.getDateInTimezone(now) const tzMonth = `${tzDate.getUTCFullYear()}-${String(tzDate.getUTCMonth() + 1).padStart(2, '0')}` - const modelKeys = - timeRange === 'today' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) - : timeRange === '7days' - ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) - : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + let modelKeys = [] + if (timeRange === 'custom' && startDate && endDate) { + // 自定义日期范围:获取范围内所有日期的模型统计 + const start = new Date(startDate) + const end = new Date(endDate) + const currentDate = new Date(start) + + while (currentDate <= end) { + const tzDateForKey = redisClient.getDateInTimezone(currentDate) + const dateStr = `${tzDateForKey.getUTCFullYear()}-${String(tzDateForKey.getUTCMonth() + 1).padStart(2, '0')}-${String(tzDateForKey.getUTCDate()).padStart(2, '0')}` + const dayKeys = await client.keys(`usage:${apiKey.id}:model:daily:*:${dateStr}`) + modelKeys = modelKeys.concat(dayKeys) + currentDate.setDate(currentDate.getDate() + 1) + } + } else { + modelKeys = + timeRange === 'today' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:${tzToday}`) + : timeRange === '7days' + ? await client.keys(`usage:${apiKey.id}:model:daily:*:*`) + : await client.keys(`usage:${apiKey.id}:model:monthly:*:${tzMonth}`) + } const modelStatsMap = new Map() @@ -295,8 +336,8 @@ router.get('/api-keys', authenticateAdmin, async (req, res) => { continue } } - } else if (timeRange === 'today') { - // today选项已经在查询时过滤了,不需要额外处理 + } else if (timeRange === 'today' || timeRange === 'custom') { + // today和custom选项已经在查询时过滤了,不需要额外处理 } const modelMatch = key.match( diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index c623d893..600086e7 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -64,12 +64,33 @@ class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-purple-500 opacity-0 blur transition duration-300 group-hover:opacity-20" > + + + +
+
@@ -245,29 +266,13 @@ class="w-[17%] min-w-[140px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300" >
- 使用统计 - 今日费用 + 使用统计(按费用排序) - - - - 总费用 -
- 今日请求 + {{ + getPeriodRequestLabel() + }} {{ formatNumber(key.usage?.daily?.requests || 0) }}次
- 今日费用 - ${{ (key.dailyCost || 0).toFixed(4) }} -
-
- 总费用 + {{ + getPeriodCostLabel() + }} ${{ (key.totalCost || 0).toFixed(4) }}${{ getPeriodCost(key).toFixed(4) }}
@@ -1078,7 +1081,9 @@
- 今日使用 + {{ + globalDateFilter.type === 'custom' ? '累计统计' : '今日使用' + }}
名称 @@ -264,11 +264,6 @@ /> - - 账号 - @@ -323,7 +318,7 @@ class="w-[7%] min-w-[70px] cursor-pointer px-3 py-4 text-right text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-600" @click="sortApiKeys('periodTokens')" > - Token数量 + Token数 - +
- +
-
- {{ key.name }} +
+ + +
+ +
+ {{ key.name }} +
+ +
+ + + + {{ + getClaudeBindingInfo(key) + .replace(/^🔒\s*专属-/, '') + .replace(/^⚠️\s*/, '') + }} + + + + + {{ + getClaudeBindingInfo(key) + .replace(/^🔒\s*专属-/, '') + .replace(/^⚠️\s*/, '') + }} + + + + + {{ getClaudeBindingInfo(key) }} + + + + + {{ + getGeminiBindingInfo(key) + .replace(/^🔒\s*专属-/, '') + .replace(/^⚠️\s*/, '') + }} + + + + + {{ getGeminiBindingInfo(key) }} + + + + + {{ + getOpenAIBindingInfo(key) + .replace(/^🔒\s*专属-/, '') + .replace(/^⚠️\s*/, '') + }} + + + + + {{ getOpenAIBindingInfo(key) }} + + + + + {{ + getBedrockBindingInfo(key) + .replace(/^🔒\s*专属-/, '') + .replace(/^⚠️\s*/, '') + }} + + + + + {{ getBedrockBindingInfo(key) }} + + + + + 共享池 + +
+
-
+
{{ key.ownerDisplayName }}
- - -
- - - - {{ - getClaudeAccountName(key.claudeAccountId) - }} - - - - - {{ - getClaudeConsoleAccountName(key.claudeConsoleAccountId) - }} - - - - - {{ - getGeminiAccountName(key.geminiAccountId) - }} - - - - - {{ - getOpenAIAccountName(key.openaiAccountId) - }} - - - - - {{ - getBedrockAccountName(key.bedrockAccountId) - }} - - - - - 共享池 - -
- - +
- + +
{{ formatNumber(getPeriodRequests(key)) }} @@ -571,7 +648,7 @@
- +
${{ getPeriodCost(key).toFixed(4) }} @@ -634,7 +711,7 @@
- +
{{ formatTokenCount(getPeriodTokens(key)) }} @@ -643,17 +720,24 @@ - {{ formatLastUsed(key.lastUsedAt) }} + + {{ formatLastUsed(key.lastUsedAt) }} + + 从未使用 {{ new Date(key.createdAt).toLocaleDateString() }} - +
已过期 {{ formatExpireDate(key.expiresAt) }} - + {{ formatExpireDate(key.expiresAt) }} 永不过期 -
- +
+
+ +
-
- - + + +
@@ -3413,6 +3416,7 @@ const exportToExcel = () => { // 基础数据 const baseData = { 名称: key.name || '', + 标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无', 请求总数: periodRequests, '总费用($)': periodCost.toFixed(4), Token数: formatTokenCount(periodTokens), @@ -3469,6 +3473,7 @@ const exportToExcel = () => { const headers = Object.keys(exportData[0] || {}) const columnWidths = headers.map((header) => { if (header === '名称') return { wch: 25 } + if (header === '标签') return { wch: 20 } if (header === '最后使用时间') return { wch: 20 } if (header.includes('费用')) return { wch: 15 } if (header.includes('Token')) return { wch: 15 } @@ -3535,6 +3540,11 @@ const exportToExcel = () => { // 根据列类型设置对齐和特殊样式 if (header === '名称') { cellStyle.alignment = { horizontal: 'left', vertical: 'center' } + } else if (header === '标签') { + cellStyle.alignment = { horizontal: 'left', vertical: 'center' } + if (value === '无') { + cellStyle.font = { ...cellStyle.font, color: { rgb: '999999' }, italic: true } + } } else if (header === '最后使用时间') { cellStyle.alignment = { horizontal: 'right', vertical: 'center' } if (value === '从未使用') { From 9d05c03a3a8a776ff3f0c733282774d9b33abf44 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 7 Sep 2025 14:18:58 +0000 Subject: [PATCH 10/21] chore: sync VERSION file with release v1.1.131 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6683f49f..c4c90b95 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.130 +1.1.131 From 9fa7602947a41ca1fae75d7bc99ed6b33237d9f8 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 00:10:01 +0800 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E5=A4=84=E7=90=86=E6=9C=BA=E5=88=B6=E5=92=8C=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将5xx错误阈值从10次降低到3次,符合行业标准(AWS ELB: 2次, K8s: 3次) - 新增网络超时(ETIMEDOUT)错误处理,触发账户降级机制 - 重构错误处理逻辑,提取统一方法_handleServerError,消除75%重复代码 - 支持不同上下文的错误日志(Network, Request, Stream等) - 修复流式请求中的参数作用域问题,确保错误处理一致性 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/services/claudeRelayService.js | 74 +++++++++++++++++------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index f7596bdd..a0e62d74 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -208,19 +208,7 @@ class ClaudeRelayService { // 检查是否为5xx状态码 else if (response.statusCode >= 500 && response.statusCode < 600) { logger.warn(`🔥 Server error (${response.statusCode}) detected for account ${accountId}`) - // 记录5xx错误 - await claudeAccountService.recordServerError(accountId, response.statusCode) - // 检查是否需要标记为临时错误状态(连续3次500) - const errorCount = await claudeAccountService.getServerErrorCount(accountId) - logger.info( - `🔥 Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` - ) - if (errorCount > 10) { - logger.error( - `❌ Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` - ) - await claudeAccountService.markAccountTempError(accountId, sessionHash) - } + await this._handleServerError(accountId, response.statusCode, sessionHash) } // 检查是否为429状态码 else if (response.statusCode === 429) { @@ -742,7 +730,7 @@ class ClaudeRelayService { onRequest(req) } - req.on('error', (error) => { + req.on('error', async (error) => { console.error(': ❌ ', error) logger.error('❌ Claude API request error:', error.message, { code: error.code, @@ -762,14 +750,19 @@ class ClaudeRelayService { errorMessage = 'Connection refused by Claude API server' } else if (error.code === 'ETIMEDOUT') { errorMessage = 'Connection timed out to Claude API server' + + await this._handleServerError(accountId, 504, null, 'Network') } reject(new Error(errorMessage)) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude API request timeout') + + await this._handleServerError(accountId, 504, null, 'Request') + reject(new Error('Request timeout')) }) @@ -989,19 +982,7 @@ class ClaudeRelayService { logger.warn( `🔥 [Stream] Server error (${res.statusCode}) detected for account ${accountId}` ) - // 记录5xx错误 - await claudeAccountService.recordServerError(accountId, res.statusCode) - // 检查是否需要标记为临时错误状态(连续3次500) - const errorCount = await claudeAccountService.getServerErrorCount(accountId) - logger.info( - `🔥 [Stream] Account ${accountId} has ${errorCount} consecutive 5xx errors in the last 5 minutes` - ) - if (errorCount > 10) { - logger.error( - `❌ [Stream] Account ${accountId} exceeded 5xx error threshold (${errorCount} errors), marking as temp_error` - ) - await claudeAccountService.markAccountTempError(accountId, sessionHash) - } + await this._handleServerError(accountId, res.statusCode, sessionHash, '[Stream]') } } @@ -1337,7 +1318,7 @@ class ClaudeRelayService { }) }) - req.on('error', (error) => { + req.on('error', async (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, @@ -1384,9 +1365,10 @@ class ClaudeRelayService { reject(error) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude stream request timeout') + if (!responseStream.headersSent) { responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', @@ -1486,7 +1468,7 @@ class ClaudeRelayService { }) }) - req.on('error', (error) => { + req.on('error', async (error) => { logger.error('❌ Claude stream request error:', error.message, { code: error.code, errno: error.errno, @@ -1533,9 +1515,10 @@ class ClaudeRelayService { reject(error) }) - req.on('timeout', () => { + req.on('timeout', async () => { req.destroy() logger.error('❌ Claude stream request timeout') + if (!responseStream.headersSent) { responseStream.writeHead(504, { 'Content-Type': 'text/event-stream', @@ -1572,6 +1555,33 @@ class ClaudeRelayService { }) } + // 🛠️ 统一的错误处理方法 + async _handleServerError(accountId, statusCode, sessionHash = null, context = '') { + try { + await claudeAccountService.recordServerError(accountId, statusCode) + const errorCount = await claudeAccountService.getServerErrorCount(accountId) + + // 根据错误类型设置不同的阈值和日志前缀 + const isTimeout = statusCode === 504 + const threshold = 3 // 统一使用3次阈值 + const prefix = context ? `${context} ` : '' + + logger.warn( + `⏱️ ${prefix}${isTimeout ? 'Timeout' : 'Server'} error for account ${accountId}, error count: ${errorCount}/${threshold}` + ) + + if (errorCount > threshold) { + const errorTypeLabel = isTimeout ? 'timeout' : '5xx' + logger.error( + `❌ ${prefix}Account ${accountId} exceeded ${errorTypeLabel} error threshold (${errorCount} errors), marking as temp_error` + ) + await claudeAccountService.markAccountTempError(accountId, sessionHash) + } + } catch (handlingError) { + logger.error(`❌ Failed to handle ${context} server error:`, handlingError) + } + } + // 🔄 重试逻辑 async _retryRequest(requestFunc, maxRetries = 3) { let lastError From 9cbf3195e04268f5abaec4db9aadd43bc65cfa31 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 00:43:33 +0800 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=B2=98?= =?UTF-8?q?=E6=80=A7=E4=BC=9A=E8=AF=9DTTL=E7=AE=A1=E7=90=86=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将默认TTL从1小时延长至15天,更适合长期项目开发 - 实现智能续期机制:剩余时间<14天时自动续期到15天 - 添加配置化支持:通过环境变量STICKY_SESSION_TTL_DAYS和STICKY_SESSION_RENEWAL_THRESHOLD_DAYS调整TTL策略 - 集成到所有调度器:Claude、OpenAI、Gemini的普通会话和分组会话 - 提升用户体验:活跃项目会话持续有效,停用项目自动清理 - 性能优化:智能判断减少不必要的Redis EXPIRE操作 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- config/config.example.js | 8 ++++ src/models/redis.js | 52 +++++++++++++++++++++++++- src/services/claudeAccountService.js | 4 ++ src/services/unifiedClaudeScheduler.js | 4 ++ src/services/unifiedGeminiScheduler.js | 4 ++ src/services/unifiedOpenAIScheduler.js | 4 ++ 6 files changed, 74 insertions(+), 2 deletions(-) diff --git a/config/config.example.js b/config/config.example.js index 433ecd1f..a5aa2c7b 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -32,6 +32,14 @@ const config = { enableTLS: process.env.REDIS_ENABLE_TLS === 'true' }, + // 🔗 会话管理配置 + session: { + // 粘性会话TTL配置(天) + stickyTtlDays: parseInt(process.env.STICKY_SESSION_TTL_DAYS) || 15, + // 续期阈值(天) + renewalThresholdDays: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_DAYS) || 14 + }, + // 🎯 Claude API配置 claude: { apiUrl: process.env.CLAUDE_API_URL || 'https://api.anthropic.com/v1/messages', diff --git a/src/models/redis.js b/src/models/redis.js index 145f94cd..c862fb57 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1356,9 +1356,11 @@ class RedisClient { } // 🔗 会话sticky映射管理 - async setSessionAccountMapping(sessionHash, accountId, ttl = 3600) { + async setSessionAccountMapping(sessionHash, accountId, ttl = null) { + const appConfig = require('../../config/config') + const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlDays || 15) * 24 * 60 * 60 const key = `sticky_session:${sessionHash}` - await this.client.set(key, accountId, 'EX', ttl) + await this.client.set(key, accountId, 'EX', defaultTTL) } async getSessionAccountMapping(sessionHash) { @@ -1366,6 +1368,52 @@ class RedisClient { return await this.client.get(key) } + // 🚀 智能会话TTL续期:剩余时间少于阈值时自动续期 + async extendSessionAccountMappingTTL(sessionHash) { + const appConfig = require('../../config/config') + const key = `sticky_session:${sessionHash}` + + // 📊 从配置获取参数 + const ttlDays = appConfig.session?.stickyTtlDays || 15 + const thresholdDays = appConfig.session?.renewalThresholdDays || 14 + + const fullTTL = ttlDays * 24 * 60 * 60 // 转换为秒 + const renewalThreshold = thresholdDays * 24 * 60 * 60 // 转换为秒 + + try { + // 获取当前剩余TTL(秒) + const remainingTTL = await this.client.ttl(key) + + // 键不存在或已过期 + if (remainingTTL === -2) { + return false + } + + // 键存在但没有TTL(永不过期,不需要处理) + if (remainingTTL === -1) { + return true + } + + // 🎯 智能续期策略:仅在剩余时间少于阈值时才续期 + if (remainingTTL < renewalThreshold) { + await this.client.expire(key, fullTTL) + logger.debug( + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 24 / 3600)}d, renewed to ${ttlDays}d)` + ) + return true + } + + // 剩余时间充足,无需续期 + logger.debug( + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 24 / 3600)}d)` + ) + return true + } catch (error) { + logger.error('❌ Failed to extend session TTL:', error) + return false + } + } + async deleteSessionAccountMapping(sessionHash) { const key = `sticky_session:${sessionHash}` return await this.client.del(key) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 86e595e5..29dee2bd 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -695,6 +695,8 @@ class ClaudeAccountService { // 验证映射的账户是否仍然可用 const mappedAccount = activeAccounts.find((acc) => acc.id === mappedAccountId) if (mappedAccount) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` ) @@ -815,6 +817,8 @@ class ClaudeAccountService { ) await redis.deleteSessionAccountMapping(sessionHash) } else { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}` ) diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index c373d1f5..7bceb040 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -177,6 +177,8 @@ class UnifiedClaudeScheduler { requestedModel ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -789,6 +791,8 @@ class UnifiedClaudeScheduler { requestedModel ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) diff --git a/src/services/unifiedGeminiScheduler.js b/src/services/unifiedGeminiScheduler.js index face2c81..27c2387f 100644 --- a/src/services/unifiedGeminiScheduler.js +++ b/src/services/unifiedGeminiScheduler.js @@ -61,6 +61,8 @@ class UnifiedGeminiScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -382,6 +384,8 @@ class UnifiedGeminiScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) diff --git a/src/services/unifiedOpenAIScheduler.js b/src/services/unifiedOpenAIScheduler.js index 85404543..3ccc435b 100644 --- a/src/services/unifiedOpenAIScheduler.js +++ b/src/services/unifiedOpenAIScheduler.js @@ -90,6 +90,8 @@ class UnifiedOpenAIScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account: ${mappedAccount.accountId} (${mappedAccount.accountType}) for session ${sessionHash}` ) @@ -388,6 +390,8 @@ class UnifiedOpenAIScheduler { mappedAccount.accountType ) if (isAvailable) { + // 🚀 智能会话续期:剩余时间少于14天时自动续期到15天 + await redis.extendSessionAccountMappingTTL(sessionHash) logger.info( `🎯 Using sticky session account from group: ${mappedAccount.accountId} (${mappedAccount.accountType})` ) From cc27b377d8f71bc0fbe13d5490056c52cf4455c5 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 01:17:35 +0800 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=9F=BA?= =?UTF-8?q?=E4=BA=8E=E6=97=A5=E8=B4=B9=E7=94=A8=E7=9A=84=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E8=B4=9F=E8=BD=BD=E5=9D=87=E8=A1=A1=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 sortAccountsByCost() 方法,支持按日费用排序账号 - 修改账号选择逻辑从时间排序改为费用排序 - 添加多层容错机制:单账号失败、全局失败、方法异常 - 费用获取失败的账号设为最低优先级,避免故障传播 - 费用相同时仍按时间排序,保持负载均衡 - 增强日志输出,显示账号费用排名和选中账号费用 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/services/claudeAccountService.js | 84 +++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 29dee2bd..deaaf87e 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -712,12 +712,8 @@ class ClaudeAccountService { } // 如果没有映射或映射无效,选择新账户 - // 优先选择最久未使用的账户(负载均衡) - const sortedAccounts = activeAccounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed // 最久未使用的优先 - }) + // 基于日费用选择账户(费用最少的优先) + const sortedAccounts = await this.sortAccountsByCost(activeAccounts) const selectedAccountId = sortedAccounts[0].id @@ -861,12 +857,8 @@ class ClaudeAccountService { return aRateLimitedAt - bRateLimitedAt // 最早限流的优先 }) } else { - // 非限流账户按最后使用时间排序(最久未使用的优先) - candidateAccounts = candidateAccounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed // 最久未使用的优先 - }) + // 非限流账户按日费用排序(费用最少的优先) + candidateAccounts = await this.sortAccountsByCost(candidateAccounts) } if (candidateAccounts.length === 0) { @@ -883,8 +875,10 @@ class ClaudeAccountService { ) } + // 显示选择的账号和其日费用 + const selectedCost = candidateAccounts[0]._dailyCost || 0 logger.info( - `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}` + `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name} - Daily cost: $${selectedCost.toFixed(4)}` ) return selectedAccountId } catch (error) { @@ -2114,6 +2108,70 @@ class ClaudeAccountService { logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) } } + + // 💰 基于日费用排序账号(费用最少的优先) + async sortAccountsByCost(accounts) { + try { + // 并行获取所有账号的日费用 + const accountsWithCost = await Promise.all( + accounts.map(async (account) => { + try { + const dailyCost = await redis.getAccountDailyCost(account.id) + return { + ...account, + _dailyCost: dailyCost + } + } catch (error) { + logger.warn(`Failed to get daily cost for account ${account.id}: ${error.message}`) + return { + ...account, + _dailyCost: Number.MAX_SAFE_INTEGER, // 获取费用失败时,设为最高值(最低优先级) + _costError: true + } + } + }) + ) + + // 按日费用排序(费用最少的优先) + const sortedAccounts = accountsWithCost.sort((a, b) => { + // 如果费用相同,按最后使用时间排序(最久未使用的优先) + if (Math.abs(a._dailyCost - b._dailyCost) < 0.000001) { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + } + return a._dailyCost - b._dailyCost + }) + + // 检查是否所有账号的费用获取都失败了 + const allAccountsHaveErrors = sortedAccounts.every((account) => account._costError) + + if (allAccountsHaveErrors) { + logger.warn('⚠️ All accounts failed to get daily cost, falling back to time-based sorting') + return accounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) + } + + logger.debug('💰 Account cost ranking:') + sortedAccounts.forEach((account, index) => { + const costDisplay = account._costError ? 'ERROR' : `$${account._dailyCost.toFixed(4)}` + logger.debug(` ${index + 1}. ${account.name} (${account.id}): ${costDisplay}`) + }) + + return sortedAccounts + } catch (error) { + logger.error('❌ Failed to sort accounts by cost:', error) + // 回退到原有的按时间排序策略 + return accounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) + } + } } module.exports = new ClaudeAccountService() From bd2f25dc198097f15af6e6cc2561fffe7278d5dd Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 01:43:19 +0800 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20=E5=B0=86=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=E4=BC=98=E5=85=88=E8=B0=83=E5=BA=A6=E9=80=BB=E8=BE=91=E9=9B=86?= =?UTF-8?q?=E6=88=90=E5=88=B0=20UnifiedClaudeScheduler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换 _sortAccountsByPriority 为 _sortAccountsByCost 方法 - 支持多种账户类型的费用获取(claude-official、claude-console、bedrock) - 实现智能降级机制:费用获取失败时自动回退到优先级排序 - 排序优先级:日费用 → 账户优先级 → 最后使用时间 - 添加详细的费用排名调试日志 - 确保所有 API 调用都使用费用优化的账户选择策略 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/services/unifiedClaudeScheduler.js | 115 +++++++++++++++++++++---- 1 file changed, 100 insertions(+), 15 deletions(-) diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 7bceb040..1f17a680 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -206,8 +206,8 @@ class UnifiedClaudeScheduler { } } - // 按优先级和最后使用时间排序 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + // 基于日费用排序账户(费用最少的优先) + const sortedAccounts = await this._sortAccountsByCost(availableAccounts) // 选择第一个账户 const selectedAccount = sortedAccounts[0] @@ -482,19 +482,104 @@ class UnifiedClaudeScheduler { return availableAccounts } - // 🔢 按优先级和最后使用时间排序账户 - _sortAccountsByPriority(accounts) { - return accounts.sort((a, b) => { - // 首先按优先级排序(数字越小优先级越高) - if (a.priority !== b.priority) { - return a.priority - b.priority + // 💰 基于日费用排序账户(费用最少的优先,支持多种账户类型) + async _sortAccountsByCost(accounts) { + try { + // 并行获取所有账号的日费用 + const accountsWithCost = await Promise.all( + accounts.map(async (account) => { + try { + let dailyCost = 0 + + // 根据账户类型获取日费用 + if (account.accountType === 'claude-official') { + dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) + } else if (account.accountType === 'claude-console') { + // Claude Console 账户也使用相同的费用存储机制 + dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) + } else if (account.accountType === 'bedrock') { + // Bedrock 账户也使用相同的费用存储机制 + dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) + } + + return { + ...account, + _dailyCost: dailyCost + } + } catch (error) { + logger.warn( + `Failed to get daily cost for account ${account.accountId || account.id}: ${error.message}` + ) + return { + ...account, + _dailyCost: Number.MAX_SAFE_INTEGER, // 获取费用失败时,设为最高值(最低优先级) + _costError: true + } + } + }) + ) + + // 按日费用排序(费用最少的优先) + const sortedAccounts = accountsWithCost.sort((a, b) => { + // 如果费用相同,按优先级排序 + if (Math.abs(a._dailyCost - b._dailyCost) < 0.000001) { + // 优先级相同时,按最后使用时间排序(最久未使用的优先) + if (a.priority === b.priority) { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + } + return a.priority - b.priority + } + return a._dailyCost - b._dailyCost + }) + + // 检查是否所有账号的费用获取都失败了 + const allAccountsHaveErrors = sortedAccounts.every((account) => account._costError) + + if (allAccountsHaveErrors) { + logger.warn( + '⚠️ All accounts failed to get daily cost, falling back to priority-based sorting' + ) + return accounts.sort((a, b) => { + // 首先按优先级排序 + if (a.priority !== b.priority) { + return a.priority - b.priority + } + // 优先级相同时,按最后使用时间排序 + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) } - // 优先级相同时,按最后使用时间排序(最久未使用的优先) - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) + logger.debug('💰 Account cost ranking:') + sortedAccounts.forEach((account, index) => { + const costDisplay = account._costError ? 'ERROR' : `$${account._dailyCost.toFixed(4)}` + const accountDisplayName = account.name || account.accountId || account.id + logger.debug( + ` ${index + 1}. ${accountDisplayName} (${account.accountType}): ${costDisplay}` + ) + }) + + return sortedAccounts + } catch (error) { + logger.error( + '❌ Failed to sort accounts by cost, falling back to priority-based sorting:', + error + ) + // 回退到原有的按优先级排序策略 + return accounts.sort((a, b) => { + // 首先按优先级排序 + if (a.priority !== b.priority) { + return a.priority - b.priority + } + // 优先级相同时,按最后使用时间排序 + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) + } } // 🔍 检查账户是否可用 @@ -876,8 +961,8 @@ class UnifiedClaudeScheduler { throw new Error(`No available accounts in group ${group.name}`) } - // 使用现有的优先级排序逻辑 - const sortedAccounts = this._sortAccountsByPriority(availableAccounts) + // 使用基于费用的排序逻辑 + const sortedAccounts = await this._sortAccountsByCost(availableAccounts) // 选择第一个账户 const selectedAccount = sortedAccounts[0] From bed7b7f00093c5d0e109ef929ede6a263e3bdec1 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 15:37:16 +0800 Subject: [PATCH 15/21] =?UTF-8?q?refactor:=20=E4=BC=98=E5=8C=96=20API=20Ke?= =?UTF-8?q?ys=20=E7=AE=A1=E7=90=86=E7=95=8C=E9=9D=A2=E5=B8=83=E5=B1=80?= =?UTF-8?q?=E5=92=8C=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主要改进: - 移除 API Key 图标功能,简化界面设计 - 新增独立的"所属账号"列,提高信息层次清晰度 - 统一所有数据列字体大小为 13px,改善可读性 - 优化列宽度分配:名称(14%)、状态(6%)、操作(27%)等 - 调整列显示顺序:费用 → Token → 请求数,更符合逻辑 - 费用显示精度从4位调整为2位小数 - 同步优化已删除 API Keys 表格布局 - 简化 Token 列标题(去掉"数"字) 技术细节: - 使用内联样式统一字体大小 - 保持活跃和已删除表格的一致性 - 清理冗余代码,减少约 30 行 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/components/common/IconPicker.vue | 14 +- web/admin-spa/src/views/ApiKeysView.vue | 540 ++++++++---------- 2 files changed, 266 insertions(+), 288 deletions(-) diff --git a/web/admin-spa/src/components/common/IconPicker.vue b/web/admin-spa/src/components/common/IconPicker.vue index 253f2fe2..d3628591 100644 --- a/web/admin-spa/src/components/common/IconPicker.vue +++ b/web/admin-spa/src/components/common/IconPicker.vue @@ -3,7 +3,7 @@
@@ -757,6 +757,18 @@ const applyCropAndSave = () => { transition: all 0.2s; } +.icon-display.size-small { + width: 24px; + height: 24px; + border-radius: 4px; +} + +.icon-display.size-large { + width: 40px; + height: 40px; + border-radius: 8px; +} + .dark .icon-display { background: #374151; } diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 22d90d91..25e30f3b 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -86,7 +86,7 @@ range-separator="至" size="small" start-placeholder="开始日期" - style="width: 320px" + style="width: 320px; height: 38px" type="datetimerange" :unlink-panels="false" value-format="YYYY-MM-DD HH:mm:ss" @@ -253,7 +253,7 @@
名称 @@ -267,13 +267,18 @@ /> + + 所属账号 + 标签 状态 @@ -288,22 +293,7 @@ - 请求数 - - - - 费用 @@ -318,10 +308,10 @@ - Token数 + Token + + 请求数 + + + 创建时间 @@ -363,7 +368,7 @@ 过期时间 @@ -378,7 +383,7 @@ 操作 @@ -401,212 +406,94 @@
-
- - -
- -
- {{ key.name }} -
- -
- - - - {{ - getClaudeBindingInfo(key) - .replace(/^🔒\s*专属-/, '') - .replace(/^⚠️\s*/, '') - }} - - - - - {{ - getClaudeBindingInfo(key) - .replace(/^🔒\s*专属-/, '') - .replace(/^⚠️\s*/, '') - }} - - - - - {{ getClaudeBindingInfo(key) }} - - - - - {{ - getGeminiBindingInfo(key) - .replace(/^🔒\s*专属-/, '') - .replace(/^⚠️\s*/, '') - }} - - - - - {{ getGeminiBindingInfo(key) }} - - - - - {{ - getOpenAIBindingInfo(key) - .replace(/^🔒\s*专属-/, '') - .replace(/^⚠️\s*/, '') - }} - - - - - {{ getOpenAIBindingInfo(key) }} - - - - - {{ - getBedrockBindingInfo(key) - .replace(/^🔒\s*专属-/, '') - .replace(/^⚠️\s*/, '') - }} - - - - - {{ getBedrockBindingInfo(key) }} - - - - - 共享池 - -
-
-
- -
- -
- - - Claude - - - {{ getClaudeBindingInfo(key) }} - -
- -
- - - Gemini - - - {{ getGeminiBindingInfo(key) }} - -
- -
- - - OpenAI - - - {{ getOpenAIBindingInfo(key) }} - -
- -
- - - Bedrock - - - {{ getBedrockBindingInfo(key) }} - -
+ +
+ {{ key.name }}
{{ key.ownerDisplayName }}
+ + +
+ +
+ + + Claude + + + {{ getClaudeBindingInfo(key) }} + +
+ +
+ + + Gemini + + + {{ getGeminiBindingInfo(key) }} + +
+ +
+ + + OpenAI + + + {{ getOpenAIBindingInfo(key) }} + +
+ +
+ + + Bedrock + + + {{ getBedrockBindingInfo(key) }} + +
+ +
+ + 共享池 +
+
+ +
- - -
- - {{ formatNumber(getPeriodRequests(key)) }} - - -
- - +
- - ${{ getPeriodCost(key).toFixed(4) }} + + ${{ getPeriodCost(key).toFixed(2) }} @@ -714,40 +595,61 @@
- +
- + {{ formatTokenCount(getPeriodTokens(key)) }}
+ + +
+ + {{ formatNumber(getPeriodRequests(key)) }} + + +
+ {{ formatLastUsed(key.lastUsedAt) }} - 从未使用 + 从未使用 {{ new Date(key.createdAt).toLocaleDateString() }} - +
- + 未激活 ({{ key.activationDays || 30 }}天) @@ -755,22 +657,25 @@ - + 已过期 - + {{ formatExpireDate(key.expiresAt) }} {{ formatExpireDate(key.expiresAt) }} @@ -780,14 +685,15 @@ - + 永不过期
- +
+ + +
+ +
+ + + Claude OAuth + +
+ +
+ + + Claude Console + +
+ +
+ + + Gemini + +
+ +
+ + 共享池 +
+
+ + -
+
- + 管理员 - + {{ key.userUsername }} - + 未知
+ {{ formatDate(key.createdAt) }} + -
+
- + {{ key.deletedBy }} - + {{ key.deletedBy }} - + {{ key.deletedBy }}
+ {{ formatDate(key.deletedAt) }} + + + + ${{ (key.usage?.total?.cost || 0).toFixed(2) }} + + + + + + {{ formatTokenCount(key.usage?.total?.tokens || 0) }} + + - +
- + {{ formatNumber(key.usage?.total?.requests || 0) }}
- - - - ${{ (key.usage?.total?.cost || 0).toFixed(4) }} - - - - - - {{ formatTokenCount(key.usage?.total?.tokens || 0) }} - - + - + {{ formatLastUsed(key.lastUsedAt) }} - 从未使用 + 从未使用
@@ -3418,7 +3384,7 @@ const exportToExcel = () => { 名称: key.name || '', 标签: key.tags && key.tags.length > 0 ? key.tags.join(', ') : '无', 请求总数: periodRequests, - '总费用($)': periodCost.toFixed(4), + '总费用($)': periodCost.toFixed(2), Token数: formatTokenCount(periodTokens), 输入Token: formatTokenCount(periodInputTokens), 输出Token: formatTokenCount(periodOutputTokens), @@ -3452,7 +3418,7 @@ const exportToExcel = () => { modelName = modelName.replace(/[:/]/g, '_') modelStats[`${modelName}_请求数`] = stats.requests || 0 - modelStats[`${modelName}_费用($)`] = (stats.cost || 0).toFixed(4) + modelStats[`${modelName}_费用($)`] = (stats.cost || 0).toFixed(2) modelStats[`${modelName}_Token`] = formatTokenCount(stats.totalTokens || 0) modelStats[`${modelName}_输入Token`] = formatTokenCount(stats.inputTokens || 0) modelStats[`${modelName}_输出Token`] = formatTokenCount(stats.outputTokens || 0) From f51d345ad9e8bf7a28511a1f9f870691ff0eed93 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 15:39:14 +0800 Subject: [PATCH 16/21] =?UTF-8?q?Revert=20"feat:=20=E5=B0=86=E8=B4=B9?= =?UTF-8?q?=E7=94=A8=E4=BC=98=E5=85=88=E8=B0=83=E5=BA=A6=E9=80=BB=E8=BE=91?= =?UTF-8?q?=E9=9B=86=E6=88=90=E5=88=B0=20UnifiedClaudeScheduler"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1a5b3b614961e889a0700809e3e86b08eccb5e19. --- src/services/unifiedClaudeScheduler.js | 115 ++++--------------------- 1 file changed, 15 insertions(+), 100 deletions(-) diff --git a/src/services/unifiedClaudeScheduler.js b/src/services/unifiedClaudeScheduler.js index 1f17a680..7bceb040 100644 --- a/src/services/unifiedClaudeScheduler.js +++ b/src/services/unifiedClaudeScheduler.js @@ -206,8 +206,8 @@ class UnifiedClaudeScheduler { } } - // 基于日费用排序账户(费用最少的优先) - const sortedAccounts = await this._sortAccountsByCost(availableAccounts) + // 按优先级和最后使用时间排序 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 const selectedAccount = sortedAccounts[0] @@ -482,104 +482,19 @@ class UnifiedClaudeScheduler { return availableAccounts } - // 💰 基于日费用排序账户(费用最少的优先,支持多种账户类型) - async _sortAccountsByCost(accounts) { - try { - // 并行获取所有账号的日费用 - const accountsWithCost = await Promise.all( - accounts.map(async (account) => { - try { - let dailyCost = 0 - - // 根据账户类型获取日费用 - if (account.accountType === 'claude-official') { - dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) - } else if (account.accountType === 'claude-console') { - // Claude Console 账户也使用相同的费用存储机制 - dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) - } else if (account.accountType === 'bedrock') { - // Bedrock 账户也使用相同的费用存储机制 - dailyCost = await redis.getAccountDailyCost(account.accountId || account.id) - } - - return { - ...account, - _dailyCost: dailyCost - } - } catch (error) { - logger.warn( - `Failed to get daily cost for account ${account.accountId || account.id}: ${error.message}` - ) - return { - ...account, - _dailyCost: Number.MAX_SAFE_INTEGER, // 获取费用失败时,设为最高值(最低优先级) - _costError: true - } - } - }) - ) - - // 按日费用排序(费用最少的优先) - const sortedAccounts = accountsWithCost.sort((a, b) => { - // 如果费用相同,按优先级排序 - if (Math.abs(a._dailyCost - b._dailyCost) < 0.000001) { - // 优先级相同时,按最后使用时间排序(最久未使用的优先) - if (a.priority === b.priority) { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - } - return a.priority - b.priority - } - return a._dailyCost - b._dailyCost - }) - - // 检查是否所有账号的费用获取都失败了 - const allAccountsHaveErrors = sortedAccounts.every((account) => account._costError) - - if (allAccountsHaveErrors) { - logger.warn( - '⚠️ All accounts failed to get daily cost, falling back to priority-based sorting' - ) - return accounts.sort((a, b) => { - // 首先按优先级排序 - if (a.priority !== b.priority) { - return a.priority - b.priority - } - // 优先级相同时,按最后使用时间排序 - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) + // 🔢 按优先级和最后使用时间排序账户 + _sortAccountsByPriority(accounts) { + return accounts.sort((a, b) => { + // 首先按优先级排序(数字越小优先级越高) + if (a.priority !== b.priority) { + return a.priority - b.priority } - logger.debug('💰 Account cost ranking:') - sortedAccounts.forEach((account, index) => { - const costDisplay = account._costError ? 'ERROR' : `$${account._dailyCost.toFixed(4)}` - const accountDisplayName = account.name || account.accountId || account.id - logger.debug( - ` ${index + 1}. ${accountDisplayName} (${account.accountType}): ${costDisplay}` - ) - }) - - return sortedAccounts - } catch (error) { - logger.error( - '❌ Failed to sort accounts by cost, falling back to priority-based sorting:', - error - ) - // 回退到原有的按优先级排序策略 - return accounts.sort((a, b) => { - // 首先按优先级排序 - if (a.priority !== b.priority) { - return a.priority - b.priority - } - // 优先级相同时,按最后使用时间排序 - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) - } + // 优先级相同时,按最后使用时间排序(最久未使用的优先) + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed + }) } // 🔍 检查账户是否可用 @@ -961,8 +876,8 @@ class UnifiedClaudeScheduler { throw new Error(`No available accounts in group ${group.name}`) } - // 使用基于费用的排序逻辑 - const sortedAccounts = await this._sortAccountsByCost(availableAccounts) + // 使用现有的优先级排序逻辑 + const sortedAccounts = this._sortAccountsByPriority(availableAccounts) // 选择第一个账户 const selectedAccount = sortedAccounts[0] From b46ccb10d0914e549ee1ac6192126c54bdec3393 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 15:39:32 +0800 Subject: [PATCH 17/21] =?UTF-8?q?Revert=20"feat:=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E5=9F=BA=E4=BA=8E=E6=97=A5=E8=B4=B9=E7=94=A8=E7=9A=84=E6=99=BA?= =?UTF-8?q?=E8=83=BD=E8=B4=9F=E8=BD=BD=E5=9D=87=E8=A1=A1=E7=AD=96=E7=95=A5?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8976c470e584f1179bcfb30c4856aa6b76633484. --- src/services/claudeAccountService.js | 84 +++++----------------------- 1 file changed, 13 insertions(+), 71 deletions(-) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index deaaf87e..29dee2bd 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -712,8 +712,12 @@ class ClaudeAccountService { } // 如果没有映射或映射无效,选择新账户 - // 基于日费用选择账户(费用最少的优先) - const sortedAccounts = await this.sortAccountsByCost(activeAccounts) + // 优先选择最久未使用的账户(负载均衡) + const sortedAccounts = activeAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) const selectedAccountId = sortedAccounts[0].id @@ -857,8 +861,12 @@ class ClaudeAccountService { return aRateLimitedAt - bRateLimitedAt // 最早限流的优先 }) } else { - // 非限流账户按日费用排序(费用最少的优先) - candidateAccounts = await this.sortAccountsByCost(candidateAccounts) + // 非限流账户按最后使用时间排序(最久未使用的优先) + candidateAccounts = candidateAccounts.sort((a, b) => { + const aLastUsed = new Date(a.lastUsedAt || 0).getTime() + const bLastUsed = new Date(b.lastUsedAt || 0).getTime() + return aLastUsed - bLastUsed // 最久未使用的优先 + }) } if (candidateAccounts.length === 0) { @@ -875,10 +883,8 @@ class ClaudeAccountService { ) } - // 显示选择的账号和其日费用 - const selectedCost = candidateAccounts[0]._dailyCost || 0 logger.info( - `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name} - Daily cost: $${selectedCost.toFixed(4)}` + `🎯 Selected shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}` ) return selectedAccountId } catch (error) { @@ -2108,70 +2114,6 @@ class ClaudeAccountService { logger.error(`❌ Failed to update session window status for account ${accountId}:`, error) } } - - // 💰 基于日费用排序账号(费用最少的优先) - async sortAccountsByCost(accounts) { - try { - // 并行获取所有账号的日费用 - const accountsWithCost = await Promise.all( - accounts.map(async (account) => { - try { - const dailyCost = await redis.getAccountDailyCost(account.id) - return { - ...account, - _dailyCost: dailyCost - } - } catch (error) { - logger.warn(`Failed to get daily cost for account ${account.id}: ${error.message}`) - return { - ...account, - _dailyCost: Number.MAX_SAFE_INTEGER, // 获取费用失败时,设为最高值(最低优先级) - _costError: true - } - } - }) - ) - - // 按日费用排序(费用最少的优先) - const sortedAccounts = accountsWithCost.sort((a, b) => { - // 如果费用相同,按最后使用时间排序(最久未使用的优先) - if (Math.abs(a._dailyCost - b._dailyCost) < 0.000001) { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - } - return a._dailyCost - b._dailyCost - }) - - // 检查是否所有账号的费用获取都失败了 - const allAccountsHaveErrors = sortedAccounts.every((account) => account._costError) - - if (allAccountsHaveErrors) { - logger.warn('⚠️ All accounts failed to get daily cost, falling back to time-based sorting') - return accounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) - } - - logger.debug('💰 Account cost ranking:') - sortedAccounts.forEach((account, index) => { - const costDisplay = account._costError ? 'ERROR' : `$${account._dailyCost.toFixed(4)}` - logger.debug(` ${index + 1}. ${account.name} (${account.id}): ${costDisplay}`) - }) - - return sortedAccounts - } catch (error) { - logger.error('❌ Failed to sort accounts by cost:', error) - // 回退到原有的按时间排序策略 - return accounts.sort((a, b) => { - const aLastUsed = new Date(a.lastUsedAt || 0).getTime() - const bLastUsed = new Date(b.lastUsedAt || 0).getTime() - return aLastUsed - bLastUsed - }) - } - } } module.exports = new ClaudeAccountService() From 3aa7c89e2543aa42f66759a626b8224ac019d12f Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 15:49:25 +0800 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20=E5=AE=8C=E5=85=A8=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=20API=20Key=20=E5=9B=BE=E6=A0=87=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 彻底删除 API Key 图标功能的所有相关代码: 前端改动: - 删除 IconPicker.vue 组件文件 - 移除 ApiKeysView.vue 中的图标显示和 updateApiKeyIcon 方法 - 清理 CreateApiKeyModal.vue 中的图标选择器 - 清理 EditApiKeyModal.vue 中的图标选择器 - 移除所有 IconPicker 组件的引用 后端改动: - 从 apiKeyService.js 中移除 icon 字段更新支持 - 从 admin.js 路由中移除 icon 参数处理和验证逻辑 - 清理创建和更新 API Key 时的 icon 参数 此改动简化了 API Key 管理界面,移除了不必要的图标功能。 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/routes/admin.js | 19 +- src/services/apiKeyService.js | 3 +- .../components/apikeys/CreateApiKeyModal.vue | 12 +- .../components/apikeys/EditApiKeyModal.vue | 8 +- .../src/components/common/IconPicker.vue | 1036 ----------------- web/admin-spa/src/views/ApiKeysView.vue | 29 - 6 files changed, 7 insertions(+), 1100 deletions(-) delete mode 100644 web/admin-spa/src/components/common/IconPicker.vue diff --git a/src/routes/admin.js b/src/routes/admin.js index e6a960b7..7700193b 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -534,8 +534,7 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { weeklyOpusCostLimit, tags, activationDays, // 新增:激活后有效天数 - expirationMode, // 新增:过期模式 - icon // 新增:图标(base64编码) + expirationMode // 新增:过期模式 } = req.body // 输入验证 @@ -989,8 +988,7 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { dailyCostLimit, weeklyOpusCostLimit, tags, - ownerId, // 新增:所有者ID字段 - icon // 新增:图标(base64编码) + ownerId // 新增:所有者ID字段 } = req.body // 只允许更新指定字段 @@ -1164,19 +1162,6 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.tags = tags } - // 处理图标 - if (icon !== undefined) { - // icon 可以是空字符串(清除图标)或 base64 编码的字符串 - if (icon !== '' && typeof icon !== 'string') { - return res.status(400).json({ error: 'Icon must be a string' }) - } - // 简单验证 base64 格式(如果不为空) - if (icon && !icon.startsWith('data:image/')) { - return res.status(400).json({ error: 'Icon must be a valid base64 image' }) - } - updates.icon = icon - } - // 处理活跃/禁用状态状态, 放在过期处理后,以确保后续增加禁用key功能 if (isActive !== undefined) { if (typeof isActive !== 'boolean') { diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index 4291d51d..637b06ec 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -412,8 +412,7 @@ class ApiKeyService { 'tags', 'userId', // 新增:用户ID(所有者变更) 'userUsername', // 新增:用户名(所有者变更) - 'createdBy', // 新增:创建者(所有者变更) - 'icon' // 新增:图标(base64编码) + 'createdBy' // 新增:创建者(所有者变更) ] const updatedData = { ...keyData } diff --git a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue index 340ac507..4e78520f 100644 --- a/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/CreateApiKeyModal.vue @@ -110,9 +110,7 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-2 sm:text-sm" >名称 * -
- - +
{ // 单个创建 const data = { ...baseData, - name: form.name, - icon: form.icon || '' + name: form.name } const result = await apiClient.post('/admin/api-keys', data) @@ -1223,8 +1218,7 @@ const createApiKey = async () => { ...baseData, createType: 'batch', baseName: form.name, - count: form.batchCount, - icon: form.icon || '' + count: form.batchCount } const result = await apiClient.post('/admin/api-keys/batch', data) diff --git a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue index 1bada0a5..fda19ade 100644 --- a/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue +++ b/web/admin-spa/src/components/apikeys/EditApiKeyModal.vue @@ -32,9 +32,7 @@ class="mb-1.5 block text-xs font-semibold text-gray-700 dark:text-gray-300 sm:mb-3 sm:text-sm" >名称 -
- - +
{ // 表单数据 const form = reactive({ name: '', - icon: '', tokenLimit: '', // 保留用于检测历史数据 rateLimitWindow: '', rateLimitRequests: '', @@ -809,7 +805,6 @@ const updateApiKey = async () => { // 准备提交的数据 const data = { name: form.name, // 添加名称字段 - icon: form.icon || '', // 添加图标字段 tokenLimit: 0, // 清除历史token限制 rateLimitWindow: form.rateLimitWindow !== '' && form.rateLimitWindow !== null @@ -1042,7 +1037,6 @@ onMounted(async () => { } form.name = props.apiKey.name - form.icon = props.apiKey.icon || '' // 处理速率限制迁移:如果有tokenLimit且没有rateLimitCost,提示用户 form.tokenLimit = props.apiKey.tokenLimit || '' diff --git a/web/admin-spa/src/components/common/IconPicker.vue b/web/admin-spa/src/components/common/IconPicker.vue deleted file mode 100644 index d3628591..00000000 --- a/web/admin-spa/src/components/common/IconPicker.vue +++ /dev/null @@ -1,1036 +0,0 @@ - - - - - diff --git a/web/admin-spa/src/views/ApiKeysView.vue b/web/admin-spa/src/views/ApiKeysView.vue index 25e30f3b..b3845b7f 100644 --- a/web/admin-spa/src/views/ApiKeysView.vue +++ b/web/admin-spa/src/views/ApiKeysView.vue @@ -1046,12 +1046,6 @@ :value="key.id" @change="updateSelectAllState" /> - -

{{ key.name }} @@ -1758,7 +1752,6 @@ import { apiClient } from '@/config/api' import { useClientsStore } from '@/stores/clients' import { useAuthStore } from '@/stores/auth' import * as XLSX from 'xlsx-js-style' -import IconPicker from '@/components/common/IconPicker.vue' import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue' import EditApiKeyModal from '@/components/apikeys/EditApiKeyModal.vue' import RenewApiKeyModal from '@/components/apikeys/RenewApiKeyModal.vue' @@ -2977,28 +2970,6 @@ const toggleApiKeyStatus = async (key) => { } // 更新API Key图标 -const updateApiKeyIcon = async (keyId, icon) => { - try { - const data = await apiClient.put(`/admin/api-keys/${keyId}`, { - icon: icon - }) - - if (data.success) { - // 更新本地数据 - const localKey = apiKeys.value.find((k) => k.id === keyId) - if (localKey) { - localKey.icon = icon - } - showToast('图标已更新', 'success') - } else { - showToast(data.message || '更新图标失败', 'error') - } - } catch (error) { - console.error('更新图标失败:', error) - showToast('更新图标失败,请重试', 'error') - } -} - // 删除API Key const deleteApiKey = async (keyId) => { let confirmed = false From 8cb9f52c1a4fb30b5174b12c44debedcd57f48a4 Mon Sep 17 00:00:00 2001 From: Edric Li Date: Mon, 8 Sep 2025 16:01:20 +0800 Subject: [PATCH 19/21] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E7=B2=98?= =?UTF-8?q?=E6=80=A7=E4=BC=9A=E8=AF=9DTTL=E7=AE=A1=E7=90=86=E7=AD=96?= =?UTF-8?q?=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将TTL默认值从15天改为1小时,更适合短期会话场景 - 将续期阈值默认设为0,默认不自动续期,提高控制精度 - 时间单位从天调整为小时/分钟,提供更细粒度的控制 - 添加环境变量配置支持:STICKY_SESSION_TTL_HOURS 和 STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES - 保持向后兼容性,所有现有部署将自动使用新的默认值 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .env.example | 6 ++++++ config/config.example.js | 8 ++++---- src/models/redis.js | 20 +++++++++++++------- src/services/claudeAccountService.js | 10 +++++++--- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/.env.example b/.env.example index 62f7fcfb..4d77eb53 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ REDIS_PASSWORD= REDIS_DB=0 REDIS_ENABLE_TLS= +# 🔗 会话管理配置 +# 粘性会话TTL配置(小时),默认1小时 +STICKY_SESSION_TTL_HOURS=1 +# 续期阈值(分钟),默认0分钟(不续期) +STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES=0 + # 🎯 Claude API 配置 CLAUDE_API_URL=https://api.anthropic.com/v1/messages CLAUDE_API_VERSION=2023-06-01 diff --git a/config/config.example.js b/config/config.example.js index a5aa2c7b..1fdcdf7c 100644 --- a/config/config.example.js +++ b/config/config.example.js @@ -34,10 +34,10 @@ const config = { // 🔗 会话管理配置 session: { - // 粘性会话TTL配置(天) - stickyTtlDays: parseInt(process.env.STICKY_SESSION_TTL_DAYS) || 15, - // 续期阈值(天) - renewalThresholdDays: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_DAYS) || 14 + // 粘性会话TTL配置(小时),默认1小时 + stickyTtlHours: parseFloat(process.env.STICKY_SESSION_TTL_HOURS) || 1, + // 续期阈值(分钟),默认0分钟(不续期) + renewalThresholdMinutes: parseInt(process.env.STICKY_SESSION_RENEWAL_THRESHOLD_MINUTES) || 0 }, // 🎯 Claude API配置 diff --git a/src/models/redis.js b/src/models/redis.js index c862fb57..7ef64ebf 100644 --- a/src/models/redis.js +++ b/src/models/redis.js @@ -1358,7 +1358,8 @@ class RedisClient { // 🔗 会话sticky映射管理 async setSessionAccountMapping(sessionHash, accountId, ttl = null) { const appConfig = require('../../config/config') - const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlDays || 15) * 24 * 60 * 60 + // 从配置读取TTL(小时),转换为秒,默认1小时 + const defaultTTL = ttl !== null ? ttl : (appConfig.session?.stickyTtlHours || 1) * 60 * 60 const key = `sticky_session:${sessionHash}` await this.client.set(key, accountId, 'EX', defaultTTL) } @@ -1374,11 +1375,16 @@ class RedisClient { const key = `sticky_session:${sessionHash}` // 📊 从配置获取参数 - const ttlDays = appConfig.session?.stickyTtlDays || 15 - const thresholdDays = appConfig.session?.renewalThresholdDays || 14 + const ttlHours = appConfig.session?.stickyTtlHours || 1 // 小时,默认1小时 + const thresholdMinutes = appConfig.session?.renewalThresholdMinutes || 0 // 分钟,默认0(不续期) - const fullTTL = ttlDays * 24 * 60 * 60 // 转换为秒 - const renewalThreshold = thresholdDays * 24 * 60 * 60 // 转换为秒 + // 如果阈值为0,不执行续期 + if (thresholdMinutes === 0) { + return true + } + + const fullTTL = ttlHours * 60 * 60 // 转换为秒 + const renewalThreshold = thresholdMinutes * 60 // 转换为秒 try { // 获取当前剩余TTL(秒) @@ -1398,14 +1404,14 @@ class RedisClient { if (remainingTTL < renewalThreshold) { await this.client.expire(key, fullTTL) logger.debug( - `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 24 / 3600)}d, renewed to ${ttlDays}d)` + `🔄 Renewed sticky session TTL: ${sessionHash} (was ${Math.round(remainingTTL / 60)}min, renewed to ${ttlHours}h)` ) return true } // 剩余时间充足,无需续期 logger.debug( - `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 24 / 3600)}d)` + `✅ Sticky session TTL sufficient: ${sessionHash} (remaining ${Math.round(remainingTTL / 60)}min)` ) return true } catch (error) { diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 29dee2bd..353fe3cf 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -3,8 +3,8 @@ const crypto = require('crypto') const ProxyHelper = require('../utils/proxyHelper') const axios = require('axios') const redis = require('../models/redis') -const logger = require('../utils/logger') const config = require('../../config/config') +const logger = require('../utils/logger') const { maskToken } = require('../utils/tokenMask') const { logRefreshStart, @@ -723,7 +723,9 @@ class ClaudeAccountService { // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) logger.info( `🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` ) @@ -877,7 +879,9 @@ class ClaudeAccountService { // 如果有会话哈希,建立新的映射 if (sessionHash) { - await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600) // 1小时过期 + // 从配置获取TTL(小时),转换为秒 + const ttlSeconds = (config.session?.stickyTtlHours || 1) * 60 * 60 + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, ttlSeconds) logger.info( `🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}` ) From 8b13403304df0d837291cb1e770257edbf0301a4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Sep 2025 08:06:36 +0000 Subject: [PATCH 20/21] chore: sync VERSION file with release v1.1.132 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c4c90b95..dc969433 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.131 +1.1.132 From 4dcd25166216a2dca21ef43e907e41b28ce9d3a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Sep 2025 08:15:05 +0000 Subject: [PATCH 21/21] chore: sync VERSION file with release v1.1.133 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c3e1bae7..64dbc165 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.132 \ No newline at end of file +1.1.133