mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
Merge remote-tracking branch 'origin/main' into dev
This commit is contained in:
994
scripts/data-transfer-enhanced.js
Normal file
994
scripts/data-transfer-enhanced.js
Normal 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
517
scripts/data-transfer.js
Normal 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
123
scripts/debug-redis-keys.js
Normal 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
32
scripts/fix-inquirer.js
Normal 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
227
scripts/fix-usage-stats.js
Normal 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
284
scripts/generate-test-data.js
Executable 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);
|
||||
});
|
||||
191
scripts/migrate-apikey-expiry.js
Normal file
191
scripts/migrate-apikey-expiry.js
Normal 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);
|
||||
});
|
||||
159
scripts/test-apikey-expiry.js
Normal file
159
scripts/test-apikey-expiry.js
Normal 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);
|
||||
});
|
||||
181
scripts/test-import-encryption.js
Normal file
181
scripts/test-import-encryption.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user