chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:48:39 +08:00
commit d6029f17d3
368 changed files with 206753 additions and 0 deletions
+606
View File
@@ -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
+53
View File
@@ -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
+738
View File
@@ -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)
})
+126
View File
@@ -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)
})
+29
View File
@@ -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)
}
+227
View File
@@ -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)
})
+280
View File
@@ -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)
})
+561
View File
@@ -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 }
+333
View File
@@ -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
+1830
View File
File diff suppressed because it is too large Load Diff
+193
View File
@@ -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)
})
+138
View File
@@ -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)
})
+273
View File
@@ -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()
+128
View File
@@ -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 }
+262
View File
@@ -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
+143
View File
@@ -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()
+141
View File
@@ -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()
+33
View File
@@ -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 }
+340
View File
@@ -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()
+133
View File
@@ -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()
+145
View File
@@ -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()
+549
View File
@@ -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()
+47
View File
@@ -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 }
+108
View File
@@ -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)
}
+91
View File
@@ -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)
})
+227
View File
@@ -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 "$@"
+78
View File
@@ -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()
+278
View File
@@ -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)
})