2026-03-13 10:03:05 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Redis 服务 - 管理会议状态
|
|
|
|
|
|
* 用于维护跨 WebSocket 连接的用户状态,支持用户重新加入时恢复状态
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
|
|
import { RedisService } from '../../plugins/redis/redis.service';
|
|
|
|
|
|
import type { Redis } from 'ioredis';
|
2026-03-13 17:45:51 +08:00
|
|
|
|
import type { BlacklistUser, JoinMode, UserPermissionState } from './types';
|
2026-03-13 10:03:05 +08:00
|
|
|
|
|
|
|
|
|
|
@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}
|
2026-03-14 18:11:31 +08:00
|
|
|
|
* Hash 字段:
|
2026-03-13 10:03:05 +08:00
|
|
|
|
* - classStatus: 课堂状态(not_started | in_class | finished)
|
|
|
|
|
|
* - speakerUid: 当前主讲人短 UID(可选)
|
|
|
|
|
|
* - teacherUid: 老师短 UID(可选)
|
2026-03-13 17:45:51 +08:00
|
|
|
|
* - endTimestamp: 房间结束时间(毫秒时间戳,可选)
|
2026-03-13 10:03:05 +08:00
|
|
|
|
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
|
|
|
|
|
* 过期时间:24 小时
|
|
|
|
|
|
*/
|
|
|
|
|
|
ROOM_STATE: 'meeting:room:',
|
2026-03-14 18:11:31 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 学生排序 Key
|
|
|
|
|
|
* 用途:存储学生列表的排序顺序
|
|
|
|
|
|
* 数据结构:List
|
|
|
|
|
|
* Key 格式:meeting:student-order:{roomId}
|
2026-03-14 22:35:30 +08:00
|
|
|
|
* List 内容:按顺序存储学生的短 ID (数字字符串)
|
|
|
|
|
|
* 适用场景:教师端拖动排序后,监控端和学生端按相同顺序显示
|
2026-03-14 18:11:31 +08:00
|
|
|
|
* 过期时间:24 小时
|
|
|
|
|
|
*/
|
|
|
|
|
|
STUDENT_ORDER: 'meeting:student-order:',
|
2026-03-13 10:03:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
let socketEntries: Array<{ socketId: string; joinMode: JoinMode }> = [];
|
|
|
|
|
|
if (map?.socketEntries) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
socketEntries = JSON.parse(map.socketEntries);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
socketEntries = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:03:05 +08:00
|
|
|
|
let socketIds: string[] = [];
|
2026-03-13 17:45:51 +08:00
|
|
|
|
if (socketEntries.length > 0) {
|
|
|
|
|
|
socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
|
|
|
|
|
} else if (map?.socketIds) {
|
2026-03-13 10:03:05 +08:00
|
|
|
|
try {
|
|
|
|
|
|
socketIds = JSON.parse(map.socketIds);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
socketEntries = socketIds.map((socketId) => ({ socketId, joinMode: 'classroom' }));
|
2026-03-13 10:03:05 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
socketIds = [];
|
2026-03-13 17:45:51 +08:00
|
|
|
|
socketEntries = [];
|
2026-03-13 10:03:05 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
isAudioMuted: map?.isAudioMuted === '1',
|
|
|
|
|
|
isVideoMuted: map?.isVideoMuted === '1',
|
|
|
|
|
|
socketIds,
|
2026-03-13 17:45:51 +08:00
|
|
|
|
socketEntries,
|
2026-03-13 10:03:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理用户状态(下课或离开时调用)
|
|
|
|
|
|
*/
|
|
|
|
|
|
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 screenShareUid - 投屏人短 UID,null 表示停止投屏
|
|
|
|
|
|
*/
|
2026-03-13 17:45:51 +08:00
|
|
|
|
async setScreenSharing(roomId: string, screenShareUid: number | null, screenShareOwnerUid?: number | null): Promise<void> {
|
2026-03-13 10:03:05 +08:00
|
|
|
|
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
|
|
|
|
|
if (screenShareUid === null) {
|
|
|
|
|
|
await this.getClient().hdel(key, 'screenShareUid');
|
2026-03-13 17:45:51 +08:00
|
|
|
|
await this.getClient().hdel(key, 'screenShareOwnerUid');
|
2026-03-13 10:03:05 +08:00
|
|
|
|
} else {
|
2026-03-13 17:45:51 +08:00
|
|
|
|
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);
|
2026-03-13 10:03:05 +08:00
|
|
|
|
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;
|
|
|
|
|
|
screenShareUid?: number;
|
2026-03-13 17:45:51 +08:00
|
|
|
|
screenShareOwnerUid?: number;
|
2026-03-13 10:03:05 +08:00
|
|
|
|
teacherUid?: number;
|
2026-03-13 17:45:51 +08:00
|
|
|
|
endTimestamp?: number;
|
2026-03-13 10:03:05 +08:00
|
|
|
|
}> {
|
|
|
|
|
|
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;
|
2026-03-13 17:45:51 +08:00
|
|
|
|
const screenShareOwnerUid = map?.screenShareOwnerUid ? Number(map.screenShareOwnerUid) : undefined;
|
2026-03-13 10:03:05 +08:00
|
|
|
|
const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
2026-03-13 17:45:51 +08:00
|
|
|
|
const endTimestamp = map?.endTimestamp ? Number(map.endTimestamp) : undefined;
|
2026-03-13 10:03:05 +08:00
|
|
|
|
return {
|
|
|
|
|
|
classStatus: status,
|
|
|
|
|
|
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
|
|
|
|
|
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
2026-03-13 17:45:51 +08:00
|
|
|
|
...(Number.isFinite(screenShareOwnerUid) ? { screenShareOwnerUid } : {}),
|
2026-03-13 10:03:05 +08:00
|
|
|
|
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
2026-03-13 17:45:51 +08:00
|
|
|
|
...(Number.isFinite(endTimestamp) ? { endTimestamp } : {}),
|
2026-03-13 10:03:05 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 17:45:51 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置房间结束时间
|
|
|
|
|
|
* @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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:03:05 +08:00
|
|
|
|
// ==================== Socket 连接管理 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 添加用户 Socket 连接(存储在 USER_STATE Hash 的 socketIds 字段中)
|
|
|
|
|
|
* @param roomId - 房间 ID
|
|
|
|
|
|
* @param shortUid - 短 UID
|
|
|
|
|
|
* @param socketId - Socket.IO 连接 ID
|
2026-03-13 17:45:51 +08:00
|
|
|
|
* @param joinMode - 加入方式(课程/监控/学生)
|
2026-03-13 10:03:05 +08:00
|
|
|
|
*/
|
2026-03-13 17:45:51 +08:00
|
|
|
|
async addSocket(roomId: string, shortUid: number, socketId: string, joinMode: JoinMode): Promise<void> {
|
2026-03-13 10:03:05 +08:00
|
|
|
|
const key = this.getUserStateKey(roomId, shortUid);
|
|
|
|
|
|
const currentState = await this.getUserState(roomId, shortUid);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
|
|
|
|
|
const existed = socketEntries.some((e) => e.socketId === socketId);
|
|
|
|
|
|
if (!existed) {
|
|
|
|
|
|
socketEntries.push({ socketId, joinMode });
|
2026-03-13 10:03:05 +08:00
|
|
|
|
}
|
2026-03-13 17:45:51 +08:00
|
|
|
|
const socketIds = [...new Set(socketEntries.map((e) => e.socketId).filter(Boolean))];
|
|
|
|
|
|
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds), socketEntries: JSON.stringify(socketEntries) });
|
2026-03-13 10:03:05 +08:00
|
|
|
|
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);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
const socketEntries = Array.isArray(currentState.socketEntries) ? currentState.socketEntries : [];
|
|
|
|
|
|
const nextEntries = socketEntries.length > 0 ? socketEntries.filter((e) => e.socketId !== socketId) : [];
|
|
|
|
|
|
const nextSocketIds =
|
2026-03-14 18:11:31 +08:00
|
|
|
|
nextEntries.length > 0
|
|
|
|
|
|
? [...new Set(nextEntries.map((e) => e.socketId).filter(Boolean))]
|
|
|
|
|
|
: (currentState.socketIds || []).filter((id) => id !== socketId);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
2026-03-13 10:03:05 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await this.getClient().del(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取用户的所有 Socket 连接 ID
|
|
|
|
|
|
* @param roomId - 房间 ID
|
|
|
|
|
|
* @param shortUid - 短 UID
|
2026-03-13 17:45:51 +08:00
|
|
|
|
* @param joinMode - 加入方式(可选;用于多端共存的场景)
|
2026-03-13 10:03:05 +08:00
|
|
|
|
* @returns Socket 连接 ID 数组
|
|
|
|
|
|
*/
|
2026-03-13 17:45:51 +08:00
|
|
|
|
async getSocketIds(roomId: string, shortUid: number, joinMode?: JoinMode): Promise<string[]> {
|
2026-03-13 10:03:05 +08:00
|
|
|
|
const state = await this.getUserState(roomId, shortUid);
|
2026-03-13 17:45:51 +08:00
|
|
|
|
if (joinMode && Array.isArray(state.socketEntries) && state.socketEntries.length > 0) {
|
|
|
|
|
|
return state.socketEntries.filter((e) => e.joinMode === joinMode).map((e) => e.socketId);
|
|
|
|
|
|
}
|
2026-03-13 10:03:05 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 18:11:31 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 设置学生排序顺序
|
|
|
|
|
|
* @param roomId - 房间 ID
|
2026-03-14 21:28:56 +08:00
|
|
|
|
* @param studentShortIds - 学生短 ID 数组 (按排序顺序)
|
2026-03-14 18:11:31 +08:00
|
|
|
|
*/
|
2026-03-14 21:28:56 +08:00
|
|
|
|
async setStudentOrder(roomId: string, studentShortIds: number[]): Promise<void> {
|
2026-03-14 18:11:31 +08:00
|
|
|
|
const key = `${this.KEY_PREFIX.STUDENT_ORDER}${roomId}`;
|
|
|
|
|
|
const client = this.getClient();
|
|
|
|
|
|
await client.del(key); // 先删除旧数据
|
2026-03-14 21:28:56 +08:00
|
|
|
|
if (studentShortIds.length > 0) {
|
|
|
|
|
|
const stringIds = studentShortIds.map((id) => String(id));
|
2026-03-14 18:11:31 +08:00
|
|
|
|
await client.rpush(key, ...stringIds);
|
|
|
|
|
|
await client.expire(key, 24 * 60 * 60);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 获取学生排序顺序
|
|
|
|
|
|
* @param roomId - 房间 ID
|
2026-03-14 22:35:30 +08:00
|
|
|
|
* @returns 学生短 ID 数组 (按排序顺序),未设置返回空数组
|
2026-03-14 18:11:31 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-13 10:03:05 +08:00
|
|
|
|
// ==================== 清理 ====================
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 清理课程状态(下课时调用,不清理黑名单)
|
|
|
|
|
|
* 清理:房间状态、用户状态(包含 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|