Merge pull request #106 from kevinconan/main - feat: 账号管理页面,给Claude账号添加了会话窗口管理与显示

This commit is contained in:
shaw
2025-07-29 11:08:56 +08:00
7 changed files with 1409 additions and 4 deletions

View File

@@ -270,8 +270,9 @@ class ClaudeAccountService {
throw new Error('No access token available');
}
// 更新最后使用时间
// 更新最后使用时间和会话窗口
accountData.lastUsedAt = new Date().toISOString();
await this.updateSessionWindow(accountId, accountData);
await redis.setClaudeAccount(accountId, accountData);
return accessToken;
@@ -286,11 +287,14 @@ class ClaudeAccountService {
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,
@@ -310,7 +314,16 @@ class ClaudeAccountService {
isRateLimited: rateLimitInfo.isRateLimited,
rateLimitedAt: rateLimitInfo.rateLimitedAt,
minutesRemaining: rateLimitInfo.minutesRemaining
} : null
} : null,
// 添加会话窗口信息
sessionWindow: sessionWindowInfo || {
hasActiveWindow: false,
windowStart: null,
windowEnd: null,
progress: 0,
remainingTime: null,
lastRequestTime: null
}
};
}));
@@ -817,6 +830,210 @@ class ClaudeAccountService {
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();