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

@@ -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) {