fix: 全面修复 22 项问题
严重 Bug: - NaN 金额校验绕过(add 页面 parseFloat 空字符串) - 4 个页面补充 waitForReady(add/budget/category-manage/group-manage) - 导出 Loading 遮罩不消失(profile exportData 缺 hideLoading) - Store 吞掉请求错误不 re-throw(transaction/stats/budget/user) - 分类排序 SQL 拼接改参数化查询(category PUT /sort) - 备份接口添加管理员权限检查(backup requireAdmin) 中等问题: - 群组加入竞态条件改为 INSERT IGNORE - 统计页切月/切Tab添加 loadSeq 防竞态 - stats store 补全 loading 状态 + Overview 初始值补全 7 字段 - 保存成功后返回不再弹「放弃修改」确认框 - 群组管理创建/加入添加 saving 双重提交保护 - 预算页添加 onShow 刷新 - 服务端输入校验(name/color/invite_code/微信code) UI/样式: - 触摸目标: bell/nav-back/nav-save→88rpx, avatar-badge→64rpx, action-btn→80rpx - :active 反馈: bell/profile-card/identity-card/id-manage/arrow/action-btn/group-btn/cat-info - 内联样式改动态绑定(4处 spacer 使用 capsuleRight) - FAB 文字+改用 Icon 组件 - prefers-reduced-motion 动画降级(Skeleton/profile/budget/add) - 胶囊适配(add/category-manage/group-manage) - CategoryIcon !important 改为 iconStyle 计算属性 文档: - CLAUDE.md 补充 SaveSuccess/ChartWrapper 组件 + category/budget/index API
This commit is contained in:
@@ -36,8 +36,8 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { code } = req.body
|
||||
if (!code) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少code参数' })
|
||||
if (!code || typeof code !== 'string' || code.length > 128 || !/^[a-zA-Z0-9_-]+$/.test(code)) {
|
||||
return res.status(400).json({ code: 40001, message: 'code参数无效' })
|
||||
}
|
||||
|
||||
// 调用微信 code2session 接口
|
||||
|
||||
@@ -4,8 +4,23 @@ import { backupDatabase, getBackupList } from '../utils/backup'
|
||||
|
||||
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.post('/', async (_req: AuthRequest, res: Response) => {
|
||||
router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
if (!requireAdmin(req, res)) return
|
||||
try {
|
||||
const filepath = await backupDatabase()
|
||||
res.json({ code: 0, data: { filepath } })
|
||||
@@ -16,7 +31,8 @@ router.post('/', async (_req: AuthRequest, res: Response) => {
|
||||
})
|
||||
|
||||
// 获取备份列表
|
||||
router.get('/', 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 })
|
||||
|
||||
@@ -24,6 +24,12 @@ router.post('/', async (req: AuthRequest, res: Response) => {
|
||||
if (!name || !icon || !color || !type) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||
}
|
||||
if (typeof name !== 'string' || name.length > 50) {
|
||||
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
|
||||
}
|
||||
if (typeof color !== 'string' || color.length > 20) {
|
||||
return res.status(400).json({ code: 40001, message: '颜色值无效' })
|
||||
}
|
||||
if (!['expense', 'income'].includes(type)) {
|
||||
return res.status(400).json({ code: 40002, message: '类型无效' })
|
||||
}
|
||||
@@ -45,6 +51,12 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
|
||||
if (!name || !color) {
|
||||
return res.status(400).json({ code: 40001, message: '缺少必要参数' })
|
||||
}
|
||||
if (typeof name !== 'string' || name.length > 50) {
|
||||
return res.status(400).json({ code: 40001, message: '分类名称不能超过50字' })
|
||||
}
|
||||
if (typeof color !== 'string' || color.length > 20) {
|
||||
return res.status(400).json({ code: 40001, message: '颜色值无效' })
|
||||
}
|
||||
const [result] = await pool.query(
|
||||
'UPDATE categories SET name = ?, color = ? WHERE id = ? AND is_custom = 1 AND user_id = ?',
|
||||
[name, color, req.params.id, req.userId]
|
||||
@@ -121,16 +133,21 @@ router.post('/:id/migrate', async (req: AuthRequest, res: Response) => {
|
||||
router.put('/sort', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { ids } = req.body // [id1, id2, id3, ...]
|
||||
if (!Array.isArray(ids) || ids.length === 0) {
|
||||
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 cases = ids.map((id: number, index: number) => `WHEN ${Number(id)} THEN ${index}`).join(' ')
|
||||
const idList = ids.map((id: number) => Number(id)).join(',')
|
||||
// 使用参数化查询构建 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 ${cases} END WHERE id IN (${idList}) AND (user_id = 0 OR user_id = ?)`,
|
||||
[req.userId]
|
||||
`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) {
|
||||
|
||||
@@ -129,8 +129,8 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
router.post('/join', async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { invite_code } = req.body
|
||||
if (!invite_code || typeof invite_code !== 'string') {
|
||||
return res.status(400).json({ code: 40001, message: '邀请码不能为空' })
|
||||
if (!invite_code || typeof invite_code !== 'string' || invite_code.length > 10) {
|
||||
return res.status(400).json({ code: 40001, message: '邀请码无效' })
|
||||
}
|
||||
|
||||
const [groupRows] = await pool.query(
|
||||
@@ -142,19 +142,14 @@ router.post('/join', async (req: AuthRequest, res: Response) => {
|
||||
return res.status(404).json({ code: 40400, message: '邀请码无效' })
|
||||
}
|
||||
|
||||
// 检查是否已是成员
|
||||
const [existing] = await pool.query(
|
||||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||||
[group.id, req.userId]
|
||||
)
|
||||
if ((existing as any[]).length > 0) {
|
||||
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
|
||||
const [result] = await pool.query(
|
||||
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||||
[group.id, req.userId, 'member']
|
||||
)
|
||||
if ((result as any).affectedRows === 0) {
|
||||
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
||||
}
|
||||
|
||||
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
|
||||
} catch (err) {
|
||||
|
||||
@@ -18,7 +18,7 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
|
||||
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,
|
||||
u.nickname as creator_nickname
|
||||
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
@@ -85,7 +85,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
||||
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,
|
||||
u.nickname as creator_nickname
|
||||
u.nickname as creator_nickname, u.avatar_url as creator_avatar
|
||||
FROM transactions t
|
||||
LEFT JOIN categories c ON t.category_id = c.id
|
||||
LEFT JOIN users u ON t.user_id = u.id
|
||||
|
||||
Reference in New Issue
Block a user