feat(backup): 实现自动备份功能及错误边界处理

This commit is contained in:
zhang1106
2026-03-13 15:13:58 +08:00
parent 4569eabda7
commit d06b191c80
31 changed files with 5202 additions and 210 deletions
+35 -52
View File
@@ -1,125 +1,108 @@
# IDC设备管理系统环境配置示例
# 复制此文件为 .env 并根据需要修改配置
# IDC设备管理系统 - 环境配置
# 生成时间: 2026-02-26T05:07:19.876Z
# ==============================================
# 服务器配置
# ==============================================
# 服务器端口(默认8000
# 如需修改,请设置为其他端口号
# 服务器端口
PORT=8000
# 运行环境development/production
# development: 开发模式,详细日志便于调试
# production: 生产模式,性能优化,减少日志输出
# 运行环境: developmentproduction
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
FRONTEND_PORT=3000
+10
View File
@@ -0,0 +1,10 @@
{
"enabled": true,
"cronExpression": "0 20 * * *",
"description": "自动备份",
"backupType": "full",
"includeFiles": true,
"compress": true,
"maxCount": 30,
"maxAgeDays": 90
}
+1 -1
View File
@@ -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: [
+107
View File
@@ -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,
};
+3 -11
View File
@@ -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;
+57 -1
View File
@@ -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",
+2
View File
@@ -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",
+551
View File
@@ -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;
+20 -4
View File
@@ -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 });
}
+67 -49
View File
@@ -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
+123
View File
@@ -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 <path> 指定备份文件输出路径
--description <text> 备份描述
--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();
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env node
/**
* 命令行恢复脚本
* 用于独立执行数据恢复,支持跨环境迁移
*
* 使用方法:
* node scripts/restore.js <backup-file> [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 <backup-file> [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();
+14
View File
@@ -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'));
+290
View File
@@ -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,
};
+935
View File
@@ -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,
};