feat(meeting): 支持房间结束时间管理及投屏双流模式

- 新增房间结束时间字段,支持老师端设置和续期
- 实现投屏双流模式,老师可同时展示摄像头和屏幕共享
- 学生端支持切换主画面显示摄像头或投屏内容
- 优化屏幕共享实现,使用独立声网客户端避免轨道冲突
- 增加设备冲突检测逻辑,区分教室端、监控端和学生端入口
This commit is contained in:
2026-03-13 17:45:51 +08:00
parent d09d3645ea
commit 3ac83f1b94
10 changed files with 768 additions and 334 deletions

View File

@ -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 - 投屏人短 UIDnull 表示停止投屏
*/
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 || [];
}

View File

@ -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}`);
}
}

View File

@ -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';
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
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 }>;
}
/**