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