mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 改进管理界面弹窗体验和滚动条美化
- 修复API Key创建/编辑弹窗和账户信息修改弹窗在低高度屏幕上被遮挡的问题 - 为所有弹窗添加自适应高度支持,最大高度限制为90vh - 美化Claude账户弹窗的滚动条样式,使用紫蓝渐变色与主题保持一致 - 添加响应式适配,移动设备上弹窗高度调整为85vh - 优化滚动条交互体验,支持悬停和激活状态 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -270,8 +270,7 @@ class Application {
|
||||
logger.info(`📊 Metrics: http://${config.server.host}:${config.server.port}/metrics`);
|
||||
});
|
||||
|
||||
// 设置服务器超时时间,与代理超时时间一致
|
||||
const serverTimeout = config.proxy.timeout || 300000; // 默认5分钟
|
||||
const serverTimeout = 600000; // 默认10分钟
|
||||
this.server.timeout = serverTimeout;
|
||||
this.server.keepAliveTimeout = serverTimeout + 5000; // keepAlive 稍长一点
|
||||
logger.info(`⏱️ Server timeout set to ${serverTimeout}ms (${serverTimeout/1000}s)`);
|
||||
|
||||
@@ -139,18 +139,24 @@ class RedisClient {
|
||||
// 📊 使用统计相关操作(支持缓存token统计和模型信息)
|
||||
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
|
||||
const key = `usage:${keyId}`;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`;
|
||||
const now = new Date();
|
||||
const today = now.toISOString().split('T')[0];
|
||||
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别
|
||||
|
||||
const daily = `usage:daily:${keyId}:${today}`;
|
||||
const monthly = `usage:monthly:${keyId}:${currentMonth}`;
|
||||
const hourly = `usage:hourly:${keyId}:${currentHour}`; // 新增小时级别key
|
||||
|
||||
// 按模型统计的键
|
||||
const modelDaily = `usage:model:daily:${model}:${today}`;
|
||||
const modelMonthly = `usage:model:monthly:${model}:${currentMonth}`;
|
||||
const modelHourly = `usage:model:hourly:${model}:${currentHour}`; // 新增模型小时级别
|
||||
|
||||
// API Key级别的模型统计
|
||||
const keyModelDaily = `usage:${keyId}:model:daily:${model}:${today}`;
|
||||
const keyModelMonthly = `usage:${keyId}:model:monthly:${model}:${currentMonth}`;
|
||||
const keyModelHourly = `usage:${keyId}:model:hourly:${model}:${currentHour}`; // 新增API Key模型小时级别
|
||||
|
||||
// 智能处理输入输出token分配
|
||||
const finalInputTokens = inputTokens || 0;
|
||||
@@ -218,13 +224,40 @@ class RedisClient {
|
||||
this.client.hincrby(keyModelMonthly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(keyModelMonthly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(keyModelMonthly, 'requests', 1),
|
||||
|
||||
// 小时级别统计
|
||||
this.client.hincrby(hourly, 'tokens', coreTokens),
|
||||
this.client.hincrby(hourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(hourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(hourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(hourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(hourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(hourly, 'requests', 1),
|
||||
// 按模型统计 - 每小时
|
||||
this.client.hincrby(modelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(modelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(modelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(modelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(modelHourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(modelHourly, 'requests', 1),
|
||||
// API Key级别的模型统计 - 每小时
|
||||
this.client.hincrby(keyModelHourly, 'inputTokens', finalInputTokens),
|
||||
this.client.hincrby(keyModelHourly, 'outputTokens', finalOutputTokens),
|
||||
this.client.hincrby(keyModelHourly, 'cacheCreateTokens', finalCacheCreateTokens),
|
||||
this.client.hincrby(keyModelHourly, 'cacheReadTokens', finalCacheReadTokens),
|
||||
this.client.hincrby(keyModelHourly, 'allTokens', totalTokens),
|
||||
this.client.hincrby(keyModelHourly, 'requests', 1),
|
||||
|
||||
// 设置过期时间
|
||||
this.client.expire(daily, 86400 * 32), // 32天过期
|
||||
this.client.expire(monthly, 86400 * 365), // 1年过期
|
||||
this.client.expire(hourly, 86400 * 7), // 小时统计7天过期
|
||||
this.client.expire(modelDaily, 86400 * 32), // 模型每日统计32天过期
|
||||
this.client.expire(modelMonthly, 86400 * 365), // 模型每月统计1年过期
|
||||
this.client.expire(modelHourly, 86400 * 7), // 模型小时统计7天过期
|
||||
this.client.expire(keyModelDaily, 86400 * 32), // API Key模型每日统计32天过期
|
||||
this.client.expire(keyModelMonthly, 86400 * 365) // API Key模型每月统计1年过期
|
||||
this.client.expire(keyModelMonthly, 86400 * 365), // API Key模型每月统计1年过期
|
||||
this.client.expire(keyModelHourly, 86400 * 7) // API Key模型小时统计7天过期
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
|
||||
const activeApiKeys = apiKeys.filter(key => key.isActive).length;
|
||||
const activeAccounts = accounts.filter(acc => acc.isActive && acc.status === 'active').length;
|
||||
const rateLimitedAccounts = accounts.filter(acc => acc.rateLimitStatus && acc.rateLimitStatus.isRateLimited).length;
|
||||
|
||||
const dashboard = {
|
||||
overview: {
|
||||
@@ -358,6 +359,7 @@ router.get('/dashboard', authenticateAdmin, async (req, res) => {
|
||||
activeApiKeys,
|
||||
totalClaudeAccounts: accounts.length,
|
||||
activeClaudeAccounts: activeAccounts,
|
||||
rateLimitedClaudeAccounts: rateLimitedAccounts,
|
||||
totalTokensUsed,
|
||||
totalRequestsUsed,
|
||||
totalInputTokensUsed,
|
||||
@@ -528,22 +530,140 @@ router.post('/cleanup', authenticateAdmin, async (req, res) => {
|
||||
// 获取使用趋势数据
|
||||
router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { days = 7 } = req.query;
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const { days = 7, granularity = 'day', startDate, endDate } = req.query;
|
||||
const client = redis.getClientSafe();
|
||||
|
||||
const trendData = [];
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度统计
|
||||
let startTime, endTime;
|
||||
|
||||
// 汇总当天所有API Key的使用数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
if (startDate && endDate) {
|
||||
// 使用自定义时间范围
|
||||
startTime = new Date(startDate);
|
||||
endTime = new Date(endDate);
|
||||
} else {
|
||||
// 默认最近24小时
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 确保时间范围不超过24小时
|
||||
const timeDiff = endTime - startTime;
|
||||
if (timeDiff > 24 * 60 * 60 * 1000) {
|
||||
return res.status(400).json({
|
||||
error: '小时粒度查询时间范围不能超过24小时'
|
||||
});
|
||||
}
|
||||
|
||||
// 按小时遍历
|
||||
const currentHour = new Date(startTime);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
while (currentHour <= endTime) {
|
||||
const dateStr = currentHour.toISOString().split('T')[0];
|
||||
const hour = String(currentHour.getHours()).padStart(2, '0');
|
||||
const hourKey = `${dateStr}:${hour}`;
|
||||
|
||||
// 获取当前小时的模型统计数据
|
||||
const modelPattern = `usage:model:hourly:*:${hourKey}`;
|
||||
const modelKeys = await client.keys(modelPattern);
|
||||
|
||||
let hourInputTokens = 0;
|
||||
let hourOutputTokens = 0;
|
||||
let hourRequests = 0;
|
||||
let hourCacheCreateTokens = 0;
|
||||
let hourCacheReadTokens = 0;
|
||||
let hourCost = 0;
|
||||
|
||||
for (const modelKey of modelKeys) {
|
||||
const modelMatch = modelKey.match(/usage:model:hourly:(.+):\d{4}-\d{2}-\d{2}:\d{2}$/);
|
||||
if (!modelMatch) continue;
|
||||
|
||||
const model = modelMatch[1];
|
||||
const data = await client.hgetall(modelKey);
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const modelInputTokens = parseInt(data.inputTokens) || 0;
|
||||
const modelOutputTokens = parseInt(data.outputTokens) || 0;
|
||||
const modelCacheCreateTokens = parseInt(data.cacheCreateTokens) || 0;
|
||||
const modelCacheReadTokens = parseInt(data.cacheReadTokens) || 0;
|
||||
const modelRequests = parseInt(data.requests) || 0;
|
||||
|
||||
hourInputTokens += modelInputTokens;
|
||||
hourOutputTokens += modelOutputTokens;
|
||||
hourCacheCreateTokens += modelCacheCreateTokens;
|
||||
hourCacheReadTokens += modelCacheReadTokens;
|
||||
hourRequests += modelRequests;
|
||||
|
||||
const modelUsage = {
|
||||
input_tokens: modelInputTokens,
|
||||
output_tokens: modelOutputTokens,
|
||||
cache_creation_input_tokens: modelCacheCreateTokens,
|
||||
cache_read_input_tokens: modelCacheReadTokens
|
||||
};
|
||||
const modelCostResult = CostCalculator.calculateCost(modelUsage, model);
|
||||
hourCost += modelCostResult.costs.total;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有模型级别的数据,尝试API Key级别的数据
|
||||
if (modelKeys.length === 0) {
|
||||
const pattern = `usage:hourly:*:${hourKey}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
for (const key of keys) {
|
||||
const data = await client.hgetall(key);
|
||||
if (data) {
|
||||
hourInputTokens += parseInt(data.inputTokens) || 0;
|
||||
hourOutputTokens += parseInt(data.outputTokens) || 0;
|
||||
hourRequests += parseInt(data.requests) || 0;
|
||||
hourCacheCreateTokens += parseInt(data.cacheCreateTokens) || 0;
|
||||
hourCacheReadTokens += parseInt(data.cacheReadTokens) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
const usage = {
|
||||
input_tokens: hourInputTokens,
|
||||
output_tokens: hourOutputTokens,
|
||||
cache_creation_input_tokens: hourCacheCreateTokens,
|
||||
cache_read_input_tokens: hourCacheReadTokens
|
||||
};
|
||||
const costResult = CostCalculator.calculateCost(usage, 'unknown');
|
||||
hourCost = costResult.costs.total;
|
||||
}
|
||||
|
||||
trendData.push({
|
||||
date: hourKey,
|
||||
hour: currentHour.toISOString(),
|
||||
inputTokens: hourInputTokens,
|
||||
outputTokens: hourOutputTokens,
|
||||
requests: hourRequests,
|
||||
cacheCreateTokens: hourCacheCreateTokens,
|
||||
cacheReadTokens: hourCacheReadTokens,
|
||||
totalTokens: hourInputTokens + hourOutputTokens + hourCacheCreateTokens + hourCacheReadTokens,
|
||||
cost: hourCost
|
||||
});
|
||||
|
||||
// 移到下一个小时
|
||||
currentHour.setHours(currentHour.getHours() + 1);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 天粒度统计(保持原有逻辑)
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
// 汇总当天所有API Key的使用数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
let dayInputTokens = 0;
|
||||
let dayOutputTokens = 0;
|
||||
@@ -553,7 +673,7 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
let dayCost = 0;
|
||||
|
||||
// 按模型统计使用量
|
||||
const modelUsageMap = new Map();
|
||||
// const modelUsageMap = new Map();
|
||||
|
||||
// 获取当天所有模型的使用数据
|
||||
const modelPattern = `usage:model:daily:*:${dateStr}`;
|
||||
@@ -630,10 +750,16 @@ router.get('/usage-trend', authenticateAdmin, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 按日期正序排列
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
res.json({ success: true, data: trendData });
|
||||
// 按日期正序排列
|
||||
if (granularity === 'hour') {
|
||||
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
|
||||
} else {
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
res.json({ success: true, data: trendData, granularity });
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get usage trend:', error);
|
||||
res.status(500).json({ error: 'Failed to get usage trend', message: error.message });
|
||||
@@ -833,6 +959,152 @@ router.get('/api-keys/:keyId/model-stats', authenticateAdmin, async (req, res) =
|
||||
});
|
||||
|
||||
|
||||
// 获取按API Key分组的使用趋势
|
||||
router.get('/api-keys-usage-trend', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
const { granularity = 'day', days = 7, startDate, endDate } = req.query;
|
||||
|
||||
logger.info(`📊 Getting API keys usage trend, granularity: ${granularity}, days: ${days}`);
|
||||
|
||||
const client = redis.getClientSafe();
|
||||
const trendData = [];
|
||||
|
||||
// 获取所有API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeys();
|
||||
const apiKeyMap = new Map(apiKeys.map(key => [key.id, key]));
|
||||
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度统计
|
||||
let endTime, startTime;
|
||||
|
||||
if (startDate && endDate) {
|
||||
// 自定义时间范围
|
||||
startTime = new Date(startDate);
|
||||
endTime = new Date(endDate);
|
||||
} else {
|
||||
// 默认近24小时
|
||||
endTime = new Date();
|
||||
startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// 按小时遍历
|
||||
const currentHour = new Date(startTime);
|
||||
currentHour.setMinutes(0, 0, 0);
|
||||
|
||||
while (currentHour <= endTime) {
|
||||
const hourKey = currentHour.toISOString().split(':')[0].replace('T', ':');
|
||||
|
||||
// 获取这个小时所有API Key的数据
|
||||
const pattern = `usage:hourly:*:${hourKey}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
const hourData = {
|
||||
hour: currentHour.toISOString(),
|
||||
apiKeys: {}
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(/usage:hourly:(.+?):\d{4}-\d{2}-\d{2}:\d{2}/);
|
||||
if (!match) continue;
|
||||
|
||||
const apiKeyId = match[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && apiKeyMap.has(apiKeyId)) {
|
||||
const totalTokens = (parseInt(data.inputTokens) || 0) +
|
||||
(parseInt(data.outputTokens) || 0) +
|
||||
(parseInt(data.cacheCreateTokens) || 0) +
|
||||
(parseInt(data.cacheReadTokens) || 0);
|
||||
|
||||
hourData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trendData.push(hourData);
|
||||
currentHour.setHours(currentHour.getHours() + 1);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 天粒度统计
|
||||
const daysCount = parseInt(days) || 7;
|
||||
const today = new Date();
|
||||
|
||||
// 获取过去N天的数据
|
||||
for (let i = 0; i < daysCount; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
// 获取这一天所有API Key的数据
|
||||
const pattern = `usage:daily:*:${dateStr}`;
|
||||
const keys = await client.keys(pattern);
|
||||
|
||||
const dayData = {
|
||||
date: dateStr,
|
||||
apiKeys: {}
|
||||
};
|
||||
|
||||
for (const key of keys) {
|
||||
const match = key.match(/usage:daily:(.+?):\d{4}-\d{2}-\d{2}/);
|
||||
if (!match) continue;
|
||||
|
||||
const apiKeyId = match[1];
|
||||
const data = await client.hgetall(key);
|
||||
|
||||
if (data && apiKeyMap.has(apiKeyId)) {
|
||||
const totalTokens = (parseInt(data.inputTokens) || 0) +
|
||||
(parseInt(data.outputTokens) || 0) +
|
||||
(parseInt(data.cacheCreateTokens) || 0) +
|
||||
(parseInt(data.cacheReadTokens) || 0);
|
||||
|
||||
dayData.apiKeys[apiKeyId] = {
|
||||
name: apiKeyMap.get(apiKeyId).name,
|
||||
tokens: totalTokens
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
trendData.push(dayData);
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间正序排列
|
||||
if (granularity === 'hour') {
|
||||
trendData.sort((a, b) => new Date(a.hour) - new Date(b.hour));
|
||||
} else {
|
||||
trendData.sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||
}
|
||||
|
||||
// 计算每个API Key的总token数,用于排序
|
||||
const apiKeyTotals = new Map();
|
||||
for (const point of trendData) {
|
||||
for (const [apiKeyId, data] of Object.entries(point.apiKeys)) {
|
||||
apiKeyTotals.set(apiKeyId, (apiKeyTotals.get(apiKeyId) || 0) + data.tokens);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取前10个使用量最多的API Key
|
||||
const topApiKeys = Array.from(apiKeyTotals.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([apiKeyId]) => apiKeyId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: trendData,
|
||||
granularity,
|
||||
topApiKeys,
|
||||
totalApiKeys: apiKeyTotals.size
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get API keys usage trend:', error);
|
||||
res.status(500).json({ error: 'Failed to get API keys usage trend', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 计算总体使用费用
|
||||
router.get('/usage-costs', authenticateAdmin, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -217,7 +217,7 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
|
||||
try {
|
||||
const initData = JSON.parse(fs.readFileSync(initFilePath, 'utf8'));
|
||||
const oldData = { ...initData }; // 备份旧数据
|
||||
// const oldData = { ...initData }; // 备份旧数据
|
||||
|
||||
// 更新 init.json
|
||||
initData.adminUsername = updatedUsername;
|
||||
@@ -252,12 +252,12 @@ router.post('/auth/change-password', async (req, res) => {
|
||||
// 清除当前会话(强制用户重新登录)
|
||||
await redis.deleteSession(token);
|
||||
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedAdminData.username}`);
|
||||
logger.success(`🔐 Admin password changed successfully for user: ${updatedUsername}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully. Please login again.',
|
||||
newUsername: updatedAdminData.username
|
||||
newUsername: updatedUsername
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -228,22 +228,35 @@ class ClaudeAccountService {
|
||||
try {
|
||||
const accounts = await redis.getAllClaudeAccounts();
|
||||
|
||||
// 处理返回数据,移除敏感信息
|
||||
return accounts.map(account => ({
|
||||
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
|
||||
// 处理返回数据,移除敏感信息并添加限流状态
|
||||
const processedAccounts = await Promise.all(accounts.map(async account => {
|
||||
// 获取限流状态信息
|
||||
const rateLimitInfo = await this.getAccountRateLimitInfo(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
|
||||
};
|
||||
}));
|
||||
|
||||
return processedAccounts;
|
||||
} catch (error) {
|
||||
logger.error('❌ Failed to get Claude accounts:', error);
|
||||
throw error;
|
||||
@@ -405,8 +418,15 @@ class ClaudeAccountService {
|
||||
// 验证映射的账户是否仍然在共享池中且可用
|
||||
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;
|
||||
// 如果映射的账户被限流了,删除映射并重新选择
|
||||
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`);
|
||||
// 清理无效的映射
|
||||
@@ -415,21 +435,54 @@ class ClaudeAccountService {
|
||||
}
|
||||
}
|
||||
|
||||
// 从共享池选择账户(负载均衡)
|
||||
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;
|
||||
// 将账户分为限流和非限流两组
|
||||
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 aLastRefresh = new Date(a.lastRefreshAt || 0).getTime();
|
||||
const bLastRefresh = new Date(b.lastRefreshAt || 0).getTime();
|
||||
return bLastRefresh - aLastRefresh;
|
||||
});
|
||||
}
|
||||
|
||||
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: ${sortedAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||||
logger.info(`🎯 Created new sticky session mapping for shared account: ${candidateAccounts[0].name} (${selectedAccountId}) for session ${sessionHash}`);
|
||||
}
|
||||
|
||||
logger.info(`🎯 Selected shared account: ${sortedAccounts[0].name} (${selectedAccountId}) for API key ${apiKeyData.name}`);
|
||||
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);
|
||||
@@ -570,6 +623,118 @@ class ClaudeAccountService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ClaudeAccountService();
|
||||
@@ -72,6 +72,35 @@ class ClaudeRelayService {
|
||||
clientResponse.removeListener('close', handleClientDisconnect);
|
||||
}
|
||||
|
||||
// 检查响应是否为限流错误
|
||||
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||
let isRateLimited = false;
|
||||
try {
|
||||
const responseBody = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;
|
||||
if (responseBody && responseBody.error && responseBody.error.message &&
|
||||
responseBody.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
isRateLimited = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// 如果解析失败,检查原始字符串
|
||||
if (response.body && response.body.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
isRateLimited = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRateLimited) {
|
||||
logger.warn(`🚫 Rate limit detected for account ${accountId}, status: ${response.statusCode}`);
|
||||
// 标记账号为限流状态并删除粘性会话映射
|
||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
|
||||
}
|
||||
} else if (response.statusCode === 200 || response.statusCode === 201) {
|
||||
// 如果请求成功,检查并移除限流状态
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
||||
if (isRateLimited) {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
// 记录成功的API调用
|
||||
const inputTokens = requestBody.messages ?
|
||||
requestBody.messages.reduce((sum, msg) => sum + (msg.content?.length || 0), 0) / 4 : 0; // 粗略估算
|
||||
@@ -408,7 +437,7 @@ class ClaudeRelayService {
|
||||
const proxyAgent = await this._getProxyAgent(accountId);
|
||||
|
||||
// 发送流式请求并捕获usage数据
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback);
|
||||
return await this._makeClaudeStreamRequestWithUsageCapture(processedBody, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash);
|
||||
} catch (error) {
|
||||
logger.error('❌ Claude stream relay with usage capture failed:', error);
|
||||
throw error;
|
||||
@@ -416,7 +445,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
|
||||
// 🌊 发送流式请求到Claude API(带usage数据捕获)
|
||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback) {
|
||||
async _makeClaudeStreamRequestWithUsageCapture(body, accessToken, proxyAgent, clientHeaders, responseStream, usageCallback, accountId, sessionHash) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(this.claudeApiUrl);
|
||||
|
||||
@@ -457,6 +486,7 @@ class ClaudeRelayService {
|
||||
let buffer = '';
|
||||
let finalUsageReported = false; // 防止重复统计的标志
|
||||
let collectedUsageData = {}; // 收集来自不同事件的usage数据
|
||||
let rateLimitDetected = false; // 限流检测标志
|
||||
|
||||
// 监听数据块,解析SSE并寻找usage信息
|
||||
res.on('data', (chunk) => {
|
||||
@@ -517,6 +547,13 @@ class ClaudeRelayService {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有限流错误
|
||||
if (data.type === 'error' && data.error && data.error.message &&
|
||||
data.error.message.toLowerCase().includes('exceed your account\'s rate limit')) {
|
||||
rateLimitDetected = true;
|
||||
logger.warn(`🚫 Rate limit detected in stream for account ${accountId}`);
|
||||
}
|
||||
|
||||
} catch (parseError) {
|
||||
// 忽略JSON解析错误,继续处理
|
||||
logger.debug('🔍 SSE line not JSON or no usage data:', line.slice(0, 100));
|
||||
@@ -525,7 +562,7 @@ class ClaudeRelayService {
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
res.on('end', async () => {
|
||||
// 处理缓冲区中剩余的数据
|
||||
if (buffer.trim()) {
|
||||
responseStream.write(buffer);
|
||||
@@ -537,6 +574,18 @@ class ClaudeRelayService {
|
||||
logger.warn('⚠️ Stream completed but no usage data was captured! This indicates a problem with SSE parsing or Claude API response format.');
|
||||
}
|
||||
|
||||
// 处理限流状态
|
||||
if (rateLimitDetected || res.statusCode === 429) {
|
||||
// 标记账号为限流状态并删除粘性会话映射
|
||||
await claudeAccountService.markAccountRateLimited(accountId, sessionHash);
|
||||
} else if (res.statusCode === 200) {
|
||||
// 如果请求成功,检查并移除限流状态
|
||||
const isRateLimited = await claudeAccountService.isAccountRateLimited(accountId);
|
||||
if (isRateLimited) {
|
||||
await claudeAccountService.removeAccountRateLimit(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug('🌊 Claude stream response with usage capture completed');
|
||||
resolve();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user