From 971ecd62d6824e484069173d63609c199d8ca7ce Mon Sep 17 00:00:00 2001
From: zhang1106 <849185023@qq.com>
Date: Tue, 17 Mar 2026 11:19:15 +0800
Subject: [PATCH] =?UTF-8?q?feat(backup):=20=E5=AE=9E=E7=8E=B0=E5=A4=87?=
=?UTF-8?q?=E4=BB=BD=E6=81=A2=E5=A4=8D=E8=BF=9B=E5=BA=A6=E5=AE=9E=E6=97=B6?=
=?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/middleware/auth.js | 14 +-
backend/routes/backup.js | 109 ++++
backend/utils/backup.js | 175 ++---
frontend/src/pages/BackupManagement.jsx | 615 ++++++++++--------
.../src/pages/PendingDeviceManagement.jsx | 8 +-
frontend/vite.config.mjs | 9 +
6 files changed, 589 insertions(+), 341 deletions(-)
diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
index 87b89be..8b4c06f 100644
--- a/backend/middleware/auth.js
+++ b/backend/middleware/auth.js
@@ -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) {
diff --git a/backend/routes/backup.js b/backend/routes/backup.js
index ee042d6..2cce834 100644
--- a/backend/routes/backup.js
+++ b/backend/routes/backup.js
@@ -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;
diff --git a/backend/utils/backup.js b/backend/utils/backup.js
index ace932a..1c0c5ee 100644
--- a/backend/utils/backup.js
+++ b/backend/utils/backup.js
@@ -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;
diff --git a/frontend/src/pages/BackupManagement.jsx b/frontend/src/pages/BackupManagement.jsx
index 932e483..816ce44 100644
--- a/frontend/src/pages/BackupManagement.jsx
+++ b/frontend/src/pages/BackupManagement.jsx
@@ -40,6 +40,7 @@ import {
PlusOutlined,
ClearOutlined,
EyeOutlined,
+ TableOutlined,
} from '@ant-design/icons';
import api, { backupAPI } from '../api';
import CloseButton from '../components/CloseButton';
@@ -542,290 +543,388 @@ const BackupManagement = () => {
const handleRestore = async (filename) => {
setRestoreLoading(true);
setRestoreProgress(0);
- setRestoreStatus('正在验证备份文件...');
+ setRestoreStatus('正在初始化...');
- try {
- setRestoreProgress(10);
- setRestoreStatus('正在读取备份数据...');
+ const token = localStorage.getItem('token');
+ const options = {
+ overwriteExisting: true,
+ skipFiles: false,
+ };
+
+ const eventSource = new EventSource(
+ `/api/backup/restore-progress/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}&options=${encodeURIComponent(JSON.stringify(options))}`
+ );
- const response = await api.post('/backup/restore', {
- filename,
- options: {
- overwriteExisting: true,
- skipFiles: false,
- },
- });
+ let resultData = null;
- setRestoreProgress(90);
- setRestoreStatus('正在完成恢复...');
+ eventSource.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+
+ setRestoreProgress(data.progress);
+ setRestoreStatus(data.message);
- if (response?.success) {
- const data = response.data;
- setRestoreProgress(100);
- setRestoreStatus('恢复完成!');
+ if (data.stage === 'complete') {
+ resultData = data.result;
+ eventSource.close();
+
+ setTimeout(() => {
+ setRestoreVisible(false);
+ setRestoreLoading(false);
+
+ const successIconStyle = {
+ width: 56,
+ height: 56,
+ borderRadius: '16px',
+ background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ boxShadow: '0 8px 24px rgba(16, 185, 129, 0.3)',
+ animation: 'successPop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)',
+ };
- setTimeout(() => {
- setRestoreVisible(false);
- setRestoreLoading(false);
- Modal.success({
- title: (
-
- {data.tablesRestored} -
-- 恢复表数 -
-- {data.recordsRestored} -
-- 恢复记录 -
-- {data.filesRestored} -
-- 恢复文件 -
-- {formatDateTime(data.restoredAt)} -
-- 恢复时间 -
-
+
+
{resultData?.tablesRestored || 0}
+恢复表数
+{resultData?.recordsRestored || 0}
+恢复记录
+{resultData?.filesRestored || 0}
+恢复文件
+{formatDateTime(resultData?.restoredAt)}
+恢复时间
+- 头像文件:{data.fileDetails.avatars} 个 -
-- 其他文件:{data.fileDetails.others} 个 -
-+ 建议刷新页面以确保所有数据生效 +
++ 刷新后可查看最新恢复的数据内容 +
+