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:
2026-03-13 10:03:05 +08:00
commit 78af453fe1
357 changed files with 70605 additions and 0 deletions

View 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,
};
}
}

View 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 - 主讲人短 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): 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 中提取 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);
}
// ==================== 清理 ====================
/**
* 清理课程状态(下课时调用,不清理黑名单)
* 清理:房间状态、用户状态(包含 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);
}
}

View 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 - 服务端房间 IDSocket.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 - 服务端房间 IDSocket.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}`);
}
}

View 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 加入的业务房间 IDSocket.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';
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
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;
// 房间 IDSocket.IO 房间名)
roomId: string;
}
export interface MuteUserData {
// 目标用户声网 shortUid
targetUid: number;
// 房间 IDSocket.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可选
* - SocketDatasocket.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>;

View 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 {}