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

@@ -148,6 +148,23 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query('ALTER TABLE notifications ADD INDEX idx_pinned (is_pinned, created_at)')
await conn.query('ALTER TABLE notifications ADD INDEX idx_expire (expire_at)')
}
// 公告已读记录表(解决系统/群组公告已读状态共享问题)
const hasNotificationReads = await tableExists(conn, 'notification_reads')
if (!hasNotificationReads) {
console.log('[DB] Migrating: creating notification_reads table')
await conn.query(`
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
}
}
export async function initDatabase() {

View File

@@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS notifications (
type ENUM('system', 'group', 'personal') NOT NULL,
title VARCHAR(100) NOT NULL,
content TEXT,
is_read TINYINT(1) DEFAULT 0,
is_read TINYINT(1) DEFAULT 0 COMMENT '仅用于个人通知,系统/群组公告使用 notification_reads 表',
is_pinned TINYINT(1) DEFAULT 0 COMMENT '是否置顶',
is_urgent TINYINT(1) DEFAULT 0 COMMENT '是否强提醒弹窗',
publish_at TIMESTAMP NULL COMMENT '定时发布时间NULL=立即发布)',
@@ -95,6 +95,17 @@ CREATE TABLE IF NOT EXISTS notifications (
INDEX idx_expire (expire_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 公告已读记录表(解决系统/群组公告已读状态共享问题)
CREATE TABLE IF NOT EXISTS notification_reads (
notification_id INT NOT NULL,
user_id INT NOT NULL,
read_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (notification_id, user_id),
INDEX idx_user (user_id),
FOREIGN KEY (notification_id) REFERENCES notifications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS saved_filters (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,

View File

@@ -100,6 +100,42 @@ app.get('/api/user/avatar/:filename', async (req, res) => {
}
})
// 通知图片 API公开不经过 auth 中间件)
app.get('/api/notifications/image/:filename', async (req, res) => {
try {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
const notifDir = path.join(uploadDir, 'notifications')
const filename = path.basename(req.params.filename)
// 文件名格式校验:允许 notif_用户ID_数字.ext
if (!/^notif_\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return res.status(400).json({ code: 40001, message: '无效文件名' })
}
const filepath = path.join(notifDir, filename)
// 路径穿越检查
if (!path.resolve(filepath).startsWith(path.resolve(notifDir))) {
return res.status(400).json({ code: 40001, message: '无效路径' })
}
if (!fs.existsSync(filepath)) {
return res.status(404).json({ code: 40400, message: '图片不存在' })
}
const ext = path.extname(filename).toLowerCase()
const mimeMap: Record<string, string> = {
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
'.png': 'image/png', '.webp': 'image/webp'
}
res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream')
res.setHeader('Cache-Control', 'public, max-age=86400')
fs.createReadStream(filepath).pipe(res)
} catch {
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
app.use(authMiddleware)
app.use('/api/auth', authLimiter, authRoutes)

View File

@@ -0,0 +1,18 @@
import { Response, NextFunction } from 'express'
import pool from '../db/connection'
import { AuthRequest } from './auth'
/** 管理员权限检查中间件 */
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
try {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
}
next()
} catch (err) {
console.error('[Auth] requireAdmin error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
}

View File

@@ -1,19 +1,10 @@
import { Router, Response, NextFunction } from 'express'
import { Router, Response } from 'express'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 管理员权限检查中间件 */
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限,请先登录获取管理员身份' })
}
next()
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
@@ -22,11 +13,15 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
try {
const [userCount] = await pool.query('SELECT COUNT(*) as count FROM users')
const [txThisMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
FROM transactions WHERE date >= DATE_FORMAT(NOW(), '%Y-%m-01')`
)
const [txLastMonth] = await pool.query(
`SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total
`SELECT COUNT(*) as count,
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as totalExpense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as totalIncome
FROM transactions
WHERE date >= DATE_FORMAT(DATE_SUB(NOW(), INTERVAL 1 MONTH), '%Y-%m-01')
AND date < DATE_FORMAT(NOW(), '%Y-%m-01')`
@@ -41,9 +36,9 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
const thisMonth = (txThisMonth as any[])[0]
const lastMonth = (txLastMonth as any[])[0]
let growth = 0
if (lastMonth.total > 0) {
growth = Math.round(((thisMonth.total - lastMonth.total) / lastMonth.total) * 100)
} else if (thisMonth.total > 0) {
if (lastMonth.totalExpense > 0) {
growth = Math.round(((thisMonth.totalExpense - lastMonth.totalExpense) / lastMonth.totalExpense) * 100)
} else if (thisMonth.totalExpense > 0) {
growth = 100
}
@@ -52,12 +47,14 @@ router.get('/dashboard', async (_req: AuthRequest, res: Response) => {
data: {
totalUsers: (userCount as any[])[0].count,
monthlyTxCount: thisMonth.count,
monthlyTxAmount: thisMonth.total,
monthlyExpense: thisMonth.totalExpense,
monthlyIncome: thisMonth.totalIncome,
dailyActive: (activeToday as any[])[0].count,
monthlyActive: (activeThisMonth as any[])[0].count,
growthPercent: growth,
lastMonthTxCount: lastMonth.count,
lastMonthTxAmount: lastMonth.total
lastMonthExpense: lastMonth.totalExpense,
lastMonthIncome: lastMonth.totalIncome
}
})
} catch (err) {
@@ -78,8 +75,10 @@ router.get('/users', async (req: AuthRequest, res: Response) => {
const params: any[] = []
if (keyword && typeof keyword === 'string' && keyword.trim()) {
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where = 'WHERE u.nickname LIKE ?'
params.push(`%${keyword.trim()}%`)
params.push(`%${escapedKeyword}%`)
}
const [rows] = await pool.query(
@@ -125,6 +124,17 @@ router.put('/users/:id/status', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能修改自己的角色' })
}
// 如果要降级管理员,检查是否至少保留一个管理员
if (role === 'user') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
}
}
const [result] = await pool.query('UPDATE users SET role = ? WHERE id = ?', [role, req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
@@ -144,6 +154,19 @@ router.delete('/users/:id', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40001, message: '不能删除自己' })
}
// 检查是否是管理员,如果是则确保至少保留一个
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.params.id])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
const [adminCount] = await pool.query(
"SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND id != ?",
[req.params.id]
)
if ((adminCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40002, message: '至少需要保留一个管理员' })
}
}
const [result] = await pool.query('DELETE FROM users WHERE id = ?', [req.params.id])
if ((result as any).affectedRows === 0) {
return res.status(404).json({ code: 40400, message: '用户不存在' })

View File

@@ -1,29 +1,18 @@
import { Router, Response } from 'express'
import { AuthRequest } from '../middleware/auth'
import { backupDatabase, getBackupList } from '../utils/backup'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
// 管理员用户 ID 列表(逗号分隔),未配置时默认仅 ID=1
const ADMIN_IDS = process.env.ADMIN_USER_IDS
? process.env.ADMIN_USER_IDS.split(',').map(Number)
: [1]
/** 检查是否为管理员,非管理员返回 403 */
function requireAdmin(req: AuthRequest, res: Response): boolean {
if (!req.userId || !ADMIN_IDS.includes(req.userId)) {
res.status(403).json({ code: 40300, message: '无权限' })
return false
}
return true
}
// 所有路由都需要管理员权限
router.use((req, res, next) => requireAdmin(req, res, next))
// 手动触发备份
router.post('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const filepath = await backupDatabase()
res.json({ code: 0, data: { filepath } })
await backupDatabase()
res.json({ code: 0, message: '备份成功' })
} catch (err) {
console.error('[Backup] 手动备份失败:', err)
res.status(500).json({ code: 50000, message: '备份失败' })
@@ -32,7 +21,6 @@ router.post('/', async (req: AuthRequest, res: Response) => {
// 获取备份列表
router.get('/', async (req: AuthRequest, res: Response) => {
if (!requireAdmin(req, res)) return
try {
const list = getBackupList()
res.json({ code: 0, data: list })

View File

@@ -25,19 +25,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [groupRows] = await pool.query(
`SELECT COALESCE(SUM(amount), 0) as amount, month
`SELECT SUM(amount) as amount, month
FROM budgets
WHERE user_id IN (SELECT user_id FROM group_members WHERE group_id = ?) AND month = ?
GROUP BY month`,
[group_id, month]
)
const [myRows] = await pool.query(
'SELECT COALESCE(amount, 0) as amount FROM budgets WHERE user_id = ? AND month = ?',
'SELECT amount FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const groupBudget = (groupRows as any[])[0] || { amount: 0, month }
const groupBudget = (groupRows as any[])[0]
const myAmount = (myRows as any[])[0]?.amount || 0
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
// 如果没有任何成员设置预算,返回 null
if (!groupBudget) {
res.json({ code: 0, data: null })
} else {
res.json({ code: 0, data: { amount: groupBudget.amount, myAmount, month } })
}
} else {
// 个人视图
const [rows] = await pool.query(

View File

@@ -18,6 +18,34 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
// 注意:此路由必须在 PUT /:id 之前注册,否则 /sort 会被当作 :id 参数
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name, icon, color, type } = req.body
@@ -96,15 +124,31 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
await conn.beginTransaction()
// 验证目标分类存在且属于当前用户或是默认分类
// 验证分类存在且属于当前用户
const [sourceRows] = await conn.query(
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
[req.params.id, req.userId]
)
const sourceCategory = (sourceRows as any[])[0]
if (!sourceCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '源分类不存在' })
}
// 验证目标分类存在且属于当前用户或是默认分类,并且类型一致
const [targetRows] = await conn.query(
'SELECT id FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
'SELECT id, type FROM categories WHERE id = ? AND (user_id = 0 OR user_id = ?)',
[targetId, req.userId]
)
if ((targetRows as any[]).length === 0) {
const targetCategory = (targetRows as any[])[0]
if (!targetCategory) {
await conn.rollback()
return res.status(404).json({ code: 40400, message: '目标分类不存在' })
}
if (sourceCategory.type !== targetCategory.type) {
await conn.rollback()
return res.status(400).json({ code: 40002, message: '只能迁移到同类型的分类' })
}
// 迁移记录
await conn.query(
@@ -129,33 +173,6 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
}
})
// 更新分类排序(批量更新,使用 CASE WHEN 一次完成)
router.put('/sort', async (req: AuthRequest, res: Response) => {
try {
const { ids } = req.body // [id1, id2, id3, ...]
if (!Array.isArray(ids) || ids.length === 0 || ids.length > 100) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 校验每个元素必须是正整数
if (!ids.every((id: any) => Number.isInteger(id) && id > 0)) {
return res.status(400).json({ code: 40001, message: '参数无效' })
}
// 使用参数化查询构建 CASE WHEN 语句
const whenClauses = ids.map(() => `WHEN ? THEN ?`).join(' ')
const whenParams: number[] = ids.flatMap((id: number, index: number) => [id, index])
const placeholders = ids.map(() => '?').join(',')
await pool.query(
`UPDATE categories SET sort_order = CASE id ${whenClauses} END WHERE id IN (${placeholders}) AND (user_id = 0 OR user_id = ?)`,
[...whenParams, ...ids, req.userId]
)
res.json({ code: 0 })
} catch (err) {
console.error('[Category] sort error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(

View File

@@ -36,6 +36,9 @@ router.post('/', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
const conn = await pool.getConnection()
try {
@@ -103,6 +106,15 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
return res.status(404).json({ code: 40400, message: '邀请码无效' })
}
// 检查群组是否已解散(没有任何成员)
const [memberCount] = await pool.query(
'SELECT COUNT(*) as count FROM group_members WHERE group_id = ?',
[group.id]
)
if ((memberCount as any[])[0].count === 0) {
return res.status(400).json({ code: 40003, message: '该群组已解散' })
}
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
const [result] = await pool.query(
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
@@ -139,37 +151,20 @@ router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
// 清除该用户在该群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[req.userId, groupId]
)
// 删除成员关系
await conn.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
res.json({ code: 0 })
} catch (err) {
console.error('[Group] leave error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 解散群组(仅 owner */
/** 解散群组(仅 owner — 保留群组记录和 transactions.group_id仅移除所有成员 */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [groupRows] = await pool.query(
@@ -184,24 +179,10 @@ router.delete('/:id', async (req: AuthRequest, res: Response) => {
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 移除所有成员(保留群组记录和 transactions.group_id历史数据不丢失
await pool.query('DELETE FROM group_members WHERE group_id = ?', [req.params.id])
// 清除该群组所有记录的标签
await conn.query('UPDATE transactions SET group_id = NULL WHERE group_id = ?', [req.params.id])
// 删除群组CASCADE 自动清理 group_members
await conn.query('DELETE FROM `groups` WHERE id = ?', [req.params.id])
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
res.json({ code: 0 })
} catch (err) {
console.error('[Group] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -235,6 +216,9 @@ router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => {
inviteCode = generateInviteCode()
attempts++
}
if (attempts >= 10) {
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
}
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
res.json({ code: 0, data: { invite_code: inviteCode } })
@@ -299,30 +283,13 @@ router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) =>
return res.status(403).json({ code: 40300, message: '仅群主可移除成员' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 删除成员关系(保留 transactions.group_id历史记录仍属于该群组
await pool.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, targetUserId]
)
// 清除该成员在群组的记录标签
await conn.query(
'UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?',
[targetUserId, groupId]
)
// 删除成员关系
await conn.query(
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, targetUserId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
res.json({ code: 0 })
} catch (err) {
console.error('[Group] remove member error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })

View File

@@ -1,17 +1,80 @@
import { Router, Response, NextFunction } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
import { requireAdmin } from '../middleware/requireAdmin'
const router = Router()
/** 管理员权限检查 */
async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (rows as any[])[0]
if (!user || user.role !== 'admin') {
return res.status(403).json({ code: 40300, message: '需要管理员权限' })
// 通知图片上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const NOTIFICATION_DIR = path.resolve(UPLOAD_DIR, 'notifications')
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
if (!fs.existsSync(NOTIFICATION_DIR)) {
fs.mkdirSync(NOTIFICATION_DIR, { recursive: true })
}
cb(null, NOTIFICATION_DIR)
},
filename: (req: AuthRequest, file, cb) => {
const extMap: Record<string, string> = {
'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'
}
const ext = extMap[file.mimetype] || '.jpg'
cb(null, `notif_${req.userId}_${Date.now()}${ext}`)
}
next()
})
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 最大 5MB
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/webp']
if (allowed.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error('仅支持 JPEG、PNG、WebP 格式'))
}
}
})
/** 公告编辑权限检查(管理员可编辑所有公告,群主可编辑自己群组的公告) */
async function requireNotificationEditAuth(req: AuthRequest, res: Response, next: NextFunction) {
const [rows] = await pool.query(
'SELECT type, group_id FROM notifications WHERE id = ?',
[req.params.id]
)
const notification = (rows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '公告不存在' })
}
// 检查是否是管理员
const [userRows] = await pool.query('SELECT role FROM users WHERE id = ?', [req.userId])
const user = (userRows as any[])[0]
if (user && user.role === 'admin') {
return next()
}
// 群组公告:检查是否是群主
if (notification.type === 'group' && notification.group_id) {
const [groupRows] = await pool.query(
'SELECT created_by FROM `groups` WHERE id = ?',
[notification.group_id]
)
const group = (groupRows as any[])[0]
if (group && group.created_by === req.userId) {
return next()
}
}
return res.status(403).json({ code: 40300, message: '需要管理员或群主权限' })
}
/** 获取通知列表 */
@@ -34,13 +97,18 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url
`SELECT n.id, n.type, n.title, n.content, n.created_at, n.group_id,
n.is_pinned, n.is_urgent, n.publish_at, n.expire_at, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
${where}
ORDER BY n.is_pinned DESC, n.created_at DESC
LIMIT ? OFFSET ?`,
[...params, pSize, offset]
[req.userId, ...params, pSize, offset]
)
const [countResult] = await pool.query(
@@ -67,16 +135,24 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.get('/pinned', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT n.id, n.type, n.title, n.content, n.is_read, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url
FROM notifications n
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND n.is_urgent = 1 AND n.is_read = 0
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
ORDER BY n.created_at DESC
`SELECT * FROM (
SELECT n.id, n.type, n.title, n.content, n.created_at,
n.is_pinned, n.is_urgent, n.image_url, n.link_url,
CASE
WHEN n.type = 'personal' THEN n.is_read
ELSE CASE WHEN nr.notification_id IS NOT NULL THEN 1 ELSE 0 END
END as is_read
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND n.is_urgent = 1
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
) AS sub
WHERE is_read = 0
ORDER BY created_at DESC
LIMIT 5`,
[req.userId, req.userId]
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
@@ -89,12 +165,19 @@ router.get('/pinned', async (req: AuthRequest, res: Response) => {
router.get('/unread-count', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT COUNT(*) as count FROM notifications
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND is_read = 0
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
`SELECT COUNT(*) as count FROM (
SELECT n.id
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
AND (
(n.type = 'personal' AND n.is_read = 0)
OR (n.type != 'personal' AND nr.notification_id IS NULL)
)
) as unread`,
[req.userId, req.userId, req.userId]
)
res.json({ code: 0, data: { count: (rows as any[])[0].count } })
} catch (err) {
@@ -106,14 +189,29 @@ router.get('/unread-count', async (req: AuthRequest, res: Response) => {
/** 标记单条已读 */
router.put('/:id/read', async (req: AuthRequest, res: Response) => {
try {
const [result] = await pool.query(
`UPDATE notifications SET is_read = 1 WHERE id = ?
AND (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
// 先检查通知是否存在且用户可见
const [notiRows] = await pool.query(
`SELECT n.id, n.type, n.user_id FROM notifications n
WHERE n.id = ?
AND (n.user_id = ? OR (n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))`,
[req.params.id, req.userId, req.userId]
)
if ((result as any).affectedRows === 0) {
const notification = (notiRows as any[])[0]
if (!notification) {
return res.status(404).json({ code: 40400, message: '通知不存在' })
}
if (notification.type === 'personal' && notification.user_id === req.userId) {
// 个人通知:直接更新 is_read 字段
await pool.query('UPDATE notifications SET is_read = 1 WHERE id = ?', [req.params.id])
} else {
// 系统/群组公告:插入 notification_reads 记录IGNORE 避免重复)
await pool.query(
'INSERT IGNORE INTO notification_reads (notification_id, user_id) VALUES (?, ?)',
[req.params.id, req.userId]
)
}
res.json({ code: 0 })
} catch (err) {
console.error('[Notification] mark-read error:', err)
@@ -123,22 +221,73 @@ router.put('/:id/read', async (req: AuthRequest, res: Response) => {
/** 全部标记已读 */
router.put('/read-all', async (req: AuthRequest, res: Response) => {
const conn = await pool.getConnection()
try {
await pool.query(
await conn.beginTransaction()
// 1. 标记所有个人通知为已读
await conn.query(
`UPDATE notifications SET is_read = 1
WHERE (user_id = ? OR (type = 'system' AND user_id IS NULL) OR (type = 'group' AND group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND is_read = 0
WHERE user_id = ? AND type = 'personal' AND is_read = 0
AND (expire_at IS NULL OR expire_at > NOW())
AND (publish_at IS NULL OR publish_at <= NOW())`,
[req.userId, req.userId]
[req.userId]
)
// 2. 为所有未读的系统/群组公告插入已读记录
await conn.query(
`INSERT IGNORE INTO notification_reads (notification_id, user_id)
SELECT n.id, ?
FROM notifications n
LEFT JOIN notification_reads nr ON n.id = nr.notification_id AND nr.user_id = ?
WHERE ((n.type = 'system' AND n.user_id IS NULL) OR (n.type = 'group' AND n.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)))
AND (n.expire_at IS NULL OR n.expire_at > NOW())
AND (n.publish_at IS NULL OR n.publish_at <= NOW())
AND nr.notification_id IS NULL`,
[req.userId, req.userId, req.userId]
)
await conn.commit()
res.json({ code: 0 })
} catch (err) {
await conn.rollback()
console.error('[Notification] read-all error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
} finally {
conn.release()
}
})
/** 上传通知图片(管理员) */
router.post('/upload-image', requireAdmin, (req: AuthRequest, res: Response) => {
upload.single('file')(req, res, async (err) => {
if (err) {
return res.status(400).json({ code: 40001, message: err.message })
}
if (!req.file) {
return res.status(400).json({ code: 40001, message: '请选择图片文件' })
}
try {
res.json({ code: 0, data: { image_url: req.file.filename } })
} catch (err) {
console.error('[Notification] image upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
/** 验证 URL 安全性(防止 javascript: 等危险协议) */
function isValidUrl(url: string): boolean {
if (!url) return true // 空值允许
try {
const parsed = new URL(url)
return ['http:', 'https:'].includes(parsed.protocol)
} catch {
// 相对路径也允许
return url.startsWith('/') || url.startsWith('./')
}
}
/** 发布公告(管理员/群主) */
router.post('/', async (req: AuthRequest, res: Response) => {
try {
@@ -150,6 +299,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
if (title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
// 系统公告需要管理员权限
if (type === 'system') {
@@ -160,8 +315,11 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
}
// 群组公告需要群主权限
if (type === 'group' && group_id) {
// 群组公告需要 group_id 且需要群主权限
if (type === 'group') {
if (!group_id || typeof group_id !== 'number') {
return res.status(400).json({ code: 40001, message: '群组公告必须指定群组' })
}
const [groupRows] = await pool.query('SELECT created_by FROM `groups` WHERE id = ?', [group_id])
const group = (groupRows as any[])[0]
if (!group || group.created_by !== req.userId) {
@@ -194,11 +352,25 @@ router.post('/', async (req: AuthRequest, res: Response) => {
}
})
/** 编辑公告(管理员) */
router.put('/:id', requireAdmin, async (req: AuthRequest, res: Response) => {
/** 编辑公告(管理员或群主 */
router.put('/:id', requireNotificationEditAuth, async (req: AuthRequest, res: Response) => {
try {
const { title, content, is_pinned, is_urgent, publish_at, expire_at, image_url, link_url } = req.body
// 验证字段
if (title !== undefined && (typeof title !== 'string' || title.trim().length === 0)) {
return res.status(400).json({ code: 40001, message: '标题不能为空' })
}
if (title && title.length > 100) {
return res.status(400).json({ code: 40001, message: '标题不能超过100字' })
}
if (content && typeof content === 'string' && content.length > 5000) {
return res.status(400).json({ code: 40001, message: '内容不能超过5000字' })
}
if (link_url && !isValidUrl(link_url)) {
return res.status(400).json({ code: 40001, message: '链接格式无效' })
}
const updates: string[] = []
const params: any[] = []

View File

@@ -22,8 +22,9 @@ async function buildWhereClause(
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId, groupId)
let where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
// 群组视图:显示该群组所有记录(包括已退出成员的历史记录)
params.push(groupId)
let where = `WHERE t.group_id = ?`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
@@ -123,7 +124,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
${result.where}
GROUP BY c.id
GROUP BY c.id, c.name, c.icon, c.color
ORDER BY amount DESC`,
[...result.params, type, startDate, endDate]
)

View File

@@ -15,6 +15,7 @@ function safeInt(val: any, fallback: number, min: number, max: number): number {
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
// 用户可以查看:自己的记录 + 自己所在群组的所有记录(包括已退出成员的历史记录)
const [rows] = await pool.query(
`SELECT t.id, t.user_id, t.amount, t.type, t.category_id, t.note, DATE_FORMAT(t.date, '%Y-%m-%d') as date, t.created_at, t.updated_at, t.group_id,
c.name as category_name, c.icon as category_icon, c.color as category_color,
@@ -24,11 +25,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
LEFT JOIN users u ON t.user_id = u.id
WHERE t.id = ? AND (
t.user_id = ?
OR t.user_id IN (
SELECT gm2.user_id FROM group_members gm1
JOIN group_members gm2 ON gm1.group_id = gm2.group_id
WHERE gm1.user_id = ?
)
OR t.group_id IN (SELECT group_id FROM group_members WHERE user_id = ?)
)`,
[req.params.id, req.userId, req.userId]
)
@@ -54,7 +51,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const params: any[] = []
if (group_id && group_id !== 'null') {
// 群组视图:群组下所有成员的记录
// 群组视图:验证用户是群组成员,然后显示该群组所有记录(包括已退出成员的历史记录
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
@@ -62,8 +59,8 @@ router.get('/', async (req: AuthRequest, res: Response) => {
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = 'WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id, group_id)
where = 'WHERE t.group_id = ?'
params.push(group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
@@ -102,7 +99,9 @@ router.get('/', async (req: AuthRequest, res: Response) => {
}
}
if (keyword && typeof keyword === 'string' && keyword.trim()) {
where += ' AND t.note LIKE ?'; params.push(`%${keyword.trim()}%`)
// 转义 LIKE 通配符,防止 % 和 _ 被当作通配符
const escapedKeyword = keyword.trim().replace(/%/g, '\\%').replace(/_/g, '\\_')
where += ' AND t.note LIKE ?'; params.push(`%${escapedKeyword}%`)
}
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'

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('')
}