Files
tauri-meeting/node_api/src/modules/websocket/meeting-redis.service.ts

592 lines
21 KiB
TypeScript
Raw Normal View History

/**
* 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
* - SetSocket 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 screenShareUid - UIDnull
*/
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 {
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);
}
}
/**
* 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;
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 连接管理 ====================
/**
* 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);
}
}