mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
1039 lines
38 KiB
JavaScript
1039 lines
38 KiB
JavaScript
const { v4: uuidv4 } = require('uuid');
|
||
const crypto = require('crypto');
|
||
const { SocksProxyAgent } = require('socks-proxy-agent');
|
||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||
const axios = require('axios');
|
||
const redis = require('../models/redis');
|
||
const logger = require('../utils/logger');
|
||
const config = require('../../config/config');
|
||
const { maskToken } = require('../utils/tokenMask');
|
||
const {
|
||
logRefreshStart,
|
||
logRefreshSuccess,
|
||
logRefreshError,
|
||
logTokenUsage,
|
||
logRefreshSkipped
|
||
} = require('../utils/tokenRefreshLogger');
|
||
const tokenRefreshService = require('./tokenRefreshService');
|
||
|
||
class ClaudeAccountService {
|
||
constructor() {
|
||
this.claudeApiUrl = 'https://console.anthropic.com/v1/oauth/token';
|
||
this.claudeOauthClientId = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
|
||
|
||
// 加密相关常量
|
||
this.ENCRYPTION_ALGORITHM = 'aes-256-cbc';
|
||
this.ENCRYPTION_SALT = 'salt';
|
||
}
|
||
|
||
// 🏢 创建Claude账户
|
||
async createAccount(options = {}) {
|
||
const {
|
||
name = 'Unnamed Account',
|
||
description = '',
|
||
email = '',
|
||
password = '',
|
||
refreshToken = '',
|
||
claudeAiOauth = null, // Claude标准格式的OAuth数据
|
||
proxy = null, // { type: 'socks5', host: 'localhost', port: 1080, username: '', password: '' }
|
||
isActive = true,
|
||
accountType = 'shared' // 'dedicated' or 'shared'
|
||
} = options;
|
||
|
||
const accountId = uuidv4();
|
||
|
||
let accountData;
|
||
|
||
if (claudeAiOauth) {
|
||
// 使用Claude标准格式的OAuth数据
|
||
accountData = {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email: this._encryptSensitiveData(email),
|
||
password: this._encryptSensitiveData(password),
|
||
claudeAiOauth: this._encryptSensitiveData(JSON.stringify(claudeAiOauth)),
|
||
accessToken: this._encryptSensitiveData(claudeAiOauth.accessToken),
|
||
refreshToken: this._encryptSensitiveData(claudeAiOauth.refreshToken),
|
||
expiresAt: claudeAiOauth.expiresAt.toString(),
|
||
scopes: claudeAiOauth.scopes.join(' '),
|
||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||
isActive: isActive.toString(),
|
||
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||
createdAt: new Date().toISOString(),
|
||
lastUsedAt: '',
|
||
lastRefreshAt: '',
|
||
status: 'active', // 有OAuth数据的账户直接设为active
|
||
errorMessage: ''
|
||
};
|
||
} else {
|
||
// 兼容旧格式
|
||
accountData = {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email: this._encryptSensitiveData(email),
|
||
password: this._encryptSensitiveData(password),
|
||
refreshToken: this._encryptSensitiveData(refreshToken),
|
||
accessToken: '',
|
||
expiresAt: '',
|
||
scopes: '',
|
||
proxy: proxy ? JSON.stringify(proxy) : '',
|
||
isActive: isActive.toString(),
|
||
accountType: accountType, // 账号类型:'dedicated' 或 'shared'
|
||
createdAt: new Date().toISOString(),
|
||
lastUsedAt: '',
|
||
lastRefreshAt: '',
|
||
status: 'created', // created, active, expired, error
|
||
errorMessage: ''
|
||
};
|
||
}
|
||
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
|
||
logger.success(`🏢 Created Claude account: ${name} (${accountId})`);
|
||
|
||
return {
|
||
id: accountId,
|
||
name,
|
||
description,
|
||
email,
|
||
isActive,
|
||
proxy,
|
||
accountType,
|
||
status: accountData.status,
|
||
createdAt: accountData.createdAt,
|
||
expiresAt: accountData.expiresAt,
|
||
scopes: claudeAiOauth ? claudeAiOauth.scopes : []
|
||
};
|
||
}
|
||
|
||
// 🔄 刷新Claude账户token
|
||
async refreshAccountToken(accountId) {
|
||
let lockAcquired = false;
|
||
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
const refreshToken = this._decryptSensitiveData(accountData.refreshToken);
|
||
|
||
if (!refreshToken) {
|
||
throw new Error('No refresh token available - manual token update required');
|
||
}
|
||
|
||
// 尝试获取分布式锁
|
||
lockAcquired = await tokenRefreshService.acquireRefreshLock(accountId, 'claude');
|
||
|
||
if (!lockAcquired) {
|
||
// 如果无法获取锁,说明另一个进程正在刷新
|
||
logger.info(`🔒 Token refresh already in progress for account: ${accountData.name} (${accountId})`);
|
||
logRefreshSkipped(accountId, accountData.name, 'claude', 'already_locked');
|
||
|
||
// 等待一段时间后返回,期望其他进程已完成刷新
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
|
||
// 重新获取账户数据(可能已被其他进程刷新)
|
||
const updatedData = await redis.getClaudeAccount(accountId);
|
||
if (updatedData && updatedData.accessToken) {
|
||
const accessToken = this._decryptSensitiveData(updatedData.accessToken);
|
||
return {
|
||
success: true,
|
||
accessToken: accessToken,
|
||
expiresAt: updatedData.expiresAt
|
||
};
|
||
}
|
||
|
||
throw new Error('Token refresh in progress by another process');
|
||
}
|
||
|
||
// 记录开始刷新
|
||
logRefreshStart(accountId, accountData.name, 'claude', 'manual_refresh');
|
||
logger.info(`🔄 Starting token refresh for account: ${accountData.name} (${accountId})`);
|
||
|
||
// 创建代理agent
|
||
const agent = this._createProxyAgent(accountData.proxy);
|
||
|
||
const response = await axios.post(this.claudeApiUrl, {
|
||
grant_type: 'refresh_token',
|
||
refresh_token: refreshToken,
|
||
client_id: this.claudeOauthClientId
|
||
}, {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Accept': 'application/json, text/plain, */*',
|
||
'User-Agent': 'claude-cli/1.0.56 (external, cli)',
|
||
'Accept-Language': 'en-US,en;q=0.9',
|
||
'Referer': 'https://claude.ai/',
|
||
'Origin': 'https://claude.ai'
|
||
},
|
||
httpsAgent: agent,
|
||
timeout: 30000
|
||
});
|
||
|
||
if (response.status === 200) {
|
||
const { access_token, refresh_token, expires_in } = response.data;
|
||
|
||
// 更新账户数据
|
||
accountData.accessToken = this._encryptSensitiveData(access_token);
|
||
accountData.refreshToken = this._encryptSensitiveData(refresh_token);
|
||
accountData.expiresAt = (Date.now() + (expires_in * 1000)).toString();
|
||
accountData.lastRefreshAt = new Date().toISOString();
|
||
accountData.status = 'active';
|
||
accountData.errorMessage = '';
|
||
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
|
||
// 记录刷新成功
|
||
logRefreshSuccess(accountId, accountData.name, 'claude', {
|
||
accessToken: access_token,
|
||
refreshToken: refresh_token,
|
||
expiresAt: accountData.expiresAt,
|
||
scopes: accountData.scopes
|
||
});
|
||
|
||
logger.success(`🔄 Refreshed token for account: ${accountData.name} (${accountId}) - Access Token: ${maskToken(access_token)}`);
|
||
|
||
return {
|
||
success: true,
|
||
accessToken: access_token,
|
||
expiresAt: accountData.expiresAt
|
||
};
|
||
} else {
|
||
throw new Error(`Token refresh failed with status: ${response.status}`);
|
||
}
|
||
} catch (error) {
|
||
// 记录刷新失败
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (accountData) {
|
||
logRefreshError(accountId, accountData.name, 'claude', error);
|
||
accountData.status = 'error';
|
||
accountData.errorMessage = error.message;
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
}
|
||
|
||
logger.error(`❌ Failed to refresh token for account ${accountId}:`, error);
|
||
|
||
throw error;
|
||
} finally {
|
||
// 释放锁
|
||
if (lockAcquired) {
|
||
await tokenRefreshService.releaseRefreshLock(accountId, 'claude');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 🎯 获取有效的访问token
|
||
async getValidAccessToken(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
if (accountData.isActive !== 'true') {
|
||
throw new Error('Account is disabled');
|
||
}
|
||
|
||
// 检查token是否过期
|
||
const expiresAt = parseInt(accountData.expiresAt);
|
||
const now = Date.now();
|
||
const isExpired = !expiresAt || now >= (expiresAt - 60000); // 60秒提前刷新
|
||
|
||
// 记录token使用情况
|
||
logTokenUsage(accountId, accountData.name, 'claude', accountData.expiresAt, isExpired);
|
||
|
||
if (isExpired) {
|
||
logger.info(`🔄 Token expired/expiring for account ${accountId}, attempting refresh...`);
|
||
try {
|
||
const refreshResult = await this.refreshAccountToken(accountId);
|
||
return refreshResult.accessToken;
|
||
} catch (refreshError) {
|
||
logger.warn(`⚠️ Token refresh failed for account ${accountId}: ${refreshError.message}`);
|
||
// 如果刷新失败,仍然尝试使用当前token(可能是手动添加的长期有效token)
|
||
const currentToken = this._decryptSensitiveData(accountData.accessToken);
|
||
if (currentToken) {
|
||
logger.info(`🔄 Using current token for account ${accountId} (refresh failed)`);
|
||
return currentToken;
|
||
}
|
||
throw refreshError;
|
||
}
|
||
}
|
||
|
||
const accessToken = this._decryptSensitiveData(accountData.accessToken);
|
||
|
||
if (!accessToken) {
|
||
throw new Error('No access token available');
|
||
}
|
||
|
||
// 更新最后使用时间和会话窗口
|
||
accountData.lastUsedAt = new Date().toISOString();
|
||
await this.updateSessionWindow(accountId, accountData);
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
|
||
return accessToken;
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get valid access token for account ${accountId}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 📋 获取所有Claude账户
|
||
async getAllAccounts() {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts();
|
||
|
||
// 处理返回数据,移除敏感信息并添加限流状态和会话窗口信息
|
||
const processedAccounts = await Promise.all(accounts.map(async account => {
|
||
// 获取限流状态信息
|
||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
|
||
|
||
// 获取会话窗口信息
|
||
const sessionWindowInfo = await this.getSessionWindowInfo(account.id);
|
||
|
||
return {
|
||
id: account.id,
|
||
name: account.name,
|
||
description: account.description,
|
||
email: account.email ? this._maskEmail(this._decryptSensitiveData(account.email)) : '',
|
||
isActive: account.isActive === 'true',
|
||
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,
|
||
expiresAt: account.expiresAt,
|
||
// 添加限流状态信息
|
||
rateLimitStatus: rateLimitInfo ? {
|
||
isRateLimited: rateLimitInfo.isRateLimited,
|
||
rateLimitedAt: rateLimitInfo.rateLimitedAt,
|
||
minutesRemaining: rateLimitInfo.minutesRemaining
|
||
} : null,
|
||
// 添加会话窗口信息
|
||
sessionWindow: sessionWindowInfo || {
|
||
hasActiveWindow: false,
|
||
windowStart: null,
|
||
windowEnd: null,
|
||
progress: 0,
|
||
remainingTime: null,
|
||
lastRequestTime: null
|
||
}
|
||
};
|
||
}));
|
||
|
||
return processedAccounts;
|
||
} catch (error) {
|
||
logger.error('❌ Failed to get Claude accounts:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 📝 更新Claude账户
|
||
async updateAccount(accountId, updates) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
const allowedUpdates = ['name', 'description', 'email', 'password', 'refreshToken', 'proxy', 'isActive', 'claudeAiOauth', 'accountType'];
|
||
const updatedData = { ...accountData };
|
||
|
||
// 检查是否新增了 refresh token
|
||
const oldRefreshToken = this._decryptSensitiveData(accountData.refreshToken);
|
||
|
||
for (const [field, value] of Object.entries(updates)) {
|
||
if (allowedUpdates.includes(field)) {
|
||
if (['email', 'password', 'refreshToken'].includes(field)) {
|
||
updatedData[field] = this._encryptSensitiveData(value);
|
||
} else if (field === 'proxy') {
|
||
updatedData[field] = value ? JSON.stringify(value) : '';
|
||
} else if (field === 'claudeAiOauth') {
|
||
// 更新 Claude AI OAuth 数据
|
||
if (value) {
|
||
updatedData.claudeAiOauth = this._encryptSensitiveData(JSON.stringify(value));
|
||
updatedData.accessToken = this._encryptSensitiveData(value.accessToken);
|
||
updatedData.refreshToken = this._encryptSensitiveData(value.refreshToken);
|
||
updatedData.expiresAt = value.expiresAt.toString();
|
||
updatedData.scopes = value.scopes.join(' ');
|
||
updatedData.status = 'active';
|
||
updatedData.errorMessage = '';
|
||
updatedData.lastRefreshAt = new Date().toISOString();
|
||
}
|
||
} else {
|
||
updatedData[field] = value.toString();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果新增了 refresh token(之前没有,现在有了),更新过期时间为10分钟
|
||
if (updates.refreshToken && !oldRefreshToken && updates.refreshToken.trim()) {
|
||
const newExpiresAt = Date.now() + (10 * 60 * 1000); // 10分钟
|
||
updatedData.expiresAt = newExpiresAt.toString();
|
||
logger.info(`🔄 New refresh token added for account ${accountId}, setting expiry to 10 minutes`);
|
||
}
|
||
|
||
// 如果通过 claudeAiOauth 更新,也要检查是否新增了 refresh token
|
||
if (updates.claudeAiOauth && updates.claudeAiOauth.refreshToken && !oldRefreshToken) {
|
||
// 如果 expiresAt 设置的时间过长(超过1小时),调整为10分钟
|
||
const providedExpiry = parseInt(updates.claudeAiOauth.expiresAt);
|
||
const now = Date.now();
|
||
const oneHour = 60 * 60 * 1000;
|
||
|
||
if (providedExpiry - now > oneHour) {
|
||
const newExpiresAt = now + (10 * 60 * 1000); // 10分钟
|
||
updatedData.expiresAt = newExpiresAt.toString();
|
||
logger.info(`🔄 Adjusted expiry time to 10 minutes for account ${accountId} with refresh token`);
|
||
}
|
||
}
|
||
|
||
updatedData.updatedAt = new Date().toISOString();
|
||
|
||
await redis.setClaudeAccount(accountId, updatedData);
|
||
|
||
logger.success(`📝 Updated Claude account: ${accountId}`);
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
logger.error('❌ Failed to update Claude account:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 🗑️ 删除Claude账户
|
||
async deleteAccount(accountId) {
|
||
try {
|
||
const result = await redis.deleteClaudeAccount(accountId);
|
||
|
||
if (result === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
logger.success(`🗑️ Deleted Claude account: ${accountId}`);
|
||
|
||
return { success: true };
|
||
} catch (error) {
|
||
logger.error('❌ Failed to delete Claude account:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 🎯 智能选择可用账户(支持sticky会话)
|
||
async selectAvailableAccount(sessionHash = null) {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts();
|
||
|
||
const activeAccounts = accounts.filter(account =>
|
||
account.isActive === 'true' &&
|
||
account.status !== 'error'
|
||
);
|
||
|
||
if (activeAccounts.length === 0) {
|
||
throw new Error('No active Claude accounts available');
|
||
}
|
||
|
||
// 如果有会话哈希,检查是否有已映射的账户
|
||
if (sessionHash) {
|
||
const mappedAccountId = await redis.getSessionAccountMapping(sessionHash);
|
||
if (mappedAccountId) {
|
||
// 验证映射的账户是否仍然可用
|
||
const mappedAccount = activeAccounts.find(acc => acc.id === mappedAccountId);
|
||
if (mappedAccount) {
|
||
logger.info(`🎯 Using sticky session account: ${mappedAccount.name} (${mappedAccountId}) for session ${sessionHash}`);
|
||
return mappedAccountId;
|
||
} else {
|
||
logger.warn(`⚠️ Mapped account ${mappedAccountId} is no longer available, selecting new account`);
|
||
// 清理无效的映射
|
||
await redis.deleteSessionAccountMapping(sessionHash);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 如果没有映射或映射无效,选择新账户
|
||
// 优先选择最久未使用的账户(负载均衡)
|
||
const sortedAccounts = activeAccounts.sort((a, b) => {
|
||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||
});
|
||
|
||
const selectedAccountId = sortedAccounts[0].id;
|
||
|
||
// 如果有会话哈希,建立新的映射
|
||
if (sessionHash) {
|
||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期
|
||
logger.info(`🎯 Created new sticky session mapping: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||
}
|
||
|
||
return selectedAccountId;
|
||
} catch (error) {
|
||
logger.error('❌ Failed to select available account:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 🎯 基于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) {
|
||
// 如果映射的账户被限流了,删除映射并重新选择
|
||
const isRateLimited = await this.isAccountRateLimited(mappedAccountId);
|
||
if (isRateLimited) {
|
||
logger.warn(`⚠️ Mapped account ${mappedAccountId} is rate limited, selecting new account`);
|
||
await redis.deleteSessionAccountMapping(sessionHash);
|
||
} else {
|
||
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 nonRateLimitedAccounts = [];
|
||
const rateLimitedAccounts = [];
|
||
|
||
for (const account of sharedAccounts) {
|
||
const isRateLimited = await this.isAccountRateLimited(account.id);
|
||
if (isRateLimited) {
|
||
const rateLimitInfo = await this.getAccountRateLimitInfo(account.id);
|
||
account._rateLimitInfo = rateLimitInfo; // 临时存储限流信息
|
||
rateLimitedAccounts.push(account);
|
||
} else {
|
||
nonRateLimitedAccounts.push(account);
|
||
}
|
||
}
|
||
|
||
// 优先从非限流账户中选择
|
||
let candidateAccounts = nonRateLimitedAccounts;
|
||
|
||
// 如果没有非限流账户,则从限流账户中选择(按限流时间排序,最早限流的优先)
|
||
if (candidateAccounts.length === 0) {
|
||
logger.warn('⚠️ All shared accounts are rate limited, selecting from rate limited pool');
|
||
candidateAccounts = rateLimitedAccounts.sort((a, b) => {
|
||
const aRateLimitedAt = new Date(a._rateLimitInfo.rateLimitedAt).getTime();
|
||
const bRateLimitedAt = new Date(b._rateLimitInfo.rateLimitedAt).getTime();
|
||
return aRateLimitedAt - bRateLimitedAt; // 最早限流的优先
|
||
});
|
||
} else {
|
||
// 非限流账户按最后使用时间排序(最久未使用的优先)
|
||
candidateAccounts = candidateAccounts.sort((a, b) => {
|
||
const aLastUsed = new Date(a.lastUsedAt || 0).getTime();
|
||
const bLastUsed = new Date(b.lastUsedAt || 0).getTime();
|
||
return aLastUsed - bLastUsed; // 最久未使用的优先
|
||
});
|
||
}
|
||
|
||
if (candidateAccounts.length === 0) {
|
||
throw new Error('No available shared Claude accounts');
|
||
}
|
||
|
||
const selectedAccountId = candidateAccounts[0].id;
|
||
|
||
// 如果有会话哈希,建立新的映射
|
||
if (sessionHash) {
|
||
await redis.setSessionAccountMapping(sessionHash, selectedAccountId, 3600); // 1小时过期
|
||
logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||
}
|
||
|
||
logger.info(`🎯 Selected shared account: ${candidateAccounts[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) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const proxy = JSON.parse(proxyConfig);
|
||
|
||
if (proxy.type === 'socks5') {
|
||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||
const socksUrl = `socks5://${auth}${proxy.host}:${proxy.port}`;
|
||
return new SocksProxyAgent(socksUrl);
|
||
} else if (proxy.type === 'http' || proxy.type === 'https') {
|
||
const auth = proxy.username && proxy.password ? `${proxy.username}:${proxy.password}@` : '';
|
||
const httpUrl = `${proxy.type}://${auth}${proxy.host}:${proxy.port}`;
|
||
return new HttpsProxyAgent(httpUrl);
|
||
}
|
||
} catch (error) {
|
||
logger.warn('⚠️ Invalid proxy configuration:', error);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// 🔐 加密敏感数据
|
||
_encryptSensitiveData(data) {
|
||
if (!data) return '';
|
||
|
||
try {
|
||
const key = this._generateEncryptionKey();
|
||
const iv = crypto.randomBytes(16);
|
||
|
||
const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||
let encrypted = cipher.update(data, 'utf8', 'hex');
|
||
encrypted += cipher.final('hex');
|
||
|
||
// 将IV和加密数据一起返回,用:分隔
|
||
return iv.toString('hex') + ':' + encrypted;
|
||
} catch (error) {
|
||
logger.error('❌ Encryption error:', error);
|
||
return data;
|
||
}
|
||
}
|
||
|
||
// 🔓 解密敏感数据
|
||
_decryptSensitiveData(encryptedData) {
|
||
if (!encryptedData) return '';
|
||
|
||
try {
|
||
// 检查是否是新格式(包含IV)
|
||
if (encryptedData.includes(':')) {
|
||
// 新格式:iv:encryptedData
|
||
const parts = encryptedData.split(':');
|
||
if (parts.length === 2) {
|
||
const key = this._generateEncryptionKey();
|
||
const iv = Buffer.from(parts[0], 'hex');
|
||
const encrypted = parts[1];
|
||
|
||
const decipher = crypto.createDecipheriv(this.ENCRYPTION_ALGORITHM, key, iv);
|
||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
return decrypted;
|
||
}
|
||
}
|
||
|
||
// 旧格式或格式错误,尝试旧方式解密(向后兼容)
|
||
// 注意:在新版本Node.js中这将失败,但我们会捕获错误
|
||
try {
|
||
const decipher = crypto.createDecipher('aes-256-cbc', config.security.encryptionKey);
|
||
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
return decrypted;
|
||
} catch (oldError) {
|
||
// 如果旧方式也失败,返回原数据
|
||
logger.warn('⚠️ Could not decrypt data, returning as-is:', oldError.message);
|
||
return encryptedData;
|
||
}
|
||
} catch (error) {
|
||
logger.error('❌ Decryption error:', error);
|
||
return encryptedData;
|
||
}
|
||
}
|
||
|
||
// 🔑 生成加密密钥(辅助方法)
|
||
_generateEncryptionKey() {
|
||
return crypto.scryptSync(config.security.encryptionKey, this.ENCRYPTION_SALT, 32);
|
||
}
|
||
|
||
// 🎭 掩码邮箱地址
|
||
_maskEmail(email) {
|
||
if (!email || !email.includes('@')) return email;
|
||
|
||
const [username, domain] = email.split('@');
|
||
const maskedUsername = username.length > 2
|
||
? `${username.slice(0, 2)}***${username.slice(-1)}`
|
||
: `${username.slice(0, 1)}***`;
|
||
|
||
return `${maskedUsername}@${domain}`;
|
||
}
|
||
|
||
// 🧹 清理错误账户
|
||
async cleanupErrorAccounts() {
|
||
try {
|
||
const accounts = await redis.getAllClaudeAccounts();
|
||
let cleanedCount = 0;
|
||
|
||
for (const account of accounts) {
|
||
if (account.status === 'error' && account.lastRefreshAt) {
|
||
const lastRefresh = new Date(account.lastRefreshAt);
|
||
const now = new Date();
|
||
const hoursSinceLastRefresh = (now - lastRefresh) / (1000 * 60 * 60);
|
||
|
||
// 如果错误状态超过24小时,尝试重新激活
|
||
if (hoursSinceLastRefresh > 24) {
|
||
account.status = 'created';
|
||
account.errorMessage = '';
|
||
await redis.setClaudeAccount(account.id, account);
|
||
cleanedCount++;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedCount > 0) {
|
||
logger.success(`🧹 Reset ${cleanedCount} error accounts`);
|
||
}
|
||
|
||
return cleanedCount;
|
||
} catch (error) {
|
||
logger.error('❌ Failed to cleanup error accounts:', error);
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// 🚫 标记账号为限流状态
|
||
async markAccountRateLimited(accountId, sessionHash = null) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
// 设置限流状态和时间
|
||
accountData.rateLimitedAt = new Date().toISOString();
|
||
accountData.rateLimitStatus = 'limited';
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
|
||
// 如果有会话哈希,删除粘性会话映射
|
||
if (sessionHash) {
|
||
await redis.deleteSessionAccountMapping(sessionHash);
|
||
logger.info(`🗑️ Deleted sticky session mapping for rate limited account: ${accountId}`);
|
||
}
|
||
|
||
logger.warn(`🚫 Account marked as rate limited: ${accountData.name} (${accountId})`);
|
||
return { success: true };
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to mark account as rate limited: ${accountId}`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// ✅ 移除账号的限流状态
|
||
async removeAccountRateLimit(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
|
||
// 清除限流状态
|
||
delete accountData.rateLimitedAt;
|
||
delete accountData.rateLimitStatus;
|
||
await redis.setClaudeAccount(accountId, accountData);
|
||
|
||
logger.success(`✅ Rate limit removed for account: ${accountData.name} (${accountId})`);
|
||
return { success: true };
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to remove rate limit for account: ${accountId}`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 🔍 检查账号是否处于限流状态
|
||
async isAccountRateLimited(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return false;
|
||
}
|
||
|
||
// 检查是否有限流状态
|
||
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||
const rateLimitedAt = new Date(accountData.rateLimitedAt);
|
||
const now = new Date();
|
||
const hoursSinceRateLimit = (now - rateLimitedAt) / (1000 * 60 * 60);
|
||
|
||
// 如果限流超过1小时,自动解除
|
||
if (hoursSinceRateLimit >= 1) {
|
||
await this.removeAccountRateLimit(accountId);
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to check rate limit status for account: ${accountId}`, error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 📊 获取账号的限流信息
|
||
async getAccountRateLimitInfo(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null;
|
||
}
|
||
|
||
if (accountData.rateLimitStatus === 'limited' && accountData.rateLimitedAt) {
|
||
const rateLimitedAt = new Date(accountData.rateLimitedAt);
|
||
const now = new Date();
|
||
const minutesSinceRateLimit = Math.floor((now - rateLimitedAt) / (1000 * 60));
|
||
const minutesRemaining = Math.max(0, 60 - minutesSinceRateLimit);
|
||
|
||
return {
|
||
isRateLimited: minutesRemaining > 0,
|
||
rateLimitedAt: accountData.rateLimitedAt,
|
||
minutesSinceRateLimit,
|
||
minutesRemaining
|
||
};
|
||
}
|
||
|
||
return {
|
||
isRateLimited: false,
|
||
rateLimitedAt: null,
|
||
minutesSinceRateLimit: 0,
|
||
minutesRemaining: 0
|
||
};
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get rate limit info for account: ${accountId}`, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 🕐 更新会话窗口
|
||
async updateSessionWindow(accountId, accountData = null) {
|
||
try {
|
||
// 如果没有传入accountData,从Redis获取
|
||
if (!accountData) {
|
||
accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
throw new Error('Account not found');
|
||
}
|
||
}
|
||
|
||
const now = new Date();
|
||
const currentTime = now.getTime();
|
||
|
||
// 检查当前是否有活跃的会话窗口
|
||
if (accountData.sessionWindowStart && accountData.sessionWindowEnd) {
|
||
const windowEnd = new Date(accountData.sessionWindowEnd).getTime();
|
||
|
||
// 如果当前时间在窗口内,不需要更新
|
||
if (currentTime < windowEnd) {
|
||
accountData.lastRequestTime = now.toISOString();
|
||
return accountData;
|
||
}
|
||
}
|
||
|
||
// 计算新的会话窗口
|
||
const windowStart = this._calculateSessionWindowStart(now);
|
||
const windowEnd = this._calculateSessionWindowEnd(windowStart);
|
||
|
||
// 更新会话窗口信息
|
||
accountData.sessionWindowStart = windowStart.toISOString();
|
||
accountData.sessionWindowEnd = windowEnd.toISOString();
|
||
accountData.lastRequestTime = now.toISOString();
|
||
|
||
logger.info(`🕐 Updated session window for account ${accountData.name} (${accountId}): ${windowStart.toISOString()} - ${windowEnd.toISOString()}`);
|
||
|
||
return accountData;
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to update session window for account ${accountId}:`, error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 🕐 计算会话窗口开始时间
|
||
_calculateSessionWindowStart(requestTime) {
|
||
const hour = requestTime.getHours();
|
||
const windowStartHour = Math.floor(hour / 5) * 5; // 向下取整到最近的5小时边界
|
||
|
||
const windowStart = new Date(requestTime);
|
||
windowStart.setHours(windowStartHour, 0, 0, 0);
|
||
|
||
return windowStart;
|
||
}
|
||
|
||
// 🕐 计算会话窗口结束时间
|
||
_calculateSessionWindowEnd(startTime) {
|
||
const endTime = new Date(startTime);
|
||
endTime.setHours(endTime.getHours() + 5); // 加5小时
|
||
return endTime;
|
||
}
|
||
|
||
// 📊 获取会话窗口信息
|
||
async getSessionWindowInfo(accountId) {
|
||
try {
|
||
const accountData = await redis.getClaudeAccount(accountId);
|
||
if (!accountData || Object.keys(accountData).length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// 如果没有会话窗口信息,返回null
|
||
if (!accountData.sessionWindowStart || !accountData.sessionWindowEnd) {
|
||
return {
|
||
hasActiveWindow: false,
|
||
windowStart: null,
|
||
windowEnd: null,
|
||
progress: 0,
|
||
remainingTime: null,
|
||
lastRequestTime: accountData.lastRequestTime || null
|
||
};
|
||
}
|
||
|
||
const now = new Date();
|
||
const windowStart = new Date(accountData.sessionWindowStart);
|
||
const windowEnd = new Date(accountData.sessionWindowEnd);
|
||
const currentTime = now.getTime();
|
||
|
||
// 检查窗口是否已过期
|
||
if (currentTime >= windowEnd.getTime()) {
|
||
return {
|
||
hasActiveWindow: false,
|
||
windowStart: accountData.sessionWindowStart,
|
||
windowEnd: accountData.sessionWindowEnd,
|
||
progress: 100,
|
||
remainingTime: 0,
|
||
lastRequestTime: accountData.lastRequestTime || null
|
||
};
|
||
}
|
||
|
||
// 计算进度百分比
|
||
const totalDuration = windowEnd.getTime() - windowStart.getTime();
|
||
const elapsedTime = currentTime - windowStart.getTime();
|
||
const progress = Math.round((elapsedTime / totalDuration) * 100);
|
||
|
||
// 计算剩余时间(分钟)
|
||
const remainingTime = Math.round((windowEnd.getTime() - currentTime) / (1000 * 60));
|
||
|
||
return {
|
||
hasActiveWindow: true,
|
||
windowStart: accountData.sessionWindowStart,
|
||
windowEnd: accountData.sessionWindowEnd,
|
||
progress,
|
||
remainingTime,
|
||
lastRequestTime: accountData.lastRequestTime || null
|
||
};
|
||
} catch (error) {
|
||
logger.error(`❌ Failed to get session window info for account ${accountId}:`, error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 🔄 初始化所有账户的会话窗口(从历史数据恢复)
|
||
async initializeSessionWindows(forceRecalculate = false) {
|
||
try {
|
||
logger.info('🔄 Initializing session windows for all Claude accounts...');
|
||
|
||
const accounts = await redis.getAllClaudeAccounts();
|
||
let initializedCount = 0;
|
||
let skippedCount = 0;
|
||
let expiredCount = 0;
|
||
|
||
for (const account of accounts) {
|
||
// 如果已经有会话窗口信息且不强制重算,跳过
|
||
if (account.sessionWindowStart && account.sessionWindowEnd && !forceRecalculate) {
|
||
skippedCount++;
|
||
logger.debug(`⏭️ Skipped account ${account.name} (${account.id}) - already has session window`);
|
||
continue;
|
||
}
|
||
|
||
// 如果有lastUsedAt,基于它恢复会话窗口
|
||
if (account.lastUsedAt) {
|
||
const lastUsedTime = new Date(account.lastUsedAt);
|
||
const now = new Date();
|
||
|
||
// 计算时间差(分钟)
|
||
const timeSinceLastUsed = Math.round((now.getTime() - lastUsedTime.getTime()) / (1000 * 60));
|
||
|
||
// 计算lastUsedAt对应的会话窗口
|
||
const windowStart = this._calculateSessionWindowStart(lastUsedTime);
|
||
const windowEnd = this._calculateSessionWindowEnd(windowStart);
|
||
|
||
// 计算窗口剩余时间(分钟)
|
||
const timeUntilWindowExpires = Math.round((windowEnd.getTime() - now.getTime()) / (1000 * 60));
|
||
|
||
logger.info(`🔍 Analyzing account ${account.name} (${account.id}):`);
|
||
logger.info(` Last used: ${lastUsedTime.toISOString()} (${timeSinceLastUsed} minutes ago)`);
|
||
logger.info(` Calculated window: ${windowStart.toISOString()} - ${windowEnd.toISOString()}`);
|
||
logger.info(` Window expires in: ${timeUntilWindowExpires > 0 ? timeUntilWindowExpires + ' minutes' : 'EXPIRED'}`);
|
||
|
||
// 只有窗口未过期才恢复
|
||
if (now.getTime() < windowEnd.getTime()) {
|
||
account.sessionWindowStart = windowStart.toISOString();
|
||
account.sessionWindowEnd = windowEnd.toISOString();
|
||
account.lastRequestTime = account.lastUsedAt;
|
||
|
||
await redis.setClaudeAccount(account.id, account);
|
||
initializedCount++;
|
||
|
||
logger.success(`✅ Initialized session window for account ${account.name} (${account.id})`);
|
||
} else {
|
||
expiredCount++;
|
||
logger.warn(`⏰ Window expired for account ${account.name} (${account.id}) - will create new window on next request`);
|
||
}
|
||
} else {
|
||
logger.info(`📭 No lastUsedAt data for account ${account.name} (${account.id}) - will create window on first request`);
|
||
}
|
||
}
|
||
|
||
logger.success(`✅ Session window initialization completed:`);
|
||
logger.success(` 📊 Total accounts: ${accounts.length}`);
|
||
logger.success(` ✅ Initialized: ${initializedCount}`);
|
||
logger.success(` ⏭️ Skipped (existing): ${skippedCount}`);
|
||
logger.success(` ⏰ Expired: ${expiredCount}`);
|
||
logger.success(` 📭 No usage data: ${accounts.length - initializedCount - skippedCount - expiredCount}`);
|
||
|
||
return {
|
||
total: accounts.length,
|
||
initialized: initializedCount,
|
||
skipped: skippedCount,
|
||
expired: expiredCount,
|
||
noData: accounts.length - initializedCount - skippedCount - expiredCount
|
||
};
|
||
} catch (error) {
|
||
logger.error('❌ Failed to initialize session windows:', error);
|
||
return {
|
||
total: 0,
|
||
initialized: 0,
|
||
skipped: 0,
|
||
expired: 0,
|
||
noData: 0,
|
||
error: error.message
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
module.exports = new ClaudeAccountService(); |