feat: 用户信息完善 + 一起记功能

- users 表添加 nickname、avatar_url 字段
- 用户信息 API(GET/PUT /me)+ 头像上传(multer)
- 头像通过 API 路由获取(公开,不经过 auth)
- 登录接口返回用户昵称和头像
- 用户信息 Store + 个人资料编辑页面
- 我的页面和首页显示真实用户信息
- 群组表(groups、group_members)+ transactions 添加 group_id
- 群组 API(创建/加入/退出/解散)
- 交易和统计 API 支持 group_id 视图切换
- 群组客户端 Store + 身份切换
- 群组管理页面
- App 初始化群组 Store
- UPLOAD_DIR 配置化
- Node.js 备份替代 mysqldump
- 统一前端 BASE_URL(config.ts)
- uploads 加入 .gitignore
- 修复 trust proxy 警告
This commit is contained in:
2026-06-03 17:04:22 +08:00
parent 9162b2c87c
commit 21f4d81959
45 changed files with 3145 additions and 348 deletions

View File

@@ -34,6 +34,55 @@ async function runMigrations(conn: mysql.Connection) {
await conn.query('ALTER TABLE budgets ADD UNIQUE KEY uk_user_month (user_id, month)')
console.log('[DB] budgets.user_id added')
}
// users 表添加 nickname 和 avatar_url
const hasNickname = await columnExists(conn, 'users', 'nickname')
if (!hasNickname) {
console.log('[DB] Migrating: adding nickname to users')
await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称'")
}
const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url')
if (!hasAvatarUrl) {
console.log('[DB] Migrating: adding avatar_url to users')
await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL'")
}
// 群组表 + transactions.group_id
const hasGroupId = await columnExists(conn, 'transactions', 'group_id')
console.log(`[DB] transactions.group_id exists: ${hasGroupId}`)
if (!hasGroupId) {
console.log('[DB] Migrating: creating groups tables and adding group_id')
await conn.query(`
CREATE TABLE IF NOT EXISTS \`groups\` (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
invite_code VARCHAR(10) UNIQUE NOT NULL,
created_by INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await conn.query(`
CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('owner', 'member') DEFAULT 'member',
nickname VARCHAR(50) DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_user (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES \`groups\`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await conn.query("ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组标签'")
await conn.query("ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)")
console.log('[DB] Groups migration complete')
}
}
export async function initDatabase() {

View File

@@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -28,10 +30,12 @@ CREATE TABLE IF NOT EXISTS transactions (
category_id INT,
note VARCHAR(200),
date DATE NOT NULL,
group_id INT DEFAULT NULL COMMENT '群组标签NULL=纯个人记录',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_date (user_id, date),
INDEX idx_user_type_date (user_id, type, date),
INDEX idx_group_date (group_id, date),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -46,3 +50,25 @@ CREATE TABLE IF NOT EXISTS budgets (
UNIQUE KEY uk_user_month (user_id, month),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `groups` (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '群组名称',
invite_code VARCHAR(10) UNIQUE NOT NULL COMMENT '邀请码',
created_by INT NOT NULL COMMENT '创建者',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS group_members (
id INT AUTO_INCREMENT PRIMARY KEY,
group_id INT NOT NULL,
user_id INT NOT NULL,
role ENUM('owner', 'member') DEFAULT 'member' COMMENT '角色',
nickname VARCHAR(50) DEFAULT '' COMMENT '群内昵称',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_group_user (group_id, user_id),
FOREIGN KEY (group_id) REFERENCES `groups`(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -2,6 +2,8 @@ import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import rateLimit from 'express-rate-limit'
import path from 'path'
import fs from 'fs'
import { authMiddleware } from './middleware/auth'
import { initDatabase } from './db/init'
import pool from './db/connection'
@@ -11,6 +13,8 @@ import statsRoutes from './routes/stats'
import budgetRoutes from './routes/budget'
import categoryRoutes from './routes/category'
import backupRoutes from './routes/backup'
import userRoutes from './routes/user'
import groupRoutes from './routes/group'
import { backupDatabase } from './utils/backup'
// Warn if token secret is using default fallback
@@ -22,6 +26,9 @@ const app = express()
const PORT = process.env.PORT || 3000
const ALLOWED_ORIGINS = (process.env.CORS_ORIGINS || '').split(',').filter(Boolean)
// 反向代理环境下信任 X-Forwarded-* 头(修复 rate-limit 警告)
app.set('trust proxy', 1)
app.use(cors({
origin: (origin, callback) => {
// No origin = WeChat mini-program request (always allow)
@@ -54,6 +61,42 @@ const apiLimiter = rateLimit({
legacyHeaders: false,
})
// 头像 API公开不经过 auth 中间件)
app.get('/api/user/avatar/:filename', async (req, res) => {
try {
const uploadDir = path.resolve(process.env.UPLOAD_DIR || './uploads')
const avatarDir = path.join(uploadDir, 'avatars')
const filename = path.basename(req.params.filename)
// 文件名格式校验:仅允许 数字_数字.ext
if (!/^\d+_\d+\.(jpg|jpeg|png|webp)$/i.test(filename)) {
return res.status(400).json({ code: 40001, message: '无效文件名' })
}
const filepath = path.join(avatarDir, filename)
// 路径穿越检查:确保解析后仍在 avatarDir 内
if (!path.resolve(filepath).startsWith(path.resolve(avatarDir))) {
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)
@@ -62,6 +105,8 @@ app.use('/api/stats', apiLimiter, statsRoutes)
app.use('/api/budget', apiLimiter, budgetRoutes)
app.use('/api/categories', apiLimiter, categoryRoutes)
app.use('/api/backup', apiLimiter, backupRoutes)
app.use('/api/user', apiLimiter, userRoutes)
app.use('/api/groups', apiLimiter, groupRoutes)
app.get('/api/health', async (_req, res) => {
try {
@@ -74,19 +119,25 @@ app.get('/api/health', async (_req, res) => {
let server: any
initDatabase().then(async () => {
// 每次启动时自动备份数据库
try {
await backupDatabase()
console.log('[Backup] 启动备份完成')
} catch (err) {
console.error('[Backup] 启动备份失败:', err)
// 生产环境:先备份再迁移,迁移出问题可恢复
async function start() {
if (process.env.NODE_ENV === 'production') {
try {
await backupDatabase()
console.log('[Backup] 迁移前备份完成')
} catch (err) {
console.error('[Backup] 备份失败:', err)
}
}
await initDatabase()
server = app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`)
})
})
}
start()
// Graceful shutdown
function shutdown(signal: string) {

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from 'express'
import { createHmac } from 'crypto'
import { createHmac, timingSafeEqual } from 'crypto'
export interface AuthRequest extends Request {
userId?: number
@@ -48,10 +48,12 @@ export function authMiddleware(req: AuthRequest, res: Response, next: NextFuncti
return res.status(401).json({ code: 40100, message: 'token无效' })
}
// Verify HMAC signature (full length)
// Verify HMAC signature (constant-time comparison)
const payload = `${userId}:${timestamp}`
const expectedSig = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
if (signature !== expectedSig) {
const sigBuf = Buffer.from(signature, 'hex')
const expectedBuf = Buffer.from(expectedSig, 'hex')
if (sigBuf.length !== expectedBuf.length || !timingSafeEqual(sigBuf, expectedBuf)) {
return res.status(401).json({ code: 40100, message: 'token无效' })
}

View File

@@ -24,7 +24,9 @@ router.post('/demo-login', async (_req: Request, res: Response) => {
)
const userId = (result as any).insertId
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
} catch (err: any) {
console.error('[Auth] Demo login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })
@@ -59,8 +61,9 @@ router.post('/login', async (req: Request, res: Response) => {
// HMAC signed token with expiry
const token = signToken(userId)
res.json({ code: 0, data: { token, userId } })
const [userRows] = await pool.query('SELECT nickname, avatar_url FROM users WHERE id = ?', [userId])
const userInfo = (userRows as any[])[0]
res.json({ code: 0, data: { token, userId, nickname: userInfo?.nickname || '小菜', avatar_url: userInfo?.avatar_url || '' } })
} catch (err: any) {
console.error('[Auth] Login failed:', err.message)
res.status(500).json({ code: 50000, message: '服务器错误' })

View File

@@ -12,14 +12,36 @@ function validateMonth(m: string): string {
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const month = validateMonth((req.query.month as string) || getCurrentMonth())
const group_id = req.query.group_id as string | undefined
const [rows] = await pool.query(
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
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]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
const budget = (rows as any[])[0] || null
res.json({ code: 0, data: budget })
const [rows] = await pool.query(
`SELECT COALESCE(SUM(amount), 0) 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 budget = (rows as any[])[0] || { amount: 0, month }
res.json({ code: 0, data: budget })
} else {
// 个人视图
const [rows] = await pool.query(
'SELECT * FROM budgets WHERE user_id = ? AND month = ?',
[req.userId, month]
)
const budget = (rows as any[])[0] || null
res.json({ code: 0, data: budget })
}
} catch (err) {
console.error('[Budget] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })

255
server/src/routes/group.ts Normal file
View File

@@ -0,0 +1,255 @@
import { Router, Response } from 'express'
import { randomInt } from 'crypto'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
/** 生成 6 位随机邀请码(密码学安全) */
function generateInviteCode(): string {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars.charAt(randomInt(chars.length))
}
return code
}
/** 创建群组 */
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { name } = req.body
const trimmedName = typeof name === 'string' ? name.trim() : ''
if (!trimmedName) {
return res.status(400).json({ code: 40001, message: '群组名称不能为空' })
}
if (trimmedName.length > 100) {
return res.status(400).json({ code: 40001, message: '群组名称不能超过100个字符' })
}
// 生成唯一邀请码
let inviteCode = generateInviteCode()
let attempts = 0
while (attempts < 10) {
const [existing] = await pool.query('SELECT id FROM `groups` WHERE invite_code = ?', [inviteCode])
if ((existing as any[]).length === 0) break
inviteCode = generateInviteCode()
attempts++
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
const [result] = await conn.query(
'INSERT INTO `groups` (name, invite_code, created_by) VALUES (?, ?, ?)',
[trimmedName, inviteCode, req.userId]
)
const groupId = (result as any).insertId
// 创建者自动成为 owner
await conn.query(
'INSERT INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
[groupId, req.userId, 'owner']
)
await conn.commit()
res.json({ code: 0, data: { id: groupId, invite_code: inviteCode } })
} catch (err) {
await conn.rollback()
throw err
} finally {
conn.release()
}
} catch (err) {
console.error('[Group] POST error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 获取用户的群组列表 */
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
`SELECT g.id, g.name, g.invite_code, g.created_by, g.created_at,
gm.role, gm.nickname as my_nickname,
(SELECT COUNT(*) FROM group_members WHERE group_id = g.id) as member_count
FROM \`groups\` g
INNER JOIN group_members gm ON g.id = gm.group_id AND gm.user_id = ?
ORDER BY g.created_at DESC`,
[req.userId]
)
res.json({ code: 0, data: rows })
} catch (err) {
console.error('[Group] GET error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 获取群组详情 + 成员列表 */
router.get('/:id', async (req: AuthRequest, res: Response) => {
try {
// 验证用户是否是群组成员
const [memberCheck] = await pool.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[req.params.id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
const [groupRows] = await pool.query(
'SELECT id, name, invite_code, created_by, created_at FROM `groups` WHERE id = ?',
[req.params.id]
)
const group = (groupRows as any[])[0]
if (!group) {
return res.status(404).json({ code: 40400, message: '群组不存在' })
}
// 获取成员列表
const [members] = await pool.query(
`SELECT gm.user_id, gm.role, gm.nickname, gm.created_at as joined_at,
u.nickname as user_nickname, u.avatar_url
FROM group_members gm
LEFT JOIN users u ON gm.user_id = u.id
WHERE gm.group_id = ?
ORDER BY gm.role DESC, gm.created_at ASC`,
[req.params.id]
)
res.json({ code: 0, data: { ...group, members } })
} catch (err) {
console.error('[Group] GET by ID error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 通过邀请码加入群组 */
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: '邀请码不能为空' })
}
const [groupRows] = await pool.query(
'SELECT id, name FROM `groups` WHERE invite_code = ?',
[invite_code.toUpperCase()]
)
const group = (groupRows as any[])[0]
if (!group) {
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 (?, ?, ?)',
[group.id, req.userId, 'member']
)
res.json({ code: 0, data: { group_id: group.id, name: group.name } })
} catch (err) {
console.error('[Group] join error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 退出群组 */
router.post('/:id/leave', async (req: AuthRequest, res: Response) => {
try {
const groupId = req.params.id
// 检查是否是成员
const [memberRows] = await pool.query(
'SELECT role FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, req.userId]
)
const member = (memberRows as any[])[0]
if (!member) {
return res.status(404).json({ code: 40400, message: '你不是该群组成员' })
}
// owner 不能退出,只能解散
if (member.role === 'owner') {
return res.status(400).json({ code: 40003, message: '群主不能退出,请先转让或解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该用户在该群组的记录标签
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()
}
} catch (err) {
console.error('[Group] leave error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 解散群组(仅 owner */
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const [groupRows] = await pool.query(
'SELECT created_by FROM `groups` WHERE id = ?',
[req.params.id]
)
const group = (groupRows as any[])[0]
if (!group) {
return res.status(404).json({ code: 40400, message: '群组不存在' })
}
if (group.created_by !== req.userId) {
return res.status(403).json({ code: 40300, message: '仅群主可解散群组' })
}
const conn = await pool.getConnection()
try {
await conn.beginTransaction()
// 清除该群组所有记录的标签
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()
}
} catch (err) {
console.error('[Group] DELETE error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
export default router

View File

@@ -6,19 +6,49 @@ import { getCurrentMonth, getMonthRange } from '../utils/date'
const router = Router()
const VALID_TYPES = ['expense', 'income']
/** 根据 group_id 参数构建 WHERE 条件(含成员验证) */
async function buildWhereClause(
userId: number | undefined,
groupId: string | undefined,
extraConditions: string[] = []
): Promise<{ where: string; params: any[] } | null> {
const params: any[] = []
if (groupId && groupId !== 'null') {
// 验证用户是群组成员
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[groupId, userId]
)
if ((memberCheck as any[]).length === 0) return null
params.push(groupId)
let where = `WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
params.push(userId)
let where = `WHERE t.user_id = ?`
if (extraConditions.length > 0) where += ' AND ' + extraConditions.join(' AND ')
return { where, params }
}
router.get('/overview', async (req: AuthRequest, res: Response) => {
try {
const { month, startDate: qStart, endDate: qEnd } = req.query
const { month, startDate: qStart, endDate: qEnd, group_id } = req.query
let startDate: string
let endDate: string
let elapsedDays = 1
if (qStart && qEnd) {
// 按日期范围查询(用于今日等场景)
startDate = qStart as string
endDate = qEnd as string
elapsedDays = 1
// 计算日期范围天数
const start = new Date(startDate)
const end = new Date(endDate)
elapsedDays = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / 86400000) + 1)
} else {
const m = (month as string) || getCurrentMonth()
const range = getMonthRange(m)
@@ -31,13 +61,20 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
elapsedDays = isCurrentMonth ? now.getDate() : new Date(year, mon, 0).getDate()
}
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0) as income,
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) as expense,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) as income,
COUNT(*) as count
FROM transactions WHERE user_id = ? AND date >= ? AND date <= ?`,
[req.userId, startDate, endDate]
FROM transactions t ${result.where}`,
[...result.params, startDate, endDate]
)
const row = (rows as any[])[0]
const daily = elapsedDays > 0 ? Math.round(row.expense / elapsedDays) : 0
@@ -54,7 +91,7 @@ router.get('/overview', async (req: AuthRequest, res: Response) => {
router.get('/category', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
@@ -63,14 +100,21 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT c.id, COALESCE(c.name, '未分类') as name, COALESCE(c.icon, '?') as icon, COALESCE(c.color, '#BFB3B3') as color, SUM(t.amount) as amount, COUNT(t.id) as count
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.user_id = ? AND t.type = ? AND t.date >= ? AND t.date <= ?
${result.where}
GROUP BY c.id
ORDER BY amount DESC`,
[req.userId, type, startDate, endDate]
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })
@@ -82,7 +126,7 @@ router.get('/category', async (req: AuthRequest, res: Response) => {
router.get('/trend', async (req: AuthRequest, res: Response) => {
try {
const { month, type = 'expense' } = req.query
const { month, type = 'expense', group_id } = req.query
if (!VALID_TYPES.includes(type as string)) {
return res.status(400).json({ code: 40002, message: '类型无效' })
}
@@ -91,13 +135,20 @@ router.get('/trend', async (req: AuthRequest, res: Response) => {
if (!range) return res.status(400).json({ code: 40001, message: '月份格式无效' })
const { startDate, endDate } = range
const result = await buildWhereClause(
req.userId,
group_id as string | undefined,
['t.type = ?', 't.date >= ?', 't.date <= ?']
)
if (!result) return res.status(403).json({ code: 40300, message: '无权访问此群组' })
const [rows] = await pool.query(
`SELECT DATE_FORMAT(date, '%Y-%m-%d') as date, SUM(amount) as amount
FROM transactions
WHERE user_id = ? AND type = ? AND date >= ? AND date <= ?
GROUP BY date
ORDER BY date`,
[req.userId, type, startDate, endDate]
`SELECT DATE_FORMAT(t.date, '%Y-%m-%d') as date, SUM(t.amount) as amount
FROM transactions t
${result.where}
GROUP BY t.date
ORDER BY t.date`,
[...result.params, type, startDate, endDate]
)
res.json({ code: 0, data: rows })

View File

@@ -16,12 +16,21 @@ 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,
c.name as category_name, c.icon as category_icon, c.color as category_color
`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
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
WHERE t.id = ? AND t.user_id = ?`,
[req.params.id, req.userId]
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 = ?
)
)`,
[req.params.id, req.userId, req.userId]
)
const tx = (rows as any[])[0]
if (!tx) {
@@ -36,13 +45,30 @@ router.get('/:id', async (req: AuthRequest, res: Response) => {
router.get('/', async (req: AuthRequest, res: Response) => {
try {
const { page, pageSize, type, startDate, endDate, sortBy } = req.query
const { page, pageSize, type, startDate, endDate, sortBy, group_id } = req.query
const pSize = safeInt(pageSize, 20, 1, 100)
const pNum = safeInt(page, 1, 1, 99999)
const offset = (pNum - 1) * pSize
let where = 'WHERE t.user_id = ?'
const params: any[] = [req.userId]
let where = ''
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]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = 'WHERE t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)'
params.push(group_id)
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
params.push(req.userId)
}
if (type && VALID_TYPES.includes(type as string)) {
where += ' AND t.type = ?'; params.push(type)
@@ -57,10 +83,12 @@ router.get('/', async (req: AuthRequest, res: Response) => {
const orderBy = sortBy === 'amount' ? 't.amount DESC' : 't.date DESC, t.created_at DESC'
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,
c.name as category_name, c.icon as category_icon, c.color as category_color
`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
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN users u ON t.user_id = u.id
${where}
ORDER BY ${orderBy}
LIMIT ? OFFSET ?`,
@@ -89,7 +117,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
router.post('/', async (req: AuthRequest, res: Response) => {
try {
const { amount, type, category_id, note, date } = req.body
const { amount, type, category_id, note, date, group_id } = req.body
if (amount === undefined || amount === null || typeof amount !== 'number' || !Number.isInteger(amount) || amount <= 0 || amount > 999999999) {
return res.status(400).json({ code: 40001, message: '金额必须为1-999999999之间的正整数单位' })
}
@@ -106,9 +134,21 @@ router.post('/', async (req: AuthRequest, res: Response) => {
return res.status(400).json({ code: 40004, message: '备注不能超过200字' })
}
// 如果指定了 group_id验证类型和用户是群组成员
const validGroupId = group_id && typeof group_id === 'number' && Number.isInteger(group_id) ? group_id : null
if (validGroupId) {
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[validGroupId, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权在此群组记账' })
}
}
const [result] = await pool.query(
'INSERT INTO transactions (user_id, amount, type, category_id, note, date) VALUES (?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date]
'INSERT INTO transactions (user_id, amount, type, category_id, note, date, group_id) VALUES (?, ?, ?, ?, ?, ?, ?)',
[req.userId, amount, type, category_id || null, note || null, date, validGroupId]
)
res.json({ code: 0, data: { id: (result as any).insertId } })

133
server/src/routes/user.ts Normal file
View File

@@ -0,0 +1,133 @@
import { Router, Response } from 'express'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
import pool from '../db/connection'
import { AuthRequest } from '../middleware/auth'
const router = Router()
// 头像上传配置
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'
const AVATAR_DIR = path.resolve(UPLOAD_DIR, 'avatars')
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
// 确保目录存在(可能被手动删除)
if (!fs.existsSync(AVATAR_DIR)) {
fs.mkdirSync(AVATAR_DIR, { recursive: true })
}
cb(null, AVATAR_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, `${req.userId}_${Date.now()}${ext}`)
}
})
const upload = multer({
storage,
limits: { fileSize: 2 * 1024 * 1024 }, // 最大 2MB
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 格式'))
}
}
})
/** 获取当前用户信息 */
router.get('/me', async (req: AuthRequest, res: Response) => {
try {
const [rows] = await pool.query(
'SELECT id, nickname, avatar_url, created_at FROM users WHERE id = ?',
[req.userId]
)
const user = (rows as any[])[0]
if (!user) {
return res.status(404).json({ code: 40400, message: '用户不存在' })
}
res.json({ code: 0, data: user })
} catch (err) {
console.error('[User] GET /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 更新用户昵称 */
router.put('/me', async (req: AuthRequest, res: Response) => {
try {
const { nickname } = req.body
if (nickname !== undefined) {
if (typeof nickname !== 'string' || nickname.trim().length === 0) {
return res.status(400).json({ code: 40001, message: '昵称不能为空' })
}
if (nickname.length > 50) {
return res.status(400).json({ code: 40001, message: '昵称不能超过50个字符' })
}
}
const updates: string[] = []
const params: any[] = []
if (nickname !== undefined) {
updates.push('nickname = ?')
params.push(nickname.trim())
}
if (updates.length === 0) {
return res.status(400).json({ code: 40001, message: '没有需要更新的字段' })
}
params.push(req.userId)
await pool.query(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`, params)
res.json({ code: 0 })
} catch (err) {
console.error('[User] PUT /me error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
/** 上传头像 */
router.post('/avatar', (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 {
// 先查询旧头像
const [rows] = await pool.query('SELECT avatar_url FROM users WHERE id = ?', [req.userId])
const oldUrl = (rows as any[])[0]?.avatar_url
// 先更新 DB再删旧文件保证 DB 和文件至少有一个正确)
await pool.query('UPDATE users SET avatar_url = ? WHERE id = ?', [req.file.filename, req.userId])
if (oldUrl) {
const oldPath = path.join(AVATAR_DIR, oldUrl)
if (fs.existsSync(oldPath)) {
fs.unlinkSync(oldPath)
}
}
res.json({ code: 0, data: { avatar_url: req.file.filename } })
} catch (err) {
console.error('[User] avatar upload error:', err)
res.status(500).json({ code: 50000, message: '服务器错误' })
}
})
})
export default router

View File

@@ -1,21 +1,18 @@
import { execFile } from 'child_process'
import { promisify } from 'util'
import path from 'path'
import fs from 'fs'
import zlib from 'zlib'
import pool from '../db/connection'
const execFileAsync = promisify(execFile)
const gzipAsync = promisify(zlib.gzip)
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
const MAX_BACKUPS = 7 // 保留最近 7 天的备份
/**
* 执行数据库备份
* @returns 备份文件路径
* 使用 Node.js mysql2 备份数据库(不依赖 mysqldump
* 导出所有表的 INSERT 语句gzip 压缩存储
*/
export async function backupDatabase(): Promise<string> {
// 确保备份目录存在
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true })
}
@@ -24,64 +21,64 @@ export async function backupDatabase(): Promise<string> {
const filename = `xiaocai_${date}.sql.gz`
const filepath = path.join(BACKUP_DIR, filename)
const dbHost = process.env.DB_HOST || 'localhost'
const dbUser = process.env.DB_USER || 'root'
const dbPassword = process.env.DB_PASSWORD || ''
const dbName = process.env.DB_NAME || 'xiaocai'
const lines: string[] = []
lines.push(`-- 小菜记账 数据库备份`)
lines.push(`-- 时间: ${new Date().toISOString()}`)
lines.push('')
try {
// 使用 execFile 避免命令注入,参数通过数组传递
const args = [
`-h${dbHost}`,
`-u${dbUser}`,
...(dbPassword ? [`-p${dbPassword}`] : []),
dbName
]
// 获取所有表名
const [tables] = await pool.query('SHOW TABLES')
const tableNames = (tables as any[]).map(row => Object.values(row)[0] as string)
const { stdout } = await execFileAsync('mysqldump', args, { maxBuffer: 50 * 1024 * 1024 })
for (const tableName of tableNames) {
lines.push(`-- ----------------------------`)
lines.push(`-- 表: ${tableName}`)
lines.push(`-- ----------------------------`)
// 使用 Node.js zlib 压缩
const compressed = await gzipAsync(Buffer.from(stdout, 'utf-8'))
fs.writeFileSync(filepath, compressed)
// 获取表数据
const [rows] = await pool.query(`SELECT * FROM \`${tableName}\``)
const data = rows as any[]
console.log(`[Backup] 完成: ${filename}`)
// 清理旧备份
await cleanOldBackups()
return filepath
} catch (err) {
console.error('[Backup] 失败:', err)
throw err
}
}
/**
* 清理过期的备份文件
*/
async function cleanOldBackups(): Promise<void> {
try {
if (!fs.existsSync(BACKUP_DIR)) return
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.startsWith('xiaocai_') && (f.endsWith('.sql.gz') || f.endsWith('.sql')))
.sort()
.reverse()
// 删除超出保留数量的文件
for (let i = MAX_BACKUPS; i < files.length; i++) {
const filepath = path.join(BACKUP_DIR, files[i])
fs.unlinkSync(filepath)
console.log(`[Backup] 清理旧备份: ${files[i]}`)
if (data.length === 0) {
lines.push(`-- ${tableName}: 无数据`)
lines.push('')
continue
}
} catch (err) {
console.error('[Backup] 清理失败:', err)
// 获取列名
const columns = Object.keys(data[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 ')
lines.push(`INSERT INTO \`${tableName}\` (${colList}) VALUES`)
lines.push(` ${values};`)
}
lines.push('')
}
const sql = lines.join('\n')
const compressed = await gzipAsync(Buffer.from(sql, 'utf-8'))
fs.writeFileSync(filepath, compressed)
console.log(`[Backup] 完成: ${filename} (${tableNames.length} 表)`)
return filepath
}
/**
* 获取备份列表
*/
export function getBackupList(): Array<{ name: string; size: number; date: string }> {
if (!fs.existsSync(BACKUP_DIR)) return []