feat: 前后端功能对齐 - 实现8个对齐差距
P0-1: 分类拖拽排序 - category-manage 添加拖拽 UI P1-1: 反馈回复展示 - 新增 GET /feedback/mine + 前端我的反馈Tab P1-2: 备份管理页面 - 新增下载端点 + backup-manage 页面 P2-1: 统计年度/周视图 - stats 支持 period 参数 + 前端维度切换器 P2-2: 数据导入 - 新增 POST /import 端点 + data-import 页面 P2-3: 数据导出服务端化 - 新增 GET /export 流式端点 P3-1: 健康检查展示 - health 移到 auth 前 + admin 状态卡片 P3-2~4: 交易标签系统 - tags CRUD + 交易关联 + 按标签筛选统计 后端: 新增 tag.ts/export.ts 路由, 改造 feedback/backup/transaction/stats 前端: 新增 DragSortList 组件, 3个新页面, 改造 7 个现有页面 QA 修复: 5个严重Bug + 4个潜在问题
This commit is contained in:
@@ -2,11 +2,74 @@ 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 fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
const router = Router()
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
|
||||
|
||||
// 所有路由都需要管理员权限
|
||||
router.use((req, res, next) => requireAdmin(req, res, next))
|
||||
// 所有路由都需要管理员权限(除了 :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', JWT_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) => {
|
||||
@@ -23,11 +86,45 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const list = getBackupList()
|
||||
res.json({ code: 0, data: list })
|
||||
// 为每条记录生成下载令牌
|
||||
const listWithToken = list.map(item => {
|
||||
const timestamp = Date.now()
|
||||
const hmac = createHmac('sha256', JWT_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
|
||||
|
||||
Reference in New Issue
Block a user