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

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