- 创建/加入群组合并为 Tab 切换卡片 - 群主可刷新邀请码(后端+前端完整链路) - 群主可管理成员:查看列表、移除成员 - 新增后端 API:refresh-code / members / remove-member - 修复分类管理页 FAB 按钮缺少 plus 图标 - 新增 plus/refresh 图标资源
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
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.post('/join', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const { invite_code } = req.body
|
||
if (!invite_code || typeof invite_code !== 'string' || invite_code.length > 10) {
|
||
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: '邀请码无效' })
|
||
}
|
||
|
||
// 使用 INSERT IGNORE 避免并发加入时的竞态条件
|
||
const [result] = await pool.query(
|
||
'INSERT IGNORE INTO group_members (group_id, user_id, role) VALUES (?, ?, ?)',
|
||
[group.id, req.userId, 'member']
|
||
)
|
||
if ((result as any).affectedRows === 0) {
|
||
return res.status(400).json({ code: 40002, message: '你已经是该群组成员' })
|
||
}
|
||
|
||
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: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
/** 刷新邀请码(仅 owner) */
|
||
router.post('/:id/refresh-code', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const groupId = req.params.id
|
||
|
||
// 验证是否是群主
|
||
const [groupRows] = await pool.query(
|
||
'SELECT created_by FROM `groups` WHERE id = ?',
|
||
[groupId]
|
||
)
|
||
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: '仅群主可刷新邀请码' })
|
||
}
|
||
|
||
// 生成新的唯一邀请码
|
||
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++
|
||
}
|
||
|
||
await pool.query('UPDATE `groups` SET invite_code = ? WHERE id = ?', [inviteCode, groupId])
|
||
res.json({ code: 0, data: { invite_code: inviteCode } })
|
||
} catch (err) {
|
||
console.error('[Group] refresh-code error:', err)
|
||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
/** 获取成员列表 */
|
||
router.get('/:id/members', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const groupId = req.params.id
|
||
|
||
// 验证用户是否是群组成员
|
||
const [memberCheck] = await pool.query(
|
||
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
|
||
[groupId, req.userId]
|
||
)
|
||
if ((memberCheck as any[]).length === 0) {
|
||
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
|
||
}
|
||
|
||
const [members] = await pool.query(
|
||
`SELECT gm.user_id, gm.role, gm.created_at as joined_at,
|
||
u.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`,
|
||
[groupId]
|
||
)
|
||
|
||
res.json({ code: 0, data: members })
|
||
} catch (err) {
|
||
console.error('[Group] members error:', err)
|
||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
/** 移除成员(仅 owner) */
|
||
router.delete('/:id/members/:userId', async (req: AuthRequest, res: Response) => {
|
||
try {
|
||
const groupId = req.params.id
|
||
const targetUserId = Number(req.params.userId)
|
||
|
||
// 不能移除自己
|
||
if (targetUserId === req.userId) {
|
||
return res.status(400).json({ code: 40001, message: '不能移除自己' })
|
||
}
|
||
|
||
// 验证是否是群主
|
||
const [groupRows] = await pool.query(
|
||
'SELECT created_by FROM `groups` WHERE id = ?',
|
||
[groupId]
|
||
)
|
||
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 user_id = ? AND group_id = ?',
|
||
[targetUserId, groupId]
|
||
)
|
||
|
||
// 删除成员关系
|
||
await conn.query(
|
||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||
[groupId, targetUserId]
|
||
)
|
||
|
||
await conn.commit()
|
||
res.json({ code: 0 })
|
||
} catch (err) {
|
||
await conn.rollback()
|
||
throw err
|
||
} finally {
|
||
conn.release()
|
||
}
|
||
} catch (err) {
|
||
console.error('[Group] remove member error:', err)
|
||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
export default router
|