chore: 初始化项目基础结构和资源文件
- 添加项目图标文件(app-icon.png、各平台图标) - 配置开发环境文件(.env、.nvmrc、.npmrc) - 添加静态资源文件(背景图片、字体、音频) - 初始化Tauri后端结构(build.rs、main.rs、模块文件) - 配置前端项目结构(TypeScript、Vue组件、样式) - 添加Node.js API服务基础结构 - 配置构建和开发工具(vite、prettier、gitignore)
This commit is contained in:
469
node_api/src/modules/websocket/meeting-redis.service.ts
Normal file
469
node_api/src/modules/websocket/meeting-redis.service.ts
Normal file
@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Redis 服务 - 管理会议状态
|
||||
* 用于维护跨 WebSocket 连接的用户状态,支持用户重新加入时恢复状态
|
||||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '../../plugins/redis/redis.service';
|
||||
import type { Redis } from 'ioredis';
|
||||
import type { BlacklistUser, 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(可选)
|
||||
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
|
||||
* 过期时间:24 小时
|
||||
*/
|
||||
ROOM_STATE: 'meeting:room:',
|
||||
};
|
||||
|
||||
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 socketIds: string[] = [];
|
||||
if (map?.socketIds) {
|
||||
try {
|
||||
socketIds = JSON.parse(map.socketIds);
|
||||
} catch {
|
||||
socketIds = [];
|
||||
}
|
||||
}
|
||||
return {
|
||||
isAudioMuted: map?.isAudioMuted === '1',
|
||||
isVideoMuted: map?.isVideoMuted === '1',
|
||||
socketIds,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户状态(下课或离开时调用)
|
||||
*/
|
||||
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 表示停止投屏
|
||||
*/
|
||||
async setScreenSharing(roomId: string, screenShareUid: number | null): Promise<void> {
|
||||
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
|
||||
if (screenShareUid === null) {
|
||||
await this.getClient().hdel(key, 'screenShareUid');
|
||||
} else {
|
||||
await this.getClient().hset(key, { screenShareUid: String(screenShareUid) });
|
||||
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;
|
||||
teacherUid?: 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 teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
|
||||
return {
|
||||
classStatus: status,
|
||||
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
|
||||
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
|
||||
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Socket 连接管理 ====================
|
||||
|
||||
/**
|
||||
* 添加用户 Socket 连接(存储在 USER_STATE Hash 的 socketIds 字段中)
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @param socketId - Socket.IO 连接 ID
|
||||
*/
|
||||
async addSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
|
||||
const key = this.getUserStateKey(roomId, shortUid);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 添加新 socketId(如果不存在)
|
||||
if (!socketIds.includes(socketId)) {
|
||||
socketIds.push(socketId);
|
||||
}
|
||||
// 存储到 Redis
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) });
|
||||
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);
|
||||
// 获取当前 socketIds
|
||||
const currentState = await this.getUserState(roomId, shortUid);
|
||||
const socketIds = currentState.socketIds || [];
|
||||
// 移除指定的 socketId
|
||||
const newSocketIds = socketIds.filter((id) => id !== socketId);
|
||||
if (newSocketIds.length > 0) {
|
||||
await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) });
|
||||
} else {
|
||||
// 如果没有 socketId 了,删除整个 key(用户离开)
|
||||
await this.getClient().del(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有 Socket 连接 ID
|
||||
* @param roomId - 房间 ID
|
||||
* @param shortUid - 短 UID
|
||||
* @returns Socket 连接 ID 数组
|
||||
*/
|
||||
async getSocketIds(roomId: string, shortUid: number): Promise<string[]> {
|
||||
const state = await this.getUserState(roomId, shortUid);
|
||||
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);
|
||||
}
|
||||
|
||||
// ==================== 清理 ====================
|
||||
|
||||
/**
|
||||
* 清理课程状态(下课时调用,不清理黑名单)
|
||||
* 清理:房间状态、用户状态(包含 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user