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:
2026-06-10 17:30:36 +08:00
parent 5df910c9f1
commit 31f6487d61
36 changed files with 4162 additions and 64 deletions

View File

@@ -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: '缺少下载令牌' })
}
// 解析 tokenbackupId: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