feat: 实现统一日志系统和维护模式功能

This commit is contained in:
zhang1106
2026-04-28 10:57:17 +08:00
parent 763e425f78
commit bf2cd221b9
43 changed files with 1966 additions and 430 deletions
+35 -56
View File
@@ -5,6 +5,7 @@ const { createBackup, createIncrementalBackup, getBackupPath } = require('./back
const { uploadToRemote } = require('./remoteBackup');
const { getEnabledTargets, getGlobalSettings } = require('./remoteBackupConfig');
const { createLogEntry, updateLogStatus } = require('./backupLog');
const logger = require('./logger').module('AutoBackup');
const schedulers = new Map();
@@ -28,7 +29,7 @@ function loadSettings() {
return { ...DEFAULT_SETTINGS, ...JSON.parse(content) };
}
} catch (error) {
console.error('加载自动备份设置失败:', error);
logger.error('加载自动备份设置失败', { error: error.message, stack: error.stack });
}
return { ...DEFAULT_SETTINGS };
}
@@ -42,7 +43,7 @@ function saveSettings(settings) {
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8');
return true;
} catch (error) {
console.error('保存自动备份设置失败:', error);
logger.error('保存自动备份设置失败', { error: error.message, stack: error.stack });
return false;
}
}
@@ -82,7 +83,7 @@ function getFileSize(filePath) {
return stats.size;
}
} catch (error) {
console.error('获取文件大小失败:', error);
logger.error('获取文件大小失败', { error: error.message, stack: error.stack });
}
return null;
}
@@ -95,32 +96,25 @@ function createAutoBackupTask(settings) {
throw new Error('无效的 Cron 表达式');
}
console.log('=== 创建自动备份任务 ===');
console.log('Cron表达式:', cronExpression);
console.log('备份类型:', backupType);
console.log('下次执行:', calculateNextRun(cronExpression));
logger.info('创建自动备份任务', { cronExpression, backupType, nextRun: calculateNextRun(cronExpression) });
if (schedulers.has('auto-backup')) {
console.log('停止已存在的调度器...');
logger.info('停止已存在的调度器...');
stopAutoBackup();
}
console.log('创建新调度器...');
logger.info('创建新调度器...');
const task = cron.schedule(
cronExpression,
async function () {
console.log('');
console.log('============================================');
console.log('=== 自动备份任务触发 ===');
console.log('触发时间:', new Date().toLocaleString('zh-CN'));
console.log('============================================');
logger.info('自动备份任务触发', { triggerTime: new Date().toLocaleString('zh-CN') });
let logId = null;
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19);
console.log('创建备份日志...');
logger.info('创建备份日志...');
const log = await createLogEntry({
logType: 'auto',
description: `${description} - ${timestamp}`,
@@ -134,7 +128,7 @@ function createAutoBackupTask(settings) {
await updateLogStatus(logId, 'running');
}
console.log('准备执行备份...');
logger.info('准备执行备份...');
const backupFunction =
backupType === 'incremental' ? createIncrementalBackup : createBackup;
@@ -148,8 +142,7 @@ function createAutoBackupTask(settings) {
});
if (result) {
console.log('自动备份完成:', result.filename);
console.log('备份类型:', result.isIncremental ? '增量备份' : '全量备份');
logger.info('自动备份完成', { filename: result.filename, type: result.isIncremental ? '增量备份' : '全量备份' });
const fileSize = getFileSize(result.path);
@@ -163,28 +156,22 @@ function createAutoBackupTask(settings) {
remoteUploads: uploadResults,
});
}
console.log('============================================\n');
} else {
console.log('无数据变化,跳过备份');
logger.info('无数据变化,跳过备份');
if (logId) {
await updateLogStatus(logId, 'success', {
errorMessage: '无数据变化,跳过备份',
});
}
console.log('============================================\n');
}
} catch (error) {
console.error('自动备份失败:', error);
console.error('错误堆栈:', error.stack);
logger.error('自动备份失败', { error: error.message, stack: error.stack });
if (logId) {
await updateLogStatus(logId, 'failed', {
errorMessage: error.message || '未知错误',
});
}
console.error('============================================\n');
}
},
{
@@ -193,7 +180,7 @@ function createAutoBackupTask(settings) {
);
schedulers.set('auto-backup', task);
console.log('自动备份任务已成功创建并启动');
logger.info('自动备份任务已成功创建并启动');
return task;
}
@@ -204,7 +191,7 @@ function startAutoBackup(settings = null) {
}
if (!settings.enabled) {
console.log('自动备份已禁用');
logger.info('自动备份已禁用');
return false;
}
@@ -212,7 +199,7 @@ function startAutoBackup(settings = null) {
createAutoBackupTask(settings);
return true;
} catch (error) {
console.error('启动自动备份失败:', error.message);
logger.error('启动自动备份失败', { error: error.message, stack: error.stack });
return false;
}
}
@@ -222,7 +209,7 @@ function stopAutoBackup() {
const task = schedulers.get('auto-backup');
task.stop();
schedulers.delete('auto-backup');
console.log('自动备份任务已停止');
logger.info('自动备份任务已停止');
return true;
}
return false;
@@ -291,14 +278,14 @@ async function uploadToRemoteTargets(localFilePath, filename) {
const globalSettings = getGlobalSettings();
if (!globalSettings.enabled || !globalSettings.uploadAfterBackup) {
console.log('远端备份已禁用');
logger.info('远端备份已禁用');
return [];
}
const enabledTargets = getEnabledTargets();
if (enabledTargets.length === 0) {
console.log('没有启用的远端备份目标');
logger.info('没有启用的远端备份目标');
return [];
}
@@ -306,7 +293,7 @@ async function uploadToRemoteTargets(localFilePath, filename) {
for (const target of enabledTargets) {
try {
console.log('开始上传到目标:' + target.name + ' (' + target.protocol + ')');
logger.info(`开始上传到目标:${target.name} (${target.protocol})`);
const remotePath = (target.prefix || 'backups/') + filename;
@@ -320,9 +307,9 @@ async function uploadToRemoteTargets(localFilePath, filename) {
...result,
});
console.log('上传到 ' + target.name + ' 成功');
logger.info(`上传到 ${target.name} 成功`);
} catch (error) {
console.error('上传到 ' + target.name + ' 失败:', error.message);
logger.error(`上传到 ${target.name} 失败`, { error: error.message, stack: error.stack });
uploadResults.push({
targetId: target.id,
targetName: target.name,
@@ -337,9 +324,9 @@ async function uploadToRemoteTargets(localFilePath, filename) {
if (settings.deleteLocalAfterUpload && uploadResults.every(r => r.success)) {
try {
fs.unlinkSync(localFilePath);
console.log('本地备份文件已删除');
logger.info('本地备份文件已删除');
} catch (error) {
console.error('删除本地备份文件失败:', error.message);
logger.error('删除本地备份文件失败', { error: error.message, stack: error.stack });
}
}
@@ -347,10 +334,7 @@ async function uploadToRemoteTargets(localFilePath, filename) {
}
async function executeBackupNow(options = {}) {
console.log('');
console.log('============================================');
console.log('=== 手动触发备份 ===');
console.log('============================================');
logger.info('手动触发备份');
let logId = null;
@@ -358,7 +342,7 @@ async function executeBackupNow(options = {}) {
const settings = loadSettings();
const backupType = options.backupType || settings.backupType || 'full';
console.log('创建备份日志...');
logger.info('创建备份日志...');
const log = await createLogEntry({
logType: 'manual',
description: options.description || '手动备份',
@@ -384,7 +368,7 @@ async function executeBackupNow(options = {}) {
maxAgeDays: settings.maxAgeDays,
});
console.log('手动备份完成:', result.filename);
logger.info('手动备份完成', { filename: result.filename });
const fileSize = getFileSize(result.path);
@@ -399,15 +383,13 @@ async function executeBackupNow(options = {}) {
});
}
console.log('============================================\n');
return {
success: true,
result,
remoteUploads: uploadResults,
};
} catch (error) {
console.error('手动备份失败:', error);
console.error('============================================\n');
logger.error('手动备份失败', { error: error.message, stack: error.stack });
if (logId) {
await updateLogStatus(logId, 'failed', {
@@ -420,25 +402,22 @@ async function executeBackupNow(options = {}) {
}
function initAutoBackup() {
console.log('');
console.log('============================================');
console.log('=== 初始化自动备份调度器 ===');
console.log('============================================');
logger.info('初始化自动备份调度器');
const settings = loadSettings();
console.log('当前设置:');
console.log(' 启用:', settings.enabled ? '是' : '否');
console.log(' Cron表达式:', settings.cronExpression);
console.log(' 备份类型:', settings.backupType);
logger.info('当前设置', {
enabled: settings.enabled,
cronExpression: settings.cronExpression,
backupType: settings.backupType,
});
if (settings.enabled) {
startAutoBackup(settings);
} else {
console.log('自动备份当前为禁用状态');
logger.info('自动备份当前为禁用状态');
}
console.log('============================================\n');
return getAutoBackupStatus();
}
+508 -107
View File
@@ -9,9 +9,225 @@ const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { sequelize, dbDialect } = require('../db');
const logger = require('./logger').module('Backup');
const { enableMaintenanceMode, disableMaintenanceMode } = require('./maintenanceMode');
const BACKUP_VERSION = '2.0.0';
const BATCH_SIZE = 500;
/**
* 还原点管理
* 恢复前自动创建当前数据的临时备份,用于中断或失败时回滚
*/
const SNAPSHOT_DIR = path.join(__dirname, '..', 'backups', '.snapshots');
/**
* 确保还原点目录存在
*/
function ensureSnapshotDir() {
if (!fs.existsSync(SNAPSHOT_DIR)) {
fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
}
return SNAPSHOT_DIR;
}
/**
* 生成还原点文件名
*/
function getSnapshotFileName(snapshotId) {
return path.join(SNAPSHOT_DIR, `snapshot_${snapshotId}.json.gz`);
}
/**
* 创建还原点(恢复前自动调用)
* 备份当前数据库所有表的数据
* @returns {Promise<string|null>} 还原点ID,失败返回null
*/
async function createSnapshot() {
try {
ensureSnapshotDir();
const snapshotId = `pre_restore_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
const snapshotPath = getSnapshotFileName(snapshotId);
logger.info('创建还原点...', { snapshotId });
// 收集当前所有表的数据
const snapshotData = {
version: BACKUP_VERSION,
timestamp: new Date().toISOString(),
type: 'snapshot',
data: {},
files: null,
};
for (const config of BACKUP_MODELS_CONFIG) {
try {
const Model = require(config.modelPath);
const records = await Model.findAll({ raw: true });
if (records.length > 0) {
snapshotData.data[config.name] = records;
}
} catch (error) {
logger.warn(`创建还原点时跳过表 ${config.name}`, { error: error.message });
}
}
// 压缩并保存
const jsonStr = JSON.stringify(snapshotData);
const compressed = zlib.gzipSync(Buffer.from(jsonStr, 'utf8'));
fs.writeFileSync(snapshotPath, compressed);
logger.info('还原点创建成功', { snapshotId, path: snapshotPath, tables: Object.keys(snapshotData.data).length });
return snapshotId;
} catch (error) {
logger.error('创建还原点失败', { error: error.message, stack: error.stack });
return null;
}
}
/**
* 从还原点恢复数据
* 当恢复过程被中断或失败时调用,回滚到恢复前的状态
* @param {string} snapshotId - 还原点ID
* @returns {Promise<boolean>} 是否成功回滚
*/
async function restoreFromSnapshot(snapshotId) {
const snapshotPath = getSnapshotFileName(snapshotId);
if (!fs.existsSync(snapshotPath)) {
logger.error('还原点不存在,无法回滚', { snapshotId });
return false;
}
try {
logger.info('开始从还原点回滚数据...', { snapshotId });
// 读取还原点数据
const compressed = fs.readFileSync(snapshotPath);
const decompressed = zlib.gunzipSync(compressed);
const snapshotData = JSON.parse(decompressed.toString('utf8'));
if (!snapshotData.data) {
logger.error('还原点数据无效');
return false;
}
await disableForeignKeyChecks();
try {
// 清空所有表(按恢复顺序的逆序,避免外键冲突)
const clearOrder = [...RESTORE_ORDER].reverse();
for (const tableName of clearOrder) {
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
if (!config) continue;
try {
const Model = require(config.modelPath);
await Model.destroy({ where: {}, truncate: true });
} catch (error) {
logger.warn(`回滚时清空表 ${tableName} 失败`, { error: error.message });
}
}
// 从还原点恢复数据
let restoredCount = 0;
for (const tableName of RESTORE_ORDER) {
const tableData = snapshotData.data[tableName];
if (!tableData || tableData.length === 0) continue;
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
if (!config) continue;
try {
const Model = require(config.modelPath);
for (let i = 0; i < tableData.length; i += BATCH_SIZE) {
const batch = tableData.slice(i, i + BATCH_SIZE);
try {
await Model.bulkCreate(batch, {
validate: false,
individualHooks: false,
logging: false,
});
restoredCount += batch.length;
} catch (bulkError) {
for (const record of batch) {
try {
await Model.create(record, { validate: false, silent: true });
restoredCount++;
} catch (insertError) {
logger.warn(`回滚时插入 ${tableName} 记录失败`, { error: insertError.message });
}
}
}
}
} catch (error) {
logger.error(`回滚表 ${tableName} 失败`, { error: error.message });
}
}
logger.info('还原点回滚完成', { restoredCount });
return true;
} finally {
await enableForeignKeyChecks();
}
} catch (error) {
logger.error('从还原点回滚失败', { error: error.message, stack: error.stack });
return false;
}
}
/**
* 删除还原点
* @param {string} snapshotId - 还原点ID
*/
function deleteSnapshot(snapshotId) {
const snapshotPath = getSnapshotFileName(snapshotId);
if (fs.existsSync(snapshotPath)) {
try {
fs.unlinkSync(snapshotPath);
logger.info('还原点已删除', { snapshotId });
} catch (error) {
logger.warn('删除还原点失败', { snapshotId, error: error.message });
}
}
}
/**
* 清理过期的还原点(保留最近7天)
*/
function cleanupOldSnapshots() {
try {
if (!fs.existsSync(SNAPSHOT_DIR)) return;
const now = Date.now();
const maxAgeMs = 7 * 24 * 60 * 60 * 1000; // 7天
const files = fs.readdirSync(SNAPSHOT_DIR);
let deletedCount = 0;
for (const file of files) {
if (!file.startsWith('snapshot_pre_restore_')) continue;
const filePath = path.join(SNAPSHOT_DIR, file);
const stat = fs.statSync(filePath);
const age = now - stat.mtimeMs;
if (age > maxAgeMs) {
fs.unlinkSync(filePath);
deletedCount++;
}
}
if (deletedCount > 0) {
logger.info('清理过期还原点', { deletedCount });
}
} catch (error) {
logger.warn('清理过期还原点失败', { error: error.message });
}
}
async function disableForeignKeyChecks() {
if (dbDialect === 'sqlite') {
await sequelize.query('PRAGMA foreign_keys = OFF');
@@ -692,8 +908,17 @@ async function validateBackupFile(filePath, options = {}) {
};
}
// 用户相关表定义
const USER_RELATED_TABLES = ['User', 'UserRole'];
async function restoreData(backupData, options = {}) {
const { overwriteExisting = true, skipTables = [], onProgress = () => {} } = options;
const {
overwriteExisting = true,
skipTables = [],
skipUserData = false,
shouldStop = () => false,
onProgress = () => {}
} = options;
const results = {
tablesRestored: 0,
@@ -701,6 +926,7 @@ async function restoreData(backupData, options = {}) {
errors: [],
skipped: [],
tableDetails: {},
stopped: false,
};
const isIncremental = backupData.backupType === 'incremental';
@@ -711,11 +937,29 @@ async function restoreData(backupData, options = {}) {
return results;
}
// 如果选择跳过用户数据,将用户相关表添加到跳过列表
const effectiveSkipTables = [...skipTables];
if (skipUserData) {
USER_RELATED_TABLES.forEach(table => {
if (!effectiveSkipTables.includes(table)) {
effectiveSkipTables.push(table);
}
});
console.log('已配置跳过用户相关表:', USER_RELATED_TABLES);
}
await disableForeignKeyChecks();
try {
for (const tableName of RESTORE_ORDER) {
if (skipTables.includes(tableName)) {
// 检查是否请求停止
if (shouldStop()) {
results.stopped = true;
console.log('恢复操作被用户中断');
break;
}
if (effectiveSkipTables.includes(tableName)) {
results.skipped.push(tableName);
onProgress(tableName, 'skipped');
continue;
@@ -736,7 +980,8 @@ async function restoreData(backupData, options = {}) {
try {
const Model = require(config.modelPath);
if (overwriteExisting) {
// 对于用户相关表,即使设置了 overwriteExisting 也不截断
if (overwriteExisting && !USER_RELATED_TABLES.includes(tableName)) {
await Model.destroy({ where: {}, truncate: true });
}
@@ -764,32 +1009,57 @@ async function restoreData(backupData, options = {}) {
});
let insertedCount = 0;
for (const record of processedRecords) {
for (let i = 0; i < processedRecords.length; i += BATCH_SIZE) {
if (shouldStop()) {
results.stopped = true;
logger.info('恢复操作在插入数据时被用户中断');
break;
}
const batch = processedRecords.slice(i, i + BATCH_SIZE);
try {
await Model.create(record, { validate: false, silent: true });
insertedCount++;
} catch (insertError) {
if (insertError.name === 'SequelizeUniqueConstraintError') {
await Model.bulkCreate(batch, {
validate: false,
individualHooks: false,
logging: false,
});
insertedCount += batch.length;
} catch (bulkError) {
for (const record of batch) {
try {
await Model.upsert(record, { validate: false, silent: true });
await Model.create(record, { validate: false, silent: true });
insertedCount++;
} catch (upsertError) {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: upsertError.message,
});
} catch (insertError) {
if (insertError.name === 'SequelizeUniqueConstraintError') {
try {
await Model.upsert(record, { validate: false, silent: true });
insertedCount++;
} catch (upsertError) {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: upsertError.message,
});
}
} else {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: insertError.message,
});
}
}
} else {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: insertError.message,
});
}
}
}
// 如果已停止,跳出外层循环
if (results.stopped) {
break;
}
results.tablesRestored++;
results.recordsRestored += insertedCount;
@@ -843,36 +1113,51 @@ async function restoreIncrementalData(incrementalData, options = {}) {
let updatedCount = 0;
if (tableIncrement.new && tableIncrement.new.length > 0) {
for (const record of tableIncrement.new) {
for (let i = 0; i < tableIncrement.new.length; i += BATCH_SIZE) {
const batch = tableIncrement.new.slice(i, i + BATCH_SIZE);
try {
await Model.create(record, { validate: false, silent: true });
updatedCount++;
} catch (insertError) {
if (insertError.name === 'SequelizeUniqueConstraintError') {
await Model.upsert(record, { validate: false, silent: true });
updatedCount++;
} else {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: `新增失败: ${insertError.message}`,
});
await Model.bulkCreate(batch, {
validate: false,
individualHooks: false,
logging: false,
});
updatedCount += batch.length;
} catch (bulkError) {
for (const record of batch) {
try {
await Model.create(record, { validate: false, silent: true });
updatedCount++;
} catch (insertError) {
if (insertError.name === 'SequelizeUniqueConstraintError') {
await Model.upsert(record, { validate: false, silent: true });
updatedCount++;
} else {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: `新增失败: ${insertError.message}`,
});
}
}
}
}
}
}
if (tableIncrement.updated && tableIncrement.updated.length > 0) {
for (const record of tableIncrement.updated) {
try {
await Model.upsert(record, { validate: false, silent: true });
updatedCount++;
} catch (updateError) {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: `更新失败: ${updateError.message}`,
});
for (let i = 0; i < tableIncrement.updated.length; i += BATCH_SIZE) {
const batch = tableIncrement.updated.slice(i, i + BATCH_SIZE);
for (const record of batch) {
try {
await Model.upsert(record, { validate: false, silent: true });
updatedCount++;
} catch (updateError) {
results.errors.push({
table: tableName,
record: record[Object.keys(record)[0]],
error: `更新失败: ${updateError.message}`,
});
}
}
}
}
@@ -944,77 +1229,189 @@ async function restoreBackup(filePath, options = {}) {
overwriteExisting = true,
skipTables = [],
skipFiles = false,
skipUserData = false,
shouldStop = () => false,
onProgress = () => {},
enableRollback = true,
} = options;
console.log('验证备份文件...');
const validation = await validateBackupFile(filePath);
if (!validation.valid) {
throw new Error(`备份文件验证失败: ${validation.error}`);
}
let snapshotId = null;
console.log('备份文件信息:');
console.log(` 版本: ${validation.version}`);
console.log(` 压缩: ${validation.compressed ? '是' : '否'}`);
console.log(` 表数: ${validation.metadata.tableCount}`);
console.log(` 记录数: ${validation.metadata.totalRecords}`);
console.log(` 文件数: ${validation.metadata.fileCount}`);
try {
logger.info('验证备份文件...');
const validation = await validateBackupFile(filePath);
if (!validation.valid) {
throw new Error(`备份文件验证失败: ${validation.error}`);
}
console.log('\n读取备份数据...');
const buffer = fs.readFileSync(filePath);
const isCompressed = filePath.endsWith('.gz');
let backupData;
if (isCompressed) {
console.log('解压备份文件...');
const decompressed = zlib.gunzipSync(buffer);
backupData = JSON.parse(decompressed.toString('utf8'));
} else {
backupData = JSON.parse(buffer.toString('utf8'));
}
console.log('\n开始恢复数据...');
const dataResults = await restoreData(backupData, {
overwriteExisting,
skipTables,
onProgress,
});
let fileResults = { filesRestored: 0, errors: [] };
if (!skipFiles && backupData.files) {
console.log('\n恢复上传文件...');
const uploadsDir = path.join(__dirname, '..', 'uploads');
fileResults = await restoreFiles(backupData.files, uploadsDir);
}
const stat = fs.statSync(filePath);
console.log('\n恢复完成!');
console.log(`备份文件: ${(stat.size / 1024 / 1024).toFixed(2)} MB`);
console.log(`数据记录: ${dataResults.recordsRestored}`);
console.log(`恢复表数: ${dataResults.tablesRestored}`);
console.log(`文件恢复: ${fileResults.filesRestored}`);
if (dataResults.errors.length > 0) {
console.log('\n恢复错误:');
dataResults.errors.forEach(err => {
console.log(` - ${err.table}: ${err.error}`);
logger.info('备份文件信息', {
version: validation.version,
compressed: validation.compressed,
tableCount: validation.metadata.tableCount,
totalRecords: validation.metadata.totalRecords,
fileCount: validation.metadata.fileCount,
});
}
return {
restoredAt: new Date().toISOString(),
tablesRestored: dataResults.tablesRestored,
recordsRestored: dataResults.recordsRestored,
filesRestored: fileResults.filesRestored,
errors: dataResults.errors,
tableDetails: dataResults.tableDetails, // 每个表的详细恢复信息
fileDetails: backupData.files
? {
avatars: backupData.files.avatars?.length || 0,
others: backupData.files.others?.length || 0,
// 步骤1: 启用维护模式,防止并发操作导致数据不一致
logger.info('启用维护模式...');
enableMaintenanceMode('正在恢复备份数据');
onProgress('maintenance', 'enabled', 0);
try {
// 步骤2: 创建还原点(如果启用回滚保护)
if (enableRollback) {
logger.info('创建还原点...');
onProgress('snapshot', 'creating', 0);
snapshotId = await createSnapshot();
if (snapshotId) {
logger.info('还原点创建成功,开始恢复数据...');
onProgress('snapshot', 'created', 5);
} else {
logger.warn('还原点创建失败,继续恢复但无法回滚');
onProgress('snapshot', 'warning', 5);
}
: null,
};
}
// 步骤3: 读取备份数据
logger.info('读取备份数据...');
const buffer = fs.readFileSync(filePath);
const isCompressed = filePath.endsWith('.gz');
let backupData;
if (isCompressed) {
logger.info('解压备份文件...');
const decompressed = zlib.gunzipSync(buffer);
backupData = JSON.parse(decompressed.toString('utf8'));
} else {
backupData = JSON.parse(buffer.toString('utf8'));
}
// 步骤4: 恢复数据
logger.info('开始恢复数据...');
const dataResults = await restoreData(backupData, {
overwriteExisting,
skipTables,
skipUserData,
shouldStop,
onProgress,
});
// 步骤5: 恢复上传文件(仅在数据恢复未停止时)
let fileResults = { filesRestored: 0, errors: [] };
if (!skipFiles && backupData.files && !dataResults.stopped) {
logger.info('恢复上传文件...');
const uploadsDir = path.join(__dirname, '..', 'uploads');
fileResults = await restoreFiles(backupData.files, uploadsDir);
}
// 步骤6: 处理恢复结果
const stat = fs.statSync(filePath);
if (dataResults.stopped) {
// 用户中断恢复 - 自动回滚到还原点
logger.info('恢复被用户中断,准备回滚数据...');
if (snapshotId && enableRollback) {
onProgress('rollback', 'starting', 0);
const rollbackSuccess = await restoreFromSnapshot(snapshotId);
if (rollbackSuccess) {
logger.info('数据已成功回滚到恢复前的状态');
onProgress('rollback', 'completed', 100);
} else {
logger.error('数据回滚失败!数据可能处于不一致状态');
onProgress('rollback', 'failed', 0);
}
} else {
logger.warn('无法回滚:没有可用的还原点');
}
// 清理还原点
if (snapshotId) {
deleteSnapshot(snapshotId);
}
return {
restoredAt: new Date().toISOString(),
tablesRestored: dataResults.tablesRestored,
recordsRestored: dataResults.recordsRestored,
filesRestored: fileResults.filesRestored,
errors: dataResults.errors,
skipped: dataResults.skipped || [],
stopped: true,
rolledBack: snapshotId ? true : false,
tableDetails: dataResults.tableDetails,
fileDetails: backupData.files
? {
avatars: backupData.files.avatars?.length || 0,
others: backupData.files.others?.length || 0,
}
: null,
};
}
// 恢复成功
logger.info('恢复完成!', {
fileSize: `${(stat.size / 1024 / 1024).toFixed(2)} MB`,
recordsRestored: dataResults.recordsRestored,
tablesRestored: dataResults.tablesRestored,
filesRestored: fileResults.filesRestored,
});
// 清理还原点(恢复成功,不再需要)
if (snapshotId) {
deleteSnapshot(snapshotId);
}
// 清理过期还原点
cleanupOldSnapshots();
if (dataResults.errors.length > 0) {
logger.warn('恢复过程中有错误', { errors: dataResults.errors });
}
return {
restoredAt: new Date().toISOString(),
tablesRestored: dataResults.tablesRestored,
recordsRestored: dataResults.recordsRestored,
filesRestored: fileResults.filesRestored,
errors: dataResults.errors,
skipped: dataResults.skipped || [],
stopped: false,
rolledBack: false,
tableDetails: dataResults.tableDetails,
fileDetails: backupData.files
? {
avatars: backupData.files.avatars?.length || 0,
others: backupData.files.others?.length || 0,
}
: null,
};
} finally {
// 无论成功或中断,都解除维护模式
disableMaintenanceMode();
onProgress('maintenance', 'disabled', 100);
}
} catch (error) {
// 恢复过程发生异常 - 尝试回滚
logger.error('恢复过程发生错误', { error: error.message, stack: error.stack });
if (snapshotId && enableRollback) {
logger.info('尝试回滚数据到恢复前的状态...');
const rollbackSuccess = await restoreFromSnapshot(snapshotId);
if (rollbackSuccess) {
logger.info('数据已成功回滚');
} else {
logger.error('数据回滚失败!数据可能处于不一致状态');
}
deleteSnapshot(snapshotId);
}
throw error;
}
}
function cleanOldBackups(options = {}) {
@@ -1115,4 +1512,8 @@ module.exports = {
restoreBackup,
calculateChecksum,
cleanOldBackups,
createSnapshot,
restoreFromSnapshot,
deleteSnapshot,
cleanupOldSnapshots,
};
+3 -4
View File
@@ -1,3 +1,5 @@
const logger = require('./logger').module('ErrorHandler');
class AppError extends Error {
constructor(code, message, statusCode = 500, details = null) {
super(message);
@@ -43,9 +45,7 @@ const FRIENDLY_MESSAGES = {
};
function logError(error, req = null) {
const timestamp = new Date().toISOString();
const logData = {
timestamp,
message: error.message,
code: error.code,
statusCode: error.statusCode,
@@ -57,8 +57,7 @@ function logError(error, req = null) {
method: req?.method,
};
console.error('=== ERROR LOG ===');
console.error(JSON.stringify(logData, null, 2));
logger.error('应用错误', logData);
}
function buildErrorResponse(error) {
+113
View File
@@ -0,0 +1,113 @@
/**
* 统一日志模块
* 基于 winston 的日志系统,支持文件轮转、级别控制、模块标识
* 全项目唯一日志入口
*/
const { createLogger, format, transports } = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const path = require('path');
// 日志目录
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '../logs');
// 日志级别
const LOG_LEVEL = process.env.LOG_LEVEL || (process.env.NODE_ENV === 'production' ? 'info' : 'debug');
// 单个日志文件最大大小
const LOG_MAX_FILE_SIZE = process.env.LOG_MAX_FILE_SIZE || '20m';
// 日志文件最大保留天数
const LOG_MAX_FILES = process.env.LOG_MAX_FILES || '30d';
/**
* 统一日志格式:时间 | 级别 | 模块 | 消息 | 元数据
*/
const logFormat = format.combine(
format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
format.errors({ stack: true }),
format.metadata({ fillExcept: ['message', 'level', 'timestamp', 'module'] }),
format.printf(({ timestamp, level, message, module, metadata, stack }) => {
const moduleTag = module ? `[${module}]` : '';
const metaStr = metadata && Object.keys(metadata).length
? ' ' + JSON.stringify(metadata)
: '';
const stackStr = stack ? `\n${stack}` : '';
return `${timestamp} ${level.toUpperCase().padEnd(5)} ${moduleTag} ${message}${metaStr}${stackStr}`;
})
);
/**
* 创建 winston logger 实例
*/
const logger = createLogger({
level: LOG_LEVEL,
format: logFormat,
defaultMeta: { service: 'idc-management' },
transports: [
// 应用日志 - 按天轮转
new DailyRotateFile({
dirname: path.join(LOG_DIR, 'app'),
filename: 'application-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: LOG_MAX_FILES,
maxSize: LOG_MAX_FILE_SIZE,
level: 'info',
}),
// 错误日志 - 独立存储
new DailyRotateFile({
dirname: path.join(LOG_DIR, 'error'),
filename: 'error-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: '60d',
maxSize: LOG_MAX_FILE_SIZE,
level: 'error',
}),
],
// 未捕获异常处理
exceptionHandlers: [
new DailyRotateFile({
dirname: path.join(LOG_DIR, 'error'),
filename: 'exceptions-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: LOG_MAX_FILES,
}),
],
// 未处理 Promise 拒绝处理
rejectionHandlers: [
new DailyRotateFile({
dirname: path.join(LOG_DIR, 'error'),
filename: 'rejections-%DATE%.log',
datePattern: 'YYYY-MM-DD',
maxFiles: LOG_MAX_FILES,
}),
],
});
// 开发环境添加控制台输出(带颜色)
if (process.env.NODE_ENV !== 'production') {
logger.add(new transports.Console({
format: format.combine(
format.colorize(),
format.timestamp({ format: 'HH:mm:ss' }),
format.printf(({ timestamp, level, message, module }) => {
const moduleTag = module ? `[${module}]` : '';
return `${timestamp} ${level} ${moduleTag} ${message}`;
})
),
}));
}
/**
* 创建带模块名的子 logger
* @param {string} moduleName - 模块名称
* @returns {Object} 包含 debug/info/warn/error 方法的对象
*/
logger.module = (moduleName) => ({
debug: (msg, meta) => logger.debug(msg, { module: moduleName, ...meta }),
info: (msg, meta) => logger.info(msg, { module: moduleName, ...meta }),
warn: (msg, meta) => logger.warn(msg, { module: moduleName, ...meta }),
error: (msg, meta) => logger.error(msg, { module: moduleName, ...meta }),
});
module.exports = logger;
+79
View File
@@ -0,0 +1,79 @@
const logger = require('./logger').module('MaintenanceMode');
const MAINTENANCE_TIMEOUT_MS = 30 * 60 * 1000;
const MaintenanceMode = {
isActive: false,
startTime: null,
reason: null,
allowedRoutes: ['/api/backup', '/api/auth', '/api/maintenance'],
};
let timeoutTimer = null;
function clearTimeoutTimer() {
if (timeoutTimer) {
clearTimeout(timeoutTimer);
timeoutTimer = null;
}
}
function isTimedOut() {
if (!MaintenanceMode.startTime) return false;
return Date.now() - MaintenanceMode.startTime.getTime() > MAINTENANCE_TIMEOUT_MS;
}
function enableMaintenanceMode(reason = '系统维护中') {
clearTimeoutTimer();
MaintenanceMode.isActive = true;
MaintenanceMode.startTime = new Date();
MaintenanceMode.reason = reason;
timeoutTimer = setTimeout(() => {
if (MaintenanceMode.isActive) {
logger.warn('维护模式超时,自动解除', {
reason: MaintenanceMode.reason,
startTime: MaintenanceMode.startTime,
});
disableMaintenanceMode();
}
}, MAINTENANCE_TIMEOUT_MS);
logger.info('维护模式已启用', { reason });
}
function disableMaintenanceMode() {
clearTimeoutTimer();
MaintenanceMode.isActive = false;
MaintenanceMode.reason = null;
MaintenanceMode.startTime = null;
logger.info('维护模式已解除');
}
function isMaintenanceModeActive() {
if (MaintenanceMode.isActive && isTimedOut()) {
disableMaintenanceMode();
return false;
}
return MaintenanceMode.isActive;
}
function getMaintenanceStatus() {
return {
active: MaintenanceMode.isActive,
reason: MaintenanceMode.reason,
startTime: MaintenanceMode.startTime,
timeoutMs: MAINTENANCE_TIMEOUT_MS,
};
}
module.exports = {
MaintenanceMode,
enableMaintenanceMode,
disableMaintenanceMode,
isMaintenanceModeActive,
getMaintenanceStatus,
};
+4 -3
View File
@@ -1,5 +1,6 @@
const path = require('path');
const routesConfig = require('../config/routes');
const logger = require('./logger').module('RouteLoader');
function loadRoutes(app) {
const routesDir = path.join(__dirname, '../routes');
@@ -11,12 +12,12 @@ function loadRoutes(app) {
if (typeof router === 'function') {
app.use(routeConfig.path, router);
console.log(`路由已加载: ${routeConfig.path} -> ${routeConfig.file}`);
logger.info(`路由已加载: ${routeConfig.path} -> ${routeConfig.file}`);
} else {
console.warn(`警告: ${routeConfig.file} 没有导出有效的 Express 路由器`);
logger.warn(`${routeConfig.file} 没有导出有效的 Express 路由器`);
}
} catch (error) {
console.error(`加载路由失败: ${routeConfig.file}`, error.message);
logger.error(`加载路由失败: ${routeConfig.file}`, { error: error.message, stack: error.stack });
throw error;
}
});