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

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