feat(meeting): 支持房间结束时间管理及投屏双流模式
- 新增房间结束时间字段,支持老师端设置和续期 - 实现投屏双流模式,老师可同时展示摄像头和屏幕共享 - 学生端支持切换主画面显示摄像头或投屏内容 - 优化屏幕共享实现,使用独立声网客户端避免轨道冲突 - 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口
This commit is contained in:
@ -6,7 +6,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../plugins/redis/redis.service';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { BlacklistUser, UserPermissionState } from './types';
|
||||
import type { BlacklistUser, JoinMode, UserPermissionState } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class MeetingRedisService {
|
||||
@ -63,6 +63,7 @@ export class MeetingRedisService {
|
||||
* - classStatus: 课堂状态(not_started | in_class | finished)
|
||||
* - speakerUid: 当前主讲人短 UID(可选)
|
||||
* - teacherUid: 老师短 UID(可选)
|
||||
* - endTimestamp: 房间结束时间(毫秒时间戳,可选)
|
||||
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
||||
* 过期时间:24 小时
|
||||
*/
|
||||
@ -115,18 +116,32 @@ export class MeetingRedisService {
|
||||
async getUserState(roomId: string, shortUid: number): Promise<UserPermissionState> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
const map = await this.getClient().hgetall(key);
|
||||
let socketEntries: Array<{ socketId: string; joinMode: JoinMode }> = [];
|
||||
if (map?.socketEntries) {
|
||||
try {
|
||||
socketEntries = JSON.parse(map.socketEntries);
|
||||
} catch {
|
||||
socketEntries = [];
|
||||
}
|
||||
}
|
||||
|
||||
let socketIds: string[] = [];
|
||||
if (map?.socketIds) {
|
||||
if (socketEntries.length > 0) {
|
||||
socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
||||
} else if (map?.socketIds) {
|
||||
try {
|
||||
socketIds = JSON.parse(map.socketIds);
|
||||
socketEntries = socketIds.map((socketId) => ({ socketId, joinMode: 'classroom' }));
|
||||
} catch {
|
||||
socketIds = [];
|
||||
socketEntries = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
isAudioMuted: map?.isAudioMuted === '1',
|
||||
isVideoMuted: map?.isVideoMuted === '1',
|
||||
socketIds,
|
||||
socketEntries,
|
||||
};
|
||||
}
|
||||
|
||||
@ -223,12 +238,17 @@ export class MeetingRedisService {
|
||||
* @param roomId - 房间 ID
|
||||
* @param screenShareUid - 投屏人短 UID,null 表示停止投屏
|
||||
*/
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null): Promise<void> {
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
if (screenShareUid === null) {
|
||||
await this.getClient().hdel(key, 'screenShareUid');
|
||||
await this.getClient().hdel(key, 'screenShareOwnerUid');
|
||||
} else {
|
||||
await this.getClient().hset(key, { screenShareUid: String(screenShareUid) });
|
||||
const hm: Record<string, string> = { screenShareUid: String(screenShareUid) };
|
||||
if (typeof screenShareOwnerUid === 'number' && Number.isFinite(screenShareOwnerUid) && screenShareOwnerUid > 0) {
|
||||
hm.screenShareOwnerUid = String(screenShareOwnerUid);
|
||||
}
|
||||
await this.getClient().hset(key, hm);
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
}
|
||||
@ -267,22 +287,67 @@ export class MeetingRedisService {
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
speakerUid?: number;
|
||||
screenShareUid?: number;
|
||||
screenShareOwnerUid?: number;
|
||||
teacherUid?: number;
|
||||
endTimestamp?: number;
|
||||
}> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const map = await this.getClient().hgetall(key);
|
||||
const status = (map?.classStatus as any) || 'not_started';
|
||||
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
|
||||
const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : undefined;
|
||||
const screenShareOwnerUid = map?.screenShareOwnerUid ? Number(map.screenShareOwnerUid) : undefined;
|
||||
const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
||||
const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined;
|
||||
return {
|
||||
classStatus: status,
|
||||
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
||||
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
||||
...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}),
|
||||
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
||||
...(Number.isFinite(endTimestamp) ? { endTimestamp } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置房间结束时间
|
||||
* @param roomId - 房间 ID
|
||||
* @param endTimestamp - 房间结束时间(毫秒时间戳)
|
||||
* @param mode - 写入模式:max 表示只允许延长,不允许缩短
|
||||
*/
|
||||
async setRoomEndTimestamp(roomId: string, endTimestamp: number, mode: 'max' | 'overwrite' = 'max'): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const next = Number(endTimestamp);
|
||||
if (!Number.isFinite(next) || next <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === 'max') {
|
||||
const current = await this.getClient().hget(key, 'endTimestamp');
|
||||
const currentNum = current ? Number(current) : NaN;
|
||||
if (Number.isFinite(currentNum) && currentNum > 0 && currentNum >= next) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.getClient().hset(key, { endTimestamp: String(next) });
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取房间结束时间
|
||||
* @param roomId - 房间 ID
|
||||
*/
|
||||
async getRoomEndTimestamp(roomId: string): Promise<number | null> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
const v = await this.getClient().hget(key, 'endTimestamp');
|
||||
const t = v ? Number(v) : NaN;
|
||||
if (!Number.isFinite(t) || t <= 0) {
|
||||
return null;
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
// ==================== Socket 连接管理 ====================
|
||||
|
||||
/**
|
||||
@ -290,18 +355,18 @@ export class MeetingRedisService {
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @param socketId - Socket.IO 连接 ID
|
||||
* @param joinMode - 加入方式(课程/监控/学生)
|
||||
*/
|
||||
async addSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||
async addSocket(roomId: string, shortUid: number, socketId: string, joinMode: JoinMode): Promise<void> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 添加新 socketId(如果不存在)
|
||||
if (!socketIds.includes(socketId)) {
|
||||
socketIds.push(socketId);
|
||||
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
||||
const existed = socketEntries.some((e) => e.socketId === socketId);
|
||||
if (!existed) {
|
||||
socketEntries.push({ socketId, joinMode });
|
||||
}
|
||||
// 存储到 Redis
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) });
|
||||
const socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds), socketEntries: JSON.stringify(socketEntries) });
|
||||
await this.getClient().expire(key, 24 * 60 * 60);
|
||||
}
|
||||
|
||||
@ -313,15 +378,19 @@ export class MeetingRedisService {
|
||||
*/
|
||||
async removeSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 移除指定的 socketId
|
||||
const newSocketIds = socketIds.filter((id) => id !== socketId);
|
||||
if (newSocketIds.length > 0) {
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) });
|
||||
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);
|
||||
|
||||
if (nextSocketIds.length > 0) {
|
||||
const hm: Record<string, string> = { socketIds: JSON.stringify(nextSocketIds) };
|
||||
if (socketEntries.length > 0) {
|
||||
hm.socketEntries = JSON.stringify(nextEntries);
|
||||
}
|
||||
await this.getClient().hset(key, hm);
|
||||
} else {
|
||||
// 如果没有 socketId 了,删除整个 key(用户离开)
|
||||
await this.getClient().del(key);
|
||||
}
|
||||
}
|
||||
@ -330,10 +399,14 @@ export class MeetingRedisService {
|
||||
* 获取用户的所有 Socket 连接 ID
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @param joinMode - 加入方式(可选;用于多端共存的场景)
|
||||
* @returns Socket 连接 ID 数组
|
||||
*/
|
||||
async getSocketIds(roomId: string, shortUid: number): Promise<string[]> {
|
||||
async getSocketIds(roomId: string, shortUid: number, joinMode?: JoinMode): Promise<string[]> {
|
||||
const state = await this.getUserState(roomId, shortUid);
|
||||
if (joinMode && Array.isArray(state.socketEntries) && state.socketEntries.length > 0) {
|
||||
return state.socketEntries.filter((e) => e.joinMode === joinMode).map((e) => e.socketId);
|
||||
}
|
||||
return state.socketIds || [];
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { LoggerService } from '@/plugins/logger/logger.service';
|
||||
import type {
|
||||
ClientToServerMessageType,
|
||||
ErrorMessage,
|
||||
JoinMode,
|
||||
JoinRoomData,
|
||||
KickUserData,
|
||||
MeetingNamespace,
|
||||
@ -42,6 +43,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
// 注入 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
||||
public server: MeetingNamespace | null = null;
|
||||
|
||||
private readonly roomExpiryTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly roomExpiryLastNotifiedMinute = new Map<string, number>();
|
||||
|
||||
public constructor(
|
||||
// 房间/用户状态:Redis 持久化
|
||||
private readonly redisService: MeetingRedisService,
|
||||
@ -92,10 +96,69 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
// 如果房间内没有 socket 连接,则清理 Redis 数据
|
||||
if (sockets.length === 0) {
|
||||
await this.redisService.clearRoomAll(roomId);
|
||||
this.clearRoomExpiryTimer(roomId);
|
||||
this.log(`[会议] 房间已空,已清理 Redis 状态:roomId=${roomId}`);
|
||||
}
|
||||
}
|
||||
|
||||
private clearRoomExpiryTimer(roomId: string): void {
|
||||
const timer = this.roomExpiryTimers.get(roomId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
this.roomExpiryTimers.delete(roomId);
|
||||
this.roomExpiryLastNotifiedMinute.delete(roomId);
|
||||
}
|
||||
|
||||
private ensureRoomExpiryTimer(roomId: string): void {
|
||||
if (this.roomExpiryTimers.has(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const endTimestamp = await this.redisService.getRoomEndTimestamp(roomId);
|
||||
if (!endTimestamp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const remainingMs = endTimestamp - now;
|
||||
if (remainingMs <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (remainingMs > 5 * 60 * 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentMinute = Math.floor(now / 60000);
|
||||
const lastMinute = this.roomExpiryLastNotifiedMinute.get(roomId);
|
||||
if (lastMinute === currentMinute) {
|
||||
return;
|
||||
}
|
||||
this.roomExpiryLastNotifiedMinute.set(roomId, currentMinute);
|
||||
|
||||
const ns = this.server;
|
||||
if (!ns) {
|
||||
return;
|
||||
}
|
||||
const sockets = await ns.in(roomId).fetchSockets();
|
||||
const teacherSockets = sockets.filter((s) => s.data.user?.role !== 0);
|
||||
for (const s of teacherSockets) {
|
||||
ns.to(s.id).emit('message', {
|
||||
type: 'sev_room_will_expire',
|
||||
data: { fromRoomId: roomId, endTimestamp, remainingMs },
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, `[会议] 房间到期提醒定时器执行失败: roomId=${roomId}`, 'MeetingWebSocket');
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
this.roomExpiryTimers.set(roomId, timer);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化后,在 namespace 层添加连接鉴权 middleware
|
||||
* @param server - 注入的 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
||||
@ -143,8 +206,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||
}
|
||||
|
||||
// 如果是老师(创建者)主动断开连接,发送下课消息给所有人
|
||||
if (roomId && isHost) {
|
||||
// 如果是老师(创建者)在“教室端”主动断开连接,发送下课消息给所有人
|
||||
if (roomId && isHost && socket.data.joinMode === 'classroom') {
|
||||
this.log(`老师(创建者)断开连接,发送下课消息:roomId=${roomId}`);
|
||||
// 设置课堂状态为已结束
|
||||
await this.redisService.setClassStatus(roomId, 'finished');
|
||||
@ -152,6 +215,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } });
|
||||
// 清理 Redis 数据
|
||||
await this.redisService.clearRoomAll(roomId);
|
||||
this.clearRoomExpiryTimer(roomId);
|
||||
}
|
||||
|
||||
if (roomId) {
|
||||
@ -177,6 +241,9 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
// 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID)
|
||||
const shortUid = socket.data.user?.shortUid;
|
||||
const userName = socket.data.user?.userName || data.userName || '用户';
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
const joinMode: JoinMode =
|
||||
data?.joinMode === 'classroom' || data?.joinMode === 'monitor' || data?.joinMode === 'student' ? data.joinMode : isHost ? 'classroom' : 'student';
|
||||
|
||||
if (!shortUid) {
|
||||
this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket');
|
||||
@ -196,7 +263,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
}
|
||||
|
||||
// 检查该用户是否已在其他设备加入了房间
|
||||
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid);
|
||||
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid, joinMode);
|
||||
if (existingSocketIds.length > 0) {
|
||||
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
|
||||
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
|
||||
@ -217,7 +284,7 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
if (result.isValid && result.sockets) {
|
||||
this.server?.to(result.oldSocketId).emit('message', {
|
||||
type: 'sev_device_conflict',
|
||||
data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid },
|
||||
data: { fromRoomId: roomId, reason: '您已在其他设备进入相同入口', targetUid: shortUid },
|
||||
} satisfies SucceedMessage);
|
||||
result.sockets.forEach((s) => s.disconnect(true));
|
||||
validSocketIds.push(result.oldSocketId);
|
||||
@ -243,9 +310,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
socket.join(roomId);
|
||||
socket.data.roomId = roomId;
|
||||
socket.data.courseRoomId = courseRoomId;
|
||||
socket.data.joinMode = joinMode;
|
||||
|
||||
// 注册 socketId 到 Redis(用于踢人、禁麦等控制功能)
|
||||
await this.redisService.addSocket(roomId, shortUid, socket.id);
|
||||
await this.redisService.addSocket(roomId, shortUid, socket.id, joinMode);
|
||||
// 添加用户到房间用户列表
|
||||
await this.redisService.addUserToRoom(roomId, shortUid);
|
||||
|
||||
@ -265,6 +333,17 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {});
|
||||
}
|
||||
|
||||
if (isHost && typeof data.endTimestamp === 'number') {
|
||||
const now = Date.now();
|
||||
const nextEndTimestamp = Number(data.endTimestamp);
|
||||
if (Number.isFinite(nextEndTimestamp) && nextEndTimestamp >= now + 5 * 60 * 1000) {
|
||||
await this.redisService.setRoomEndTimestamp(roomId, nextEndTimestamp, 'max').catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedEndTimestamp = await this.redisService.getRoomEndTimestamp(roomId).catch(() => null);
|
||||
this.ensureRoomExpiryTimer(roomId);
|
||||
|
||||
// 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态
|
||||
socket.emit('message', {
|
||||
type: 'sev_join_room',
|
||||
@ -276,8 +355,10 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
isVideoMuted: userState.isVideoMuted,
|
||||
classStatus: roomState.classStatus,
|
||||
screenShareUid: roomState.screenShareUid,
|
||||
screenShareOwnerUid: roomState.screenShareOwnerUid,
|
||||
speakerUid,
|
||||
teacherUid,
|
||||
...(resolvedEndTimestamp ? { endTimestamp: resolvedEndTimestamp } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@ -290,6 +371,35 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_renew_room_end_timestamp')
|
||||
public async handleRenewRoomEndTimestamp(
|
||||
@MessageBody() data: { roomId: string; endTimestamp: number },
|
||||
@ConnectedSocket() socket: MeetingSocket
|
||||
): Promise<void> {
|
||||
if (!data?.roomId || typeof data.endTimestamp !== 'number') {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nextEndTimestamp = Number(data.endTimestamp);
|
||||
if (!Number.isFinite(nextEndTimestamp) || nextEndTimestamp < now + 5 * 60 * 1000) {
|
||||
socket.emit('message', { type: 'error', data: { reason: '结束时间不能小于当前时间的5分钟' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.redisService.setRoomEndTimestamp(data.roomId, nextEndTimestamp, 'max');
|
||||
this.ensureRoomExpiryTimer(data.roomId);
|
||||
this.server?.to(data.roomId).emit('message', {
|
||||
type: 'sev_room_end_timestamp_updated',
|
||||
data: { fromRoomId: data.roomId, endTimestamp: nextEndTimestamp },
|
||||
});
|
||||
this.log(`房间 ${data.roomId} 已续期,新的结束时间: ${nextEndTimestamp}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_leave_room')
|
||||
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// Socket.IO 会自动处理离房逻辑,这里仅记录
|
||||
@ -299,16 +409,13 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
|
||||
// 移除 socketId 映射
|
||||
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||
// 从房间用户列表移除
|
||||
await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {});
|
||||
// 清理用户状态
|
||||
await this.redisService.clearUserState(roomId, shortUid).catch(() => {});
|
||||
}
|
||||
try {
|
||||
roomId && socket.leave(roomId);
|
||||
} catch {}
|
||||
socket.data.roomId = undefined;
|
||||
socket.data.courseRoomId = undefined;
|
||||
socket.data.joinMode = undefined;
|
||||
if (roomId) {
|
||||
await this.cleanupRoomIfEmpty(roomId);
|
||||
}
|
||||
@ -548,8 +655,8 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
* 处理开始投屏消息(仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
|
||||
public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
public async handleStartScreenShare(@MessageBody() data: { roomId: string; screenUid: number }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId || typeof data.screenUid !== 'number') {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
@ -558,18 +665,22 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
return;
|
||||
}
|
||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||
const screenUid = Number(data.screenUid);
|
||||
if (!Number.isFinite(screenUid) || screenUid <= 0) {
|
||||
return;
|
||||
}
|
||||
// 保存投屏状态到 Redis
|
||||
await this.redisService.setScreenSharing(data.roomId, targetUid);
|
||||
await this.redisService.setScreenSharing(data.roomId, screenUid, targetUid);
|
||||
// 广播给整个房间:有人开始投屏
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid } });
|
||||
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid, screenUid } });
|
||||
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}, screenUid=${screenUid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理停止投屏消息(仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_stop_screen_share')
|
||||
public async handleStopScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
public async handleStopScreenShare(@MessageBody() data: { roomId: string; screenUid?: number }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
return;
|
||||
}
|
||||
@ -579,10 +690,11 @@ export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDi
|
||||
return;
|
||||
}
|
||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||
const screenUid = typeof data.screenUid === 'number' ? Number(data.screenUid) : 0;
|
||||
// 清除投屏状态
|
||||
await this.redisService.setScreenSharing(data.roomId, null);
|
||||
// 广播给整个房间:有人停止投屏
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid } });
|
||||
this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/sort-type-constituents */
|
||||
/**
|
||||
* meeting WebSocket 模块的“类型总出口”
|
||||
*
|
||||
@ -41,6 +42,13 @@ export interface MeetingSocketData {
|
||||
* - 主要用于清理 courseRoomId -> roomId 的映射 key
|
||||
*/
|
||||
courseRoomId?: string;
|
||||
|
||||
/**
|
||||
* 加入方式(课程/监控/学生)
|
||||
* - 由 handleJoinRoom 写入
|
||||
* - 用于区分教师端窗口断开时是否需要触发下课逻辑
|
||||
*/
|
||||
joinMode?: JoinMode;
|
||||
}
|
||||
|
||||
export interface MeetingJoinRoomData {
|
||||
@ -79,6 +87,7 @@ export interface MeetingMuteUserData {
|
||||
export interface MeetingScreenShareData {
|
||||
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
||||
roomId: string;
|
||||
screenUid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,8 +96,10 @@ export interface MeetingScreenShareData {
|
||||
export interface MeetingScreenShareNotifyData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 投屏用户短 UID */
|
||||
/** 目标用户声网 shortUid */
|
||||
targetUid: number;
|
||||
/** 投屏用户声网 shortUid */
|
||||
screenUid: number;
|
||||
}
|
||||
|
||||
export interface MeetingClientToServerEvents {
|
||||
@ -112,6 +123,8 @@ export interface MeetingClientToServerEvents {
|
||||
client_start_screen_share: (data: MeetingScreenShareData) => void;
|
||||
/** 创建者停止投屏 */
|
||||
client_stop_screen_share: (data: MeetingScreenShareData) => void;
|
||||
/** 续期房间结束时间(仅创建者/老师) */
|
||||
client_renew_room_end_timestamp: (data: { roomId: string; endTimestamp: number }) => void;
|
||||
}
|
||||
|
||||
export interface MeetingWsMessagePacket<T extends string = string, D = unknown> {
|
||||
@ -148,10 +161,13 @@ export interface MeetingJoinRoomSuccessData {
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||
screenShareUid?: number;
|
||||
screenShareOwnerUid?: number;
|
||||
/** 上台的短uid,默认为老师的uid */
|
||||
speakerUid: number;
|
||||
/** 老师的短uid */
|
||||
teacherUid: number;
|
||||
/** 房间结束时间(毫秒时间戳) */
|
||||
endTimestamp?: number;
|
||||
}
|
||||
|
||||
export interface MeetingKickedData {
|
||||
@ -182,9 +198,25 @@ export interface MeetingClassStateData {
|
||||
fromRoomId: string;
|
||||
}
|
||||
|
||||
export interface MeetingRoomWillExpireData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 房间结束时间(毫秒时间戳) */
|
||||
endTimestamp: number;
|
||||
/** 剩余毫秒数 */
|
||||
remainingMs: number;
|
||||
}
|
||||
|
||||
export interface MeetingRoomEndTimestampUpdatedData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 房间结束时间(毫秒时间戳) */
|
||||
endTimestamp: number;
|
||||
}
|
||||
|
||||
export type MeetingDownlinkPacket =
|
||||
/** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */
|
||||
| MeetingWsMessagePacket<'error', { reason: string; code?: number }>
|
||||
| ErrorMessage
|
||||
/** 下课通知 */
|
||||
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
|
||||
/** 上课通知 */
|
||||
@ -208,7 +240,11 @@ export type MeetingDownlinkPacket =
|
||||
/** 解除禁麦通知 */
|
||||
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
|
||||
/** 解除禁视频通知 */
|
||||
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>;
|
||||
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>
|
||||
/** 房间即将到期提醒(仅老师端) */
|
||||
| MeetingWsMessagePacket<'sev_room_will_expire', MeetingRoomWillExpireData>
|
||||
/** 房间结束时间更新(续期后广播) */
|
||||
| MeetingWsMessagePacket<'sev_room_end_timestamp_updated', MeetingRoomEndTimestampUpdatedData>;
|
||||
|
||||
export interface MeetingServerToClientEvents {
|
||||
/** 约定:服务端统一通过 message 事件下发业务包(packet.type 决定语义) */
|
||||
@ -230,7 +266,8 @@ export type ClientToServerMessageType =
|
||||
| 'client_start_screen_share' // 开始投屏
|
||||
| 'client_stop_screen_share' // 停止投屏
|
||||
| 'client_unmute_audio' // 解除禁麦(仅创建者)
|
||||
| 'client_unmute_video'; // 解除禁视频(仅创建者)
|
||||
| 'client_unmute_video' // 解除禁视频(仅创建者)
|
||||
| 'client_renew_room_end_timestamp'; // 续期房间结束时间(仅创建者)
|
||||
|
||||
/**
|
||||
* 服务端 -> 客户端(下发消息)
|
||||
@ -247,7 +284,9 @@ export type ServerToClientMessageType =
|
||||
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
|
||||
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
|
||||
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
|
||||
| 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户)
|
||||
| 'sev_unmute_video' // 解除禁视频通知(转发给目标用户)
|
||||
| 'sev_room_will_expire' // 房间即将到期提醒(仅老师端)
|
||||
| 'sev_room_end_timestamp_updated'; // 房间结束时间更新(续期后广播)
|
||||
|
||||
export interface ErrorMessage {
|
||||
// 固定为 error,便于前端统一处理
|
||||
@ -270,6 +309,8 @@ export interface SucceedMessage {
|
||||
/**
|
||||
* 加入房间数据
|
||||
*/
|
||||
export type JoinMode = 'classroom' | 'monitor' | 'student';
|
||||
|
||||
export interface JoinRoomData {
|
||||
// 课程房间 ID(前端传入)
|
||||
courseRoomId: string;
|
||||
@ -277,6 +318,10 @@ export interface JoinRoomData {
|
||||
homeworkId: number;
|
||||
// 用户名(仅用于日志/展示)
|
||||
userName: string;
|
||||
/** 加入方式(课程/监控/学生) */
|
||||
joinMode: JoinMode;
|
||||
/** 房间结束时间(毫秒时间戳),仅老师端首次进入时会写入 */
|
||||
endTimestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -306,6 +351,8 @@ export interface UserPermissionState {
|
||||
isVideoMuted: boolean;
|
||||
/** 用户的所有 Socket 连接 ID(支持多设备同时在线) */
|
||||
socketIds: string[];
|
||||
/** 用户的 Socket 连接详情(用于区分加入方式) */
|
||||
socketEntries?: Array<{ socketId: string; joinMode: JoinMode }>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user