chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,606 @@
|
||||
#!/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')
|
||||
|
||||
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) =>
|
||||
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 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 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
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 检查 Redis 中的所有键
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
|
||||
async function checkRedisKeys() {
|
||||
console.log('🔍 检查 Redis 中的所有键...\n')
|
||||
|
||||
try {
|
||||
// 确保 Redis 已连接
|
||||
await redis.connect()
|
||||
|
||||
// 获取所有键
|
||||
const allKeys = await redis.client.keys('*')
|
||||
console.log(`找到 ${allKeys.length} 个键\n`)
|
||||
|
||||
// 按类型分组
|
||||
const keysByType = {}
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const prefix = key.split(':')[0]
|
||||
if (!keysByType[prefix]) {
|
||||
keysByType[prefix] = []
|
||||
}
|
||||
keysByType[prefix].push(key)
|
||||
})
|
||||
|
||||
// 显示各类型的键
|
||||
Object.keys(keysByType)
|
||||
.sort()
|
||||
.forEach((type) => {
|
||||
console.log(`\n📁 ${type}: ${keysByType[type].length} 个`)
|
||||
|
||||
// 显示前 5 个键作为示例
|
||||
const keysToShow = keysByType[type].slice(0, 5)
|
||||
keysToShow.forEach((key) => {
|
||||
console.log(` - ${key}`)
|
||||
})
|
||||
|
||||
if (keysByType[type].length > 5) {
|
||||
console.log(` ... 还有 ${keysByType[type].length - 5} 个`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('❌ 错误:', error)
|
||||
console.error(error.stack)
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
checkRedisKeys()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,738 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 数据导出/导入工具
|
||||
*
|
||||
* 使用方法:
|
||||
* 导出: node scripts/data-transfer.js export --output=backup.json [options]
|
||||
* 导入: node scripts/data-transfer.js import --input=backup.json [options]
|
||||
*
|
||||
* 选项:
|
||||
* --types: 要导出/导入的数据类型(apikeys,accounts,admins,all)
|
||||
* --sanitize: 导出时脱敏敏感数据
|
||||
* --force: 导入时强制覆盖已存在的数据
|
||||
* --skip-conflicts: 导入时跳过冲突的数据
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises
|
||||
const redis = require('../src/models/redis')
|
||||
const logger = require('../src/utils/logger')
|
||||
const readline = require('readline')
|
||||
|
||||
// 解析命令行参数
|
||||
const args = process.argv.slice(2)
|
||||
const command = args[0]
|
||||
const params = {}
|
||||
|
||||
args.slice(1).forEach((arg) => {
|
||||
const [key, value] = arg.split('=')
|
||||
params[key.replace('--', '')] = value || true
|
||||
})
|
||||
|
||||
// 创建 readline 接口
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
async function askConfirmation(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (yes/no): `, (answer) => {
|
||||
resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 数据脱敏函数
|
||||
function sanitizeData(data, type) {
|
||||
const sanitized = { ...data }
|
||||
|
||||
switch (type) {
|
||||
case 'apikey':
|
||||
// 隐藏 API Key 的大部分内容
|
||||
if (sanitized.apiKey) {
|
||||
sanitized.apiKey = `${sanitized.apiKey.substring(0, 10)}...[REDACTED]`
|
||||
}
|
||||
break
|
||||
|
||||
case 'claude_account':
|
||||
case 'gemini_account':
|
||||
// 隐藏 OAuth tokens
|
||||
if (sanitized.accessToken) {
|
||||
sanitized.accessToken = '[REDACTED]'
|
||||
}
|
||||
if (sanitized.refreshToken) {
|
||||
sanitized.refreshToken = '[REDACTED]'
|
||||
}
|
||||
if (sanitized.claudeAiOauth) {
|
||||
sanitized.claudeAiOauth = '[REDACTED]'
|
||||
}
|
||||
// 隐藏代理密码
|
||||
if (sanitized.proxyPassword) {
|
||||
sanitized.proxyPassword = '[REDACTED]'
|
||||
}
|
||||
break
|
||||
|
||||
case 'admin':
|
||||
// 隐藏管理员密码
|
||||
if (sanitized.password) {
|
||||
sanitized.password = '[REDACTED]'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// CSV 字段映射配置
|
||||
const CSV_FIELD_MAPPING = {
|
||||
// 基本信息
|
||||
id: 'ID',
|
||||
name: '名称',
|
||||
description: '描述',
|
||||
isActive: '状态',
|
||||
createdAt: '创建时间',
|
||||
lastUsedAt: '最后使用时间',
|
||||
createdBy: '创建者',
|
||||
|
||||
// API Key 信息
|
||||
apiKey: 'API密钥',
|
||||
tokenLimit: '令牌限制',
|
||||
|
||||
// 过期设置
|
||||
expirationMode: '过期模式',
|
||||
expiresAt: '过期时间',
|
||||
activationDays: '激活天数',
|
||||
activationUnit: '激活单位',
|
||||
isActivated: '已激活',
|
||||
activatedAt: '激活时间',
|
||||
|
||||
// 权限设置
|
||||
permissions: '服务权限',
|
||||
|
||||
// 限制设置
|
||||
rateLimitWindow: '速率窗口(分钟)',
|
||||
rateLimitRequests: '请求次数限制',
|
||||
rateLimitCost: '费用限制(美元)',
|
||||
concurrencyLimit: '并发限制',
|
||||
dailyCostLimit: '日费用限制(美元)',
|
||||
totalCostLimit: '总费用限制(美元)',
|
||||
weeklyOpusCostLimit: '周Claude费用限制(美元)',
|
||||
|
||||
// 账户绑定
|
||||
claudeAccountId: 'Claude专属账户',
|
||||
claudeConsoleAccountId: 'Claude控制台账户',
|
||||
geminiAccountId: 'Gemini专属账户',
|
||||
openaiAccountId: 'OpenAI专属账户',
|
||||
azureOpenaiAccountId: 'Azure OpenAI专属账户',
|
||||
bedrockAccountId: 'Bedrock专属账户',
|
||||
|
||||
// 限制配置
|
||||
enableModelRestriction: '启用模型限制',
|
||||
restrictedModels: '限制的模型',
|
||||
enableClientRestriction: '启用客户端限制',
|
||||
allowedClients: '允许的客户端',
|
||||
|
||||
// 标签和用户
|
||||
tags: '标签',
|
||||
userId: '用户ID',
|
||||
userUsername: '用户名',
|
||||
|
||||
// 其他信息
|
||||
icon: '图标'
|
||||
}
|
||||
|
||||
// 数据格式化函数
|
||||
function formatCSVValue(key, value, shouldSanitize = false) {
|
||||
if (!value || value === '' || value === 'null' || value === 'undefined') {
|
||||
return ''
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'apiKey':
|
||||
if (shouldSanitize && value.length > 10) {
|
||||
return `${value.substring(0, 10)}...[已脱敏]`
|
||||
}
|
||||
return value
|
||||
|
||||
case 'isActive':
|
||||
case 'isActivated':
|
||||
case 'enableModelRestriction':
|
||||
case 'enableClientRestriction':
|
||||
return value === 'true' ? '是' : '否'
|
||||
|
||||
case 'expirationMode':
|
||||
return value === 'activation' ? '首次使用后激活' : value === 'fixed' ? '固定时间' : value
|
||||
|
||||
case 'activationUnit':
|
||||
return value === 'hours' ? '小时' : value === 'days' ? '天' : value
|
||||
|
||||
case 'permissions':
|
||||
switch (value) {
|
||||
case 'all':
|
||||
return '全部服务'
|
||||
case 'claude':
|
||||
return '仅Claude'
|
||||
case 'gemini':
|
||||
return '仅Gemini'
|
||||
case 'openai':
|
||||
return '仅OpenAI'
|
||||
default:
|
||||
return value
|
||||
}
|
||||
|
||||
case 'restrictedModels':
|
||||
case 'allowedClients':
|
||||
case 'tags':
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.join('; ') : value
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
|
||||
case 'createdAt':
|
||||
case 'lastUsedAt':
|
||||
case 'activatedAt':
|
||||
case 'expiresAt':
|
||||
if (value) {
|
||||
try {
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ''
|
||||
|
||||
case 'rateLimitWindow':
|
||||
case 'rateLimitRequests':
|
||||
case 'concurrencyLimit':
|
||||
case 'activationDays':
|
||||
case 'tokenLimit':
|
||||
return value === '0' || value === 0 ? '无限制' : value
|
||||
|
||||
case 'rateLimitCost':
|
||||
case 'dailyCostLimit':
|
||||
case 'totalCostLimit':
|
||||
case 'weeklyOpusCostLimit':
|
||||
return value === '0' || value === 0 ? '无限制' : `$${value}`
|
||||
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// 转义 CSV 字段
|
||||
function escapeCSVField(field) {
|
||||
if (field === null || field === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const str = String(field)
|
||||
|
||||
// 如果包含逗号、引号或换行符,需要用引号包围
|
||||
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
|
||||
// 先转义引号(双引号变成两个双引号)
|
||||
const escaped = str.replace(/"/g, '""')
|
||||
return `"${escaped}"`
|
||||
}
|
||||
|
||||
return str
|
||||
}
|
||||
|
||||
// 转换数据为 CSV 格式
|
||||
function convertToCSV(exportDataObj, shouldSanitize = false) {
|
||||
if (!exportDataObj.data.apiKeys || exportDataObj.data.apiKeys.length === 0) {
|
||||
throw new Error('CSV format only supports API Keys export. Please use --types=apikeys')
|
||||
}
|
||||
|
||||
const { apiKeys } = exportDataObj.data
|
||||
const fields = Object.keys(CSV_FIELD_MAPPING)
|
||||
const headers = Object.values(CSV_FIELD_MAPPING)
|
||||
|
||||
// 生成标题行
|
||||
const csvLines = [headers.map(escapeCSVField).join(',')]
|
||||
|
||||
// 生成数据行
|
||||
for (const apiKey of apiKeys) {
|
||||
const row = fields.map((field) => {
|
||||
const value = formatCSVValue(field, apiKey[field], shouldSanitize)
|
||||
return escapeCSVField(value)
|
||||
})
|
||||
csvLines.push(row.join(','))
|
||||
}
|
||||
|
||||
return csvLines.join('\n')
|
||||
}
|
||||
|
||||
// 导出数据
|
||||
async function exportData() {
|
||||
try {
|
||||
const format = params.format || 'json'
|
||||
const fileExtension = format === 'csv' ? '.csv' : '.json'
|
||||
const defaultFileName = `backup-${new Date().toISOString().split('T')[0]}${fileExtension}`
|
||||
const outputFile = params.output || defaultFileName
|
||||
const types = params.types ? params.types.split(',') : ['all']
|
||||
const shouldSanitize = params.sanitize === true
|
||||
|
||||
// CSV 格式验证
|
||||
if (format === 'csv' && !types.includes('apikeys') && !types.includes('all')) {
|
||||
logger.error('❌ CSV format only supports API Keys export. Please use --types=apikeys')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.info('🔄 Starting data export...')
|
||||
logger.info(`📁 Output file: ${outputFile}`)
|
||||
logger.info(`📋 Data types: ${types.join(', ')}`)
|
||||
logger.info(`📄 Output format: ${format.toUpperCase()}`)
|
||||
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`)
|
||||
|
||||
// 连接 Redis
|
||||
await redis.connect()
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
const exportDataObj = {
|
||||
metadata: {
|
||||
version: '1.0',
|
||||
exportDate: new Date().toISOString(),
|
||||
sanitized: shouldSanitize,
|
||||
types
|
||||
},
|
||||
data: {}
|
||||
}
|
||||
|
||||
// 导出 API Keys
|
||||
if (types.includes('all') || types.includes('apikeys')) {
|
||||
logger.info('📤 Exporting API Keys...')
|
||||
const keys = await redis.client.keys('apikey:*')
|
||||
const apiKeys = []
|
||||
|
||||
for (const key of keys) {
|
||||
if (key === 'apikey:hash_map') {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 hgetall 而不是 get,因为数据存储在哈希表中
|
||||
const data = await redis.client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data)
|
||||
}
|
||||
}
|
||||
|
||||
exportDataObj.data.apiKeys = apiKeys
|
||||
logger.success(`✅ Exported ${apiKeys.length} API Keys`)
|
||||
}
|
||||
|
||||
// 导出 Claude 账户
|
||||
if (types.includes('all') || types.includes('accounts')) {
|
||||
logger.info('📤 Exporting Claude accounts...')
|
||||
// 注意:Claude 账户使用 claude:account: 前缀,不是 claude_account:
|
||||
const keys = await redis.client.keys('claude:account:*')
|
||||
logger.info(`Found ${keys.length} Claude account keys in Redis`)
|
||||
const accounts = []
|
||||
|
||||
for (const key of keys) {
|
||||
// 使用 hgetall 而不是 get,因为数据存储在哈希表中
|
||||
const data = await redis.client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
// 解析 JSON 字段(如果存在)
|
||||
if (data.claudeAiOauth) {
|
||||
try {
|
||||
data.claudeAiOauth = JSON.parse(data.claudeAiOauth)
|
||||
} catch (e) {
|
||||
// 保持原样
|
||||
}
|
||||
}
|
||||
accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data)
|
||||
}
|
||||
}
|
||||
|
||||
exportDataObj.data.claudeAccounts = accounts
|
||||
logger.success(`✅ Exported ${accounts.length} Claude accounts`)
|
||||
|
||||
// 导出 Gemini 账户
|
||||
logger.info('📤 Exporting Gemini accounts...')
|
||||
const geminiKeys = await redis.client.keys('gemini_account:*')
|
||||
logger.info(`Found ${geminiKeys.length} Gemini account keys in Redis`)
|
||||
const geminiAccounts = []
|
||||
|
||||
for (const key of geminiKeys) {
|
||||
// 使用 hgetall 而不是 get,因为数据存储在哈希表中
|
||||
const data = await redis.client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data)
|
||||
}
|
||||
}
|
||||
|
||||
exportDataObj.data.geminiAccounts = geminiAccounts
|
||||
logger.success(`✅ Exported ${geminiAccounts.length} Gemini accounts`)
|
||||
}
|
||||
|
||||
// 导出管理员
|
||||
if (types.includes('all') || types.includes('admins')) {
|
||||
logger.info('📤 Exporting admins...')
|
||||
const keys = await redis.client.keys('admin:*')
|
||||
const admins = []
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.includes('admin_username:')) {
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用 hgetall 而不是 get,因为数据存储在哈希表中
|
||||
const data = await redis.client.hgetall(key)
|
||||
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data)
|
||||
}
|
||||
}
|
||||
|
||||
exportDataObj.data.admins = admins
|
||||
logger.success(`✅ Exported ${admins.length} admins`)
|
||||
}
|
||||
|
||||
// 根据格式写入文件
|
||||
let fileContent
|
||||
if (format === 'csv') {
|
||||
fileContent = convertToCSV(exportDataObj, shouldSanitize)
|
||||
// 添加 UTF-8 BOM 以便 Excel 正确识别中文
|
||||
fileContent = `\ufeff${fileContent}`
|
||||
await fs.writeFile(outputFile, fileContent, 'utf8')
|
||||
} else {
|
||||
await fs.writeFile(outputFile, JSON.stringify(exportDataObj, null, 2))
|
||||
}
|
||||
|
||||
// 显示导出摘要
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('✅ Export Complete!')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Output file: ${outputFile}`)
|
||||
console.log(`File size: ${(await fs.stat(outputFile)).size} bytes`)
|
||||
|
||||
if (exportDataObj.data.apiKeys) {
|
||||
console.log(`API Keys: ${exportDataObj.data.apiKeys.length}`)
|
||||
}
|
||||
if (exportDataObj.data.claudeAccounts) {
|
||||
console.log(`Claude Accounts: ${exportDataObj.data.claudeAccounts.length}`)
|
||||
}
|
||||
if (exportDataObj.data.geminiAccounts) {
|
||||
console.log(`Gemini Accounts: ${exportDataObj.data.geminiAccounts.length}`)
|
||||
}
|
||||
if (exportDataObj.data.admins) {
|
||||
console.log(`Admins: ${exportDataObj.data.admins.length}`)
|
||||
}
|
||||
console.log('='.repeat(60))
|
||||
|
||||
if (shouldSanitize) {
|
||||
logger.warn('⚠️ Sensitive data has been sanitized in this export.')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('💥 Export failed:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await redis.disconnect()
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 导入数据
|
||||
async function importData() {
|
||||
try {
|
||||
const inputFile = params.input
|
||||
if (!inputFile) {
|
||||
logger.error('❌ Please specify input file with --input=filename.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const forceOverwrite = params.force === true
|
||||
const skipConflicts = params['skip-conflicts'] === true
|
||||
|
||||
logger.info('🔄 Starting data import...')
|
||||
logger.info(`📁 Input file: ${inputFile}`)
|
||||
logger.info(
|
||||
`⚡ Mode: ${forceOverwrite ? 'FORCE OVERWRITE' : skipConflicts ? 'SKIP CONFLICTS' : 'ASK ON CONFLICT'}`
|
||||
)
|
||||
|
||||
// 读取文件
|
||||
const fileContent = await fs.readFile(inputFile, 'utf8')
|
||||
const importDataObj = JSON.parse(fileContent)
|
||||
|
||||
// 验证文件格式
|
||||
if (!importDataObj.metadata || !importDataObj.data) {
|
||||
logger.error('❌ Invalid backup file format')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
logger.info(`📅 Backup date: ${importDataObj.metadata.exportDate}`)
|
||||
logger.info(`🔒 Sanitized: ${importDataObj.metadata.sanitized ? 'YES' : 'NO'}`)
|
||||
|
||||
if (importDataObj.metadata.sanitized) {
|
||||
logger.warn('⚠️ This backup contains sanitized data. Sensitive fields will be missing!')
|
||||
const proceed = await askConfirmation('Continue with sanitized data?')
|
||||
if (!proceed) {
|
||||
logger.info('❌ Import cancelled')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 显示导入摘要
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('📋 Import Summary:')
|
||||
console.log('='.repeat(60))
|
||||
if (importDataObj.data.apiKeys) {
|
||||
console.log(`API Keys to import: ${importDataObj.data.apiKeys.length}`)
|
||||
}
|
||||
if (importDataObj.data.claudeAccounts) {
|
||||
console.log(`Claude Accounts to import: ${importDataObj.data.claudeAccounts.length}`)
|
||||
}
|
||||
if (importDataObj.data.geminiAccounts) {
|
||||
console.log(`Gemini Accounts to import: ${importDataObj.data.geminiAccounts.length}`)
|
||||
}
|
||||
if (importDataObj.data.admins) {
|
||||
console.log(`Admins to import: ${importDataObj.data.admins.length}`)
|
||||
}
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
// 确认导入
|
||||
const confirmed = await askConfirmation('⚠️ Proceed with import?')
|
||||
if (!confirmed) {
|
||||
logger.info('❌ Import cancelled')
|
||||
return
|
||||
}
|
||||
|
||||
// 连接 Redis
|
||||
await redis.connect()
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
const stats = {
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
// 导入 API Keys
|
||||
if (importDataObj.data.apiKeys) {
|
||||
logger.info('\n📥 Importing API Keys...')
|
||||
for (const apiKey of importDataObj.data.apiKeys) {
|
||||
try {
|
||||
const exists = await redis.client.exists(`apikey:${apiKey.id}`)
|
||||
|
||||
if (exists && !forceOverwrite) {
|
||||
if (skipConflicts) {
|
||||
logger.warn(`⏭️ Skipped existing API Key: ${apiKey.name} (${apiKey.id})`)
|
||||
stats.skipped++
|
||||
continue
|
||||
} else {
|
||||
const overwrite = await askConfirmation(
|
||||
`API Key "${apiKey.name}" (${apiKey.id}) exists. Overwrite?`
|
||||
)
|
||||
if (!overwrite) {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 hset 存储到哈希表
|
||||
const pipeline = redis.client.pipeline()
|
||||
for (const [field, value] of Object.entries(apiKey)) {
|
||||
pipeline.hset(`apikey:${apiKey.id}`, field, value)
|
||||
}
|
||||
await pipeline.exec()
|
||||
|
||||
// 更新哈希映射
|
||||
if (apiKey.apiKey && !importDataObj.metadata.sanitized) {
|
||||
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id)
|
||||
}
|
||||
|
||||
logger.success(`✅ Imported API Key: ${apiKey.name} (${apiKey.id})`)
|
||||
stats.imported++
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to import API Key ${apiKey.id}:`, error.message)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入 Claude 账户
|
||||
if (importDataObj.data.claudeAccounts) {
|
||||
logger.info('\n📥 Importing Claude accounts...')
|
||||
for (const account of importDataObj.data.claudeAccounts) {
|
||||
try {
|
||||
const exists = await redis.client.exists(`claude_account:${account.id}`)
|
||||
|
||||
if (exists && !forceOverwrite) {
|
||||
if (skipConflicts) {
|
||||
logger.warn(`⏭️ Skipped existing Claude account: ${account.name} (${account.id})`)
|
||||
stats.skipped++
|
||||
continue
|
||||
} else {
|
||||
const overwrite = await askConfirmation(
|
||||
`Claude account "${account.name}" (${account.id}) exists. Overwrite?`
|
||||
)
|
||||
if (!overwrite) {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 hset 存储到哈希表
|
||||
const pipeline = redis.client.pipeline()
|
||||
for (const [field, value] of Object.entries(account)) {
|
||||
// 如果是对象,需要序列化
|
||||
if (field === 'claudeAiOauth' && typeof value === 'object') {
|
||||
pipeline.hset(`claude_account:${account.id}`, field, JSON.stringify(value))
|
||||
} else {
|
||||
pipeline.hset(`claude_account:${account.id}`, field, value)
|
||||
}
|
||||
}
|
||||
await pipeline.exec()
|
||||
logger.success(`✅ Imported Claude account: ${account.name} (${account.id})`)
|
||||
stats.imported++
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to import Claude account ${account.id}:`, error.message)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导入 Gemini 账户
|
||||
if (importDataObj.data.geminiAccounts) {
|
||||
logger.info('\n📥 Importing Gemini accounts...')
|
||||
for (const account of importDataObj.data.geminiAccounts) {
|
||||
try {
|
||||
const exists = await redis.client.exists(`gemini_account:${account.id}`)
|
||||
|
||||
if (exists && !forceOverwrite) {
|
||||
if (skipConflicts) {
|
||||
logger.warn(`⏭️ Skipped existing Gemini account: ${account.name} (${account.id})`)
|
||||
stats.skipped++
|
||||
continue
|
||||
} else {
|
||||
const overwrite = await askConfirmation(
|
||||
`Gemini account "${account.name}" (${account.id}) exists. Overwrite?`
|
||||
)
|
||||
if (!overwrite) {
|
||||
stats.skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 hset 存储到哈希表
|
||||
const pipeline = redis.client.pipeline()
|
||||
for (const [field, value] of Object.entries(account)) {
|
||||
pipeline.hset(`gemini_account:${account.id}`, field, value)
|
||||
}
|
||||
await pipeline.exec()
|
||||
logger.success(`✅ Imported Gemini account: ${account.name} (${account.id})`)
|
||||
stats.imported++
|
||||
} catch (error) {
|
||||
logger.error(`❌ Failed to import Gemini account ${account.id}:`, error.message)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 显示导入结果
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('✅ Import Complete!')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Successfully imported: ${stats.imported}`)
|
||||
console.log(`Skipped: ${stats.skipped}`)
|
||||
console.log(`Errors: ${stats.errors}`)
|
||||
console.log('='.repeat(60))
|
||||
} catch (error) {
|
||||
logger.error('💥 Import failed:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await redis.disconnect()
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
// 显示帮助信息
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
Data Transfer Tool for Claude Relay Service
|
||||
|
||||
This tool allows you to export and import data between environments.
|
||||
|
||||
Usage:
|
||||
node scripts/data-transfer.js <command> [options]
|
||||
|
||||
Commands:
|
||||
export Export data from Redis to a JSON file
|
||||
import Import data from a JSON file to Redis
|
||||
|
||||
Export Options:
|
||||
--output=FILE Output filename (default: backup-YYYY-MM-DD.json/.csv)
|
||||
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
|
||||
--format=FORMAT Output format: json,csv (default: json)
|
||||
--sanitize Remove sensitive data from export
|
||||
|
||||
Import Options:
|
||||
--input=FILE Input filename (required)
|
||||
--force Overwrite existing data without asking
|
||||
--skip-conflicts Skip conflicting data without asking
|
||||
|
||||
Examples:
|
||||
# Export all data
|
||||
node scripts/data-transfer.js export
|
||||
|
||||
# Export only API keys with sanitized data
|
||||
node scripts/data-transfer.js export --types=apikeys --sanitize
|
||||
|
||||
# Import data, skip conflicts
|
||||
node scripts/data-transfer.js import --input=backup.json --skip-conflicts
|
||||
|
||||
# Export specific data types
|
||||
node scripts/data-transfer.js export --types=apikeys,accounts --output=prod-data.json
|
||||
|
||||
# Export API keys to CSV format
|
||||
node scripts/data-transfer.js export --types=apikeys --format=csv --sanitize
|
||||
|
||||
# Export to CSV with custom filename
|
||||
node scripts/data-transfer.js export --types=apikeys --format=csv --output=api-keys.csv
|
||||
`)
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
if (!command || command === '--help' || command === 'help') {
|
||||
showHelp()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case 'export':
|
||||
await exportData()
|
||||
break
|
||||
|
||||
case 'import':
|
||||
await importData()
|
||||
break
|
||||
|
||||
default:
|
||||
logger.error(`❌ Unknown command: ${command}`)
|
||||
showHelp()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行
|
||||
main().catch((error) => {
|
||||
logger.error('💥 Unexpected error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Redis 键调试工具
|
||||
* 用于查看 Redis 中存储的所有键和数据结构
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const logger = require('../src/utils/logger')
|
||||
|
||||
async function debugRedisKeys() {
|
||||
try {
|
||||
logger.info('🔄 Connecting to Redis...')
|
||||
await redis.connect()
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
// 获取所有键
|
||||
const allKeys = await redis.client.keys('*')
|
||||
logger.info(`\n📊 Total keys in Redis: ${allKeys.length}\n`)
|
||||
|
||||
// 按类型分组
|
||||
const keysByType = {
|
||||
apiKeys: [],
|
||||
claudeAccounts: [],
|
||||
geminiAccounts: [],
|
||||
admins: [],
|
||||
sessions: [],
|
||||
usage: [],
|
||||
other: []
|
||||
}
|
||||
|
||||
// 分类键
|
||||
for (const key of allKeys) {
|
||||
if (key.startsWith('apikey:')) {
|
||||
keysByType.apiKeys.push(key)
|
||||
} else if (key.startsWith('claude_account:')) {
|
||||
keysByType.claudeAccounts.push(key)
|
||||
} else if (key.startsWith('gemini_account:')) {
|
||||
keysByType.geminiAccounts.push(key)
|
||||
} else if (key.startsWith('admin:') || key.startsWith('admin_username:')) {
|
||||
keysByType.admins.push(key)
|
||||
} else if (key.startsWith('session:')) {
|
||||
keysByType.sessions.push(key)
|
||||
} else if (
|
||||
key.includes('usage') ||
|
||||
key.includes('rate_limit') ||
|
||||
key.includes('concurrency')
|
||||
) {
|
||||
keysByType.usage.push(key)
|
||||
} else {
|
||||
keysByType.other.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
// 显示分类结果
|
||||
console.log('='.repeat(60))
|
||||
console.log('📂 Keys by Category:')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`API Keys: ${keysByType.apiKeys.length}`)
|
||||
console.log(`Claude Accounts: ${keysByType.claudeAccounts.length}`)
|
||||
console.log(`Gemini Accounts: ${keysByType.geminiAccounts.length}`)
|
||||
console.log(`Admins: ${keysByType.admins.length}`)
|
||||
console.log(`Sessions: ${keysByType.sessions.length}`)
|
||||
console.log(`Usage/Rate Limit: ${keysByType.usage.length}`)
|
||||
console.log(`Other: ${keysByType.other.length}`)
|
||||
console.log('='.repeat(60))
|
||||
|
||||
// 详细显示每个类别的键
|
||||
if (keysByType.apiKeys.length > 0) {
|
||||
console.log('\n🔑 API Keys:')
|
||||
for (const key of keysByType.apiKeys.slice(0, 5)) {
|
||||
console.log(` - ${key}`)
|
||||
}
|
||||
if (keysByType.apiKeys.length > 5) {
|
||||
console.log(` ... and ${keysByType.apiKeys.length - 5} more`)
|
||||
}
|
||||
}
|
||||
|
||||
if (keysByType.claudeAccounts.length > 0) {
|
||||
console.log('\n🤖 Claude Accounts:')
|
||||
for (const key of keysByType.claudeAccounts) {
|
||||
console.log(` - ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (keysByType.geminiAccounts.length > 0) {
|
||||
console.log('\n💎 Gemini Accounts:')
|
||||
for (const key of keysByType.geminiAccounts) {
|
||||
console.log(` - ${key}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (keysByType.other.length > 0) {
|
||||
console.log('\n❓ Other Keys:')
|
||||
for (const key of keysByType.other.slice(0, 10)) {
|
||||
console.log(` - ${key}`)
|
||||
}
|
||||
if (keysByType.other.length > 10) {
|
||||
console.log(` ... and ${keysByType.other.length - 10} more`)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查数据类型
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('🔍 Checking Data Types:')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
// 随机检查几个键的类型
|
||||
const sampleKeys = allKeys.slice(0, Math.min(10, allKeys.length))
|
||||
for (const key of sampleKeys) {
|
||||
const type = await redis.client.type(key)
|
||||
console.log(`${key} => ${type}`)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('💥 Debug failed:', error)
|
||||
} finally {
|
||||
await redis.disconnect()
|
||||
logger.info('👋 Disconnected from Redis')
|
||||
}
|
||||
}
|
||||
|
||||
// 运行调试
|
||||
debugRedisKeys().catch((error) => {
|
||||
logger.error('💥 Unexpected error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 修复 inquirer ESM 问题
|
||||
* 降级到支持 CommonJS 的版本
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process')
|
||||
|
||||
console.log('🔧 修复 inquirer ESM 兼容性问题...\n')
|
||||
|
||||
try {
|
||||
// 卸载当前版本
|
||||
console.log('📦 卸载当前 inquirer 版本...')
|
||||
execSync('npm uninstall inquirer', { stdio: 'inherit' })
|
||||
|
||||
// 安装兼容 CommonJS 的版本 (8.x 是最后支持 CommonJS 的主要版本)
|
||||
console.log('\n📦 安装兼容版本 inquirer@8.2.6...')
|
||||
execSync('npm install inquirer@8.2.6', { stdio: 'inherit' })
|
||||
|
||||
console.log('\n✅ 修复完成!')
|
||||
console.log('\n现在可以正常使用 CLI 工具了:')
|
||||
console.log(' npm run cli admin')
|
||||
console.log(' npm run cli keys')
|
||||
console.log(' npm run cli status')
|
||||
} catch (error) {
|
||||
console.error('❌ 修复失败:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 数据迁移脚本:修复历史使用统计数据
|
||||
*
|
||||
* 功能:
|
||||
* 1. 统一 totalTokens 和 allTokens 字段
|
||||
* 2. 确保 allTokens 包含所有类型的 tokens
|
||||
* 3. 修复历史数据的不一致性
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/fix-usage-stats.js [--dry-run]
|
||||
*/
|
||||
|
||||
require('dotenv').config()
|
||||
const redis = require('../src/models/redis')
|
||||
const logger = require('../src/utils/logger')
|
||||
|
||||
// 解析命令行参数
|
||||
const args = process.argv.slice(2)
|
||||
const isDryRun = args.includes('--dry-run')
|
||||
|
||||
async function fixUsageStats() {
|
||||
try {
|
||||
logger.info('🔧 开始修复使用统计数据...')
|
||||
if (isDryRun) {
|
||||
logger.info('📝 DRY RUN 模式 - 不会实际修改数据')
|
||||
}
|
||||
|
||||
// 连接到 Redis
|
||||
await redis.connect()
|
||||
logger.success('✅ 已连接到 Redis')
|
||||
|
||||
const client = redis.getClientSafe()
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
totalKeys: 0,
|
||||
fixedTotalKeys: 0,
|
||||
fixedDailyKeys: 0,
|
||||
fixedMonthlyKeys: 0,
|
||||
fixedModelKeys: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
// 1. 修复 API Key 级别的总统计
|
||||
logger.info('\n📊 修复 API Key 总统计数据...')
|
||||
const apiKeyPattern = 'apikey:*'
|
||||
const apiKeys = await client.keys(apiKeyPattern)
|
||||
stats.totalKeys = apiKeys.length
|
||||
|
||||
for (const apiKeyKey of apiKeys) {
|
||||
const keyId = apiKeyKey.replace('apikey:', '')
|
||||
const usageKey = `usage:${keyId}`
|
||||
|
||||
try {
|
||||
const usageData = await client.hgetall(usageKey)
|
||||
if (usageData && Object.keys(usageData).length > 0) {
|
||||
const inputTokens = parseInt(usageData.totalInputTokens) || 0
|
||||
const outputTokens = parseInt(usageData.totalOutputTokens) || 0
|
||||
const cacheCreateTokens = parseInt(usageData.totalCacheCreateTokens) || 0
|
||||
const cacheReadTokens = parseInt(usageData.totalCacheReadTokens) || 0
|
||||
const currentAllTokens = parseInt(usageData.totalAllTokens) || 0
|
||||
|
||||
// 计算正确的 allTokens
|
||||
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
|
||||
logger.info(` 修复 ${keyId}: ${currentAllTokens} -> ${correctAllTokens}`)
|
||||
|
||||
if (!isDryRun) {
|
||||
await client.hset(usageKey, 'totalAllTokens', correctAllTokens)
|
||||
}
|
||||
stats.fixedTotalKeys++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(` 错误处理 ${keyId}: ${error.message}`)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 修复每日统计数据
|
||||
logger.info('\n📅 修复每日统计数据...')
|
||||
const dailyPattern = 'usage:daily:*'
|
||||
const dailyKeys = await client.keys(dailyPattern)
|
||||
|
||||
for (const dailyKey of dailyKeys) {
|
||||
try {
|
||||
const data = await client.hgetall(dailyKey)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const inputTokens = parseInt(data.inputTokens) || 0
|
||||
const outputTokens = parseInt(data.outputTokens) || 0
|
||||
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
|
||||
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
|
||||
const currentAllTokens = parseInt(data.allTokens) || 0
|
||||
|
||||
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
|
||||
if (!isDryRun) {
|
||||
await client.hset(dailyKey, 'allTokens', correctAllTokens)
|
||||
}
|
||||
stats.fixedDailyKeys++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(` 错误处理 ${dailyKey}: ${error.message}`)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 修复每月统计数据
|
||||
logger.info('\n📆 修复每月统计数据...')
|
||||
const monthlyPattern = 'usage:monthly:*'
|
||||
const monthlyKeys = await client.keys(monthlyPattern)
|
||||
|
||||
for (const monthlyKey of monthlyKeys) {
|
||||
try {
|
||||
const data = await client.hgetall(monthlyKey)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const inputTokens = parseInt(data.inputTokens) || 0
|
||||
const outputTokens = parseInt(data.outputTokens) || 0
|
||||
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
|
||||
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
|
||||
const currentAllTokens = parseInt(data.allTokens) || 0
|
||||
|
||||
const correctAllTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
|
||||
if (!isDryRun) {
|
||||
await client.hset(monthlyKey, 'allTokens', correctAllTokens)
|
||||
}
|
||||
stats.fixedMonthlyKeys++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(` 错误处理 ${monthlyKey}: ${error.message}`)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 修复模型级别的统计数据
|
||||
logger.info('\n🤖 修复模型级别统计数据...')
|
||||
const modelPatterns = [
|
||||
'usage:model:daily:*',
|
||||
'usage:model:monthly:*',
|
||||
'usage:*:model:daily:*',
|
||||
'usage:*:model:monthly:*'
|
||||
]
|
||||
|
||||
for (const pattern of modelPatterns) {
|
||||
const modelKeys = await client.keys(pattern)
|
||||
|
||||
for (const modelKey of modelKeys) {
|
||||
try {
|
||||
const data = await client.hgetall(modelKey)
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
const inputTokens = parseInt(data.inputTokens) || 0
|
||||
const outputTokens = parseInt(data.outputTokens) || 0
|
||||
const cacheCreateTokens = parseInt(data.cacheCreateTokens) || 0
|
||||
const cacheReadTokens = parseInt(data.cacheReadTokens) || 0
|
||||
const currentAllTokens = parseInt(data.allTokens) || 0
|
||||
|
||||
const correctAllTokens =
|
||||
inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
if (currentAllTokens !== correctAllTokens && correctAllTokens > 0) {
|
||||
if (!isDryRun) {
|
||||
await client.hset(modelKey, 'allTokens', correctAllTokens)
|
||||
}
|
||||
stats.fixedModelKeys++
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(` 错误处理 ${modelKey}: ${error.message}`)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 验证修复结果
|
||||
if (!isDryRun) {
|
||||
logger.info('\n✅ 验证修复结果...')
|
||||
|
||||
// 随机抽样验证
|
||||
const sampleSize = Math.min(5, apiKeys.length)
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * apiKeys.length)
|
||||
const keyId = apiKeys[randomIndex].replace('apikey:', '')
|
||||
const usage = await redis.getUsageStats(keyId)
|
||||
|
||||
logger.info(` 样本 ${keyId}:`)
|
||||
logger.info(` Total tokens: ${usage.total.tokens}`)
|
||||
logger.info(` All tokens: ${usage.total.allTokens}`)
|
||||
logger.info(` 一致性: ${usage.total.tokens === usage.total.allTokens ? '✅' : '❌'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 打印统计结果
|
||||
logger.info('\n📊 修复统计:')
|
||||
logger.info(` 总 API Keys: ${stats.totalKeys}`)
|
||||
logger.info(` 修复的总统计: ${stats.fixedTotalKeys}`)
|
||||
logger.info(` 修复的日统计: ${stats.fixedDailyKeys}`)
|
||||
logger.info(` 修复的月统计: ${stats.fixedMonthlyKeys}`)
|
||||
logger.info(` 修复的模型统计: ${stats.fixedModelKeys}`)
|
||||
logger.info(` 错误数: ${stats.errors}`)
|
||||
|
||||
if (isDryRun) {
|
||||
logger.info('\n💡 这是 DRY RUN - 没有实际修改数据')
|
||||
logger.info(' 运行不带 --dry-run 参数来实际执行修复')
|
||||
} else {
|
||||
logger.success('\n✅ 数据修复完成!')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('❌ 修复过程出错:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await redis.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 执行修复
|
||||
fixUsageStats().catch((error) => {
|
||||
logger.error('❌ 未处理的错误:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 历史数据生成脚本
|
||||
* 用于测试不同时间范围的Token统计功能
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/generate-test-data.js [--clean]
|
||||
*
|
||||
* 选项:
|
||||
* --clean: 清除所有测试数据
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const logger = require('../src/utils/logger')
|
||||
|
||||
// 解析命令行参数
|
||||
const args = process.argv.slice(2)
|
||||
const shouldClean = args.includes('--clean')
|
||||
|
||||
// 模拟的模型列表
|
||||
const models = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-opus-20240229'
|
||||
]
|
||||
|
||||
// 生成指定日期的数据
|
||||
async function generateDataForDate(apiKeyId, date, dayOffset) {
|
||||
const client = redis.getClientSafe()
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
|
||||
// 根据日期偏移量调整数据量(越近的日期数据越多)
|
||||
const requestCount = Math.max(5, 20 - dayOffset * 2) // 5-20个请求
|
||||
|
||||
logger.info(`📊 Generating ${requestCount} requests for ${dateStr}`)
|
||||
|
||||
for (let i = 0; i < requestCount; i++) {
|
||||
// 随机选择模型
|
||||
const model = models[Math.floor(Math.random() * models.length)]
|
||||
|
||||
// 生成随机Token数据
|
||||
const inputTokens = Math.floor(Math.random() * 2000) + 500 // 500-2500
|
||||
const outputTokens = Math.floor(Math.random() * 3000) + 1000 // 1000-4000
|
||||
const cacheCreateTokens = Math.random() > 0.7 ? Math.floor(Math.random() * 1000) : 0 // 30%概率有缓存创建
|
||||
const cacheReadTokens = Math.random() > 0.5 ? Math.floor(Math.random() * 500) : 0 // 50%概率有缓存读取
|
||||
|
||||
const coreTokens = inputTokens + outputTokens
|
||||
const allTokens = inputTokens + outputTokens + cacheCreateTokens + cacheReadTokens
|
||||
|
||||
// 更新各种统计键
|
||||
const totalKey = `usage:${apiKeyId}`
|
||||
const dailyKey = `usage:daily:${apiKeyId}:${dateStr}`
|
||||
const monthlyKey = `usage:monthly:${apiKeyId}:${month}`
|
||||
const modelDailyKey = `usage:model:daily:${model}:${dateStr}`
|
||||
const modelMonthlyKey = `usage:model:monthly:${model}:${month}`
|
||||
const keyModelDailyKey = `usage:${apiKeyId}:model:daily:${model}:${dateStr}`
|
||||
const keyModelMonthlyKey = `usage:${apiKeyId}:model:monthly:${model}:${month}`
|
||||
|
||||
await Promise.all([
|
||||
// 总计数据
|
||||
client.hincrby(totalKey, 'totalTokens', coreTokens),
|
||||
client.hincrby(totalKey, 'totalInputTokens', inputTokens),
|
||||
client.hincrby(totalKey, 'totalOutputTokens', outputTokens),
|
||||
client.hincrby(totalKey, 'totalCacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(totalKey, 'totalCacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(totalKey, 'totalAllTokens', allTokens),
|
||||
client.hincrby(totalKey, 'totalRequests', 1),
|
||||
|
||||
// 每日统计
|
||||
client.hincrby(dailyKey, 'tokens', coreTokens),
|
||||
client.hincrby(dailyKey, 'inputTokens', inputTokens),
|
||||
client.hincrby(dailyKey, 'outputTokens', outputTokens),
|
||||
client.hincrby(dailyKey, 'cacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(dailyKey, 'cacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(dailyKey, 'allTokens', allTokens),
|
||||
client.hincrby(dailyKey, 'requests', 1),
|
||||
|
||||
// 每月统计
|
||||
client.hincrby(monthlyKey, 'tokens', coreTokens),
|
||||
client.hincrby(monthlyKey, 'inputTokens', inputTokens),
|
||||
client.hincrby(monthlyKey, 'outputTokens', outputTokens),
|
||||
client.hincrby(monthlyKey, 'cacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(monthlyKey, 'cacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(monthlyKey, 'allTokens', allTokens),
|
||||
client.hincrby(monthlyKey, 'requests', 1),
|
||||
|
||||
// 模型统计 - 每日
|
||||
client.hincrby(modelDailyKey, 'totalInputTokens', inputTokens),
|
||||
client.hincrby(modelDailyKey, 'totalOutputTokens', outputTokens),
|
||||
client.hincrby(modelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(modelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(modelDailyKey, 'totalAllTokens', allTokens),
|
||||
client.hincrby(modelDailyKey, 'requests', 1),
|
||||
|
||||
// 模型统计 - 每月
|
||||
client.hincrby(modelMonthlyKey, 'totalInputTokens', inputTokens),
|
||||
client.hincrby(modelMonthlyKey, 'totalOutputTokens', outputTokens),
|
||||
client.hincrby(modelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(modelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(modelMonthlyKey, 'totalAllTokens', allTokens),
|
||||
client.hincrby(modelMonthlyKey, 'requests', 1),
|
||||
|
||||
// API Key级别的模型统计 - 每日
|
||||
// 同时存储带total前缀和不带前缀的字段,保持兼容性
|
||||
client.hincrby(keyModelDailyKey, 'inputTokens', inputTokens),
|
||||
client.hincrby(keyModelDailyKey, 'outputTokens', outputTokens),
|
||||
client.hincrby(keyModelDailyKey, 'cacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(keyModelDailyKey, 'cacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(keyModelDailyKey, 'allTokens', allTokens),
|
||||
client.hincrby(keyModelDailyKey, 'totalInputTokens', inputTokens),
|
||||
client.hincrby(keyModelDailyKey, 'totalOutputTokens', outputTokens),
|
||||
client.hincrby(keyModelDailyKey, 'totalCacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(keyModelDailyKey, 'totalCacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(keyModelDailyKey, 'totalAllTokens', allTokens),
|
||||
client.hincrby(keyModelDailyKey, 'requests', 1),
|
||||
|
||||
// API Key级别的模型统计 - 每月
|
||||
client.hincrby(keyModelMonthlyKey, 'inputTokens', inputTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'outputTokens', outputTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'cacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'cacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'allTokens', allTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'totalInputTokens', inputTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'totalOutputTokens', outputTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'totalCacheCreateTokens', cacheCreateTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'totalCacheReadTokens', cacheReadTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'totalAllTokens', allTokens),
|
||||
client.hincrby(keyModelMonthlyKey, 'requests', 1)
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 清除测试数据
|
||||
async function cleanTestData() {
|
||||
const client = redis.getClientSafe()
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
|
||||
logger.info('🧹 Cleaning test data...')
|
||||
|
||||
// 获取所有API Keys
|
||||
const allKeys = await apiKeyService.getAllApiKeysFast()
|
||||
|
||||
// 找出所有测试 API Keys
|
||||
const testKeys = allKeys.filter((key) => key.name && key.name.startsWith('Test API Key'))
|
||||
|
||||
for (const testKey of testKeys) {
|
||||
const apiKeyId = testKey.id
|
||||
|
||||
// 获取所有相关的键
|
||||
const patterns = [
|
||||
`usage:${apiKeyId}`,
|
||||
`usage:daily:${apiKeyId}:*`,
|
||||
`usage:monthly:${apiKeyId}:*`,
|
||||
`usage:${apiKeyId}:model:daily:*`,
|
||||
`usage:${apiKeyId}:model:monthly:*`
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除 API Key 本身
|
||||
await apiKeyService.deleteApiKey(apiKeyId)
|
||||
logger.info(`🗑️ Deleted test API Key: ${testKey.name} (${apiKeyId})`)
|
||||
}
|
||||
|
||||
// 清除模型统计
|
||||
const modelPatterns = ['usage:model:daily:*', 'usage:model:monthly:*']
|
||||
|
||||
for (const pattern of modelPatterns) {
|
||||
const keys = await client.keys(pattern)
|
||||
if (keys.length > 0) {
|
||||
await client.del(...keys)
|
||||
logger.info(`🗑️ Deleted ${keys.length} keys matching pattern: ${pattern}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
try {
|
||||
await redis.connect()
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
// 创建测试API Keys
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const testApiKeys = []
|
||||
const createdKeys = []
|
||||
|
||||
// 总是创建新的测试 API Keys
|
||||
logger.info('📝 Creating test API Keys...')
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const newKey = await apiKeyService.generateApiKey({
|
||||
name: `Test API Key ${i}`,
|
||||
description: `Test key for historical data generation ${i}`,
|
||||
tokenLimit: 10000000,
|
||||
concurrencyLimit: 10,
|
||||
rateLimitWindow: 60,
|
||||
rateLimitRequests: 100
|
||||
})
|
||||
|
||||
testApiKeys.push(newKey.id)
|
||||
createdKeys.push(newKey)
|
||||
logger.success(`✅ Created test API Key: ${newKey.name} (${newKey.id})`)
|
||||
logger.info(` 🔑 API Key: ${newKey.apiKey}`)
|
||||
}
|
||||
|
||||
if (shouldClean) {
|
||||
await cleanTestData()
|
||||
logger.success('✅ Test data cleaned successfully')
|
||||
return
|
||||
}
|
||||
|
||||
// 生成历史数据
|
||||
const now = new Date()
|
||||
|
||||
for (const apiKeyId of testApiKeys) {
|
||||
logger.info(`\n🔄 Generating data for API Key: ${apiKeyId}`)
|
||||
|
||||
// 生成过去30天的数据
|
||||
for (let dayOffset = 0; dayOffset < 30; dayOffset++) {
|
||||
const date = new Date(now)
|
||||
date.setDate(date.getDate() - dayOffset)
|
||||
|
||||
await generateDataForDate(apiKeyId, date, dayOffset)
|
||||
}
|
||||
|
||||
logger.success(`✅ Generated 30 days of historical data for API Key: ${apiKeyId}`)
|
||||
}
|
||||
|
||||
// 显示统计摘要
|
||||
logger.info('\n📊 Test Data Summary:')
|
||||
logger.info('='.repeat(60))
|
||||
|
||||
for (const apiKeyId of testApiKeys) {
|
||||
const totalKey = `usage:${apiKeyId}`
|
||||
const totalData = await redis.getClientSafe().hgetall(totalKey)
|
||||
|
||||
if (totalData && Object.keys(totalData).length > 0) {
|
||||
logger.info(`\nAPI Key: ${apiKeyId}`)
|
||||
logger.info(` Total Requests: ${totalData.totalRequests || 0}`)
|
||||
logger.info(` Total Tokens (Core): ${totalData.totalTokens || 0}`)
|
||||
logger.info(` Total Tokens (All): ${totalData.totalAllTokens || 0}`)
|
||||
logger.info(` Input Tokens: ${totalData.totalInputTokens || 0}`)
|
||||
logger.info(` Output Tokens: ${totalData.totalOutputTokens || 0}`)
|
||||
logger.info(` Cache Create Tokens: ${totalData.totalCacheCreateTokens || 0}`)
|
||||
logger.info(` Cache Read Tokens: ${totalData.totalCacheReadTokens || 0}`)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`\n${'='.repeat(60)}`)
|
||||
logger.success('\n✅ Test data generation completed!')
|
||||
logger.info('\n📋 Created API Keys:')
|
||||
for (const key of createdKeys) {
|
||||
logger.info(`- ${key.name}: ${key.apiKey}`)
|
||||
}
|
||||
logger.info('\n💡 Tips:')
|
||||
logger.info('- Check the admin panel to see the different time ranges')
|
||||
logger.info('- Use --clean flag to remove all test data and API Keys')
|
||||
logger.info('- The script generates more recent data to simulate real usage patterns')
|
||||
} catch (error) {
|
||||
logger.error('❌ Error:', error)
|
||||
} finally {
|
||||
await redis.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// 运行脚本
|
||||
main().catch((error) => {
|
||||
logger.error('💥 Unexpected error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,561 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 会话窗口管理脚本
|
||||
* 用于调试、恢复和管理Claude账户的会话窗口
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
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()
|
||||
const isActive = now < windowEnd
|
||||
|
||||
console.log(` 会话窗口: ${windowStart.toLocaleString()} - ${windowEnd.toLocaleString()}`)
|
||||
console.log(` 窗口状态: ${isActive ? '✅ 活跃' : '❌ 已过期'}`)
|
||||
|
||||
// 只有在窗口已过期时才显示可恢复窗口
|
||||
if (!isActive && account.lastUsedAt) {
|
||||
const lastUsed = new Date(account.lastUsedAt)
|
||||
const recoveredWindowStart = claudeAccountService._calculateSessionWindowStart(lastUsed)
|
||||
const recoveredWindowEnd =
|
||||
claudeAccountService._calculateSessionWindowEnd(recoveredWindowStart)
|
||||
|
||||
if (now < recoveredWindowEnd) {
|
||||
stats.canRecover++
|
||||
console.log(
|
||||
` 可恢复窗口: ✅ ${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()}`
|
||||
)
|
||||
} else {
|
||||
stats.expired++
|
||||
console.log(
|
||||
` 可恢复窗口: ❌ 已过期 (${recoveredWindowStart.toLocaleString()} - ${recoveredWindowEnd.toLocaleString()})`
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(' 会话窗口: ❌ 无')
|
||||
|
||||
// 没有会话窗口时,检查是否有可恢复的窗口
|
||||
if (account.lastUsedAt) {
|
||||
const lastUsed = new Date(account.lastUsedAt)
|
||||
const now = new Date()
|
||||
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()})`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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}分钟前)`)
|
||||
} 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 <command>\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 }
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { spawn, exec } = require('child_process')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const process = require('process')
|
||||
|
||||
const PID_FILE = path.join(__dirname, '..', 'claude-relay-service.pid')
|
||||
const LOG_FILE = path.join(__dirname, '..', 'logs', 'service.log')
|
||||
const ERROR_LOG_FILE = path.join(__dirname, '..', 'logs', 'service-error.log')
|
||||
const APP_FILE = path.join(__dirname, '..', 'src', 'app.js')
|
||||
|
||||
class ServiceManager {
|
||||
constructor() {
|
||||
this.ensureLogDir()
|
||||
}
|
||||
|
||||
ensureLogDir() {
|
||||
const logDir = path.dirname(LOG_FILE)
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
getPid() {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim())
|
||||
return pid
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('读取PID文件失败:', error.message)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
isProcessRunning(pid) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
writePid(pid) {
|
||||
try {
|
||||
fs.writeFileSync(PID_FILE, pid.toString())
|
||||
console.log(`✅ PID ${pid} 已保存到 ${PID_FILE}`)
|
||||
} catch (error) {
|
||||
console.error('写入PID文件失败:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
removePidFile() {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
fs.unlinkSync(PID_FILE)
|
||||
console.log('🗑️ 已清理PID文件')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('清理PID文件失败:', error.message)
|
||||
}
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
const pid = this.getPid()
|
||||
if (pid && this.isProcessRunning(pid)) {
|
||||
return { running: true, pid }
|
||||
}
|
||||
return { running: false, pid: null }
|
||||
}
|
||||
|
||||
start(daemon = false) {
|
||||
const status = this.getStatus()
|
||||
if (status.running) {
|
||||
console.log(`⚠️ 服务已在运行中 (PID: ${status.pid})`)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('🚀 启动 Claude Relay Service...')
|
||||
|
||||
if (daemon) {
|
||||
// 后台运行模式 - 使用nohup实现真正的后台运行
|
||||
const { exec: execChild } = require('child_process')
|
||||
|
||||
const command = `nohup node "${APP_FILE}" > "${LOG_FILE}" 2> "${ERROR_LOG_FILE}" & echo $!`
|
||||
|
||||
execChild(command, (error, stdout) => {
|
||||
if (error) {
|
||||
console.error('❌ 后台启动失败:', error.message)
|
||||
return
|
||||
}
|
||||
|
||||
const pid = parseInt(stdout.trim())
|
||||
if (pid && !isNaN(pid)) {
|
||||
this.writePid(pid)
|
||||
console.log(`🔄 服务已在后台启动 (PID: ${pid})`)
|
||||
console.log(`📝 日志文件: ${LOG_FILE}`)
|
||||
console.log(`❌ 错误日志: ${ERROR_LOG_FILE}`)
|
||||
console.log('✅ 终端现在可以安全关闭')
|
||||
} else {
|
||||
console.error('❌ 无法获取进程ID')
|
||||
}
|
||||
})
|
||||
|
||||
// 给exec一点时间执行
|
||||
setTimeout(() => {
|
||||
process.exit(0)
|
||||
}, 1000)
|
||||
} else {
|
||||
// 前台运行模式
|
||||
const child = spawn('node', [APP_FILE], {
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
console.log(`🔄 服务已启动 (PID: ${child.pid})`)
|
||||
|
||||
this.writePid(child.pid)
|
||||
|
||||
// 监听进程退出
|
||||
child.on('exit', (code, signal) => {
|
||||
this.removePidFile()
|
||||
if (code !== 0) {
|
||||
console.log(`💥 进程退出 (代码: ${code}, 信号: ${signal})`)
|
||||
}
|
||||
})
|
||||
|
||||
child.on('error', (error) => {
|
||||
console.error('❌ 启动失败:', error.message)
|
||||
this.removePidFile()
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
stop() {
|
||||
const status = this.getStatus()
|
||||
if (!status.running) {
|
||||
console.log('⚠️ 服务未在运行')
|
||||
this.removePidFile() // 清理可能存在的过期PID文件
|
||||
return false
|
||||
}
|
||||
|
||||
console.log(`🛑 停止服务 (PID: ${status.pid})...`)
|
||||
|
||||
try {
|
||||
// 优雅关闭:先发送SIGTERM
|
||||
process.kill(status.pid, 'SIGTERM')
|
||||
|
||||
// 等待进程退出
|
||||
let attempts = 0
|
||||
const maxAttempts = 30 // 30秒超时
|
||||
|
||||
const checkExit = setInterval(() => {
|
||||
attempts++
|
||||
if (!this.isProcessRunning(status.pid)) {
|
||||
clearInterval(checkExit)
|
||||
console.log('✅ 服务已停止')
|
||||
this.removePidFile()
|
||||
return
|
||||
}
|
||||
|
||||
if (attempts >= maxAttempts) {
|
||||
clearInterval(checkExit)
|
||||
console.log('⚠️ 优雅关闭超时,强制终止进程...')
|
||||
try {
|
||||
process.kill(status.pid, 'SIGKILL')
|
||||
console.log('✅ 服务已强制停止')
|
||||
} catch (error) {
|
||||
console.error('❌ 强制停止失败:', error.message)
|
||||
}
|
||||
this.removePidFile()
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
console.error('❌ 停止服务失败:', error.message)
|
||||
this.removePidFile()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
restart(daemon = false) {
|
||||
console.log('🔄 重启服务...')
|
||||
this.stop()
|
||||
// 等待停止完成
|
||||
setTimeout(() => {
|
||||
this.start(daemon)
|
||||
}, 2000)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
status() {
|
||||
const status = this.getStatus()
|
||||
if (status.running) {
|
||||
console.log(`✅ 服务正在运行 (PID: ${status.pid})`)
|
||||
|
||||
// 显示进程信息
|
||||
exec(`ps -p ${status.pid} -o pid,ppid,pcpu,pmem,etime,cmd --no-headers`, (error, stdout) => {
|
||||
if (!error && stdout.trim()) {
|
||||
console.log('\n📊 进程信息:')
|
||||
console.log('PID\tPPID\tCPU%\tMEM%\tTIME\t\tCOMMAND')
|
||||
console.log(stdout.trim())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
console.log('❌ 服务未运行')
|
||||
}
|
||||
return status.running
|
||||
}
|
||||
|
||||
logs(lines = 50) {
|
||||
console.log(`📖 最近 ${lines} 行日志:\n`)
|
||||
|
||||
exec(`tail -n ${lines} ${LOG_FILE}`, (error, stdout) => {
|
||||
if (error) {
|
||||
console.error('读取日志失败:', error.message)
|
||||
return
|
||||
}
|
||||
console.log(stdout)
|
||||
})
|
||||
}
|
||||
|
||||
help() {
|
||||
console.log(`
|
||||
🔧 Claude Relay Service 进程管理器
|
||||
|
||||
用法: npm run service <command> [options]
|
||||
|
||||
重要提示:
|
||||
如果要传递参数,请在npm run命令中使用 -- 分隔符
|
||||
npm run service <command> -- [options]
|
||||
|
||||
命令:
|
||||
start [-d|--daemon] 启动服务 (-d: 后台运行)
|
||||
stop 停止服务
|
||||
restart [-d|--daemon] 重启服务 (-d: 后台运行)
|
||||
status 查看服务状态
|
||||
logs [lines] 查看日志 (默认50行)
|
||||
help 显示帮助信息
|
||||
|
||||
命令缩写:
|
||||
s, start 启动服务
|
||||
r, restart 重启服务
|
||||
st, status 查看状态
|
||||
l, log, logs 查看日志
|
||||
halt, stop 停止服务
|
||||
h, help 显示帮助
|
||||
|
||||
示例:
|
||||
npm run service start # 前台启动
|
||||
npm run service -- start -d # 后台启动(正确方式)
|
||||
npm run service:start:d # 后台启动(推荐快捷方式)
|
||||
npm run service:daemon # 后台启动(推荐快捷方式)
|
||||
npm run service stop # 停止服务
|
||||
npm run service -- restart -d # 后台重启(正确方式)
|
||||
npm run service:restart:d # 后台重启(推荐快捷方式)
|
||||
npm run service status # 查看状态
|
||||
npm run service logs # 查看日志
|
||||
npm run service -- logs 100 # 查看最近100行日志
|
||||
|
||||
推荐的快捷方式(无需 -- 分隔符):
|
||||
npm run service:start:d # 等同于 npm run service -- start -d
|
||||
npm run service:restart:d # 等同于 npm run service -- restart -d
|
||||
npm run service:daemon # 等同于 npm run service -- start -d
|
||||
|
||||
直接使用脚本(推荐):
|
||||
node scripts/manage.js start -d # 后台启动
|
||||
node scripts/manage.js restart -d # 后台重启
|
||||
node scripts/manage.js status # 查看状态
|
||||
node scripts/manage.js logs 100 # 查看最近100行日志
|
||||
|
||||
文件位置:
|
||||
PID文件: ${PID_FILE}
|
||||
日志文件: ${LOG_FILE}
|
||||
错误日志: ${ERROR_LOG_FILE}
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
// 主程序
|
||||
function main() {
|
||||
const manager = new ServiceManager()
|
||||
const args = process.argv.slice(2)
|
||||
const command = args[0]
|
||||
const isDaemon = args.includes('-d') || args.includes('--daemon')
|
||||
|
||||
switch (command) {
|
||||
case 'start':
|
||||
case 's':
|
||||
manager.start(isDaemon)
|
||||
break
|
||||
case 'stop':
|
||||
case 'halt':
|
||||
manager.stop()
|
||||
break
|
||||
case 'restart':
|
||||
case 'r':
|
||||
manager.restart(isDaemon)
|
||||
break
|
||||
case 'status':
|
||||
case 'st':
|
||||
manager.status()
|
||||
break
|
||||
case 'logs':
|
||||
case 'log':
|
||||
case 'l': {
|
||||
const lines = parseInt(args[1]) || 50
|
||||
manager.logs(lines)
|
||||
break
|
||||
}
|
||||
case 'help':
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'h':
|
||||
manager.help()
|
||||
break
|
||||
default:
|
||||
console.log('❌ 未知命令:', command)
|
||||
manager.help()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
|
||||
module.exports = ServiceManager
|
||||
Executable
+1830
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 数据迁移脚本:为现有 API Key 设置默认有效期
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/migrate-apikey-expiry.js [--days=30] [--dry-run]
|
||||
*
|
||||
* 参数:
|
||||
* --days: 设置默认有效期天数(默认30天)
|
||||
* --dry-run: 仅模拟运行,不实际修改数据
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const logger = require('../src/utils/logger')
|
||||
const readline = require('readline')
|
||||
|
||||
// 解析命令行参数
|
||||
const args = process.argv.slice(2)
|
||||
const params = {}
|
||||
args.forEach((arg) => {
|
||||
const [key, value] = arg.split('=')
|
||||
params[key.replace('--', '')] = value || true
|
||||
})
|
||||
|
||||
const DEFAULT_DAYS = params.days ? parseInt(params.days) : 30
|
||||
const DRY_RUN = params['dry-run'] === true
|
||||
|
||||
// 创建 readline 接口用于用户确认
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
async function askConfirmation(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} (yes/no): `, (answer) => {
|
||||
resolve(answer.toLowerCase() === 'yes' || answer.toLowerCase() === 'y')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function migrateApiKeys() {
|
||||
try {
|
||||
logger.info('🔄 Starting API Key expiry migration...')
|
||||
logger.info(`📅 Default expiry period: ${DEFAULT_DAYS} days`)
|
||||
logger.info(`🔍 Mode: ${DRY_RUN ? 'DRY RUN (no changes will be made)' : 'LIVE RUN'}`)
|
||||
|
||||
// 连接 Redis
|
||||
await redis.connect()
|
||||
logger.success('✅ Connected to Redis')
|
||||
|
||||
// 获取所有 API Keys
|
||||
const apiKeys = await apiKeyService.getAllApiKeysFast()
|
||||
logger.info(`📊 Found ${apiKeys.length} API Keys in total`)
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
total: apiKeys.length,
|
||||
needsMigration: 0,
|
||||
alreadyHasExpiry: 0,
|
||||
migrated: 0,
|
||||
errors: 0
|
||||
}
|
||||
|
||||
// 需要迁移的 Keys
|
||||
const keysToMigrate = []
|
||||
|
||||
// 分析每个 API Key
|
||||
for (const key of apiKeys) {
|
||||
if (!key.expiresAt || key.expiresAt === 'null' || key.expiresAt === '') {
|
||||
keysToMigrate.push(key)
|
||||
stats.needsMigration++
|
||||
logger.info(`📌 API Key "${key.name}" (${key.id}) needs migration`)
|
||||
} else {
|
||||
stats.alreadyHasExpiry++
|
||||
const expiryDate = new Date(key.expiresAt)
|
||||
logger.info(
|
||||
`✓ API Key "${key.name}" (${key.id}) already has expiry: ${expiryDate.toLocaleString()}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (keysToMigrate.length === 0) {
|
||||
logger.success('✨ No API Keys need migration!')
|
||||
return
|
||||
}
|
||||
|
||||
// 显示迁移摘要
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('📋 Migration Summary:')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Total API Keys: ${stats.total}`)
|
||||
console.log(`Already have expiry: ${stats.alreadyHasExpiry}`)
|
||||
console.log(`Need migration: ${stats.needsMigration}`)
|
||||
console.log(`Default expiry: ${DEFAULT_DAYS} days from now`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
// 如果不是 dry run,请求确认
|
||||
if (!DRY_RUN) {
|
||||
const confirmed = await askConfirmation(
|
||||
`⚠️ This will set expiry dates for ${keysToMigrate.length} API Keys. Continue?`
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
logger.warn('❌ Migration cancelled by user')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
const newExpiryDate = new Date()
|
||||
newExpiryDate.setDate(newExpiryDate.getDate() + DEFAULT_DAYS)
|
||||
const newExpiryISO = newExpiryDate.toISOString()
|
||||
|
||||
logger.info(`\n🚀 Starting migration... New expiry date: ${newExpiryDate.toLocaleString()}`)
|
||||
|
||||
// 执行迁移
|
||||
for (const key of keysToMigrate) {
|
||||
try {
|
||||
if (!DRY_RUN) {
|
||||
// 直接更新 Redis 中的数据
|
||||
// 使用 hset 更新单个字段
|
||||
await redis.client.hset(`apikey:${key.id}`, 'expiresAt', newExpiryISO)
|
||||
logger.success(`✅ Migrated: "${key.name}" (${key.id})`)
|
||||
} else {
|
||||
logger.info(`[DRY RUN] Would migrate: "${key.name}" (${key.id})`)
|
||||
}
|
||||
stats.migrated++
|
||||
} catch (error) {
|
||||
logger.error(`❌ Error migrating "${key.name}" (${key.id}):`, error.message)
|
||||
stats.errors++
|
||||
}
|
||||
}
|
||||
|
||||
// 显示最终结果
|
||||
console.log(`\n${'='.repeat(60)}`)
|
||||
console.log('✅ Migration Complete!')
|
||||
console.log('='.repeat(60))
|
||||
console.log(`Successfully migrated: ${stats.migrated}`)
|
||||
console.log(`Errors: ${stats.errors}`)
|
||||
console.log(`New expiry date: ${newExpiryDate.toLocaleString()}`)
|
||||
console.log(`${'='.repeat(60)}\n`)
|
||||
|
||||
if (DRY_RUN) {
|
||||
logger.warn('⚠️ This was a DRY RUN. No actual changes were made.')
|
||||
logger.info('💡 Run without --dry-run flag to apply changes.')
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('💥 Migration failed:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
// 清理
|
||||
rl.close()
|
||||
await redis.disconnect()
|
||||
logger.info('👋 Disconnected from Redis')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示帮助信息
|
||||
if (params.help) {
|
||||
console.log(`
|
||||
API Key Expiry Migration Script
|
||||
|
||||
This script adds expiry dates to existing API Keys that don't have one.
|
||||
|
||||
Usage:
|
||||
node scripts/migrate-apikey-expiry.js [options]
|
||||
|
||||
Options:
|
||||
--days=NUMBER Set default expiry days (default: 30)
|
||||
--dry-run Simulate the migration without making changes
|
||||
--help Show this help message
|
||||
|
||||
Examples:
|
||||
# Set 30-day expiry for all API Keys without expiry
|
||||
node scripts/migrate-apikey-expiry.js
|
||||
|
||||
# Set 90-day expiry
|
||||
node scripts/migrate-apikey-expiry.js --days=90
|
||||
|
||||
# Test run without making changes
|
||||
node scripts/migrate-apikey-expiry.js --dry-run
|
||||
`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// 运行迁移
|
||||
migrateApiKeys().catch((error) => {
|
||||
logger.error('💥 Unexpected error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 历史数据索引迁移脚本
|
||||
* 为现有的 usage 数据建立索引,加速查询
|
||||
*/
|
||||
const Redis = require('ioredis')
|
||||
const config = require('../config/config')
|
||||
|
||||
const redis = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
db: config.redis.db || 0
|
||||
})
|
||||
|
||||
async function migrate() {
|
||||
console.log('开始迁移历史数据索引...')
|
||||
console.log('Redis DB:', config.redis.db || 0)
|
||||
|
||||
const stats = {
|
||||
dailyIndex: 0,
|
||||
hourlyIndex: 0,
|
||||
modelDailyIndex: 0,
|
||||
modelHourlyIndex: 0
|
||||
}
|
||||
|
||||
// 1. 迁移 usage:daily:{keyId}:{date} 索引
|
||||
console.log('\n1. 迁移 usage:daily 索引...')
|
||||
let cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:daily:{keyId}:{date}
|
||||
const match = key.match(/^usage:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, date] = match
|
||||
pipeline.sadd(`usage:daily:index:${date}`, keyId)
|
||||
pipeline.expire(`usage:daily:index:${date}`, 86400 * 32)
|
||||
stats.dailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.dailyIndex} 条`)
|
||||
|
||||
// 2. 迁移 usage:hourly:{keyId}:{date}:{hour} 索引
|
||||
console.log('\n2. 迁移 usage:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:hourly:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:hourly:{keyId}:{date}:{hour}
|
||||
const match = key.match(/^usage:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, keyId, hourKey] = match
|
||||
pipeline.sadd(`usage:hourly:index:${hourKey}`, keyId)
|
||||
pipeline.expire(`usage:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.hourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.hourlyIndex} 条`)
|
||||
|
||||
// 3. 迁移 usage:model:daily:{model}:{date} 索引
|
||||
console.log('\n3. 迁移 usage:model:daily 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(cursor, 'MATCH', 'usage:model:daily:*', 'COUNT', 500)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:daily:{model}:{date}
|
||||
const match = key.match(/^usage:model:daily:([^:]+):(\d{4}-\d{2}-\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, date] = match
|
||||
pipeline.sadd(`usage:model:daily:index:${date}`, model)
|
||||
pipeline.expire(`usage:model:daily:index:${date}`, 86400 * 32)
|
||||
stats.modelDailyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelDailyIndex} 条`)
|
||||
|
||||
// 4. 迁移 usage:model:hourly:{model}:{date}:{hour} 索引
|
||||
console.log('\n4. 迁移 usage:model:hourly 索引...')
|
||||
cursor = '0'
|
||||
do {
|
||||
const [newCursor, keys] = await redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
'usage:model:hourly:*',
|
||||
'COUNT',
|
||||
500
|
||||
)
|
||||
cursor = newCursor
|
||||
|
||||
const pipeline = redis.pipeline()
|
||||
for (const key of keys) {
|
||||
// usage:model:hourly:{model}:{date}:{hour}
|
||||
const match = key.match(/^usage:model:hourly:([^:]+):(\d{4}-\d{2}-\d{2}:\d{2})$/)
|
||||
if (match) {
|
||||
const [, model, hourKey] = match
|
||||
pipeline.sadd(`usage:model:hourly:index:${hourKey}`, model)
|
||||
pipeline.expire(`usage:model:hourly:index:${hourKey}`, 86400 * 7)
|
||||
stats.modelHourlyIndex++
|
||||
}
|
||||
}
|
||||
if (keys.length > 0) {
|
||||
await pipeline.exec()
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
console.log(` 已处理 ${stats.modelHourlyIndex} 条`)
|
||||
|
||||
console.log('\n迁移完成!')
|
||||
console.log('统计:', stats)
|
||||
|
||||
redis.disconnect()
|
||||
}
|
||||
|
||||
migrate().catch((err) => {
|
||||
console.error('迁移失败:', err)
|
||||
redis.disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
Executable
+273
@@ -0,0 +1,273 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Claude Relay Service - 增强版实时监控脚本
|
||||
# 结合并发监控和系统状态的完整监控方案
|
||||
|
||||
# 加载环境变量
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
echo "🔍 Claude Relay Service - 增强版实时监控"
|
||||
echo "按 Ctrl+C 退出 | 按 's' 切换详细/简单模式"
|
||||
echo "========================================"
|
||||
|
||||
# 获取服务配置
|
||||
SERVICE_HOST=${HOST:-127.0.0.1}
|
||||
SERVICE_PORT=${PORT:-3000}
|
||||
|
||||
# 如果HOST是0.0.0.0,客户端应该连接localhost
|
||||
if [ "$SERVICE_HOST" = "0.0.0.0" ]; then
|
||||
SERVICE_HOST="127.0.0.1"
|
||||
fi
|
||||
|
||||
SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}"
|
||||
|
||||
# 获取Redis配置
|
||||
REDIS_HOST=${REDIS_HOST:-127.0.0.1}
|
||||
REDIS_PORT=${REDIS_PORT:-6379}
|
||||
REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||
|
||||
if [ ! -z "$REDIS_PASSWORD" ]; then
|
||||
REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD"
|
||||
fi
|
||||
|
||||
# 检查Redis连接
|
||||
if ! $REDIS_CMD ping > /dev/null 2>&1; then
|
||||
echo "❌ Redis连接失败,请检查Redis服务是否运行"
|
||||
echo " 配置: $REDIS_HOST:$REDIS_PORT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 显示模式: simple(简单) / detailed(详细)
|
||||
DISPLAY_MODE="simple"
|
||||
|
||||
# 获取API Key详细信息
|
||||
get_api_key_info() {
|
||||
local api_key_id=$1
|
||||
local api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
|
||||
local concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null)
|
||||
local token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null)
|
||||
local created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null)
|
||||
|
||||
if [ -z "$api_key_name" ]; then
|
||||
api_key_name="Unknown"
|
||||
fi
|
||||
|
||||
if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then
|
||||
concurrency_limit="无限制"
|
||||
fi
|
||||
|
||||
if [ -z "$token_limit" ] || [ "$token_limit" = "0" ]; then
|
||||
token_limit="无限制"
|
||||
else
|
||||
token_limit=$(printf "%'d" $token_limit)
|
||||
fi
|
||||
|
||||
echo "$api_key_name|$concurrency_limit|$token_limit|$created_at"
|
||||
}
|
||||
|
||||
# 获取使用统计信息
|
||||
get_usage_stats() {
|
||||
local api_key_id=$1
|
||||
local today=$(date '+%Y-%m-%d')
|
||||
local current_month=$(date '+%Y-%m')
|
||||
|
||||
# 获取总体使用量
|
||||
local total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null)
|
||||
local total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null)
|
||||
|
||||
# 获取今日使用量
|
||||
local daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null)
|
||||
local daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null)
|
||||
|
||||
total_requests=${total_requests:-0}
|
||||
total_tokens=${total_tokens:-0}
|
||||
daily_requests=${daily_requests:-0}
|
||||
daily_tokens=${daily_tokens:-0}
|
||||
|
||||
echo "$total_requests|$total_tokens|$daily_requests|$daily_tokens"
|
||||
}
|
||||
|
||||
# 格式化数字
|
||||
format_number() {
|
||||
local num=$1
|
||||
if [ "$num" -ge 1000000 ]; then
|
||||
echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M"
|
||||
elif [ "$num" -ge 1000 ]; then
|
||||
echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K"
|
||||
else
|
||||
echo "$num"
|
||||
fi
|
||||
}
|
||||
|
||||
# 获取系统信息
|
||||
get_system_info() {
|
||||
# Redis信息
|
||||
local redis_info=$($REDIS_CMD info server 2>/dev/null)
|
||||
local redis_memory_info=$($REDIS_CMD info memory 2>/dev/null)
|
||||
|
||||
local redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
local redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
local used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
|
||||
local redis_uptime_hours=0
|
||||
if [ ! -z "$redis_uptime" ]; then
|
||||
redis_uptime_hours=$((redis_uptime / 3600))
|
||||
fi
|
||||
|
||||
# 服务状态
|
||||
local service_status="unknown"
|
||||
local service_uptime="0"
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
local health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
|
||||
if [ $? -eq 0 ]; then
|
||||
service_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1)
|
||||
service_uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
local service_uptime_hours="0"
|
||||
if [ ! -z "$service_uptime" ] && [ "$service_uptime" != "null" ]; then
|
||||
service_uptime_hours=$(echo "scale=1; $service_uptime / 3600" | bc 2>/dev/null)
|
||||
fi
|
||||
|
||||
echo "$redis_version|$redis_uptime_hours|$used_memory|$service_status|$service_uptime_hours"
|
||||
}
|
||||
|
||||
# 主监控函数
|
||||
monitor_enhanced() {
|
||||
while true; do
|
||||
clear
|
||||
echo "🔍 Claude Relay Service - 增强版实时监控 | $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "模式: $DISPLAY_MODE | 服务: $SERVICE_URL | Redis: $REDIS_HOST:$REDIS_PORT"
|
||||
echo "========================================"
|
||||
|
||||
# 获取系统信息
|
||||
local system_info=$(get_system_info)
|
||||
local redis_version=$(echo "$system_info" | cut -d'|' -f1)
|
||||
local redis_uptime=$(echo "$system_info" | cut -d'|' -f2)
|
||||
local redis_memory=$(echo "$system_info" | cut -d'|' -f3)
|
||||
local service_status=$(echo "$system_info" | cut -d'|' -f4)
|
||||
local service_uptime=$(echo "$system_info" | cut -d'|' -f5)
|
||||
|
||||
# 系统状态概览
|
||||
echo "🏥 系统状态概览:"
|
||||
if [ "$service_status" = "healthy" ]; then
|
||||
echo " ✅ 服务: 健康 (运行 ${service_uptime}h)"
|
||||
else
|
||||
echo " ⚠️ 服务: 异常 ($service_status)"
|
||||
fi
|
||||
echo " 📊 Redis: v${redis_version} (运行 ${redis_uptime}h, 内存 ${redis_memory})"
|
||||
echo ""
|
||||
|
||||
# 获取并发信息
|
||||
local concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null)
|
||||
local total_concurrent=0
|
||||
local active_keys=0
|
||||
local concurrent_details=""
|
||||
|
||||
if [ ! -z "$concurrency_keys" ]; then
|
||||
for key in $concurrency_keys; do
|
||||
local count=$($REDIS_CMD get "$key" 2>/dev/null)
|
||||
if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then
|
||||
local api_key_id=${key#concurrency:}
|
||||
local key_info=$(get_api_key_info "$api_key_id")
|
||||
local key_name=$(echo "$key_info" | cut -d'|' -f1)
|
||||
local concurrency_limit=$(echo "$key_info" | cut -d'|' -f2)
|
||||
|
||||
concurrent_details="${concurrent_details}${key_name}:${count}/${concurrency_limit} "
|
||||
total_concurrent=$((total_concurrent + count))
|
||||
active_keys=$((active_keys + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# 并发状态显示
|
||||
echo "📊 当前并发状态:"
|
||||
if [ $total_concurrent -eq 0 ]; then
|
||||
echo " 💤 无活跃并发连接"
|
||||
else
|
||||
echo " 🔥 总并发: $total_concurrent 个连接 ($active_keys 个API Key)"
|
||||
if [ "$DISPLAY_MODE" = "detailed" ]; then
|
||||
echo " 📋 详情: $concurrent_details"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# API Key统计
|
||||
local total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l)
|
||||
local total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l)
|
||||
|
||||
echo "📋 资源统计:"
|
||||
echo " 🔑 API Keys: $total_keys 个"
|
||||
echo " 🏢 Claude账户: $total_accounts 个"
|
||||
|
||||
# 详细模式显示更多信息
|
||||
if [ "$DISPLAY_MODE" = "detailed" ]; then
|
||||
echo ""
|
||||
echo "📈 使用统计 (今日/总计):"
|
||||
|
||||
# 获取所有API Key
|
||||
local api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map")
|
||||
local total_daily_requests=0
|
||||
local total_daily_tokens=0
|
||||
local total_requests=0
|
||||
local total_tokens=0
|
||||
|
||||
if [ ! -z "$api_keys" ]; then
|
||||
for key in $api_keys; do
|
||||
local api_key_id=${key#apikey:}
|
||||
local key_info=$(get_api_key_info "$api_key_id")
|
||||
local key_name=$(echo "$key_info" | cut -d'|' -f1)
|
||||
local usage_info=$(get_usage_stats "$api_key_id")
|
||||
|
||||
local key_total_requests=$(echo "$usage_info" | cut -d'|' -f1)
|
||||
local key_total_tokens=$(echo "$usage_info" | cut -d'|' -f2)
|
||||
local key_daily_requests=$(echo "$usage_info" | cut -d'|' -f3)
|
||||
local key_daily_tokens=$(echo "$usage_info" | cut -d'|' -f4)
|
||||
|
||||
total_daily_requests=$((total_daily_requests + key_daily_requests))
|
||||
total_daily_tokens=$((total_daily_tokens + key_daily_tokens))
|
||||
total_requests=$((total_requests + key_total_requests))
|
||||
total_tokens=$((total_tokens + key_total_tokens))
|
||||
|
||||
if [ $((key_daily_requests + key_total_requests)) -gt 0 ]; then
|
||||
echo " 📱 $key_name: ${key_daily_requests}req/$(format_number $key_daily_tokens) | ${key_total_requests}req/$(format_number $key_total_tokens)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
echo " 🌍 系统总计: ${total_daily_requests}req/$(format_number $total_daily_tokens) | ${total_requests}req/$(format_number $total_tokens)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🔄 刷新间隔: 5秒 | 按 Ctrl+C 退出 | 按 Enter 切换详细/简单模式"
|
||||
|
||||
# 非阻塞读取用户输入
|
||||
read -t 5 user_input
|
||||
if [ $? -eq 0 ]; then
|
||||
case "$user_input" in
|
||||
"s"|"S"|"")
|
||||
if [ "$DISPLAY_MODE" = "simple" ]; then
|
||||
DISPLAY_MODE="detailed"
|
||||
else
|
||||
DISPLAY_MODE="simple"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# 信号处理
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "👋 监控已停止"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# 开始监控
|
||||
monitor_enhanced
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
let dotenvLoaded = false
|
||||
try {
|
||||
require('dotenv').config()
|
||||
dotenvLoaded = true
|
||||
} catch (_error) {
|
||||
dotenvLoaded = false
|
||||
}
|
||||
|
||||
const Redis = require('ioredis')
|
||||
|
||||
const CONFIG_KEY = 'claude_relay_config'
|
||||
const REQUEST_DETAIL_KEY_PATTERN = 'request_detail:*'
|
||||
const DEFAULT_RETENTION_HOURS = 6
|
||||
const MAX_RETENTION_HOURS = 720
|
||||
const SCAN_COUNT = 200
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const params = {}
|
||||
args.forEach((arg) => {
|
||||
const [key, value] = arg.split('=')
|
||||
params[key.replace(/^--/, '')] = value ?? true
|
||||
})
|
||||
|
||||
const isDryRun = params['dry-run'] === true
|
||||
const requestedHours = Number.parseInt(params.hours, 10)
|
||||
const targetHours = Number.isFinite(requestedHours) ? requestedHours : DEFAULT_RETENTION_HOURS
|
||||
|
||||
if (!Number.isInteger(targetHours) || targetHours < 1 || targetHours > MAX_RETENTION_HOURS) {
|
||||
console.error(
|
||||
`requestDetailRetentionHours must be an integer between 1 and ${MAX_RETENTION_HOURS}`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function scanRequestDetailKeys(client) {
|
||||
let cursor = '0'
|
||||
const keys = []
|
||||
|
||||
do {
|
||||
const [nextCursor, batch] = await client.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
REQUEST_DETAIL_KEY_PATTERN,
|
||||
'COUNT',
|
||||
SCAN_COUNT
|
||||
)
|
||||
cursor = nextCursor
|
||||
if (Array.isArray(batch) && batch.length > 0) {
|
||||
keys.push(...batch)
|
||||
}
|
||||
} while (cursor !== '0')
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
async function resetRequestDetailRetentionHours() {
|
||||
let client = null
|
||||
|
||||
try {
|
||||
console.log('🔄 Resetting request detail retention configuration...')
|
||||
console.log(`🕒 Target request detail retention: ${targetHours} hour(s)`)
|
||||
if (isDryRun) {
|
||||
console.log('📝 DRY RUN mode enabled; no data will be modified')
|
||||
}
|
||||
|
||||
if (dotenvLoaded) {
|
||||
console.log('📄 Loaded .env configuration')
|
||||
}
|
||||
|
||||
client = new Redis({
|
||||
host: process.env.REDIS_HOST || '127.0.0.1',
|
||||
port: Number.parseInt(process.env.REDIS_PORT, 10) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: Number.parseInt(process.env.REDIS_DB, 10) || 0,
|
||||
tls: process.env.REDIS_ENABLE_TLS === 'true' || process.env.REDIS_TLS === 'true' ? {} : false,
|
||||
lazyConnect: true
|
||||
})
|
||||
await client.connect()
|
||||
|
||||
const rawConfig = await client.get(CONFIG_KEY)
|
||||
const currentConfig = rawConfig ? JSON.parse(rawConfig) : {}
|
||||
const requestDetailKeys = await scanRequestDetailKeys(client)
|
||||
|
||||
console.log(`📦 Found ${requestDetailKeys.length} request detail Redis key(s)`)
|
||||
console.log(
|
||||
`⚙️ Current config: requestDetailRetentionDays=${currentConfig.requestDetailRetentionDays ?? 'unset'}, requestDetailRetentionHours=${currentConfig.requestDetailRetentionHours ?? 'unset'}`
|
||||
)
|
||||
|
||||
const nextConfig = {
|
||||
...currentConfig,
|
||||
requestDetailRetentionHours: targetHours,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedBy: 'request-detail-retention-hours-reset-script'
|
||||
}
|
||||
delete nextConfig.requestDetailRetentionDays
|
||||
|
||||
if (!isDryRun) {
|
||||
if (requestDetailKeys.length > 0) {
|
||||
for (let index = 0; index < requestDetailKeys.length; index += SCAN_COUNT) {
|
||||
const batch = requestDetailKeys.slice(index, index + SCAN_COUNT)
|
||||
// Use UNLINK to avoid blocking Redis with a large DEL.
|
||||
await client.unlink(...batch)
|
||||
}
|
||||
}
|
||||
|
||||
await client.set(CONFIG_KEY, JSON.stringify(nextConfig))
|
||||
}
|
||||
|
||||
console.log(
|
||||
`${isDryRun ? '📝 Would delete' : '🧹 Deleted'} ${requestDetailKeys.length} request detail key(s)`
|
||||
)
|
||||
console.log(
|
||||
`${isDryRun ? '📝 Would write' : '✅ Wrote'} requestDetailRetentionHours=${targetHours} and removed requestDetailRetentionDays`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to reset request detail retention configuration:', error)
|
||||
process.exitCode = 1
|
||||
} finally {
|
||||
if (client) {
|
||||
await client.quit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetRequestDetailRetentionHours()
|
||||
@@ -0,0 +1,128 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('crypto')
|
||||
const chalk = require('chalk')
|
||||
const ora = require('ora')
|
||||
|
||||
const config = require('../config/config')
|
||||
|
||||
async function setup() {
|
||||
console.log(chalk.blue.bold('\n🚀 Claude Relay Service 初始化设置\n'))
|
||||
|
||||
const spinner = ora('正在进行初始化设置...').start()
|
||||
|
||||
try {
|
||||
// 1. 创建必要目录
|
||||
const directories = ['logs', 'data', 'temp']
|
||||
|
||||
directories.forEach((dir) => {
|
||||
const dirPath = path.join(__dirname, '..', dir)
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true })
|
||||
}
|
||||
})
|
||||
|
||||
// 2. 生成环境配置文件
|
||||
if (!fs.existsSync(path.join(__dirname, '..', '.env'))) {
|
||||
const envTemplate = fs.readFileSync(path.join(__dirname, '..', '.env.example'), 'utf8')
|
||||
|
||||
// 生成随机密钥
|
||||
const jwtSecret = crypto.randomBytes(64).toString('hex')
|
||||
const encryptionKey = crypto.randomBytes(32).toString('hex')
|
||||
|
||||
const envContent = envTemplate
|
||||
.replace('your-jwt-secret-here', jwtSecret)
|
||||
.replace('your-encryption-key-here', encryptionKey)
|
||||
|
||||
fs.writeFileSync(path.join(__dirname, '..', '.env'), envContent)
|
||||
}
|
||||
|
||||
// 3. 生成或使用环境变量中的管理员凭据
|
||||
const adminUsername =
|
||||
process.env.ADMIN_USERNAME || `cr_admin_${crypto.randomBytes(4).toString('hex')}`
|
||||
const adminPassword =
|
||||
process.env.ADMIN_PASSWORD ||
|
||||
crypto
|
||||
.randomBytes(16)
|
||||
.toString('base64')
|
||||
.replace(/[^a-zA-Z0-9]/g, '')
|
||||
.substring(0, 16)
|
||||
|
||||
// 如果使用了环境变量,显示提示
|
||||
if (process.env.ADMIN_USERNAME || process.env.ADMIN_PASSWORD) {
|
||||
console.log(chalk.yellow('\n📌 使用环境变量中的管理员凭据'))
|
||||
}
|
||||
|
||||
// 4. 创建初始化完成标记文件
|
||||
const initData = {
|
||||
initializedAt: new Date().toISOString(),
|
||||
adminUsername,
|
||||
adminPassword,
|
||||
version: '1.0.0'
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(__dirname, '..', 'data', 'init.json'),
|
||||
JSON.stringify(initData, null, 2)
|
||||
)
|
||||
|
||||
spinner.succeed('初始化设置完成')
|
||||
|
||||
console.log(chalk.green('\n✅ 设置完成!\n'))
|
||||
console.log(chalk.yellow('📋 重要信息:\n'))
|
||||
console.log(` 管理员用户名: ${chalk.cyan(adminUsername)}`)
|
||||
console.log(` 管理员密码: ${chalk.cyan(adminPassword)}`)
|
||||
|
||||
// 如果是自动生成的凭据,强调需要保存
|
||||
if (!process.env.ADMIN_USERNAME && !process.env.ADMIN_PASSWORD) {
|
||||
console.log(chalk.red('\n⚠️ 请立即保存这些凭据!首次登录后建议修改密码。'))
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'\n💡 提示: 也可以通过环境变量 ADMIN_USERNAME 和 ADMIN_PASSWORD 预设管理员凭据。\n'
|
||||
)
|
||||
)
|
||||
} else {
|
||||
console.log(chalk.green('\n✅ 已使用预设的管理员凭据。\n'))
|
||||
}
|
||||
|
||||
console.log(chalk.blue('🚀 启动服务:\n'))
|
||||
console.log(' npm start - 启动生产服务')
|
||||
console.log(' npm run dev - 启动开发服务')
|
||||
console.log(' npm run cli admin - 管理员CLI工具\n')
|
||||
|
||||
console.log(chalk.blue('🌐 访问地址:\n'))
|
||||
console.log(` Web管理界面: http://localhost:${config.server.port}/web`)
|
||||
console.log(` API端点: http://localhost:${config.server.port}/api/v1/messages`)
|
||||
console.log(` 健康检查: http://localhost:${config.server.port}/health\n`)
|
||||
} catch (error) {
|
||||
spinner.fail('初始化设置失败')
|
||||
console.error(chalk.red('❌ 错误:'), error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已初始化
|
||||
function checkInitialized() {
|
||||
const initFile = path.join(__dirname, '..', 'data', 'init.json')
|
||||
if (fs.existsSync(initFile)) {
|
||||
const initData = JSON.parse(fs.readFileSync(initFile, 'utf8'))
|
||||
console.log(chalk.yellow('⚠️ 服务已经初始化过了!'))
|
||||
console.log(` 初始化时间: ${new Date(initData.initializedAt).toLocaleString()}`)
|
||||
console.log(` 管理员用户名: ${initData.adminUsername}`)
|
||||
console.log('\n如需重新初始化,请删除 data/init.json 文件后再运行此命令。')
|
||||
console.log(chalk.red('\n⚠️ 重要提示:'))
|
||||
console.log(' 1. 删除 init.json 文件后运行 npm run setup')
|
||||
console.log(' 2. 生成新的账号密码后,需要重启服务才能生效')
|
||||
console.log(' 3. 使用 npm run service:restart 重启服务\n')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
if (!checkInitialized()) {
|
||||
setup()
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { setup, checkInitialized }
|
||||
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Claude Relay Service - 统一状态检查脚本
|
||||
# 提供完整的系统状态概览
|
||||
|
||||
# 加载环境变量
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
fi
|
||||
|
||||
# 参数处理
|
||||
DETAIL_MODE=false
|
||||
if [ "$1" = "--detail" ] || [ "$1" = "-d" ]; then
|
||||
DETAIL_MODE=true
|
||||
fi
|
||||
|
||||
echo "🔍 Claude Relay Service - 系统状态检查"
|
||||
if [ "$DETAIL_MODE" = true ]; then
|
||||
echo "模式: 详细信息"
|
||||
else
|
||||
echo "模式: 概览 (使用 --detail 查看详细信息)"
|
||||
fi
|
||||
echo "========================================"
|
||||
|
||||
# 获取服务配置
|
||||
SERVICE_HOST=${HOST:-127.0.0.1}
|
||||
SERVICE_PORT=${PORT:-3000}
|
||||
|
||||
if [ "$SERVICE_HOST" = "0.0.0.0" ]; then
|
||||
SERVICE_HOST="127.0.0.1"
|
||||
fi
|
||||
|
||||
SERVICE_URL="http://${SERVICE_HOST}:${SERVICE_PORT}"
|
||||
|
||||
# 获取Redis配置
|
||||
REDIS_HOST=${REDIS_HOST:-127.0.0.1}
|
||||
REDIS_PORT=${REDIS_PORT:-6379}
|
||||
REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT"
|
||||
|
||||
if [ ! -z "$REDIS_PASSWORD" ]; then
|
||||
REDIS_CMD="redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASSWORD"
|
||||
fi
|
||||
|
||||
# 检查Redis连接
|
||||
echo "🔍 连接检查:"
|
||||
if $REDIS_CMD ping > /dev/null 2>&1; then
|
||||
echo " ✅ Redis连接正常 ($REDIS_HOST:$REDIS_PORT)"
|
||||
else
|
||||
echo " ❌ Redis连接失败 ($REDIS_HOST:$REDIS_PORT)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 检查服务状态
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
|
||||
if [ $? -eq 0 ]; then
|
||||
health_status=$(echo "$health_response" | grep -o '"status":"[^"]*"' | cut -d'"' -f4 | head -1)
|
||||
if [ "$health_status" = "healthy" ]; then
|
||||
echo " ✅ 服务状态正常 ($SERVICE_URL)"
|
||||
else
|
||||
echo " ⚠️ 服务状态异常: $health_status ($SERVICE_URL)"
|
||||
fi
|
||||
else
|
||||
echo " ❌ 服务无法访问 ($SERVICE_URL)"
|
||||
fi
|
||||
else
|
||||
echo " ⚠️ curl命令不可用,无法检查服务状态"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 格式化数字函数
|
||||
format_number() {
|
||||
local num=$1
|
||||
if [ "$num" -ge 1000000 ]; then
|
||||
echo "$(echo "scale=1; $num / 1000000" | bc 2>/dev/null)M"
|
||||
elif [ "$num" -ge 1000 ]; then
|
||||
echo "$(echo "scale=1; $num / 1000" | bc 2>/dev/null)K"
|
||||
else
|
||||
echo "$num"
|
||||
fi
|
||||
}
|
||||
|
||||
# 系统信息
|
||||
echo "🏥 系统信息:"
|
||||
|
||||
# Redis信息
|
||||
redis_info=$($REDIS_CMD info server 2>/dev/null)
|
||||
redis_memory_info=$($REDIS_CMD info memory 2>/dev/null)
|
||||
|
||||
redis_version=$(echo "$redis_info" | grep redis_version | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
redis_uptime=$(echo "$redis_info" | grep uptime_in_seconds | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
used_memory=$(echo "$redis_memory_info" | grep used_memory_human | cut -d: -f2 | tr -d '\r' 2>/dev/null)
|
||||
|
||||
if [ ! -z "$redis_version" ]; then
|
||||
echo " 📊 Redis版本: $redis_version"
|
||||
fi
|
||||
|
||||
if [ ! -z "$redis_uptime" ]; then
|
||||
uptime_hours=$((redis_uptime / 3600))
|
||||
echo " ⏱️ Redis运行时间: $uptime_hours 小时"
|
||||
fi
|
||||
|
||||
if [ ! -z "$used_memory" ]; then
|
||||
echo " 💾 Redis内存使用: $used_memory"
|
||||
fi
|
||||
|
||||
# 服务信息
|
||||
if command -v curl > /dev/null 2>&1; then
|
||||
health_response=$(curl -s ${SERVICE_URL}/health 2>/dev/null)
|
||||
if [ $? -eq 0 ]; then
|
||||
uptime=$(echo "$health_response" | grep -o '"uptime":[^,}]*' | cut -d: -f2 | head -1)
|
||||
|
||||
if [ ! -z "$uptime" ] && [ "$uptime" != "null" ]; then
|
||||
uptime_hours=$(echo "scale=1; $uptime / 3600" | bc 2>/dev/null)
|
||||
if [ ! -z "$uptime_hours" ]; then
|
||||
echo " ⏰ 服务运行时间: $uptime_hours 小时"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 检查端口
|
||||
if netstat -ln 2>/dev/null | grep -q ":${SERVICE_PORT} "; then
|
||||
echo " 🔌 端口${SERVICE_PORT}: 正在监听"
|
||||
else
|
||||
echo " ❌ 端口${SERVICE_PORT}: 未监听"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 并发状态
|
||||
echo "📊 并发状态:"
|
||||
concurrency_keys=$($REDIS_CMD --scan --pattern "concurrency:*" 2>/dev/null)
|
||||
|
||||
if [ -z "$concurrency_keys" ]; then
|
||||
echo " 💤 当前无活跃并发连接"
|
||||
else
|
||||
total_concurrent=0
|
||||
active_keys=0
|
||||
|
||||
for key in $concurrency_keys; do
|
||||
count=$($REDIS_CMD get "$key" 2>/dev/null)
|
||||
if [ ! -z "$count" ] && [ "$count" -gt 0 ]; then
|
||||
api_key_id=${key#concurrency:}
|
||||
|
||||
if [ "$DETAIL_MODE" = true ]; then
|
||||
api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
|
||||
concurrency_limit=$($REDIS_CMD hget "apikey:$api_key_id" concurrencyLimit 2>/dev/null)
|
||||
|
||||
if [ -z "$api_key_name" ]; then
|
||||
api_key_name="Unknown"
|
||||
fi
|
||||
|
||||
if [ -z "$concurrency_limit" ] || [ "$concurrency_limit" = "0" ]; then
|
||||
limit_text="无限制"
|
||||
else
|
||||
limit_text="$concurrency_limit"
|
||||
fi
|
||||
|
||||
echo " 🔑 $api_key_name: $count 个并发 (限制: $limit_text)"
|
||||
fi
|
||||
|
||||
total_concurrent=$((total_concurrent + count))
|
||||
active_keys=$((active_keys + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo " 📈 总计: $total_concurrent 个活跃并发连接 ($active_keys 个API Key)"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# 资源统计
|
||||
echo "📋 资源统计:"
|
||||
|
||||
total_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map" | wc -l)
|
||||
total_accounts=$($REDIS_CMD keys "claude:account:*" 2>/dev/null | wc -l)
|
||||
|
||||
echo " 🔑 API Key总数: $total_keys"
|
||||
echo " 🏢 Claude账户数: $total_accounts"
|
||||
|
||||
# 详细模式下的使用统计
|
||||
if [ "$DETAIL_MODE" = true ]; then
|
||||
echo ""
|
||||
echo "📈 使用统计:"
|
||||
|
||||
today=$(date '+%Y-%m-%d')
|
||||
current_month=$(date '+%Y-%m')
|
||||
|
||||
# 系统总体统计
|
||||
total_daily_requests=0
|
||||
total_daily_tokens=0
|
||||
total_requests=0
|
||||
total_tokens=0
|
||||
|
||||
api_keys=$($REDIS_CMD keys "apikey:*" 2>/dev/null | grep -v "apikey:hash_map")
|
||||
|
||||
if [ ! -z "$api_keys" ]; then
|
||||
echo " 📱 API Key详情:"
|
||||
|
||||
for key in $api_keys; do
|
||||
api_key_id=${key#apikey:}
|
||||
|
||||
# API Key基本信息
|
||||
api_key_name=$($REDIS_CMD hget "apikey:$api_key_id" name 2>/dev/null)
|
||||
token_limit=$($REDIS_CMD hget "apikey:$api_key_id" tokenLimit 2>/dev/null)
|
||||
created_at=$($REDIS_CMD hget "apikey:$api_key_id" createdAt 2>/dev/null)
|
||||
|
||||
# 使用统计
|
||||
key_total_requests=$($REDIS_CMD hget "usage:$api_key_id" totalRequests 2>/dev/null)
|
||||
key_total_tokens=$($REDIS_CMD hget "usage:$api_key_id" totalTokens 2>/dev/null)
|
||||
key_daily_requests=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" requests 2>/dev/null)
|
||||
key_daily_tokens=$($REDIS_CMD hget "usage:daily:$api_key_id:$today" tokens 2>/dev/null)
|
||||
|
||||
# 默认值处理
|
||||
api_key_name=${api_key_name:-"Unknown"}
|
||||
token_limit=${token_limit:-0}
|
||||
key_total_requests=${key_total_requests:-0}
|
||||
key_total_tokens=${key_total_tokens:-0}
|
||||
key_daily_requests=${key_daily_requests:-0}
|
||||
key_daily_tokens=${key_daily_tokens:-0}
|
||||
|
||||
# 格式化Token限制
|
||||
if [ "$token_limit" = "0" ]; then
|
||||
limit_text="无限制"
|
||||
else
|
||||
limit_text=$(format_number $token_limit)
|
||||
fi
|
||||
|
||||
# 创建时间格式化
|
||||
if [ ! -z "$created_at" ]; then
|
||||
created_date=$(echo "$created_at" | cut -d'T' -f1)
|
||||
else
|
||||
created_date="未知"
|
||||
fi
|
||||
|
||||
echo " • $api_key_name (创建: $created_date, 限制: $limit_text)"
|
||||
echo " 今日: ${key_daily_requests}请求 / $(format_number $key_daily_tokens)tokens"
|
||||
echo " 总计: ${key_total_requests}请求 / $(format_number $key_total_tokens)tokens"
|
||||
echo ""
|
||||
|
||||
# 累计统计
|
||||
total_daily_requests=$((total_daily_requests + key_daily_requests))
|
||||
total_daily_tokens=$((total_daily_tokens + key_daily_tokens))
|
||||
total_requests=$((total_requests + key_total_requests))
|
||||
total_tokens=$((total_tokens + key_total_tokens))
|
||||
done
|
||||
fi
|
||||
|
||||
echo " 🌍 系统总计:"
|
||||
echo " 今日: ${total_daily_requests}请求 / $(format_number $total_daily_tokens)tokens"
|
||||
echo " 总计: ${total_requests}请求 / $(format_number $total_tokens)tokens"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ 状态检查完成 - $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
if [ "$DETAIL_MODE" = false ]; then
|
||||
echo ""
|
||||
echo "💡 使用 'npm run status -- --detail' 查看详细信息"
|
||||
fi
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 测试账号显示问题是否已修复
|
||||
*/
|
||||
|
||||
const axios = require('axios')
|
||||
const config = require('../config/config')
|
||||
|
||||
// 从 init.json 读取管理员凭据
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
async function testAccountDisplay() {
|
||||
console.log('🔍 测试账号显示问题...\n')
|
||||
|
||||
try {
|
||||
// 读取管理员凭据
|
||||
const initPath = path.join(__dirname, '..', 'config', 'init.json')
|
||||
if (!fs.existsSync(initPath)) {
|
||||
console.error('❌ 找不到 init.json 文件,请运行 npm run setup')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const initData = JSON.parse(fs.readFileSync(initPath, 'utf8'))
|
||||
const adminUser = initData.admins?.[0]
|
||||
if (!adminUser) {
|
||||
console.error('❌ 没有找到管理员账号')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const baseURL = `http://localhost:${config.server.port}`
|
||||
|
||||
// 登录获取 token
|
||||
console.log('🔐 登录管理员账号...')
|
||||
const loginResp = await axios.post(`${baseURL}/admin/login`, {
|
||||
username: adminUser.username,
|
||||
password: adminUser.password
|
||||
})
|
||||
|
||||
if (!loginResp.data.success) {
|
||||
console.error('❌ 登录失败')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const { token } = loginResp.data
|
||||
console.log('✅ 登录成功\n')
|
||||
|
||||
// 设置请求头
|
||||
const headers = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
// 获取 Claude OAuth 账号
|
||||
console.log('📋 获取 Claude OAuth 账号...')
|
||||
const claudeResp = await axios.get(`${baseURL}/admin/claude-accounts`, { headers })
|
||||
const claudeAccounts = claudeResp.data.data || []
|
||||
|
||||
console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`)
|
||||
|
||||
// 分类显示
|
||||
const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated')
|
||||
const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group')
|
||||
const claudeShared = claudeAccounts.filter((a) => a.accountType === 'shared')
|
||||
|
||||
console.log(`- 专属账号: ${claudeDedicated.length} 个`)
|
||||
console.log(`- 分组账号: ${claudeGroup.length} 个`)
|
||||
console.log(`- 共享账号: ${claudeShared.length} 个`)
|
||||
|
||||
// 检查 platform 字段
|
||||
console.log('\n检查 platform 字段:')
|
||||
claudeAccounts.slice(0, 3).forEach((acc) => {
|
||||
console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`)
|
||||
})
|
||||
|
||||
// 获取 Claude Console 账号
|
||||
console.log('\n📋 获取 Claude Console 账号...')
|
||||
const consoleResp = await axios.get(`${baseURL}/admin/claude-console-accounts`, { headers })
|
||||
const consoleAccounts = consoleResp.data.data || []
|
||||
|
||||
console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`)
|
||||
|
||||
// 分类显示
|
||||
const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated')
|
||||
const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group')
|
||||
const consoleShared = consoleAccounts.filter((a) => a.accountType === 'shared')
|
||||
|
||||
console.log(`- 专属账号: ${consoleDedicated.length} 个`)
|
||||
console.log(`- 分组账号: ${consoleGroup.length} 个`)
|
||||
console.log(`- 共享账号: ${consoleShared.length} 个`)
|
||||
|
||||
// 检查 platform 字段
|
||||
console.log('\n检查 platform 字段:')
|
||||
consoleAccounts.slice(0, 3).forEach((acc) => {
|
||||
console.log(`- ${acc.name}: platform=${acc.platform}, accountType=${acc.accountType}`)
|
||||
})
|
||||
|
||||
// 获取账号分组
|
||||
console.log('\n📋 获取账号分组...')
|
||||
const groupsResp = await axios.get(`${baseURL}/admin/account-groups`, { headers })
|
||||
const groups = groupsResp.data.data || []
|
||||
|
||||
console.log(`找到 ${groups.length} 个账号分组`)
|
||||
|
||||
const claudeGroups = groups.filter((g) => g.platform === 'claude')
|
||||
const geminiGroups = groups.filter((g) => g.platform === 'gemini')
|
||||
|
||||
console.log(`- Claude 分组: ${claudeGroups.length} 个`)
|
||||
console.log(`- Gemini 分组: ${geminiGroups.length} 个`)
|
||||
|
||||
// 测试结果总结
|
||||
console.log('\n📊 测试结果总结:')
|
||||
console.log('✅ Claude OAuth 账号已包含 platform 字段')
|
||||
console.log('✅ Claude Console 账号已包含 platform 字段')
|
||||
console.log('✅ 账号分组功能正常')
|
||||
|
||||
const totalDedicated = claudeDedicated.length + consoleDedicated.length
|
||||
const totalGroups = claudeGroups.length
|
||||
|
||||
if (totalDedicated > 0) {
|
||||
console.log(`\n✅ 共有 ${totalDedicated} 个专属账号应该显示在下拉框中`)
|
||||
} else {
|
||||
console.log('\n⚠️ 没有找到专属账号,请在账号管理页面设置账号类型为"专属账户"')
|
||||
}
|
||||
|
||||
if (totalGroups > 0) {
|
||||
console.log(`✅ 共有 ${totalGroups} 个分组应该显示在下拉框中`)
|
||||
}
|
||||
|
||||
console.log('\n💡 请在浏览器中测试创建/编辑 API Key,检查下拉框是否正确显示三个类别:')
|
||||
console.log(' 1. 调度分组')
|
||||
console.log(' 2. Claude OAuth 账号')
|
||||
console.log(' 3. Claude Console 账号')
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error.message)
|
||||
if (error.response) {
|
||||
console.error('响应数据:', error.response.data)
|
||||
}
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
testAccountDisplay()
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* 测试 API 响应中的账号数据
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
|
||||
async function testApiResponse() {
|
||||
console.log('🔍 测试 API 响应数据...\n')
|
||||
|
||||
try {
|
||||
// 确保 Redis 已连接
|
||||
await redis.connect()
|
||||
|
||||
// 1. 测试 Claude OAuth 账号服务
|
||||
console.log('📋 测试 Claude OAuth 账号服务...')
|
||||
const claudeAccounts = await claudeAccountService.getAllAccounts()
|
||||
console.log(`找到 ${claudeAccounts.length} 个 Claude OAuth 账号`)
|
||||
|
||||
// 检查前3个账号的数据结构
|
||||
console.log('\n账号数据结构示例:')
|
||||
claudeAccounts.slice(0, 3).forEach((acc) => {
|
||||
console.log(`\n账号: ${acc.name}`)
|
||||
console.log(` - ID: ${acc.id}`)
|
||||
console.log(` - accountType: ${acc.accountType}`)
|
||||
console.log(` - platform: ${acc.platform}`)
|
||||
console.log(` - status: ${acc.status}`)
|
||||
console.log(` - isActive: ${acc.isActive}`)
|
||||
})
|
||||
|
||||
// 统计专属账号
|
||||
const claudeDedicated = claudeAccounts.filter((a) => a.accountType === 'dedicated')
|
||||
const claudeGroup = claudeAccounts.filter((a) => a.accountType === 'group')
|
||||
|
||||
console.log('\n统计结果:')
|
||||
console.log(` - 专属账号: ${claudeDedicated.length} 个`)
|
||||
console.log(` - 分组账号: ${claudeGroup.length} 个`)
|
||||
|
||||
// 2. 测试 Claude Console 账号服务
|
||||
console.log('\n\n📋 测试 Claude Console 账号服务...')
|
||||
const consoleAccounts = await claudeConsoleAccountService.getAllAccounts()
|
||||
console.log(`找到 ${consoleAccounts.length} 个 Claude Console 账号`)
|
||||
|
||||
// 检查前3个账号的数据结构
|
||||
console.log('\n账号数据结构示例:')
|
||||
consoleAccounts.slice(0, 3).forEach((acc) => {
|
||||
console.log(`\n账号: ${acc.name}`)
|
||||
console.log(` - ID: ${acc.id}`)
|
||||
console.log(` - accountType: ${acc.accountType}`)
|
||||
console.log(` - platform: ${acc.platform}`)
|
||||
console.log(` - status: ${acc.status}`)
|
||||
console.log(` - isActive: ${acc.isActive}`)
|
||||
})
|
||||
|
||||
// 统计专属账号
|
||||
const consoleDedicated = consoleAccounts.filter((a) => a.accountType === 'dedicated')
|
||||
const consoleGroup = consoleAccounts.filter((a) => a.accountType === 'group')
|
||||
|
||||
console.log('\n统计结果:')
|
||||
console.log(` - 专属账号: ${consoleDedicated.length} 个`)
|
||||
console.log(` - 分组账号: ${consoleGroup.length} 个`)
|
||||
|
||||
// 3. 测试账号分组服务
|
||||
console.log('\n\n📋 测试账号分组服务...')
|
||||
const groups = await accountGroupService.getAllGroups()
|
||||
console.log(`找到 ${groups.length} 个账号分组`)
|
||||
|
||||
// 显示分组信息
|
||||
groups.forEach((group) => {
|
||||
console.log(`\n分组: ${group.name}`)
|
||||
console.log(` - ID: ${group.id}`)
|
||||
console.log(` - platform: ${group.platform}`)
|
||||
console.log(` - memberCount: ${group.memberCount}`)
|
||||
})
|
||||
|
||||
// 4. 验证结果
|
||||
console.log('\n\n📊 验证结果:')
|
||||
|
||||
// 检查 platform 字段
|
||||
const claudeWithPlatform = claudeAccounts.filter((a) => a.platform === 'claude')
|
||||
const consoleWithPlatform = consoleAccounts.filter((a) => a.platform === 'claude-console')
|
||||
|
||||
if (claudeWithPlatform.length === claudeAccounts.length) {
|
||||
console.log('✅ 所有 Claude OAuth 账号都有正确的 platform 字段')
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ 只有 ${claudeWithPlatform.length}/${claudeAccounts.length} 个 Claude OAuth 账号有正确的 platform 字段`
|
||||
)
|
||||
}
|
||||
|
||||
if (consoleWithPlatform.length === consoleAccounts.length) {
|
||||
console.log('✅ 所有 Claude Console 账号都有正确的 platform 字段')
|
||||
} else {
|
||||
console.log(
|
||||
`⚠️ 只有 ${consoleWithPlatform.length}/${consoleAccounts.length} 个 Claude Console 账号有正确的 platform 字段`
|
||||
)
|
||||
}
|
||||
|
||||
// 总结
|
||||
const totalDedicated = claudeDedicated.length + consoleDedicated.length
|
||||
const totalGroup = claudeGroup.length + consoleGroup.length
|
||||
const totalGroups = groups.filter((g) => g.platform === 'claude').length
|
||||
|
||||
console.log('\n📈 总结:')
|
||||
console.log(
|
||||
`- 专属账号总数: ${totalDedicated} 个 (Claude OAuth: ${claudeDedicated.length}, Console: ${consoleDedicated.length})`
|
||||
)
|
||||
console.log(
|
||||
`- 分组账号总数: ${totalGroup} 个 (Claude OAuth: ${claudeGroup.length}, Console: ${consoleGroup.length})`
|
||||
)
|
||||
console.log(`- 账号分组总数: ${totalGroups} 个`)
|
||||
|
||||
if (totalDedicated + totalGroups > 0) {
|
||||
console.log('\n✅ 前端下拉框应该能够显示:')
|
||||
if (totalGroups > 0) {
|
||||
console.log(' - 调度分组')
|
||||
}
|
||||
if (claudeDedicated.length > 0) {
|
||||
console.log(' - Claude OAuth 专属账号 (仅 dedicated 类型)')
|
||||
}
|
||||
if (consoleDedicated.length > 0) {
|
||||
console.log(' - Claude Console 专属账号 (仅 dedicated 类型)')
|
||||
}
|
||||
} else {
|
||||
console.log('\n⚠️ 没有找到任何专属账号或分组,请检查账号配置')
|
||||
}
|
||||
|
||||
console.log('\n💡 说明:')
|
||||
console.log('- 专属账号下拉框只显示 accountType="dedicated" 的账号')
|
||||
console.log('- accountType="group" 的账号通过分组调度,不在专属账号中显示')
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error)
|
||||
console.error(error.stack)
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
testApiResponse()
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
|
||||
|
||||
async function testBedrockModels() {
|
||||
try {
|
||||
console.log('🧪 测试Bedrock模型配置...')
|
||||
|
||||
// 测试可用模型列表
|
||||
const models = await bedrockRelayService.getAvailableModels()
|
||||
console.log(`📋 找到 ${models.length} 个可用模型:`)
|
||||
models.forEach((model) => {
|
||||
console.log(` - ${model.id} (${model.name})`)
|
||||
})
|
||||
|
||||
// 测试默认模型
|
||||
console.log(`\n🎯 系统默认模型: ${bedrockRelayService.defaultModel}`)
|
||||
console.log(`🎯 系统默认小模型: ${bedrockRelayService.defaultSmallModel}`)
|
||||
|
||||
console.log('\n✅ Bedrock模型配置测试完成')
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
console.error('❌ Bedrock模型测试失败:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
testBedrockModels()
|
||||
}
|
||||
|
||||
module.exports = { testBedrockModels }
|
||||
Executable
+340
@@ -0,0 +1,340 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 计费事件测试脚本
|
||||
*
|
||||
* 用于测试计费事件的发布和消费功能
|
||||
*
|
||||
* 使用方法:
|
||||
* node scripts/test-billing-events.js [command]
|
||||
*
|
||||
* 命令:
|
||||
* publish - 发布测试事件
|
||||
* consume - 消费事件(测试模式)
|
||||
* info - 查看队列状态
|
||||
* clear - 清空队列(危险操作)
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const Redis = require('ioredis')
|
||||
|
||||
// 加载配置
|
||||
require('dotenv').config({ path: path.join(__dirname, '../.env') })
|
||||
|
||||
const config = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: parseInt(process.env.REDIS_DB) || 0
|
||||
}
|
||||
|
||||
const redis = new Redis(config)
|
||||
const STREAM_KEY = 'billing:events'
|
||||
|
||||
// ========================================
|
||||
// 命令实现
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 发布测试事件
|
||||
*/
|
||||
async function publishTestEvent() {
|
||||
console.log('📤 Publishing test billing event...')
|
||||
|
||||
const testEvent = {
|
||||
eventId: `test-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
eventType: 'usage.recorded',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '1.0',
|
||||
apiKey: {
|
||||
id: 'test-key-123',
|
||||
name: 'Test API Key',
|
||||
userId: 'test-user-456'
|
||||
},
|
||||
usage: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
inputTokens: 1500,
|
||||
outputTokens: 800,
|
||||
cacheCreateTokens: 200,
|
||||
cacheReadTokens: 100,
|
||||
ephemeral5mTokens: 150,
|
||||
ephemeral1hTokens: 50,
|
||||
totalTokens: 2600
|
||||
},
|
||||
cost: {
|
||||
total: 0.0156,
|
||||
currency: 'USD',
|
||||
breakdown: {
|
||||
input: 0.0045,
|
||||
output: 0.012,
|
||||
cacheCreate: 0.00075,
|
||||
cacheRead: 0.00003,
|
||||
ephemeral5m: 0.0005625,
|
||||
ephemeral1h: 0.0001875
|
||||
}
|
||||
},
|
||||
account: {
|
||||
id: 'test-account-789',
|
||||
type: 'claude-official'
|
||||
},
|
||||
context: {
|
||||
isLongContext: false,
|
||||
requestTimestamp: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const messageId = await redis.xadd(
|
||||
STREAM_KEY,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
100000,
|
||||
'*',
|
||||
'data',
|
||||
JSON.stringify(testEvent)
|
||||
)
|
||||
|
||||
console.log('✅ Event published successfully!')
|
||||
console.log(` Message ID: ${messageId}`)
|
||||
console.log(` Event ID: ${testEvent.eventId}`)
|
||||
console.log(` Cost: $${testEvent.cost.total}`)
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to publish event:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 消费事件(测试模式,不创建消费者组)
|
||||
*/
|
||||
async function consumeTestEvents() {
|
||||
console.log('📬 Consuming test events...')
|
||||
console.log(' Press Ctrl+C to stop\n')
|
||||
|
||||
let isRunning = true
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\n⏹️ Stopping consumer...')
|
||||
isRunning = false
|
||||
})
|
||||
|
||||
let lastId = '0' // 从头开始
|
||||
|
||||
while (isRunning) {
|
||||
try {
|
||||
// 使用 XREAD 而不是 XREADGROUP(测试模式)
|
||||
const messages = await redis.xread('BLOCK', 5000, 'COUNT', 10, 'STREAMS', STREAM_KEY, lastId)
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const [streamKey, entries] = messages[0]
|
||||
console.log(`📬 Received ${entries.length} messages from ${streamKey}\n`)
|
||||
|
||||
for (const [messageId, fields] of entries) {
|
||||
try {
|
||||
const data = {}
|
||||
for (let i = 0; i < fields.length; i += 2) {
|
||||
data[fields[i]] = fields[i + 1]
|
||||
}
|
||||
|
||||
const event = JSON.parse(data.data)
|
||||
|
||||
console.log(`📊 Event: ${event.eventId}`)
|
||||
console.log(` API Key: ${event.apiKey.name} (${event.apiKey.id})`)
|
||||
console.log(` Model: ${event.usage.model}`)
|
||||
console.log(` Tokens: ${event.usage.totalTokens}`)
|
||||
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
|
||||
console.log(` Timestamp: ${event.timestamp}`)
|
||||
console.log('')
|
||||
|
||||
lastId = messageId // 更新位置
|
||||
} catch (parseError) {
|
||||
console.error(`❌ Failed to parse message ${messageId}:`, parseError.message)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isRunning) {
|
||||
console.error('❌ Error consuming messages:', error.message)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('👋 Consumer stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* 查看队列状态
|
||||
*/
|
||||
async function showQueueInfo() {
|
||||
console.log('📊 Queue Information\n')
|
||||
|
||||
try {
|
||||
// Stream 长度
|
||||
const length = await redis.xlen(STREAM_KEY)
|
||||
console.log(`Stream: ${STREAM_KEY}`)
|
||||
console.log(`Length: ${length} messages\n`)
|
||||
|
||||
if (length === 0) {
|
||||
console.log('ℹ️ Queue is empty')
|
||||
return
|
||||
}
|
||||
|
||||
// Stream 详细信息
|
||||
const info = await redis.xinfo('STREAM', STREAM_KEY)
|
||||
const infoObj = {}
|
||||
for (let i = 0; i < info.length; i += 2) {
|
||||
infoObj[info[i]] = info[i + 1]
|
||||
}
|
||||
|
||||
console.log('Stream Details:')
|
||||
console.log(` First Entry ID: ${infoObj['first-entry'] ? infoObj['first-entry'][0] : 'N/A'}`)
|
||||
console.log(` Last Entry ID: ${infoObj['last-entry'] ? infoObj['last-entry'][0] : 'N/A'}`)
|
||||
console.log(` Consumer Groups: ${infoObj.groups || 0}\n`)
|
||||
|
||||
// 消费者组信息
|
||||
if (infoObj.groups > 0) {
|
||||
console.log('Consumer Groups:')
|
||||
const groups = await redis.xinfo('GROUPS', STREAM_KEY)
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const group = groups[i]
|
||||
const groupObj = {}
|
||||
for (let j = 0; j < group.length; j += 2) {
|
||||
groupObj[group[j]] = group[j + 1]
|
||||
}
|
||||
|
||||
console.log(`\n Group: ${groupObj.name}`)
|
||||
console.log(` Consumers: ${groupObj.consumers}`)
|
||||
console.log(` Pending: ${groupObj.pending}`)
|
||||
console.log(` Last Delivered ID: ${groupObj['last-delivered-id']}`)
|
||||
|
||||
// 消费者详情
|
||||
if (groupObj.consumers > 0) {
|
||||
const consumers = await redis.xinfo('CONSUMERS', STREAM_KEY, groupObj.name)
|
||||
console.log(' Consumer Details:')
|
||||
|
||||
for (let k = 0; k < consumers.length; k++) {
|
||||
const consumer = consumers[k]
|
||||
const consumerObj = {}
|
||||
for (let l = 0; l < consumer.length; l += 2) {
|
||||
consumerObj[consumer[l]] = consumer[l + 1]
|
||||
}
|
||||
|
||||
console.log(` - ${consumerObj.name}`)
|
||||
console.log(` Pending: ${consumerObj.pending}`)
|
||||
console.log(` Idle: ${Math.round(consumerObj.idle / 1000)}s`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 最新 5 条消息
|
||||
console.log('\n📬 Latest 5 Messages:')
|
||||
const latest = await redis.xrevrange(STREAM_KEY, '+', '-', 'COUNT', 5)
|
||||
|
||||
if (latest.length === 0) {
|
||||
console.log(' No messages')
|
||||
} else {
|
||||
for (const [messageId, fields] of latest) {
|
||||
const data = {}
|
||||
for (let i = 0; i < fields.length; i += 2) {
|
||||
data[fields[i]] = fields[i + 1]
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(data.data)
|
||||
console.log(`\n ${messageId}`)
|
||||
console.log(` Event ID: ${event.eventId}`)
|
||||
console.log(` Model: ${event.usage.model}`)
|
||||
console.log(` Cost: $${event.cost.total.toFixed(6)}`)
|
||||
console.log(` Time: ${event.timestamp}`)
|
||||
} catch (e) {
|
||||
console.log(`\n ${messageId} (Parse Error)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get queue info:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列(危险操作)
|
||||
*/
|
||||
async function clearQueue() {
|
||||
console.log('⚠️ WARNING: This will delete all messages in the queue!')
|
||||
console.log(` Stream: ${STREAM_KEY}`)
|
||||
|
||||
// 简单的确认机制
|
||||
const readline = require('readline')
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
rl.question('Type "yes" to confirm: ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'yes') {
|
||||
try {
|
||||
await redis.del(STREAM_KEY)
|
||||
console.log('✅ Queue cleared successfully')
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to clear queue:', error.message)
|
||||
}
|
||||
} else {
|
||||
console.log('❌ Operation cancelled')
|
||||
}
|
||||
rl.close()
|
||||
redis.quit()
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// CLI 处理
|
||||
// ========================================
|
||||
|
||||
async function main() {
|
||||
const command = process.argv[2] || 'info'
|
||||
|
||||
console.log('🔧 Billing Events Test Tool\n')
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'publish':
|
||||
await publishTestEvent()
|
||||
break
|
||||
|
||||
case 'consume':
|
||||
await consumeTestEvents()
|
||||
break
|
||||
|
||||
case 'info':
|
||||
await showQueueInfo()
|
||||
break
|
||||
|
||||
case 'clear':
|
||||
await clearQueue()
|
||||
return // clearQueue 会自己关闭连接
|
||||
|
||||
default:
|
||||
console.error(`❌ Unknown command: ${command}`)
|
||||
console.log('\nAvailable commands:')
|
||||
console.log(' publish - Publish a test event')
|
||||
console.log(' consume - Consume events (test mode)')
|
||||
console.log(' info - Show queue status')
|
||||
console.log(' clear - Clear the queue (dangerous)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await redis.quit()
|
||||
} catch (error) {
|
||||
console.error('💥 Fatal error:', error)
|
||||
await redis.quit()
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 测试专属账号显示问题
|
||||
*/
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
|
||||
async function testDedicatedAccounts() {
|
||||
console.log('🔍 检查专属账号...\n')
|
||||
|
||||
try {
|
||||
// 确保 Redis 已连接
|
||||
await redis.connect()
|
||||
|
||||
// 获取所有 Claude 账号
|
||||
const claudeKeys = await redis.client.keys('claude:account:*')
|
||||
console.log(`找到 ${claudeKeys.length} 个 Claude 账号\n`)
|
||||
|
||||
const dedicatedAccounts = []
|
||||
const groupAccounts = []
|
||||
const sharedAccounts = []
|
||||
|
||||
for (const key of claudeKeys) {
|
||||
const account = await redis.client.hgetall(key)
|
||||
const accountType = account.accountType || 'shared'
|
||||
|
||||
const accountInfo = {
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType,
|
||||
status: account.status,
|
||||
isActive: account.isActive,
|
||||
createdAt: account.createdAt
|
||||
}
|
||||
|
||||
if (accountType === 'dedicated') {
|
||||
dedicatedAccounts.push(accountInfo)
|
||||
} else if (accountType === 'group') {
|
||||
groupAccounts.push(accountInfo)
|
||||
} else {
|
||||
sharedAccounts.push(accountInfo)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📊 账号统计:')
|
||||
console.log(`- 专属账号: ${dedicatedAccounts.length} 个`)
|
||||
console.log(`- 分组账号: ${groupAccounts.length} 个`)
|
||||
console.log(`- 共享账号: ${sharedAccounts.length} 个`)
|
||||
console.log('')
|
||||
|
||||
if (dedicatedAccounts.length > 0) {
|
||||
console.log('✅ 专属账号列表:')
|
||||
dedicatedAccounts.forEach((acc) => {
|
||||
console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
|
||||
})
|
||||
console.log('')
|
||||
} else {
|
||||
console.log('⚠️ 没有找到专属账号!')
|
||||
console.log('💡 提示: 请确保在账号管理页面将账号类型设置为"专属账户"')
|
||||
console.log('')
|
||||
}
|
||||
|
||||
if (groupAccounts.length > 0) {
|
||||
console.log('📁 分组账号列表:')
|
||||
groupAccounts.forEach((acc) => {
|
||||
console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
|
||||
})
|
||||
console.log('')
|
||||
}
|
||||
|
||||
// 检查分组
|
||||
const groupKeys = await redis.client.keys('account_group:*')
|
||||
console.log(`\n找到 ${groupKeys.length} 个账号分组`)
|
||||
|
||||
if (groupKeys.length > 0) {
|
||||
console.log('📋 分组列表:')
|
||||
for (const key of groupKeys) {
|
||||
const group = await redis.client.hgetall(key)
|
||||
console.log(
|
||||
` - ${group.name} (平台: ${group.platform}, 成员数: ${group.memberCount || 0})`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 检查 Claude Console 账号
|
||||
const consoleKeys = await redis.client.keys('claude_console_account:*')
|
||||
console.log(`\n找到 ${consoleKeys.length} 个 Claude Console 账号`)
|
||||
|
||||
const dedicatedConsoleAccounts = []
|
||||
const groupConsoleAccounts = []
|
||||
|
||||
for (const key of consoleKeys) {
|
||||
const account = await redis.client.hgetall(key)
|
||||
const accountType = account.accountType || 'shared'
|
||||
|
||||
if (accountType === 'dedicated') {
|
||||
dedicatedConsoleAccounts.push({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType,
|
||||
status: account.status
|
||||
})
|
||||
} else if (accountType === 'group') {
|
||||
groupConsoleAccounts.push({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
accountType,
|
||||
status: account.status
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (dedicatedConsoleAccounts.length > 0) {
|
||||
console.log('\n✅ Claude Console 专属账号:')
|
||||
dedicatedConsoleAccounts.forEach((acc) => {
|
||||
console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
|
||||
})
|
||||
}
|
||||
|
||||
if (groupConsoleAccounts.length > 0) {
|
||||
console.log('\n📁 Claude Console 分组账号:')
|
||||
groupConsoleAccounts.forEach((acc) => {
|
||||
console.log(` - ${acc.name} (ID: ${acc.id}, 状态: ${acc.status})`)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 错误:', error)
|
||||
console.error(error.stack)
|
||||
} finally {
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
testDedicatedAccounts()
|
||||
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 测试 Gemini token 刷新功能
|
||||
*/
|
||||
|
||||
const path = require('path')
|
||||
const dotenv = require('dotenv')
|
||||
|
||||
// 加载环境变量
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env') })
|
||||
|
||||
const redis = require('../src/models/redis')
|
||||
const geminiAccountService = require('../src/services/account/geminiAccountService')
|
||||
const crypto = require('crypto')
|
||||
const config = require('../config/config')
|
||||
|
||||
// 加密相关常量(与 geminiAccountService 保持一致)
|
||||
const ALGORITHM = 'aes-256-cbc'
|
||||
const ENCRYPTION_SALT = 'gemini-account-salt' // 注意:是 'gemini-account-salt' 不是其他值!
|
||||
|
||||
// 生成加密密钥
|
||||
function generateEncryptionKey() {
|
||||
return crypto.scryptSync(config.security.encryptionKey, ENCRYPTION_SALT, 32)
|
||||
}
|
||||
|
||||
// 解密函数(用于调试)
|
||||
function debugDecrypt(text) {
|
||||
if (!text) {
|
||||
return { success: false, error: 'Empty text' }
|
||||
}
|
||||
try {
|
||||
const key = generateEncryptionKey()
|
||||
const ivHex = text.substring(0, 32)
|
||||
const encryptedHex = text.substring(33)
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex')
|
||||
const encryptedText = Buffer.from(encryptedHex, 'hex')
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
|
||||
let decrypted = decipher.update(encryptedText)
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()])
|
||||
return { success: true, value: decrypted.toString() }
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
|
||||
async function testGeminiTokenRefresh() {
|
||||
try {
|
||||
console.log('🚀 开始测试 Gemini token 刷新功能...\n')
|
||||
|
||||
// 显示配置信息
|
||||
console.log('📋 配置信息:')
|
||||
console.log(` 加密密钥: ${config.security.encryptionKey}`)
|
||||
console.log(` 加密盐值: ${ENCRYPTION_SALT}`)
|
||||
console.log()
|
||||
|
||||
// 1. 连接 Redis
|
||||
console.log('📡 连接 Redis...')
|
||||
await redis.connect()
|
||||
console.log('✅ Redis 连接成功\n')
|
||||
|
||||
// 2. 获取所有 Gemini 账户
|
||||
console.log('🔍 获取 Gemini 账户列表...')
|
||||
const accounts = await geminiAccountService.getAllAccounts()
|
||||
const geminiAccounts = accounts.filter((acc) => acc.platform === 'gemini')
|
||||
|
||||
if (geminiAccounts.length === 0) {
|
||||
console.log('❌ 没有找到 Gemini 账户')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`✅ 找到 ${geminiAccounts.length} 个 Gemini 账户\n`)
|
||||
|
||||
// 3. 测试每个账户的 token 刷新
|
||||
for (const account of geminiAccounts) {
|
||||
console.log(`\n📋 测试账户: ${account.name} (${account.id})`)
|
||||
console.log(` 状态: ${account.status}`)
|
||||
|
||||
try {
|
||||
// 获取原始账户数据(用于调试)
|
||||
const client = redis.getClient()
|
||||
const rawData = await client.hgetall(`gemini_account:${account.id}`)
|
||||
|
||||
console.log(' 📊 原始数据检查:')
|
||||
console.log(` refreshToken 存在: ${rawData.refreshToken ? '是' : '否'}`)
|
||||
if (rawData.refreshToken) {
|
||||
console.log(` refreshToken 长度: ${rawData.refreshToken.length}`)
|
||||
console.log(` refreshToken 前50字符: ${rawData.refreshToken.substring(0, 50)}...`)
|
||||
|
||||
// 尝试手动解密
|
||||
const decryptResult = debugDecrypt(rawData.refreshToken)
|
||||
if (decryptResult.success) {
|
||||
console.log(' ✅ 手动解密成功')
|
||||
console.log(` 解密后前20字符: ${decryptResult.value.substring(0, 20)}...`)
|
||||
} else {
|
||||
console.log(` ❌ 手动解密失败: ${decryptResult.error}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取完整账户信息(包括解密的 token)
|
||||
const fullAccount = await geminiAccountService.getAccount(account.id)
|
||||
|
||||
if (!fullAccount.refreshToken) {
|
||||
console.log(' ⚠️ 跳过:该账户无 refresh token\n')
|
||||
continue
|
||||
}
|
||||
|
||||
console.log(' ✅ 找到 refresh token')
|
||||
console.log(
|
||||
` 📝 解密后的 refresh token 前20字符: ${fullAccount.refreshToken.substring(0, 20)}...`
|
||||
)
|
||||
|
||||
console.log(' 🔄 开始刷新 token...')
|
||||
const startTime = Date.now()
|
||||
|
||||
// 执行 token 刷新
|
||||
const newTokens = await geminiAccountService.refreshAccountToken(account.id)
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(` ✅ Token 刷新成功!耗时: ${duration}ms`)
|
||||
console.log(` 📅 新的过期时间: ${new Date(newTokens.expiry_date).toLocaleString()}`)
|
||||
console.log(` 🔑 Access Token: ${newTokens.access_token.substring(0, 20)}...`)
|
||||
|
||||
// 验证账户状态已更新
|
||||
const updatedAccount = await geminiAccountService.getAccount(account.id)
|
||||
console.log(` 📊 账户状态: ${updatedAccount.status}`)
|
||||
} catch (error) {
|
||||
console.log(` ❌ Token 刷新失败: ${error.message}`)
|
||||
console.log(' 🔍 错误详情:', error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ 测试完成!')
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error)
|
||||
} finally {
|
||||
// 断开 Redis 连接
|
||||
await redis.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testGeminiTokenRefresh()
|
||||
@@ -0,0 +1,549 @@
|
||||
/**
|
||||
* 分组调度功能测试脚本
|
||||
* 用于测试账户分组管理和调度逻辑的正确性
|
||||
*/
|
||||
|
||||
require('dotenv').config()
|
||||
const { v4: uuidv4 } = require('uuid')
|
||||
const redis = require('../src/models/redis')
|
||||
const accountGroupService = require('../src/services/accountGroupService')
|
||||
const claudeAccountService = require('../src/services/account/claudeAccountService')
|
||||
const claudeConsoleAccountService = require('../src/services/account/claudeConsoleAccountService')
|
||||
const apiKeyService = require('../src/services/apiKeyService')
|
||||
const unifiedClaudeScheduler = require('../src/services/scheduler/unifiedClaudeScheduler')
|
||||
|
||||
// 测试配置
|
||||
const TEST_PREFIX = 'test_group_'
|
||||
const CLEANUP_ON_FINISH = true // 测试完成后是否清理数据
|
||||
|
||||
// 测试数据存储
|
||||
const testData = {
|
||||
groups: [],
|
||||
accounts: [],
|
||||
apiKeys: []
|
||||
}
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
green: '\x1b[32m',
|
||||
red: '\x1b[31m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[34m',
|
||||
reset: '\x1b[0m'
|
||||
}
|
||||
|
||||
function log(message, type = 'info') {
|
||||
const color =
|
||||
{
|
||||
success: colors.green,
|
||||
error: colors.red,
|
||||
warning: colors.yellow,
|
||||
info: colors.blue
|
||||
}[type] || colors.reset
|
||||
|
||||
console.log(`${color}${message}${colors.reset}`)
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
async function cleanup() {
|
||||
log('\n🧹 清理测试数据...', 'info')
|
||||
|
||||
// 删除测试API Keys
|
||||
for (const apiKey of testData.apiKeys) {
|
||||
try {
|
||||
await apiKeyService.deleteApiKey(apiKey.id)
|
||||
log(`✅ 删除测试API Key: ${apiKey.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除API Key失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试账户
|
||||
for (const account of testData.accounts) {
|
||||
try {
|
||||
if (account.type === 'claude') {
|
||||
await claudeAccountService.deleteAccount(account.id)
|
||||
} else if (account.type === 'claude-console') {
|
||||
await claudeConsoleAccountService.deleteAccount(account.id)
|
||||
}
|
||||
log(`✅ 删除测试账户: ${account.name}`, 'success')
|
||||
} catch (error) {
|
||||
log(`❌ 删除账户失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 删除测试分组
|
||||
for (const group of testData.groups) {
|
||||
try {
|
||||
await accountGroupService.deleteGroup(group.id)
|
||||
log(`✅ 删除测试分组: ${group.name}`, 'success')
|
||||
} catch (error) {
|
||||
// 可能因为还有成员而删除失败,先移除所有成员
|
||||
if (error.message.includes('分组内还有账户')) {
|
||||
const members = await accountGroupService.getGroupMembers(group.id)
|
||||
for (const memberId of members) {
|
||||
await accountGroupService.removeAccountFromGroup(memberId, group.id)
|
||||
}
|
||||
// 重试删除
|
||||
await accountGroupService.deleteGroup(group.id)
|
||||
log(`✅ 删除测试分组: ${group.name} (清空成员后)`, 'success')
|
||||
} else {
|
||||
log(`❌ 删除分组失败: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 测试1: 创建分组
|
||||
async function test1_createGroups() {
|
||||
log('\n📝 测试1: 创建账户分组', 'info')
|
||||
|
||||
try {
|
||||
// 创建Claude分组
|
||||
const claudeGroup = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}Claude组`,
|
||||
platform: 'claude',
|
||||
description: '测试用Claude账户分组'
|
||||
})
|
||||
testData.groups.push(claudeGroup)
|
||||
log(`✅ 创建Claude分组成功: ${claudeGroup.name} (ID: ${claudeGroup.id})`, 'success')
|
||||
|
||||
// 创建Gemini分组
|
||||
const geminiGroup = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}Gemini组`,
|
||||
platform: 'gemini',
|
||||
description: '测试用Gemini账户分组'
|
||||
})
|
||||
testData.groups.push(geminiGroup)
|
||||
log(`✅ 创建Gemini分组成功: ${geminiGroup.name} (ID: ${geminiGroup.id})`, 'success')
|
||||
|
||||
// 验证分组信息
|
||||
const allGroups = await accountGroupService.getAllGroups()
|
||||
const testGroups = allGroups.filter((g) => g.name.startsWith(TEST_PREFIX))
|
||||
|
||||
if (testGroups.length === 2) {
|
||||
log(`✅ 分组创建验证通过,共创建 ${testGroups.length} 个测试分组`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组数量不正确,期望2个,实际${testGroups.length}个`)
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试1失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试2: 创建账户并添加到分组
|
||||
async function test2_createAccountsAndAddToGroup() {
|
||||
log('\n📝 测试2: 创建账户并添加到分组', 'info')
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find((g) => g.platform === 'claude')
|
||||
|
||||
// 创建Claude OAuth账户
|
||||
const claudeAccount1 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}Claude账户1`,
|
||||
email: 'test1@example.com',
|
||||
refreshToken: 'test_refresh_token_1',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push({ ...claudeAccount1, type: 'claude' })
|
||||
log(`✅ 创建Claude OAuth账户1成功: ${claudeAccount1.name}`, 'success')
|
||||
|
||||
const claudeAccount2 = await claudeAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}Claude账户2`,
|
||||
email: 'test2@example.com',
|
||||
refreshToken: 'test_refresh_token_2',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push({ ...claudeAccount2, type: 'claude' })
|
||||
log(`✅ 创建Claude OAuth账户2成功: ${claudeAccount2.name}`, 'success')
|
||||
|
||||
// 创建Claude Console账户
|
||||
const consoleAccount = await claudeConsoleAccountService.createAccount({
|
||||
name: `${TEST_PREFIX}Console账户`,
|
||||
apiUrl: 'https://api.example.com',
|
||||
apiKey: 'test_api_key',
|
||||
accountType: 'group'
|
||||
})
|
||||
testData.accounts.push({ ...consoleAccount, type: 'claude-console' })
|
||||
log(`✅ 创建Claude Console账户成功: ${consoleAccount.name}`, 'success')
|
||||
|
||||
// 添加账户到分组
|
||||
await accountGroupService.addAccountToGroup(claudeAccount1.id, claudeGroup.id, 'claude')
|
||||
log('✅ 添加账户1到分组成功', 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(claudeAccount2.id, claudeGroup.id, 'claude')
|
||||
log('✅ 添加账户2到分组成功', 'success')
|
||||
|
||||
await accountGroupService.addAccountToGroup(consoleAccount.id, claudeGroup.id, 'claude')
|
||||
log('✅ 添加Console账户到分组成功', 'success')
|
||||
|
||||
// 验证分组成员
|
||||
const members = await accountGroupService.getGroupMembers(claudeGroup.id)
|
||||
if (members.length === 3) {
|
||||
log(`✅ 分组成员验证通过,共有 ${members.length} 个成员`, 'success')
|
||||
} else {
|
||||
throw new Error(`分组成员数量不正确,期望3个,实际${members.length}个`)
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试2失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试3: 平台一致性验证
|
||||
async function test3_platformConsistency() {
|
||||
log('\n📝 测试3: 平台一致性验证', 'info')
|
||||
|
||||
try {
|
||||
const geminiGroup = testData.groups.find((g) => g.platform === 'gemini')
|
||||
|
||||
// 尝试将Claude账户添加到Gemini分组(应该失败)
|
||||
const claudeAccount = testData.accounts.find((a) => a.type === 'claude')
|
||||
|
||||
try {
|
||||
await accountGroupService.addAccountToGroup(claudeAccount.id, geminiGroup.id, 'claude')
|
||||
throw new Error('平台验证失败:Claude账户不应该能添加到Gemini分组')
|
||||
} catch (error) {
|
||||
if (error.message.includes('平台与分组平台不匹配')) {
|
||||
log(`✅ 平台一致性验证通过:${error.message}`, 'success')
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试3失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试4: API Key绑定分组
|
||||
async function test4_apiKeyBindGroup() {
|
||||
log('\n📝 测试4: API Key绑定分组', 'info')
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find((g) => g.platform === 'claude')
|
||||
|
||||
// 创建绑定到分组的API Key
|
||||
const apiKey = await apiKeyService.generateApiKey({
|
||||
name: `${TEST_PREFIX}API Key`,
|
||||
description: '测试分组调度的API Key',
|
||||
claudeAccountId: `group:${claudeGroup.id}`,
|
||||
permissions: 'claude'
|
||||
})
|
||||
testData.apiKeys.push(apiKey)
|
||||
log(`✅ 创建API Key成功: ${apiKey.name} (绑定到分组: ${claudeGroup.name})`, 'success')
|
||||
|
||||
// 验证API Key信息
|
||||
const keyInfo = await redis.getApiKey(apiKey.id)
|
||||
if (keyInfo && keyInfo.claudeAccountId === `group:${claudeGroup.id}`) {
|
||||
log('✅ API Key分组绑定验证通过', 'success')
|
||||
} else {
|
||||
throw new Error('API Key分组绑定信息不正确')
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试4失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试5: 分组调度负载均衡
|
||||
async function test5_groupSchedulingLoadBalance() {
|
||||
log('\n📝 测试5: 分组调度负载均衡', 'info')
|
||||
|
||||
try {
|
||||
const apiKey = testData.apiKeys[0]
|
||||
|
||||
// 记录每个账户被选中的次数
|
||||
const selectionCount = {}
|
||||
const totalSelections = 30
|
||||
|
||||
for (let i = 0; i < totalSelections; i++) {
|
||||
// 模拟不同的会话
|
||||
const sessionHash = uuidv4()
|
||||
|
||||
const result = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
{
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
},
|
||||
sessionHash
|
||||
)
|
||||
|
||||
if (!selectionCount[result.accountId]) {
|
||||
selectionCount[result.accountId] = 0
|
||||
}
|
||||
selectionCount[result.accountId]++
|
||||
|
||||
// 短暂延迟,模拟真实请求间隔
|
||||
await sleep(50)
|
||||
}
|
||||
|
||||
// 分析选择分布
|
||||
log(`\n📊 负载均衡分布统计 (共${totalSelections}次选择):`, 'info')
|
||||
const accounts = Object.keys(selectionCount)
|
||||
|
||||
for (const accountId of accounts) {
|
||||
const count = selectionCount[accountId]
|
||||
const percentage = ((count / totalSelections) * 100).toFixed(1)
|
||||
const accountInfo = testData.accounts.find((a) => a.id === accountId)
|
||||
log(` ${accountInfo.name}: ${count}次 (${percentage}%)`, 'info')
|
||||
}
|
||||
|
||||
// 验证是否实现了负载均衡
|
||||
const counts = Object.values(selectionCount)
|
||||
const avgCount = totalSelections / accounts.length
|
||||
const variance =
|
||||
counts.reduce((sum, count) => sum + Math.pow(count - avgCount, 2), 0) / counts.length
|
||||
const stdDev = Math.sqrt(variance)
|
||||
|
||||
log(`\n 平均选择次数: ${avgCount.toFixed(1)}`, 'info')
|
||||
log(` 标准差: ${stdDev.toFixed(1)}`, 'info')
|
||||
|
||||
// 如果标准差小于平均值的50%,认为负载均衡效果良好
|
||||
if (stdDev < avgCount * 0.5) {
|
||||
log('✅ 负载均衡验证通过,分布相对均匀', 'success')
|
||||
} else {
|
||||
log('⚠️ 负载分布不够均匀,但这可能是正常的随机波动', 'warning')
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试5失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试6: 会话粘性测试
|
||||
async function test6_stickySession() {
|
||||
log('\n📝 测试6: 会话粘性(Sticky Session)测试', 'info')
|
||||
|
||||
try {
|
||||
const apiKey = testData.apiKeys[0]
|
||||
const sessionHash = `test_session_${uuidv4()}`
|
||||
|
||||
// 第一次选择
|
||||
const firstSelection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
{
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
},
|
||||
sessionHash
|
||||
)
|
||||
|
||||
log(` 首次选择账户: ${firstSelection.accountId}`, 'info')
|
||||
|
||||
// 使用相同的sessionHash多次请求
|
||||
let consistentCount = 0
|
||||
const testCount = 10
|
||||
|
||||
for (let i = 0; i < testCount; i++) {
|
||||
const selection = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
{
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
},
|
||||
sessionHash
|
||||
)
|
||||
|
||||
if (selection.accountId === firstSelection.accountId) {
|
||||
consistentCount++
|
||||
}
|
||||
|
||||
await sleep(100)
|
||||
}
|
||||
|
||||
log(` 会话一致性: ${consistentCount}/${testCount} 次选择了相同账户`, 'info')
|
||||
|
||||
if (consistentCount === testCount) {
|
||||
log('✅ 会话粘性验证通过,同一会话始终选择相同账户', 'success')
|
||||
} else {
|
||||
throw new Error(`会话粘性失败,只有${consistentCount}/${testCount}次选择了相同账户`)
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试6失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试7: 账户可用性检查
|
||||
async function test7_accountAvailability() {
|
||||
log('\n📝 测试7: 账户可用性检查', 'info')
|
||||
|
||||
try {
|
||||
const apiKey = testData.apiKeys[0]
|
||||
const accounts = testData.accounts.filter(
|
||||
(a) => a.type === 'claude' || a.type === 'claude-console'
|
||||
)
|
||||
|
||||
// 禁用第一个账户
|
||||
const firstAccount = accounts[0]
|
||||
if (firstAccount.type === 'claude') {
|
||||
await claudeAccountService.updateAccount(firstAccount.id, { isActive: false })
|
||||
} else {
|
||||
await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: false })
|
||||
}
|
||||
log(` 已禁用账户: ${firstAccount.name}`, 'info')
|
||||
|
||||
// 多次选择,验证不会选择到禁用的账户
|
||||
const selectionResults = []
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const sessionHash = uuidv4() // 每次使用新会话
|
||||
const result = await unifiedClaudeScheduler.selectAccountForApiKey(
|
||||
{
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
},
|
||||
sessionHash
|
||||
)
|
||||
|
||||
selectionResults.push(result.accountId)
|
||||
}
|
||||
|
||||
// 检查是否选择了禁用的账户
|
||||
const selectedDisabled = selectionResults.includes(firstAccount.id)
|
||||
|
||||
if (!selectedDisabled) {
|
||||
log('✅ 账户可用性验证通过,未选择禁用的账户', 'success')
|
||||
} else {
|
||||
throw new Error('错误:选择了已禁用的账户')
|
||||
}
|
||||
|
||||
// 重新启用账户
|
||||
if (firstAccount.type === 'claude') {
|
||||
await claudeAccountService.updateAccount(firstAccount.id, { isActive: true })
|
||||
} else {
|
||||
await claudeConsoleAccountService.updateAccount(firstAccount.id, { isActive: true })
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试7失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试8: 分组成员管理
|
||||
async function test8_groupMemberManagement() {
|
||||
log('\n📝 测试8: 分组成员管理', 'info')
|
||||
|
||||
try {
|
||||
const claudeGroup = testData.groups.find((g) => g.platform === 'claude')
|
||||
const account = testData.accounts.find((a) => a.type === 'claude')
|
||||
|
||||
// 获取账户所属分组
|
||||
const accountGroups = await accountGroupService.getAccountGroup(account.id)
|
||||
const hasTargetGroup = accountGroups.some((group) => group.id === claudeGroup.id)
|
||||
if (hasTargetGroup) {
|
||||
log('✅ 账户分组查询验证通过', 'success')
|
||||
} else {
|
||||
throw new Error('账户分组查询结果不正确')
|
||||
}
|
||||
|
||||
// 从分组移除账户
|
||||
await accountGroupService.removeAccountFromGroup(account.id, claudeGroup.id)
|
||||
log(` 从分组移除账户: ${account.name}`, 'info')
|
||||
|
||||
// 验证账户已不在分组中
|
||||
const membersAfterRemove = await accountGroupService.getGroupMembers(claudeGroup.id)
|
||||
if (!membersAfterRemove.includes(account.id)) {
|
||||
log('✅ 账户移除验证通过', 'success')
|
||||
} else {
|
||||
throw new Error('账户移除失败')
|
||||
}
|
||||
|
||||
// 重新添加账户
|
||||
await accountGroupService.addAccountToGroup(account.id, claudeGroup.id, 'claude')
|
||||
log(' 重新添加账户到分组', 'info')
|
||||
} catch (error) {
|
||||
log(`❌ 测试8失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 测试9: 空分组处理
|
||||
async function test9_emptyGroupHandling() {
|
||||
log('\n📝 测试9: 空分组处理', 'info')
|
||||
|
||||
try {
|
||||
// 创建一个空分组
|
||||
const emptyGroup = await accountGroupService.createGroup({
|
||||
name: `${TEST_PREFIX}空分组`,
|
||||
platform: 'claude',
|
||||
description: '测试空分组'
|
||||
})
|
||||
testData.groups.push(emptyGroup)
|
||||
|
||||
// 创建绑定到空分组的API Key
|
||||
const apiKey = await apiKeyService.generateApiKey({
|
||||
name: `${TEST_PREFIX}空分组API Key`,
|
||||
claudeAccountId: `group:${emptyGroup.id}`,
|
||||
permissions: 'claude'
|
||||
})
|
||||
testData.apiKeys.push(apiKey)
|
||||
|
||||
// 尝试从空分组选择账户(应该失败)
|
||||
try {
|
||||
await unifiedClaudeScheduler.selectAccountForApiKey({
|
||||
id: apiKey.id,
|
||||
claudeAccountId: apiKey.claudeAccountId,
|
||||
name: apiKey.name
|
||||
})
|
||||
throw new Error('空分组选择账户应该失败')
|
||||
} catch (error) {
|
||||
if (error.message.includes('has no members')) {
|
||||
log(`✅ 空分组处理验证通过:${error.message}`, 'success')
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`❌ 测试9失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 主测试函数
|
||||
async function runTests() {
|
||||
log('\n🚀 开始分组调度功能测试\n', 'info')
|
||||
|
||||
try {
|
||||
// 连接Redis
|
||||
await redis.connect()
|
||||
log('✅ Redis连接成功', 'success')
|
||||
|
||||
// 执行测试
|
||||
await test1_createGroups()
|
||||
await test2_createAccountsAndAddToGroup()
|
||||
await test3_platformConsistency()
|
||||
await test4_apiKeyBindGroup()
|
||||
await test5_groupSchedulingLoadBalance()
|
||||
await test6_stickySession()
|
||||
await test7_accountAvailability()
|
||||
await test8_groupMemberManagement()
|
||||
await test9_emptyGroupHandling()
|
||||
|
||||
log('\n🎉 所有测试通过!分组调度功能工作正常', 'success')
|
||||
} catch (error) {
|
||||
log(`\n❌ 测试失败: ${error.message}`, 'error')
|
||||
console.error(error)
|
||||
} finally {
|
||||
// 清理测试数据
|
||||
if (CLEANUP_ON_FINISH) {
|
||||
await cleanup()
|
||||
} else {
|
||||
log('\n⚠️ 测试数据未清理,请手动清理', 'warning')
|
||||
}
|
||||
|
||||
// 关闭Redis连接
|
||||
await redis.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
runTests()
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const bedrockRelayService = require('../src/services/relay/bedrockRelayService')
|
||||
|
||||
function testModelMapping() {
|
||||
console.log('🧪 测试模型映射功能...')
|
||||
|
||||
// 测试用例
|
||||
const testCases = [
|
||||
// 标准Claude模型名
|
||||
'claude-3-5-haiku-20241022',
|
||||
'claude-3-5-sonnet-20241022',
|
||||
'claude-3-5-sonnet',
|
||||
'claude-3-5-haiku',
|
||||
'claude-sonnet-4',
|
||||
'claude-opus-4-1',
|
||||
'claude-3-7-sonnet',
|
||||
|
||||
// 已经是Bedrock格式的
|
||||
'us.anthropic.claude-sonnet-4-20250514-v1:0',
|
||||
'anthropic.claude-3-5-haiku-20241022-v1:0',
|
||||
|
||||
// 未知模型
|
||||
'unknown-model'
|
||||
]
|
||||
|
||||
console.log('\n📋 模型映射测试结果:')
|
||||
testCases.forEach((testModel) => {
|
||||
const mappedModel = bedrockRelayService._mapToBedrockModel(testModel)
|
||||
const isChanged = mappedModel !== testModel
|
||||
const status = isChanged ? '🔄' : '✅'
|
||||
|
||||
console.log(`${status} ${testModel}`)
|
||||
if (isChanged) {
|
||||
console.log(` → ${mappedModel}`)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('\n✅ 模型映射测试完成')
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
testModelMapping()
|
||||
}
|
||||
|
||||
module.exports = { testModelMapping }
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 官方模型版本识别测试 - 最终版 v2
|
||||
*/
|
||||
|
||||
const { isOpus45OrNewer } = require('../src/utils/modelHelper')
|
||||
|
||||
// 官方模型
|
||||
const officialModels = [
|
||||
{ name: 'claude-3-opus-20240229', desc: 'Opus 3 (已弃用)', expectPro: false },
|
||||
{ name: 'claude-opus-4-20250514', desc: 'Opus 4.0', expectPro: false },
|
||||
{ name: 'claude-opus-4-1-20250805', desc: 'Opus 4.1', expectPro: false },
|
||||
{ name: 'claude-opus-4-5-20251101', desc: 'Opus 4.5', expectPro: true }
|
||||
]
|
||||
|
||||
// 非 Opus 模型
|
||||
const nonOpusModels = [
|
||||
{ name: 'claude-sonnet-4-20250514', desc: 'Sonnet 4' },
|
||||
{ name: 'claude-sonnet-4-5-20250929', desc: 'Sonnet 4.5' },
|
||||
{ name: 'claude-haiku-4-5-20251001', desc: 'Haiku 4.5' },
|
||||
{ name: 'claude-3-5-haiku-20241022', desc: 'Haiku 3.5' },
|
||||
{ name: 'claude-3-haiku-20240307', desc: 'Haiku 3' },
|
||||
{ name: 'claude-3-7-sonnet-20250219', desc: 'Sonnet 3.7 (已弃用)' }
|
||||
]
|
||||
|
||||
// 其他格式测试
|
||||
const otherFormats = [
|
||||
{ name: 'claude-opus-4.5', expected: true, desc: 'Opus 4.5 点分隔' },
|
||||
{ name: 'claude-opus-4-5', expected: true, desc: 'Opus 4.5 横线分隔' },
|
||||
{ name: 'opus-4.5', expected: true, desc: 'Opus 4.5 无前缀' },
|
||||
{ name: 'opus-4-5', expected: true, desc: 'Opus 4-5 无前缀' },
|
||||
{ name: 'opus-latest', expected: true, desc: 'Opus latest' },
|
||||
{ name: 'claude-opus-5', expected: true, desc: 'Opus 5 (未来)' },
|
||||
{ name: 'claude-opus-5-0', expected: true, desc: 'Opus 5.0 (未来)' },
|
||||
{ name: 'opus-4.0', expected: false, desc: 'Opus 4.0' },
|
||||
{ name: 'opus-4.1', expected: false, desc: 'Opus 4.1' },
|
||||
{ name: 'opus-4.4', expected: false, desc: 'Opus 4.4' },
|
||||
{ name: 'opus-4', expected: false, desc: 'Opus 4' },
|
||||
{ name: 'opus-4-0', expected: false, desc: 'Opus 4-0' },
|
||||
{ name: 'opus-4-1', expected: false, desc: 'Opus 4-1' },
|
||||
{ name: 'opus-4-4', expected: false, desc: 'Opus 4-4' },
|
||||
{ name: 'opus', expected: false, desc: '仅 opus' },
|
||||
{ name: null, expected: false, desc: 'null' },
|
||||
{ name: '', expected: false, desc: '空字符串' }
|
||||
]
|
||||
|
||||
console.log('='.repeat(90))
|
||||
console.log('官方模型版本识别测试 - 最终版 v2')
|
||||
console.log('='.repeat(90))
|
||||
console.log()
|
||||
|
||||
let passed = 0
|
||||
let failed = 0
|
||||
|
||||
// 测试官方 Opus 模型
|
||||
console.log('📌 官方 Opus 模型:')
|
||||
for (const m of officialModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expectPro ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expectPro) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const proSupport = result ? 'Pro 可用 ✅' : 'Pro 不可用 ❌'
|
||||
console.log(` ${status} | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${proSupport}`)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 非 Opus 模型 (不受此函数影响):')
|
||||
for (const m of nonOpusModels) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
console.log(
|
||||
` ➖ | ${m.name.padEnd(32)} | ${m.desc.padEnd(18)} | ${result ? '⚠️ 异常' : '正确跳过'}`
|
||||
)
|
||||
if (result) {
|
||||
failed++ // 非 Opus 模型不应返回 true
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('📌 其他格式测试:')
|
||||
for (const m of otherFormats) {
|
||||
const result = isOpus45OrNewer(m.name)
|
||||
const status = result === m.expected ? '✅ PASS' : '❌ FAIL'
|
||||
if (result === m.expected) {
|
||||
passed++
|
||||
} else {
|
||||
failed++
|
||||
}
|
||||
const display = m.name === null ? 'null' : m.name === '' ? '""' : m.name
|
||||
console.log(
|
||||
` ${status} | ${display.padEnd(25)} | ${m.desc.padEnd(18)} | ${result ? 'Pro 可用' : 'Pro 不可用'}`
|
||||
)
|
||||
}
|
||||
|
||||
console.log()
|
||||
console.log('='.repeat(90))
|
||||
console.log('测试结果:', passed, '通过,', failed, '失败')
|
||||
console.log('='.repeat(90))
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('\n❌ 有测试失败,请检查函数逻辑')
|
||||
process.exit(1)
|
||||
} else {
|
||||
console.log('\n✅ 所有测试通过!函数可以安全使用')
|
||||
process.exit(0)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// 测试定价服务的fallback机制
|
||||
async function testPricingFallback() {
|
||||
console.log('🧪 Testing pricing service fallback mechanism...\n')
|
||||
|
||||
// 备份现有的模型定价文件
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
const pricingFile = path.join(dataDir, 'model_pricing.json')
|
||||
const backupFile = path.join(dataDir, 'model_pricing.backup.json')
|
||||
|
||||
// 1. 备份现有文件
|
||||
if (fs.existsSync(pricingFile)) {
|
||||
console.log('📦 Backing up existing pricing file...')
|
||||
fs.copyFileSync(pricingFile, backupFile)
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 删除现有定价文件以触发fallback
|
||||
if (fs.existsSync(pricingFile)) {
|
||||
console.log('🗑️ Removing existing pricing file to test fallback...')
|
||||
fs.unlinkSync(pricingFile)
|
||||
}
|
||||
|
||||
// 3. 初始化定价服务
|
||||
console.log('🚀 Initializing pricing service...\n')
|
||||
|
||||
// 清除require缓存以确保重新加载
|
||||
delete require.cache[require.resolve('../src/services/pricingService')]
|
||||
const pricingService = require('../src/services/pricingService')
|
||||
|
||||
// 模拟网络失败,强制使用fallback
|
||||
const originalDownload = pricingService._downloadFromRemote
|
||||
pricingService._downloadFromRemote = function () {
|
||||
return Promise.reject(new Error('Simulated network failure for testing'))
|
||||
}
|
||||
|
||||
// 初始化服务
|
||||
await pricingService.initialize()
|
||||
|
||||
// 4. 验证fallback是否工作
|
||||
console.log('\n📊 Verifying fallback data...')
|
||||
const status = pricingService.getStatus()
|
||||
console.log(` - Initialized: ${status.initialized}`)
|
||||
console.log(` - Model count: ${status.modelCount}`)
|
||||
console.log(` - Last updated: ${status.lastUpdated}`)
|
||||
|
||||
// 5. 测试获取模型定价
|
||||
const testModels = ['claude-3-opus-20240229', 'gpt-4', 'gemini-pro']
|
||||
console.log('\n💰 Testing model pricing retrieval:')
|
||||
|
||||
for (const model of testModels) {
|
||||
const pricing = pricingService.getModelPricing(model)
|
||||
if (pricing) {
|
||||
console.log(` ✅ ${model}: Found pricing data`)
|
||||
} else {
|
||||
console.log(` ❌ ${model}: No pricing data`)
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 验证文件是否被创建
|
||||
if (fs.existsSync(pricingFile)) {
|
||||
console.log('\n✅ Fallback successfully created pricing file in data directory')
|
||||
const fileStats = fs.statSync(pricingFile)
|
||||
console.log(` - File size: ${(fileStats.size / 1024).toFixed(2)} KB`)
|
||||
} else {
|
||||
console.log('\n❌ Fallback failed to create pricing file')
|
||||
}
|
||||
|
||||
// 恢复原始下载函数
|
||||
pricingService._downloadFromRemote = originalDownload
|
||||
} finally {
|
||||
// 7. 恢复备份文件
|
||||
if (fs.existsSync(backupFile)) {
|
||||
console.log('\n📦 Restoring original pricing file...')
|
||||
fs.copyFileSync(backupFile, pricingFile)
|
||||
fs.unlinkSync(backupFile)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✨ Fallback mechanism test completed!')
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testPricingFallback().catch((error) => {
|
||||
console.error('❌ Test failed:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 测试 web-dist 分支构建和获取流程
|
||||
# 用于验证 CI/CD 流程和 manage.sh 的修改
|
||||
|
||||
# 颜色定义
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;36m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
print_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
# 测试构建并推送到 web-dist 分支
|
||||
test_build_and_push() {
|
||||
print_info "开始测试构建和推送流程..."
|
||||
|
||||
# 检查是否在项目根目录
|
||||
if [ ! -f "package.json" ] || [ ! -d "web/admin-spa" ]; then
|
||||
print_error "请在项目根目录运行此脚本"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 构建前端
|
||||
print_info "构建前端..."
|
||||
cd web/admin-spa
|
||||
|
||||
# 检查 node_modules
|
||||
if [ ! -d "node_modules" ]; then
|
||||
print_info "安装前端依赖..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 执行构建
|
||||
npm run build
|
||||
|
||||
if [ ! -d "dist" ]; then
|
||||
print_error "构建失败,dist 目录不存在"
|
||||
cd ../..
|
||||
return 1
|
||||
fi
|
||||
|
||||
print_success "前端构建成功"
|
||||
cd ../..
|
||||
|
||||
# 创建临时目录保存构建产物
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
print_info "复制构建产物到临时目录: $TEMP_DIR"
|
||||
cp -r web/admin-spa/dist/* "$TEMP_DIR/"
|
||||
|
||||
# 配置 git
|
||||
git config user.name "Test User"
|
||||
git config user.email "test@example.com"
|
||||
|
||||
# 检查 web-dist 分支是否存在
|
||||
print_info "检查 web-dist 分支..."
|
||||
if git ls-remote --heads origin web-dist | grep -q web-dist; then
|
||||
print_info "web-dist 分支已存在,获取最新版本"
|
||||
git fetch origin web-dist:web-dist
|
||||
git checkout web-dist
|
||||
else
|
||||
print_info "创建新的 web-dist 分支"
|
||||
git checkout --orphan web-dist
|
||||
fi
|
||||
|
||||
# 清空当前目录(保留 .git)
|
||||
git rm -rf . 2>/dev/null || true
|
||||
|
||||
# 复制构建产物
|
||||
cp -r "$TEMP_DIR"/* .
|
||||
|
||||
# 添加 README
|
||||
cat > README.md << EOF
|
||||
# Claude Relay Service - Web Frontend Build
|
||||
|
||||
This branch contains the pre-built frontend assets for Claude Relay Service.
|
||||
|
||||
**DO NOT EDIT FILES IN THIS BRANCH DIRECTLY**
|
||||
|
||||
These files are automatically generated by the CI/CD pipeline.
|
||||
|
||||
Test Build Date: $(date -u +"%Y-%m-%d %H:%M:%S UTC")
|
||||
EOF
|
||||
|
||||
# 提交
|
||||
git add -A
|
||||
git commit -m "test: frontend build test $(date +%Y%m%d%H%M%S)"
|
||||
|
||||
print_success "本地 web-dist 分支创建成功"
|
||||
print_warning "注意:这只是本地测试,没有推送到远程仓库"
|
||||
print_info "如需推送,请运行: git push origin web-dist --force"
|
||||
|
||||
# 切换回主分支
|
||||
git checkout main
|
||||
|
||||
# 清理临时目录
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
print_success "测试完成"
|
||||
}
|
||||
|
||||
# 测试从 web-dist 分支获取文件
|
||||
test_fetch_from_web_dist() {
|
||||
print_info "测试从 web-dist 分支获取文件..."
|
||||
|
||||
# 创建测试目录
|
||||
TEST_DIR="test-web-dist-fetch"
|
||||
rm -rf "$TEST_DIR"
|
||||
mkdir -p "$TEST_DIR"
|
||||
|
||||
# 检查远程 web-dist 分支
|
||||
if ! git ls-remote --heads origin web-dist | grep -q web-dist; then
|
||||
print_warning "远程 web-dist 分支不存在"
|
||||
print_info "尝试使用本地 web-dist 分支..."
|
||||
|
||||
# 检查本地分支
|
||||
if ! git branch | grep -q web-dist; then
|
||||
print_error "本地和远程都没有 web-dist 分支"
|
||||
rm -rf "$TEST_DIR"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
print_info "克隆 web-dist 分支到测试目录..."
|
||||
|
||||
# 创建临时目录用于 clone
|
||||
TEMP_CLONE_DIR=$(mktemp -d)
|
||||
|
||||
# 获取仓库 URL
|
||||
REPO_URL=$(git config --get remote.origin.url)
|
||||
|
||||
# 克隆 web-dist 分支
|
||||
if git clone --depth 1 --branch web-dist --single-branch "$REPO_URL" "$TEMP_CLONE_DIR" 2>/dev/null; then
|
||||
print_success "成功克隆 web-dist 分支"
|
||||
|
||||
# 复制文件(排除 .git 和 README.md)
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -av --exclude='.git' --exclude='README.md' "$TEMP_CLONE_DIR/" "$TEST_DIR/"
|
||||
else
|
||||
cp -r "$TEMP_CLONE_DIR"/* "$TEST_DIR/" 2>/dev/null
|
||||
rm -rf "$TEST_DIR/.git" 2>/dev/null
|
||||
rm -f "$TEST_DIR/README.md" 2>/dev/null
|
||||
fi
|
||||
|
||||
print_success "文件复制成功"
|
||||
print_info "测试目录内容:"
|
||||
ls -la "$TEST_DIR" | head -10
|
||||
|
||||
# 验证关键文件
|
||||
if [ -f "$TEST_DIR/index.html" ]; then
|
||||
print_success "✓ index.html 文件存在"
|
||||
else
|
||||
print_error "✗ index.html 文件不存在"
|
||||
fi
|
||||
|
||||
if [ -d "$TEST_DIR/assets" ]; then
|
||||
print_success "✓ assets 目录存在"
|
||||
else
|
||||
print_error "✗ assets 目录不存在"
|
||||
fi
|
||||
|
||||
else
|
||||
print_error "克隆 web-dist 分支失败"
|
||||
print_info "可能需要先运行: test_build_and_push"
|
||||
fi
|
||||
|
||||
# 清理
|
||||
rm -rf "$TEMP_CLONE_DIR"
|
||||
rm -rf "$TEST_DIR"
|
||||
|
||||
print_success "获取测试完成"
|
||||
}
|
||||
|
||||
# 显示帮助
|
||||
show_help() {
|
||||
echo "用法: $0 [命令]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " build - 测试构建并创建本地 web-dist 分支"
|
||||
echo " fetch - 测试从 web-dist 分支获取文件"
|
||||
echo " all - 运行所有测试"
|
||||
echo " help - 显示帮助"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
case "$1" in
|
||||
build)
|
||||
test_build_and_push
|
||||
;;
|
||||
fetch)
|
||||
test_fetch_from_web_dist
|
||||
;;
|
||||
all)
|
||||
test_build_and_push
|
||||
echo ""
|
||||
test_fetch_from_web_dist
|
||||
;;
|
||||
help)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
print_error "未知命令: $1"
|
||||
echo ""
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 运行主函数
|
||||
main "$@"
|
||||
@@ -0,0 +1,78 @@
|
||||
const axios = require('axios')
|
||||
|
||||
const BASE_URL = 'http://localhost:3312'
|
||||
|
||||
// 你需要替换为一个有效的 API Key
|
||||
const API_KEY = 'cr_your_api_key_here'
|
||||
|
||||
async function testWindowRemaining() {
|
||||
try {
|
||||
console.log('🔍 测试时间窗口剩余时间功能...\n')
|
||||
|
||||
// 第一步:获取 API Key ID
|
||||
console.log('1. 获取 API Key ID...')
|
||||
const idResponse = await axios.post(`${BASE_URL}/api-stats/api/get-key-id`, {
|
||||
apiKey: API_KEY
|
||||
})
|
||||
|
||||
if (!idResponse.data.success) {
|
||||
throw new Error('Failed to get API Key ID')
|
||||
}
|
||||
|
||||
const apiId = idResponse.data.data.id
|
||||
console.log(` ✅ API Key ID: ${apiId}\n`)
|
||||
|
||||
// 第二步:查询统计数据
|
||||
console.log('2. 查询统计数据(包含时间窗口信息)...')
|
||||
const statsResponse = await axios.post(`${BASE_URL}/api-stats/api/user-stats`, {
|
||||
apiId
|
||||
})
|
||||
|
||||
if (!statsResponse.data.success) {
|
||||
throw new Error('Failed to get stats data')
|
||||
}
|
||||
|
||||
const stats = statsResponse.data.data
|
||||
console.log(` ✅ 成功获取统计数据\n`)
|
||||
|
||||
// 第三步:检查时间窗口信息
|
||||
console.log('3. 时间窗口信息:')
|
||||
console.log(` - 窗口时长: ${stats.limits.rateLimitWindow} 分钟`)
|
||||
console.log(` - 请求限制: ${stats.limits.rateLimitRequests || '无限制'}`)
|
||||
console.log(` - Token限制: ${stats.limits.tokenLimit || '无限制'}`)
|
||||
console.log(` - 当前请求数: ${stats.limits.currentWindowRequests}`)
|
||||
console.log(` - 当前Token数: ${stats.limits.currentWindowTokens}`)
|
||||
|
||||
if (stats.limits.windowStartTime) {
|
||||
const startTime = new Date(stats.limits.windowStartTime)
|
||||
const endTime = new Date(stats.limits.windowEndTime)
|
||||
|
||||
console.log(`\n ⏰ 时间窗口状态:`)
|
||||
console.log(` - 窗口开始时间: ${startTime.toLocaleString()}`)
|
||||
console.log(` - 窗口结束时间: ${endTime.toLocaleString()}`)
|
||||
console.log(` - 剩余时间: ${stats.limits.windowRemainingSeconds} 秒`)
|
||||
|
||||
if (stats.limits.windowRemainingSeconds > 0) {
|
||||
const minutes = Math.floor(stats.limits.windowRemainingSeconds / 60)
|
||||
const seconds = stats.limits.windowRemainingSeconds % 60
|
||||
console.log(` - 格式化剩余时间: ${minutes}分${seconds}秒`)
|
||||
console.log(` - 窗口状态: 🟢 活跃中`)
|
||||
} else {
|
||||
console.log(` - 窗口状态: 🔴 已过期(下次请求时重置)`)
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ⏰ 时间窗口状态: ⚪ 未启动(还没有任何请求)`)
|
||||
}
|
||||
|
||||
console.log('\n✅ 测试完成!时间窗口剩余时间功能正常工作。')
|
||||
} catch (error) {
|
||||
console.error('❌ 测试失败:', error.message)
|
||||
if (error.response) {
|
||||
console.error(' 响应数据:', error.response.data)
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// 运行测试
|
||||
testWindowRemaining()
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 手动更新模型价格数据脚本
|
||||
* 从价格镜像分支下载最新的模型价格和上下文窗口信息
|
||||
*/
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const https = require('https')
|
||||
const crypto = require('crypto')
|
||||
const pricingSource = require('../config/pricingSource')
|
||||
|
||||
// 颜色输出
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
bright: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
blue: '\x1b[36m',
|
||||
magenta: '\x1b[35m'
|
||||
}
|
||||
|
||||
// 日志函数
|
||||
const log = {
|
||||
info: (msg) => console.log(`${colors.blue}[INFO]${colors.reset} ${msg}`),
|
||||
success: (msg) => console.log(`${colors.green}[SUCCESS]${colors.reset} ${msg}`),
|
||||
error: (msg) => console.error(`${colors.red}[ERROR]${colors.reset} ${msg}`),
|
||||
warn: (msg) => console.warn(`${colors.yellow}[WARNING]${colors.reset} ${msg}`)
|
||||
}
|
||||
|
||||
// 配置
|
||||
const config = {
|
||||
dataDir: path.join(process.cwd(), 'data'),
|
||||
pricingFile: path.join(process.cwd(), 'data', 'model_pricing.json'),
|
||||
hashFile: path.join(process.cwd(), 'data', 'model_pricing.sha256'),
|
||||
pricingUrl: pricingSource.pricingUrl,
|
||||
fallbackFile: path.join(
|
||||
process.cwd(),
|
||||
'resources',
|
||||
'model-pricing',
|
||||
'model_prices_and_context_window.json'
|
||||
),
|
||||
backupFile: path.join(process.cwd(), 'data', 'model_pricing.backup.json'),
|
||||
timeout: 30000 // 30秒超时
|
||||
}
|
||||
|
||||
// 创建数据目录
|
||||
function ensureDataDir() {
|
||||
if (!fs.existsSync(config.dataDir)) {
|
||||
fs.mkdirSync(config.dataDir, { recursive: true })
|
||||
log.info('Created data directory')
|
||||
}
|
||||
}
|
||||
|
||||
// 备份现有文件
|
||||
function backupExistingFile() {
|
||||
if (fs.existsSync(config.pricingFile)) {
|
||||
try {
|
||||
fs.copyFileSync(config.pricingFile, config.backupFile)
|
||||
log.info('Backed up existing pricing file')
|
||||
return true
|
||||
} catch (error) {
|
||||
log.warn(`Failed to backup existing file: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 恢复备份
|
||||
function restoreBackup() {
|
||||
if (fs.existsSync(config.backupFile)) {
|
||||
try {
|
||||
fs.copyFileSync(config.backupFile, config.pricingFile)
|
||||
log.info('Restored from backup')
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error(`Failed to restore backup: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 下载价格数据
|
||||
function downloadPricingData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
log.info('正在从价格镜像分支拉取最新的模型价格数据...')
|
||||
log.info(`拉取地址: ${config.pricingUrl}`)
|
||||
|
||||
const request = https.get(config.pricingUrl, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`HTTP ${response.statusCode}: ${response.statusMessage}`))
|
||||
return
|
||||
}
|
||||
|
||||
let data = ''
|
||||
let downloadedBytes = 0
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk
|
||||
downloadedBytes += chunk.length
|
||||
// 显示下载进度
|
||||
process.stdout.write(`\rDownloading... ${Math.round(downloadedBytes / 1024)}KB`)
|
||||
})
|
||||
|
||||
response.on('end', () => {
|
||||
process.stdout.write('\n') // 换行
|
||||
try {
|
||||
const jsonData = JSON.parse(data)
|
||||
|
||||
// 验证数据结构
|
||||
if (typeof jsonData !== 'object' || Object.keys(jsonData).length === 0) {
|
||||
throw new Error('Invalid pricing data structure')
|
||||
}
|
||||
|
||||
// 保存到文件
|
||||
const formattedJson = JSON.stringify(jsonData, null, 2)
|
||||
fs.writeFileSync(config.pricingFile, formattedJson)
|
||||
|
||||
const hash = crypto.createHash('sha256').update(formattedJson).digest('hex')
|
||||
fs.writeFileSync(config.hashFile, `${hash}\n`)
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
const fileSize = Math.round(fs.statSync(config.pricingFile).size / 1024)
|
||||
|
||||
log.success(`Downloaded pricing data for ${modelCount} models (${fileSize}KB)`)
|
||||
|
||||
// 显示一些统计信息
|
||||
const claudeModels = Object.keys(jsonData).filter((k) => k.includes('claude')).length
|
||||
const gptModels = Object.keys(jsonData).filter((k) => k.includes('gpt')).length
|
||||
const geminiModels = Object.keys(jsonData).filter((k) => k.includes('gemini')).length
|
||||
|
||||
log.info('Model breakdown:')
|
||||
log.info(` - Claude models: ${claudeModels}`)
|
||||
log.info(` - GPT models: ${gptModels}`)
|
||||
log.info(` - Gemini models: ${geminiModels}`)
|
||||
log.info(` - Other models: ${modelCount - claudeModels - gptModels - geminiModels}`)
|
||||
|
||||
resolve(jsonData)
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse pricing data: ${error.message}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
request.on('error', (error) => {
|
||||
reject(new Error(`Network error: ${error.message}`))
|
||||
})
|
||||
|
||||
request.setTimeout(config.timeout, () => {
|
||||
request.destroy()
|
||||
reject(new Error(`Download timeout after ${config.timeout / 1000} seconds`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 使用 fallback 文件
|
||||
function useFallback() {
|
||||
log.warn('Attempting to use fallback pricing data...')
|
||||
|
||||
if (!fs.existsSync(config.fallbackFile)) {
|
||||
log.error(`Fallback file not found: ${config.fallbackFile}`)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const fallbackData = fs.readFileSync(config.fallbackFile, 'utf8')
|
||||
const jsonData = JSON.parse(fallbackData)
|
||||
|
||||
// 保存到data目录
|
||||
fs.writeFileSync(config.pricingFile, JSON.stringify(jsonData, null, 2))
|
||||
|
||||
const modelCount = Object.keys(jsonData).length
|
||||
log.warn(`Using fallback pricing data for ${modelCount} models`)
|
||||
log.info('Note: Fallback data may be outdated. Try updating again later.')
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error(`Failed to use fallback: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 显示当前状态
|
||||
function showCurrentStatus() {
|
||||
if (fs.existsSync(config.pricingFile)) {
|
||||
const stats = fs.statSync(config.pricingFile)
|
||||
const fileAge = Date.now() - stats.mtime.getTime()
|
||||
const ageInHours = Math.round(fileAge / (60 * 60 * 1000))
|
||||
const ageInDays = Math.floor(ageInHours / 24)
|
||||
|
||||
let ageString = ''
|
||||
if (ageInDays > 0) {
|
||||
ageString = `${ageInDays} day${ageInDays > 1 ? 's' : ''} and ${ageInHours % 24} hour${ageInHours % 24 !== 1 ? 's' : ''}`
|
||||
} else {
|
||||
ageString = `${ageInHours} hour${ageInHours !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
log.info(`Current pricing file age: ${ageString}`)
|
||||
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(config.pricingFile, 'utf8'))
|
||||
log.info(`Current file contains ${Object.keys(data).length} models`)
|
||||
} catch (error) {
|
||||
log.warn('Current file exists but could not be parsed')
|
||||
}
|
||||
} else {
|
||||
log.info('No existing pricing file found')
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log(`${colors.bright}${colors.blue}======================================${colors.reset}`)
|
||||
console.log(`${colors.bright} Model Pricing Update Tool${colors.reset}`)
|
||||
console.log(
|
||||
`${colors.bright}${colors.blue}======================================${colors.reset}\n`
|
||||
)
|
||||
|
||||
// 显示当前状态
|
||||
showCurrentStatus()
|
||||
console.log('')
|
||||
|
||||
// 确保数据目录存在
|
||||
ensureDataDir()
|
||||
|
||||
// 备份现有文件
|
||||
const hasBackup = backupExistingFile()
|
||||
|
||||
try {
|
||||
// 尝试下载最新数据
|
||||
await downloadPricingData()
|
||||
|
||||
// 清理备份文件(成功下载后)
|
||||
if (hasBackup && fs.existsSync(config.backupFile)) {
|
||||
fs.unlinkSync(config.backupFile)
|
||||
log.info('Cleaned up backup file')
|
||||
}
|
||||
|
||||
console.log(`\n${colors.green}✅ Model pricing updated successfully!${colors.reset}`)
|
||||
process.exit(0)
|
||||
} catch (error) {
|
||||
log.error(`Download failed: ${error.message}`)
|
||||
|
||||
// 尝试恢复备份
|
||||
if (hasBackup) {
|
||||
if (restoreBackup()) {
|
||||
log.info('Original file restored')
|
||||
}
|
||||
}
|
||||
|
||||
// 尝试使用 fallback
|
||||
if (useFallback()) {
|
||||
console.log(
|
||||
`\n${colors.yellow}⚠️ Using fallback data (update completed with warnings)${colors.reset}`
|
||||
)
|
||||
process.exit(0)
|
||||
} else {
|
||||
console.log(`\n${colors.red}❌ Failed to update model pricing${colors.reset}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理未捕获的错误
|
||||
process.on('unhandledRejection', (error) => {
|
||||
log.error(`Unhandled error: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// 运行主函数
|
||||
main().catch((error) => {
|
||||
log.error(`Fatal error: ${error.message}`)
|
||||
process.exit(1)
|
||||
})
|
||||
Reference in New Issue
Block a user