diff --git a/src/routes/admin.js b/src/routes/admin.js index 4eb714d5..58bcdbbc 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -78,9 +78,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { try { const { keyId } = req.params; - const { tokenLimit, concurrencyLimit } = req.body; + const { tokenLimit, concurrencyLimit, claudeAccountId } = req.body; - // 只允许更新tokenLimit和concurrencyLimit + // 只允许更新tokenLimit、concurrencyLimit和claudeAccountId const updates = {}; if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { @@ -97,6 +97,11 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { updates.concurrencyLimit = Number(concurrencyLimit); } + if (claudeAccountId !== undefined) { + // 空字符串表示解绑,null或空字符串都设置为空字符串 + updates.claudeAccountId = claudeAccountId || ''; + } + await apiKeyService.updateApiKey(keyId, updates); logger.success(`📝 Admin updated API key: ${keyId}`); @@ -244,13 +249,19 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { password, refreshToken, claudeAiOauth, - proxy + proxy, + accountType } = req.body; if (!name) { return res.status(400).json({ error: 'Name is required' }); } + // 验证accountType的有效性 + if (accountType && !['shared', 'dedicated'].includes(accountType)) { + return res.status(400).json({ error: 'Invalid account type. Must be "shared" or "dedicated"' }); + } + const newAccount = await claudeAccountService.createAccount({ name, description, @@ -258,10 +269,11 @@ router.post('/claude-accounts', authenticateAdmin, async (req, res) => { password, refreshToken, claudeAiOauth, - proxy + proxy, + accountType: accountType || 'shared' // 默认为共享类型 }); - logger.success(`🏢 Admin created new Claude account: ${name}`); + logger.success(`🏢 Admin created new Claude account: ${name} (${accountType || 'shared'})`); res.json({ success: true, data: newAccount }); } catch (error) { logger.error('❌ Failed to create Claude account:', error); diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index 1d2a9ded..7dce5925 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -27,7 +27,8 @@ class ClaudeAccountService { refreshToken = '', claudeAiOauth = null, // Claude标准格式的OAuth数据 proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' } - isActive = true + isActive = true, + accountType = 'shared' // 'dedicated' or 'shared' } = options; const accountId = uuidv4(); @@ -49,6 +50,7 @@ class ClaudeAccountService { scopes: claudeAiOauth.scopes.join(' '), proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), + accountType: accountType, // 账号类型:'dedicated' 或 'shared' createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', @@ -69,6 +71,7 @@ class ClaudeAccountService { scopes: '', proxy: proxy ? JSON.stringify(proxy) : '', isActive: isActive.toString(), + accountType: accountType, // 账号类型:'dedicated' 或 'shared' createdAt: new Date().toISOString(), lastUsedAt: '', lastRefreshAt: '', @@ -88,6 +91,7 @@ class ClaudeAccountService { email, isActive, proxy, + accountType, status: accountData.status, createdAt: accountData.createdAt, expiresAt: accountData.expiresAt, @@ -234,6 +238,7 @@ class ClaudeAccountService { proxy: account.proxy ? JSON.parse(account.proxy) : null, status: account.status, errorMessage: account.errorMessage, + accountType: account.accountType || 'shared', // 兼容旧数据,默认为共享 createdAt: account.createdAt, lastUsedAt: account.lastUsedAt, lastRefreshAt: account.lastRefreshAt, @@ -254,7 +259,7 @@ class ClaudeAccountService { throw new Error('Account not found'); } - const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth']; + const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType']; const updatedData = { ...accountData }; for (const [field, value] of Object.entries(updates)) { @@ -366,6 +371,72 @@ class ClaudeAccountService { } } + // 🎯 基于API Key选择账户(支持专属绑定和共享池) + async selectAccountForApiKey(apiKeyData, sessionHash = null) { + try { + // 如果API Key绑定了专属账户,优先使用 + if (apiKeyData.claudeAccountId) { + const boundAccount = await redis.getClaudeAccount(apiKeyData.claudeAccountId); + if (boundAccount && boundAccount.isActive === 'true' && boundAccount.status !== 'error') { + logger.info(`🎯 Using bound dedicated account: ${boundAccount.name} (${apiKeyData.claudeAccountId}) for API key ${apiKeyData.name}`); + return apiKeyData.claudeAccountId; + } else { + logger.warn(`⚠️ Bound account ${apiKeyData.claudeAccountId} is not available, falling back to shared pool`); + } + } + + // 如果没有绑定账户或绑定账户不可用,从共享池选择 + const accounts = await redis.getAllClaudeAccounts(); + + const sharedAccounts = accounts.filter(account => + account.isActive === 'true' && + account.status !== 'error' && + (account.accountType === 'shared' || !account.accountType) // 兼容旧数据 + ); + + if (sharedAccounts.length === 0) { + throw new Error('No active shared Claude accounts available'); + } + + // 如果有会话哈希,检查是否有已映射的账户 + if (sessionHash) { + const mappedAccountId = await redis.getSessionAccountMapping(sessionHash); + if (mappedAccountId) { + // 验证映射的账户是否仍然在共享池中且可用 + const mappedAccount = sharedAccounts.find(acc => acc.id === mappedAccountId); + if (mappedAccount) { + logger.info(`🎯 Using sticky session shared account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`); + return mappedAccountId; + } else { + logger.warn(`⚠️ Mapped shared account ${mappedAccountId} is no longer available, selecting new account`); + // 清理无效的映射 + await redis.deleteSessionAccountMapping(sessionHash); + } + } + } + + // 从共享池选择账户(负载均衡) + const sortedAccounts = sharedAccounts.sort((a, b) => { + const aLastRefresh = new Date(a.lastRefreshAt || 0).getTime(); + const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime(); + return bLastRefresh - aLastRefresh; + }); + const selectedAccountId = sortedAccounts[0].id; + + // 如果有会话哈希,建立新的映射 + if (sessionHash) { + await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期 + logger.info(`🎯 Created new sticky session mapping for shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`); + } + + logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`); + return selectedAccountId; + } catch (error) { + logger.error('❌ Failed to select account for API key:', error); + throw error; + } + } + // 🌐 创建代理agent _createProxyAgent(proxyConfig) { if (!proxyConfig) { diff --git a/src/services/claudeRelayService.js b/src/services/claudeRelayService.js index 3f6d1f4f..7d144dcf 100644 --- a/src/services/claudeRelayService.js +++ b/src/services/claudeRelayService.js @@ -25,8 +25,8 @@ class ClaudeRelayService { // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody); - // 选择可用的Claude账户(支持sticky会话) - const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount(sessionHash); + // 选择可用的Claude账户(支持专属绑定和sticky会话) + const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash); logger.info(`📤 Processing API request for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`); @@ -393,8 +393,8 @@ class ClaudeRelayService { // 生成会话哈希用于sticky会话 const sessionHash = sessionHelper.generateSessionHash(requestBody); - // 选择可用的Claude账户(支持sticky会话) - const accountId = apiKeyData.claudeAccountId || await claudeAccountService.selectAvailableAccount(sessionHash); + // 选择可用的Claude账户(支持专属绑定和sticky会话) + const accountId = await claudeAccountService.selectAccountForApiKey(apiKeyData, sessionHash); logger.info(`📡 Processing streaming API request with usage capture for key: ${apiKeyData.name || apiKeyData.id}, account: ${accountId}${sessionHash ? `, session: ${sessionHash}` : ''}`); diff --git a/web/admin/app.js b/web/admin/app.js index 6150aa74..0d21bc4c 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -103,7 +103,8 @@ const app = createApp({ name: '', tokenLimit: '', description: '', - concurrencyLimit: '' + concurrencyLimit: '', + claudeAccountId: '' }, apiKeyModelStats: {}, // 存储每个key的模型统计数据 expandedApiKeys: {}, // 跟踪展开的API Keys @@ -140,7 +141,8 @@ const app = createApp({ id: '', name: '', tokenLimit: '', - concurrencyLimit: '' + concurrencyLimit: '', + claudeAccountId: '' }, // 账户 @@ -152,6 +154,7 @@ const app = createApp({ name: '', description: '', addType: 'oauth', // 'oauth' 或 'manual' + accountType: 'shared', // 'shared' 或 'dedicated' accessToken: '', refreshToken: '', proxyType: '', @@ -168,6 +171,8 @@ const app = createApp({ id: '', name: '', description: '', + accountType: 'shared', + originalAccountType: 'shared', accessToken: '', refreshToken: '', proxyType: '', @@ -207,6 +212,13 @@ const app = createApp({ // 动态计算BASE_URL currentBaseUrl() { return `${window.location.protocol}//${window.location.host}/api/`; + }, + + // 获取专属账号列表 + dedicatedAccounts() { + return this.accounts.filter(account => + account.accountType === 'dedicated' && account.isActive === true + ); } }, @@ -269,6 +281,17 @@ const app = createApp({ }, methods: { + // 获取绑定账号名称 + getBoundAccountName(accountId) { + const account = this.accounts.find(acc => acc.id === accountId); + return account ? account.name : '未知账号'; + }, + + // 获取绑定到特定账号的API Key数量 + getBoundApiKeysCount(accountId) { + return this.apiKeys.filter(key => key.claudeAccountId === accountId).length; + }, + // Toast 通知方法 showToast(message, type = 'info', title = null, duration = 5000) { const id = ++this.toastIdCounter; @@ -356,6 +379,8 @@ const app = createApp({ id: account.id, name: account.name, description: account.description || '', + accountType: account.accountType || 'shared', + originalAccountType: account.accountType || 'shared', accessToken: '', refreshToken: '', proxyType: account.proxy ? account.proxy.type : '', @@ -374,6 +399,8 @@ const app = createApp({ id: '', name: '', description: '', + accountType: 'shared', + originalAccountType: 'shared', accessToken: '', refreshToken: '', proxyType: '', @@ -388,10 +415,21 @@ const app = createApp({ async updateAccount() { this.editAccountLoading = true; try { + // 验证账户类型切换 + if (this.editAccountForm.accountType === 'shared' && + this.editAccountForm.originalAccountType === 'dedicated') { + const boundKeysCount = this.getBoundApiKeysCount(this.editAccountForm.id); + if (boundKeysCount > 0) { + this.showToast(`无法切换到共享账户,该账户绑定了 ${boundKeysCount} 个API Key,请先解绑所有API Key`, 'error', '切换失败'); + return; + } + } + // 构建更新数据 let updateData = { name: this.editAccountForm.name, - description: this.editAccountForm.description + description: this.editAccountForm.description, + accountType: this.editAccountForm.accountType }; // 只在有值时才更新 token @@ -465,6 +503,7 @@ const app = createApp({ name: '', description: '', addType: 'oauth', + accountType: 'shared', accessToken: '', refreshToken: '', proxyType: '', @@ -593,7 +632,8 @@ const app = createApp({ name: this.accountForm.name, description: this.accountForm.description, claudeAiOauth: exchangeData.data.claudeAiOauth, - proxy: proxy + proxy: proxy, + accountType: this.accountForm.accountType }) }); @@ -665,7 +705,8 @@ const app = createApp({ name: this.accountForm.name, description: this.accountForm.description, claudeAiOauth: manualOauthData, - proxy: proxy + proxy: proxy, + accountType: this.accountForm.accountType }) }); @@ -1054,6 +1095,10 @@ const app = createApp({ if (data.success) { this.accounts = data.data || []; + // 为每个账号计算绑定的API Key数量 + this.accounts.forEach(account => { + account.boundApiKeysCount = this.apiKeys.filter(key => key.claudeAccountId === account.id).length; + }); } } catch (error) { console.error('Failed to load accounts:', error); @@ -1097,7 +1142,8 @@ const app = createApp({ name: this.apiKeyForm.name, tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, description: this.apiKeyForm.description || '', - concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0 + concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, + claudeAccountId: this.apiKeyForm.claudeAccountId || null }) }); @@ -1115,7 +1161,7 @@ const app = createApp({ // 关闭创建弹窗并清理表单 this.showCreateApiKeyModal = false; - this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '' }; + this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '' }; // 重新加载API Keys列表 await this.loadApiKeys(); @@ -1158,7 +1204,8 @@ const app = createApp({ id: key.id, name: key.name, tokenLimit: key.tokenLimit || '', - concurrencyLimit: key.concurrencyLimit || '' + concurrencyLimit: key.concurrencyLimit || '', + claudeAccountId: key.claudeAccountId || '' }; this.showEditApiKeyModal = true; }, @@ -1169,7 +1216,8 @@ const app = createApp({ id: '', name: '', tokenLimit: '', - concurrencyLimit: '' + concurrencyLimit: '', + claudeAccountId: '' }; }, @@ -1184,7 +1232,8 @@ const app = createApp({ }, body: JSON.stringify({ tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0, - concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0 + concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0, + claudeAccountId: this.editApiKeyForm.claudeAccountId || null }) }); @@ -1206,6 +1255,13 @@ const app = createApp({ }, async deleteAccount(accountId) { + // 检查是否有API Key绑定到此账号 + const boundKeysCount = this.getBoundApiKeysCount(accountId); + if (boundKeysCount > 0) { + this.showToast(`无法删除此账号,有 ${boundKeysCount} 个API Key绑定到此账号,请先解绑所有API Key`, 'error', '删除失败'); + return; + } + if (!confirm('确定要删除这个 Claude 账户吗?')) return; try { diff --git a/web/admin/index.html b/web/admin/index.html index 6bdaa394..94184fa3 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -423,6 +423,16 @@
{{ key.name }}
{{ key.id }}
+
+ + + 绑定: {{ getBoundAccountName(key.claudeAccountId) }} + + + + 使用共享池 + +
@@ -739,7 +749,17 @@
-
{{ account.name }}
+
+
{{ account.name }}
+ + 专属 + + + 共享 + +
{{ account.id }}
@@ -755,12 +775,18 @@ - -
- {{ account.isActive ? '正常' : '异常' }} -
+
+ +
+ {{ account.isActive ? '正常' : '异常' }} +
+ + 绑定: {{ account.boundApiKeysCount || 0 }} 个API Key + +
@@ -1738,6 +1764,24 @@ >
+
+ + +

选择专属账号后,此API Key将只使用该账号,不选择则使用共享账号池

+
+
+
+ + +

修改绑定账号将影响此API Key的请求路由

+
+
+
+ +
+ + +
+

+ 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用 +

+
+
@@ -2299,6 +2388,43 @@ >
+
+ +
+ + +
+
+
+ +
+

切换到共享账户需要验证

+

当前账户绑定了 {{ getBoundApiKeysCount(editAccountForm.id) }} 个API Key,需要先解绑所有API Key才能切换到共享账户。

+
+
+
+

+ 共享账户:供所有API Key使用;专属账户:仅供特定API Key使用 +

+
+