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