feat(backup): 实现备份恢复进度实时显示功能
This commit is contained in:
@@ -89,16 +89,24 @@ const verifyToken = (token) => {
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
let token = null;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
}
|
||||
|
||||
if (!token && req.query.token) {
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: '未提供认证令牌'
|
||||
});
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const decoded = verifyToken(token);
|
||||
|
||||
if (!decoded) {
|
||||
|
||||
@@ -3,6 +3,7 @@ const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
const {
|
||||
getBackupPath,
|
||||
ensureBackupDir,
|
||||
@@ -174,6 +175,114 @@ router.get('/validate/:filename', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/restore-progress/:filename', authMiddleware, async (req, res) => {
|
||||
const { filename } = req.params;
|
||||
const options = req.query.options ? JSON.parse(req.query.options) : {};
|
||||
|
||||
if (!filename) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '请提供备份文件名',
|
||||
});
|
||||
}
|
||||
|
||||
const backupPath = getBackupPath();
|
||||
const filePath = path.join(backupPath, filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '备份文件不存在',
|
||||
});
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
const sendProgress = (data) => {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
console.log(`开始恢复备份: ${filename}`);
|
||||
sendProgress({ stage: 'start', message: '正在验证备份文件...', progress: 5 });
|
||||
|
||||
const validation = await validateBackupFile(filePath);
|
||||
if (!validation.valid) {
|
||||
sendProgress({ stage: 'error', message: `备份文件验证失败: ${validation.error}`, progress: 0 });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress({ stage: 'validate', message: '备份文件验证通过', progress: 10, metadata: validation.metadata });
|
||||
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const isCompressed = filePath.endsWith('.gz');
|
||||
|
||||
let backupData;
|
||||
if (isCompressed) {
|
||||
sendProgress({ stage: 'decompress', message: '正在解压备份文件...', progress: 15 });
|
||||
const decompressed = zlib.gunzipSync(buffer);
|
||||
backupData = JSON.parse(decompressed.toString('utf8'));
|
||||
} else {
|
||||
backupData = JSON.parse(buffer.toString('utf8'));
|
||||
}
|
||||
|
||||
sendProgress({ stage: 'parse', message: '正在解析备份数据...', progress: 20 });
|
||||
|
||||
const totalTables = require('../utils/backup').RESTORE_ORDER.length;
|
||||
let processedTables = 0;
|
||||
|
||||
const result = await restoreBackup(filePath, {
|
||||
overwriteExisting: options.overwriteExisting !== false,
|
||||
skipTables: options.skipTables || [],
|
||||
skipFiles: options.skipFiles === true,
|
||||
onProgress: (tableName, status, count) => {
|
||||
processedTables++;
|
||||
const progress = 20 + Math.floor((processedTables / totalTables) * 70);
|
||||
const statusMap = {
|
||||
'restored': '已恢复',
|
||||
'skipped': '已跳过',
|
||||
'empty': '无数据',
|
||||
'error': '错误',
|
||||
};
|
||||
sendProgress({
|
||||
stage: 'restore',
|
||||
message: `正在恢复: ${tableName} (${statusMap[status] || status}${count ? ` - ${count} 条` : ''})`,
|
||||
progress,
|
||||
currentTable: tableName,
|
||||
status,
|
||||
count,
|
||||
processedTables,
|
||||
totalTables,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
sendProgress({
|
||||
stage: 'complete',
|
||||
message: '恢复完成!',
|
||||
progress: 100,
|
||||
result: {
|
||||
tablesRestored: result.tablesRestored,
|
||||
recordsRestored: result.recordsRestored,
|
||||
filesRestored: result.filesRestored,
|
||||
restoredAt: result.restoredAt,
|
||||
tableDetails: result.tableDetails,
|
||||
fileDetails: result.fileDetails,
|
||||
}
|
||||
});
|
||||
|
||||
res.end();
|
||||
} catch (error) {
|
||||
console.error('恢复备份失败:', error);
|
||||
sendProgress({ stage: 'error', message: `恢复失败: ${error.message}`, progress: 0 });
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/restore', async (req, res) => {
|
||||
try {
|
||||
const { filename, options = {} } = req.body;
|
||||
|
||||
+99
-76
@@ -8,9 +8,26 @@ const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const zlib = require('zlib');
|
||||
const { pipeline } = require('stream/promises');
|
||||
const { sequelize, dbDialect } = require('../db');
|
||||
|
||||
const BACKUP_VERSION = '2.0.0';
|
||||
|
||||
async function disableForeignKeyChecks() {
|
||||
if (dbDialect === 'sqlite') {
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
} else if (dbDialect === 'mysql') {
|
||||
await sequelize.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
}
|
||||
}
|
||||
|
||||
async function enableForeignKeyChecks() {
|
||||
if (dbDialect === 'sqlite') {
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
} else if (dbDialect === 'mysql') {
|
||||
await sequelize.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
}
|
||||
}
|
||||
|
||||
// 数据表名称中英文映射
|
||||
const TABLE_NAME_MAPPING = {
|
||||
'User': '用户',
|
||||
@@ -684,99 +701,105 @@ async function restoreData(backupData, options = {}) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const tableName of RESTORE_ORDER) {
|
||||
if (skipTables.includes(tableName)) {
|
||||
results.skipped.push(tableName);
|
||||
onProgress(tableName, 'skipped');
|
||||
continue;
|
||||
}
|
||||
await disableForeignKeyChecks();
|
||||
|
||||
const tableData = dataToRestore[tableName];
|
||||
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
|
||||
onProgress(tableName, 'empty');
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
|
||||
if (!config) {
|
||||
results.errors.push({ table: tableName, error: '未找到模型配置' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
|
||||
if (overwriteExisting) {
|
||||
await Model.destroy({ where: {}, truncate: true });
|
||||
try {
|
||||
for (const tableName of RESTORE_ORDER) {
|
||||
if (skipTables.includes(tableName)) {
|
||||
results.skipped.push(tableName);
|
||||
onProgress(tableName, 'skipped');
|
||||
continue;
|
||||
}
|
||||
|
||||
const processedRecords = tableData.map(record => {
|
||||
const processed = { ...record };
|
||||
const tableData = dataToRestore[tableName];
|
||||
if (!tableData || !Array.isArray(tableData) || tableData.length === 0) {
|
||||
onProgress(tableName, 'empty');
|
||||
continue;
|
||||
}
|
||||
|
||||
const config = BACKUP_MODELS_CONFIG.find(c => c.name === tableName);
|
||||
if (!config) {
|
||||
results.errors.push({ table: tableName, error: '未找到模型配置' });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const Model = require(config.modelPath);
|
||||
|
||||
if (tableName === 'Device' && processed.customFields !== undefined && processed.customFields !== null) {
|
||||
if (typeof processed.customFields === 'string') {
|
||||
try {
|
||||
processed.customFields = JSON.parse(processed.customFields);
|
||||
} catch (e) {
|
||||
console.warn(`解析 Device.customFields 失败:${processed.deviceId}, 错误:${e.message}`);
|
||||
processed.customFields = {};
|
||||
if (overwriteExisting) {
|
||||
await Model.destroy({ where: {}, truncate: true });
|
||||
}
|
||||
|
||||
const processedRecords = tableData.map(record => {
|
||||
const processed = { ...record };
|
||||
|
||||
if (tableName === 'Device' && processed.customFields !== undefined && processed.customFields !== null) {
|
||||
if (typeof processed.customFields === 'string') {
|
||||
try {
|
||||
processed.customFields = JSON.parse(processed.customFields);
|
||||
} catch (e) {
|
||||
console.warn(`解析 Device.customFields 失败:${processed.deviceId}, 错误:${e.message}`);
|
||||
processed.customFields = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
return processed;
|
||||
});
|
||||
|
||||
let insertedCount = 0;
|
||||
for (const record of processedRecords) {
|
||||
try {
|
||||
await Model.create(record, { validate: false, silent: true });
|
||||
insertedCount++;
|
||||
} catch (insertError) {
|
||||
if (insertError.name === 'SequelizeUniqueConstraintError') {
|
||||
try {
|
||||
await Model.upsert(record, { validate: false, silent: true });
|
||||
insertedCount++;
|
||||
} catch (upsertError) {
|
||||
let insertedCount = 0;
|
||||
for (const record of processedRecords) {
|
||||
try {
|
||||
await Model.create(record, { validate: false, silent: true });
|
||||
insertedCount++;
|
||||
} 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: upsertError.message,
|
||||
error: insertError.message,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
results.errors.push({
|
||||
table: tableName,
|
||||
record: record[Object.keys(record)[0]],
|
||||
error: insertError.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.tablesRestored++;
|
||||
results.recordsRestored += insertedCount;
|
||||
|
||||
results.tableDetails[tableName] = {
|
||||
recordCount: insertedCount,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
success: insertedCount > 0,
|
||||
};
|
||||
|
||||
onProgress(tableName, 'restored', insertedCount);
|
||||
} catch (error) {
|
||||
results.errors.push({ table: tableName, error: error.message });
|
||||
onProgress(tableName, 'error', error.message);
|
||||
}
|
||||
|
||||
results.tablesRestored++;
|
||||
results.recordsRestored += insertedCount;
|
||||
|
||||
results.tableDetails[tableName] = {
|
||||
recordCount: insertedCount,
|
||||
displayName: TABLE_NAME_MAPPING[tableName] || tableName,
|
||||
success: insertedCount > 0,
|
||||
};
|
||||
|
||||
onProgress(tableName, 'restored', insertedCount);
|
||||
} catch (error) {
|
||||
results.errors.push({ table: tableName, error: error.message });
|
||||
onProgress(tableName, 'error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (isIncremental && backupData.incrementalData) {
|
||||
console.log('\n恢复增量数据...');
|
||||
const incrementalResults = await restoreIncrementalData(backupData.incrementalData, options);
|
||||
results.tablesRestored += incrementalResults.tablesRestored;
|
||||
results.recordsRestored += incrementalResults.recordsRestored;
|
||||
results.errors.push(...incrementalResults.errors);
|
||||
Object.assign(results.tableDetails, incrementalResults.tableDetails);
|
||||
if (isIncremental && backupData.incrementalData) {
|
||||
console.log('\n恢复增量数据...');
|
||||
const incrementalResults = await restoreIncrementalData(backupData.incrementalData, options);
|
||||
results.tablesRestored += incrementalResults.tablesRestored;
|
||||
results.recordsRestored += incrementalResults.recordsRestored;
|
||||
results.errors.push(...incrementalResults.errors);
|
||||
Object.assign(results.tableDetails, incrementalResults.tableDetails);
|
||||
}
|
||||
} finally {
|
||||
await enableForeignKeyChecks();
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
Reference in New Issue
Block a user