Files
nxxmdata/backend/services/backupService.js
2025-09-12 20:08:42 +08:00

706 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 数据备份服务
* @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;