From 0ba048aced83d626bc0d4052ddc2afd938252d0a Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 23 Sep 2025 15:48:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E4=B8=93=E5=B1=9E?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E5=88=A0=E9=99=A4=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/admin.js | 127 ++++++++++++++++++++--- src/services/apiKeyService.js | 64 ++++++++++++ web/admin-spa/src/views/AccountsView.vue | 40 +++---- 3 files changed, 200 insertions(+), 31 deletions(-) diff --git a/src/routes/admin.js b/src/routes/admin.js index c21727eb..88fbff97 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -2256,6 +2256,9 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) try { const { accountId } = req.params + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude') + // 获取账户信息以检查是否在分组中 const account = await claudeAccountService.getAccount(accountId) if (account && account.accountType === 'group') { @@ -2267,8 +2270,17 @@ router.delete('/claude-accounts/:accountId', authenticateAdmin, async (req, res) await claudeAccountService.deleteAccount(accountId) - logger.success(`🗑️ Admin deleted Claude account: ${accountId}`) - return res.json({ success: true, message: 'Claude account deleted successfully' }) + let message = 'Claude账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Claude account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('❌ Failed to delete Claude account:', error) return res @@ -2634,6 +2646,9 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r try { const { accountId } = req.params + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'claude-console') + // 获取账户信息以检查是否在分组中 const account = await claudeConsoleAccountService.getAccount(accountId) if (account && account.accountType === 'group') { @@ -2645,8 +2660,19 @@ router.delete('/claude-console-accounts/:accountId', authenticateAdmin, async (r await claudeConsoleAccountService.deleteAccount(accountId) - logger.success(`🗑️ Admin deleted Claude Console account: ${accountId}`) - return res.json({ success: true, message: 'Claude Console account deleted successfully' }) + let message = 'Claude Console账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `🗑️ Admin deleted Claude Console account: ${accountId}, unbound ${unboundCount} keys` + ) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('❌ Failed to delete Claude Console account:', error) return res @@ -3028,6 +3054,9 @@ router.delete('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => try { const { accountId } = req.params + // 尝试自动解绑(CCR账户实际上不会绑定API Key,但保持代码一致性) + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'ccr') + // 获取账户信息以检查是否在分组中 const account = await ccrAccountService.getAccount(accountId) if (account && account.accountType === 'group') { @@ -3039,8 +3068,18 @@ router.delete('/ccr-accounts/:accountId', authenticateAdmin, async (req, res) => await ccrAccountService.deleteAccount(accountId) + let message = 'CCR账号已成功删除' + if (unboundCount > 0) { + // 理论上不会发生,但保持消息格式一致 + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + logger.success(`🗑️ Admin deleted CCR account: ${accountId}`) - return res.json({ success: true, message: 'CCR account deleted successfully' }) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('❌ Failed to delete CCR account:', error) return res.status(500).json({ error: 'Failed to delete CCR account', message: error.message }) @@ -3384,6 +3423,9 @@ router.delete('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res try { const { accountId } = req.params + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'bedrock') + const result = await bedrockAccountService.deleteAccount(accountId) if (!result.success) { @@ -3392,8 +3434,17 @@ router.delete('/bedrock-accounts/:accountId', authenticateAdmin, async (req, res .json({ error: 'Failed to delete Bedrock account', message: result.error }) } - logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}`) - return res.json({ success: true, message: 'Bedrock account deleted successfully' }) + let message = 'Bedrock账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Bedrock account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('❌ Failed to delete Bedrock account:', error) return res @@ -3830,6 +3881,9 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) try { const { accountId } = req.params + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(accountId, 'gemini') + // 获取账户信息以检查是否在分组中 const account = await geminiAccountService.getAccount(accountId) if (account && account.accountType === 'group') { @@ -3841,8 +3895,17 @@ router.delete('/gemini-accounts/:accountId', authenticateAdmin, async (req, res) await geminiAccountService.deleteAccount(accountId) - logger.success(`🗑️ Admin deleted Gemini account: ${accountId}`) - return res.json({ success: true, message: 'Gemini account deleted successfully' }) + let message = 'Gemini账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Gemini account: ${accountId}, unbound ${unboundCount} keys`) + return res.json({ + success: true, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('❌ Failed to delete Gemini account:', error) return res.status(500).json({ error: 'Failed to delete account', message: error.message }) @@ -6692,6 +6755,9 @@ router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => { }) } + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai') + // 如果账户在分组中,从分组中移除 if (account.accountType === 'group') { const group = await accountGroupService.getAccountGroup(id) @@ -6702,11 +6768,19 @@ router.delete('/openai-accounts/:id', authenticateAdmin, async (req, res) => { await openaiAccountService.deleteAccount(id) - logger.success(`✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id})`) + let message = 'OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success( + `✅ 删除 OpenAI 账户成功: ${account.name} (ID: ${id}), unbound ${unboundCount} keys` + ) return res.json({ success: true, - message: '账户删除成功' + message, + unboundKeys: unboundCount }) } catch (error) { logger.error('删除 OpenAI 账户失败:', error) @@ -7055,11 +7129,22 @@ router.delete('/azure-openai-accounts/:id', authenticateAdmin, async (req, res) try { const { id } = req.params + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'azure_openai') + await azureOpenaiAccountService.deleteAccount(id) + let message = 'Azure OpenAI账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted Azure OpenAI account: ${id}, unbound ${unboundCount} keys`) + res.json({ success: true, - message: 'Azure OpenAI account deleted successfully' + message, + unboundKeys: unboundCount }) } catch (error) { logger.error('Failed to delete Azure OpenAI account:', error) @@ -7424,6 +7509,9 @@ router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, r }) } + // 自动解绑所有绑定的 API Keys + const unboundCount = await apiKeyService.unbindAccountFromAllKeys(id, 'openai-responses') + // 检查是否在分组中 const groups = await accountGroupService.getAllGroups() for (const group of groups) { @@ -7434,7 +7522,20 @@ router.delete('/openai-responses-accounts/:id', authenticateAdmin, async (req, r } const result = await openaiResponsesAccountService.deleteAccount(id) - res.json({ success: true, ...result }) + + let message = 'OpenAI-Responses账号已成功删除' + if (unboundCount > 0) { + message += `,${unboundCount} 个 API Key 已切换为共享池模式` + } + + logger.success(`🗑️ Admin deleted OpenAI-Responses account: ${id}, unbound ${unboundCount} keys`) + + res.json({ + success: true, + ...result, + message, + unboundKeys: unboundCount + }) } catch (error) { logger.error('Failed to delete OpenAI-Responses account:', error) res.status(500).json({ diff --git a/src/services/apiKeyService.js b/src/services/apiKeyService.js index e99b91dd..3674a96e 100644 --- a/src/services/apiKeyService.js +++ b/src/services/apiKeyService.js @@ -1328,6 +1328,70 @@ class ApiKeyService { } } + // 🔓 解绑账号从所有API Keys + async unbindAccountFromAllKeys(accountId, accountType) { + try { + // 账号类型与字段的映射关系 + const fieldMap = { + claude: 'claudeAccountId', + 'claude-console': 'claudeConsoleAccountId', + gemini: 'geminiAccountId', + openai: 'openaiAccountId', + 'openai-responses': 'openaiAccountId', // 特殊处理,带 responses: 前缀 + azure_openai: 'azureOpenaiAccountId', + bedrock: 'bedrockAccountId', + ccr: null // CCR 账号没有对应的 API Key 字段 + } + + const field = fieldMap[accountType] + if (!field) { + logger.info(`账号类型 ${accountType} 不需要解绑 API Key`) + return 0 + } + + // 获取所有API Keys + const allKeys = await this.getAllApiKeys() + + // 筛选绑定到此账号的 API Keys + let boundKeys = [] + if (accountType === 'openai-responses') { + // OpenAI-Responses 特殊处理:查找 openaiAccountId 字段中带 responses: 前缀的 + boundKeys = allKeys.filter((key) => key.openaiAccountId === `responses:${accountId}`) + } else { + // 其他账号类型正常匹配 + boundKeys = allKeys.filter((key) => key[field] === accountId) + } + + // 批量解绑 + for (const key of boundKeys) { + const updates = {} + if (accountType === 'openai-responses') { + updates.openaiAccountId = null + } else if (accountType === 'claude-console') { + updates.claudeConsoleAccountId = null + } else { + updates[field] = null + } + + await this.updateApiKey(key.id, updates) + logger.info( + `✅ 自动解绑 API Key ${key.id} (${key.name}) 从 ${accountType} 账号 ${accountId}` + ) + } + + if (boundKeys.length > 0) { + logger.success( + `🔓 成功解绑 ${boundKeys.length} 个 API Key 从 ${accountType} 账号 ${accountId}` + ) + } + + return boundKeys.length + } catch (error) { + logger.error(`❌ 解绑 API Keys 失败 (${accountType} 账号 ${accountId}):`, error) + return 0 + } + } + // 🧹 清理过期的API Keys async cleanupExpiredKeys() { try { diff --git a/web/admin-spa/src/views/AccountsView.vue b/web/admin-spa/src/views/AccountsView.vue index 79ca5a3c..54dd8ade 100644 --- a/web/admin-spa/src/views/AccountsView.vue +++ b/web/admin-spa/src/views/AccountsView.vue @@ -1598,7 +1598,7 @@ const editAccount = (account) => { // 删除账户 const deleteAccount = async (account) => { // 检查是否有API Key绑定到此账号 - const boundKeysCount = apiKeys.value.filter( + const boundKeys = apiKeys.value.filter( (key) => key.claudeAccountId === account.id || key.claudeConsoleAccountId === account.id || @@ -1606,22 +1606,18 @@ const deleteAccount = async (account) => { key.openaiAccountId === account.id || key.azureOpenaiAccountId === account.id || key.openaiAccountId === `responses:${account.id}` - ).length - - if (boundKeysCount > 0) { - showToast( - `无法删除此账号,有 ${boundKeysCount} 个API Key绑定到此账号,请先解绑所有API Key`, - 'error' - ) - return - } - - const confirmed = await showConfirm( - '删除账户', - `确定要删除账户 "${account.name}" 吗?\n\n此操作不可恢复。`, - '删除', - '取消' ) + const boundKeysCount = boundKeys.length + + // 构建确认消息 + let confirmMessage = `确定要删除账户 "${account.name}" 吗?` + if (boundKeysCount > 0) { + confirmMessage += `\n\n⚠️ 注意:此账号有 ${boundKeysCount} 个 API Key 绑定。` + confirmMessage += `\n删除后,这些 API Key 将自动切换为共享池模式。` + } + confirmMessage += '\n\n此操作不可恢复。' + + const confirmed = await showConfirm('删除账户', confirmMessage, '删除', '取消') if (!confirmed) return @@ -1648,10 +1644,18 @@ const deleteAccount = async (account) => { const data = await apiClient.delete(endpoint) if (data.success) { - showToast('账户已删除', 'success') - // 清空分组成员缓存,因为账户可能从分组中移除 + // 根据解绑结果显示不同的消息 + let toastMessage = '账户已成功删除' + if (data.unboundKeys > 0) { + toastMessage += `,${data.unboundKeys} 个 API Key 已切换为共享池模式` + } + showToast(toastMessage, 'success') + + // 清空相关缓存 groupMembersLoaded.value = false + apiKeysLoaded.value = false // 重新加载API Keys以反映解绑变化 loadAccounts() + loadApiKeys(true) // 强制重新加载API Keys } else { showToast(data.message || '删除失败', 'error') }