- S1: .githooks/pre-commit blocks .env commits, .env.test in .gitignore - S3: TOKEN_SECRET centralized to config/token.ts, all files import from it - S3: checkTokenSecret enforces min 32-char TOKEN_SECRET at startup - S5: Refresh Token mechanism (Access 2h + Refresh 30d, dual token) - S5: POST /auth/refresh endpoint with rotation and max 5 per user - S5: Frontend request.ts auto-refresh on 40101, concurrent-safe - S6: Soft delete (deleted_at column), admin soft-delete with confirmName - S6: POST /admin/users/:id/restore endpoint - S6: All user queries filter deleted_at IS NULL - S7: DB connection requires env vars, no fallback credentials - Infra: jest.config.js, vitest.config.ts, .env.test - Infra: node-cron, jest, ts-jest, supertest, vitest, @pinia/testing
300 lines
9.8 KiB
TypeScript
300 lines
9.8 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++
|
||
}
|
||
if (attempts >= 10) {
|
||
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
|
||
}
|
||
|
||
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: '邀请码无效' })
|
||
}
|
||
|
||
// 检查群组是否已解散(没有任何成员)
|
||
const [memberCount] = await pool.query(
|
||
'SELECT COUNT(*) as count FROM group_members WHERE group_id = ?',
|
||
[group.id]
|
||
)
|
||
if ((memberCount as any[])[0].count === 0) {
|
||
return res.status(400).json({ code: 40003, 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: '群主不能退出,请先转让或解散群组' })
|
||
}
|
||
|
||
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||
await pool.query(
|
||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||
[groupId, req.userId]
|
||
)
|
||
|
||
res.json({ code: 0 })
|
||
} catch (err) {
|
||
console.error('[Group] leave error:', err)
|
||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
/** 解散群组(仅 owner) — 保留群组记录和 transactions.group_id,仅移除所有成员 */
|
||
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: '仅群主可解散群组' })
|
||
}
|
||
|
||
// 移除所有成员(保留群组记录和 transactions.group_id,历史数据不丢失)
|
||
await pool.query('DELETE FROM group_members WHERE group_id = ?', [req.params.id])
|
||
|
||
res.json({ code: 0 })
|
||
} 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++
|
||
}
|
||
if (attempts >= 10) {
|
||
return res.status(500).json({ code: 50001, message: '生成邀请码失败,请重试' })
|
||
}
|
||
|
||
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 AND u.deleted_at IS NULL
|
||
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: '仅群主可移除成员' })
|
||
}
|
||
|
||
// 删除成员关系(保留 transactions.group_id,历史记录仍属于该群组)
|
||
await pool.query(
|
||
'DELETE FROM group_members WHERE group_id = ? AND user_id = ?',
|
||
[groupId, targetUserId]
|
||
)
|
||
|
||
res.json({ code: 0 })
|
||
} catch (err) {
|
||
console.error('[Group] remove member error:', err)
|
||
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||
}
|
||
})
|
||
|
||
export default router
|