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: ( - -
- -
- 数据恢复成功 -
- ), - width: 700, - content: ( -
- {/* 汇总信息 */} -
- - -
-

- {data.tablesRestored} -

-

- 恢复表数 -

-
- - -
-

- {data.recordsRestored} -

-

- 恢复记录 -

-
- - -
-

- {data.filesRestored} -

-

- 恢复文件 -

-
- - -
-

- {formatDateTime(data.restoredAt)} -

-

- 恢复时间 -

-
- -
-
+ const statCardStyle = (gradient, shadowColor) => ({ + background: gradient, + borderRadius: '16px', + padding: '20px 16px', + textAlign: 'center', + boxShadow: `0 4px 16px ${shadowColor}`, + transition: 'transform 0.3s ease, box-shadow 0.3s ease', + cursor: 'default', + position: 'relative', + overflow: 'hidden', + }); - {/* 数据表恢复详情 */} - {data.tableDetails && Object.keys(data.tableDetails).length > 0 && ( + const statValueStyle = { + margin: 0, + fontSize: '32px', + fontWeight: 700, + color: '#ffffff', + lineHeight: 1.2, + textShadow: '0 2px 4px rgba(0,0,0,0.1)', + }; + + const statLabelStyle = { + margin: '8px 0 0 0', + fontSize: '13px', + fontWeight: 500, + color: 'rgba(255,255,255,0.9)', + letterSpacing: '0.5px', + }; + + const tableCardStyle = { + padding: '12px 16px', + background: 'linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)', + borderRadius: '12px', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + border: '1px solid #e2e8f0', + transition: 'all 0.25s ease', + cursor: 'default', + }; + + Modal.success({ + icon: null, + title: null, + width: 720, + content: ( +
+ +
-

+ +

+

+ 数据恢复成功 +

+
- - 数据表恢复详情 -

-
- {Object.entries(data.tableDetails).map(([tableName, tableInfo]) => ( -
-
- - {tableInfo.displayName || tableName} - - {tableInfo.displayName !== tableName && ( - - {tableName} - - )} -
- - {tableInfo.recordCount} 条 - -
- ))} + + + 所有数据已安全恢复 +
- )} - {/* 文件恢复详情 */} - {data.fileDetails && (data.fileDetails.avatars > 0 || data.fileDetails.others > 0) && (
-

+ +

{resultData?.tablesRestored || 0}

+

恢复表数

+
+ +
+ +

{resultData?.recordsRestored || 0}

+

恢复记录

+
+ +
+ +

{resultData?.filesRestored || 0}

+

恢复文件

+
+ +
+ +

{formatDateTime(resultData?.restoredAt)}

+

恢复时间

+
+
+ + {resultData?.tableDetails && Object.keys(resultData.tableDetails).length > 0 && ( +
+
+
+
+ +
+ + 数据表恢复详情 + +
+ + 共 {Object.keys(resultData.tableDetails).length} 个表 + +
+ +
+ {Object.entries(resultData.tableDetails).map(([tableName, tableInfo], index) => ( +
+
+
+ + {tableInfo.displayName || tableName} + +
+
+ + {tableInfo.recordCount} + + + 条 + +
+
+ ))} +
+
+ )} + +
+
- - 文件恢复详情 -

- - {data.fileDetails.avatars > 0 && ( -
-

- 头像文件:{data.fileDetails.avatars} 个 -

-
- )} - - {data.fileDetails.others > 0 && ( -
-

- 其他文件:{data.fileDetails.others} 个 -

-
- )} + +
+
+

+ 建议刷新页面以确保所有数据生效 +

+

+ 刷新后可查看最新恢复的数据内容 +

+
- )} +
+ ), + centered: true, + maskClosable: false, + okText: '完成', + cancelButtonProps: { style: { display: 'none' } }, + okButtonProps: { + style: { + background: 'linear-gradient(135deg, #6366f1 0%, #4f46e5 100%)', + border: 'none', + borderRadius: '10px', + height: '44px', + padding: '0 32px', + fontSize: '15px', + fontWeight: 600, + boxShadow: '0 4px 12px rgba(99, 102, 241, 0.3)', + }, + }, + }); + }, 500); + } - -
- ), - maskClosable: false, - okText: '完成', - okButtonProps: { - style: primaryButtonStyle, - }, + if (data.stage === 'error') { + eventSource.close(); + setRestoreLoading(false); + Modal.error({ + title: '数据恢复失败', + content: data.message, }); - }, 500); - } else { - throw new Error(response.data?.message || '恢复失败'); + } + } catch (e) { + console.error('解析 SSE 数据失败:', e); } - } catch (error) { + }; + + eventSource.onerror = (error) => { + console.error('SSE 连接错误:', error); + eventSource.close(); setRestoreLoading(false); Modal.error({ title: '数据恢复失败', - content: error.response?.data?.message || error.message || '未知错误', + content: '连接中断,请重试', }); - } + }; }; const handleDelete = async (filename) => { diff --git a/frontend/src/pages/PendingDeviceManagement.jsx b/frontend/src/pages/PendingDeviceManagement.jsx index 5f856cb..55f8a25 100644 --- a/frontend/src/pages/PendingDeviceManagement.jsx +++ b/frontend/src/pages/PendingDeviceManagement.jsx @@ -648,7 +648,7 @@ const PendingDeviceManagement = () => {
- + { - + { - + { - + 0 ? Math.round((stats.synced / stats.total) * 100) : 0} diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs index 7aadd5d..3741294 100644 --- a/frontend/vite.config.mjs +++ b/frontend/vite.config.mjs @@ -51,6 +51,15 @@ export default defineConfig({ host: '0.0.0.0', port: port, proxy: { + '/api/backup/restore-progress': { + target: 'http://localhost:8000', + changeOrigin: true, + configure: (proxy, _options) => { + proxy.on('proxyReq', (proxyReq, req, _res) => { + proxyReq.setHeader('Connection', 'keep-alive'); + }); + } + }, '/api': { target: 'http://localhost:8000', changeOrigin: true