Files
tauri-meeting/node_api/src/modules/websocket/meeting-redis.service.ts
O昵称重要吗O bfaad97f61 feat(websocket): 优化投屏状态管理并新增全员禁麦功能
- 重构 Redis 投屏状态存储,统一使用 screenShareOwnerUid 字段
- 移除冗余的 screenShareUid 字段,简化房间状态数据结构
- 新增 client_mute_all_students 消息类型支持全员禁麦功能
- 实现创建者权限验证的全员禁麦逻辑,排除老师角色用户
- 优化 Tauri 桌面应用悬浮窗创建接口,支持状态参数传递
- 添加悬浮窗关闭功能及教师端静音状态同步机制
- 修复学生端界面中投屏状态变量命名不一致问题
- 统一按钮样式规范,提升界面组件一致性
2026-03-15 18:21:15 +08:00

584 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Redis 服务 - 管理会议状态
* 用于维护跨 WebSocket 连接的用户状态,支持用户重新加入时恢复状态
*/
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../plugins/redis/redis.service';
import type { Redis } from 'ioredis';
import type { BlacklistUser, JoinMode, UserPermissionState } from './types';
@Injectable()
export class MeetingRedisService {
/** Redis 客户端(会议数据库 DB 0 */
private redisClient!: Redis;
/**
* Redis Key 前缀配置
*
* 设计原则:
* 1. 使用冒号(:)作为分隔符,符合 Redis Key 命名规范
* 2. 按功能模块分组,便于管理和排查问题
* 3. 所有 Key 都设置了 24 小时过期时间,自动清理
*
* 数据结构选择:
* - Hash适合存储对象类型的数据如用户状态、黑名单
* - Set适合存储需要去重的集合如房间用户列表、Socket ID 列表)
*/
private readonly KEY_PREFIX = {
/**
* 用户状态 Key
* 用途:存储用户在会议中的权限状态(禁麦/禁视频)和 Socket 连接列表
* 数据结构Hash
* Key 格式meeting:user:{roomId}:{shortUid}
* Hash 字段:
* - isAudioMuted: 是否被禁麦('1' 或 '0'
* - isVideoMuted: 是否被禁视频('1' 或 '0'
* - socketIds: JSON 字符串数组,用户的所有 Socket 连接 ID
* 适用场景:用户断线重连、教师禁麦/禁视频后用户重新加入、多设备登录检测
* 过期时间24 小时
*/
USER_STATE: 'meeting:user:',
/**
* 黑名单 Key
* 用途:存储被踢出房间的用户列表,防止用户再次加入
* 数据结构Hash
* Key 格式meeting:blacklist:{roomId}
* Hash 字段:
* - field: shortUid用户短 UID
* - value: JSON 字符串 { shortUid, userName }
* 适用场景:用户被踢出后记录,下次用户尝试加入时检查
* 过期时间24 小时
* 注意:黑名单在课程结束后不会被清理,需要手动调用 clearRoomAll 或单独清理
*/
BLACKLIST: 'meeting:blacklist:',
/**
* 房间状态 Key
* 用途:存储房间的全局状态信息
* 数据结构Hash
* Key 格式meeting:room:{roomId}
* Hash 字段:
* - classStatus: 课堂状态not_started | in_class | finished
* - speakerUid: 当前主讲人短 UID可选
* - teacherUid: 老师短 UID可选
* - endTimestamp: 房间结束时间(毫秒时间戳,可选)
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
* 过期时间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) {
// ✅ 不在构造函数中初始化,改为懒加载
}
/**
* 懒加载获取 Redis 客户端(第一次使用时才初始化)
*/
private getClient(): Redis {
if (!this.redisClient) {
this.redisClient = this.redisService.getMeetingClient();
}
return this.redisClient;
}
/**
* 获取用户状态 Key
*/
private getUserStateKey(roomId: string, shortUid: number): string {
return `${this.KEY_PREFIX.USER_STATE}${roomId}:${shortUid}`;
}
/**
* 设置用户状态Hash 结构)
*/
async setUserState(roomId: string, shortUid: number, state: Partial<UserPermissionState>): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
const hm: Record<string, string> = {};
if (typeof state.isAudioMuted === 'boolean') {
hm.isAudioMuted = state.isAudioMuted ? '1' : '0';
}
if (typeof state.isVideoMuted === 'boolean') {
hm.isVideoMuted = state.isVideoMuted ? '1' : '0';
}
if (Object.keys(hm).length > 0) {
await this.getClient().hset(key, hm);
// 设置过期时间24 小时(会议结束后自动清理)
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 获取用户状态Hash 结构)
*/
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 (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,
};
}
/**
* 清理用户状态(下课或离开时调用)
*/
async clearUserState(roomId: string, shortUid: number): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
await this.getClient().del(key);
}
// ==================== 黑名单管理 ====================
/**
* 检查用户是否被踢出(黑名单中)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async isUserKicked(roomId: string, shortUid: number): Promise<boolean> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const exists = await this.getClient().hexists(key, String(shortUid));
return exists === 1;
}
/**
* 将用户加入黑名单
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param userName - 用户名称
*/
async addToBlacklist(roomId: string, shortUid: number, userName: string): Promise<void> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const userData: BlacklistUser = { shortUid, userName };
await this.getClient().hset(key, String(shortUid), JSON.stringify(userData));
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 从黑名单移除用户(按短 UID
*/
async removeFromBlacklist(roomId: string, shortUid: number): Promise<void> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
await this.getClient().hdel(key, String(shortUid));
}
/**
* 获取黑名单中的所有用户
* @returns 黑名单用户列表(包含短 UID 和名称)
*/
async getBlacklist(roomId: string): Promise<BlacklistUser[]> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const values = await this.getClient().hvals(key);
return values
.map((v) => {
try {
return JSON.parse(v) as BlacklistUser;
} catch {
return null;
}
})
.filter((u): u is BlacklistUser => u !== null);
}
// ==================== 房间状态管理 ====================
/**
* 设置课堂状态
* @param roomId - 房间 ID
* @param status - 状态not_started未开始/ in_class上课中/ finished已下课
*/
async setClassStatus(roomId: string, status: 'finished' | 'in_class' | 'not_started'): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
await this.getClient().hset(key, { classStatus: status });
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 设置主讲人
* @param roomId - 房间 ID
* @param speakerUid - 主讲人短 UIDnull 表示取消主讲
*/
async setSpeaker(roomId: string, speakerUid: number | null): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
if (speakerUid === null) {
await this.getClient().hdel(key, 'speakerUid');
} else {
await this.getClient().hset(key, { speakerUid: String(speakerUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 设置投屏状态
* @param roomId - 房间 ID
* @param screenShareOwnerUid - 投屏人短 UIDnull 表示停止投屏
*/
async setScreenSharing(roomId: string, screenShareOwnerUid: number | null): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
if (screenShareOwnerUid === null) {
await this.getClient().hdel(key, 'screenShareOwnerUid');
} else {
await this.getClient().hset(key, { screenShareOwnerUid: String(screenShareOwnerUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 设置老师短 UID
* @param roomId - 房间 ID
* @param teacherUid - 老师短 UID
*/
async setTeacherUid(roomId: string, teacherUid: number): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
await this.getClient().hset(key, { teacherUid: String(teacherUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 获取老师短 UID
* @param roomId - 房间 ID
* @returns 老师短 UID不存在返回 null
*/
async getTeacherUid(roomId: string): Promise<number | null> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
const v = await this.getClient().hget(key, 'teacherUid');
const uid = v ? Number(v) : NaN;
if (!Number.isFinite(uid) || uid <= 0) {
return null;
}
return uid;
}
/**
* 获取房间状态
* @returns 房间状态(包含课堂状态、主讲人、投屏状态)
*/
async getRoomState(roomId: string): Promise<{
classStatus: 'finished' | 'in_class' | 'not_started';
speakerUid?: 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 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(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 连接管理 ====================
/**
* 添加用户 Socket 连接(存储在 USER_STATE Hash 的 socketIds 字段中)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param socketId - Socket.IO 连接 ID
* @param joinMode - 加入方式(课程/监控/学生)
*/
async addSocket(roomId: string, shortUid: number, socketId: string, joinMode: JoinMode): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
const currentState = await this.getUserState(roomId, shortUid);
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
const existed = socketEntries.some((e) => e.socketId === socketId);
if (!existed) {
socketEntries.push({ socketId, joinMode });
}
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);
}
/**
* 移除用户 Socket 连接
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param socketId - Socket.IO 连接 ID
*/
async removeSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
const currentState = await this.getUserState(roomId, shortUid);
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 {
await this.getClient().del(key);
}
}
/**
* 获取用户的所有 Socket 连接 ID
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param joinMode - 加入方式(可选;用于多端共存的场景)
* @returns Socket 连接 ID 数组
*/
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 || [];
}
// ==================== 房间用户管理 ====================
/**
* 添加用户到房间(通过设置用户状态来标记用户在线)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async addUserToRoom(roomId: string, shortUid: number): Promise<void> {
// 通过设置用户状态来标记用户在线(设置一个占位状态)
await this.setUserState(roomId, shortUid, {});
}
/**
* 从房间移除用户(清理用户状态)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async removeUserFromRoom(roomId: string, shortUid: number): Promise<void> {
// 清理用户状态即表示用户离开房间
await this.clearUserState(roomId, shortUid);
}
/**
* 获取房间中的所有用户短 UID 列表
* 通过扫描 USER_STATE 模式来获取房间内所有用户
* @param roomId - 房间 ID
* @returns 短 UID 数组
*/
async getUsersInRoom(roomId: string): Promise<number[]> {
const pattern = `${this.KEY_PREFIX.USER_STATE}${roomId}:*`;
const client = this.getClient();
const userUids: number[] = [];
let cursor = '0';
do {
// eslint-disable-next-line no-await-in-loop
const res = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
cursor = res[0];
const keys = res[1] ?? [];
for (const key of keys) {
// 从 key 中提取 shortUidmeeting:user:{roomId}:{shortUid}
const parts = key.split(':');
const shortUid = Number(parts[parts.length - 1]);
if (Number.isFinite(shortUid)) {
userUids.push(shortUid);
}
}
} while (cursor !== '0');
return userUids;
}
/**
* 检查房间是否为空(没有任何用户连接)
* 通过检查 Redis 中是否有任何用户的 socketIds 来判断
* 注意:这是检查 Redis 状态,不依赖 Socket.IO 的 rooms
* @param roomId - 房间 ID
* @returns true 表示房间为空false 表示还有用户
*/
async isRoomEmpty(roomId: string): Promise<boolean> {
const users = await this.getUsersInRoom(roomId);
// 如果没有用户,直接返回 true
if (users.length === 0) {
return true;
}
// 并行检查所有用户是否还有有效的 socket 连接
const socketChecks = await Promise.all(
users.map(async (shortUid) => {
const socketIds = await this.getSocketIds(roomId, shortUid);
return socketIds.length > 0;
})
);
// 如果任何一个用户还有 socketIds说明房间不为空
return !socketChecks.includes(true);
}
/**
* 设置学生排序顺序
* @param roomId - 房间 ID
* @param studentShortIds - 学生短 ID 数组 (按排序顺序)
*/
async setStudentOrder(roomId: string, studentShortIds: number[]): Promise<void> {
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
const client = this.getClient();
await client.del(key); // 先删除旧数据
if (studentShortIds.length > 0) {
const stringIds = studentShortIds.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);
}
// ==================== 清理 ====================
/**
* 清理课程状态(下课时调用,不清理黑名单)
* 清理:房间状态、用户状态(包含 socketIds
* 注意:用户状态通过 scan 模式匹配清理socketIds 存储在用户状态 Hash 中一起清理
* @param roomId - 房间 ID
*/
async clearClassData(roomId: string): Promise<void> {
const client = this.getClient();
const userStatePattern = `${this.KEY_PREFIX.USER_STATE}${roomId}:*`;
const roomStateKey = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
// 使用 SCAN 遍历所有匹配的用户状态 key
let cursor: string | null = '0';
const keysToDelete: string[] = [];
// 遍历直到 cursor 回到 '0' 或者返回 null
while (cursor !== null && cursor !== '0') {
// eslint-disable-next-line no-await-in-loop
const res: [string, string[]] = await client.scan(cursor, 'MATCH', userStatePattern, 'COUNT', 200);
cursor = res[0] === '0' ? null : res[0];
const keys = res[1] ?? [];
if (keys.length > 0) {
keysToDelete.push(...keys);
}
}
// 使用 pipeline 批量删除,减少网络往返
if (keysToDelete.length > 0) {
const pipeline = client.pipeline();
for (const key of keysToDelete) {
pipeline.del(key);
}
await pipeline.exec();
}
// 清理房间状态
await client.del(roomStateKey);
}
/**
* 清理房间所有状态(包括黑名单)
* 用于房间彻底无人时使用
* @param roomId - 房间 ID
*/
async clearRoomAll(roomId: string): Promise<void> {
// 先清理课程数据
await this.clearClassData(roomId);
// 再清理黑名单
const blacklistKey = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
await this.getClient().del(blacklistKey);
}
}