Merge remote-tracking branch 'origin/main' into dev

This commit is contained in:
shaw
2025-07-28 09:30:33 +08:00
45 changed files with 9066 additions and 553 deletions

View File

@@ -0,0 +1,994 @@
#!/usr/bin/env node
/**
* 增强版数据导出/导入工具
* 支持加密数据的处理
*/
const fs = require('fs').promises;
const crypto = require('crypto');
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const readline = require('readline');
const config = require('../config/config');
// 解析命令行参数
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');
});
});
}
// Claude 账户解密函数
function decryptClaudeData(encryptedData) {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
// Gemini 账户解密函数
function decryptGeminiData(encryptedData) {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
// 数据加密函数(用于导入)
function encryptClaudeData(data) {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
function encryptGeminiData(data) {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, 'gemini-account-salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// 导出使用统计数据
async function exportUsageStats(keyId) {
try {
const stats = {
total: {},
daily: {},
monthly: {},
hourly: {},
models: {}
};
// 导出总统计
const totalKey = `usage:${keyId}`;
const totalData = await redis.client.hgetall(totalKey);
if (totalData && Object.keys(totalData).length > 0) {
stats.total = totalData;
}
// 导出每日统计最近30天
const today = new Date();
for (let i = 0; i < 30; i++) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const dailyKey = `usage:daily:${keyId}:${dateStr}`;
const dailyData = await redis.client.hgetall(dailyKey);
if (dailyData && Object.keys(dailyData).length > 0) {
stats.daily[dateStr] = dailyData;
}
}
// 导出每月统计最近12个月
for (let i = 0; i < 12; i++) {
const date = new Date(today);
date.setMonth(date.getMonth() - i);
const monthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
const monthlyKey = `usage:monthly:${keyId}:${monthStr}`;
const monthlyData = await redis.client.hgetall(monthlyKey);
if (monthlyData && Object.keys(monthlyData).length > 0) {
stats.monthly[monthStr] = monthlyData;
}
}
// 导出小时统计最近24小时
for (let i = 0; i < 24; i++) {
const date = new Date(today);
date.setHours(date.getHours() - i);
const dateStr = date.toISOString().split('T')[0];
const hour = String(date.getHours()).padStart(2, '0');
const hourKey = `${dateStr}:${hour}`;
const hourlyKey = `usage:hourly:${keyId}:${hourKey}`;
const hourlyData = await redis.client.hgetall(hourlyKey);
if (hourlyData && Object.keys(hourlyData).length > 0) {
stats.hourly[hourKey] = hourlyData;
}
}
// 导出模型统计
// 每日模型统计
const modelDailyPattern = `usage:${keyId}:model:daily:*`;
const modelDailyKeys = await redis.client.keys(modelDailyPattern);
for (const key of modelDailyKeys) {
const match = key.match(/usage:.+:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
if (match) {
const model = match[1];
const date = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} };
stats.models[model].daily[date] = data;
}
}
}
// 每月模型统计
const modelMonthlyPattern = `usage:${keyId}:model:monthly:*`;
const modelMonthlyKeys = await redis.client.keys(modelMonthlyPattern);
for (const key of modelMonthlyKeys) {
const match = key.match(/usage:.+:model:monthly:(.+):(\d{4}-\d{2})$/);
if (match) {
const model = match[1];
const month = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!stats.models[model]) stats.models[model] = { daily: {}, monthly: {} };
stats.models[model].monthly[month] = data;
}
}
}
return stats;
} catch (error) {
logger.warn(`⚠️ Failed to export usage stats for ${keyId}: ${error.message}`);
return null;
}
}
// 导入使用统计数据
async function importUsageStats(keyId, stats) {
try {
if (!stats) return;
const pipeline = redis.client.pipeline();
let importCount = 0;
// 导入总统计
if (stats.total && Object.keys(stats.total).length > 0) {
for (const [field, value] of Object.entries(stats.total)) {
pipeline.hset(`usage:${keyId}`, field, value);
}
importCount++;
}
// 导入每日统计
if (stats.daily) {
for (const [date, data] of Object.entries(stats.daily)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:daily:${keyId}:${date}`, field, value);
}
importCount++;
}
}
// 导入每月统计
if (stats.monthly) {
for (const [month, data] of Object.entries(stats.monthly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:monthly:${keyId}:${month}`, field, value);
}
importCount++;
}
}
// 导入小时统计
if (stats.hourly) {
for (const [hour, data] of Object.entries(stats.hourly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:hourly:${keyId}:${hour}`, field, value);
}
importCount++;
}
}
// 导入模型统计
if (stats.models) {
for (const [model, modelStats] of Object.entries(stats.models)) {
// 每日模型统计
if (modelStats.daily) {
for (const [date, data] of Object.entries(modelStats.daily)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:${keyId}:model:daily:${model}:${date}`, field, value);
}
importCount++;
}
}
// 每月模型统计
if (modelStats.monthly) {
for (const [month, data] of Object.entries(modelStats.monthly)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:${keyId}:model:monthly:${model}:${month}`, field, value);
}
importCount++;
}
}
}
}
await pipeline.exec();
logger.info(` 📊 Imported ${importCount} usage stat entries for API Key ${keyId}`);
} catch (error) {
logger.warn(`⚠️ Failed to import usage stats for ${keyId}: ${error.message}`);
}
}
// 数据脱敏函数
function sanitizeData(data, type) {
const sanitized = { ...data };
switch (type) {
case 'apikey':
if (sanitized.apiKey) {
sanitized.apiKey = sanitized.apiKey.substring(0, 10) + '...[REDACTED]';
}
break;
case 'claude_account':
if (sanitized.email) sanitized.email = '[REDACTED]';
if (sanitized.password) sanitized.password = '[REDACTED]';
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 'gemini_account':
if (sanitized.geminiOauth) sanitized.geminiOauth = '[REDACTED]';
if (sanitized.accessToken) sanitized.accessToken = '[REDACTED]';
if (sanitized.refreshToken) sanitized.refreshToken = '[REDACTED]';
if (sanitized.proxyPassword) sanitized.proxyPassword = '[REDACTED]';
break;
case 'admin':
if (sanitized.password) sanitized.password = '[REDACTED]';
break;
}
return sanitized;
}
// 导出数据
async function exportData() {
try {
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`;
const types = params.types ? params.types.split(',') : ['all'];
const shouldSanitize = params.sanitize === true;
const shouldDecrypt = params.decrypt !== false; // 默认解密
logger.info('🔄 Starting data export...');
logger.info(`📁 Output file: ${outputFile}`);
logger.info(`📋 Data types: ${types.join(', ')}`);
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`);
logger.info(`🔓 Decrypt data: ${shouldDecrypt ? 'YES' : 'NO'}`);
await redis.connect();
logger.success('✅ Connected to Redis');
const exportData = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: shouldSanitize,
decrypted: shouldDecrypt,
types: 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;
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 获取该 API Key 的 ID
const keyId = data.id;
// 导出使用统计数据
if (keyId && (types.includes('all') || types.includes('stats'))) {
data.usageStats = await exportUsageStats(keyId);
}
apiKeys.push(shouldSanitize ? sanitizeData(data, 'apikey') : data);
}
}
exportData.data.apiKeys = apiKeys;
logger.success(`✅ Exported ${apiKeys.length} API Keys`);
}
// 导出 Claude 账户
if (types.includes('all') || types.includes('accounts')) {
logger.info('📤 Exporting Claude accounts...');
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) {
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 解密敏感字段
if (shouldDecrypt && !shouldSanitize) {
if (data.email) data.email = decryptClaudeData(data.email);
if (data.password) data.password = decryptClaudeData(data.password);
if (data.accessToken) data.accessToken = decryptClaudeData(data.accessToken);
if (data.refreshToken) data.refreshToken = decryptClaudeData(data.refreshToken);
if (data.claudeAiOauth) {
const decrypted = decryptClaudeData(data.claudeAiOauth);
try {
data.claudeAiOauth = JSON.parse(decrypted);
} catch (e) {
data.claudeAiOauth = decrypted;
}
}
}
accounts.push(shouldSanitize ? sanitizeData(data, 'claude_account') : data);
}
}
exportData.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) {
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
// 解密敏感字段
if (shouldDecrypt && !shouldSanitize) {
if (data.geminiOauth) {
const decrypted = decryptGeminiData(data.geminiOauth);
try {
data.geminiOauth = JSON.parse(decrypted);
} catch (e) {
data.geminiOauth = decrypted;
}
}
if (data.accessToken) data.accessToken = decryptGeminiData(data.accessToken);
if (data.refreshToken) data.refreshToken = decryptGeminiData(data.refreshToken);
}
geminiAccounts.push(shouldSanitize ? sanitizeData(data, 'gemini_account') : data);
}
}
exportData.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;
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
admins.push(shouldSanitize ? sanitizeData(data, 'admin') : data);
}
}
exportData.data.admins = admins;
logger.success(`✅ Exported ${admins.length} admins`);
}
// 导出全局模型统计(如果需要)
if (types.includes('all') || types.includes('stats')) {
logger.info('📤 Exporting global model statistics...');
const globalStats = {
daily: {},
monthly: {},
hourly: {}
};
// 导出全局每日模型统计
const globalDailyPattern = 'usage:model:daily:*';
const globalDailyKeys = await redis.client.keys(globalDailyPattern);
for (const key of globalDailyKeys) {
const match = key.match(/usage:model:daily:(.+):(\d{4}-\d{2}-\d{2})$/);
if (match) {
const model = match[1];
const date = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.daily[date]) globalStats.daily[date] = {};
globalStats.daily[date][model] = data;
}
}
}
// 导出全局每月模型统计
const globalMonthlyPattern = 'usage:model:monthly:*';
const globalMonthlyKeys = await redis.client.keys(globalMonthlyPattern);
for (const key of globalMonthlyKeys) {
const match = key.match(/usage:model:monthly:(.+):(\d{4}-\d{2})$/);
if (match) {
const model = match[1];
const month = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.monthly[month]) globalStats.monthly[month] = {};
globalStats.monthly[month][model] = data;
}
}
}
// 导出全局每小时模型统计
const globalHourlyPattern = 'usage:model:hourly:*';
const globalHourlyKeys = await redis.client.keys(globalHourlyPattern);
for (const key of globalHourlyKeys) {
const match = key.match(/usage:model:hourly:(.+):(\d{4}-\d{2}-\d{2}:\d{2})$/);
if (match) {
const model = match[1];
const hour = match[2];
const data = await redis.client.hgetall(key);
if (data && Object.keys(data).length > 0) {
if (!globalStats.hourly[hour]) globalStats.hourly[hour] = {};
globalStats.hourly[hour][model] = data;
}
}
}
exportData.data.globalModelStats = globalStats;
logger.success(`✅ Exported global model statistics`);
}
// 写入文件
await fs.writeFile(outputFile, JSON.stringify(exportData, 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 (exportData.data.apiKeys) {
console.log(`API Keys: ${exportData.data.apiKeys.length}`);
}
if (exportData.data.claudeAccounts) {
console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`);
}
if (exportData.data.geminiAccounts) {
console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`);
}
if (exportData.data.admins) {
console.log(`Admins: ${exportData.data.admins.length}`);
}
console.log('='.repeat(60));
if (shouldSanitize) {
logger.warn('⚠️ Sensitive data has been sanitized in this export.');
}
if (shouldDecrypt) {
logger.info('🔓 Encrypted data has been decrypted for portability.');
}
} catch (error) {
logger.error('💥 Export failed:', error);
process.exit(1);
} finally {
await redis.disconnect();
rl.close();
}
}
// 显示帮助信息
function showHelp() {
console.log(`
Enhanced Data Transfer Tool for Claude Relay Service
This tool handles encrypted data export/import between environments.
Usage:
node scripts/data-transfer-enhanced.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)
--types=TYPE,... Data types: apikeys,accounts,admins,stats,all (default: all)
stats: Include usage statistics with API keys
--sanitize Remove sensitive data from export
--decrypt=false Keep data encrypted (default: true - decrypt for portability)
Import Options:
--input=FILE Input filename (required)
--force Overwrite existing data without asking
--skip-conflicts Skip conflicting data without asking
Important Notes:
- The tool automatically handles encryption/decryption during import
- If importing decrypted data, it will be re-encrypted automatically
- If importing encrypted data, it will be stored as-is
- Sanitized exports cannot be properly imported (missing sensitive data)
Examples:
# Export all data with decryption (for migration)
node scripts/data-transfer-enhanced.js export
# Export without decrypting (for backup)
node scripts/data-transfer-enhanced.js export --decrypt=false
# Import data (auto-handles encryption)
node scripts/data-transfer-enhanced.js import --input=backup.json
# Import with force overwrite
node scripts/data-transfer-enhanced.js import --input=backup.json --force
`);
}
// 导入数据
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 importData = JSON.parse(fileContent);
// 验证文件格式
if (!importData.metadata || !importData.data) {
logger.error('❌ Invalid backup file format');
process.exit(1);
}
logger.info(`📅 Backup date: ${importData.metadata.exportDate}`);
logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`);
logger.info(`🔓 Decrypted: ${importData.metadata.decrypted ? 'YES' : 'NO'}`);
if (importData.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 (importData.data.apiKeys) {
console.log(`API Keys to import: ${importData.data.apiKeys.length}`);
}
if (importData.data.claudeAccounts) {
console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`);
}
if (importData.data.geminiAccounts) {
console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`);
}
if (importData.data.admins) {
console.log(`Admins to import: ${importData.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 (importData.data.apiKeys) {
logger.info('\n📥 Importing API Keys...');
for (const apiKey of importData.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;
}
}
}
// 保存使用统计数据以便单独导入
const usageStats = apiKey.usageStats;
// 从apiKey对象中删除usageStats字段避免存储到主键中
const apiKeyData = { ...apiKey };
delete apiKeyData.usageStats;
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(apiKeyData)) {
pipeline.hset(`apikey:${apiKey.id}`, field, value);
}
await pipeline.exec();
// 更新哈希映射
if (apiKey.apiKey && !importData.metadata.sanitized) {
await redis.client.hset('apikey:hash_map', apiKey.apiKey, apiKey.id);
}
// 导入使用统计数据
if (usageStats) {
await importUsageStats(apiKey.id, usageStats);
}
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 (importData.data.claudeAccounts) {
logger.info('\n📥 Importing Claude accounts...');
for (const account of importData.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;
}
}
}
// 复制账户数据以避免修改原始数据
const accountData = { ...account };
// 如果数据已解密且不是脱敏数据,需要重新加密
if (importData.metadata.decrypted && !importData.metadata.sanitized) {
logger.info(`🔐 Re-encrypting sensitive data for Claude account: ${account.name}`);
if (accountData.email) accountData.email = encryptClaudeData(accountData.email);
if (accountData.password) accountData.password = encryptClaudeData(accountData.password);
if (accountData.accessToken) accountData.accessToken = encryptClaudeData(accountData.accessToken);
if (accountData.refreshToken) accountData.refreshToken = encryptClaudeData(accountData.refreshToken);
if (accountData.claudeAiOauth) {
// 如果是对象,先序列化再加密
const oauthStr = typeof accountData.claudeAiOauth === 'object' ?
JSON.stringify(accountData.claudeAiOauth) : accountData.claudeAiOauth;
accountData.claudeAiOauth = encryptClaudeData(oauthStr);
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(accountData)) {
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 (importData.data.geminiAccounts) {
logger.info('\n📥 Importing Gemini accounts...');
for (const account of importData.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;
}
}
}
// 复制账户数据以避免修改原始数据
const accountData = { ...account };
// 如果数据已解密且不是脱敏数据,需要重新加密
if (importData.metadata.decrypted && !importData.metadata.sanitized) {
logger.info(`🔐 Re-encrypting sensitive data for Gemini account: ${account.name}`);
if (accountData.geminiOauth) {
const oauthStr = typeof accountData.geminiOauth === 'object' ?
JSON.stringify(accountData.geminiOauth) : accountData.geminiOauth;
accountData.geminiOauth = encryptGeminiData(oauthStr);
}
if (accountData.accessToken) accountData.accessToken = encryptGeminiData(accountData.accessToken);
if (accountData.refreshToken) accountData.refreshToken = encryptGeminiData(accountData.refreshToken);
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(accountData)) {
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++;
}
}
}
// 导入管理员账户
if (importData.data.admins) {
logger.info('\n📥 Importing admins...');
for (const admin of importData.data.admins) {
try {
const exists = await redis.client.exists(`admin:${admin.id}`);
if (exists && !forceOverwrite) {
if (skipConflicts) {
logger.warn(`⏭️ Skipped existing admin: ${admin.username} (${admin.id})`);
stats.skipped++;
continue;
} else {
const overwrite = await askConfirmation(`Admin "${admin.username}" (${admin.id}) exists. Overwrite?`);
if (!overwrite) {
stats.skipped++;
continue;
}
}
}
// 使用 hset 存储到哈希表
const pipeline = redis.client.pipeline();
for (const [field, value] of Object.entries(admin)) {
pipeline.hset(`admin:${admin.id}`, field, value);
}
await pipeline.exec();
// 更新用户名映射
await redis.client.set(`admin_username:${admin.username}`, admin.id);
logger.success(`✅ Imported admin: ${admin.username} (${admin.id})`);
stats.imported++;
} catch (error) {
logger.error(`❌ Failed to import admin ${admin.id}:`, error.message);
stats.errors++;
}
}
}
// 导入全局模型统计
if (importData.data.globalModelStats) {
logger.info('\n📥 Importing global model statistics...');
try {
const globalStats = importData.data.globalModelStats;
const pipeline = redis.client.pipeline();
let globalStatCount = 0;
// 导入每日统计
if (globalStats.daily) {
for (const [date, models] of Object.entries(globalStats.daily)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:daily:${model}:${date}`, field, value);
}
globalStatCount++;
}
}
}
// 导入每月统计
if (globalStats.monthly) {
for (const [month, models] of Object.entries(globalStats.monthly)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:monthly:${model}:${month}`, field, value);
}
globalStatCount++;
}
}
}
// 导入每小时统计
if (globalStats.hourly) {
for (const [hour, models] of Object.entries(globalStats.hourly)) {
for (const [model, data] of Object.entries(models)) {
for (const [field, value] of Object.entries(data)) {
pipeline.hset(`usage:model:hourly:${model}:${hour}`, field, value);
}
globalStatCount++;
}
}
}
await pipeline.exec();
logger.success(`✅ Imported ${globalStatCount} global model stat entries`);
stats.imported += globalStatCount;
} catch (error) {
logger.error(`❌ Failed to import global model stats:`, 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();
}
}
// 主函数
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);
});

517
scripts/data-transfer.js Normal file
View File

@@ -0,0 +1,517 @@
#!/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 path = require('path');
const crypto = require('crypto');
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;
}
// 导出数据
async function exportData() {
try {
const outputFile = params.output || `backup-${new Date().toISOString().split('T')[0]}.json`;
const types = params.types ? params.types.split(',') : ['all'];
const shouldSanitize = params.sanitize === true;
logger.info('🔄 Starting data export...');
logger.info(`📁 Output file: ${outputFile}`);
logger.info(`📋 Data types: ${types.join(', ')}`);
logger.info(`🔒 Sanitize sensitive data: ${shouldSanitize ? 'YES' : 'NO'}`);
// 连接 Redis
await redis.connect();
logger.success('✅ Connected to Redis');
const exportData = {
metadata: {
version: '1.0',
exportDate: new Date().toISOString(),
sanitized: shouldSanitize,
types: 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);
}
}
exportData.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);
}
}
exportData.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);
}
}
exportData.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);
}
}
exportData.data.admins = admins;
logger.success(`✅ Exported ${admins.length} admins`);
}
// 写入文件
await fs.writeFile(outputFile, JSON.stringify(exportData, 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 (exportData.data.apiKeys) {
console.log(`API Keys: ${exportData.data.apiKeys.length}`);
}
if (exportData.data.claudeAccounts) {
console.log(`Claude Accounts: ${exportData.data.claudeAccounts.length}`);
}
if (exportData.data.geminiAccounts) {
console.log(`Gemini Accounts: ${exportData.data.geminiAccounts.length}`);
}
if (exportData.data.admins) {
console.log(`Admins: ${exportData.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 importData = JSON.parse(fileContent);
// 验证文件格式
if (!importData.metadata || !importData.data) {
logger.error('❌ Invalid backup file format');
process.exit(1);
}
logger.info(`📅 Backup date: ${importData.metadata.exportDate}`);
logger.info(`🔒 Sanitized: ${importData.metadata.sanitized ? 'YES' : 'NO'}`);
if (importData.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 (importData.data.apiKeys) {
console.log(`API Keys to import: ${importData.data.apiKeys.length}`);
}
if (importData.data.claudeAccounts) {
console.log(`Claude Accounts to import: ${importData.data.claudeAccounts.length}`);
}
if (importData.data.geminiAccounts) {
console.log(`Gemini Accounts to import: ${importData.data.geminiAccounts.length}`);
}
if (importData.data.admins) {
console.log(`Admins to import: ${importData.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 (importData.data.apiKeys) {
logger.info('\n📥 Importing API Keys...');
for (const apiKey of importData.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 && !importData.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 (importData.data.claudeAccounts) {
logger.info('\n📥 Importing Claude accounts...');
for (const account of importData.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 (importData.data.geminiAccounts) {
logger.info('\n📥 Importing Gemini accounts...');
for (const account of importData.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)
--types=TYPE,... Data types to export: apikeys,accounts,admins,all (default: all)
--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
`);
}
// 主函数
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);
});

123
scripts/debug-redis-keys.js Normal file
View File

@@ -0,0 +1,123 @@
#!/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);
});

32
scripts/fix-inquirer.js Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* 修复 inquirer ESM 问题
* 降级到支持 CommonJS 的版本
*/
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
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
scripts/fix-usage-stats.js Normal file
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();
// 统计信息
let 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);
});

284
scripts/generate-test-data.js Executable file
View File

@@ -0,0 +1,284 @@
#!/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.getAllApiKeys();
// 找出所有测试 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');
let testApiKeys = [];
let 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);
});

View File

@@ -0,0 +1,191 @@
#!/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 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 redis.getAllApiKeys();
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);
});

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
/**
* 测试 API Key 过期功能
* 快速创建和修改 API Key 过期时间以便测试
*/
const apiKeyService = require('../src/services/apiKeyService');
const redis = require('../src/models/redis');
const logger = require('../src/utils/logger');
const chalk = require('chalk');
async function createTestApiKeys() {
console.log(chalk.bold.blue('\n🧪 创建测试 API Keys\n'));
try {
await redis.connect();
// 创建不同过期时间的测试 Keys
const testKeys = [
{
name: 'Test-Expired',
description: '已过期的测试 Key',
expiresAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() // 1天前过期
},
{
name: 'Test-1Hour',
description: '1小时后过期的测试 Key',
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString() // 1小时后
},
{
name: 'Test-1Day',
description: '1天后过期的测试 Key',
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString() // 1天后
},
{
name: 'Test-7Days',
description: '7天后过期的测试 Key',
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString() // 7天后
},
{
name: 'Test-Never',
description: '永不过期的测试 Key',
expiresAt: null // 永不过期
}
];
console.log('正在创建测试 API Keys...\n');
for (const keyData of testKeys) {
try {
const newKey = await apiKeyService.generateApiKey(keyData);
const expiryInfo = keyData.expiresAt
? new Date(keyData.expiresAt).toLocaleString()
: '永不过期';
console.log(`✅ 创建成功: ${keyData.name}`);
console.log(` API Key: ${newKey.apiKey}`);
console.log(` 过期时间: ${expiryInfo}`);
console.log('');
} catch (error) {
console.log(chalk.red(`❌ 创建失败: ${keyData.name} - ${error.message}`));
}
}
// 运行清理任务测试
console.log(chalk.bold.yellow('\n🔄 运行清理任务...\n'));
const cleanedCount = await apiKeyService.cleanupExpiredKeys();
console.log(`清理了 ${cleanedCount} 个过期的 API Keys\n`);
// 显示所有 API Keys 状态
console.log(chalk.bold.cyan('📊 当前所有 API Keys 状态:\n'));
const allKeys = await apiKeyService.getAllApiKeys();
for (const key of allKeys) {
const now = new Date();
const expiresAt = key.expiresAt ? new Date(key.expiresAt) : null;
let status = '✅ 活跃';
let expiryInfo = '永不过期';
if (expiresAt) {
if (expiresAt < now) {
status = '❌ 已过期';
expiryInfo = `过期于 ${expiresAt.toLocaleString()}`;
} else {
const hoursLeft = Math.ceil((expiresAt - now) / (1000 * 60 * 60));
const daysLeft = Math.ceil(hoursLeft / 24);
if (hoursLeft < 24) {
expiryInfo = chalk.yellow(`${hoursLeft}小时后过期`);
} else if (daysLeft <= 7) {
expiryInfo = chalk.yellow(`${daysLeft}天后过期`);
} else {
expiryInfo = chalk.green(`${daysLeft}天后过期`);
}
}
}
if (!key.isActive) {
status = '🔒 已禁用';
}
console.log(`${status} ${key.name} - ${expiryInfo}`);
console.log(` API Key: ${key.apiKey?.substring(0, 30)}...`);
console.log('');
}
} catch (error) {
console.error(chalk.red('测试失败:'), error);
} finally {
await redis.disconnect();
}
}
// 主函数
async function main() {
console.log(chalk.bold.magenta('\n===================================='));
console.log(chalk.bold.magenta(' API Key 过期功能测试工具'));
console.log(chalk.bold.magenta('====================================\n'));
console.log('此工具将:');
console.log('1. 创建不同过期时间的测试 API Keys');
console.log('2. 运行清理任务禁用过期的 Keys');
console.log('3. 显示所有 Keys 的当前状态\n');
console.log(chalk.yellow('⚠️ 注意:这会在您的系统中创建真实的 API Keys\n'));
const readline = require('readline').createInterface({
input: process.stdin,
output: process.stdout
});
readline.question('是否继续?(y/n): ', async (answer) => {
if (answer.toLowerCase() === 'y') {
await createTestApiKeys();
console.log(chalk.bold.green('\n✅ 测试完成!\n'));
console.log('您现在可以:');
console.log('1. 使用 CLI 工具管理这些测试 Keys:');
console.log(' npm run cli keys');
console.log('');
console.log('2. 在 Web 界面查看和管理这些 Keys');
console.log('');
console.log('3. 测试 API 调用时的过期验证');
} else {
console.log('\n已取消');
}
readline.close();
});
}
// 运行
main().catch(error => {
console.error(chalk.red('错误:'), error);
process.exit(1);
});

View File

@@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* 测试导入加密处理
* 验证增强版数据传输工具是否正确处理加密和未加密的导出数据
*/
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const config = require('../config/config');
const logger = require('../src/utils/logger');
// 模拟加密函数
function encryptData(data, salt = 'salt') {
if (!data || !config.security.encryptionKey) return data;
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
let encrypted = cipher.update(data, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
// 模拟解密函数
function decryptData(encryptedData, salt = 'salt') {
if (!encryptedData || !config.security.encryptionKey) return encryptedData;
try {
if (encryptedData.includes(':')) {
const parts = encryptedData.split(':');
const key = crypto.scryptSync(config.security.encryptionKey, salt, 32);
const iv = Buffer.from(parts[0], 'hex');
const encrypted = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
return encryptedData;
} catch (error) {
logger.warn(`⚠️ Failed to decrypt data: ${error.message}`);
return encryptedData;
}
}
async function testImportHandling() {
console.log('🧪 测试导入加密处理\n');
// 测试数据
const testClaudeAccount = {
id: 'test-claude-123',
name: 'Test Claude Account',
email: 'test@example.com',
password: 'testPassword123',
accessToken: 'test-access-token',
refreshToken: 'test-refresh-token',
claudeAiOauth: {
access_token: 'oauth-access-token',
refresh_token: 'oauth-refresh-token',
scopes: ['read', 'write']
}
};
const testGeminiAccount = {
id: 'test-gemini-456',
name: 'Test Gemini Account',
geminiOauth: {
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token'
},
accessToken: 'gemini-access-token',
refreshToken: 'gemini-refresh-token'
};
// 1. 创建解密的导出文件(模拟 --decrypt=true
const decryptedExport = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: false,
decrypted: true, // 标记为已解密
types: ['all']
},
data: {
claudeAccounts: [testClaudeAccount],
geminiAccounts: [testGeminiAccount]
}
};
// 2. 创建加密的导出文件(模拟 --decrypt=false
const encryptedClaudeAccount = { ...testClaudeAccount };
encryptedClaudeAccount.email = encryptData(encryptedClaudeAccount.email);
encryptedClaudeAccount.password = encryptData(encryptedClaudeAccount.password);
encryptedClaudeAccount.accessToken = encryptData(encryptedClaudeAccount.accessToken);
encryptedClaudeAccount.refreshToken = encryptData(encryptedClaudeAccount.refreshToken);
encryptedClaudeAccount.claudeAiOauth = encryptData(JSON.stringify(encryptedClaudeAccount.claudeAiOauth));
const encryptedGeminiAccount = { ...testGeminiAccount };
encryptedGeminiAccount.geminiOauth = encryptData(JSON.stringify(encryptedGeminiAccount.geminiOauth), 'gemini-account-salt');
encryptedGeminiAccount.accessToken = encryptData(encryptedGeminiAccount.accessToken, 'gemini-account-salt');
encryptedGeminiAccount.refreshToken = encryptData(encryptedGeminiAccount.refreshToken, 'gemini-account-salt');
const encryptedExport = {
metadata: {
version: '2.0',
exportDate: new Date().toISOString(),
sanitized: false,
decrypted: false, // 标记为未解密(加密状态)
types: ['all']
},
data: {
claudeAccounts: [encryptedClaudeAccount],
geminiAccounts: [encryptedGeminiAccount]
}
};
// 写入测试文件
const testDir = path.join(__dirname, '../data/test-imports');
await fs.mkdir(testDir, { recursive: true });
await fs.writeFile(
path.join(testDir, 'decrypted-export.json'),
JSON.stringify(decryptedExport, null, 2)
);
await fs.writeFile(
path.join(testDir, 'encrypted-export.json'),
JSON.stringify(encryptedExport, null, 2)
);
console.log('✅ 测试文件已创建:');
console.log(' - data/test-imports/decrypted-export.json (解密的数据)');
console.log(' - data/test-imports/encrypted-export.json (加密的数据)\n');
console.log('📋 测试场景:\n');
console.log('1. 导入解密的数据decrypted=true');
console.log(' - 导入时应该重新加密敏感字段');
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/decrypted-export.json\n');
console.log('2. 导入加密的数据decrypted=false');
console.log(' - 导入时应该保持原样(已经是加密的)');
console.log(' - 命令: npm run data:import:enhanced -- --input=data/test-imports/encrypted-export.json\n');
console.log('3. 验证导入后的数据:');
console.log(' - 使用 CLI 查看账户状态');
console.log(' - 命令: npm run cli accounts list\n');
// 显示示例数据对比
console.log('📊 数据对比示例:\n');
console.log('原始数据(解密状态):');
console.log(` email: "${testClaudeAccount.email}"`);
console.log(` password: "${testClaudeAccount.password}"`);
console.log(` accessToken: "${testClaudeAccount.accessToken}"\n`);
console.log('加密后的数据:');
console.log(` email: "${encryptedClaudeAccount.email.substring(0, 50)}..."`);
console.log(` password: "${encryptedClaudeAccount.password.substring(0, 50)}..."`);
console.log(` accessToken: "${encryptedClaudeAccount.accessToken.substring(0, 50)}..."\n`);
// 验证加密/解密
console.log('🔐 验证加密/解密功能:');
const testString = 'test-data-123';
const encrypted = encryptData(testString);
const decrypted = decryptData(encrypted);
console.log(` 原始: "${testString}"`);
console.log(` 加密: "${encrypted.substring(0, 50)}..."`);
console.log(` 解密: "${decrypted}"`);
console.log(` 验证: ${testString === decrypted ? '✅ 成功' : '❌ 失败'}\n`);
}
// 运行测试
testImportHandling().catch(error => {
console.error('❌ 测试失败:', error);
process.exit(1);
});