/** * 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): Promise { const key = this.getUserStateKey(roomId, shortUid); const hm: Record = {}; 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 { 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 { const key = this.getUserStateKey(roomId, shortUid); await this.getClient().del(key); } // ==================== 黑名单管理 ==================== /** * 检查用户是否被踢出(黑名单中) * @param roomId - 房间 ID * @param shortUid - 短 UID */ async isUserKicked(roomId: string, shortUid: number): Promise { 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 { 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 { const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`; await this.getClient().hdel(key, String(shortUid)); } /** * 获取黑名单中的所有用户 * @returns 黑名单用户列表(包含短 UID 和名称) */ async getBlacklist(roomId: string): Promise { 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 { 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 - 主讲人短 UID,null 表示取消主讲 */ async setSpeaker(roomId: string, speakerUid: number | null): Promise { 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 - 投屏人短 UID,null 表示停止投屏 */ async setScreenSharing(roomId: string, screenShareOwnerUid: number | null): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 = { 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 { 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 { // 通过设置用户状态来标记用户在线(设置一个占位状态) await this.setUserState(roomId, shortUid, {}); } /** * 从房间移除用户(清理用户状态) * @param roomId - 房间 ID * @param shortUid - 短 UID */ async removeUserFromRoom(roomId: string, shortUid: number): Promise { // 清理用户状态即表示用户离开房间 await this.clearUserState(roomId, shortUid); } /** * 获取房间中的所有用户短 UID 列表 * 通过扫描 USER_STATE 模式来获取房间内所有用户 * @param roomId - 房间 ID * @returns 短 UID 数组 */ async getUsersInRoom(roomId: string): Promise { 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 中提取 shortUid:meeting: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 { 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 { 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 { 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 { 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 { 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 { // 先清理课程数据 await this.clearClassData(roomId); // 再清理黑名单 const blacklistKey = `${this.KEY_PREFIX.BLACKLIST}${roomId}`; await this.getClient().del(blacklistKey); } }