feat(meeting): 添加学生列表排序功能

- 实现学生列表拖拽排序并保存到 Redis
- 添加 WebSocket 消息类型支持学生排序变更
- 在教师端界面添加学生列表排序按钮和对话框
- 修改数据传输方式以支持排序信息同步
- 更新 API 接口以传递房间 ID 获取排序数据
- 添加 Redis 存储学生排序顺序的功能
This commit is contained in:
2026-03-14 18:11:31 +08:00
parent ff6dde5f4d
commit 3686bf1c1f
12 changed files with 530 additions and 63 deletions

View File

@ -1,18 +1,14 @@
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
@Injectable()
export class NonEmptyStringPipe implements PipeTransform<string, string> {
public constructor(
private readonly name?: string,
private readonly code: 422 | 423 = 422
) {}
/**
* 静态 transform 方法 (用于装饰器)
* @param value - 待验证的值
*/
public transform(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) {
const message = `${this.name ?? '参数'}不能为空`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
throw new UnprocessableEntityException('参数不能为空');
}
return value.trim();
}

View File

@ -1,43 +1,25 @@
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
import { Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
@Injectable()
export class Uint32Pipe implements PipeTransform<string, number> {
public constructor(
private readonly name?: string,
private readonly code: 422 | 423 = 422
) {}
/**
* 静态 transform 方法 (用于装饰器)
* @param value - 待验证的值
*/
public transform(value: unknown): number {
const field = this.name ?? '参数';
if (value === undefined || value === null || value === '') {
const message = `${field}不能为空`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
throw new UnprocessableEntityException('参数不能为空');
}
if ((typeof value === 'string' && !/^\d+$/.test(value)) || isNaN(Number(value))) {
const message = `${field}必须是数字`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
throw new UnprocessableEntityException('参数必须是数字');
}
const num = Number(value);
if (!Number.isSafeInteger(num)) {
const message = `${field}超出安全整数范围`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
throw new UnprocessableEntityException('参数超出安全整数范围');
} else if (num < 0 || num > 4294967295) {
const message = `${field}必须在0到4294967295之间`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
throw new UnprocessableEntityException('参数必须在 0 到 4294967295 之间');
}
return num;
}

View File

@ -1,5 +1,5 @@
import { defineConfig } from '@mikro-orm/mysql';
import { MeetingUser } from '../modules/meeting/entities/meeting-user.entity';
import { MeetingUser } from '../entities/MeetingUser';
const config = defineConfig({
dbName: process.env.DB_NAME || 'scs',

View File

@ -1,12 +1,13 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { ApiParam, ApiProperty, ApiQuery, ApiTags } from '@nestjs/swagger';
import { FailResult, OkResult } from '../../common/dto/result.dto';
import { MeetingService } from './meeting.service';
import { NonEmptyStringPipe } from '@/common/pipes/non-empty-string.pipe';
import { Uint32Pipe } from '@/common/pipes/uint32.pipe';
import { ApiCustomOkResponse } from '@/common/decorators/swagger.decorator';
import { StudentListResponseDto, TokenResponseDto } from './meeting.dto';
import { SaveStudentOrderDto, StudentListResponseDto, TokenResponseDto } from './meeting.dto';
import { MeetingRedisService } from '../websocket/meeting-redis.service';
import { MeetingWebSocketGateway } from '../websocket/meeting.websocket';
/**
* 黑名单用户 DTO
@ -28,7 +29,8 @@ class BlacklistUserDto {
export class MeetingController {
public constructor(
private readonly meetingService: MeetingService,
private readonly redis: MeetingRedisService
private readonly redis: MeetingRedisService,
private readonly websocketGateway: MeetingWebSocketGateway // 注入 WebSocket Gateway
) {}
/**
@ -44,7 +46,7 @@ export class MeetingController {
})
@ApiParam({ name: 'channelName', description: '需要加入的频道名(格式: `n_课程名称`)', example: 'n_1234567890' })
@ApiParam({ name: 'uid', description: '用户的短 UID(0~4294967295)', example: '1001' })
public async getToken(@Param('channelName', new NonEmptyStringPipe('channelName')) channelName: string, @Param('uid', new Uint32Pipe('uid')) uid: number) {
public async getToken(@Param('channelName', NonEmptyStringPipe) channelName: string, @Param('uid', Uint32Pipe) uid: number) {
const result = this.meetingService.generateToken(channelName, uid);
if (!result) {
return new FailResult('生成 Token 失败');
@ -64,7 +66,7 @@ export class MeetingController {
apiDescription: '房间黑名单(包含短 UID 和名称的数组)',
resDescription: '房间黑名单(包含短 UID 和名称的数组)',
})
public async getBlacklist(@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string) {
public async getBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string) {
const list = await this.redis.getBlacklist(roomId);
return new OkResult(list);
}
@ -82,10 +84,7 @@ export class MeetingController {
apiDescription: '从房间黑名单移除指定用户(短 UID',
resDescription: '是否成功移除用户',
})
public async removeFromBlacklist(
@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string,
@Param('shortUid', new Uint32Pipe('shortUid')) shortUid: number
) {
public async removeFromBlacklist(@Param('roomId', NonEmptyStringPipe) roomId: string, @Param('shortUid', Uint32Pipe) shortUid: number) {
await this.redis.removeFromBlacklist(roomId, shortUid);
return new OkResult(true);
}
@ -93,20 +92,57 @@ export class MeetingController {
/**
* 通过作业 ID 查询学生详细信息列表
*/
@Get('students/:homeworkId')
@Get('students')
@HttpCode(HttpStatus.OK)
@ApiParam({ name: 'homeworkId', description: '作业 ID', example: '1234567890' })
@ApiQuery({ name: 'homeworkId', description: '作业 ID', example: '1234567890' })
@ApiQuery({ name: 'roomId', description: '房间 ID (可选,用于获取 Redis 中的排序)', required: false, example: 'n_1234567890' })
@ApiCustomOkResponse({
summary: '学生详细信息列表',
model: StudentListResponseDto,
apiDescription: '学生详细信息列表,包含长 ID、短 ID (可能为 null) 和姓名',
resDescription: '学生详细信息列表',
})
public async getStudentDetails(@Param('homeworkId', new Uint32Pipe('homeworkId')) homeworkId: number) {
const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId);
public async getStudentDetails(@Query('homeworkId', ParseIntPipe) homeworkId: number, @Query('roomId', NonEmptyStringPipe) roomId?: string) {
const students = await this.meetingService.getStudentDetailsByHomeworkId(homeworkId, roomId);
return new OkResult({
total: students.length,
students,
});
}
/**
* 保存学生排序顺序
*/
@Post('student-order')
@HttpCode(HttpStatus.OK)
@ApiCustomOkResponse({
summary: '保存学生排序顺序',
model: Boolean,
apiDescription: '保存学生排序顺序到 Redis 并发送给监控端',
resDescription: '是否成功保存',
})
public async saveStudentOrder(@Body() dto: SaveStudentOrderDto) {
// 1. 保存到 Redis
await this.meetingService.saveStudentOrder(dto.roomId, dto.studentLongIds);
// 2. 获取房间内所有 socket 并过滤出监控端 (monitor 模式)
const ns = this.websocketGateway.getServer();
if (ns) {
const sockets = await ns.in(dto.roomId).fetchSockets();
const monitorSocketIds = sockets.filter((socket) => socket.data.joinMode === 'monitor').map((socket) => socket.id);
if (monitorSocketIds.length > 0) {
// 发送给所有监控端的 socket
ns.to(monitorSocketIds).emit('message', {
type: 'sev_student_order_changed',
data: {
fromRoomId: dto.roomId,
studentLongIds: dto.studentLongIds,
},
});
}
}
return new OkResult(true);
}
}

View File

@ -38,3 +38,14 @@ export class StudentListResponseDto {
@ApiProperty({ description: '学生列表', type: [StudentDetailResponseDto] })
students!: StudentDetailResponseDto[];
}
/**
* 保存学生排序请求 DTO
*/
export class SaveStudentOrderDto {
@ApiProperty({ description: '房间 ID', example: 'room_123' })
roomId!: string;
@ApiProperty({ description: '学生长 ID 数组 (按排序顺序)', example: [1234567890, 1234567891, 1234567892] })
studentLongIds!: number[];
}

View File

@ -4,6 +4,7 @@ import { NacosConfigService } from '../../plugins/nacos/nacos-config.service';
import { EntityManager } from '@mikro-orm/core';
import type { TokenResponseDto } from './meeting.dto';
import { MeetingUser } from '@/entities/MeetingUser';
import { MeetingRedisService } from '../websocket/meeting-redis.service';
/**
* 学生信息接口
@ -31,7 +32,8 @@ export interface StudentDetailInfo {
export class MeetingService {
constructor(
private readonly em: EntityManager,
private readonly nacosConfig: NacosConfigService
private readonly nacosConfig: NacosConfigService,
private readonly redis: MeetingRedisService
) {}
/**
@ -39,7 +41,7 @@ export class MeetingService {
* @param homeworkId - 作业 ID
* @returns 学生详细信息列表,包含长 ID、短 ID 和姓名
*/
public async getStudentDetailsByHomeworkId(homeworkId: number): Promise<StudentDetailInfo[]> {
public async getStudentDetailsByHomeworkId(homeworkId: number, roomId?: string): Promise<StudentDetailInfo[]> {
const id = Number(homeworkId);
if (!Number.isFinite(id) || id <= 0) {
return [];
@ -87,11 +89,47 @@ export class MeetingService {
}
// 转换为最终结果
return results.map((row) => ({
let finalResults = results.map((row) => ({
longId: Number(row.longId),
studentName: row.studentName || '',
shortId: row.shortId ?? 0,
}));
// 如果提供了 roomId从 Redis 获取排序并应用
if (roomId) {
const orderedIds = await this.redis.getStudentOrder(roomId);
if (orderedIds.length > 0) {
// 创建映射表
const studentMap = new Map<number, StudentDetailInfo>();
finalResults.forEach((student) => {
studentMap.set(student.longId, student);
});
// 按 Redis 中的顺序重新排列
const orderedResults: StudentDetailInfo[] = [];
for (const longId of orderedIds) {
const student = studentMap.get(longId);
if (student) {
orderedResults.push(student);
studentMap.delete(longId); // 移除已添加的学生
}
}
// 添加 Redis 中没有的学生 (新加入的学生)
finalResults = [...orderedResults, ...Array.from(studentMap.values())];
}
}
return finalResults;
}
/**
* 保存学生排序顺序
* @param roomId - 房间 ID
* @param studentLongIds - 学生长 ID 数组 (按排序顺序)
*/
public async saveStudentOrder(roomId: string, studentLongIds: number[]): Promise<void> {
await this.redis.setStudentOrder(roomId, studentLongIds);
}
/**
@ -171,7 +209,7 @@ export class MeetingService {
if (existingUser) {
// 复用已有的短 UID
console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`);
return existingUser.id;
return Number(existingUser.id);
}
// 创建新记录(数据库自增 ID 会自动分配)
@ -179,7 +217,7 @@ export class MeetingService {
const newUser = this.em.create(MeetingUser, { longUserId: Number(userId), createdAt: new Date() });
await this.em.flush();
console.log(`[MeetingService] 为用户 ${longUserId} 分配短 UID: ${newUser.id}`);
return newUser.id;
return Number(newUser.id);
} catch (error: any) {
// 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry)
if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) {
@ -187,7 +225,7 @@ export class MeetingService {
this.em.clear(); // 清除当前上下文,防止缓存干扰
const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
if (retryUser) {
return retryUser.id;
return Number(retryUser.id);
}
}
throw error;

View File

@ -59,7 +59,7 @@ export class MeetingRedisService {
* 用途:存储房间的全局状态信息
* 数据结构Hash
* Key 格式meeting:room:{roomId}
* Hash 字段
* Hash 字段:
* - classStatus: 课堂状态not_started | in_class | finished
* - speakerUid: 当前主讲人短 UID可选
* - teacherUid: 老师短 UID可选
@ -68,6 +68,17 @@ export class MeetingRedisService {
* 过期时间24 小时
*/
ROOM_STATE: 'meeting:room:',
/**
* 学生排序 Key
* 用途:存储学生列表的排序顺序
* 数据结构List
* Key 格式meeting:student-order:{roomId}
* List 内容:按顺序存储学生的长 ID (字符串)
* 适用场景:教师端拖动排序后,监控端按相同顺序显示
* 过期时间24 小时
*/
STUDENT_ORDER: 'meeting:student-order:',
};
constructor(private readonly redisService: RedisService) {
@ -382,7 +393,9 @@ export class MeetingRedisService {
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : [];
const nextSocketIds =
nextEntries.length > 0 ? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))] : (currentState.socketIds || []).filter((id) => id !== socketId);
nextEntries.length > 0
? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))]
: (currentState.socketIds || []).filter((id) => id !== socketId);
if (nextSocketIds.length > 0) {
const hm: Record<string, string> = { socketIds: JSON.stringify(nextSocketIds) };
@ -486,6 +499,42 @@ export class MeetingRedisService {
return !socketChecks.includes(true);
}
/**
* 设置学生排序顺序
* @param roomId - 房间 ID
* @param studentLongIds - 学生长 ID 数组 (按排序顺序)
*/
async setStudentOrder(roomId: string, studentLongIds: number[]): Promise<void> {
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
const client = this.getClient();
await client.del(key); // 先删除旧数据
if (studentLongIds.length > 0) {
const stringIds = studentLongIds.map((id) => String(id));
await client.rpush(key, ...stringIds);
await client.expire(key, 24 * 60 * 60);
}
}
/**
* 获取学生排序顺序
* @param roomId - 房间 ID
* @returns 学生长 ID 数组 (按排序顺序),未设置返回空数组
*/
async getStudentOrder(roomId: string): Promise<number[]> {
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
const ids = await this.getClient().lrange(key, 0, -1);
return ids.map((id) => Number(id)).filter((id) => Number.isFinite(id));
}
/**
* 清理学生排序
* @param roomId - 房间 ID
*/
async clearStudentOrder(roomId: string): Promise<void> {
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
await this.getClient().del(key);
}
// ==================== 清理 ====================
/**

View File

@ -62,6 +62,14 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
this.logger.info({}, message, 'MeetingWebSocket');
}
/**
* 获取 Socket.IO Server 实例
* @returns Socket.IO Namespace 实例
*/
public getServer(): MeetingNamespace | null {
return this.server;
}
/**
* 查找目标用户的所有 Socket本节点 + 跨节点)
* @param roomId - 服务端房间 IDSocket.IO 房间名)
@ -239,7 +247,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
const roomId = courseRoomId;
// 从鉴权后的 user 中获取 shortUid鉴权通过就有短 ID
const shortUid = socket.data.user?.shortUid;
const shortUid = Number(socket.data.user?.shortUid || 0);
const userName = socket.data.user?.userName || data.userName || '用户';
const isHost = socket.data.user?.role !== 0;
const joinMode: JoinMode =
@ -697,4 +705,36 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
this.log(`创建者停止投屏roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
}
/**
* 处理学生排序消息 (仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_student_order_changed')
public async handleStudentOrderChanged(
@MessageBody() data: { roomId: string; studentLongIds: number[] },
@ConnectedSocket() socket: MeetingSocket
): Promise<void> {
if (!data?.roomId || !Array.isArray(data.studentLongIds)) {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试设置学生排序userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
// 保存到 Redis
await this.redisService.setStudentOrder(data.roomId, data.studentLongIds);
// 获取房间内所有 socket 并过滤出监控端 (monitor 模式)
const ns = this.server;
if (ns) {
const sockets = await ns.in(data.roomId).fetchSockets();
const monitorSocketIds = sockets.filter((s) => s.data.joinMode === 'monitor').map((s) => s.id);
if (monitorSocketIds.length > 0) {
// 发送给所有监控端的 socket
ns.to(monitorSocketIds).emit('message', { type: 'sev_student_order_changed', data: { fromRoomId: data.roomId, studentLongIds: data.studentLongIds } });
}
}
this.log(`创建者更新了学生排序roomId=${data.roomId}, 学生数量=${data.studentLongIds.length}`);
}
}

View File

@ -214,6 +214,13 @@ export interface MeetingRoomEndTimestampUpdatedData {
endTimestamp: number;
}
export interface MeetingStudentOrderChangedData {
/** 来源房间 */
fromRoomId: string;
/** 学生长 ID 数组 (按排序顺序) */
studentLongIds: number[];
}
export type MeetingDownlinkPacket =
/** 统一错误包WsExceptionFilter/业务侧主动 emit 的错误) */
| ErrorMessage
@ -237,6 +244,8 @@ export type MeetingDownlinkPacket =
| MeetingWsMessagePacket<'sev_start_screen_share', MeetingScreenShareNotifyData>
/** 停止投屏通知 */
| MeetingWsMessagePacket<'sev_stop_screen_share', MeetingScreenShareNotifyData>
/** 学生排序变更通知 */
| MeetingWsMessagePacket<'sev_student_order_changed', MeetingStudentOrderChangedData>
/** 解除禁麦通知 */
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
/** 解除禁视频通知 */
@ -265,6 +274,7 @@ export type ClientToServerMessageType =
| 'client_start_class' // 开始课程(仅创建者)
| 'client_start_screen_share' // 开始投屏
| 'client_stop_screen_share' // 停止投屏
| 'client_student_order_changed' // 学生排序变更(仅创建者)
| 'client_unmute_audio' // 解除禁麦(仅创建者)
| 'client_unmute_video' // 解除禁视频(仅创建者)
| 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者)
@ -283,6 +293,7 @@ export type ServerToClientMessageType =
| 'sev_set_main_video' // 设置主屏通知
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
| 'sev_student_order_changed' // 学生排序变更通知(广播给监控端)
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
| 'sev_unmute_video' // 解除禁视频通知(转发给目标用户)
| 'sev_room_will_expire' // 房间即将到期提醒(仅老师端)