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:
shaw
2025-07-17 08:50:12 +08:00
parent a64ced0e36
commit ee9bd4aea4
5 changed files with 293 additions and 28 deletions

View File

@@ -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) {

View File

@@ -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}` : ''}`);