diff --git a/backend/.env.example b/backend/.env.example index dc545e9..2d05d83 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,125 +1,108 @@ -# IDC设备管理系统环境配置示例 -# 复制此文件为 .env 并根据需要修改配置 +# IDC设备管理系统 - 环境配置 +# 生成时间: 2026-02-26T05:07:19.876Z # ============================================== # 服务器配置 # ============================================== -# 服务器端口(默认8000) -# 如需修改,请设置为其他端口号 +# 服务器端口 PORT=8000 -# 运行环境(development/production) -# development: 开发模式,详细日志便于调试 -# production: 生产模式,性能优化,减少日志输出 +# 运行环境: development 或 production NODE_ENV=development # ============================================== # 数据库配置 # ============================================== -# 数据库类型:sqlite(默认)或 mysql -# sqlite: 零配置嵌入式数据库,适合开发和小规模应用 -# mysql: 关系型数据库,适合生产环境和大规模应用 +# 数据库类型: sqlite 或 mysql DB_TYPE=sqlite -# SQLite 配置(当DB_TYPE=sqlite时使用) -# SQLite数据库文件路径,文件会自动创建 +# SQLite 配置(当 DB_TYPE=sqlite 时使用) DB_PATH=./idc_management.db -# MySQL 配置(当DB_TYPE=mysql时使用) -# 注意:使用MySQL前请确保MySQL服务已安装并创建相应的数据库 +# MySQL 配置(当 DB_TYPE=mysql 时使用) +# 注意:使用 MySQL 前请确保 MySQL 服务已安装并创建相应的数据库 # 数据库创建命令:CREATE DATABASE idc_management CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -MYSQL_HOST=localhost # MySQL服务器地址 -MYSQL_PORT=3306 # MySQL端口号 -MYSQL_USERNAME=root # MySQL用户名 -MYSQL_PASSWORD= # MySQL密码(为空则无密码) -MYSQL_DATABASE=idc_management # MySQL数据库名 +MYSQL_HOST=localhost +MYSQL_PORT=3306 +MYSQL_USERNAME=root +MYSQL_PASSWORD= +MYSQL_DATABASE=idc_management # ============================================== -# 安全配置(必填) +# 安全配置 # ============================================== # JWT 密钥 - 用于签名和验证用户身份令牌 -# ⚠️ 安全警告: -# - 生产环境必须修改默认值,使用强随机密钥 -# - 至少 32 位字符,推荐 64 位 -# - 定期更换(建议每3-6个月) -# - 不要提交到代码仓库,仅保存在服务器环境变量 -# -# 生成强密钥命令: -# PowerShell: -join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ }) -# Linux/Mac: openssl rand -base64 64 -# Node.js: require('crypto').randomBytes(64).toString('hex') -# -JWT_SECRET=your-strong-secret-key-minimum-32-characters-change-in-production +# 开发环境留空会自动生成并保存到 .env 文件 +# 生产环境必须设置强随机密钥(至少32位) +# 生成命令(PowerShell):-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ }) +# 生成命令(Node.js):require('crypto').randomBytes(64).toString('hex') +JWT_SECRET= # Token 过期时间(格式:数字+单位,如 24h, 2h, 30m) -# 建议:开发环境 24h,生产环境 2h 或更短 TOKEN_EXPIRY=24h -# 密码加密强度(bcrypt salt rounds) -# 默认值:10,范围:4-12,值越大越安全但计算越慢 -SALT_ROUNDS=10 +# 密码加密强度(bcrypt salt rounds,范围:4-12) +SALT_ROUNDS=12 # 登录失败锁定阈值 -# 连续登录失败超过此次数后锁定账户 -MAX_LOGIN_ATTEMPTS=5 +MAX_LOGIN_ATTEMPTS=3 # 账户锁定时间(分钟) -# 登录失败锁定后的解锁等待时间 -LOCK_TIME_MINUTES=30 +LOCK_TIME_MINUTES=15 # 密码最小长度 -PASSWORD_MIN_LENGTH=6 +PASSWORD_MIN_LENGTH=8 # 用户名长度限制 -USERNAME_MIN_LENGTH=3 -USERNAME_MAX_LENGTH=50 +USERNAME_MIN_LENGTH=4 +USERNAME_MAX_LENGTH=30 # ============================================== # API 配置 # ============================================== # API 请求超时时间(毫秒) -API_TIMEOUT=30000 +API_TIMEOUT=20000 # 数据库查询超时时间(毫秒) -DB_QUERY_TIMEOUT=30000 +DB_QUERY_TIMEOUT=15000 # ============================================== # 分页配置 # ============================================== # 默认每页条数 -DEFAULT_PAGE_SIZE=10 +DEFAULT_PAGE_SIZE=20 # 最大每页条数 -MAX_PAGE_SIZE=1000 +MAX_PAGE_SIZE=500 # ============================================== # 文件上传配置 # ============================================== # 最大文件上传大小(MB) -MAX_FILE_SIZE_MB=50 +MAX_FILE_SIZE_MB=30 # 最大头像上传大小(MB) -MAX_AVATAR_SIZE_MB=5 +MAX_AVATAR_SIZE_MB=2 # ============================================== # 重试配置 # ============================================== # 最大重试次数 -MAX_RETRIES=3 +MAX_RETRIES=5 # 重试延迟(毫秒) -RETRY_DELAY=1000 +RETRY_DELAY=2000 # ============================================== # 前端配置 # ============================================== # 前端默认端口 -FRONTEND_PORT=3000 \ No newline at end of file +FRONTEND_PORT=3000 diff --git a/backend/config/auto-backup-settings.json b/backend/config/auto-backup-settings.json new file mode 100644 index 0000000..ec84ab1 --- /dev/null +++ b/backend/config/auto-backup-settings.json @@ -0,0 +1,10 @@ +{ + "enabled": true, + "cronExpression": "0 20 * * *", + "description": "自动备份", + "backupType": "full", + "includeFiles": true, + "compress": true, + "maxCount": 30, + "maxAgeDays": 90 +} \ No newline at end of file diff --git a/backend/config/constants.js b/backend/config/constants.js index 26cdd1b..bcc19a2 100644 --- a/backend/config/constants.js +++ b/backend/config/constants.js @@ -11,7 +11,7 @@ module.exports = { }, FILE_UPLOAD: { - MAX_FILE_SIZE: (parseInt(process.env.MAX_FILE_SIZE_MB, 10) || 50) * 1024 * 1024, + MAX_FILE_SIZE: (parseInt(process.env.MAX_FILE_SIZE_MB, 10) || 500) * 1024 * 1024, MAX_AVATAR_SIZE: (parseInt(process.env.MAX_AVATAR_SIZE_MB, 10) || 5) * 1024 * 1024, ALLOWED_IMAGE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], ALLOWED_DOC_TYPES: [ diff --git a/backend/initConfig.js b/backend/initConfig.js new file mode 100644 index 0000000..bbbc7f0 --- /dev/null +++ b/backend/initConfig.js @@ -0,0 +1,107 @@ +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); + +const ENV_FILE_PATH = path.join(__dirname, '.env'); + +function generateSecret(length = 64) { + return crypto.randomBytes(length).toString('base64'); +} + +function parseEnvContent(content) { + const lines = content.split('\n'); + const result = {}; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.substring(0, equalIndex).trim(); + const value = trimmed.substring(equalIndex + 1).trim(); + result[key] = value; + } + } + + return result; +} + +function stringifyEnvContent(envObj, originalContent) { + const lines = originalContent.split('\n'); + const updatedLines = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (!trimmed || trimmed.startsWith('#')) { + updatedLines.push(line); + continue; + } + + const equalIndex = trimmed.indexOf('='); + if (equalIndex > 0) { + const key = trimmed.substring(0, equalIndex).trim(); + + if (key === 'JWT_SECRET' && envObj.hasOwnProperty('JWT_SECRET')) { + updatedLines.push(`JWT_SECRET=${envObj.JWT_SECRET}`); + } else { + updatedLines.push(line); + } + } else { + updatedLines.push(line); + } + } + + return updatedLines.join('\n'); +} + +function ensureJwtSecret() { + const currentSecret = process.env.JWT_SECRET; + + if (currentSecret && currentSecret.length >= 32) { + console.log('✓ JWT_SECRET 已配置'); + return currentSecret; + } + + if (process.env.NODE_ENV === 'production') { + throw new Error( + '[致命错误] 生产环境未设置 JWT_SECRET 环境变量!\n' + + '请在服务器环境变量中设置强密钥(至少32位随机字符)。\n' + + '生成命令(PowerShell):-join ((48..57) + (65..90) + (97..122) | Get-Random -Count 64 | ForEach-Object { [char]$_ })' + ); + } + + const newSecret = generateSecret(64); + console.log('⚠️ JWT_SECRET 未配置或长度不足,正在自动生成...'); + + try { + if (fs.existsSync(ENV_FILE_PATH)) { + const originalContent = fs.readFileSync(ENV_FILE_PATH, 'utf-8'); + const updatedContent = stringifyEnvContent({ JWT_SECRET: newSecret }, originalContent); + fs.writeFileSync(ENV_FILE_PATH, updatedContent, 'utf-8'); + console.log('✓ JWT_SECRET 已自动生成并保存到 .env 文件'); + } else { + const defaultContent = `# IDC设备管理系统 - 环境配置 +# 自动生成时间: ${new Date().toISOString()} + +JWT_SECRET=${newSecret} +`; + fs.writeFileSync(ENV_FILE_PATH, defaultContent, 'utf-8'); + console.log('✓ JWT_SECRET 已自动生成并创建 .env 文件'); + } + + process.env.JWT_SECRET = newSecret; + return newSecret; + } catch (err) { + console.warn('⚠️ 无法保存 JWT_SECRET 到 .env 文件,使用临时密钥(重启后失效)'); + console.warn(' 错误信息:', err.message); + process.env.JWT_SECRET = newSecret; + return newSecret; + } +} + +module.exports = { + ensureJwtSecret, + generateSecret, +}; diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index 61d9bdc..87b89be 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -1,16 +1,9 @@ const jwt = require('jsonwebtoken'); -const crypto = require('crypto'); const User = require('../models/User'); -/** - * 获取 JWT Secret - * - 生产环境:强制从环境变量读取,未设置则抛出错误 - * - 开发环境:未设置时自动生成临时密钥(重启后失效) - */ function getJwtSecret() { const envSecret = process.env.JWT_SECRET; - // 生产环境强制校验 if (process.env.NODE_ENV === 'production') { if (!envSecret) { throw new Error( @@ -25,11 +18,10 @@ function getJwtSecret() { return envSecret; } - // 开发环境:使用环境变量或临时密钥 if (!envSecret) { - const tempSecret = crypto.randomBytes(32).toString('hex'); - console.warn('⚠️ [开发模式] JWT_SECRET 未设置,使用临时密钥(重启后所有 Token 失效)'); - return tempSecret; + throw new Error( + '[错误] JWT_SECRET 未配置,请检查 initConfig.js 是否正确执行' + ); } return envSecret; diff --git a/backend/package-lock.json b/backend/package-lock.json index 21746ac..cb4e797 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -20,7 +20,9 @@ "iconv-lite": "^0.6.3", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", + "multer": "^2.1.1", "mysql2": "^3.16.0", + "node-cron": "^4.2.1", "sequelize": "^6.32.1", "sqlite3": "^5.1.6", "three": "^0.182.0", @@ -2355,6 +2357,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/aproba": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", @@ -2913,7 +2921,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, "license": "MIT" }, "node_modules/busboy": { @@ -3341,6 +3348,21 @@ "devOptional": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -7612,6 +7634,25 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mysql2": { "version": "3.16.0", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.16.0.tgz", @@ -7716,6 +7757,15 @@ "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-gyp": { "version": "8.4.1", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-8.4.1.tgz", @@ -10036,6 +10086,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index 17d83e4..0c4d832 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,7 +26,9 @@ "iconv-lite": "^0.6.3", "joi": "^18.0.2", "jsonwebtoken": "^9.0.3", + "multer": "^2.1.1", "mysql2": "^3.16.0", + "node-cron": "^4.2.1", "sequelize": "^6.32.1", "sqlite3": "^5.1.6", "three": "^0.182.0", diff --git a/backend/routes/backup.js b/backend/routes/backup.js new file mode 100644 index 0000000..062bba2 --- /dev/null +++ b/backend/routes/backup.js @@ -0,0 +1,551 @@ +const express = require('express'); +const router = express.Router(); +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); +const { + getBackupPath, + ensureBackupDir, + createBackup, + validateBackupFile, + restoreBackup, + cleanOldBackups, +} = require('../utils/backup'); +const { + loadSettings, + saveSettings, + validateCronExpression, + timeToCron, + startAutoBackup, + stopAutoBackup, + getAutoBackupStatus, + updateAutoBackupSettings, + executeBackupNow, +} = require('../utils/autoBackupScheduler'); + +const tempDir = path.join(__dirname, '..', 'temp'); +if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); +} + +router.post('/', async (req, res) => { + try { + const { description = '', includeFiles = true } = req.body; + + console.log('开始创建备份...'); + const result = await createBackup({ + description, + includeFiles: includeFiles !== false, + }); + + res.json({ + success: true, + message: '备份创建成功', + data: result, + }); + } catch (error) { + console.error('创建备份失败:', error); + res.status(500).json({ + success: false, + message: '创建备份失败', + error: error.message, + }); + } +}); + +router.get('/list', async (req, res) => { + try { + const backupPath = getBackupPath(); + + if (!fs.existsSync(backupPath)) { + return res.json({ + success: true, + data: { backups: [], total: 0 }, + }); + } + + const files = fs.readdirSync(backupPath) + .filter(f => (f.startsWith('backup_') || f.startsWith('uploaded_')) && (f.endsWith('.json') || f.endsWith('.json.gz'))) + .map(async f => { + const filePath = path.join(backupPath, f); + const stats = fs.statSync(filePath); + const isCompressed = f.endsWith('.gz'); + + // 尝试从文件内容中提取元数据 + let metadata = { + filename: f, + size: stats.size, + compressed: isCompressed, + createdAt: stats.birthtime, + modifiedAt: stats.mtime, + }; + + try { + // 读取文件头部的元数据信息 + let content; + if (isCompressed) { + const compressed = fs.readFileSync(filePath); + content = zlib.gunzipSync(compressed).toString('utf8'); + } else { + content = fs.readFileSync(filePath, 'utf8'); + } + + const backupData = JSON.parse(content); + + // 提取关键元数据 + metadata.description = backupData.description || ''; + metadata.backupType = backupData.backupType || 'full'; + metadata.version = backupData.version || '1.0.0'; + metadata.timestamp = backupData.timestamp; + metadata.checksum = backupData.checksum; + metadata.metadata = backupData.metadata; + metadata.systemInfo = backupData.systemInfo; + + // 判断是否为上传的文件(通过文件名判断) + metadata.isUploaded = f.startsWith('uploaded_'); + + } catch (error) { + // 如果读取失败,标记为无效文件 + metadata.invalid = true; + metadata.error = '无法读取文件内容'; + } + + return metadata; + }); + + const resolvedFiles = await Promise.all(files); + + res.json({ + success: true, + data: { backups: resolvedFiles, total: resolvedFiles.length }, + }); + } catch (error) { + console.error('获取备份列表失败:', error); + res.status(500).json({ + success: false, + message: '获取备份列表失败', + error: error.message, + }); + } +}); + +router.get('/validate/:filename', async (req, res) => { + try { + const { filename } = req.params; + const backupPath = getBackupPath(); + const filePath = path.join(backupPath, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + message: '备份文件不存在', + }); + } + + const validation = await validateBackupFile(filePath); + + res.json({ + success: true, + data: validation, + }); + } catch (error) { + console.error('验证备份文件失败:', error); + res.status(500).json({ + success: false, + message: '验证备份文件失败', + error: error.message, + }); + } +}); + +router.post('/restore', async (req, res) => { + try { + const { filename, options = {} } = req.body; + + 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: '备份文件不存在', + }); + } + + console.log(`开始恢复备份: ${filename}`); + + const result = await restoreBackup(filePath, { + overwriteExisting: options.overwriteExisting !== false, + skipTables: options.skipTables || [], + skipFiles: options.skipFiles === true, + onProgress: (tableName, status, count) => { + console.log(` ${tableName}: ${status}${count ? ` (${count})` : ''}`); + }, + }); + + res.json({ + success: true, + message: '数据恢复成功', + data: result, + }); + } catch (error) { + console.error('恢复备份失败:', error); + res.status(500).json({ + success: false, + message: '恢复备份失败', + error: error.message, + }); + } +}); + +router.post('/upload', async (req, res) => { + try { + if (!req.files || !req.files.backup) { + return res.status(400).json({ + success: false, + message: '请上传备份文件', + }); + } + + const backupFile = req.files.backup; + const originalName = backupFile.name || ''; + const nameLower = originalName.toLowerCase(); + + // 验证文件类型 + if (!nameLower.endsWith('.json') && !nameLower.endsWith('.gz')) { + return res.status(400).json({ + success: false, + message: '只支持 JSON 或 GZ 格式的备份文件', + }); + } + + const isCompressed = nameLower.endsWith('.gz'); + + console.log(`上传备份文件: ${originalName}, 压缩: ${isCompressed}, 大小: ${backupFile.size}`); + + // 保存到临时文件 + const tempFilename = `upload_${Date.now()}`; + const tempPath = path.join(tempDir, tempFilename); + + await backupFile.mv(tempPath); + + const validation = await validateBackupFile(tempPath, { isCompressed }); + + if (!validation.valid) { + fs.unlinkSync(tempPath); + return res.status(400).json({ + success: false, + message: `备份文件验证失败: ${validation.error}`, + }); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); + const ext = isCompressed ? '.json.gz' : '.json'; + const newFilename = `uploaded_${timestamp}${ext}`; + const backupPath = getBackupPath(); + ensureBackupDir(backupPath); + const targetPath = path.join(backupPath, newFilename); + + fs.renameSync(tempPath, targetPath); + + res.json({ + success: true, + message: '备份文件上传成功', + data: { + filename: newFilename, + validation, + }, + }); + } catch (error) { + console.error('上传备份文件失败:', error); + res.status(500).json({ + success: false, + message: '上传备份文件失败', + error: error.message, + }); + } +}); + +router.get('/download/:filename', (req, res) => { + try { + const { filename } = req.params; + const backupPath = getBackupPath(); + const filePath = path.join(backupPath, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + message: '备份文件不存在', + }); + } + + res.download(filePath, filename, (err) => { + if (err) { + console.error('下载备份文件失败:', err); + } + }); + } catch (error) { + console.error('下载备份文件失败:', error); + res.status(500).json({ + success: false, + message: '下载备份文件失败', + error: error.message, + }); + } +}); + +router.delete('/:filename', (req, res) => { + try { + const { filename } = req.params; + const backupPath = getBackupPath(); + const filePath = path.join(backupPath, filename); + + if (!fs.existsSync(filePath)) { + return res.status(404).json({ + success: false, + message: '备份文件不存在', + }); + } + + fs.unlinkSync(filePath); + + res.json({ + success: true, + message: '备份文件已删除', + data: { filename }, + }); + } catch (error) { + console.error('删除备份文件失败:', error); + res.status(500).json({ + success: false, + message: '删除备份文件失败', + error: error.message, + }); + } +}); + +router.get('/info', (req, res) => { + try { + const backupPath = getBackupPath(); + let totalSize = 0; + let backupCount = 0; + + if (fs.existsSync(backupPath)) { + const files = fs.readdirSync(backupPath).filter(f => f.endsWith('.json') || f.endsWith('.json.gz')); + backupCount = files.length; + files.forEach(f => { + const stats = fs.statSync(path.join(backupPath, f)); + totalSize += stats.size; + }); + } + + res.json({ + success: true, + data: { + backupPath, + backupCount, + totalSize, + totalSizeFormatted: formatBytes(totalSize), + }, + }); + } catch (error) { + console.error('获取备份信息失败:', error); + res.status(500).json({ + success: false, + message: '获取备份信息失败', + error: error.message, + }); + } +}); + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +router.post('/clean', (req, res) => { + try { + const { maxCount = 30, maxAgeDays = 90, dryRun = false } = req.body; + + const result = cleanOldBackups({ maxCount, maxAgeDays, dryRun }); + + res.json({ + success: true, + message: dryRun ? '预览完成' : '清理完成', + data: result, + }); + } catch (error) { + console.error('清理备份失败:', error); + res.status(500).json({ + success: false, + message: '清理备份失败', + error: error.message, + }); + } +}); + +router.use((error, req, res, next) => { + console.error('路由错误:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误', + error: error.message, + }); +}); + +// ==================== 自动备份接口 ==================== + +// 获取自动备份状态 +router.get('/auto/status', (req, res) => { + try { + const status = getAutoBackupStatus(); + res.json({ + success: true, + data: status, + }); + } catch (error) { + console.error('获取自动备份状态失败:', error); + res.status(500).json({ + success: false, + message: '获取自动备份状态失败', + error: error.message, + }); + } +}); + +// 更新自动备份设置 +router.post('/auto/settings', (req, res) => { + try { + const { + enabled, + hour, + minute, + cronExpression, + description, + includeFiles, + compress, + maxCount, + maxAgeDays, + } = req.body; + + const newSettings = {}; + if (enabled !== undefined) newSettings.enabled = enabled; + if (hour !== undefined || minute !== undefined) { + newSettings.hour = hour || 2; + newSettings.minute = minute || 0; + } + if (cronExpression) { + if (!validateCronExpression(cronExpression)) { + return res.status(400).json({ + success: false, + message: '无效的 Cron 表达式', + }); + } + newSettings.cronExpression = cronExpression; + } + if (description) newSettings.description = description; + if (includeFiles !== undefined) newSettings.includeFiles = includeFiles; + if (compress !== undefined) newSettings.compress = compress; + if (maxCount !== undefined) newSettings.maxCount = maxCount; + if (maxAgeDays !== undefined) newSettings.maxAgeDays = maxAgeDays; + + const success = updateAutoBackupSettings(newSettings); + if (success) { + const status = getAutoBackupStatus(); + res.json({ + success: true, + message: '自动备份设置已更新', + data: status, + }); + } else { + res.status(500).json({ + success: false, + message: '保存设置失败', + }); + } + } catch (error) { + console.error('更新自动备份设置失败:', error); + res.status(500).json({ + success: false, + message: '更新自动备份设置失败', + error: error.message, + }); + } +}); + +// 立即执行备份 +router.post('/auto/execute', async (req, res) => { + try { + const { description, includeFiles, compress } = req.body; + + const result = await executeBackupNow({ + description: description || '手动触发备份', + includeFiles, + compress, + }); + + if (result.success) { + res.json({ + success: true, + message: '备份执行成功', + data: result.result, + }); + } else { + res.status(500).json({ + success: false, + message: result.error, + }); + } + } catch (error) { + console.error('立即执行备份失败:', error); + res.status(500).json({ + success: false, + message: '立即执行备份失败', + error: error.message, + }); + } +}); + +// 测试 Cron 表达式 +router.post('/auto/test-cron', (req, res) => { + try { + const { cronExpression } = req.body; + + if (!cronExpression) { + return res.status(400).json({ + success: false, + message: '请提供 Cron 表达式', + }); + } + + const isValid = validateCronExpression(cronExpression); + + res.json({ + success: isValid, + message: isValid ? 'Cron 表达式有效' : 'Cron 表达式无效', + data: { + valid: isValid, + cronExpression, + }, + }); + } catch (error) { + console.error('测试 Cron 表达式失败:', error); + res.status(500).json({ + success: false, + message: '测试 Cron 表达式失败', + error: error.message, + }); + } +}); + +module.exports = router; diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index 66ac6bb..54c7a18 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -41,9 +41,17 @@ router.get('/', async (req, res) => { order: [['createdAt', 'DESC']] }); + const consumables = rows.map(item => { + const data = item.toJSON(); + if (!Array.isArray(data.snList)) { + data.snList = []; + } + return data; + }); + res.json({ total: count, - consumables: rows, + consumables, page: parseInt(page), pageSize: parseInt(pageSize) }); @@ -182,12 +190,16 @@ router.get('/by-sn/:sn', async (req, res) => { const sn = req.params.sn; const consumables = await Consumable.findAll(); const consumable = consumables.find(c => { - const snList = c.snList || []; + const snList = Array.isArray(c.snList) ? c.snList : []; return snList.includes(sn); }); + const result = consumable ? consumable.toJSON() : null; + if (result && !Array.isArray(result.snList)) { + result.snList = []; + } res.json({ found: !!consumable, - consumable + consumable: result }); } catch (error) { res.status(500).json({ error: error.message }); @@ -831,7 +843,11 @@ router.get('/:id', async (req, res) => { if (!consumable) { return res.status(404).json({ error: '耗材不存在' }); } - res.json(consumable); + const data = consumable.toJSON(); + if (!Array.isArray(data.snList)) { + data.snList = []; + } + res.json(data); } catch (error) { res.status(500).json({ error: error.message }); } diff --git a/backend/routes/devices.js b/backend/routes/devices.js index aee7024..bc63963 100644 --- a/backend/routes/devices.js +++ b/backend/routes/devices.js @@ -15,6 +15,7 @@ const Ticket = require('../models/Ticket'); const DevicePort = require('../models/DevicePort'); const Cable = require('../models/Cable'); const NetworkCard = require('../models/NetworkCard'); +const InventoryRecord = require('../models/InventoryRecord'); const { validateBody, validateQuery } = require('../middleware/validation'); const { createDeviceSchema, @@ -1043,14 +1044,14 @@ router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => { }); // 批量删除设备 -router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => { +router.post('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, res) => { const t = await sequelize.transaction(); try { const { deviceIds } = req.body; if (!deviceIds || !Array.isArray(deviceIds) || deviceIds.length === 0) { await t.rollback(); - return res.status(400).json({ error: '请提供有效的设备ID列表' }); + return res.status(400).json({ error: '请提供有效的设备 ID 列表' }); } const devices = await Device.findAll({ @@ -1058,7 +1059,19 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r transaction: t }); - // 1. 删除相关接线 (Delete associated Cables) + // 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡) + await DevicePort.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 2. 删除相关网卡 + await NetworkCard.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 3. 删除相关接线 await Cable.destroy({ where: { [Op.or]: [ @@ -1068,18 +1081,6 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r }, transaction: t }); - - // 2. 删除相关网卡 (Delete associated NetworkCards) - await NetworkCard.destroy({ - where: { deviceId: { [Op.in]: deviceIds } }, - transaction: t - }); - - // 3. 删除相关端口 (Delete associated DevicePorts) - await DevicePort.destroy({ - where: { deviceId: { [Op.in]: deviceIds } }, - transaction: t - }); // 4. 解除工单关联 await Ticket.update( @@ -1087,13 +1088,13 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r { where: { deviceId: { [Op.in]: deviceIds } }, transaction: t } ); - // 5. 删除设备 - const deletedCount = await Device.destroy({ + // 5. 删除盘点记录 + await InventoryRecord.destroy({ where: { deviceId: { [Op.in]: deviceIds } }, transaction: t }); - // 更新机柜功率 + // 6. 更新机柜功率 for (const device of devices) { if (device.rackId) { const rack = await Rack.findByPk(device.rackId, { transaction: t }); @@ -1104,6 +1105,12 @@ router.delete('/batch-delete', validateBody(batchDeviceIdsSchema), async (req, r } } } + + // 7. 删除设备 + const deletedCount = await Device.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); await t.commit(); @@ -1132,7 +1139,19 @@ router.delete('/delete-all', async (req, res) => { const deviceIds = allDevices.map(d => d.deviceId); - // 1. 删除相关接线 + // 1. 删除相关端口 + await DevicePort.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 2. 删除相关网卡 + await NetworkCard.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 3. 删除相关接线 await Cable.destroy({ where: { [Op.or]: [ @@ -1143,25 +1162,19 @@ router.delete('/delete-all', async (req, res) => { transaction: t }); - // 2. 删除相关网卡 - await NetworkCard.destroy({ - where: { deviceId: { [Op.in]: deviceIds } }, - transaction: t - }); - - // 3. 删除相关端口 - await DevicePort.destroy({ - where: { deviceId: { [Op.in]: deviceIds } }, - transaction: t - }); - // 4. 解除工单关联 await Ticket.update( { deviceId: null }, { where: { deviceId: { [Op.in]: deviceIds } }, transaction: t } ); - // 5. 更新机柜功率 + // 5. 删除盘点记录 + await InventoryRecord.destroy({ + where: { deviceId: { [Op.in]: deviceIds } }, + transaction: t + }); + + // 6. 更新机柜功率 for (const device of allDevices) { if (device.rackId) { const rack = await Rack.findByPk(device.rackId, { transaction: t }); @@ -1173,7 +1186,7 @@ router.delete('/delete-all', async (req, res) => { } } - // 6. 删除所有设备 + // 7. 删除所有设备 const deletedCount = await Device.destroy({ where: {}, transaction: t @@ -1205,7 +1218,19 @@ router.delete('/:deviceId', async (req, res) => { return res.status(404).json({ error: '设备不存在' }); } - // 1. 删除相关接线 (Delete associated Cables) + // 1. 删除相关端口 (必须在网卡之前删除,因为端口依赖网卡) + const deletedPorts = await DevicePort.destroy({ + where: { deviceId: deviceId }, + transaction: t + }); + + // 2. 删除相关网卡 + const deletedNetworkCards = await NetworkCard.destroy({ + where: { deviceId: deviceId }, + transaction: t + }); + + // 3. 删除相关接线 // 必须在删除设备之前删除,否则可能触发外键约束错误 const deletedCables = await Cable.destroy({ where: { @@ -1216,19 +1241,6 @@ router.delete('/:deviceId', async (req, res) => { }, transaction: t }); - - // 2. 删除相关网卡 (Delete associated NetworkCards) - const deletedNetworkCards = await NetworkCard.destroy({ - where: { deviceId: deviceId }, - transaction: t - }); - - // 3. 删除相关端口 (Delete associated DevicePorts) - // 必须在删除设备之前删除,否则触发外键约束错误 - const deletedPorts = await DevicePort.destroy({ - where: { deviceId: deviceId }, - transaction: t - }); // 4. 解除工单关联 (Unlink Tickets) await Ticket.update( @@ -1236,7 +1248,13 @@ router.delete('/:deviceId', async (req, res) => { { where: { deviceId: deviceId }, transaction: t } ); - // 5. 更新机柜功率 (必须在删除设备之前) + // 5. 删除盘点记录 + await InventoryRecord.destroy({ + where: { deviceId: deviceId }, + transaction: t + }); + + // 6. 更新机柜功率 (必须在删除设备之前) if (device.rackId) { try { const rack = await Rack.findByPk(device.rackId, { transaction: t }); @@ -1251,7 +1269,7 @@ router.delete('/:deviceId', async (req, res) => { } } - // 6. 删除设备 (Delete Device) + // 7. 删除设备 (Delete Device) await Device.destroy({ where: { deviceId: deviceId }, transaction: t diff --git a/backend/scripts/backup.js b/backend/scripts/backup.js new file mode 100644 index 0000000..7398525 --- /dev/null +++ b/backend/scripts/backup.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +/** + * 命令行备份脚本 + * 用于独立执行数据备份,支持环境迁移 + * + * 使用方法: + * node scripts/backup.js [options] + * + * 选项: + * --output, -o 指定备份文件输出路径 + * --description 备份描述 + * --no-files 不包含上传文件 + * --help, -h 显示帮助信息 + */ + +const path = require('path'); +const fs = require('fs'); + +const args = process.argv.slice(2); +const options = { + output: null, + description: '', + includeFiles: true, +}; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--output': + case '-o': + options.output = args[++i]; + break; + case '--description': + options.description = args[++i]; + break; + case '--no-files': + options.includeFiles = false; + break; + case '--help': + case '-h': + console.log(` +数据备份脚本 + +使用方法: + node scripts/backup.js [options] + +选项: + --output, -o 指定备份文件输出路径 + --description 备份描述 + --no-files 不包含上传文件 + --help, -h 显示帮助信息 + +示例: + node scripts/backup.js + node scripts/backup.js -o /path/to/backup.json + node scripts/backup.js --description "迁移前备份" --no-files + `); + process.exit(0); + } +} + +async function runBackup() { + console.log('========================================'); + console.log(' IDC设备管理系统 - 数据备份工具'); + console.log('========================================\n'); + + try { + process.chdir(path.join(__dirname, '..')); + + const { createBackup, getBackupPath } = require('../utils/backup'); + const { sequelize } = require('../db'); + + console.log('连接数据库...'); + await sequelize.authenticate(); + console.log('数据库连接成功\n'); + + const backupPath = options.output ? path.dirname(options.output) : getBackupPath(); + + console.log('备份配置:'); + console.log(` 输出路径: ${backupPath}`); + console.log(` 包含文件: ${options.includeFiles ? '是' : '否'}`); + if (options.description) { + console.log(` 备份描述: ${options.description}`); + } + console.log(''); + + const result = await createBackup({ + description: options.description, + includeFiles: options.includeFiles, + backupPath: options.output ? path.dirname(options.output) : null, + }); + + if (options.output) { + const targetPath = options.output; + const sourcePath = result.path; + if (sourcePath !== targetPath) { + fs.copyFileSync(sourcePath, targetPath); + result.path = targetPath; + result.filename = path.basename(targetPath); + } + } + + console.log('\n========================================'); + console.log(' 备份完成!'); + console.log('========================================'); + console.log(`\n备份文件: ${result.filename}`); + console.log(`完整路径: ${result.path}`); + console.log(`文件大小: ${(result.size / 1024).toFixed(2)} KB`); + console.log(`数据记录: ${result.recordCount} 条`); + console.log(`文件数量: ${result.fileCount} 个`); + console.log(`创建时间: ${result.createdAt}`); + + await sequelize.close(); + process.exit(0); + } catch (error) { + console.error('\n备份失败:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runBackup(); diff --git a/backend/scripts/restore.js b/backend/scripts/restore.js new file mode 100644 index 0000000..2b1b78e --- /dev/null +++ b/backend/scripts/restore.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +/** + * 命令行恢复脚本 + * 用于独立执行数据恢复,支持跨环境迁移 + * + * 使用方法: + * node scripts/restore.js [options] + * + * 选项: + * --skip-users 跳过用户数据恢复 + * --skip-files 跳过文件恢复 + * --no-overwrite 不覆盖现有数据(追加模式) + * --help, -h 显示帮助信息 + */ + +const path = require('path'); +const fs = require('fs'); + +const args = process.argv.slice(2); +const options = { + file: null, + skipUsers: false, + skipFiles: false, + overwrite: true, +}; + +for (let i = 0; i < args.length; i++) { + const arg = args[i]; + switch (arg) { + case '--skip-users': + options.skipUsers = true; + break; + case '--skip-files': + options.skipFiles = true; + break; + case '--no-overwrite': + options.overwrite = false; + break; + case '--help': + case '-h': + console.log(` +数据恢复脚本 + +使用方法: + node scripts/restore.js [options] + +选项: + --skip-users 跳过用户数据恢复 + --skip-files 跳过文件恢复 + --no-overwrite 不覆盖现有数据(追加模式) + --help, -h 显示帮助信息 + +示例: + node scripts/restore.js backup_2024-01-15.json + node scripts/restore.js ./backups/backup_xxx.json --skip-users + node scripts/restore.js /path/to/backup.json --no-overwrite + `); + process.exit(0); + default: + if (!arg.startsWith('-') && !options.file) { + options.file = arg; + } + } +} + +if (!options.file) { + console.error('错误: 请指定备份文件路径'); + console.error('使用 --help 查看帮助信息'); + process.exit(1); +} + +async function runRestore() { + console.log('========================================'); + console.log(' IDC设备管理系统 - 数据恢复工具'); + console.log('========================================\n'); + + const backupFile = path.resolve(options.file); + + if (!fs.existsSync(backupFile)) { + console.error(`错误: 备份文件不存在: ${backupFile}`); + process.exit(1); + } + + try { + process.chdir(path.join(__dirname, '..')); + + const { restoreBackup, validateBackupFile } = require('../utils/backup'); + const { sequelize } = require('../db'); + + console.log('连接数据库...'); + await sequelize.authenticate(); + console.log('数据库连接成功\n'); + + console.log('验证备份文件...'); + const validation = await validateBackupFile(backupFile); + + if (!validation.valid) { + console.error(`错误: 备份文件验证失败: ${validation.error}`); + process.exit(1); + } + + console.log('\n备份文件信息:'); + console.log(` 版本: ${validation.version}`); + console.log(` 类型: ${validation.backupType || 'full'}`); + console.log(` 时间: ${validation.timestamp}`); + console.log(` 表数: ${validation.metadata.tableCount}`); + console.log(` 记录数: ${validation.metadata.totalRecords}`); + console.log(` 文件数: ${validation.metadata.fileCount || 0}`); + + if (validation.description) { + console.log(` 描述: ${validation.description}`); + } + + console.log('\n恢复配置:'); + console.log(` 覆盖模式: ${options.overwrite ? '覆盖现有数据' : '追加模式'}`); + console.log(` 恢复用户: ${options.skipUsers ? '否' : '是'}`); + console.log(` 恢复文件: ${options.skipFiles ? '否' : '是'}`); + console.log(''); + + const skipTables = []; + if (options.skipUsers) { + skipTables.push('User', 'UserRole', 'Permission'); + } + + console.log('开始恢复数据...\n'); + + const result = await restoreBackup(backupFile, { + overwriteExisting: options.overwrite, + skipTables, + skipFiles: options.skipFiles, + onProgress: (tableName, status, count) => { + const statusMap = { + 'restored': '✓ 已恢复', + 'skipped': '○ 已跳过', + 'empty': '- 无数据', + 'error': '✗ 错误', + }; + const statusText = statusMap[status] || status; + const countText = count ? ` (${count} 条)` : ''; + console.log(` ${tableName.padEnd(25)} ${statusText}${countText}`); + }, + }); + + console.log('\n========================================'); + console.log(' 恢复完成!'); + console.log('========================================'); + console.log(`\n恢复统计:`); + console.log(` 恢复表数: ${result.tablesRestored} 个`); + console.log(` 恢复记录: ${result.recordsRestored} 条`); + console.log(` 恢复文件: ${result.filesRestored} 个`); + console.log(` 完成时间: ${result.restoredAt}`); + + if (result.errors && result.errors.length > 0) { + console.log(`\n警告: 有 ${result.errors.length} 个错误`); + result.errors.slice(0, 10).forEach(err => { + console.log(` - ${err.table}: ${err.error}`); + }); + if (result.errors.length > 10) { + console.log(` ... 还有 ${result.errors.length - 10} 个错误`); + } + } + + console.log('\n提示: 请重启后端服务以确保所有数据生效'); + + await sequelize.close(); + process.exit(0); + } catch (error) { + console.error('\n恢复失败:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +runRestore(); diff --git a/backend/server.js b/backend/server.js index 892ff39..7c4c62a 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,4 +1,7 @@ require('dotenv').config(); +const { ensureJwtSecret } = require('./initConfig'); +ensureJwtSecret(); + const express = require('express'); const cors = require('cors'); const fileUpload = require('express-fileupload'); @@ -117,6 +120,14 @@ async function initFaultCategories() { console.log('故障分类初始化完成'); } +async function initAutoBackupScheduler() { + const { initAutoBackup } = require('./utils/autoBackupScheduler'); + const status = initAutoBackup(); + if (status.enabled) { + console.log(`自动备份已启用,下次执行时间:${status.nextRun || '未知'}`); + } +} + async function initializeApp() { try { await syncDatabase(); @@ -128,6 +139,7 @@ async function initializeApp() { await syncInventoryModels(); await initDefaultSystemSettings(); await initFaultCategories(); + await initAutoBackupScheduler(); console.log('所有初始化完成,服务器准备就绪'); } catch (error) { @@ -157,6 +169,7 @@ const cableRoutes = require('./routes/cables'); const devicePortRoutes = require('./routes/devicePorts'); const networkCardRoutes = require('./routes/networkCards'); const inventoryRoutes = require('./routes/inventory'); +const backupRoutes = require('./routes/backup'); app.use('/api/devices', deviceRoutes); app.use('/api/racks', rackRoutes); @@ -177,6 +190,7 @@ app.use('/api/cables', cableRoutes); app.use('/api/device-ports', devicePortRoutes); app.use('/api/network-cards', networkCardRoutes); app.use('/api/inventory', inventoryRoutes); +app.use('/api/backup', backupRoutes); app.use('/uploads', express.static('uploads')); diff --git a/backend/utils/autoBackupScheduler.js b/backend/utils/autoBackupScheduler.js new file mode 100644 index 0000000..0b54a0b --- /dev/null +++ b/backend/utils/autoBackupScheduler.js @@ -0,0 +1,290 @@ +/** + * 自动备份调度器模块 + * 使用 node-cron 实现定时自动备份功能 + */ + +const cron = require('node-cron'); +const path = require('path'); +const fs = require('fs'); +const { createBackup, createIncrementalBackup, getBackupPath } = require('./backup'); + +// 全局调度器存储 +const schedulers = new Map(); + +// 备份设置文件路径 +const SETTINGS_FILE = path.join(__dirname, '..', 'config', 'auto-backup-settings.json'); + +// 默认设置 +const DEFAULT_SETTINGS = { + enabled: false, + cronExpression: '0 2 * * *', // 每天凌晨 2 点 + description: '自动备份', + backupType: 'full', // 'full' 或 'incremental' + includeFiles: true, + compress: true, + maxCount: 30, + maxAgeDays: 90, +}; + +/** + * 加载备份设置 + */ +function loadSettings() { + try { + if (fs.existsSync(SETTINGS_FILE)) { + const content = fs.readFileSync(SETTINGS_FILE, 'utf8'); + return { ...DEFAULT_SETTINGS, ...JSON.parse(content) }; + } + } catch (error) { + console.error('加载自动备份设置失败:', error); + } + return { ...DEFAULT_SETTINGS }; +} + +/** + * 保存备份设置 + */ +function saveSettings(settings) { + try { + const configDir = path.dirname(SETTINGS_FILE); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2), 'utf8'); + return true; + } catch (error) { + console.error('保存自动备份设置失败:', error); + return false; + } +} + +/** + * 验证 Cron 表达式 + */ +function validateCronExpression(expression) { + return cron.validate(expression); +} + +/** + * 将中文时间转换为 Cron 表达式 + */ +function timeToCron(hour, minute) { + return `${minute} ${hour} * * *`; +} + +/** + * 创建自动备份任务 + */ +function createAutoBackupTask(settings) { + const { cronExpression, description, backupType, includeFiles, compress, maxCount, maxAgeDays } = settings; + + if (!validateCronExpression(cronExpression)) { + throw new Error('无效的 Cron 表达式'); + } + + // 如果已有调度器,先停止 + if (schedulers.has('auto-backup')) { + stopAutoBackup(); + } + + // 创建新的调度器 + const task = cron.schedule(cronExpression, async () => { + console.log('=== 开始执行自动备份 ==='); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); + + // 根据备份类型选择函数 + const backupFunction = backupType === 'incremental' ? createIncrementalBackup : createBackup; + + const result = await backupFunction({ + description: `${description} - ${timestamp}`, + includeFiles, + compress, + autoClean: true, + maxCount, + maxAgeDays, + }); + + if (result) { + console.log('自动备份完成:', result.filename); + console.log(`备份类型:${result.isIncremental ? '增量备份' : '全量备份'}`); + console.log('========================\n'); + } else { + console.log('无数据变化,跳过备份'); + } + } catch (error) { + console.error('自动备份失败:', error); + console.error('========================\n'); + } + }, { + scheduled: true, + timezone: 'Asia/Shanghai', // 设置时区为中国时区 + }); + + schedulers.set('auto-backup', task); + console.log(`自动备份任务已启动,Cron 表达式:${cronExpression}, 备份类型:${backupType}`); + + return task; +} + +/** + * 启动自动备份 + */ +function startAutoBackup(settings = null) { + if (!settings) { + settings = loadSettings(); + } + + if (!settings.enabled) { + console.log('自动备份已禁用'); + return false; + } + + try { + createAutoBackupTask(settings); + return true; + } catch (error) { + console.error('启动自动备份失败:', error.message); + return false; + } +} + +/** + * 停止自动备份 + */ +function stopAutoBackup() { + if (schedulers.has('auto-backup')) { + const task = schedulers.get('auto-backup'); + task.stop(); + schedulers.delete('auto-backup'); + console.log('自动备份任务已停止'); + return true; + } + return false; +} + +/** + * 获取自动备份状态 + */ +function getAutoBackupStatus() { + const settings = loadSettings(); + const isActive = schedulers.has('auto-backup'); + + // 计算下次执行时间 + let nextRun = null; + if (isActive && settings.enabled) { + // 简单计算下次执行时间(基于当前时间和 Cron 表达式) + const now = new Date(); + const [minute, hour] = settings.cronExpression.split(' ').slice(0, 2); + + const next = new Date(now); + next.setHours(parseInt(hour), parseInt(minute), 0, 0); + + if (next <= now) { + next.setDate(next.getDate() + 1); + } + + nextRun = next.toISOString(); + } + + return { + enabled: settings.enabled, + isActive, + cronExpression: settings.cronExpression, + description: settings.description, + backupType: settings.backupType || 'full', + includeFiles: settings.includeFiles, + compress: settings.compress, + maxCount: settings.maxCount, + maxAgeDays: settings.maxAgeDays, + nextRun, + }; +} + +/** + * 更新自动备份设置 + */ +function updateAutoBackupSettings(newSettings) { + const currentSettings = loadSettings(); + const updatedSettings = { ...currentSettings, ...newSettings }; + + // 如果提供了小时和分钟,转换为 Cron 表达式 + if (newSettings.hour !== undefined && newSettings.minute !== undefined) { + updatedSettings.cronExpression = timeToCron(newSettings.hour, newSettings.minute); + delete updatedSettings.hour; + delete updatedSettings.minute; + } + + // 验证 Cron 表达式 + if (!validateCronExpression(updatedSettings.cronExpression)) { + throw new Error('无效的 Cron 表达式'); + } + + // 保存设置 + if (saveSettings(updatedSettings)) { + // 如果启用了自动备份,重新启动调度器 + if (updatedSettings.enabled) { + startAutoBackup(updatedSettings); + } else { + stopAutoBackup(); + } + return true; + } + + return false; +} + +/** + * 立即执行一次备份 + */ +async function executeBackupNow(options = {}) { + console.log('=== 手动触发备份 ==='); + try { + const settings = loadSettings(); + const result = await createBackup({ + description: options.description || '手动备份', + includeFiles: options.includeFiles !== undefined ? options.includeFiles : settings.includeFiles, + compress: options.compress !== undefined ? options.compress : settings.compress, + autoClean: true, + maxCount: settings.maxCount, + maxAgeDays: settings.maxAgeDays, + }); + + console.log('手动备份完成:', result.filename); + console.log('====================\n'); + return { success: true, result }; + } catch (error) { + console.error('手动备份失败:', error); + console.error('====================\n'); + return { success: false, error: error.message }; + } +} + +/** + * 初始化自动备份(服务器启动时调用) + */ +function initAutoBackup() { + console.log('初始化自动备份...'); + const settings = loadSettings(); + + if (settings.enabled) { + startAutoBackup(settings); + } else { + console.log('自动备份当前为禁用状态'); + } + + return getAutoBackupStatus(); +} + +module.exports = { + loadSettings, + saveSettings, + validateCronExpression, + timeToCron, + startAutoBackup, + stopAutoBackup, + getAutoBackupStatus, + updateAutoBackupSettings, + executeBackupNow, + initAutoBackup, +}; diff --git a/backend/utils/backup.js b/backend/utils/backup.js new file mode 100644 index 0000000..ff062fe --- /dev/null +++ b/backend/utils/backup.js @@ -0,0 +1,935 @@ +/** + * 数据备份与恢复工具模块 + * 支持完整备份所有数据库表和上传文件 + */ + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const zlib = require('zlib'); +const { pipeline } = require('stream/promises'); + +const BACKUP_VERSION = '2.0.0'; + +// 数据表名称中英文映射 +const TABLE_NAME_MAPPING = { + 'User': '用户', + 'Role': '角色', + 'UserRole': '用户角色关联', + 'Permission': '权限', + 'Room': '机房', + 'Rack': '机柜', + 'Device': '设备', + 'DeviceField': '设备自定义字段', + 'DevicePort': '设备端口', + 'NetworkCard': '网卡', + 'Cable': '线缆', + 'PendingDevice': '待入库设备', + 'FaultCategory': '故障分类', + 'Ticket': '工单', + 'TicketField': '工单自定义字段', + 'TicketOperationRecord': '工单操作记录', + 'ConsumableCategory': '耗材分类', + 'Consumable': '耗材', + 'ConsumableRecord': '耗材记录', + 'ConsumableLog': '耗材操作日志', + 'ConsumableLogArchive': '耗材操作日志归档', + 'InventoryPlan': '盘点计划', + 'InventoryTask': '盘点任务', + 'InventoryRecord': '盘点记录', + 'SystemSetting': '系统设置', +}; + +// 增量备份配置 +const INCREMENTAL_BACKUP_CONFIG = { + // 哪些表支持增量备份(需要有 updatedAt 或 createdAt 字段) + supportedTables: [ + 'Device', + 'Ticket', + 'TicketOperationRecord', + 'Consumable', + 'ConsumableRecord', + 'ConsumableLog', + 'InventoryTask', + 'InventoryRecord', + ], + // 必须全量备份的表 + fullBackupTables: [ + 'User', + 'Role', + 'UserRole', + 'Permission', + 'Room', + 'Rack', + 'DeviceField', + 'DevicePort', + 'NetworkCard', + 'Cable', + 'PendingDevice', + 'FaultCategory', + 'TicketField', + 'ConsumableCategory', + 'ConsumableLogArchive', + 'InventoryPlan', + 'SystemSetting', + ], +}; + +const BACKUP_MODELS_CONFIG = [ + { name: 'User', modelPath: '../models/User' }, + { name: 'Role', modelPath: '../models/Role' }, + { name: 'UserRole', modelPath: '../models/UserRole' }, + { name: 'Permission', modelPath: '../models/Permission' }, + { name: 'Room', modelPath: '../models/Room' }, + { name: 'Rack', modelPath: '../models/Rack' }, + { name: 'Device', modelPath: '../models/Device' }, + { name: 'DeviceField', modelPath: '../models/DeviceField' }, + { name: 'DevicePort', modelPath: '../models/DevicePort' }, + { name: 'NetworkCard', modelPath: '../models/NetworkCard' }, + { name: 'Cable', modelPath: '../models/Cable' }, + { name: 'PendingDevice', modelPath: '../models/PendingDevice' }, + { name: 'FaultCategory', modelPath: '../models/FaultCategory' }, + { name: 'Ticket', modelPath: '../models/Ticket' }, + { name: 'TicketField', modelPath: '../models/TicketField' }, + { name: 'TicketOperationRecord', modelPath: '../models/TicketOperationRecord' }, + { name: 'ConsumableCategory', modelPath: '../models/ConsumableCategory' }, + { name: 'Consumable', modelPath: '../models/Consumable' }, + { name: 'ConsumableRecord', modelPath: '../models/ConsumableRecord' }, + { name: 'ConsumableLog', modelPath: '../models/ConsumableLog' }, + { name: 'ConsumableLogArchive', modelPath: '../models/ConsumableLogArchive' }, + { name: 'InventoryPlan', modelPath: '../models/InventoryPlan' }, + { name: 'InventoryTask', modelPath: '../models/InventoryTask' }, + { name: 'InventoryRecord', modelPath: '../models/InventoryRecord' }, + { name: 'SystemSetting', modelPath: '../models/SystemSetting' }, +]; + +const RESTORE_ORDER = [ + 'SystemSetting', + 'Role', + 'User', + 'UserRole', + 'Permission', + 'Room', + 'Rack', + 'DeviceField', + 'Device', + 'DevicePort', + 'NetworkCard', + 'Cable', + 'PendingDevice', + 'FaultCategory', + 'TicketField', + 'Ticket', + 'TicketOperationRecord', + 'ConsumableCategory', + 'Consumable', + 'ConsumableRecord', + 'ConsumableLog', + 'ConsumableLogArchive', + 'InventoryPlan', + 'InventoryTask', + 'InventoryRecord', +]; + +function getBackupPath() { + const backupDir = path.join(__dirname, '..', 'backups'); + if (!fs.existsSync(backupDir)) { + fs.mkdirSync(backupDir, { recursive: true }); + } + return backupDir; +} + +/** + * 获取上次备份的时间 + */ +function getLastBackupTime() { + const backupDir = getBackupPath(); + const files = fs.readdirSync(backupDir) + .filter(f => (f.startsWith('backup_') || f.startsWith('incremental_')) && (f.endsWith('.json') || f.endsWith('.json.gz'))) + .map(f => { + const filePath = path.join(backupDir, f); + return { + filename: f, + createdAt: fs.statSync(filePath).birthtime, + }; + }) + .sort((a, b) => b.createdAt - a.createdAt); + + if (files.length > 0) { + return files[0].createdAt; + } + return null; +} + +/** + * 收集增量数据(只收集自上次备份以来变化的数据) + */ +async function collectIncrementalData(lastBackupTime) { + const incrementalData = {}; + let totalNewRecords = 0; + let totalUpdatedRecords = 0; + + for (const config of BACKUP_MODELS_CONFIG) { + if (!INCREMENTAL_BACKUP_CONFIG.supportedTables.includes(config.name)) { + continue; + } + + try { + const Model = require(config.modelPath); + + // 查询自上次备份以来新增或更新的记录 + const newRecords = await Model.findAll({ + where: { + createdAt: { + [require('sequelize').Op.gt]: lastBackupTime, + }, + }, + }); + + const updatedRecords = await Model.findAll({ + where: { + updatedAt: { + [require('sequelize').Op.gt]: lastBackupTime, + }, + createdAt: { + [require('sequelize').Op.lte]: lastBackupTime, + }, + }, + }); + + if (newRecords.length > 0 || updatedRecords.length > 0) { + incrementalData[config.name] = { + new: newRecords.map(r => r.toJSON()), + updated: updatedRecords.map(r => r.toJSON()), + deleted: [], // 删除的记录需要特殊处理,暂时不支持 + }; + totalNewRecords += newRecords.length; + totalUpdatedRecords += updatedRecords.length; + } + } catch (error) { + console.warn(`收集表 ${config.name} 的增量数据失败:`, error.message); + } + } + + return { + data: incrementalData, + totalNewRecords, + totalUpdatedRecords, + totalChangedRecords: totalNewRecords + totalUpdatedRecords, + }; +} + +function ensureBackupDir(backupPath) { + if (!fs.existsSync(backupPath)) { + fs.mkdirSync(backupPath, { recursive: true }); + } +} + +async function collectAllData() { + const data = {}; + let totalRecords = 0; + + for (const config of BACKUP_MODELS_CONFIG) { + try { + const Model = require(config.modelPath); + const records = await Model.findAll({ raw: true }); + data[config.name] = records; + totalRecords += records.length; + console.log(`收集 ${config.name}: ${records.length} 条记录`); + } catch (error) { + console.warn(`收集 ${config.name} 失败:`, error.message); + data[config.name] = []; + } + } + + return { data, totalRecords }; +} + +async function collectFiles(uploadsDir) { + const files = { + avatars: [], + others: [], + }; + + const avatarsDir = path.join(uploadsDir, 'avatars'); + if (fs.existsSync(avatarsDir)) { + const avatarFiles = fs.readdirSync(avatarsDir); + for (const filename of avatarFiles) { + const filePath = path.join(avatarsDir, filename); + const stat = fs.statSync(filePath); + if (stat.isFile()) { + const content = fs.readFileSync(filePath); + files.avatars.push({ + filename, + path: 'uploads/avatars/', + content: content.toString('base64'), + size: stat.size, + }); + } + } + } + + if (fs.existsSync(uploadsDir)) { + const otherFiles = fs.readdirSync(uploadsDir).filter(f => { + const filePath = path.join(uploadsDir, f); + return fs.statSync(filePath).isFile(); + }); + + for (const filename of otherFiles) { + const filePath = path.join(uploadsDir, filename); + const content = fs.readFileSync(filePath); + files.others.push({ + filename, + path: 'uploads/', + content: content.toString('base64'), + size: content.length, + }); + } + } + + return files; +} + +function calculateChecksum(data) { + const content = JSON.stringify(data); + return crypto.createHash('sha256').update(content).digest('hex'); +} + +async function createBackup(options = {}) { + const { + description = '', + includeFiles = true, + backupPath = null, + compress = true, + autoClean = true, + maxCount = 30, + maxAgeDays = 90, + } = options; + + const finalBackupPath = backupPath || getBackupPath(); + ensureBackupDir(finalBackupPath); + + console.log('开始备份数据...'); + const { data, totalRecords } = await collectAllData(); + + let files = { avatars: [], others: [] }; + let fileCount = 0; + + if (includeFiles) { + console.log('备份上传文件...'); + const uploadsDir = path.join(__dirname, '..', 'uploads'); + files = await collectFiles(uploadsDir); + fileCount = files.avatars.length + files.others.length; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); + const ext = compress ? '.json.gz' : '.json'; + const filename = `backup_${timestamp}${ext}`; + + const backupData = { + version: BACKUP_VERSION, + backupType: 'full', + timestamp: new Date().toISOString(), + description, + compressed: compress, + systemInfo: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + dbType: process.env.DB_TYPE || 'sqlite', + }, + metadata: { + tableCount: Object.keys(data).length, + totalRecords, + fileCount, + }, + data, + files, + }; + + backupData.checksum = calculateChecksum(backupData); + + const filePath = path.join(finalBackupPath, filename); + const jsonContent = JSON.stringify(backupData); + + if (compress) { + const compressed = zlib.gzipSync(Buffer.from(jsonContent, 'utf8')); + fs.writeFileSync(filePath, compressed); + } else { + fs.writeFileSync(filePath, jsonContent, 'utf8'); + } + + const stat = fs.statSync(filePath); + + console.log(`备份完成: ${filename}`); + console.log(`文件大小: ${(stat.size / 1024).toFixed(2)} KB`); + if (compress) { + const originalSize = Buffer.byteLength(jsonContent, 'utf8'); + const ratio = ((1 - stat.size / originalSize) * 100).toFixed(1); + console.log(`压缩率: ${ratio}% (原始 ${(originalSize / 1024).toFixed(2)} KB)`); + } + console.log(`数据记录: ${totalRecords} 条`); + console.log(`文件数量: ${fileCount} 个`); + + let cleanResult = null; + if (autoClean) { + console.log('\n检查旧备份文件...'); + cleanResult = cleanOldBackups({ maxCount, maxAgeDays }); + if (cleanResult.deletedCount > 0) { + console.log(`已清理 ${cleanResult.deletedCount} 个旧备份,释放 ${cleanResult.freedSizeFormatted}`); + } else { + console.log('无需清理旧备份'); + } + } + + return { + filename, + path: filePath, + size: stat.size, + recordCount: totalRecords, + fileCount, + compressed: compress, + createdAt: backupData.timestamp, + cleaned: cleanResult, + }; +} + +/** + * 创建增量备份 + */ +async function createIncrementalBackup(options = {}) { + const { + description = '', + includeFiles = false, + backupPath = null, + compress = true, + autoClean = true, + maxCount = 30, + maxAgeDays = 90, + } = options; + + const finalBackupPath = backupPath || getBackupPath(); + ensureBackupDir(finalBackupPath); + + // 获取上次备份时间 + const lastBackupTime = getLastBackupTime(); + if (!lastBackupTime) { + console.log('未找到上次备份,执行全量备份'); + return await createBackup(options); + } + + console.log(`开始增量备份(上次备份时间:${lastBackupTime.toISOString()})...`); + + // 收集增量数据 + const { data: incrementalData, totalChangedRecords } = await collectIncrementalData(lastBackupTime); + + if (totalChangedRecords === 0) { + console.log('自上次备份以来没有数据变化,跳过备份'); + return null; + } + + // 收集配置数据(总是全量备份) + console.log('备份配置数据...'); + const { data: fullData } = await collectAllData(INCREMENTAL_BACKUP_CONFIG.fullBackupTables); + + let files = { avatars: [], others: [] }; + let fileCount = 0; + + if (includeFiles) { + console.log('备份上传文件...'); + const uploadsDir = path.join(__dirname, '..', 'uploads'); + files = await collectFiles(uploadsDir); + fileCount = files.avatars.length + files.others.length; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').substring(0, 19); + const ext = compress ? '.json.gz' : '.json'; + const filename = `incremental_${timestamp}${ext}`; + + const backupData = { + version: BACKUP_VERSION, + backupType: 'incremental', + timestamp: new Date().toISOString(), + description, + compressed: compress, + lastBackupTime: lastBackupTime.toISOString(), + systemInfo: { + nodeVersion: process.version, + platform: process.platform, + arch: process.arch, + dbType: process.env.DB_TYPE || 'sqlite', + }, + metadata: { + fullBackupTables: Object.keys(fullData).length, + incrementalTables: Object.keys(incrementalData).length, + totalChangedRecords, + fileCount, + }, + fullData, + incrementalData, + files, + }; + + backupData.checksum = calculateChecksum(backupData); + + const filePath = path.join(finalBackupPath, filename); + const jsonContent = JSON.stringify(backupData); + + if (compress) { + const compressed = zlib.gzipSync(Buffer.from(jsonContent, 'utf8')); + fs.writeFileSync(filePath, compressed); + } else { + fs.writeFileSync(filePath, jsonContent, 'utf8'); + } + + const stat = fs.statSync(filePath); + + console.log(`增量备份完成:${filename}`); + console.log(`文件大小:${(stat.size / 1024).toFixed(2)} KB`); + if (compress) { + const originalSize = Buffer.byteLength(jsonContent, 'utf8'); + const ratio = ((1 - stat.size / originalSize) * 100).toFixed(1); + console.log(`压缩率:${ratio}% (原始 ${(originalSize / 1024).toFixed(2)} KB)`); + } + console.log(`变化记录:${totalChangedRecords} 条`); + console.log(`文件数量:${fileCount} 个`); + + let cleanResult = null; + if (autoClean) { + console.log('\n检查旧备份文件...'); + cleanResult = cleanOldBackups({ maxCount, maxAgeDays }); + if (cleanResult.deletedCount > 0) { + console.log(`已清理 ${cleanResult.deletedCount} 个旧备份,释放 ${cleanResult.freedSizeFormatted}`); + } else { + console.log('无需清理旧备份'); + } + } + + return { + filename, + path: filePath, + size: stat.size, + recordCount: totalChangedRecords, + fileCount, + compressed: compress, + createdAt: backupData.timestamp, + cleaned: cleanResult, + isIncremental: true, + }; +} + +async function validateBackupFile(filePath, options = {}) { + const { isCompressed: forceCompressed } = options; + + if (!fs.existsSync(filePath)) { + return { valid: false, error: '备份文件不存在' }; + } + + let backupData; + try { + const buffer = fs.readFileSync(filePath); + const isCompressed = forceCompressed !== undefined ? forceCompressed : filePath.endsWith('.gz'); + + if (isCompressed) { + const decompressed = zlib.gunzipSync(buffer); + backupData = JSON.parse(decompressed.toString('utf8')); + } else { + backupData = JSON.parse(buffer.toString('utf8')); + } + } catch (error) { + console.error('备份文件解析失败:', error.message); + return { valid: false, error: `备份文件格式无效: ${error.message}` }; + } + + if (!backupData.version) { + return { valid: false, error: '备份文件缺少版本信息' }; + } + + if (!backupData.data) { + return { valid: false, error: '备份文件缺少数据内容' }; + } + + const savedChecksum = backupData.checksum; + if (savedChecksum) { + const dataToCheck = { ...backupData }; + delete dataToCheck.checksum; + const calculatedChecksum = calculateChecksum(dataToCheck); + if (calculatedChecksum !== savedChecksum) { + return { valid: false, error: '备份文件校验和不匹配,文件可能已损坏' }; + } + } + + // 详细的表信息统计 + const tableDetails = {}; + let totalRecords = 0; + for (const tableName of Object.keys(backupData.data)) { + if (Array.isArray(backupData.data[tableName])) { + const recordCount = backupData.data[tableName].length; + tableDetails[tableName] = { + recordCount, + hasData: recordCount > 0, + displayName: TABLE_NAME_MAPPING[tableName] || tableName, // 使用中文显示名称 + }; + totalRecords += recordCount; + } + } + + // 文件详情 + const fileDetails = { + avatars: backupData.files?.avatars?.length || 0, + others: backupData.files?.others?.length || 0, + total: (backupData.files?.avatars?.length || 0) + (backupData.files?.others?.length || 0), + avatarList: backupData.files?.avatars?.map(f => ({ + filename: f.filename, + size: f.size, + })) || [], + otherList: backupData.files?.others?.map(f => ({ + filename: f.filename, + size: f.size, + })) || [], + }; + + return { + valid: true, + version: backupData.version, + backupType: backupData.backupType, + timestamp: backupData.timestamp, + description: backupData.description, + compressed: backupData.compressed, + systemInfo: backupData.systemInfo, + metadata: { + tableCount: Object.keys(backupData.data).length, + totalRecords, + fileCount: fileDetails.total, + }, + // 详细信息 + details: { + tables: tableDetails, + files: fileDetails, + systemInfo: backupData.systemInfo, + }, + }; +} + +async function restoreData(backupData, options = {}) { + const { + overwriteExisting = true, + skipTables = [], + onProgress = () => {}, + } = options; + + const results = { + tablesRestored: 0, + recordsRestored: 0, + errors: [], + skipped: [], + tableDetails: {}, // 每个表的详细恢复信息 + }; + + for (const tableName of RESTORE_ORDER) { + if (skipTables.includes(tableName)) { + results.skipped.push(tableName); + onProgress(tableName, 'skipped'); + continue; + } + + const tableData = backupData.data[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 }); + } + + // 预处理记录:修复 JSON 字段格式 + const processedRecords = tableData.map(record => { + const processed = { ...record }; + + // 修复 Device 表的 customFields 字段 + 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; + }); + + 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: 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); + } + } + + return results; +} + +async function restoreFiles(files, uploadsDir) { + const results = { + filesRestored: 0, + errors: [], + }; + + if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); + } + + const avatarsDir = path.join(uploadsDir, 'avatars'); + if (!fs.existsSync(avatarsDir)) { + fs.mkdirSync(avatarsDir, { recursive: true }); + } + + if (files.avatars && Array.isArray(files.avatars)) { + for (const file of files.avatars) { + try { + const filePath = path.join(avatarsDir, file.filename); + const content = Buffer.from(file.content, 'base64'); + fs.writeFileSync(filePath, content); + results.filesRestored++; + } catch (error) { + results.errors.push({ file: file.filename, error: error.message }); + } + } + } + + if (files.others && Array.isArray(files.others)) { + for (const file of files.others) { + try { + const filePath = path.join(uploadsDir, file.filename); + const content = Buffer.from(file.content, 'base64'); + fs.writeFileSync(filePath, content); + results.filesRestored++; + } catch (error) { + results.errors.push({ file: file.filename, error: error.message }); + } + } + } + + return results; +} + +async function restoreBackup(filePath, options = {}) { + const { + overwriteExisting = true, + skipTables = [], + skipFiles = false, + onProgress = () => {}, + } = options; + + console.log('验证备份文件...'); + const validation = await validateBackupFile(filePath); + if (!validation.valid) { + throw new Error(`备份文件验证失败: ${validation.error}`); + } + + 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}`); + + 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}`); + }); + } + + 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, + } : null, + }; +} + +function cleanOldBackups(options = {}) { + const { + maxCount = 30, + maxAgeDays = 90, + dryRun = false, + } = options; + + const backupPath = getBackupPath(); + + if (!fs.existsSync(backupPath)) { + return { deleted: [], kept: [], totalSize: 0, freedSize: 0 }; + } + + const now = Date.now(); + const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000; + + const files = fs.readdirSync(backupPath) + .filter(f => (f.startsWith('backup_') || f.startsWith('uploaded_')) && (f.endsWith('.json') || f.endsWith('.json.gz'))) + .map(f => { + const filePath = path.join(backupPath, f); + const stats = fs.statSync(filePath); + return { + filename: f, + path: filePath, + size: stats.size, + createdAt: stats.birthtime, + createdMs: stats.birthtimeMs || stats.birthtime.getTime(), + age: now - (stats.birthtimeMs || stats.birthtime.getTime()), + }; + }) + .sort((a, b) => b.createdMs - a.createdMs); + + const toDelete = []; + const toKeep = []; + let freedSize = 0; + + files.forEach((file, index) => { + const tooOld = file.age > maxAgeMs; + const tooMany = index >= maxCount; + + if (tooOld || tooMany) { + toDelete.push(file); + freedSize += file.size; + } else { + toKeep.push(file); + } + }); + + if (!dryRun) { + toDelete.forEach(file => { + try { + fs.unlinkSync(file.path); + console.log(`已删除旧备份: ${file.filename}`); + } catch (err) { + console.warn(`删除备份失败: ${file.filename}`, err.message); + } + }); + } + + return { + deleted: toDelete.map(f => f.filename), + kept: toKeep.map(f => f.filename), + deletedCount: toDelete.length, + keptCount: toKeep.length, + totalSize: files.reduce((sum, f) => sum + f.size, 0), + freedSize, + freedSizeFormatted: formatBytes(freedSize), + }; +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +module.exports = { + BACKUP_VERSION, + BACKUP_MODELS_CONFIG, + RESTORE_ORDER, + INCREMENTAL_BACKUP_CONFIG, + getBackupPath, + ensureBackupDir, + createBackup, + createIncrementalBackup, + getLastBackupTime, + validateBackupFile, + restoreData, + restoreFiles, + restoreBackup, + calculateChecksum, + cleanOldBackups, +}; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 892f404..c648c3b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -53,6 +53,7 @@ import useIdleTimeout from './hooks/useIdleTimeout'; import { SWRConfig, swrConfig } from './hooks/useSWR'; import axios from 'axios'; import { Spin } from 'antd'; +import ErrorBoundary from './components/ErrorBoundary'; const Dashboard = lazy(() => import('./pages/Dashboard')); const DeviceManagement = lazy(() => import('./pages/DeviceManagement')); @@ -76,6 +77,9 @@ const PortManagement = lazy(() => import('./pages/PortManagement')); const InventoryManagement = lazy(() => import('./pages/InventoryManagement')); const InventoryTaskExecution = lazy(() => import('./pages/InventoryTaskExecution')); const PendingDeviceManagement = lazy(() => import('./pages/PendingDeviceManagement')); +const BackupManagement = lazy(() => import('./pages/BackupManagement')); +const AutoBackupSettings = lazy(() => import('./pages/AutoBackupSettings')); +const ErrorBoundaryTest = lazy(() => import('./pages/ErrorBoundaryTest')); const { Header, Content, Sider } = Layout; @@ -198,7 +202,8 @@ const AppLayout = ({ children }) => { path.startsWith('/users') || path.startsWith('/login-history') || path.startsWith('/operation-logs') || - path.startsWith('/settings') + path.startsWith('/settings') || + path.startsWith('/backup') ) return 'system-management'; if (path.startsWith('/tickets')) return 'ticket-management'; @@ -347,6 +352,11 @@ const AppLayout = ({ children }) => { icon: , label: 系统设置, }, + { + key: 'backup', + icon: , + label: 数据备份, + }, ], }, ]; @@ -583,43 +593,79 @@ const routeConfig = [ { path: '/inventory/execution', component: InventoryTaskExecution }, { path: '/pending-devices', component: PendingDeviceManagement }, { path: '/ports', component: PortManagement }, + { path: '/backup', component: BackupManagement }, + { path: '/auto-backup-settings', component: AutoBackupSettings }, + { path: '/error-boundary-test', component: ErrorBoundaryTest }, ]; const ThemeConfig = () => { const designTokens = useDesignTokens(); + const renderRoute = (path, Component, needsErrorBoundary = true) => { + const element = ( + + + + ); + + if (needsErrorBoundary) { + return ( + + {element} + + } + /> + ); + } + + return ; + }; + return ( }> - } /> - {routeConfig.map(({ path, component: Component }) => ( + + + } + /> + {routeConfig.map(({ path, component: Component }) => + renderRoute(path, Component) + )} + - + + + + + } /> - ))} - - - - - - } - /> - } /> - - - + } /> + + + ); diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d14549c..19e186c 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -153,4 +153,21 @@ export const ticketCategoryAPI = { init: () => api.post('/ticket-categories/init'), }; +export const backupAPI = { + list: () => api.get('/backup/list'), + create: (data = {}) => api.post('/backup', data), + validate: filename => api.get(`/backup/validate/${filename}`), + restore: (filename, options = {}) => api.post('/backup/restore', { filename, options }), + download: filename => `/api/backup/download/${filename}`, + delete: filename => api.delete(`/backup/${filename}`), + upload: file => { + const formData = new FormData(); + formData.append('backup', file); + return api.post('/backup/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + info: () => api.get('/backup/info'), +}; + export default api; diff --git a/frontend/src/assets/3d/env.hdr b/frontend/src/assets/3d/env.hdr deleted file mode 100644 index e7438f8..0000000 Binary files a/frontend/src/assets/3d/env.hdr and /dev/null differ diff --git a/frontend/src/assets/fonts/Inter-Regular.woff2 b/frontend/src/assets/fonts/Inter-Regular.woff2 deleted file mode 100644 index 2bcd222..0000000 Binary files a/frontend/src/assets/fonts/Inter-Regular.woff2 and /dev/null differ diff --git a/frontend/src/components/3d/Scene.jsx b/frontend/src/components/3d/Scene.jsx index f3d0bd4..4b4d5a8 100644 --- a/frontend/src/components/3d/Scene.jsx +++ b/frontend/src/components/3d/Scene.jsx @@ -12,6 +12,7 @@ const envMapUrl = '/assets/3d/env.hdr'; import RackModel from './RackModel'; import { useScene3D } from '../../context/Scene3DContext'; import * as THREE from 'three'; +import ErrorBoundary from '../ErrorBoundary'; // 检测是否为移动设备 const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); @@ -153,50 +154,56 @@ const Scene = forwardRef( }, [rackHeightMeters]); return ( - - - - - - - - - - - - {/* Models */} - - - - - {/* Controls - 使用独立组件保持旋转中心固定 */} - { - controlsApiRef.current = api; + - + style={{ background: 'transparent' }} + > + + + + + + + + + + + {/* Models */} + + + + + {/* Controls - 使用独立组件保持旋转中心固定 */} + { + controlsApiRef.current = api; + }} + /> + + ); } ); diff --git a/frontend/src/components/ErrorBoundary.jsx b/frontend/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..bffca17 --- /dev/null +++ b/frontend/src/components/ErrorBoundary.jsx @@ -0,0 +1,163 @@ +import React, { Component } from 'react'; +import { Button, Result, Space, Collapse, Typography } from 'antd'; +import { + WarningOutlined, + ReloadOutlined, + HomeOutlined, + BugOutlined, +} from '@ant-design/icons'; + +const { Text, Paragraph } = Typography; +const { Panel } = Collapse; + +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error) { + return { hasError: true }; + } + + componentDidCatch(error, errorInfo) { + this.setState({ + error, + errorInfo, + }); + + console.error('错误边界捕获到错误:', error, errorInfo); + + if (this.props.onError) { + this.props.onError(error, errorInfo); + } + } + + handleReload = () => { + window.location.reload(); + }; + + handleGoHome = () => { + window.location.href = '/'; + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ + } + extra={ + + + + + } + > + {this.state.error && ( +
+ + + 查看错误详情(开发环境) + + ), + children: ( + <> + + {this.state.error.toString()} + + {this.state.errorInfo?.componentStack && ( + + {this.state.errorInfo.componentStack} + + )} + + ), + }, + ]} + /> +
+ )} +
+
+ ); + } + + return this.props.children; + } +} + +ErrorBoundary.defaultProps = { + fallback: null, + fullPage: false, + title: null, + subTitle: null, + onError: null, +}; + +export default ErrorBoundary; diff --git a/frontend/src/components/device/DeviceFormModal.jsx b/frontend/src/components/device/DeviceFormModal.jsx index 7185374..f420346 100644 --- a/frontend/src/components/device/DeviceFormModal.jsx +++ b/frontend/src/components/device/DeviceFormModal.jsx @@ -103,7 +103,7 @@ const DeviceFormModal = ({ style={inputStyle} className="form-input-enhanced" > - {field.options && + {Array.isArray(field.options) && field.options.map((option) => (