Files
yunrui_asset/backend/routes/backup.js
T

1235 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const logger = require('../utils/logger').module('BackupRoute');
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;
logger.info('开始创建备份...');
const result = await createBackup({
description,
includeFiles: includeFiles !== false,
});
res.json({
success: true,
message: '备份创建成功',
data: result,
});
} catch (error) {
logger.error('创建备份失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取备份列表失败', { error: error.message, stack: error.stack });
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) {
logger.error('验证备份文件失败', { error: error.message, stack: error.stack });
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 {
logger.info('开始恢复备份: ${filename}');
logger.info('恢复选项', { data: options });
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;
let isStopped = false;
let clientDisconnected = false;
// 监听客户端断开连接 - 使用多种事件确保可靠检测
req.on('close', () => {
if (!clientDisconnected) {
clientDisconnected = true;
logger.info('客户端断开连接(req.close),准备停止恢复...');
isStopped = true;
}
});
req.on('aborted', () => {
if (!clientDisconnected) {
clientDisconnected = true;
logger.info('客户端中断请求(req.aborted),准备停止恢复...');
isStopped = true;
}
});
res.on('close', () => {
if (!clientDisconnected) {
clientDisconnected = true;
logger.info('响应流关闭(res.close),准备停止恢复...');
isStopped = true;
}
});
// 定期检查客户端是否仍然连接
const keepAliveInterval = setInterval(() => {
if (res.writableEnded || res.destroyed) {
if (!clientDisconnected) {
clientDisconnected = true;
logger.info('检测到响应流已结束,准备停止恢复...');
isStopped = true;
}
clearInterval(keepAliveInterval);
}
}, 1000);
const result = await restoreBackup(filePath, {
overwriteExisting: options.overwriteExisting !== false,
skipTables: options.skipTables || [],
skipFiles: options.skipFiles === true,
skipUserData: options.skipUserData === true,
shouldStop: () => isStopped,
onProgress: (tableName, status, count) => {
// 如果已停止,不再发送进度
if (isStopped) return;
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,
});
},
});
clearInterval(keepAliveInterval);
if (result.stopped) {
sendProgress({
stage: 'stopped',
message: result.rolledBack ? '恢复已停止,数据已回滚到恢复前的状态' : '恢复已停止,数据可能处于不一致状态',
progress: 0,
result: {
tablesRestored: result.tablesRestored,
recordsRestored: result.recordsRestored,
filesRestored: result.filesRestored,
restoredAt: result.restoredAt,
tableDetails: result.tableDetails,
fileDetails: result.fileDetails,
stopped: true,
rolledBack: result.rolledBack,
},
});
} else {
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) {
logger.error('恢复备份失败', { error: error.message, stack: error.stack });
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: '备份文件不存在',
});
}
logger.info('开始恢复备份: ${filename}');
logger.info('恢复选项', { data: options });
const result = await restoreBackup(filePath, {
overwriteExisting: options.overwriteExisting !== false,
skipTables: options.skipTables || [],
skipFiles: options.skipFiles === true,
skipUserData: options.skipUserData === true,
onProgress: (tableName, status, count) => {
logger.info(` ${tableName}: ${status}${count ? ` (${count})` : ''}`);
},
});
res.json({
success: true,
message: '数据恢复成功',
data: result,
});
} catch (error) {
logger.error('恢复备份失败', { error: error.message, stack: error.stack });
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');
logger.info('上传备份文件: ${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) {
logger.error('上传备份文件失败', { error: error.message, stack: error.stack });
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) {
logger.error('下载备份文件失败', { err: err.message, stack: err.stack });
}
});
} catch (error) {
logger.error('下载备份文件失败', { error: error.message, stack: error.stack });
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) {
logger.error('删除备份文件失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取备份信息失败', { error: error.message, stack: error.stack });
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) {
logger.error('清理备份失败', { error: error.message, stack: error.stack });
res.status(500).json({
success: false,
message: '清理备份失败',
error: error.message,
});
}
});
router.use((error, req, res, next) => {
logger.error('路由错误', { error: error.message, stack: error.stack });
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) {
logger.error('获取自动备份状态失败', { error: error.message, stack: error.stack });
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) {
logger.error('更新自动备份设置失败', { error: error.message, stack: error.stack });
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) {
logger.error('立即执行备份失败', { error: error.message, stack: error.stack });
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) {
logger.error('测试 Cron 表达式失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取远端备份目标失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取远端备份目标失败', { error: error.message, stack: error.stack });
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) {
logger.error('添加远端备份目标失败', { error: error.message, stack: error.stack });
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) {
logger.error('更新远端备份目标失败', { error: error.message, stack: error.stack });
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) {
logger.error('删除远端备份目标失败', { error: error.message, stack: error.stack });
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) {
logger.error('[RemoteBackup] 测试远端连接失败', { error: error.message, stack: error.stack });
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) {
logger.error('测试远端连接失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取远端备份设置失败', { error: error.message, stack: error.stack });
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) {
logger.error('更新远端备份设置失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取协议列表失败', { error: error.message, stack: error.stack });
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) {
logger.error('手动上传备份失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取备份日志失败', { error: error.message, stack: error.stack });
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) {
logger.error('获取备份日志详情失败', { error: error.message, stack: error.stack });
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) {
logger.error('清理旧日志失败', { error: error.message, stack: error.stack });
res.status(500).json({
success: false,
message: '清理旧日志失败',
error: error.message,
});
}
});
module.exports = router;