- 重构 Redis 投屏状态存储,统一使用 screenShareOwnerUid 字段 - 移除冗余的 screenShareUid 字段,简化房间状态数据结构 - 新增 client_mute_all_students 消息类型支持全员禁麦功能 - 实现创建者权限验证的全员禁麦逻辑,排除老师角色用户 - 优化 Tauri 桌面应用悬浮窗创建接口,支持状态参数传递 - 添加悬浮窗关闭功能及教师端静音状态同步机制 - 修复学生端界面中投屏状态变量命名不一致问题 - 统一按钮样式规范,提升界面组件一致性
584 lines
20 KiB
TypeScript
584 lines
20 KiB
TypeScript
/**
|
||
* 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 - 主讲人短 UID,null 表示取消主讲
|
||
*/
|
||
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 - 投屏人短 UID,null 表示停止投屏
|
||
*/
|
||
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 中提取 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<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);
|
||
}
|
||
}
|