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:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
server/src/middleware/requireAdmin.ts
Normal file
18
server/src/middleware/requireAdmin.ts
Normal 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: '服务器错误' })
|
||||
}
|
||||
}
|
||||
@@ -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: '用户不存在' })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: '服务器错误' })
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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('')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user