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 || [];
}