mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
feat: 实现Claude账户专属绑定功能
- 添加账户类型(dedicated/shared)支持 - API Key可绑定专属账户,优先使用绑定账户 - 未绑定的API Key继续使用共享池和粘性会话 - 修复专属账户下拉框显示问题(isActive类型不匹配) - 修复getBoundAccountName方法未定义错误 - 添加删除账户前的API Key绑定检查 - 完全保留原有粘性会话机制 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}` : ''}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user