consumableImport.js挂载在/api根路径,使用router.use(authMiddleware) 会拦截所有/api/*请求(包括公开路由如/api/auth/login), 导致登录等公开接口返回401。 修复方案:移除router.use(authMiddleware),改为在每个具体路由上 单独添加authMiddleware中间件。
1167 lines
30 KiB
JavaScript
1167 lines
30 KiB
JavaScript
const express = require('express');
|
||
const router = express.Router();
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const zlib = require('zlib');
|
||
const { authMiddleware } = require('../middleware/auth');
|
||
|
||
/**
|
||
* 安全解析备份文件路径,防止路径遍历攻击
|
||
* @param {string} filename - 用户提供的文件名
|
||
* @param {string} backupPath - 备份目录的绝对路径
|
||
* @returns {{ safePath: string, error: string|null }}
|
||
*/
|
||
function resolveBackupPath(filename, backupPath) {
|
||
if (!filename || typeof filename !== 'string') {
|
||
return { safePath: null, error: '请提供备份文件名' };
|
||
}
|
||
// 拒绝包含路径分隔符或上级目录引用的文件名
|
||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||
return { safePath: null, error: '文件名包含非法字符' };
|
||
}
|
||
// 只允许备份文件扩展名
|
||
if (!filename.endsWith('.json') && !filename.endsWith('.json.gz')) {
|
||
return { safePath: null, error: '文件名格式不正确' };
|
||
}
|
||
const safePath = path.join(backupPath, filename);
|
||
// 二次校验:解析后的路径必须在备份目录内
|
||
const resolvedPath = path.resolve(safePath);
|
||
const resolvedBackupDir = path.resolve(backupPath);
|
||
if (!resolvedPath.startsWith(resolvedBackupDir + path.sep)) {
|
||
return { safePath: null, error: '文件路径不合法' };
|
||
}
|
||
return { safePath: resolvedPath, error: null };
|
||
}
|
||
const {
|
||
getBackupPath,
|
||
ensureBackupDir,
|
||
createBackup,
|
||
validateBackupFile,
|
||
restoreBackup,
|
||
cleanOldBackups,
|
||
} = require('../utils/backup');
|
||
const {
|
||
loadSettings,
|
||
saveSettings,
|
||
validateCronExpression,
|
||
timeToCron,
|
||
startAutoBackup,
|
||
stopAutoBackup,
|
||
getAutoBackupStatus,
|
||
updateAutoBackupSettings,
|
||
executeBackupNow,
|
||
} = require('../utils/autoBackupScheduler');
|
||
const { getBackupLogs, getBackupLogById, deleteOldLogs } = require('../utils/backupLog');
|
||
const {
|
||
getAllTargets,
|
||
getTarget,
|
||
addTarget,
|
||
updateTarget,
|
||
deleteTarget,
|
||
getGlobalSettings,
|
||
updateGlobalSettings,
|
||
getEnabledTargets,
|
||
} = require('../utils/remoteBackupConfig');
|
||
const { testRemoteConnection, PROTOCOL_TYPES, PROTOCOL_LABELS } = require('../utils/remoteBackup');
|
||
|
||
const tempDir = path.join(__dirname, '..', 'temp');
|
||
if (!fs.existsSync(tempDir)) {
|
||
fs.mkdirSync(tempDir, { recursive: true });
|
||
}
|
||
|
||
// 全局认证保护:所有备份路由需要登录
|
||
router.use(authMiddleware);
|
||
|
||
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');
|
||
|
||
// 尝试从文件内容中提取元数据
|
||
const metadata = {
|
||
filename: f,
|
||
size: stats.size,
|
||
compressed: isCompressed,
|
||
createdAt: stats.birthtime,
|
||
modifiedAt: stats.mtime,
|
||
};
|
||
|
||
try {
|
||
// 只读取文件头部(前 4KB)提取元数据,避免大文件内存溢出
|
||
let content;
|
||
if (isCompressed) {
|
||
const compressed = fs.readFileSync(filePath);
|
||
// 限制解压大小,超过 1MB 的备份不解析元数据
|
||
if (compressed.length > 1024 * 1024) {
|
||
metadata.description = '(文件过大,跳过元数据解析)';
|
||
return metadata;
|
||
}
|
||
content = zlib.gunzipSync(compressed).toString('utf8');
|
||
} else {
|
||
// 只读取前 4KB
|
||
const fd = fs.openSync(filePath, 'r');
|
||
const buf = Buffer.alloc(4096);
|
||
const bytesRead = fs.readSync(fd, buf, 0, 4096, 0);
|
||
fs.closeSync(fd);
|
||
content = buf.slice(0, bytesRead).toString('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 { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
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.get('/restore-progress/:filename', async (req, res) => {
|
||
const { filename } = req.params;
|
||
const options = req.query.options ? JSON.parse(req.query.options) : {};
|
||
|
||
const backupPath = getBackupPath();
|
||
const { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '备份文件不存在',
|
||
});
|
||
}
|
||
|
||
res.setHeader('Content-Type', 'text/event-stream');
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
res.setHeader('X-Accel-Buffering', 'no');
|
||
|
||
const sendProgress = data => {
|
||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||
};
|
||
|
||
try {
|
||
console.log(`开始恢复备份: ${filename}`);
|
||
sendProgress({ stage: 'start', message: '正在验证备份文件...', progress: 5 });
|
||
|
||
const validation = await validateBackupFile(filePath);
|
||
if (!validation.valid) {
|
||
sendProgress({
|
||
stage: 'error',
|
||
message: `备份文件验证失败: ${validation.error}`,
|
||
progress: 0,
|
||
});
|
||
res.end();
|
||
return;
|
||
}
|
||
|
||
sendProgress({
|
||
stage: 'validate',
|
||
message: '备份文件验证通过',
|
||
progress: 10,
|
||
metadata: validation.metadata,
|
||
});
|
||
|
||
const buffer = fs.readFileSync(filePath);
|
||
const isCompressed = filePath.endsWith('.gz');
|
||
|
||
let backupData;
|
||
if (isCompressed) {
|
||
sendProgress({ stage: 'decompress', message: '正在解压备份文件...', progress: 15 });
|
||
const decompressed = zlib.gunzipSync(buffer);
|
||
backupData = JSON.parse(decompressed.toString('utf8'));
|
||
} else {
|
||
backupData = JSON.parse(buffer.toString('utf8'));
|
||
}
|
||
|
||
sendProgress({ stage: 'parse', message: '正在解析备份数据...', progress: 20 });
|
||
|
||
const totalTables = require('../utils/backup').RESTORE_ORDER.length;
|
||
let processedTables = 0;
|
||
|
||
const result = await restoreBackup(filePath, {
|
||
overwriteExisting: options.overwriteExisting !== false,
|
||
skipTables: options.skipTables || [],
|
||
skipFiles: options.skipFiles === true,
|
||
onProgress: (tableName, status, count) => {
|
||
processedTables++;
|
||
const progress = 20 + Math.floor((processedTables / totalTables) * 70);
|
||
const statusMap = {
|
||
restored: '已恢复',
|
||
skipped: '已跳过',
|
||
empty: '无数据',
|
||
error: '错误',
|
||
};
|
||
sendProgress({
|
||
stage: 'restore',
|
||
message: `正在恢复: ${tableName} (${statusMap[status] || status}${count ? ` - ${count} 条` : ''})`,
|
||
progress,
|
||
currentTable: tableName,
|
||
status,
|
||
count,
|
||
processedTables,
|
||
totalTables,
|
||
});
|
||
},
|
||
});
|
||
|
||
sendProgress({
|
||
stage: 'complete',
|
||
message: '恢复完成!',
|
||
progress: 100,
|
||
result: {
|
||
tablesRestored: result.tablesRestored,
|
||
recordsRestored: result.recordsRestored,
|
||
filesRestored: result.filesRestored,
|
||
restoredAt: result.restoredAt,
|
||
tableDetails: result.tableDetails,
|
||
fileDetails: result.fileDetails,
|
||
},
|
||
});
|
||
|
||
res.end();
|
||
} catch (error) {
|
||
console.error('恢复备份失败:', error);
|
||
sendProgress({ stage: 'error', message: `恢复失败: ${error.message}`, progress: 0 });
|
||
res.end();
|
||
}
|
||
});
|
||
|
||
router.post('/restore', async (req, res) => {
|
||
try {
|
||
const { filename, options = {} } = req.body;
|
||
|
||
const backupPath = getBackupPath();
|
||
const { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
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 { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
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 { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
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,
|
||
backupType,
|
||
} = 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;
|
||
}
|
||
if (backupType !== undefined) {
|
||
newSettings.backupType = backupType;
|
||
}
|
||
|
||
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, backupType } = req.body;
|
||
|
||
const result = await executeBackupNow({
|
||
description: description || '手动触发备份',
|
||
includeFiles,
|
||
compress,
|
||
backupType,
|
||
});
|
||
|
||
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,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 远端备份配置路由 ====================
|
||
|
||
// 获取所有远端备份目标
|
||
router.get('/remote/targets', (req, res) => {
|
||
try {
|
||
const targets = getAllTargets();
|
||
res.json({
|
||
success: true,
|
||
data: { targets },
|
||
});
|
||
} catch (error) {
|
||
console.error('获取远端备份目标失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取远端备份目标失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 获取单个远端备份目标
|
||
router.get('/remote/targets/:id', (req, res) => {
|
||
try {
|
||
const target = getTarget(req.params.id);
|
||
|
||
if (!target) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '目标不存在',
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: { target },
|
||
});
|
||
} catch (error) {
|
||
console.error('获取远端备份目标失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取远端备份目标失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 添加远端备份目标
|
||
router.post('/remote/targets', (req, res) => {
|
||
try {
|
||
const targetData = req.body;
|
||
|
||
if (!targetData.name || !targetData.protocol) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '请提供目标名称和协议类型',
|
||
});
|
||
}
|
||
|
||
const target = addTarget(targetData);
|
||
|
||
res.status(201).json({
|
||
success: true,
|
||
message: '远端备份目标已添加',
|
||
data: { target },
|
||
});
|
||
} catch (error) {
|
||
console.error('添加远端备份目标失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '添加远端备份目标失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 更新远端备份目标
|
||
router.put('/remote/targets/:id', (req, res) => {
|
||
try {
|
||
const updates = req.body;
|
||
const target = updateTarget(req.params.id, updates);
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '远端备份目标已更新',
|
||
data: { target },
|
||
});
|
||
} catch (error) {
|
||
console.error('更新远端备份目标失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '更新远端备份目标失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 删除远端备份目标
|
||
router.delete('/remote/targets/:id', (req, res) => {
|
||
try {
|
||
const deleted = deleteTarget(req.params.id);
|
||
|
||
if (!deleted) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '目标不存在',
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '远端备份目标已删除',
|
||
});
|
||
} catch (error) {
|
||
console.error('删除远端备份目标失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '删除远端备份目标失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 直接测试远端连接(不保存,用于添加节点前的测试)
|
||
router.post('/remote/test', async (req, res) => {
|
||
try {
|
||
const config = req.body;
|
||
|
||
if (!config.host || !config.port || !config.username) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '请提供完整的连接信息(主机、端口、用户名)',
|
||
});
|
||
}
|
||
|
||
if (!config.protocol) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '请提供协议类型(protocol)',
|
||
});
|
||
}
|
||
|
||
const result = await testRemoteConnection(config);
|
||
|
||
if (result.success) {
|
||
res.json({
|
||
success: true,
|
||
message: '连接测试成功',
|
||
data: result,
|
||
});
|
||
} else {
|
||
res.status(500).json({
|
||
success: false,
|
||
message: result.message,
|
||
error: result.error,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('[RemoteBackup] 测试远端连接失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '测试远端连接失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 测试远端连接
|
||
router.post('/remote/targets/:id/test', async (req, res) => {
|
||
try {
|
||
const target = getTarget(req.params.id);
|
||
|
||
if (!target) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '目标不存在',
|
||
});
|
||
}
|
||
|
||
const result = await testRemoteConnection(target);
|
||
|
||
if (result.success) {
|
||
res.json({
|
||
success: true,
|
||
message: '连接测试成功',
|
||
data: result,
|
||
});
|
||
} else {
|
||
res.status(500).json({
|
||
success: false,
|
||
message: result.message,
|
||
error: result.error,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('测试远端连接失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '测试远端连接失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 获取远端备份全局设置
|
||
router.get('/remote/settings', (req, res) => {
|
||
try {
|
||
const settings = getGlobalSettings();
|
||
res.json({
|
||
success: true,
|
||
data: { settings },
|
||
});
|
||
} catch (error) {
|
||
console.error('获取远端备份设置失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取远端备份设置失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 更新远端备份全局设置
|
||
router.put('/remote/settings', (req, res) => {
|
||
try {
|
||
const settings = updateGlobalSettings(req.body);
|
||
res.json({
|
||
success: true,
|
||
message: '远端备份设置已更新',
|
||
data: { settings },
|
||
});
|
||
} catch (error) {
|
||
console.error('更新远端备份设置失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '更新远端备份设置失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 获取支持的协议列表
|
||
router.get('/remote/protocols', (req, res) => {
|
||
try {
|
||
const protocols = Object.entries(PROTOCOL_TYPES).map(([key, value]) => ({
|
||
key,
|
||
value,
|
||
label: PROTOCOL_LABELS[value],
|
||
}));
|
||
|
||
res.json({
|
||
success: true,
|
||
data: { protocols },
|
||
});
|
||
} catch (error) {
|
||
console.error('获取协议列表失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取协议列表失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 手动上传备份到远端
|
||
router.post('/remote/upload', async (req, res) => {
|
||
try {
|
||
const { filename, targetIds } = req.body;
|
||
|
||
const backupPath = getBackupPath();
|
||
const { safePath, error } = resolveBackupPath(filename, backupPath);
|
||
if (error) {
|
||
return res.status(400).json({ success: false, message: error });
|
||
}
|
||
const filePath = safePath;
|
||
|
||
if (!fs.existsSync(filePath)) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '备份文件不存在',
|
||
});
|
||
}
|
||
|
||
const { uploadToRemote } = require('../utils/remoteBackup');
|
||
const { getTarget } = require('../utils/remoteBackupConfig');
|
||
|
||
const targets = targetIds
|
||
? targetIds.map(id => getTarget(id)).filter(Boolean)
|
||
: getEnabledTargets();
|
||
|
||
if (targets.length === 0) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
message: '没有可用的远端目标',
|
||
});
|
||
}
|
||
|
||
const uploadResults = [];
|
||
|
||
for (const target of targets) {
|
||
try {
|
||
const remotePath = `${target.prefix || 'backups/'}${filename}`;
|
||
const result = await uploadToRemote(target, filePath, remotePath);
|
||
|
||
uploadResults.push({
|
||
targetId: target.id,
|
||
targetName: target.name,
|
||
success: true,
|
||
...result,
|
||
});
|
||
} catch (error) {
|
||
uploadResults.push({
|
||
targetId: target.id,
|
||
targetName: target.name,
|
||
success: false,
|
||
error: error.message,
|
||
});
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '上传完成',
|
||
data: {
|
||
filename,
|
||
results: uploadResults,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
console.error('手动上传备份失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '手动上传备份失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 备份日志接口 ====================
|
||
|
||
// 获取备份日志列表
|
||
router.get('/logs', async (req, res) => {
|
||
try {
|
||
const { page = 1, pageSize = 20, logType, status } = req.query;
|
||
|
||
const result = await getBackupLogs({
|
||
page: parseInt(page),
|
||
pageSize: parseInt(pageSize),
|
||
logType,
|
||
status,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
data: result,
|
||
});
|
||
} catch (error) {
|
||
console.error('获取备份日志失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取备份日志失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 获取备份日志详情
|
||
router.get('/logs/:id', async (req, res) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const log = await getBackupLogById(parseInt(id));
|
||
|
||
if (!log) {
|
||
return res.status(404).json({
|
||
success: false,
|
||
message: '备份日志不存在',
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
data: log,
|
||
});
|
||
} catch (error) {
|
||
console.error('获取备份日志详情失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '获取备份日志详情失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
// 清理旧日志
|
||
router.delete('/logs/clean', async (req, res) => {
|
||
try {
|
||
const { days = 30 } = req.body;
|
||
const deletedCount = await deleteOldLogs(parseInt(days));
|
||
|
||
res.json({
|
||
success: true,
|
||
message: '清理完成',
|
||
data: { deletedCount },
|
||
});
|
||
} catch (error) {
|
||
console.error('清理旧日志失败:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
message: '清理旧日志失败',
|
||
error: error.message,
|
||
});
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|