From 3fd9110ba751a6bfa53490c375ba3b960c056b93 Mon Sep 17 00:00:00 2001 From: sususu Date: Wed, 3 Sep 2025 17:53:45 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BAAPI=20Key=20?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=A4=84=E7=90=86=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=98=8E=E6=96=87=E4=B8=8E=E5=93=88=E5=B8=8C=E5=80=BC=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=AF=86=E5=88=AB=E4=BB=A5=E5=AE=9E=E7=8E=B0=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5apiKey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/data-transfer-enhanced.js | 65 +++++++++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/scripts/data-transfer-enhanced.js b/scripts/data-transfer-enhanced.js index 47b3920f..09416fb4 100644 --- a/scripts/data-transfer-enhanced.js +++ b/scripts/data-transfer-enhanced.js @@ -86,6 +86,33 @@ function decryptGeminiData(encryptedData) { } } +// API Key 哈希函数(与apiKeyService保持一致) +function hashApiKey(apiKey) { + if (!apiKey || !config.security.encryptionKey) { + return apiKey + } + + return crypto + .createHash('sha256') + .update(apiKey + config.security.encryptionKey) + .digest('hex') +} + +// 检查是否为明文API Key(通过格式判断,不依赖前缀) +function isPlaintextApiKey(apiKey) { + if (!apiKey || typeof apiKey !== 'string') { + return false + } + + // SHA256哈希值固定为64个十六进制字符,如果是哈希值则返回false + if (apiKey.length === 64 && /^[a-f0-9]+$/i.test(apiKey)) { + return false // 已经是哈希值 + } + + // 其他情况都认为是明文API Key(包括sk-ant-、cr_、自定义前缀等) + return true +} + // 数据加密函数(用于导入) function encryptClaudeData(data) { if (!data || !config.security.encryptionKey) { @@ -651,6 +678,13 @@ Important Notes: - If importing decrypted data, it will be re-encrypted automatically - If importing encrypted data, it will be stored as-is - Sanitized exports cannot be properly imported (missing sensitive data) + - Automatic handling of plaintext API Keys + * Uses your configured API_KEY_PREFIX from config (sk-, cr_, etc.) + * Automatically detects plaintext vs hashed API Keys by format + * Plaintext API Keys are automatically hashed during import + * Hash mappings are created correctly for plaintext keys + * Supports custom prefixes and legacy format detection + * No manual conversion needed - just import your backup file Examples: # Export all data with decryption (for migration) @@ -659,7 +693,7 @@ Examples: # Export without decrypting (for backup) node scripts/data-transfer-enhanced.js export --decrypt=false - # Import data (auto-handles encryption) + # Import data (auto-handles encryption and plaintext API keys) node scripts/data-transfer-enhanced.js import --input=backup.json # Import with force overwrite @@ -773,6 +807,26 @@ async function importData() { const apiKeyData = { ...apiKey } delete apiKeyData.usageStats + // 检查并处理API Key哈希 + let plainTextApiKey = null + let hashedApiKey = null + + if (apiKeyData.apiKey && isPlaintextApiKey(apiKeyData.apiKey)) { + // 如果是明文API Key,保存明文并计算哈希 + plainTextApiKey = apiKeyData.apiKey + hashedApiKey = hashApiKey(plainTextApiKey) + logger.info(`🔐 Detected plaintext API Key for: ${apiKey.name} (${apiKey.id})`) + } else if (apiKeyData.apiKey) { + // 如果已经是哈希值,直接使用 + hashedApiKey = apiKeyData.apiKey + logger.info(`🔍 Using existing hashed API Key for: ${apiKey.name} (${apiKey.id})`) + } + + // API Key字段始终存储哈希值 + if (hashedApiKey) { + apiKeyData.apiKey = hashedApiKey + } + // 使用 hset 存储到哈希表 const pipeline = redis.client.pipeline() for (const [field, value] of Object.entries(apiKeyData)) { @@ -780,9 +834,12 @@ async function importData() { } await pipeline.exec() - // 更新哈希映射 - if (apiKey.apiKey && !importDataObj.metadata.sanitized) { - await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id) + // 更新哈希映射:hash_map的key必须是哈希值 + if (!importDataObj.metadata.sanitized && hashedApiKey) { + await redis.client.hset('apikey:hash_map', hashedApiKey, apiKey.id) + logger.info( + `📝 Updated hash mapping: ${hashedApiKey.substring(0, 8)}... -> ${apiKey.id}` + ) } // 导入使用统计数据 From 861af192bf4ee8bb23afc218431aa149207af38e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Sep 2025 03:06:11 +0000 Subject: [PATCH 2/5] chore: sync VERSION file with release v1.1.128 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index c3c6aefa..48cd0cc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.127 +1.1.128 From 4b0861eb7fd34d310c6ade907b3edb74bd433544 Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Thu, 4 Sep 2025 13:09:55 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=E4=BF=AE=E5=A4=8D=E4=BA=86=E9=87=8D?= =?UTF-8?q?=E7=BD=AE=E7=8A=B6=E6=80=81=E5=8F=AA=E5=88=A0=E9=99=A4js?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E8=80=8C=E6=B2=A1=E6=9C=89=E5=88=A0=E9=99=A4?= =?UTF-8?q?redis=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/claudeAccountService.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 1c37262b..96cbcab6 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1813,6 +1813,20 @@ class ClaudeAccountService { // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + const fieldsToDelete = [ + 'errorMessage', + 'unauthorizedAt', + 'blockedAt', + 'rateLimitedAt', + 'rateLimitStatus', + 'rateLimitEndAt', + 'tempErrorAt', + 'sessionWindowStart', + 'sessionWindowEnd' + ] + await redis.client.hdel(`claude:account:${accountId}`, ...fieldsToDelete) + // 清除401错误计数 const errorKey = `claude_account:${accountId}:401_errors` await redis.client.del(errorKey) From ae727d381cad47fe4f245658497cc46c7a878191 Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Thu, 4 Sep 2025 13:49:55 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=E7=A1=AE=E4=BF=9D=E6=B8=85=E6=A5=9A?= =?UTF-8?q?=E4=BA=865xx=E9=94=99=E8=AF=AF=E5=AF=BC=E8=87=B4=E7=9A=84?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E7=86=94=E6=96=AD=E7=8A=B6=E6=80=81,?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B9=8B=E5=89=8D=E6=B2=A1=E6=9C=89=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=9A=845=E5=88=86=E9=92=9F=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/claudeAccountService.js | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 96cbcab6..86e595e5 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -1878,6 +1878,10 @@ class ClaudeAccountService { delete account.errorMessage delete account.tempErrorAt await redis.setClaudeAccount(account.id, account) + + // 显式从 Redis 中删除这些字段(因为 HSET 不会删除现有字段) + await redis.client.hdel(`claude:account:${account.id}`, 'errorMessage', 'tempErrorAt') + // 同时清除500错误计数 await this.clearInternalErrors(account.id) cleanedCount++ @@ -1965,6 +1969,52 @@ class ClaudeAccountService { // 保存更新后的账户数据 await redis.setClaudeAccount(accountId, updatedAccountData) + // 设置 5 分钟后自动恢复(一次性定时器) + setTimeout( + async () => { + try { + const account = await redis.getClaudeAccount(accountId) + if (account && account.status === 'temp_error' && account.tempErrorAt) { + // 验证是否确实过了 5 分钟(防止重复定时器) + const tempErrorAt = new Date(account.tempErrorAt) + const now = new Date() + const minutesSince = (now - tempErrorAt) / (1000 * 60) + + if (minutesSince >= 5) { + // 恢复账户 + account.status = 'active' + account.schedulable = 'true' + delete account.errorMessage + delete account.tempErrorAt + + await redis.setClaudeAccount(accountId, account) + + // 显式删除 Redis 字段 + await redis.client.hdel( + `claude:account:${accountId}`, + 'errorMessage', + 'tempErrorAt' + ) + + // 清除 500 错误计数 + await this.clearInternalErrors(accountId) + + logger.success( + `✅ Auto-recovered temp_error after 5 minutes: ${account.name} (${accountId})` + ) + } else { + logger.debug( + `⏰ Temp error timer triggered but only ${minutesSince.toFixed(1)} minutes passed for ${account.name} (${accountId})` + ) + } + } + } catch (error) { + logger.error(`❌ Failed to auto-recover temp_error account ${accountId}:`, error) + } + }, + 6 * 60 * 1000 + ) // 6 分钟后执行,确保已过 5 分钟 + // 如果有sessionHash,删除粘性会话映射 if (sessionHash) { await redis.client.del(`sticky_session:${sessionHash}`) From b2e7d686fe6da1428eae27d0d78728bf196eca09 Mon Sep 17 00:00:00 2001 From: sczheng189 <724100151@qq.com> Date: Thu, 4 Sep 2025 14:08:55 +0800 Subject: [PATCH 5/5] =?UTF-8?q?Fix:=E5=89=8D=E7=AB=AF=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E4=B8=B4=E6=97=B6=E5=BC=82=E5=B8=B8=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin-spa/src/views/AccountsView.vue | 35 ++++++++++++++++++------ 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 8fe44e0a..01fae1c5 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -376,9 +376,11 @@ ? 'bg-orange-100 text-orange-800' : account.status === 'unauthorized' ? 'bg-red-100 text-red-800' - : account.isActive - ? 'bg-green-100 text-green-800' - : 'bg-red-100 text-red-800' + : account.status === 'temp_error' + ? 'bg-orange-100 text-orange-800' + : account.isActive + ? 'bg-green-100 text-green-800' + : 'bg-red-100 text-red-800' ]" >
{{ @@ -398,9 +402,11 @@ ? '已封锁' : account.status === 'unauthorized' ? '异常' - : account.isActive - ? '正常' - : '异常' + : account.status === 'temp_error' + ? '临时异常' + : account.isActive + ? '正常' + : '异常' }} { if (account.status === 'unauthorized') { return '认证失败(401错误)' } + if (account.status === 'temp_error' && account.errorMessage) { + return account.errorMessage + } if (account.status === 'error' && account.errorMessage) { return account.errorMessage } @@ -1668,6 +1677,8 @@ const getAccountStatusText = (account) => { account.rateLimitStatus === 'limited' ) return '限流中' + // 检查是否临时错误 + if (account.status === 'temp_error') return '临时异常' // 检查是否错误 if (account.status === 'error' || !account.isActive) return '错误' // 检查是否可调度 @@ -1692,6 +1703,9 @@ const getAccountStatusClass = (account) => { ) { return 'bg-orange-100 text-orange-800' } + if (account.status === 'temp_error') { + return 'bg-orange-100 text-orange-800' + } if (account.status === 'error' || !account.isActive) { return 'bg-red-100 text-red-800' } @@ -1717,6 +1731,9 @@ const getAccountStatusDotClass = (account) => { ) { return 'bg-orange-500' } + if (account.status === 'temp_error') { + return 'bg-orange-500' + } if (account.status === 'error' || !account.isActive) { return 'bg-red-500' }