import { Router, Response } from 'express' import { AuthRequest } from '../middleware/auth' import { backupDatabase, getBackupList } from '../utils/backup' import { requireAdmin } from '../middleware/requireAdmin' import { createHmac } from 'crypto' import { TOKEN_SECRET } from '../config/token' import fs from 'fs' import path from 'path' const router = Router() const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai' // 所有路由都需要管理员权限(除了 :id/download 使用签名校验) router.use((req, res, next) => { // /:id/download 不走 requireAdmin,自行校验签名 if (req.path.match(/^\/\d+\/download$/)) { return next() } requireAdmin(req, res, next) }) // 下载备份文件(签名 token 校验,不走 authMiddleware) router.get('/:id/download', async (req: AuthRequest, res: Response) => { try { const token = req.query.token as string if (!token) { return res.status(400).json({ code: 40001, message: '缺少下载令牌' }) } // 解析 token:backupId:timestamp:hmac const parts = token.split(':') if (parts.length !== 3) { return res.status(400).json({ code: 40001, message: '令牌格式无效' }) } const [backupId, timestamp, hmac] = parts // 校验 timestamp 未过期(1小时内) const ts = parseInt(timestamp) if (isNaN(ts) || Date.now() - ts > 3600000) { return res.status(400).json({ code: 40001, message: '令牌已过期' }) } // 校验 HMAC const expectedHmac = createHmac('sha256', TOKEN_SECRET) .update(`${backupId}:${timestamp}`) .digest('hex') if (hmac !== expectedHmac) { return res.status(403).json({ code: 40300, message: '令牌签名无效' }) } // 校验 backupId 存在于备份列表中 const list = getBackupList() const backup = list.find(b => b.name === backupId) if (!backup) { return res.status(404).json({ code: 40400, message: '备份不存在' }) } // 流式返回备份文件 const filepath = path.join(BACKUP_DIR, backup.name) if (!fs.existsSync(filepath)) { return res.status(404).json({ code: 40400, message: '备份文件不存在' }) } res.setHeader('Content-Type', 'application/gzip') res.setHeader('Content-Disposition', `attachment; filename=${backup.name}`) fs.createReadStream(filepath).pipe(res) } catch (err) { console.error('[Backup] download error:', err) res.status(500).json({ code: 50000, message: '下载失败' }) } }) // 手动触发备份 router.post('/', async (req: AuthRequest, res: Response) => { try { await backupDatabase() res.json({ code: 0, message: '备份成功' }) } catch (err) { console.error('[Backup] 手动备份失败:', err) res.status(500).json({ code: 50000, message: '备份失败' }) } }) // 获取备份列表 router.get('/', async (req: AuthRequest, res: Response) => { try { const list = getBackupList() // 为每条记录生成下载令牌 const listWithToken = list.map(item => { const timestamp = Date.now() const hmac = createHmac('sha256', TOKEN_SECRET) .update(`${item.name}:${timestamp}`) .digest('hex') return { ...item, downloadToken: `${item.name}:${timestamp}:${hmac}` } }) res.json({ code: 0, data: listWithToken }) } catch (err) { console.error('[Backup] 获取列表失败:', err) res.status(500).json({ code: 50000, message: '获取备份列表失败' }) } }) // 删除备份 router.delete('/:id', async (req: AuthRequest, res: Response) => { try { const backupName = req.params.id const list = getBackupList() const backup = list.find(b => b.name === backupName) if (!backup) { return res.status(404).json({ code: 40400, message: '备份不存在' }) } // 删除备份文件 const filepath = path.join(BACKUP_DIR, backup.name) if (fs.existsSync(filepath)) { fs.unlinkSync(filepath) } res.json({ code: 0 }) } catch (err) { console.error('[Backup] 删除备份失败:', err) res.status(500).json({ code: 50000, message: '删除备份失败' }) } }) export default router