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:
133
server/src/routes/user.ts
Normal file
133
server/src/routes/user.ts
Normal 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
|
||||
Reference in New Issue
Block a user