Files
nxxmdata/backend/services/backupService.js

706 lines
21 KiB
JavaScript
Raw Normal View History

2025-09-12 20:08:42 +08:00
/**
* 数据备份服务
* @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;