From 0b2610842a46b995a567d783af075e9f3bd1fcb7 Mon Sep 17 00:00:00 2001 From: shaw Date: Sat, 11 Oct 2025 22:39:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20droid=20apikey=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E7=A7=BB=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/droidAccountService.js | 40 ++++ src/services/droidRelayService.js | 191 +++++++++++++++++- .../src/components/accounts/AccountForm.vue | 3 + 3 files changed, 228 insertions(+), 6 deletions(-) diff --git a/src/services/droidAccountService.js b/src/services/droidAccountService.js index 7217ab58..c6baecbf 100644 --- a/src/services/droidAccountService.js +++ b/src/services/droidAccountService.js @@ -308,6 +308,46 @@ class DroidAccountService { } } + /** + * 删除指定的 Droid API Key 条目 + */ + async removeApiKeyEntry(accountId, keyId) { + if (!accountId || !keyId) { + return { removed: false, remainingCount: 0 } + } + + try { + const accountData = await redis.getDroidAccount(accountId) + if (!accountData) { + return { removed: false, remainingCount: 0 } + } + + const entries = this._parseApiKeyEntries(accountData.apiKeys) + if (!entries || entries.length === 0) { + return { removed: false, remainingCount: 0 } + } + + const filtered = entries.filter((entry) => entry && entry.id !== keyId) + if (filtered.length === entries.length) { + return { removed: false, remainingCount: entries.length } + } + + accountData.apiKeys = filtered.length ? JSON.stringify(filtered) : '' + accountData.apiKeyCount = String(filtered.length) + + await redis.setDroidAccount(accountId, accountData) + + logger.warn( + `🚫 已删除 Droid API Key ${keyId}(Account: ${accountId}),剩余 ${filtered.length}` + ) + + return { removed: true, remainingCount: filtered.length } + } catch (error) { + logger.error(`❌ 删除 Droid API Key 失败:${keyId}(Account: ${accountId})`, error) + return { removed: false, remainingCount: 0, error } + } + } + /** * 使用 WorkOS Refresh Token 刷新并验证凭证 */ diff --git a/src/services/droidRelayService.js b/src/services/droidRelayService.js index 377094db..604aa10f 100644 --- a/src/services/droidRelayService.js +++ b/src/services/droidRelayService.js @@ -192,8 +192,12 @@ class DroidRelayService { disableStreaming = false } = options const keyInfo = apiKeyData || {} + const clientApiKeyId = keyInfo.id || null const normalizedEndpoint = this._normalizeEndpointType(endpointType) const normalizedRequestBody = this._normalizeRequestBody(requestBody, normalizedEndpoint) + let account = null + let selectedApiKey = null + let accessToken = null try { logger.info( @@ -203,16 +207,13 @@ class DroidRelayService { ) // 选择一个可用的 Droid 账户(支持粘性会话和分组调度) - const account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) + account = await droidScheduler.selectAccount(keyInfo, normalizedEndpoint, sessionHash) if (!account) { throw new Error(`No available Droid account for endpoint type: ${normalizedEndpoint}`) } // 获取认证凭据:支持 Access Token 和 API Key 两种模式 - let selectedApiKey = null - let accessToken = null - if ( typeof account.authenticationMethod === 'string' && account.authenticationMethod.toLowerCase().trim() === 'api_key' @@ -281,7 +282,10 @@ class DroidRelayService { keyInfo, normalizedRequestBody, normalizedEndpoint, - skipUsageRecord + skipUsageRecord, + selectedApiKey, + sessionHash, + clientApiKeyId ) } else { // 非流式响应:使用 axios @@ -316,6 +320,21 @@ class DroidRelayService { } catch (error) { logger.error(`❌ Droid relay error: ${error.message}`, error) + const status = error?.response?.status + if (status >= 400 && status < 500) { + try { + await this._handleUpstreamClientError(status, { + account, + selectedAccountApiKey: selectedApiKey, + endpointType: normalizedEndpoint, + sessionHash, + clientApiKeyId + }) + } catch (handlingError) { + logger.error('❌ 处理 Droid 4xx 异常失败:', handlingError) + } + } + if (error.response) { // HTTP 错误响应 return { @@ -354,7 +373,10 @@ class DroidRelayService { apiKeyData, requestBody, endpointType, - skipUsageRecord = false + skipUsageRecord = false, + selectedAccountApiKey = null, + sessionHash = null, + clientApiKeyId = null ) { return new Promise((resolve, reject) => { const url = new URL(apiUrl) @@ -470,6 +492,17 @@ class DroidRelayService { logger.info('✅ res.end() reached') const body = Buffer.concat(chunks).toString() logger.error(`❌ Factory.ai error response body: ${body || '(empty)'}`) + if (res.statusCode >= 400 && res.statusCode < 500) { + this._handleUpstreamClientError(res.statusCode, { + account, + selectedAccountApiKey, + endpointType, + sessionHash, + clientApiKeyId + }).catch((handlingError) => { + logger.error('❌ 处理 Droid 流式4xx 异常失败:', handlingError) + }) + } if (!clientResponse.headersSent) { clientResponse.status(res.statusCode).json({ error: 'upstream_error', @@ -1123,6 +1156,152 @@ class DroidRelayService { } } + /** + * 处理上游 4xx 响应,移除问题 API Key 或停止账号调度 + */ + async _handleUpstreamClientError(statusCode, context = {}) { + if (!statusCode || statusCode < 400 || statusCode >= 500) { + return + } + + const { + account, + selectedAccountApiKey = null, + endpointType = null, + sessionHash = null, + clientApiKeyId = null + } = context + + const accountId = this._extractAccountId(account) + if (!accountId) { + logger.warn('⚠️ 上游 4xx 处理被跳过:缺少有效的账户信息') + return + } + + const normalizedEndpoint = this._normalizeEndpointType( + endpointType || account?.endpointType || 'anthropic' + ) + const authMethod = + typeof account?.authenticationMethod === 'string' + ? account.authenticationMethod.toLowerCase().trim() + : '' + + if (authMethod === 'api_key') { + if (selectedAccountApiKey?.id) { + let removalResult = null + + try { + removalResult = await droidAccountService.removeApiKeyEntry( + accountId, + selectedAccountApiKey.id + ) + } catch (error) { + logger.error( + `❌ 移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})失败:`, + error + ) + } + + await this._clearApiKeyStickyMapping(accountId, normalizedEndpoint, sessionHash) + + if (removalResult?.removed) { + logger.warn( + `🚫 上游返回 ${statusCode},已移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + ) + } else { + logger.warn( + `⚠️ 上游返回 ${statusCode},但未能移除 Droid API Key ${selectedAccountApiKey.id}(Account: ${accountId})` + ) + } + + if (!removalResult || removalResult.remainingCount === 0) { + await this._stopDroidAccountScheduling(accountId, statusCode, 'API Key 已全部失效') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } else { + logger.info( + `ℹ️ Droid 账号 ${accountId} 仍有 ${removalResult.remainingCount} 个 API Key 可用` + ) + } + + return + } + + logger.warn( + `⚠️ 上游返回 ${statusCode},但未获取到对应的 Droid API Key(Account: ${accountId})` + ) + await this._stopDroidAccountScheduling(accountId, statusCode, '缺少可用 API Key') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + return + } + + await this._stopDroidAccountScheduling(accountId, statusCode, '凭证不可用') + await this._clearAccountStickyMapping(normalizedEndpoint, sessionHash, clientApiKeyId) + } + + /** + * 停止指定 Droid 账号的调度 + */ + async _stopDroidAccountScheduling(accountId, statusCode, reason = '') { + if (!accountId) { + return + } + + const message = reason ? `${reason}` : '上游返回 4xx 错误' + + try { + await droidAccountService.updateAccount(accountId, { + schedulable: 'false', + status: 'error', + errorMessage: `上游返回 ${statusCode}:${message}` + }) + logger.warn(`🚫 已停止调度 Droid 账号 ${accountId}(状态码 ${statusCode},原因:${message})`) + } catch (error) { + logger.error(`❌ 停止调度 Droid 账号失败:${accountId}`, error) + } + } + + /** + * 清理账号层面的粘性调度映射 + */ + async _clearAccountStickyMapping(endpointType, sessionHash, clientApiKeyId) { + if (!sessionHash) { + return + } + + const normalizedEndpoint = this._normalizeEndpointType(endpointType) + const apiKeyPart = clientApiKeyId || 'default' + const stickyKey = `droid:${normalizedEndpoint}:${apiKeyPart}:${sessionHash}` + + try { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid 粘性会话映射:${stickyKey}`) + } catch (error) { + logger.warn(`⚠️ 清理 Droid 粘性会话映射失败:${stickyKey}`, error) + } + } + + /** + * 清理 API Key 级别的粘性映射 + */ + async _clearApiKeyStickyMapping(accountId, endpointType, sessionHash) { + if (!accountId || !sessionHash) { + return + } + + try { + const stickyKey = this._composeApiKeyStickyKey(accountId, endpointType, sessionHash) + if (stickyKey) { + await redis.deleteSessionAccountMapping(stickyKey) + logger.debug(`🧹 已清理 Droid API Key 粘性映射:${stickyKey}`) + } + } catch (error) { + logger.warn( + `⚠️ 清理 Droid API Key 粘性映射失败:${accountId}(endpoint: ${endpointType})`, + error + ) + } + } + _mapNetworkErrorStatus(error) { const code = (error && error.code ? String(error.code) : '').toUpperCase() diff --git a/web/admin-spa/src/components/accounts/AccountForm.vue b/web/admin-spa/src/components/accounts/AccountForm.vue index 960ae79e..4199d0f0 100644 --- a/web/admin-spa/src/components/accounts/AccountForm.vue +++ b/web/admin-spa/src/components/accounts/AccountForm.vue @@ -1773,6 +1773,9 @@