feat: 全面系统检查与修复 — 51项问题修复

## 重大修复

### 安全性修复
- 修复 notification read-all SQL 运算符优先级 bug
- 修复 link_url XSS 漏洞(验证 URL 协议)
- 修复 LIKE 通配符注入(转义 % 和 _)
- 修复 uploadImageIfNeeded 运算符优先级 bug
- 401 时清理所有本地存储(token/nickname/avatar/role/group)

### 数据完整性修复
- 修复公告已读状态共享问题(新建 notification_reads 表)
- 修复群组账单数据缺失(退出群组时保留 group_id)
- 修复群组解散后邀请码仍可加入
- 修复分类迁移未校验目标类型
- 修复群组公告缺少 group_id 必填校验

### 功能修复
- 修复 category PUT /sort 路由冲突
- 修复 GROUP BY 不完整问题
- 修复 budget API 类型不匹配
- 修复 categoryStore.migrateCategory 不刷新本地数据
- 修复 groupStore 并发请求问题
- 修复账单页覆盖 store 数据
- 修复群组预算查询返回 0 而非 null
- 修复通知页面 onShow 不刷新列表
- 修复统计页面不必要的重复请求

### 用户体验优化
- 添加通知详情查看功能(弹窗)
- 添加通知图片服务器上传
- 添加 Markdown 富文本工具栏
- 添加管理页面客户端认证检查
- 添加管理员公告页面下拉刷新
- 添加数据导出进度反馈
- 添加账单删除后筛选金额更新
- Numpad 添加安全区域 padding

### 代码质量提升
- 提取 requireAdmin 为共享中间件
- filter-panel 使用设计 token
- 修复 getCurrentMonth 时区不一致
- 备份功能使用分页查询避免内存问题
- 管理后台仪表盘添加缓存
- 邀请码碰撞重试后报错

## 新增文件
- server/src/middleware/requireAdmin.ts — 共享管理员权限中间件
- client/src/pages/admin/notifications/index.vue — 公告管理页面

## 数据库变更
- 新增 notification_reads 表(公告已读记录)
- 群组解散时保留 groups 记录和 transactions.group_id
This commit is contained in:
wangxiaogang
2026-06-07 15:35:37 +08:00
parent 4f42f2c342
commit bd7af8e512
35 changed files with 1683 additions and 759 deletions

View File

@@ -8,9 +8,13 @@ const gzipAsync = promisify(zlib.gzip)
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
// 分页查询大小,避免一次性加载大表到内存
const QUERY_BATCH_SIZE = 1000
/**
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump
* 导出所有表的 INSERT 语句gzip 压缩存储
* 使用分页查询避免大表内存问题
*/
export async function backupDatabase(): Promise<string> {
if (!fs.existsSync(BACKUP_DIR)) {
@@ -35,38 +39,50 @@ export async function backupDatabase(): Promise<string> {
lines.push(`-- 表: ${tableName}`)
lines.push(`-- ----------------------------`)
// 获取表数
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
const data = rows as any[]
// 获取表总行
const [countResult] = await pool.query(`SELECT COUNT(*) as count FROM \`${tableName}\``)
const totalRows = (countResult as any[])[0].count
if (data.length === 0) {
if (totalRows === 0) {
lines.push(`-- ${tableName}: 无数据`)
lines.push('')
continue
}
// 获取列名
const columns = Object.keys(data[0])
// 获取列名(从第一行)
const [firstRow] = await pool.query(`SELECT * FROM \`${tableName}\` LIMIT 1`)
const columns = Object.keys((firstRow as any[])[0])
const colList = columns.map(c => `\`${c}\``).join(', ')
// 生成 INSERT 语句(每 100 行一批)
const BATCH_SIZE = 100
for (let i = 0; i < data.length; i += BATCH_SIZE) {
const batch = data.slice(i, i + BATCH_SIZE)
const values = batch.map(row => {
const vals = columns.map(col => {
const v = row[col]
if (v === null) return 'NULL'
if (typeof v === 'number') return String(v)
if (typeof v === 'boolean') return v ? '1' : '0'
// mysql2 escape 处理所有字符串/日期转义(自带引号)
return pool.escape(v)
})
return `(${vals.join(', ')})`
}).join(',\n ')
// 分页查询并生成 INSERT 语句
let offset = 0
while (offset < totalRows) {
const [rows] = await pool.query(
`SELECT * FROM \`${tableName}\` LIMIT ? OFFSET ?`,
[QUERY_BATCH_SIZE, offset]
)
const data = rows as any[]
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
// 每 100 行生成一条 INSERT
const INSERT_BATCH = 100
for (let i = 0; i < data.length; i += INSERT_BATCH) {
const batch = data.slice(i, i + INSERT_BATCH)
const values = batch.map(row => {
const vals = columns.map(col => {
const v = row[col]
if (v === null) return 'NULL'
if (typeof v === 'number') return String(v)
if (typeof v === 'boolean') return v ? '1' : '0'
return pool.escape(v)
})
return `(${vals.join(', ')})`
}).join(',\n ')
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
}
offset += QUERY_BATCH_SIZE
}
lines.push('')
}