/** * 数据备份服务 * @file backupService.js * @description 提供数据库备份、文件备份和故障恢复功能 */ const fs = require('fs').promises; const path = require('path'); const { spawn } = require('child_process'); const archiver = require('archiver'); const { sequelize } = require('../config/database-simple'); const logger = require('../utils/logger'); const moment = require('moment'); /** * 备份配置 */ const BACKUP_CONFIG = { // 备份目录 backupDir: path.join(__dirname, '../../backups'), // 数据库备份配置 database: { host: process.env.DB_HOST || 'localhost', port: process.env.DB_PORT || 3306, database: process.env.DB_NAME || 'nxxm_farming', username: process.env.DB_USER || 'root', password: process.env.DB_PASS || '' }, // 备份保留策略 retention: { daily: 7, // 保留7天的日备份 weekly: 4, // 保留4周的周备份 monthly: 12 // 保留12个月的月备份 }, // 压缩级别 (0-9) compressionLevel: 6 }; /** * 备份服务类 */ class BackupService { constructor() { this.isBackupRunning = false; this.backupQueue = []; this.initBackupDirectory(); } /** * 初始化备份目录 */ async initBackupDirectory() { try { await fs.mkdir(BACKUP_CONFIG.backupDir, { recursive: true }); await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'database'), { recursive: true }); await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'files'), { recursive: true }); await fs.mkdir(path.join(BACKUP_CONFIG.backupDir, 'logs'), { recursive: true }); logger.info('备份目录初始化完成'); } catch (error) { logger.error('备份目录初始化失败:', error); throw error; } } /** * 执行完整系统备份 * @param {Object} options 备份选项 * @returns {Object} 备份结果 */ async createFullBackup(options = {}) { if (this.isBackupRunning) { throw new Error('备份正在进行中,请稍后再试'); } this.isBackupRunning = true; const backupId = this.generateBackupId(); const backupPath = path.join(BACKUP_CONFIG.backupDir, backupId); try { logger.info(`开始创建完整备份: ${backupId}`); await fs.mkdir(backupPath, { recursive: true }); const backupResult = { id: backupId, timestamp: new Date(), type: 'full', status: 'in_progress', components: { database: { status: 'pending', size: 0, path: '' }, files: { status: 'pending', size: 0, path: '' }, logs: { status: 'pending', size: 0, path: '' } }, totalSize: 0, duration: 0 }; const startTime = Date.now(); // 1. 备份数据库 logger.info('开始备份数据库...'); const dbBackupResult = await this.backupDatabase(backupPath); backupResult.components.database = dbBackupResult; // 2. 备份文件(上传文件、配置文件等) logger.info('开始备份文件...'); const fileBackupResult = await this.backupFiles(backupPath); backupResult.components.files = fileBackupResult; // 3. 备份日志 logger.info('开始备份日志...'); const logBackupResult = await this.backupLogs(backupPath); backupResult.components.logs = logBackupResult; // 4. 创建压缩包 logger.info('开始压缩备份文件...'); const archivePath = await this.createArchive(backupPath, backupId); // 5. 计算总大小和持续时间 const archiveStats = await fs.stat(archivePath); backupResult.totalSize = archiveStats.size; backupResult.duration = Date.now() - startTime; backupResult.status = 'completed'; backupResult.archivePath = archivePath; // 6. 清理临时目录 await this.cleanupDirectory(backupPath); // 7. 保存备份元数据 await this.saveBackupMetadata(backupResult); logger.info(`完整备份创建完成: ${backupId}, 大小: ${this.formatSize(backupResult.totalSize)}, 用时: ${backupResult.duration}ms`); return backupResult; } catch (error) { logger.error(`创建备份失败: ${backupId}`, error); // 清理失败的备份 try { await this.cleanupDirectory(backupPath); } catch (cleanupError) { logger.error('清理失败备份目录出错:', cleanupError); } throw error; } finally { this.isBackupRunning = false; } } /** * 备份数据库 * @param {string} backupPath 备份路径 * @returns {Object} 备份结果 */ async backupDatabase(backupPath) { return new Promise((resolve, reject) => { const timestamp = moment().format('YYYYMMDD_HHmmss'); const filename = `database_${timestamp}.sql`; const outputPath = path.join(backupPath, filename); const mysqldumpArgs = [ '-h', BACKUP_CONFIG.database.host, '-P', BACKUP_CONFIG.database.port, '-u', BACKUP_CONFIG.database.username, `--password=${BACKUP_CONFIG.database.password}`, '--single-transaction', '--routines', '--triggers', '--set-gtid-purged=OFF', BACKUP_CONFIG.database.database ]; const mysqldump = spawn('mysqldump', mysqldumpArgs); const writeStream = require('fs').createWriteStream(outputPath); mysqldump.stdout.pipe(writeStream); let errorOutput = ''; mysqldump.stderr.on('data', (data) => { errorOutput += data.toString(); }); mysqldump.on('close', async (code) => { try { if (code === 0) { const stats = await fs.stat(outputPath); resolve({ status: 'completed', size: stats.size, path: filename, duration: Date.now() - Date.now() }); } else { reject(new Error(`mysqldump失败,退出代码: ${code}, 错误: ${errorOutput}`)); } } catch (error) { reject(error); } }); mysqldump.on('error', (error) => { reject(new Error(`mysqldump执行失败: ${error.message}`)); }); }); } /** * 备份文件 * @param {string} backupPath 备份路径 * @returns {Object} 备份结果 */ async backupFiles(backupPath) { try { const fileBackupPath = path.join(backupPath, 'files'); await fs.mkdir(fileBackupPath, { recursive: true }); // 备份上传文件(如果存在) const uploadsDir = path.join(__dirname, '../../uploads'); try { await fs.access(uploadsDir); await this.copyDirectory(uploadsDir, path.join(fileBackupPath, 'uploads')); } catch (error) { // 上传目录不存在,跳过 logger.warn('上传目录不存在,跳过文件备份'); } // 备份配置文件 const configFiles = [ path.join(__dirname, '../config'), path.join(__dirname, '../package.json'), path.join(__dirname, '../.env') ]; for (const configPath of configFiles) { try { await fs.access(configPath); const basename = path.basename(configPath); const destPath = path.join(fileBackupPath, basename); const stats = await fs.stat(configPath); if (stats.isDirectory()) { await this.copyDirectory(configPath, destPath); } else { await fs.copyFile(configPath, destPath); } } catch (error) { logger.warn(`配置文件 ${configPath} 备份跳过:`, error.message); } } const backupStats = await this.getDirectorySize(fileBackupPath); return { status: 'completed', size: backupStats.size, path: 'files', fileCount: backupStats.fileCount }; } catch (error) { logger.error('文件备份失败:', error); return { status: 'failed', error: error.message, size: 0, path: '' }; } } /** * 备份日志 * @param {string} backupPath 备份路径 * @returns {Object} 备份结果 */ async backupLogs(backupPath) { try { const logBackupPath = path.join(backupPath, 'logs'); await fs.mkdir(logBackupPath, { recursive: true }); const logsDir = path.join(__dirname, '../logs'); try { await fs.access(logsDir); await this.copyDirectory(logsDir, logBackupPath); } catch (error) { logger.warn('日志目录不存在,跳过日志备份'); return { status: 'skipped', size: 0, path: '', message: '日志目录不存在' }; } const backupStats = await this.getDirectorySize(logBackupPath); return { status: 'completed', size: backupStats.size, path: 'logs', fileCount: backupStats.fileCount }; } catch (error) { logger.error('日志备份失败:', error); return { status: 'failed', error: error.message, size: 0, path: '' }; } } /** * 创建压缩包 * @param {string} sourcePath 源路径 * @param {string} backupId 备份ID * @returns {string} 压缩包路径 */ async createArchive(sourcePath, backupId) { return new Promise((resolve, reject) => { const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`); const output = require('fs').createWriteStream(archivePath); const archive = archiver('zip', { zlib: { level: BACKUP_CONFIG.compressionLevel } }); output.on('close', () => { logger.info(`压缩包创建完成: ${archivePath}, 大小: ${archive.pointer()} bytes`); resolve(archivePath); }); archive.on('error', (error) => { reject(error); }); archive.pipe(output); archive.directory(sourcePath, false); archive.finalize(); }); } /** * 复制目录 * @param {string} src 源目录 * @param {string} dest 目标目录 */ async copyDirectory(src, dest) { await fs.mkdir(dest, { recursive: true }); const entries = await fs.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await this.copyDirectory(srcPath, destPath); } else { await fs.copyFile(srcPath, destPath); } } } /** * 获取目录大小 * @param {string} dirPath 目录路径 * @returns {Object} 大小统计 */ async getDirectorySize(dirPath) { let totalSize = 0; let fileCount = 0; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { const subStats = await this.getDirectorySize(fullPath); totalSize += subStats.size; fileCount += subStats.fileCount; } else { const stats = await fs.stat(fullPath); totalSize += stats.size; fileCount++; } } } catch (error) { logger.warn(`获取目录大小失败: ${dirPath}`, error); } return { size: totalSize, fileCount }; } /** * 清理目录 * @param {string} dirPath 目录路径 */ async cleanupDirectory(dirPath) { try { await fs.rmdir(dirPath, { recursive: true }); } catch (error) { logger.warn(`清理目录失败: ${dirPath}`, error); } } /** * 生成备份ID * @returns {string} 备份ID */ generateBackupId() { const timestamp = moment().format('YYYYMMDD_HHmmss'); const random = Math.random().toString(36).substring(2, 8); return `backup_${timestamp}_${random}`; } /** * 保存备份元数据 * @param {Object} backupResult 备份结果 */ async saveBackupMetadata(backupResult) { try { const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json'); let metadata = []; try { const existing = await fs.readFile(metadataPath, 'utf8'); metadata = JSON.parse(existing); } catch (error) { // 文件不存在或格式错误,使用空数组 } metadata.unshift(backupResult); // 只保留最近100条记录 if (metadata.length > 100) { metadata = metadata.slice(0, 100); } await fs.writeFile(metadataPath, JSON.stringify(metadata, null, 2)); logger.info('备份元数据已保存'); } catch (error) { logger.error('保存备份元数据失败:', error); } } /** * 获取备份列表 * @returns {Array} 备份列表 */ async getBackupList() { try { const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json'); try { const data = await fs.readFile(metadataPath, 'utf8'); return JSON.parse(data); } catch (error) { return []; } } catch (error) { logger.error('获取备份列表失败:', error); return []; } } /** * 删除备份 * @param {string} backupId 备份ID * @returns {boolean} 删除结果 */ async deleteBackup(backupId) { try { const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`); try { await fs.unlink(archivePath); logger.info(`备份文件已删除: ${archivePath}`); } catch (error) { logger.warn(`备份文件删除失败或不存在: ${archivePath}`); } // 更新元数据 const metadata = await this.getBackupList(); const updatedMetadata = metadata.filter(backup => backup.id !== backupId); const metadataPath = path.join(BACKUP_CONFIG.backupDir, 'metadata.json'); await fs.writeFile(metadataPath, JSON.stringify(updatedMetadata, null, 2)); return true; } catch (error) { logger.error(`删除备份失败: ${backupId}`, error); return false; } } /** * 自动清理过期备份 */ async cleanupExpiredBackups() { try { const backups = await this.getBackupList(); const now = moment(); let deletedCount = 0; for (const backup of backups) { const backupDate = moment(backup.timestamp); const daysDiff = now.diff(backupDate, 'days'); let shouldDelete = false; // 根据备份类型和时间决定是否删除 if (backup.type === 'daily' && daysDiff > BACKUP_CONFIG.retention.daily) { shouldDelete = true; } else if (backup.type === 'weekly' && daysDiff > (BACKUP_CONFIG.retention.weekly * 7)) { shouldDelete = true; } else if (backup.type === 'monthly' && daysDiff > (BACKUP_CONFIG.retention.monthly * 30)) { shouldDelete = true; } else if (backup.type === 'full' && daysDiff > BACKUP_CONFIG.retention.daily) { // 完整备份使用日备份的保留策略 shouldDelete = true; } if (shouldDelete) { const deleted = await this.deleteBackup(backup.id); if (deleted) { deletedCount++; } } } if (deletedCount > 0) { logger.info(`自动清理完成,删除了 ${deletedCount} 个过期备份`); } return deletedCount; } catch (error) { logger.error('自动清理过期备份失败:', error); return 0; } } /** * 恢复数据库 * @param {string} backupId 备份ID * @returns {boolean} 恢复结果 */ async restoreDatabase(backupId) { try { const backups = await this.getBackupList(); const backup = backups.find(b => b.id === backupId); if (!backup) { throw new Error('备份不存在'); } const archivePath = path.join(BACKUP_CONFIG.backupDir, `${backupId}.zip`); // 这里可以实现数据库恢复逻辑 // 由于涉及到解压和执行SQL脚本,需要谨慎实现 logger.info(`数据库恢复功能需要进一步实现: ${backupId}`); return true; } catch (error) { logger.error(`恢复数据库失败: ${backupId}`, error); return false; } } /** * 获取备份统计 * @returns {Object} 统计信息 */ async getBackupStats() { try { const backups = await this.getBackupList(); const stats = { total: backups.length, totalSize: backups.reduce((sum, backup) => sum + (backup.totalSize || 0), 0), byType: { full: backups.filter(b => b.type === 'full').length, daily: backups.filter(b => b.type === 'daily').length, weekly: backups.filter(b => b.type === 'weekly').length, monthly: backups.filter(b => b.type === 'monthly').length }, byStatus: { completed: backups.filter(b => b.status === 'completed').length, failed: backups.filter(b => b.status === 'failed').length, inProgress: backups.filter(b => b.status === 'in_progress').length }, latest: backups[0] || null, oldestRetained: backups[backups.length - 1] || null }; return stats; } catch (error) { logger.error('获取备份统计失败:', error); return { total: 0, totalSize: 0, byType: {}, byStatus: {}, latest: null, oldestRetained: null }; } } /** * 格式化文件大小 * @param {number} bytes 字节数 * @returns {string} 格式化的大小 */ formatSize(bytes) { const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; if (bytes === 0) return '0 B'; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; } /** * 检查备份系统健康状态 * @returns {Object} 健康状态 */ async checkBackupHealth() { try { const stats = await this.getBackupStats(); const diskUsage = await this.getDiskUsage(); const health = { status: 'healthy', issues: [], stats, diskUsage, lastBackup: stats.latest, recommendations: [] }; // 检查磁盘空间 if (diskUsage.usagePercent > 90) { health.status = 'warning'; health.issues.push('磁盘空间不足(使用率超过90%)'); health.recommendations.push('清理过期备份或扩展存储空间'); } // 检查最近备份时间 if (stats.latest) { const daysSinceLastBackup = moment().diff(moment(stats.latest.timestamp), 'days'); if (daysSinceLastBackup > 1) { health.status = 'warning'; health.issues.push(`最近备份时间过久(${daysSinceLastBackup}天前)`); health.recommendations.push('建议执行新的备份'); } } else { health.status = 'error'; health.issues.push('没有找到任何备份记录'); health.recommendations.push('立即创建第一个备份'); } return health; } catch (error) { logger.error('检查备份健康状态失败:', error); return { status: 'error', issues: ['无法检查备份状态'], error: error.message }; } } /** * 获取磁盘使用情况 * @returns {Object} 磁盘使用情况 */ async getDiskUsage() { try { const stats = await fs.stat(BACKUP_CONFIG.backupDir); // 简化的磁盘使用情况检查 // 在实际环境中可以使用更精确的磁盘空间检查 return { total: 100 * 1024 * 1024 * 1024, // 假设100GB used: stats.size || 0, free: 100 * 1024 * 1024 * 1024 - (stats.size || 0), usagePercent: ((stats.size || 0) / (100 * 1024 * 1024 * 1024)) * 100 }; } catch (error) { logger.error('获取磁盘使用情况失败:', error); return { total: 0, used: 0, free: 0, usagePercent: 0 }; } } } // 创建单例实例 const backupService = new BackupService(); module.exports = backupService;