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:
157
node_api/src/modules/websocket/meeting-auth.guard.ts
Normal file
157
node_api/src/modules/websocket/meeting-auth.guard.ts
Normal file
@ -0,0 +1,157 @@
|
||||
/**
|
||||
* WebSocket 认证守卫
|
||||
* 用于验证 Socket.IO 连接的用户身份
|
||||
*/
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import type { Socket } from 'socket.io';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
import { RedisDatabase, RedisService } from '../../plugins/redis/redis.service';
|
||||
import { LoggerService } from '../../plugins/logger/logger.service';
|
||||
import { MeetingService } from '../meeting/meeting.service';
|
||||
import type { MeetingWsUser } from './types';
|
||||
|
||||
@Injectable()
|
||||
export class MeetingAuthGuard implements CanActivate {
|
||||
public constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly logger: LoggerService,
|
||||
private readonly meetingService: MeetingService
|
||||
) {}
|
||||
|
||||
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// 获取 Socket 实例
|
||||
const socket = context.switchToWs().getClient<Socket>();
|
||||
|
||||
// 检查是否已经通过连接认证
|
||||
if (socket.data.user) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await this.validateToken(socket);
|
||||
socket.data.user = user;
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof WsException) {
|
||||
this.logger.error(e.message, 'WsAuthGuard');
|
||||
throw e;
|
||||
}
|
||||
this.logger.error(e, 'WsAuthGuard');
|
||||
throw new WsException('WebSocket 鉴权失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token 并返回用户信息
|
||||
* 可供 handleConnection 直接调用
|
||||
*/
|
||||
public async validateToken(socket: Socket): Promise<MeetingWsUser> {
|
||||
// 尝试从握手信息中获取认证头(兼容大小写)
|
||||
const handshake = socket.handshake;
|
||||
const authHeader = String(
|
||||
handshake.auth?.authorization || handshake.auth?.Authorization || handshake.headers?.authorization || handshake.headers?.Authorization || ''
|
||||
);
|
||||
const customMac = String(
|
||||
handshake.auth?.['custom-mac'] || handshake.auth?.['Custom-Mac'] || handshake.headers?.['custom-mac'] || handshake.headers?.['Custom-Mac'] || ''
|
||||
);
|
||||
const customPlatform = String(
|
||||
handshake.auth?.['custom-platform'] ||
|
||||
handshake.auth?.['Custom-Platform'] ||
|
||||
handshake.headers?.['custom-platform'] ||
|
||||
handshake.headers?.['Custom-Platform'] ||
|
||||
''
|
||||
);
|
||||
const customTimestamp = String(
|
||||
handshake.auth?.['custom-timestamp'] ||
|
||||
handshake.auth?.['Custom-Timestamp'] ||
|
||||
handshake.headers?.['custom-timestamp'] ||
|
||||
handshake.headers?.['Custom-Timestamp'] ||
|
||||
''
|
||||
);
|
||||
|
||||
// 验证必要的认证信息
|
||||
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer')) {
|
||||
throw new WsException('缺少或无效的 Authorization');
|
||||
}
|
||||
if (!customMac) {
|
||||
throw new WsException('缺少 Custom-Mac');
|
||||
}
|
||||
if (!customPlatform) {
|
||||
throw new WsException('缺少 Custom-Platform');
|
||||
}
|
||||
if (!customTimestamp) {
|
||||
throw new WsException('缺少 Custom-Timestamp');
|
||||
}
|
||||
|
||||
// 解析 Token
|
||||
const token = authHeader.slice(7).trim();
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new WsException('无效的 Token 格式');
|
||||
}
|
||||
|
||||
const payload: any = (() => {
|
||||
try {
|
||||
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new WsException('无法解析 Token');
|
||||
}
|
||||
})();
|
||||
const NAME_ID_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier';
|
||||
const ROLE_CLAIM = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
|
||||
const userId = String(payload?.[NAME_ID_CLAIM] || '');
|
||||
const role = Number(payload?.[ROLE_CLAIM] || '-1');
|
||||
|
||||
if (!userId || isNaN(role) || role < 0) {
|
||||
throw new WsException('Token 非法');
|
||||
}
|
||||
|
||||
// 验证 Token 过期时间
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const exp = Number(payload?.exp || 0);
|
||||
if (!Number.isFinite(exp) || nowSec >= exp) {
|
||||
throw new WsException('Token 已过期');
|
||||
}
|
||||
|
||||
const platform = customPlatform.toUpperCase();
|
||||
|
||||
// 验证 Redis 中的登录态
|
||||
const redisKey = `Auth:${userId}:${platform}`;
|
||||
const redis = this.redisService.getClient(RedisDatabase.GLOBAL);
|
||||
const v = await redis.get(redisKey);
|
||||
|
||||
if (!v) {
|
||||
throw new WsException('未找到登录态');
|
||||
}
|
||||
|
||||
const redisObj: any = (() => {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
throw new WsException('登录态数据异常');
|
||||
}
|
||||
})();
|
||||
|
||||
const redisToken = redisObj?.Token || redisObj?.token || '';
|
||||
if (redisToken !== token) {
|
||||
throw new WsException('登录态已失效');
|
||||
}
|
||||
|
||||
// 查询数据库获取短 UID 和用户名
|
||||
const shortUid = await this.meetingService.getOrAssignShortUid(Number(userId));
|
||||
// 从 Redis 登录态中获取用户名
|
||||
const userName = redisObj?.userName || redisObj?.UserName || `用户-${userId}`;
|
||||
console.log(`[WsAuthGuard] 用户 ${userId} 获得短 UID: ${shortUid}, userName: ${userName}`);
|
||||
|
||||
return {
|
||||
userId,
|
||||
role,
|
||||
shortUid,
|
||||
platform,
|
||||
userName,
|
||||
};
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
588
node_api/src/modules/websocket/meeting.websocket.ts
Normal file
588
node_api/src/modules/websocket/meeting.websocket.ts
Normal file
@ -0,0 +1,588 @@
|
||||
import {
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
type OnGatewayConnection,
|
||||
type OnGatewayDisconnect,
|
||||
type OnGatewayInit,
|
||||
SubscribeMessage,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
WsException,
|
||||
} from '@nestjs/websockets';
|
||||
import { UseFilters, UseGuards } from '@nestjs/common';
|
||||
import { MeetingRedisService } from './meeting-redis.service';
|
||||
import { MeetingService } from '../meeting/meeting.service';
|
||||
import { MeetingAuthGuard } from './meeting-auth.guard';
|
||||
import { WsExceptionFilter } from '@/common/filters/ws-exception.filter';
|
||||
import { LoggerService } from '@/plugins/logger/logger.service';
|
||||
import type {
|
||||
ClientToServerMessageType,
|
||||
ErrorMessage,
|
||||
JoinRoomData,
|
||||
KickUserData,
|
||||
MeetingNamespace,
|
||||
MeetingRemoteSocket,
|
||||
MeetingSocket,
|
||||
MuteUserData,
|
||||
SucceedMessage,
|
||||
} from './types';
|
||||
|
||||
@WebSocketGateway({
|
||||
// namespace 对应前端连接的 /meeting
|
||||
namespace: 'meeting',
|
||||
// 跨域(当前项目允许任意 origin)
|
||||
cors: { origin: '*' },
|
||||
})
|
||||
// 对所有 @SubscribeMessage 事件启用鉴权守卫
|
||||
@UseGuards(MeetingAuthGuard)
|
||||
// WebSocket 异常统一格式化输出
|
||||
@UseFilters(WsExceptionFilter)
|
||||
export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
|
||||
@WebSocketServer()
|
||||
// 注入 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
||||
public server: MeetingNamespace | null = null;
|
||||
|
||||
public constructor(
|
||||
// 房间/用户状态:Redis 持久化
|
||||
private readonly redisService: MeetingRedisService,
|
||||
// shortUid/token 等业务能力
|
||||
private readonly meetingService: MeetingService,
|
||||
// WebSocket 鉴权能力(用于 afterInit 中的 middleware)
|
||||
private readonly wsAuthGuard: MeetingAuthGuard,
|
||||
// 统一日志服务
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
private log(message: string): void {
|
||||
// 统一打到 MeetingWebSocket tag,便于检索
|
||||
this.logger.info({}, message, 'MeetingWebSocket');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找目标用户的所有 Socket(本节点 + 跨节点)
|
||||
* @param roomId - 服务端房间 ID(Socket.IO 房间名)
|
||||
* @param targetUid - 目标用户声网 shortUid
|
||||
* @returns 目标用户的所有 Socket 数组
|
||||
*/
|
||||
private async findTargetSockets(roomId: string, targetUid: number): Promise<MeetingRemoteSocket[]> {
|
||||
// namespace(@WebSocketServer 注入)可能在启动早期为空,保护性返回
|
||||
const ns = this.server;
|
||||
if (!ns) {
|
||||
return [];
|
||||
}
|
||||
// fetchSockets() 会返回本节点 Socket 或跨节点 RemoteSocket(包含 socket.data)
|
||||
const sockets = await ns.in(roomId).fetchSockets();
|
||||
// 通过 socket.data.user.shortUid 精准定位目标用户(同一用户可能多端在线)
|
||||
return sockets.filter((s) => s.data.user?.shortUid === targetUid);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理房间状态(如果已空)
|
||||
* 使用 Socket.IO 的 fetchSockets() 检查房间内是否有连接
|
||||
* 注意:fetchSockets() 会返回本节点和跨节点的 socket
|
||||
* @param roomId - 服务端房间 ID(Socket.IO 房间名)
|
||||
*/
|
||||
private async cleanupRoomIfEmpty(roomId: string): Promise<void> {
|
||||
const ns = this.server;
|
||||
if (!ns) {
|
||||
return;
|
||||
}
|
||||
// 使用 fetchSockets() 获取房间内的所有 socket(包括跨节点)
|
||||
const sockets = await ns.in(roomId).fetchSockets();
|
||||
// 如果房间内没有 socket 连接,则清理 Redis 数据
|
||||
if (sockets.length === 0) {
|
||||
await this.redisService.clearRoomAll(roomId);
|
||||
this.log(`[会议] 房间已空,已清理 Redis 状态:roomId=${roomId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化后,在 namespace 层添加连接鉴权 middleware
|
||||
* @param server - 注入的 Socket.IO namespace(带泛型,确保 server.in().fetchSockets() 等返回强类型)
|
||||
*/
|
||||
public afterInit(server: MeetingNamespace): void {
|
||||
// middleware:在 namespace 层做连接鉴权(用于 fetchSockets 时也能拿到 data.user)
|
||||
server.use(async (socket, next) => {
|
||||
try {
|
||||
// 解析握手信息并校验 Token,返回 user 信息
|
||||
const user = await this.wsAuthGuard.validateToken(socket);
|
||||
// 写入 socket.data(会被 fetchSockets() 带回)
|
||||
socket.data.user = user;
|
||||
// 放行连接
|
||||
next();
|
||||
} catch (e) {
|
||||
console.error('鉴权失败====', e);
|
||||
// 交给 socket.io 处理为 connect_error,前端可据此处理 Unauthorized
|
||||
next(new WsException('Unauthorized'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async handleConnection(socket: MeetingSocket): Promise<void> {
|
||||
// 读取鉴权 middleware 写入的 user
|
||||
const user = socket.data.user;
|
||||
if (!user) {
|
||||
// 理论上不应该发生(有 guard + middleware),但仍兜底断开
|
||||
this.logger.warn({}, `[MeetingWebSocket] WebSocket 认证失败,拒绝连接:${socket.id}`, 'MeetingWebSocket');
|
||||
socket.disconnect(true);
|
||||
return;
|
||||
}
|
||||
// 连接建立日志(便于排查 shortUid/role 等)
|
||||
this.log(`新 WebSocket 连接建立:${socket.id} (userId=${user.userId}, shortUid=${user.shortUid}, role=${user.role})`);
|
||||
}
|
||||
|
||||
public async handleDisconnect(socket: MeetingSocket): Promise<void> {
|
||||
// Socket.IO 会自动将 socket 从房间移除,这里仅做日志
|
||||
this.log(`WebSocket 连接断开:${socket.id}`);
|
||||
const roomId = socket.data.roomId;
|
||||
const shortUid = socket.data.user?.shortUid ? Number(socket.data.user.shortUid) : 0;
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
|
||||
// 移除 socketId 映射(避免下次加入时误判为设备冲突)
|
||||
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
|
||||
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||
}
|
||||
|
||||
// 如果是老师(创建者)主动断开连接,发送下课消息给所有人
|
||||
if (roomId && isHost) {
|
||||
this.log(`老师(创建者)断开连接,发送下课消息:roomId=${roomId}`);
|
||||
// 设置课堂状态为已结束
|
||||
await this.redisService.setClassStatus(roomId, 'finished');
|
||||
// 通知所有人下课
|
||||
this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } });
|
||||
// 清理 Redis 数据
|
||||
await this.redisService.clearRoomAll(roomId);
|
||||
}
|
||||
|
||||
if (roomId) {
|
||||
this.cleanupRoomIfEmpty(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_join_room')
|
||||
public async handleJoinRoom(@MessageBody() data: JoinRoomData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.courseRoomId) {
|
||||
this.logger.warn({}, '[会议] join_room 数据不完整', 'MeetingWebSocket');
|
||||
// 下发标准错误包(前端统一处理)
|
||||
socket.emit('message', { type: 'error', data: { reason: '数据不完整' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一为 string,避免 Redis key 不一致
|
||||
const courseRoomId = String(data.courseRoomId);
|
||||
// 直接使用 courseRoomId 作为 roomId
|
||||
const roomId = courseRoomId;
|
||||
|
||||
// 从鉴权后的 user 中获取 shortUid(鉴权通过就有短 ID)
|
||||
const shortUid = socket.data.user?.shortUid;
|
||||
const userName = socket.data.user?.userName || data.userName || '用户';
|
||||
|
||||
if (!shortUid) {
|
||||
this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '认证失败' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 黑名单校验(用短 UID 判断;被踢后不允许再次进入)
|
||||
const isKicked = await this.redisService.isUserKicked(roomId, shortUid);
|
||||
if (isKicked) {
|
||||
this.logger.warn({}, `[会议] 用户 ${shortUid} 已被踢出房间 ${roomId},拒绝重新加入`, 'MeetingWebSocket');
|
||||
socket.emit('message', {
|
||||
type: 'sev_kick_user',
|
||||
data: { fromRoomId: roomId, reason: '您已被创建者移出会议,无法重新加入' },
|
||||
} satisfies SucceedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查该用户是否已在其他设备加入了房间
|
||||
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid);
|
||||
if (existingSocketIds.length > 0) {
|
||||
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
|
||||
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
|
||||
|
||||
if (otherDeviceSocketIds.length > 0) {
|
||||
// 验证旧连接是否真的存在(可能用户已断开但 Redis 未清理)
|
||||
// 使用 Promise.all 并行验证所有旧连接
|
||||
const validationResults = await Promise.all(
|
||||
otherDeviceSocketIds.map(async (oldSocketId) => {
|
||||
const sockets = await this.server?.in(oldSocketId).fetchSockets();
|
||||
return { oldSocketId, sockets, isValid: sockets ? sockets.length > 0 : false };
|
||||
})
|
||||
);
|
||||
|
||||
// 处理有效的旧连接:发送通知并断开
|
||||
const validSocketIds: string[] = [];
|
||||
for (const result of validationResults) {
|
||||
if (result.isValid && result.sockets) {
|
||||
this.server?.to(result.oldSocketId).emit('message', {
|
||||
type: 'sev_device_conflict',
|
||||
data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid },
|
||||
} satisfies SucceedMessage);
|
||||
result.sockets.forEach((s) => s.disconnect(true));
|
||||
validSocketIds.push(result.oldSocketId);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理所有旧的 socketId(无论连接是否还存在)
|
||||
await Promise.all(otherDeviceSocketIds.map((oldSocketId) => this.redisService.removeSocket(roomId, shortUid, oldSocketId).catch(() => {})));
|
||||
if (validSocketIds.length > 0) {
|
||||
this.log(`用户 ${userName}(短 UID:${shortUid}) 在其他设备加入,已踢出旧连接,房间 ${roomId}`);
|
||||
}
|
||||
} else {
|
||||
// 当前 socket 已在 Redis 中存在(同一设备刷新等情况),清理旧的并允许加入
|
||||
this.log(`用户 ${userName}(短 UID:${shortUid}) 同一设备重新加入,房间 ${roomId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复用户状态(如果之前被禁麦/禁视频)
|
||||
const userState = await this.redisService.getUserState(roomId, shortUid);
|
||||
|
||||
try {
|
||||
// 加入 Socket.IO 房间(用于广播/按房间查找)
|
||||
socket.join(roomId);
|
||||
socket.data.roomId = roomId;
|
||||
socket.data.courseRoomId = courseRoomId;
|
||||
|
||||
// 注册 socketId 到 Redis(用于踢人、禁麦等控制功能)
|
||||
await this.redisService.addSocket(roomId, shortUid, socket.id);
|
||||
// 添加用户到房间用户列表
|
||||
await this.redisService.addUserToRoom(roomId, shortUid);
|
||||
|
||||
// 获取房间状态
|
||||
const roomState = await this.redisService.getRoomState(roomId);
|
||||
const existedTeacherUid = Number.isFinite(roomState.teacherUid) ? Number(roomState.teacherUid) : 0;
|
||||
let teacherUid = existedTeacherUid > 0 ? existedTeacherUid : 0;
|
||||
if (teacherUid <= 0) {
|
||||
const homeworkId = Number(data?.homeworkId);
|
||||
const resolvedTeacherUid = Number.isFinite(homeworkId) && homeworkId > 0 ? await this.meetingService.getTeacherShortUidByHomeworkId(homeworkId) : null;
|
||||
teacherUid = resolvedTeacherUid && resolvedTeacherUid > 0 ? resolvedTeacherUid : shortUid;
|
||||
await this.redisService.setTeacherUid(roomId, teacherUid).catch(() => {});
|
||||
}
|
||||
const existedSpeakerUid = Number.isFinite(roomState.speakerUid) ? Number(roomState.speakerUid) : 0;
|
||||
const speakerUid = existedSpeakerUid > 0 ? existedSpeakerUid : teacherUid;
|
||||
if (existedSpeakerUid <= 0) {
|
||||
await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {});
|
||||
}
|
||||
|
||||
// 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态
|
||||
socket.emit('message', {
|
||||
type: 'sev_join_room',
|
||||
data: {
|
||||
roomId,
|
||||
shortUid,
|
||||
tokenInfo: this.meetingService.generateToken(roomId, shortUid)!,
|
||||
isAudioMuted: userState.isAudioMuted,
|
||||
isVideoMuted: userState.isVideoMuted,
|
||||
classStatus: roomState.classStatus,
|
||||
screenShareUid: roomState.screenShareUid,
|
||||
speakerUid,
|
||||
teacherUid,
|
||||
},
|
||||
});
|
||||
|
||||
// 成功日志
|
||||
this.log(`用户 ${userName}(短 UID:${shortUid}) 加入房间 ${roomId}`);
|
||||
} catch (error) {
|
||||
// 记录错误后抛出,交由 WsExceptionFilter 统一处理
|
||||
this.logger.error({}, `[会议] 为用户 ${shortUid} 分配短 UID 失败`, 'MeetingWebSocket');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_leave_room')
|
||||
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// Socket.IO 会自动处理离房逻辑,这里仅记录
|
||||
this.log(`用户离开房间:${socket.id}`);
|
||||
const roomId: string | undefined = socket.data.roomId;
|
||||
const shortUid = socket.data.user?.shortUid ? Number(socket.data.user.shortUid) : 0;
|
||||
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
|
||||
// 移除 socketId 映射
|
||||
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
|
||||
// 从房间用户列表移除
|
||||
await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {});
|
||||
// 清理用户状态
|
||||
await this.redisService.clearUserState(roomId, shortUid).catch(() => {});
|
||||
}
|
||||
try {
|
||||
roomId && socket.leave(roomId);
|
||||
} catch {}
|
||||
socket.data.roomId = undefined;
|
||||
socket.data.courseRoomId = undefined;
|
||||
if (roomId) {
|
||||
await this.cleanupRoomIfEmpty(roomId);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_kick_user')
|
||||
public async handleKickUser(@MessageBody() data: KickUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] kick_user 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可踢人
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试踢人:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据 Redis 中的 socketId 列表定位连接
|
||||
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取用户名用于黑名单
|
||||
const targetSockets = await this.findTargetSockets(data.roomId, data.targetUid);
|
||||
const targetUserName = targetSockets[0]?.data?.user?.userName || `用户-${data.targetUid}`;
|
||||
|
||||
// 对该用户的所有连接发送踢出通知并断开连接
|
||||
for (const id of socketIds) {
|
||||
this.server?.to(id).emit('message', { type: 'sev_kick_user', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
this.server?.in(id).disconnectSockets(true);
|
||||
}
|
||||
|
||||
// 写入黑名单(用短 UID),禁止重连
|
||||
await this.redisService.addToBlacklist(data.roomId, data.targetUid, targetUserName);
|
||||
|
||||
// 成功日志
|
||||
this.log(`用户 shortUid=${data.targetUid} 已被踢出房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_mute_audio')
|
||||
public async handleMuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] mute_audio 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可禁麦
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试禁麦:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下发禁麦通知(由前端执行 Agora unpublish/setEnabled)
|
||||
for (const id of socketIds) {
|
||||
this.server?.to(id).emit('message', { type: 'sev_mute_audio', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
}
|
||||
// 写入 Redis:下次加入/重连时恢复禁麦状态
|
||||
await this.redisService.setUserState(data.roomId, data.targetUid, { isAudioMuted: true });
|
||||
this.log(`用户 ${data.targetUid} 已被禁麦,房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_unmute_audio')
|
||||
public async handleUnmuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] unmute_audio 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可解除禁麦
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试解除禁麦:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下发解除禁麦通知
|
||||
for (const id of socketIds) {
|
||||
this.server?.to(id).emit('message', { type: 'sev_unmute_audio', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
}
|
||||
// 写入 Redis:下次加入/重连时恢复状态
|
||||
await this.redisService.setUserState(data.roomId, data.targetUid, { isAudioMuted: false });
|
||||
this.log(`用户 ${data.targetUid} 已被解除禁麦,房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_mute_video')
|
||||
public async handleMuteVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] mute_video 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可禁视频
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试禁视频:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下发禁视频通知(由前端执行 Agora unpublish/setEnabled)
|
||||
for (const id of socketIds) {
|
||||
this.server?.to(id).emit('message', { type: 'sev_mute_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
}
|
||||
// 写入 Redis:下次加入/重连时恢复禁视频状态
|
||||
await this.redisService.setUserState(data.roomId, data.targetUid, { isVideoMuted: true });
|
||||
this.log(`用户 ${data.targetUid} 已被禁视频,房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_unmute_video')
|
||||
public async handleUnmuteVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 基础参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] unmute_video 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验:仅创建者/管理员可解除禁视频
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试解除禁视频:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
|
||||
if (socketIds.length === 0) {
|
||||
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
|
||||
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 下发解除禁视频通知
|
||||
for (const id of socketIds) {
|
||||
this.server?.to(id).emit('message', { type: 'sev_unmute_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
}
|
||||
// 写入 Redis:下次加入/重连时恢复状态
|
||||
await this.redisService.setUserState(data.roomId, data.targetUid, { isVideoMuted: false });
|
||||
this.log(`用户 ${data.targetUid} 已被解除禁视频,房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理设置全员主屏消息(仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_set_main_video')
|
||||
public async handleSetMainVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
// 参数校验
|
||||
if (!data?.targetUid || !data.roomId) {
|
||||
this.logger.warn({}, '[会议] set_main_video 数据不完整', 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限校验
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试设置主屏:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
|
||||
// 广播给整个房间
|
||||
const ns = this.server;
|
||||
if (!ns) {
|
||||
return;
|
||||
}
|
||||
ns.to(data.roomId).emit('message', { type: 'sev_set_main_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
|
||||
await this.redisService.setSpeaker(data.roomId, Number(data.targetUid)).catch(() => {});
|
||||
this.log(`已设置全员主屏:targetUid=${data.targetUid} 房间 ${data.roomId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师上课:允许推流
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_start_class')
|
||||
public async handleStartClass(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
return;
|
||||
}
|
||||
await this.redisService.setClassStatus(data.roomId, 'in_class');
|
||||
// 通知所有人可以开始推流了
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_class_started', data: { fromRoomId: data.roomId } });
|
||||
this.log(`房间 ${data.roomId} 上课开始`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 老师下课:停止推流并清理本节课数据(保持 socket 连接)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_end_class')
|
||||
public async handleEndClass(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
return;
|
||||
}
|
||||
await this.redisService.setClassStatus(data.roomId, 'finished');
|
||||
// 通知所有人结束推流(由前端停止/离开 RTC)
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: data.roomId } });
|
||||
// 清理本节课全部 Redis 数据(不清理黑名单)
|
||||
await this.redisService.clearClassData(data.roomId);
|
||||
this.log(`房间 ${data.roomId} 下课并清理 Redis 数据`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理开始投屏消息(仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
|
||||
public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试开始投屏:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||
// 保存投屏状态到 Redis
|
||||
await this.redisService.setScreenSharing(data.roomId, targetUid);
|
||||
// 广播给整个房间:有人开始投屏
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid } });
|
||||
this.log(`创建者开始投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理停止投屏消息(仅创建者)
|
||||
*/
|
||||
@SubscribeMessage<ClientToServerMessageType>('client_stop_screen_share')
|
||||
public async handleStopScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
|
||||
if (!data?.roomId) {
|
||||
return;
|
||||
}
|
||||
const isHost = socket.data.user?.role !== 0;
|
||||
if (!isHost) {
|
||||
this.logger.warn({}, `[会议] 非创建者尝试停止投屏:userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
|
||||
return;
|
||||
}
|
||||
const targetUid = socket.data.user?.shortUid ?? 0;
|
||||
// 清除投屏状态
|
||||
await this.redisService.setScreenSharing(data.roomId, null);
|
||||
// 广播给整个房间:有人停止投屏
|
||||
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid } });
|
||||
this.log(`创建者停止投屏:roomId=${data.roomId}, shortUid=${targetUid}`);
|
||||
}
|
||||
}
|
||||
333
node_api/src/modules/websocket/types.ts
Normal file
333
node_api/src/modules/websocket/types.ts
Normal file
@ -0,0 +1,333 @@
|
||||
/**
|
||||
* meeting WebSocket 模块的“类型总出口”
|
||||
*
|
||||
* 目标:
|
||||
* - 把 socket.io Server/Namespace/Socket 的泛型参数一次性定义清楚
|
||||
* - 让 meeting.websocket.ts 不再依赖 any/as any 来访问 socket.data.user 或事件名
|
||||
* - 让 fetchSockets() 返回的 RemoteSocket 拥有正确的 data 类型
|
||||
*/
|
||||
import type { Namespace, RemoteSocket, Server, Socket } from 'socket.io';
|
||||
|
||||
export interface MeetingWsUser {
|
||||
/** 用户长 ID(来自 Token 中的 nameidentifier claim) */
|
||||
userId: string;
|
||||
/** 角色:0 学生,非 0 代表创建者/管理员(和当前业务一致) */
|
||||
role: number;
|
||||
/** 声网短 UID(用于加入 RTC 频道) */
|
||||
shortUid: number;
|
||||
/** 平台标识(从 Custom-Platform 解析得来) */
|
||||
platform: string;
|
||||
/** 用户名称(用于展示和黑名单) */
|
||||
userName: string;
|
||||
}
|
||||
|
||||
export interface MeetingSocketData {
|
||||
/**
|
||||
* 通过 MeetingAuthGuard 注入到 socket.data 中的用户信息
|
||||
* - 该字段会被包含在 fetchSockets() 返回结果的 data 里
|
||||
* - 因此这是“跨节点查找用户 / 过滤用户”的关键字段
|
||||
*/
|
||||
user?: MeetingWsUser;
|
||||
|
||||
/**
|
||||
* 当前 socket 加入的业务房间 ID(Socket.IO 房间名)
|
||||
* - 由 handleJoinRoom 写入
|
||||
* - 由 handleLeaveRoom/handleDisconnect 用于判断“房间是否已空”
|
||||
*/
|
||||
roomId?: string;
|
||||
|
||||
/**
|
||||
* 课程房间 ID(前端路由中的 id)
|
||||
* - 主要用于清理 courseRoomId -> roomId 的映射 key
|
||||
*/
|
||||
courseRoomId?: string;
|
||||
}
|
||||
|
||||
export interface MeetingJoinRoomData {
|
||||
/** 课程房间 ID(前端路由参数) */
|
||||
courseRoomId: string;
|
||||
/** 用户长 ID(数据库中的真实用户 ID,用于分配 shortUid) */
|
||||
longUserId: number;
|
||||
/** 展示用用户名 */
|
||||
userName: string;
|
||||
/** 是否创建者(可选;当前后端实际仍以 socket.data.user.role 判断权限) */
|
||||
isHost?: boolean;
|
||||
}
|
||||
|
||||
export interface MeetingKickUserData {
|
||||
/** 目标用户声网 shortUid */
|
||||
targetUid: number;
|
||||
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface MeetingLeaveRoomData {
|
||||
/** 课程房间 ID(用于业务侧记录/日志;Socket.IO 会自动离开房间) */
|
||||
courseRoomId: string;
|
||||
}
|
||||
|
||||
export interface MeetingMuteUserData {
|
||||
/** 目标用户声网 shortUid */
|
||||
targetUid: number;
|
||||
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 投屏数据(上行:客户端 -> 服务端)
|
||||
*/
|
||||
export interface MeetingScreenShareData {
|
||||
/** 房间 ID(服务端实际 Socket.IO 房间名) */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 投屏通知数据(下行:服务端 -> 客户端)
|
||||
*/
|
||||
export interface MeetingScreenShareNotifyData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 投屏用户短 UID */
|
||||
targetUid: number;
|
||||
}
|
||||
|
||||
export interface MeetingClientToServerEvents {
|
||||
/** 客户端加入会议房间(join + 分配 shortUid + 返回 tokenInfo + 恢复禁用状态) */
|
||||
client_join_room: (data: MeetingJoinRoomData) => void;
|
||||
/** 客户端离开会议房间(业务事件;Socket.IO 会自动处理房间成员移除) */
|
||||
client_leave_room: (data: MeetingLeaveRoomData) => void;
|
||||
/** 创建者踢人 */
|
||||
client_kick_user: (data: MeetingKickUserData) => void;
|
||||
/** 创建者禁麦 */
|
||||
client_mute_audio: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者解除禁麦 */
|
||||
client_unmute_audio: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者禁视频 */
|
||||
client_mute_video: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者解除禁视频 */
|
||||
client_unmute_video: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者设置某个用户为全员主屏 */
|
||||
client_set_main_video: (data: MeetingMuteUserData) => void;
|
||||
/** 创建者开始投屏 */
|
||||
client_start_screen_share: (data: MeetingScreenShareData) => void;
|
||||
/** 创建者停止投屏 */
|
||||
client_stop_screen_share: (data: MeetingScreenShareData) => void;
|
||||
}
|
||||
|
||||
export interface MeetingWsMessagePacket<T extends string = string, D = unknown> {
|
||||
/** 消息类型(sev_* / error 等) */
|
||||
type: T;
|
||||
/** 业务数据 */
|
||||
data: D;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token 响应数据 DTO
|
||||
*/
|
||||
export interface TokenResponseDto {
|
||||
/** 声网appid */
|
||||
appid: string;
|
||||
/** 声网token */
|
||||
rtcToken: string;
|
||||
/** 过期时间 */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface MeetingJoinRoomSuccessData {
|
||||
/** 服务端实际房间 ID(用于后续控制指令中的 roomId) */
|
||||
roomId: string;
|
||||
/** 当前用户声网短 UID */
|
||||
shortUid: number;
|
||||
/** 声网 token 信息(当前项目中由 meetingService.generateToken 生成) */
|
||||
tokenInfo: TokenResponseDto;
|
||||
/** 恢复状态:是否被禁麦 */
|
||||
isAudioMuted: boolean;
|
||||
/** 恢复状态:是否被禁视频 */
|
||||
isVideoMuted: boolean;
|
||||
/** 课堂状态:not_started(未开始)/ in_class(上课中)/ finished(已下课) */
|
||||
classStatus: 'finished' | 'in_class' | 'not_started';
|
||||
/** 投屏状态:正在投屏的用户短 UID,undefined 表示无人投屏 */
|
||||
screenShareUid?: number;
|
||||
/** 上台的短uid,默认为老师的uid */
|
||||
speakerUid: number;
|
||||
/** 老师的短uid */
|
||||
teacherUid: number;
|
||||
}
|
||||
|
||||
export interface MeetingKickedData {
|
||||
/** 来源房间(可选) */
|
||||
fromRoomId?: string;
|
||||
/** 提示原因(可选) */
|
||||
reason?: string;
|
||||
/** 被踢出的用户短 UID(用于前端同步状态) */
|
||||
targetUid?: number;
|
||||
}
|
||||
|
||||
export interface MeetingControlData {
|
||||
/** 来源房间(用于前端提示/一致性校验) */
|
||||
fromRoomId: string;
|
||||
/** 被控制的用户短 UID(用于前端同步状态) */
|
||||
targetUid?: number;
|
||||
}
|
||||
|
||||
export interface MeetingSetMainVideoData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
/** 被设置为主屏的用户短 UID */
|
||||
targetUid: number;
|
||||
}
|
||||
|
||||
export interface MeetingClassStateData {
|
||||
/** 来源房间 */
|
||||
fromRoomId: string;
|
||||
}
|
||||
|
||||
export type MeetingDownlinkPacket =
|
||||
/** 统一错误包(WsExceptionFilter/业务侧主动 emit 的错误) */
|
||||
| MeetingWsMessagePacket<'error', { reason: string; code?: number }>
|
||||
/** 下课通知 */
|
||||
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
|
||||
/** 上课通知 */
|
||||
| MeetingWsMessagePacket<'sev_class_started', MeetingClassStateData>
|
||||
/** 设备冲突通知(多设备登录被踢出) */
|
||||
| MeetingWsMessagePacket<'sev_device_conflict', MeetingKickedData>
|
||||
/** 加入房间成功响应 */
|
||||
| MeetingWsMessagePacket<'sev_join_room', MeetingJoinRoomSuccessData>
|
||||
/** 被踢出通知 */
|
||||
| MeetingWsMessagePacket<'sev_kick_user', MeetingKickedData>
|
||||
/** 禁麦通知 */
|
||||
| MeetingWsMessagePacket<'sev_mute_audio', MeetingControlData>
|
||||
/** 禁视频通知 */
|
||||
| MeetingWsMessagePacket<'sev_mute_video', MeetingControlData>
|
||||
/** 设置主屏通知 */
|
||||
| MeetingWsMessagePacket<'sev_set_main_video', MeetingSetMainVideoData>
|
||||
/** 开始投屏通知 */
|
||||
| MeetingWsMessagePacket<'sev_start_screen_share', MeetingScreenShareNotifyData>
|
||||
/** 停止投屏通知 */
|
||||
| MeetingWsMessagePacket<'sev_stop_screen_share', MeetingScreenShareNotifyData>
|
||||
/** 解除禁麦通知 */
|
||||
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
|
||||
/** 解除禁视频通知 */
|
||||
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>;
|
||||
|
||||
export interface MeetingServerToClientEvents {
|
||||
/** 约定:服务端统一通过 message 事件下发业务包(packet.type 决定语义) */
|
||||
message: (packet: MeetingDownlinkPacket) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端 -> 服务端(上发消息)
|
||||
*/
|
||||
export type ClientToServerMessageType =
|
||||
| 'client_end_class' // 结束课程(仅创建者)
|
||||
| 'client_join_room' // 加入会议房间
|
||||
| 'client_kick_user' // 踢出指定用户(仅创建者)
|
||||
| 'client_leave_room' // 离开会议房间
|
||||
| 'client_mute_audio' // 禁麦指定用户(仅创建者)
|
||||
| 'client_mute_video' // 禁视频指定用户(仅创建者)
|
||||
| 'client_set_main_video' // 设置主屏用户
|
||||
| 'client_start_class' // 开始课程(仅创建者)
|
||||
| 'client_start_screen_share' // 开始投屏
|
||||
| 'client_stop_screen_share' // 停止投屏
|
||||
| 'client_unmute_audio' // 解除禁麦(仅创建者)
|
||||
| 'client_unmute_video'; // 解除禁视频(仅创建者)
|
||||
|
||||
/**
|
||||
* 服务端 -> 客户端(下发消息)
|
||||
*/
|
||||
export type ServerToClientMessageType =
|
||||
| 'sev_class_ended' // 下课通知
|
||||
| 'sev_class_started' // 上课通知
|
||||
| 'sev_device_conflict' // 设备冲突通知(多设备登录被踢出)
|
||||
| 'sev_join_room' // 加入房间成功响应
|
||||
| 'sev_kick_user' // 被踢出通知(创建者踢人)
|
||||
| 'sev_mute_audio' // 禁麦通知(转发给目标用户)
|
||||
| 'sev_mute_video' // 禁视频通知(转发给目标用户)
|
||||
| 'sev_set_main_video' // 设置主屏通知
|
||||
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
|
||||
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
|
||||
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
|
||||
| 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户)
|
||||
|
||||
export interface ErrorMessage {
|
||||
// 固定为 error,便于前端统一处理
|
||||
type: 'error';
|
||||
// 错误信息载体
|
||||
data: { reason: string; code?: number };
|
||||
}
|
||||
|
||||
export interface SucceedMessage {
|
||||
// 下发消息类型(sev_*)
|
||||
type: ServerToClientMessageType;
|
||||
// 最小化返回字段:来源房间 + 可选原因 + 可选目标用户
|
||||
data: {
|
||||
reason?: string;
|
||||
fromRoomId: string;
|
||||
targetUid?: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入房间数据
|
||||
*/
|
||||
export interface JoinRoomData {
|
||||
// 课程房间 ID(前端传入)
|
||||
courseRoomId: string;
|
||||
/** 作业 ID */
|
||||
homeworkId: number;
|
||||
// 用户名(仅用于日志/展示)
|
||||
userName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 踢人数据
|
||||
*/
|
||||
export interface KickUserData {
|
||||
// 目标用户声网 shortUid
|
||||
targetUid: number;
|
||||
// 房间 ID(Socket.IO 房间名)
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface MuteUserData {
|
||||
// 目标用户声网 shortUid
|
||||
targetUid: number;
|
||||
// 房间 ID(Socket.IO 房间名)
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户状态(用于恢复被禁麦/禁视频状态,以及管理多设备连接)
|
||||
*/
|
||||
export interface UserPermissionState {
|
||||
/** 是否被禁麦 */
|
||||
isAudioMuted: boolean;
|
||||
/** 是否被禁视频 */
|
||||
isVideoMuted: boolean;
|
||||
/** 用户的所有 Socket 连接 ID(支持多设备同时在线) */
|
||||
socketIds: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 黑名单用户信息
|
||||
*/
|
||||
export interface BlacklistUser {
|
||||
/** 短 UID */
|
||||
shortUid: number;
|
||||
/** 用户名称 */
|
||||
userName: string;
|
||||
}
|
||||
/**
|
||||
* 强类型化的 Server / Namespace / Socket / RemoteSocket
|
||||
*
|
||||
* 对应 socket.io v4 的泛型定义:
|
||||
* Server<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
|
||||
* - ListenEvents:客户端 -> 服务端(socket.on / @SubscribeMessage)
|
||||
* - EmitEvents:服务端 -> 客户端(socket.emit)
|
||||
* - ServerSideEvents:服务器之间 serverSideEmit(可选)
|
||||
* - SocketData:socket.data 中可持久化/可被 fetchSockets 取回的数据
|
||||
*/
|
||||
export type MeetingServer = Server<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
|
||||
export type MeetingNamespace = Namespace<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
|
||||
export type MeetingSocket = Socket<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
|
||||
export type MeetingRemoteSocket = RemoteSocket<MeetingServerToClientEvents, MeetingSocketData>;
|
||||
19
node_api/src/modules/websocket/websocket.module.ts
Normal file
19
node_api/src/modules/websocket/websocket.module.ts
Normal file
@ -0,0 +1,19 @@
|
||||
/**
|
||||
* WebSocket 模块
|
||||
* 包含 MeetingWebSocketGateway、MeetingAuthGuard、WsExceptionFilter
|
||||
*/
|
||||
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MeetingWebSocketGateway } from './meeting.websocket';
|
||||
import { MeetingAuthGuard } from './meeting-auth.guard';
|
||||
import { WsExceptionFilter } from '@/common/filters/ws-exception.filter';
|
||||
import { LoggerService } from '@/plugins/logger/logger.service';
|
||||
import { MeetingModule } from '../meeting/meeting.module';
|
||||
import { MeetingRedisService } from './meeting-redis.service';
|
||||
|
||||
@Module({
|
||||
providers: [MeetingWebSocketGateway, MeetingAuthGuard, WsExceptionFilter, LoggerService, MeetingRedisService],
|
||||
imports: [forwardRef(() => MeetingModule)],
|
||||
exports: [MeetingWebSocketGateway, MeetingRedisService],
|
||||
})
|
||||
export class WebsocketModule {}
|
||||
Reference in New Issue
Block a user