From 718733b78b80d27eccae5534bb36625edd5c5352 Mon Sep 17 00:00:00 2001 From: KevinLiao Date: Mon, 28 Jul 2025 15:51:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A6=E5=8F=B7ses?= =?UTF-8?q?sion=E7=AA=97=E5=8F=A3=E7=AE=A1=E7=90=86=E4=B8=8E=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E3=80=82=E5=90=8E=E7=BB=AD=E5=8F=AF=E4=BB=A5=E6=8D=AE?= =?UTF-8?q?=E6=AD=A4=E4=BC=98=E5=8C=96=E8=B4=A6=E5=8F=B7=E8=BD=AE=E8=BD=AC?= =?UTF-8?q?=E9=80=BB=E8=BE=91=E3=80=82=20scripts=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=9C=89=E7=9B=B8=E5=85=B3=E7=AE=A1=E7=90=86=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=EF=BC=8C=E8=AF=B7=E8=87=AA=E8=A1=8C=E6=8E=A2=E7=B4=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/analyze-log-sessions.js | 597 +++++++++++++++++++++++++++ scripts/manage-session-windows.js | 532 ++++++++++++++++++++++++ src/app.js | 5 + src/services/claudeAccountService.js | 223 +++++++++- web/admin/app.js | 28 ++ web/admin/index.html | 26 ++ 6 files changed, 1408 insertions(+), 3 deletions(-) create mode 100644 scripts/analyze-log-sessions.js create mode 100644 scripts/manage-session-windows.js diff --git a/scripts/analyze-log-sessions.js b/scripts/analyze-log-sessions.js new file mode 100644 index 00000000..dafcc70a --- /dev/null +++ b/scripts/analyze-log-sessions.js @@ -0,0 +1,597 @@ +#!/usr/bin/env node + +/** + * 从日志文件分析Claude账户请求时间的CLI工具 + * 用于恢复会话窗口数据 + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const zlib = require('zlib'); +const redis = require('../src/models/redis'); +const claudeAccountService = require('../src/services/claudeAccountService'); +const logger = require('../src/utils/logger'); + +class LogSessionAnalyzer { + constructor() { + // 更新正则表达式以匹配实际的日志格式 + this.accountUsagePattern = /🎯 Using sticky session shared account: (.+?) \(([a-f0-9-]{36})\) for session ([a-f0-9]+)/; + this.processingPattern = /📡 Processing streaming API request with usage capture for key: (.+?), account: ([a-f0-9-]{36}), session: ([a-f0-9]+)/; + this.completedPattern = /🔗 ✅ Request completed in (\d+)ms for key: (.+)/; + this.usageRecordedPattern = /🔗 📊 Stream usage recorded \(real\) - Model: (.+?), Input: (\d+), Output: (\d+), Cache Create: (\d+), Cache Read: (\d+), Total: (\d+) tokens/; + this.timestampPattern = /\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/; + this.accounts = new Map(); + this.requestHistory = []; + this.sessions = new Map(); // 记录会话信息 + } + + // 解析时间戳 + parseTimestamp(line) { + const match = line.match(this.timestampPattern); + if (match) { + return new Date(match[1]); + } + return null; + } + + // 分析单个日志文件 + async analyzeLogFile(filePath) { + console.log(`📖 分析日志文件: ${filePath}`); + + let fileStream = fs.createReadStream(filePath); + + // 如果是gz文件,需要先解压 + if (filePath.endsWith('.gz')) { + console.log(` 🗜️ 检测到gz压缩文件,正在解压...`); + fileStream = fileStream.pipe(zlib.createGunzip()); + } + + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity + }); + + let lineCount = 0; + let requestCount = 0; + let usageCount = 0; + + for await (const line of rl) { + lineCount++; + + // 解析时间戳 + const timestamp = this.parseTimestamp(line); + if (!timestamp) continue; + + // 查找账户使用记录 + const accountUsageMatch = line.match(this.accountUsagePattern); + if (accountUsageMatch) { + const accountName = accountUsageMatch[1]; + const accountId = accountUsageMatch[2]; + const sessionId = accountUsageMatch[3]; + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName, + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }); + } + + const account = this.accounts.get(accountId); + account.sessions.add(sessionId); + + if (timestamp < account.firstRequest) { + account.firstRequest = timestamp; + } + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp; + } + } + + // 查找请求处理记录 + const processingMatch = line.match(this.processingPattern); + if (processingMatch) { + const apiKeyName = processingMatch[1]; + const accountId = processingMatch[2]; + const sessionId = processingMatch[3]; + + if (!this.accounts.has(accountId)) { + this.accounts.set(accountId, { + accountId, + accountName: 'Unknown', + requests: [], + firstRequest: timestamp, + lastRequest: timestamp, + totalRequests: 0, + sessions: new Set() + }); + } + + const account = this.accounts.get(accountId); + account.requests.push({ + timestamp, + apiKeyName, + sessionId, + type: 'processing' + }); + + account.sessions.add(sessionId); + account.totalRequests++; + requestCount++; + + if (timestamp > account.lastRequest) { + account.lastRequest = timestamp; + } + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + accountId, + apiKeyName, + sessionId, + type: 'processing' + }); + } + + // 查找请求完成记录 + const completedMatch = line.match(this.completedPattern); + if (completedMatch) { + const duration = parseInt(completedMatch[1]); + const apiKeyName = completedMatch[2]; + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + apiKeyName, + duration, + type: 'completed' + }); + } + + // 查找使用统计记录 + const usageMatch = line.match(this.usageRecordedPattern); + if (usageMatch) { + const model = usageMatch[1]; + const inputTokens = parseInt(usageMatch[2]); + const outputTokens = parseInt(usageMatch[3]); + const cacheCreateTokens = parseInt(usageMatch[4]); + const cacheReadTokens = parseInt(usageMatch[5]); + const totalTokens = parseInt(usageMatch[6]); + + usageCount++; + + // 记录到全局请求历史 + this.requestHistory.push({ + timestamp, + type: 'usage', + model, + inputTokens, + outputTokens, + cacheCreateTokens, + cacheReadTokens, + totalTokens + }); + } + } + + console.log(` 📊 解析完成: ${lineCount} 行, 找到 ${requestCount} 个请求记录, ${usageCount} 个使用统计`); + } + + // 分析日志目录中的所有文件 + async analyzeLogDirectory(logDir = './logs') { + console.log(`🔍 扫描日志目录: ${logDir}\n`); + + try { + const files = fs.readdirSync(logDir); + const logFiles = files + .filter(file => { + return file.includes('claude-relay') && ( + file.endsWith('.log') || + file.endsWith('.log.1') || + file.endsWith('.log.gz') || + file.match(/\.log\.\d+\.gz$/) || + file.match(/\.log\.\d+$/) + ); + }) + .sort() + .reverse(); // 最新的文件优先 + + if (logFiles.length === 0) { + console.log('❌ 没有找到日志文件'); + return; + } + + console.log(`📁 找到 ${logFiles.length} 个日志文件:`); + logFiles.forEach(file => console.log(` - ${file}`)); + console.log(''); + + // 分析每个文件 + for (const file of logFiles) { + const filePath = path.join(logDir, file); + await this.analyzeLogFile(filePath); + } + + } catch (error) { + console.error(`❌ 读取日志目录失败: ${error.message}`); + throw error; + } + } + + // 分析单个日志文件(支持直接传入文件路径) + async analyzeSingleFile(filePath) { + console.log(`🔍 分析单个日志文件: ${filePath}\n`); + + try { + if (!fs.existsSync(filePath)) { + console.log('❌ 文件不存在'); + return; + } + + await this.analyzeLogFile(filePath); + + } catch (error) { + console.error(`❌ 分析文件失败: ${error.message}`); + throw error; + } + } + + // 计算会话窗口 + calculateSessionWindow(requestTime) { + const hour = requestTime.getHours(); + const windowStartHour = Math.floor(hour / 5) * 5; + + const windowStart = new Date(requestTime); + windowStart.setHours(windowStartHour, 0, 0, 0); + + const windowEnd = new Date(windowStart); + windowEnd.setHours(windowEnd.getHours() + 5); + + return { windowStart, windowEnd }; + } + + // 分析会话窗口 + analyzeSessionWindows() { + console.log('🕐 分析会话窗口...\n'); + + const now = new Date(); + const results = []; + + for (const [accountId, accountData] of this.accounts) { + const sessions = []; + const requests = accountData.requests.sort((a, b) => a.timestamp - b.timestamp); + + // 按会话窗口分组请求 + const windowGroups = new Map(); + + for (const request of requests) { + const { windowStart, windowEnd } = this.calculateSessionWindow(request.timestamp); + const windowKey = `${windowStart.getTime()}-${windowEnd.getTime()}`; + + if (!windowGroups.has(windowKey)) { + windowGroups.set(windowKey, { + windowStart, + windowEnd, + requests: [], + isActive: now >= windowStart && now < windowEnd + }); + } + + windowGroups.get(windowKey).requests.push(request); + } + + // 转换为数组并排序 + const windowArray = Array.from(windowGroups.values()) + .sort((a, b) => b.windowStart - a.windowStart); // 最新的窗口优先 + + const result = { + accountId, + accountName: accountData.accountName, + totalRequests: accountData.totalRequests, + firstRequest: accountData.firstRequest, + lastRequest: accountData.lastRequest, + sessions: accountData.sessions, + windows: windowArray, + currentActiveWindow: windowArray.find(w => w.isActive) || null, + mostRecentWindow: windowArray[0] || null + }; + + results.push(result); + } + + return results.sort((a, b) => b.lastRequest - a.lastRequest); + } + + // 显示分析结果 + displayResults(results) { + console.log('📊 分析结果:\n'); + console.log('='.repeat(80)); + + for (const result of results) { + console.log(`🏢 账户: ${result.accountName || 'Unknown'} (${result.accountId})`); + console.log(` 总请求数: ${result.totalRequests}`); + console.log(` 会话数: ${result.sessions ? result.sessions.size : 0}`); + console.log(` 首次请求: ${result.firstRequest.toLocaleString()}`); + console.log(` 最后请求: ${result.lastRequest.toLocaleString()}`); + + if (result.currentActiveWindow) { + console.log(` ✅ 当前活跃窗口: ${result.currentActiveWindow.windowStart.toLocaleString()} - ${result.currentActiveWindow.windowEnd.toLocaleString()}`); + console.log(` 窗口内请求: ${result.currentActiveWindow.requests.length} 次`); + const progress = this.calculateWindowProgress(result.currentActiveWindow.windowStart, result.currentActiveWindow.windowEnd); + console.log(` 窗口进度: ${progress}%`); + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow; + console.log(` ⏰ 最近窗口(已过期): ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`); + console.log(` 窗口内请求: ${window.requests.length} 次`); + const hoursAgo = Math.round((new Date() - window.windowEnd) / (1000 * 60 * 60)); + console.log(` 过期时间: ${hoursAgo} 小时前`); + } else { + console.log(` ❌ 无会话窗口数据`); + } + + // 显示最近几个窗口 + if (result.windows.length > 1) { + console.log(` 📈 历史窗口: ${result.windows.length} 个`); + const recentWindows = result.windows.slice(0, 3); + for (let i = 0; i < recentWindows.length; i++) { + const window = recentWindows[i]; + const status = window.isActive ? '活跃' : '已过期'; + console.log(` ${i + 1}. ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()} (${status}, ${window.requests.length}次请求)`); + } + } + + // 显示最近几个会话的API Key使用情况 + const accountData = this.accounts.get(result.accountId); + if (accountData && accountData.requests && accountData.requests.length > 0) { + const recentRequests = accountData.requests.slice(-5); // 最近5个请求 + const apiKeyStats = {}; + + for (const req of accountData.requests) { + if (!apiKeyStats[req.apiKeyName]) { + apiKeyStats[req.apiKeyName] = 0; + } + apiKeyStats[req.apiKeyName]++; + } + + console.log(` 🔑 API Key使用统计:`); + for (const [keyName, count] of Object.entries(apiKeyStats)) { + console.log(` - ${keyName}: ${count} 次`); + } + } + + console.log(''); + } + + console.log('='.repeat(80)); + console.log(`总计: ${results.length} 个账户, ${this.requestHistory.length} 个日志记录\n`); + } + + // 计算窗口进度百分比 + calculateWindowProgress(windowStart, windowEnd) { + const now = new Date(); + const totalDuration = windowEnd.getTime() - windowStart.getTime(); + const elapsedTime = now.getTime() - windowStart.getTime(); + return Math.max(0, Math.min(100, Math.round((elapsedTime / totalDuration) * 100))); + } + + // 更新Redis中的会话窗口数据 + async updateRedisSessionWindows(results, dryRun = true) { + if (dryRun) { + console.log('🧪 模拟模式 - 不会实际更新Redis数据\n'); + } else { + console.log('💾 更新Redis中的会话窗口数据...\n'); + await redis.connect(); + } + + let updatedCount = 0; + let skippedCount = 0; + + for (const result of results) { + try { + const accountData = await redis.getClaudeAccount(result.accountId); + + if (!accountData || Object.keys(accountData).length === 0) { + console.log(`⚠️ 账户 ${result.accountId} 在Redis中不存在,跳过`); + skippedCount++; + continue; + } + + console.log(`🔄 处理账户: ${accountData.name || result.accountId}`); + + // 确定要设置的会话窗口 + let targetWindow = null; + + if (result.currentActiveWindow) { + targetWindow = result.currentActiveWindow; + console.log(` ✅ 使用当前活跃窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`); + } else if (result.mostRecentWindow) { + const window = result.mostRecentWindow; + const now = new Date(); + + // 如果最近窗口是在过去24小时内的,可以考虑恢复 + const hoursSinceWindow = (now - window.windowEnd) / (1000 * 60 * 60); + + if (hoursSinceWindow <= 24) { + console.log(` 🕐 最近窗口在24小时内,但已过期: ${window.windowStart.toLocaleString()} - ${window.windowEnd.toLocaleString()}`); + console.log(` ❌ 不恢复已过期窗口(${hoursSinceWindow.toFixed(1)}小时前过期)`); + } else { + console.log(` ⏰ 最近窗口超过24小时前,不予恢复`); + } + } + + if (targetWindow && !dryRun) { + // 更新Redis中的会话窗口数据 + accountData.sessionWindowStart = targetWindow.windowStart.toISOString(); + accountData.sessionWindowEnd = targetWindow.windowEnd.toISOString(); + accountData.lastUsedAt = result.lastRequest.toISOString(); + accountData.lastRequestTime = result.lastRequest.toISOString(); + + await redis.setClaudeAccount(result.accountId, accountData); + updatedCount++; + + console.log(` ✅ 已更新会话窗口数据`); + } else if (targetWindow) { + updatedCount++; + console.log(` 🧪 [模拟] 将设置会话窗口: ${targetWindow.windowStart.toLocaleString()} - ${targetWindow.windowEnd.toLocaleString()}`); + } else { + skippedCount++; + console.log(` ⏭️ 跳过(无有效窗口)`); + } + + console.log(''); + + } catch (error) { + console.error(`❌ 处理账户 ${result.accountId} 时出错: ${error.message}`); + skippedCount++; + } + } + + if (!dryRun) { + await redis.disconnect(); + } + + console.log('📊 更新结果:'); + console.log(` ✅ 已更新: ${updatedCount}`); + console.log(` ⏭️ 已跳过: ${skippedCount}`); + console.log(` 📋 总计: ${results.length}`); + } + + // 主分析函数 + async analyze(options = {}) { + const { + logDir = './logs', + singleFile = null, + updateRedis = false, + dryRun = true + } = options; + + try { + console.log('🔍 Claude账户会话窗口分析工具\n'); + + // 分析日志文件 + if (singleFile) { + await this.analyzeSingleFile(singleFile); + } else { + await this.analyzeLogDirectory(logDir); + } + + if (this.accounts.size === 0) { + console.log('❌ 没有找到任何Claude账户的请求记录'); + return; + } + + // 分析会话窗口 + const results = this.analyzeSessionWindows(); + + // 显示结果 + this.displayResults(results); + + // 更新Redis(如果需要) + if (updateRedis) { + await this.updateRedisSessionWindows(results, dryRun); + } + + return results; + + } catch (error) { + console.error('❌ 分析失败:', error); + throw error; + } + } +} + +// 命令行参数解析 +function parseArgs() { + const args = process.argv.slice(2); + const options = { + logDir: './logs', + singleFile: null, + updateRedis: false, + dryRun: true + }; + + for (const arg of args) { + if (arg.startsWith('--log-dir=')) { + options.logDir = arg.split('=')[1]; + } else if (arg.startsWith('--file=')) { + options.singleFile = arg.split('=')[1]; + } else if (arg === '--update-redis') { + options.updateRedis = true; + } else if (arg === '--no-dry-run') { + options.dryRun = false; + } else if (arg === '--help' || arg === '-h') { + showHelp(); + process.exit(0); + } + } + + return options; +} + +// 显示帮助信息 +function showHelp() { + console.log(` +Claude账户会话窗口日志分析工具 + +从日志文件中分析Claude账户的请求时间,计算会话窗口,并可选择性地更新Redis数据。 + +用法: + node scripts/analyze-log-sessions.js [选项] + +选项: + --log-dir=PATH 日志文件目录 (默认: ./logs) + --file=PATH 分析单个日志文件 + --update-redis 更新Redis中的会话窗口数据 + --no-dry-run 实际执行Redis更新(默认为模拟模式) + --help, -h 显示此帮助信息 + +示例: + # 分析默认日志目录 + node scripts/analyze-log-sessions.js + + # 分析指定目录的日志 + node scripts/analyze-log-sessions.js --log-dir=/path/to/logs + + # 分析单个日志文件 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log + + # 模拟更新Redis数据(不实际更新) + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis + + # 实际更新Redis数据 + node scripts/analyze-log-sessions.js --file=/path/to/logfile.log --update-redis --no-dry-run + +会话窗口规则: + - Claude官方规定每5小时为一个会话窗口 + - 窗口按整点对齐(如 05:00-10:00, 10:00-15:00) + - 只有当前时间在窗口内的才被认为是活跃窗口 + - 工具会自动识别并恢复活跃的会话窗口 +`); +} + +// 主函数 +async function main() { + try { + const options = parseArgs(); + + const analyzer = new LogSessionAnalyzer(); + await analyzer.analyze(options); + + console.log('🎉 分析完成'); + + } catch (error) { + console.error('💥 程序执行失败:', error); + process.exit(1); + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main(); +} + +module.exports = LogSessionAnalyzer; \ No newline at end of file diff --git a/scripts/manage-session-windows.js b/scripts/manage-session-windows.js new file mode 100644 index 00000000..9bed5714 --- /dev/null +++ b/scripts/manage-session-windows.js @@ -0,0 +1,532 @@ +#!/usr/bin/env node + +/** + * 会话窗口管理脚本 + * 用于调试、恢复和管理Claude账户的会话窗口 + */ + +const redis = require('../src/models/redis'); +const claudeAccountService = require('../src/services/claudeAccountService'); +const logger = require('../src/utils/logger'); +const readline = require('readline'); + +// 创建readline接口 +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout +}); + +// 辅助函数:询问用户输入 +function askQuestion(question) { + return new Promise((resolve) => { + rl.question(question, resolve); + }); +} + +// 辅助函数:解析时间输入 +function parseTimeInput(input) { + const now = new Date(); + + // 如果是 HH:MM 格式 + const timeMatch = input.match(/^(\d{1,2}):(\d{2})$/); + if (timeMatch) { + const hour = parseInt(timeMatch[1]); + const minute = parseInt(timeMatch[2]); + + if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { + const time = new Date(now); + time.setHours(hour, minute, 0, 0); + return time; + } + } + + // 如果是相对时间(如 "2小时前") + const relativeMatch = input.match(/^(\d+)(小时|分钟)前$/); + if (relativeMatch) { + const amount = parseInt(relativeMatch[1]); + const unit = relativeMatch[2]; + const time = new Date(now); + + if (unit === '小时') { + time.setHours(time.getHours() - amount); + } else if (unit === '分钟') { + time.setMinutes(time.getMinutes() - amount); + } + + return time; + } + + // 如果是 ISO 格式或其他日期格式 + const parsedDate = new Date(input); + if (!isNaN(parsedDate.getTime())) { + return parsedDate; + } + + return null; +} + +// 辅助函数:显示可用的时间窗口选项 +function showTimeWindowOptions() { + const now = new Date(); + console.log('\n⏰ 可用的5小时时间窗口:'); + + for (let hour = 0; hour < 24; hour += 5) { + const start = hour; + const end = hour + 5; + const startStr = `${String(start).padStart(2, '0')}:00`; + const endStr = `${String(end).padStart(2, '0')}:00`; + + const currentHour = now.getHours(); + const isActive = currentHour >= start && currentHour < end; + const status = isActive ? ' 🟢 (当前活跃)' : ''; + + console.log(` ${start/5 + 1}. ${startStr} - ${endStr}${status}`); + } + console.log(''); +} + +const commands = { + // 调试所有账户的会话窗口状态 + async debug() { + console.log('🔍 开始调试会话窗口状态...\n'); + + const accounts = await redis.getAllClaudeAccounts(); + console.log(`📊 找到 ${accounts.length} 个Claude账户\n`); + + const stats = { + total: accounts.length, + hasWindow: 0, + hasLastUsed: 0, + canRecover: 0, + expired: 0 + }; + + for (const account of accounts) { + console.log(`🏢 ${account.name} (${account.id})`); + console.log(` 状态: ${account.isActive === 'true' ? '✅ 活跃' : '❌ 禁用'}`); + + if (account.sessionWindowStart && account.sessionWindowEnd) { + stats.hasWindow++; + const windowStart = new Date(account.sessionWindowStart); + const windowEnd = new Date(account.sessionWindowEnd); + const now = new Date(); + + console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + console.log(` 窗口状态: ${now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`); + } else { + console.log(` 会话窗口: ❌ 无`); + } + + if (account.lastUsedAt) { + stats.hasLastUsed++; + const lastUsed = new Date(account.lastUsedAt); + const now = new Date(); + const minutesAgo = Math.round((now - lastUsed) / (1000 * 60)); + + console.log(` 最后使用: ${lastUsed.toLocaleString()} (${minutesAgo}分钟前)`); + + // 计算基于lastUsedAt的窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsed); + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); + + if (now < windowEnd) { + stats.canRecover++; + console.log(` 可恢复窗口: ✅ ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + } else { + stats.expired++; + console.log(` 可恢复窗口: ❌ 已过期 (${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()})`); + } + } else { + console.log(` 最后使用: ❌ 无记录`); + } + + console.log(''); + } + + console.log('📈 汇总统计:'); + console.log(` 总账户数: ${stats.total}`); + console.log(` 有会话窗口: ${stats.hasWindow}`); + console.log(` 有使用记录: ${stats.hasLastUsed}`); + console.log(` 可以恢复: ${stats.canRecover}`); + console.log(` 窗口已过期: ${stats.expired}`); + }, + + // 初始化会话窗口(默认行为) + async init() { + console.log('🔄 初始化会话窗口...\n'); + const result = await claudeAccountService.initializeSessionWindows(); + + console.log('\n📊 初始化结果:'); + console.log(` 总账户数: ${result.total}`); + console.log(` 成功初始化: ${result.initialized}`); + console.log(` 已跳过(已有窗口): ${result.skipped}`); + console.log(` 窗口已过期: ${result.expired}`); + console.log(` 无使用数据: ${result.noData}`); + + if (result.error) { + console.log(` 错误: ${result.error}`); + } + }, + + // 强制重新计算所有会话窗口 + async force() { + console.log('🔄 强制重新计算所有会话窗口...\n'); + const result = await claudeAccountService.initializeSessionWindows(true); + + console.log('\n📊 强制重算结果:'); + console.log(` 总账户数: ${result.total}`); + console.log(` 成功初始化: ${result.initialized}`); + console.log(` 窗口已过期: ${result.expired}`); + console.log(` 无使用数据: ${result.noData}`); + + if (result.error) { + console.log(` 错误: ${result.error}`); + } + }, + + // 清除所有会话窗口 + async clear() { + console.log('🗑️ 清除所有会话窗口...\n'); + + const accounts = await redis.getAllClaudeAccounts(); + let clearedCount = 0; + + for (const account of accounts) { + if (account.sessionWindowStart || account.sessionWindowEnd) { + delete account.sessionWindowStart; + delete account.sessionWindowEnd; + delete account.lastRequestTime; + + await redis.setClaudeAccount(account.id, account); + clearedCount++; + + console.log(`✅ 清除账户 ${account.name} (${account.id}) 的会话窗口`); + } + } + + console.log(`\n📊 清除完成: 共清除 ${clearedCount} 个账户的会话窗口`); + }, + + // 创建测试会话窗口(将lastUsedAt设置为当前时间) + async test() { + console.log('🧪 创建测试会话窗口...\n'); + + const accounts = await redis.getAllClaudeAccounts(); + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户'); + return; + } + + const now = new Date(); + let updatedCount = 0; + + for (const account of accounts) { + if (account.isActive === 'true') { + // 设置为当前时间(模拟刚刚使用) + account.lastUsedAt = now.toISOString(); + + // 计算新的会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(now); + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); + + account.sessionWindowStart = windowStart.toISOString(); + account.sessionWindowEnd = windowEnd.toISOString(); + account.lastRequestTime = now.toISOString(); + + await redis.setClaudeAccount(account.id, account); + updatedCount++; + + console.log(`✅ 为账户 ${account.name} 创建测试会话窗口:`); + console.log(` 窗口时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + console.log(` 最后使用: ${now.toLocaleString()}\n`); + } + } + + console.log(`📊 测试完成: 为 ${updatedCount} 个活跃账户创建了测试会话窗口`); + }, + + // 手动设置账户的会话窗口 + async set() { + console.log('🔧 手动设置会话窗口...\n'); + + // 获取所有账户 + const accounts = await redis.getAllClaudeAccounts(); + if (accounts.length === 0) { + console.log('❌ 没有找到Claude账户'); + return; + } + + // 显示账户列表 + console.log('📋 可用的Claude账户:'); + accounts.forEach((account, index) => { + const status = account.isActive === 'true' ? '✅' : '❌'; + const hasWindow = account.sessionWindowStart ? '🕐' : '➖'; + console.log(` ${index + 1}. ${status} ${hasWindow} ${account.name} (${account.id})`); + }); + + // 让用户选择账户 + const accountIndex = await askQuestion('\n请选择账户 (输入编号): '); + const selectedIndex = parseInt(accountIndex) - 1; + + if (selectedIndex < 0 || selectedIndex >= accounts.length) { + console.log('❌ 无效的账户编号'); + return; + } + + const selectedAccount = accounts[selectedIndex]; + console.log(`\n🎯 已选择账户: ${selectedAccount.name}`); + + // 显示当前会话窗口状态 + if (selectedAccount.sessionWindowStart && selectedAccount.sessionWindowEnd) { + const windowStart = new Date(selectedAccount.sessionWindowStart); + const windowEnd = new Date(selectedAccount.sessionWindowEnd); + const now = new Date(); + const isActive = now >= windowStart && now < windowEnd; + + console.log(`📊 当前会话窗口:`); + console.log(` 时间: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + console.log(` 状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`); + } else { + console.log(`📊 当前会话窗口: ❌ 无`); + } + + // 显示设置选项 + console.log('\n🛠️ 设置选项:'); + console.log(' 1. 使用预设时间窗口'); + console.log(' 2. 自定义最后使用时间'); + console.log(' 3. 直接设置窗口时间'); + console.log(' 4. 清除会话窗口'); + + const option = await askQuestion('\n请选择设置方式 (1-4): '); + + switch (option) { + case '1': + await setPresetWindow(selectedAccount); + break; + case '2': + await setCustomLastUsed(selectedAccount); + break; + case '3': + await setDirectWindow(selectedAccount); + break; + case '4': + await clearAccountWindow(selectedAccount); + break; + default: + console.log('❌ 无效的选项'); + return; + } + }, + + // 显示帮助信息 + help() { + console.log('🔧 会话窗口管理脚本\n'); + console.log('用法: node scripts/manage-session-windows.js \n'); + console.log('命令:'); + console.log(' debug - 调试所有账户的会话窗口状态'); + console.log(' init - 初始化会话窗口(跳过已有窗口的账户)'); + console.log(' force - 强制重新计算所有会话窗口'); + console.log(' test - 创建测试会话窗口(设置当前时间为使用时间)'); + console.log(' set - 手动设置特定账户的会话窗口 🆕'); + console.log(' clear - 清除所有会话窗口'); + console.log(' help - 显示此帮助信息\n'); + console.log('示例:'); + console.log(' node scripts/manage-session-windows.js debug'); + console.log(' node scripts/manage-session-windows.js set'); + console.log(' node scripts/manage-session-windows.js test'); + console.log(' node scripts/manage-session-windows.js force'); + } +}; + +// 设置函数实现 + +// 使用预设时间窗口 +async function setPresetWindow(account) { + showTimeWindowOptions(); + + const windowChoice = await askQuestion('请选择时间窗口 (1-5): '); + const windowIndex = parseInt(windowChoice) - 1; + + if (windowIndex < 0 || windowIndex >= 5) { + console.log('❌ 无效的窗口选择'); + return; + } + + const now = new Date(); + const startHour = windowIndex * 5; + + // 创建窗口开始时间 + const windowStart = new Date(now); + windowStart.setHours(startHour, 0, 0, 0); + + // 创建窗口结束时间 + const windowEnd = new Date(windowStart); + windowEnd.setHours(windowEnd.getHours() + 5); + + // 如果选择的窗口已经过期,则设置为明天的同一时间段 + if (windowEnd <= now) { + windowStart.setDate(windowStart.getDate() + 1); + windowEnd.setDate(windowEnd.getDate() + 1); + } + + // 询问是否要设置为当前时间作为最后使用时间 + const setLastUsed = await askQuestion('是否设置当前时间为最后使用时间? (y/N): '); + + // 更新账户数据 + account.sessionWindowStart = windowStart.toISOString(); + account.sessionWindowEnd = windowEnd.toISOString(); + account.lastRequestTime = now.toISOString(); + + if (setLastUsed.toLowerCase() === 'y' || setLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = now.toISOString(); + } + + await redis.setClaudeAccount(account.id, account); + + console.log(`\n✅ 已设置会话窗口:`); + console.log(` 账户: ${account.name}`); + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '⏰ 未来窗口'}`); +} + +// 自定义最后使用时间 +async function setCustomLastUsed(account) { + console.log('\n📝 设置最后使用时间:'); + console.log('支持的时间格式:'); + console.log(' - HH:MM (如: 14:30)'); + console.log(' - 相对时间 (如: 2小时前, 30分钟前)'); + console.log(' - ISO格式 (如: 2025-07-28T14:30:00)'); + + const timeInput = await askQuestion('\n请输入最后使用时间: '); + const lastUsedTime = parseTimeInput(timeInput); + + if (!lastUsedTime) { + console.log('❌ 无效的时间格式'); + return; + } + + // 基于最后使用时间计算会话窗口 + const windowStart = claudeAccountService._calculateSessionWindowStart(lastUsedTime); + const windowEnd = claudeAccountService._calculateSessionWindowEnd(windowStart); + + // 更新账户数据 + account.lastUsedAt = lastUsedTime.toISOString(); + account.sessionWindowStart = windowStart.toISOString(); + account.sessionWindowEnd = windowEnd.toISOString(); + account.lastRequestTime = lastUsedTime.toISOString(); + + await redis.setClaudeAccount(account.id, account); + + console.log(`\n✅ 已设置会话窗口:`); + console.log(` 账户: ${account.name}`); + console.log(` 最后使用: ${lastUsedTime.toLocaleString()}`); + console.log(` 窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`); + + const now = new Date(); + console.log(` 状态: ${now >= windowStart && now < windowEnd ? '✅ 活跃' : '❌ 已过期'}`); +} + +// 直接设置窗口时间 +async function setDirectWindow(account) { + console.log('\n⏰ 直接设置窗口时间:'); + + const startInput = await askQuestion('请输入窗口开始时间 (HH:MM): '); + const startTime = parseTimeInput(startInput); + + if (!startTime) { + console.log('❌ 无效的开始时间格式'); + return; + } + + // 自动计算结束时间(开始时间+5小时) + const endTime = new Date(startTime); + endTime.setHours(endTime.getHours() + 5); + + // 如果跨天,询问是否确认 + if (endTime.getDate() !== startTime.getDate()) { + const confirm = await askQuestion(`窗口将跨天到次日 ${endTime.getHours()}:00,确认吗? (y/N): `); + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消设置'); + return; + } + } + + const now = new Date(); + + // 更新账户数据 + account.sessionWindowStart = startTime.toISOString(); + account.sessionWindowEnd = endTime.toISOString(); + account.lastRequestTime = now.toISOString(); + + // 询问是否更新最后使用时间 + const updateLastUsed = await askQuestion('是否将最后使用时间设置为窗口开始时间? (y/N): '); + if (updateLastUsed.toLowerCase() === 'y' || updateLastUsed.toLowerCase() === 'yes') { + account.lastUsedAt = startTime.toISOString(); + } + + await redis.setClaudeAccount(account.id, account); + + console.log(`\n✅ 已设置会话窗口:`); + console.log(` 账户: ${account.name}`); + console.log(` 窗口: ${startTime.toLocaleString()} - ${endTime.toLocaleString()}`); + console.log(` 状态: ${now >= startTime && now < endTime ? '✅ 活跃' : (now < startTime ? '⏰ 未来窗口' : '❌ 已过期')}`); +} + +// 清除账户会话窗口 +async function clearAccountWindow(account) { + const confirm = await askQuestion(`确认清除账户 "${account.name}" 的会话窗口吗? (y/N): `); + + if (confirm.toLowerCase() !== 'y' && confirm.toLowerCase() !== 'yes') { + console.log('❌ 已取消操作'); + return; + } + + // 清除会话窗口相关数据 + delete account.sessionWindowStart; + delete account.sessionWindowEnd; + delete account.lastRequestTime; + + await redis.setClaudeAccount(account.id, account); + + console.log(`\n✅ 已清除账户 "${account.name}" 的会话窗口`); +} + +async function main() { + const command = process.argv[2] || 'help'; + + if (!commands[command]) { + console.error(`❌ 未知命令: ${command}`); + commands.help(); + process.exit(1); + } + + if (command === 'help') { + commands.help(); + return; + } + + try { + // 连接Redis + await redis.connect(); + + // 执行命令 + await commands[command](); + + } catch (error) { + console.error('❌ 执行失败:', error); + process.exit(1); + } finally { + await redis.disconnect(); + rl.close(); + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main().then(() => { + console.log('\n🎉 操作完成'); + process.exit(0); + }); +} + +module.exports = { commands }; \ No newline at end of file diff --git a/src/app.js b/src/app.js index c26665ce..973b1e3d 100644 --- a/src/app.js +++ b/src/app.js @@ -62,6 +62,11 @@ class Application { logger.info(`💰 Cost initialization completed: ${result.processed} processed, ${result.errors} errors`); } + // 🕐 初始化Claude账户会话窗口 + logger.info('🕐 Initializing Claude account session windows...'); + const claudeAccountService = require('./services/claudeAccountService'); + await claudeAccountService.initializeSessionWindows(); + // 🛡️ 安全中间件 this.app.use(helmet({ contentSecurityPolicy: false, // 允许内联样式和脚本 diff --git a/src/services/claudeAccountService.js b/src/services/claudeAccountService.js index c93ed79d..247236be 100644 --- a/src/services/claudeAccountService.js +++ b/src/services/claudeAccountService.js @@ -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(); \ No newline at end of file diff --git a/web/admin/app.js b/web/admin/app.js index ff580da9..d1493215 100644 --- a/web/admin/app.js +++ b/web/admin/app.js @@ -2538,6 +2538,34 @@ const app = createApp({ return number.toLocaleString(); }, + // 格式化会话窗口时间 + formatSessionWindow(windowStart, windowEnd) { + if (!windowStart || !windowEnd) return '--'; + + const start = new Date(windowStart); + const end = new Date(windowEnd); + + const startHour = start.getHours().toString().padStart(2, '0'); + const startMin = start.getMinutes().toString().padStart(2, '0'); + const endHour = end.getHours().toString().padStart(2, '0'); + const endMin = end.getMinutes().toString().padStart(2, '0'); + + return `${startHour}:${startMin} - ${endHour}:${endMin}`; + }, + + // 格式化剩余时间 + formatRemainingTime(minutes) { + if (!minutes || minutes <= 0) return '已结束'; + + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + + if (hours > 0) { + return `${hours}小时${mins}分钟`; + } + return `${mins}分钟`; + }, + // 格式化运行时间 formatUptime(seconds) { if (!seconds) return '0s'; diff --git a/web/admin/index.html b/web/admin/index.html index 7a4a6cd4..44327661 100644 --- a/web/admin/index.html +++ b/web/admin/index.html @@ -1019,6 +1019,7 @@ 代理 今日使用 + 会话窗口 最后使用 操作 @@ -1107,6 +1108,31 @@
暂无数据
+ +
+
+
+
+
+ + {{ account.sessionWindow.progress }}% + +
+
+
{{ formatSessionWindow(account.sessionWindow.windowStart, account.sessionWindow.windowEnd) }}
+
+ 剩余 {{ formatRemainingTime(account.sessionWindow.remainingTime) }} +
+
+
+
+ +
+
+ N/A +
+ {{ account.lastUsedAt ? new Date(account.lastUsedAt).toLocaleDateString() : '从未使用' }}