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:
29
node_api/src/app.module.ts
Normal file
29
node_api/src/app.module.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { LoggerModule } from './plugins/logger/logger.module';
|
||||
import { RedisModule } from './plugins/redis/redis.module';
|
||||
import { MikroOrmConfigModule } from './plugins/mikro-orm/mikro-orm.module';
|
||||
import { NacosConfigModule } from './plugins/nacos/nacos.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
import { WebsocketModule } from './modules/websocket/websocket.module';
|
||||
import { AuthGuard } from './common/guards/auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.development', '.env.production'],
|
||||
}),
|
||||
// 全局基础设施模块
|
||||
LoggerModule,
|
||||
RedisModule,
|
||||
MikroOrmConfigModule,
|
||||
NacosConfigModule,
|
||||
// 业务模块
|
||||
MeetingModule,
|
||||
WebsocketModule,
|
||||
],
|
||||
providers: [{ provide: APP_GUARD, useClass: AuthGuard }],
|
||||
})
|
||||
export class AppModule {}
|
||||
23
node_api/src/common/decorators/auth-user.decorator.ts
Normal file
23
node_api/src/common/decorators/auth-user.decorator.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
|
||||
|
||||
export const AuthUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const user = req.user;
|
||||
return data ? user?.[data] : user;
|
||||
});
|
||||
|
||||
/**
|
||||
* 当前用户的uid
|
||||
*/
|
||||
export const UserId = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user?.userId;
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取当前用户角色
|
||||
*/
|
||||
export const UserRole = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
return req.user?.role;
|
||||
});
|
||||
214
node_api/src/common/decorators/swagger.decorator.ts
Normal file
214
node_api/src/common/decorators/swagger.decorator.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { type Type, applyDecorators } from '@nestjs/common';
|
||||
import { ApiExtraModels, ApiOkResponse, ApiOperation, getSchemaPath } from '@nestjs/swagger';
|
||||
import { OkResult } from '../dto/result.dto';
|
||||
|
||||
type ApiCustomOkResponseOP<T> = {
|
||||
/** 接口名称 */
|
||||
summary: string;
|
||||
|
||||
/** 接口说明 */
|
||||
apiDescription?: string;
|
||||
|
||||
/** 返回说明 */
|
||||
resDescription?: string;
|
||||
|
||||
/** 默认值 */
|
||||
default?: unknown;
|
||||
|
||||
/**
|
||||
* 接口实例
|
||||
* - 传入 DTO 类(如 TokenResponseDto):使用该类作为 data 的类型
|
||||
* - 传入 null/undefined:data 为 null 类型
|
||||
* - 传入 Boolean/Number/String:使用对应的基本类型
|
||||
* - 传入 'boolean'/'number'/'string':使用对应的基本类型(字符串形式)
|
||||
* - 传入 [Boolean]/[Number]/[String]:使用对应的数组类型
|
||||
* - 传入 ['boolean']/['number']/['string']:使用对应的数组类型(字符串形式)
|
||||
* - 传入 [TokenResponseDto]:使用 DTO 数组类型
|
||||
* - 传入 [null]:使用 null 数组类型
|
||||
*/
|
||||
model: T;
|
||||
};
|
||||
|
||||
/** DTO 类型 */
|
||||
type DtoType = Type<unknown>;
|
||||
|
||||
/** 基本类型映射(构造函数 -> 字符串类型) */
|
||||
const primitiveTypeMap = new Map<unknown, string>([
|
||||
[Boolean, 'boolean'],
|
||||
[Number, 'number'],
|
||||
[String, 'string'],
|
||||
]);
|
||||
|
||||
/** 字符串基本类型集合(用于直接指定类型) */
|
||||
const primitiveStringTypes = new Set(['boolean', 'number', 'string']);
|
||||
|
||||
/** Schema 类型定义 */
|
||||
type SchemaType = { type: string; items?: { type: string; $ref?: string }; $ref?: string } | null;
|
||||
|
||||
/** 获取 schema 类型 */
|
||||
function getSchemaType(model: unknown): SchemaType {
|
||||
// 处理 null 和 undefined
|
||||
if (model === null || model === undefined) {
|
||||
return { type: 'null' };
|
||||
}
|
||||
|
||||
// 处理数组类型
|
||||
if (Array.isArray(model)) {
|
||||
if (model.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const firstItem = model[0];
|
||||
// [null] 或 [undefined]
|
||||
if (firstItem === null || firstItem === undefined) {
|
||||
return { type: 'array', items: { type: 'null' } };
|
||||
}
|
||||
// ['boolean'], ['number'], ['string']
|
||||
if (typeof firstItem === 'string' && primitiveStringTypes.has(firstItem)) {
|
||||
return { type: 'array', items: { type: firstItem } };
|
||||
}
|
||||
// [Boolean], [Number], [String]
|
||||
const primitiveType = primitiveTypeMap.get(firstItem);
|
||||
if (primitiveType) {
|
||||
return {
|
||||
type: 'array',
|
||||
items: { type: primitiveType },
|
||||
};
|
||||
}
|
||||
|
||||
// [TokenResponseDto], [BlacklistUserDto] 等 DTO 类数组
|
||||
if (typeof firstItem === 'function') {
|
||||
return {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
$ref: getSchemaPath(firstItem as Type<unknown>),
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 'boolean', 'number', 'string'
|
||||
if (typeof model === 'string' && primitiveStringTypes.has(model)) {
|
||||
return { type: model };
|
||||
}
|
||||
|
||||
// Boolean, Number, String
|
||||
const primitiveType = primitiveTypeMap.get(model);
|
||||
if (primitiveType) {
|
||||
return { type: primitiveType };
|
||||
}
|
||||
|
||||
// TokenResponseDto, BlacklistUserDto 等 DTO 类 - 返回 null,让 else 分支处理
|
||||
if (typeof model === 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 基本类型字符串 */
|
||||
type PrimitiveString = 'boolean' | 'number' | 'string';
|
||||
|
||||
/** 基本类型构造函数 */
|
||||
type PrimitiveCtor = BooleanConstructor | NumberConstructor | StringConstructor;
|
||||
|
||||
/** 自定义返回 */
|
||||
export function ApiCustomOkResponse<T extends Array<DtoType | PrimitiveCtor | PrimitiveString | null> | DtoType | PrimitiveCtor | PrimitiveString | null>(
|
||||
op: ApiCustomOkResponseOP<T>
|
||||
) {
|
||||
/** 额外需要导入的类型 */
|
||||
const extraModels: Type<unknown>[] = [OkResult];
|
||||
|
||||
const arr = [
|
||||
ApiOperation({
|
||||
summary: op.summary,
|
||||
description: op.apiDescription,
|
||||
}),
|
||||
];
|
||||
|
||||
const schemaType = getSchemaType(op.model);
|
||||
|
||||
if (schemaType) {
|
||||
// 基本类型或数组类型
|
||||
const isNullType = schemaType.type === 'null';
|
||||
const { items, ...restSchema } = schemaType;
|
||||
const dataProps: any = {
|
||||
...restSchema,
|
||||
description: op.resDescription || '主体内容',
|
||||
default: op.default,
|
||||
example: isNullType ? null : undefined,
|
||||
};
|
||||
if (items) {
|
||||
dataProps.items = items;
|
||||
}
|
||||
arr.push(
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
description: op.resDescription || '主体内容',
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(OkResult) },
|
||||
{
|
||||
properties: {
|
||||
data: dataProps,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
} else if (Array.isArray(op.model)) {
|
||||
// DTO 数组类型 [TokenResponseDto]
|
||||
const itemModel = op.model[0];
|
||||
if (itemModel && typeof itemModel === 'function') {
|
||||
extraModels.push(itemModel);
|
||||
arr.push(
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
description: op.resDescription || '主体内容',
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(OkResult) },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: {
|
||||
$ref: getSchemaPath(itemModel),
|
||||
},
|
||||
description: op.resDescription || '主体内容',
|
||||
default: op.default,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} else if (typeof op.model === 'function') {
|
||||
// 单个 DTO 类 TokenResponseDto
|
||||
extraModels.push(op.model as Type<unknown>);
|
||||
arr.push(
|
||||
ApiOkResponse({
|
||||
schema: {
|
||||
description: op.resDescription || '主体内容',
|
||||
allOf: [
|
||||
{ $ref: getSchemaPath(OkResult) },
|
||||
{
|
||||
properties: {
|
||||
data: {
|
||||
type: 'object',
|
||||
$ref: getSchemaPath(op.model as Type<unknown>),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
arr.push(ApiExtraModels(...extraModels));
|
||||
|
||||
return applyDecorators(...arr);
|
||||
}
|
||||
42
node_api/src/common/dto/result.dto.ts
Normal file
42
node_api/src/common/dto/result.dto.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 通用响应结果 DTO
|
||||
*/
|
||||
export class Result<T = any> {
|
||||
/** 状态码 */
|
||||
@ApiProperty({ description: '状态码', example: 200 })
|
||||
code!: number;
|
||||
|
||||
/** 返回的数据 */
|
||||
@ApiProperty({ description: '数据', nullable: true })
|
||||
data!: T | null;
|
||||
|
||||
/** 提示消息 */
|
||||
@ApiProperty({ description: '消息', example: 'success' })
|
||||
msg!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应结果
|
||||
*/
|
||||
export class OkResult<T = any> extends Result<T> {
|
||||
constructor(data: T, msg?: string, code?: number) {
|
||||
super();
|
||||
this.code = code ?? 200;
|
||||
this.data = data;
|
||||
this.msg = msg ?? 'success';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败响应结果
|
||||
*/
|
||||
export class FailResult<T = any> extends Result<T> {
|
||||
constructor(msg?: string, data?: T, code?: number) {
|
||||
super();
|
||||
this.code = code ?? 500;
|
||||
this.data = data ?? null;
|
||||
this.msg = msg ?? 'error';
|
||||
}
|
||||
}
|
||||
8
node_api/src/common/exceptions/custom.exception.ts
Normal file
8
node_api/src/common/exceptions/custom.exception.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
/** 自定义异常 */
|
||||
export class CustomException extends HttpException {
|
||||
public constructor(msg: string, code = 424) {
|
||||
super(msg, code);
|
||||
}
|
||||
}
|
||||
115
node_api/src/common/filters/catch.exception.filter.ts
Normal file
115
node_api/src/common/filters/catch.exception.filter.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { type ArgumentsHost, Catch, type ExceptionFilter, type HttpException, HttpStatus } from '@nestjs/common';
|
||||
import type { FastifyReply } from 'fastify';
|
||||
import { CustomException } from '../exceptions/custom.exception';
|
||||
import type { LoggerService } from '../../plugins/logger/logger.service';
|
||||
|
||||
/**
|
||||
* 异常捕获过滤器.
|
||||
*/
|
||||
@Catch()
|
||||
export class CatchExceptionFilter implements ExceptionFilter {
|
||||
public constructor(private readonly logger: LoggerService) {}
|
||||
|
||||
/**
|
||||
* 重写 catch 方法,实现自定义的异常捕获逻辑.
|
||||
*/
|
||||
public catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
|
||||
/**
|
||||
* 获取 Fastify 响应对象
|
||||
*/
|
||||
const response = ctx.getResponse<FastifyReply>();
|
||||
|
||||
const errorResponse = {
|
||||
code: exception?.getStatus?.() || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
msg: exception.message,
|
||||
data: null,
|
||||
};
|
||||
|
||||
if (exception instanceof CustomException) {
|
||||
errorResponse.msg = exception.message;
|
||||
} else if (exception.message.includes('Body cannot be empty when content-type is')) {
|
||||
errorResponse.msg = 'body不能为空';
|
||||
} else {
|
||||
// 打印日志
|
||||
errorResponse.code !== 401 && this.logger.error(exception);
|
||||
// 判断不同的异常类型并设置中文提示信息
|
||||
switch (errorResponse.code) {
|
||||
case 400:
|
||||
// BadRequestException 异常
|
||||
errorResponse.msg = '请求格式错误';
|
||||
break;
|
||||
case 401:
|
||||
console.log('exception====', exception);
|
||||
// UnauthorizedException 异常
|
||||
errorResponse.msg = '未授权访问';
|
||||
break;
|
||||
case 403:
|
||||
// ForbiddenException 异常
|
||||
errorResponse.msg = '没有权限';
|
||||
break;
|
||||
case 404:
|
||||
// NotFoundException 异常
|
||||
errorResponse.msg = '资源未找到';
|
||||
break;
|
||||
case 406:
|
||||
// NotAcceptableException 异常
|
||||
errorResponse.msg = '请求的格式不被接受';
|
||||
break;
|
||||
case 408:
|
||||
// RequestTimeoutException 异常
|
||||
errorResponse.msg = '请求超时';
|
||||
break;
|
||||
case 409:
|
||||
// ConflictException 异常
|
||||
errorResponse.msg = '请求冲突';
|
||||
break;
|
||||
case 410:
|
||||
// GoneException 异常
|
||||
errorResponse.msg = '资源已不存在';
|
||||
break;
|
||||
case 413:
|
||||
// PayloadTooLargeException 异常
|
||||
errorResponse.msg = '请求负载过大';
|
||||
break;
|
||||
case 415:
|
||||
// UnsupportedMediaTypeException 异常
|
||||
errorResponse.msg = '不支持的媒体类型';
|
||||
break;
|
||||
case 422:
|
||||
// UnprocessableException 异常
|
||||
errorResponse.msg = '参数错误';
|
||||
break;
|
||||
case 423:
|
||||
errorResponse.msg = '参数错误';
|
||||
break;
|
||||
case 500:
|
||||
// InternalServerErrorException 异常
|
||||
errorResponse.msg = '服务器内部错误';
|
||||
break;
|
||||
case 501:
|
||||
// NotImplementedException 异常
|
||||
errorResponse.msg = '功能尚未实现';
|
||||
break;
|
||||
case 502:
|
||||
// BadGatewayException 异常
|
||||
errorResponse.msg = '错误的网关';
|
||||
break;
|
||||
case 503:
|
||||
// ServiceUnavailableException 异常
|
||||
errorResponse.msg = '服务不可用';
|
||||
break;
|
||||
case 504:
|
||||
// GatewayTimeoutException 异常
|
||||
errorResponse.msg = '网关超时';
|
||||
break;
|
||||
default:
|
||||
errorResponse.msg = '内部服务器错误,001';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.status(HttpStatus.OK).send(errorResponse);
|
||||
}
|
||||
}
|
||||
36
node_api/src/common/filters/ws-exception.filter.ts
Normal file
36
node_api/src/common/filters/ws-exception.filter.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
|
||||
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
|
||||
import type { Socket } from 'socket.io';
|
||||
import { LoggerService } from '../../plugins/logger/logger.service';
|
||||
|
||||
@Catch()
|
||||
export class WsExceptionFilter extends BaseWsExceptionFilter {
|
||||
public constructor(private readonly logger: LoggerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
public catch(exception: unknown, host: ArgumentsHost) {
|
||||
const ctx = host.switchToWs();
|
||||
const client = ctx.getClient<Socket>();
|
||||
const errorResponse = {
|
||||
type: 'error',
|
||||
data: { reason: '未知错误', code: 500 },
|
||||
};
|
||||
|
||||
if (exception instanceof WsException) {
|
||||
errorResponse.data.reason = exception.message;
|
||||
errorResponse.data.code = 400; // WebSocket 异常默认为 400
|
||||
} else if (exception instanceof HttpException) {
|
||||
errorResponse.data.reason = exception.message;
|
||||
errorResponse.data.code = exception.getStatus();
|
||||
} else if (exception instanceof Error) {
|
||||
errorResponse.data.reason = exception.message;
|
||||
this.logger.error(exception, 'WsExceptionFilter');
|
||||
} else {
|
||||
this.logger.error(exception as any, 'WsExceptionFilter');
|
||||
}
|
||||
|
||||
// 发送统一格式的错误消息
|
||||
client.emit('message', errorResponse);
|
||||
}
|
||||
}
|
||||
101
node_api/src/common/guards/auth.guard.ts
Normal file
101
node_api/src/common/guards/auth.guard.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import { RedisDatabase, RedisService } from '../../plugins/redis/redis.service';
|
||||
import { LoggerService } from '../../plugins/logger/logger.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
public constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly logger: LoggerService
|
||||
) {}
|
||||
|
||||
public async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<FastifyRequest>();
|
||||
|
||||
const url = String((req as any).url || '');
|
||||
if (url.startsWith('/api-docs')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const headers = (req.headers || {}) as Record<string, any>;
|
||||
// ✅ 兼容大小写
|
||||
const authorization = String(headers.authorization || headers.Authorization || '');
|
||||
const customMac = String(headers['custom-mac'] || headers['Custom-Mac'] || '');
|
||||
const customPlatform = String(headers['custom-platform'] || headers['Custom-Platform'] || '');
|
||||
const customTimestamp = String(headers['custom-timestamp'] || headers['Custom-Timestamp'] || '');
|
||||
|
||||
if (!authorization || !authorization.toLowerCase().startsWith('bearer ')) {
|
||||
throw new UnauthorizedException('缺少或无效的Authorization');
|
||||
}
|
||||
if (!customMac) {
|
||||
throw new UnauthorizedException('缺少Custom-Mac');
|
||||
}
|
||||
if (!customPlatform) {
|
||||
throw new UnauthorizedException('缺少Custom-Platform');
|
||||
}
|
||||
if (!customTimestamp) {
|
||||
throw new UnauthorizedException('缺少Custom-Timestamp');
|
||||
}
|
||||
|
||||
const token = authorization.slice(7).trim();
|
||||
const parts = token.split('.');
|
||||
|
||||
if (parts.length < 2) {
|
||||
throw new UnauthorizedException('无效的Token格式');
|
||||
}
|
||||
|
||||
const payload: any = (() => {
|
||||
try {
|
||||
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
throw new UnauthorizedException('无法解析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 UnauthorizedException('Token非法');
|
||||
}
|
||||
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const exp = Number(payload?.exp || 0);
|
||||
if (!Number.isFinite(exp) || nowSec >= exp) {
|
||||
throw new UnauthorizedException('Token已过期');
|
||||
}
|
||||
|
||||
const platform = customPlatform.toUpperCase();
|
||||
|
||||
const redisKey = `Auth:${userId}:${platform}`;
|
||||
try {
|
||||
const redis = this.redisService.getClient(RedisDatabase.GLOBAL);
|
||||
const v = await redis.get(redisKey);
|
||||
if (!v) {
|
||||
throw new UnauthorizedException('未找到登录态');
|
||||
}
|
||||
const redisObj: any = (() => {
|
||||
try {
|
||||
return JSON.parse(v);
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录态数据异常');
|
||||
}
|
||||
})();
|
||||
const redisToken = redisObj?.Token || redisObj?.token;
|
||||
if (redisToken !== token) {
|
||||
throw new UnauthorizedException('登录态已失效');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof UnauthorizedException) {
|
||||
throw e;
|
||||
}
|
||||
this.logger.error(e, 'AuthGuard');
|
||||
throw new UnauthorizedException('鉴权失败');
|
||||
}
|
||||
(req as any).user = { userId, role };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
19
node_api/src/common/pipes/non-empty-string.pipe.ts
Normal file
19
node_api/src/common/pipes/non-empty-string.pipe.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class NonEmptyStringPipe implements PipeTransform<string, string> {
|
||||
public constructor(
|
||||
private readonly name?: string,
|
||||
private readonly code: 422 | 423 = 422
|
||||
) {}
|
||||
public transform(value: unknown): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
const message = `${this.name ?? '参数'}不能为空`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
44
node_api/src/common/pipes/uint32.pipe.ts
Normal file
44
node_api/src/common/pipes/uint32.pipe.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class Uint32Pipe implements PipeTransform<string, number> {
|
||||
public constructor(
|
||||
private readonly name?: string,
|
||||
private readonly code: 422 | 423 = 422
|
||||
) {}
|
||||
|
||||
public transform(value: unknown): number {
|
||||
const field = this.name ?? '参数';
|
||||
if (value === undefined || value === null || value === '') {
|
||||
const message = `${field}不能为空`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
}
|
||||
|
||||
if ((typeof value === 'string' && !/^\d+$/.test(value)) || isNaN(Number(value))) {
|
||||
const message = `${field}必须是数字`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
}
|
||||
|
||||
const num = Number(value);
|
||||
if (!Number.isSafeInteger(num)) {
|
||||
const message = `${field}超出安全整数范围`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
} else if (num < 0 || num > 4294967295) {
|
||||
const message = `${field}必须在0到4294967295之间`;
|
||||
if (this.code === 422) {
|
||||
throw new UnprocessableEntityException(message);
|
||||
}
|
||||
throw new HttpException(message, 423);
|
||||
}
|
||||
return num;
|
||||
}
|
||||
}
|
||||
42
node_api/src/config/env.ts
Normal file
42
node_api/src/config/env.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 环境变量加载模块
|
||||
* 根据 NODE_ENV 自动加载对应的 .env 文件
|
||||
*/
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
/** 运行环境类型 */
|
||||
export type NodeEnv = 'development' | 'production';
|
||||
|
||||
/** 获取当前运行环境 */
|
||||
export const getNodeEnv = (): NodeEnv => {
|
||||
const env = process.env.NODE_ENV;
|
||||
if (env === 'production') {
|
||||
return 'production';
|
||||
}
|
||||
return 'development';
|
||||
};
|
||||
|
||||
/** 是否生产环境 */
|
||||
export const isProduction = (): boolean => getNodeEnv() === 'production';
|
||||
|
||||
/** 是否开发环境 */
|
||||
export const isDevelopment = (): boolean => getNodeEnv() === 'development';
|
||||
|
||||
/** 加载环境变量 */
|
||||
export const loadEnv = (): void => {
|
||||
const env = getNodeEnv();
|
||||
const envFile = `.env.${env}`;
|
||||
|
||||
// 先加载默认 .env 文件(如果存在)
|
||||
config({ path: resolve(process.cwd(), '.env') });
|
||||
|
||||
// 再加载环境特定的 .env 文件(覆盖默认值)
|
||||
config({ path: resolve(process.cwd(), envFile) });
|
||||
|
||||
// 最后加载本地 .env.local 文件(最高优先级,不提交到版本控制)
|
||||
config({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
};
|
||||
|
||||
// 自动加载环境变量
|
||||
loadEnv();
|
||||
29
node_api/src/config/mikro-orm.config.ts
Normal file
29
node_api/src/config/mikro-orm.config.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { defineConfig } from '@mikro-orm/mysql';
|
||||
import { MeetingUser } from '../modules/meeting/entities/meeting-user.entity';
|
||||
|
||||
const config = defineConfig({
|
||||
dbName: process.env.DB_NAME || 'scs',
|
||||
host: process.env.DB_HOST || '47.109.17.238',
|
||||
port: Number(process.env.DB_PORT) || 13306,
|
||||
user: process.env.DB_USER || 'user',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
charset: 'utf8mb4',
|
||||
timezone: '+08:00',
|
||||
|
||||
// 连接池配置 (v7 版本只支持 max 和 idleTimeoutMillis)
|
||||
pool: {
|
||||
max: 20, // 最大连接数
|
||||
idleTimeoutMillis: 30000, // 空闲连接回收 30 秒
|
||||
},
|
||||
|
||||
// 自动加载实体
|
||||
entities: [MeetingUser],
|
||||
|
||||
// 调试模式
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
// 允许全局上下文(适用于 WebSocket 等长连接场景)
|
||||
allowGlobalContext: true,
|
||||
});
|
||||
|
||||
export default config;
|
||||
91
node_api/src/config/swagger.config.ts
Normal file
91
node_api/src/config/swagger.config.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import type { ParameterObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
|
||||
import { isObject } from 'class-validator';
|
||||
|
||||
/** 注册 Swagger */
|
||||
export function useSwaggerConfig(app: NestFastifyApplication, ipv4: string, port: number) {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('API文档')
|
||||
.setDescription('Test01移动端(小程序/APP/移动网页)文档')
|
||||
.setVersion('1.0')
|
||||
.setContact('马小平', '', 'mxp131011@qq.com')
|
||||
.setLicense('MIT', 'https://opensource.org/licenses/MIT')
|
||||
.addServer('https://meeting.qyzhjy.com', '生产环境')
|
||||
.addServer(`http://${ipv4}:${port}`, '测试环境')
|
||||
// JWT Bearer 认证
|
||||
.addBearerAuth(
|
||||
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: '使用 JWT Token 进行鉴权', in: 'header' },
|
||||
'JWT-auth' // 安全方案名称
|
||||
)
|
||||
// 全局启用 JWT 认证(所有接口默认需要认证)
|
||||
.addSecurityRequirements('JWT-auth');
|
||||
|
||||
/** 全局参数 */
|
||||
const list: Omit<ParameterObject, 'example' | 'examples'>[] = [
|
||||
{
|
||||
name: 'Custom-Mac',
|
||||
in: 'header',
|
||||
description: '设备id,设备的唯一识别码',
|
||||
required: true,
|
||||
schema: { type: 'string', default: '165362158044846585135' },
|
||||
},
|
||||
{
|
||||
name: 'Custom-Timestamp',
|
||||
in: 'header',
|
||||
description: '时间戳(毫秒)',
|
||||
required: true,
|
||||
schema: { type: 'string', default: 1772768634072 },
|
||||
},
|
||||
{
|
||||
name: 'Custom-Platform',
|
||||
in: 'header',
|
||||
description: '设备平台如:ios/android/web',
|
||||
required: true,
|
||||
schema: { type: 'string', default: 'WECHAT', enum: ['WECHAT', 'PC', 'WEB', 'WECHAT', 'PC-APP'] },
|
||||
},
|
||||
];
|
||||
|
||||
config.addGlobalParameters(...list);
|
||||
// 创建 Swagger 文档
|
||||
const document = SwaggerModule.createDocument(app, config.build());
|
||||
|
||||
/** 设置 Swagger 路径 */
|
||||
SwaggerModule.setup('api-docs', app, document, {
|
||||
jsonDocumentUrl: 'api-docs/swagger.json',
|
||||
/**
|
||||
* 重写请求时的文档处理逻辑
|
||||
*/
|
||||
patchDocumentOnRequest(req: any, _res, docs) {
|
||||
// 动态更新时间戳参数的 default 值为当前时间戳
|
||||
if (docs.components?.parameters) {
|
||||
const timestampParam = docs.components.parameters['Custom-Timestamp'];
|
||||
// 检查是否为 ParameterObject(而非 ReferenceObject)
|
||||
if (timestampParam && 'schema' in timestampParam && timestampParam.schema) {
|
||||
(timestampParam.schema as Record<string, unknown>).default = String(Date.now());
|
||||
}
|
||||
}
|
||||
// 判断是否是 YAML 文档请求,并且是否存在 paths 属性
|
||||
if (req.url.includes('api-docs/swagger.json') && isObject(docs.paths)) {
|
||||
// 遍历 paths 对象的所有属性(即路径)
|
||||
for (const path of Object.keys(docs.paths)) {
|
||||
const methods = docs.paths[path]! as Record<string, unknown>;
|
||||
for (const method of Object.keys(methods)) {
|
||||
if (isObject(methods[method]) && 'parameters' in methods[method]) {
|
||||
const parameters = methods[method].parameters;
|
||||
if (Array.isArray(parameters)) {
|
||||
methods[method].parameters = parameters.filter((item: Record<string, unknown>) => {
|
||||
// 提取全局参数的名称
|
||||
const keys = list.map((item2) => item2.name);
|
||||
// 过滤掉名称在全局参数名称列表中的参数
|
||||
return !(isObject(item) && keys.includes(String(item?.name || '')));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return docs;
|
||||
},
|
||||
});
|
||||
}
|
||||
77
node_api/src/main.ts
Normal file
77
node_api/src/main.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import './config/env';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { ip } from 'address';
|
||||
import { execSync } from 'child_process';
|
||||
import { AppModule } from './app.module';
|
||||
import { CatchExceptionFilter } from './common/filters/catch.exception.filter';
|
||||
import { LoggerService } from './plugins/logger/logger.service';
|
||||
import { useSwaggerConfig } from './config/swagger.config';
|
||||
|
||||
// 跨平台 UTF-8 编码支持(解决 Windows 中文乱码)
|
||||
if (process.platform === 'win32') {
|
||||
// 设置 Node.js 使用 UTF-8 编码
|
||||
process.env.NODE_SKIP_UTF8_CHECK = 'true';
|
||||
|
||||
try {
|
||||
// 尝试将 Windows 控制台设置为 UTF-8
|
||||
execSync('chcp 65001 > nul 2>&1', { stdio: 'ignore' });
|
||||
} catch {
|
||||
// 忽略错误,继续运行
|
||||
}
|
||||
}
|
||||
|
||||
/** 入口 */
|
||||
async function bootstrap() {
|
||||
/** 得到id */
|
||||
const ipv4 = ip() || '127.0.0.1';
|
||||
|
||||
/** 端口 */
|
||||
const port = parseInt(process.env.PORT || '4001', 10);
|
||||
const pinoLogger = new LoggerService();
|
||||
const fastifyAdapter = new FastifyAdapter({ loggerInstance: pinoLogger.logger });
|
||||
const app: NestFastifyApplication = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter, { bufferLogs: true });
|
||||
|
||||
// 启用 CORS
|
||||
app.enableCors({
|
||||
origin: true, // 允许所有来源(生产环境应该指定具体域名)
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
|
||||
allowedHeaders: ['*'],
|
||||
credentials: true,
|
||||
});
|
||||
// 启用全局异常过滤器
|
||||
app.useGlobalFilters(new CatchExceptionFilter(pinoLogger));
|
||||
// 自动验证传入的请求数据
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true, // 是否自动将请求数据转换为 DTO 类的实例。
|
||||
whitelist: true, // 是否自动去除 DTO 类中未定义的属性。
|
||||
forbidNonWhitelisted: true, // 是否禁止请求数据中包含 DTO 类中未定义的属性
|
||||
errorHttpStatusCode: 422, // 自定义 HTTP 错误码
|
||||
stopAtFirstError: true, // 当设置为 true 时,给定属性的验证将在遇到第一个错误后停止。默认为 false。
|
||||
enableDebugMessages: false, // 是否自动将请求数据转换为 DTO 类的实例。
|
||||
})
|
||||
);
|
||||
|
||||
/** 自定义Logger(复用已有实例) */
|
||||
app.useLogger(pinoLogger);
|
||||
|
||||
/** 注册 Swagger */
|
||||
useSwaggerConfig(app, ipv4, port);
|
||||
|
||||
try {
|
||||
await app.listen(port, '0.0.0.0');
|
||||
console.log('\x1b[32;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口地址: ', `http://${ipv4}:${port}`);
|
||||
console.log('\x1b[36;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口文档 UI 地址: ', `http://${ipv4}:${port}/api-docs`);
|
||||
console.log('\x1b[36;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口文档JSON地址: ', `http://${ipv4}:${port}/api-docs/swagger.json`, '注意会过滤全局header');
|
||||
} catch (error) {
|
||||
pinoLogger.error(error, 'bootstrap');
|
||||
}
|
||||
}
|
||||
if (import.meta.env.PROD) {
|
||||
bootstrap();
|
||||
}
|
||||
|
||||
// Vite 热重载需要导出 viteNodeApp
|
||||
export const viteNodeApp = bootstrap();
|
||||
14
node_api/src/modules/meeting/entities/meeting-user.entity.ts
Normal file
14
node_api/src/modules/meeting/entities/meeting-user.entity.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy';
|
||||
|
||||
@Entity({ tableName: 'meeting_user' })
|
||||
export class MeetingUser {
|
||||
@PrimaryKey({ type: 'number' })
|
||||
public id!: number;
|
||||
|
||||
@Unique()
|
||||
@Property({ type: 'bigint', fieldName: 'long_user_Id' })
|
||||
public longUserId!: number;
|
||||
|
||||
@Property({ type: 'date' })
|
||||
public createdAt: Date = new Date();
|
||||
}
|
||||
92
node_api/src/modules/meeting/meeting.controller.ts
Normal file
92
node_api/src/modules/meeting/meeting.controller.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
|
||||
import { ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
|
||||
import { FailResult, OkResult } from '../../common/dto/result.dto';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { NonEmptyStringPipe } from '@/common/pipes/non-empty-string.pipe';
|
||||
import { Uint32Pipe } from '@/common/pipes/uint32.pipe';
|
||||
import { ApiCustomOkResponse } from '@/common/decorators/swagger.decorator';
|
||||
import { TokenResponseDto } from './meeting.dto';
|
||||
import { MeetingRedisService } from '../websocket/meeting-redis.service';
|
||||
|
||||
/**
|
||||
* 黑名单用户 DTO
|
||||
*/
|
||||
class BlacklistUserDto {
|
||||
@ApiProperty({ description: '短 UID', example: 1001 })
|
||||
shortUid?: number;
|
||||
|
||||
@ApiProperty({ description: '用户名称', example: '张三' })
|
||||
userName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会议控制器
|
||||
* 处理会议相关的 API 请求
|
||||
*/
|
||||
@ApiTags('Meeting')
|
||||
@Controller('meeting')
|
||||
export class MeetingController {
|
||||
public constructor(
|
||||
private readonly meetingService: MeetingService,
|
||||
private readonly redis: MeetingRedisService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 生成声网 RTC Token
|
||||
*/
|
||||
@Get('get-token/:channelName/:uid')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiCustomOkResponse({
|
||||
summary: '声网 RTC Token',
|
||||
model: TokenResponseDto,
|
||||
apiDescription: '声网 RTC Token 信息',
|
||||
resDescription: '声网 RTC Token 信息包含appid',
|
||||
})
|
||||
@ApiParam({ name: 'channelName', description: '需要加入的频道名(格式: `n_课程名称`)', example: 'n_1234567890' })
|
||||
@ApiParam({ name: 'uid', description: '用户的短 UID(0~4294967295)', example: '1001' })
|
||||
public async getToken(@Param('channelName', new NonEmptyStringPipe('channelName')) channelName: string, @Param('uid', new Uint32Pipe('uid')) uid: number) {
|
||||
const result = this.meetingService.generateToken(channelName, uid);
|
||||
if (!result) {
|
||||
return new FailResult('生成 Token 失败');
|
||||
}
|
||||
return new OkResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询房间黑名单(包含短 UID 和名称的数组)
|
||||
*/
|
||||
@Get('blacklist/:roomId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiParam({ name: 'roomId', description: '服务端房间 ID(Socket.IO 房间名)' })
|
||||
@ApiCustomOkResponse({
|
||||
summary: '房间黑名单',
|
||||
model: [BlacklistUserDto],
|
||||
apiDescription: '房间黑名单(包含短 UID 和名称的数组)',
|
||||
resDescription: '房间黑名单(包含短 UID 和名称的数组)',
|
||||
})
|
||||
public async getBlacklist(@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string) {
|
||||
const list = await this.redis.getBlacklist(roomId);
|
||||
return new OkResult(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从房间黑名单移除指定用户(短 UID)
|
||||
*/
|
||||
@Delete('blacklist/:roomId/:shortUid')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiParam({ name: 'roomId', description: '服务端房间 ID(Socket.IO 房间名)' })
|
||||
@ApiParam({ name: 'shortUid', description: '用户短 UID', example: '1001' })
|
||||
@ApiCustomOkResponse({
|
||||
summary: '从房间黑名单移除用户',
|
||||
model: Boolean,
|
||||
apiDescription: '从房间黑名单移除指定用户(短 UID)',
|
||||
resDescription: '是否成功移除用户',
|
||||
})
|
||||
public async removeFromBlacklist(
|
||||
@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string,
|
||||
@Param('shortUid', new Uint32Pipe('shortUid')) shortUid: number
|
||||
) {
|
||||
await this.redis.removeFromBlacklist(roomId, shortUid);
|
||||
return new OkResult(true);
|
||||
}
|
||||
}
|
||||
15
node_api/src/modules/meeting/meeting.dto.ts
Normal file
15
node_api/src/modules/meeting/meeting.dto.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* Token 响应数据 DTO
|
||||
*/
|
||||
export class TokenResponseDto {
|
||||
@ApiProperty({ description: '应用 ID', example: '1234567890' })
|
||||
public appid!: string;
|
||||
|
||||
@ApiProperty({ description: '声网RTC的Token', example: '0061234567890abcdef...' })
|
||||
public rtcToken!: string;
|
||||
|
||||
@ApiProperty({ description: '过期时间戳', example: 1704067200000 })
|
||||
public expiresAt!: number;
|
||||
}
|
||||
12
node_api/src/modules/meeting/meeting.module.ts
Normal file
12
node_api/src/modules/meeting/meeting.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { WebsocketModule } from '../websocket/websocket.module';
|
||||
|
||||
@Module({
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
imports: [forwardRef(() => WebsocketModule)],
|
||||
exports: [MeetingService],
|
||||
})
|
||||
export class MeetingModule {}
|
||||
108
node_api/src/modules/meeting/meeting.service.ts
Normal file
108
node_api/src/modules/meeting/meeting.service.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Role, RtcTokenBuilder } from '../../plugins/shengwang/RtcTokenBuilder2';
|
||||
import { NacosConfigService } from '../../plugins/nacos/nacos-config.service';
|
||||
import { EntityManager } from '@mikro-orm/core';
|
||||
import { MeetingUser } from './entities/meeting-user.entity';
|
||||
import type { TokenResponseDto } from './meeting.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MeetingService {
|
||||
constructor(
|
||||
private readonly em: EntityManager,
|
||||
private readonly nacosConfig: NacosConfigService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 通过作业 ID 查询老师的长 ID
|
||||
* @param homeworkId - 作业 ID(JoinRoomData.homeworkId)
|
||||
* @returns 老师长 ID,未查询到返回 null
|
||||
*/
|
||||
public async getTeacherLongUserIdByHomeworkId(homeworkId: number): Promise<number | null> {
|
||||
const id = Number(homeworkId);
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
return null;
|
||||
}
|
||||
const sql = 'SELECT B.TeacherId AS teacherId FROM icr_homework AS A LEFT JOIN scs_studentgroup AS B ON A.StudentGroupId = B.Id WHERE A.Id = ? LIMIT 1';
|
||||
const rows = (await this.em.getConnection().execute(sql, [id])) as Array<{ teacherId?: number | string }>;
|
||||
const teacherIdRaw = rows?.[0]?.teacherId;
|
||||
const teacherId = teacherIdRaw === undefined ? NaN : Number(teacherIdRaw);
|
||||
if (!Number.isFinite(teacherId) || teacherId <= 0) {
|
||||
return null;
|
||||
}
|
||||
return teacherId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过作业 ID 获取老师的短 UID
|
||||
* @param homeworkId - 作业 ID(JoinRoomData.homeworkId)
|
||||
* @returns 老师短 UID,未查询到返回 null
|
||||
*/
|
||||
public async getTeacherShortUidByHomeworkId(homeworkId: number): Promise<number | null> {
|
||||
const teacherLongUserId = await this.getTeacherLongUserIdByHomeworkId(homeworkId);
|
||||
if (!teacherLongUserId) {
|
||||
return null;
|
||||
}
|
||||
return await this.getOrAssignShortUid(teacherLongUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或分配用户的短 UID
|
||||
* @param longUserId - 用户的真实 ID(bigint)
|
||||
* @returns 分配的短 UID(数据库自增 ID)
|
||||
*/
|
||||
public async getOrAssignShortUid(longUserId: number | string): Promise<number> {
|
||||
const userId = typeof longUserId === 'string' ? BigInt(longUserId) : BigInt(longUserId);
|
||||
|
||||
// 尝试获取已存在的记录
|
||||
const existingUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
|
||||
|
||||
if (existingUser) {
|
||||
// 复用已有的短 UID
|
||||
console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`);
|
||||
return existingUser.id;
|
||||
}
|
||||
|
||||
// 创建新记录(数据库自增 ID 会自动分配)
|
||||
try {
|
||||
const newUser = this.em.create(MeetingUser, { longUserId: Number(userId), createdAt: new Date() });
|
||||
await this.em.flush();
|
||||
console.log(`[MeetingService] 为用户 ${longUserId} 分配短 UID: ${newUser.id}`);
|
||||
return newUser.id;
|
||||
} catch (error: any) {
|
||||
// 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry)
|
||||
if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) {
|
||||
console.warn(`[MeetingService] 检测到并发创建冲突,重新获取用户 ${longUserId}`);
|
||||
this.em.clear(); // 清除当前上下文,防止缓存干扰
|
||||
const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
|
||||
if (retryUser) {
|
||||
return retryUser.id;
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public generateToken(channelName: string, uid: number): TokenResponseDto | null {
|
||||
// 从 Nacos 配置获取 appId 和 appCertificate
|
||||
const agoraConfig = this.nacosConfig.getDefaultAgoraAppConfig();
|
||||
const now = Date.now();
|
||||
const role = Role.PUBLISHER;
|
||||
const tokenExpirationInSecond = 60 * 60 * 24; // 24 小时
|
||||
const privilegeExpirationInSecond = tokenExpirationInSecond;
|
||||
const token = RtcTokenBuilder.buildTokenWithUid(
|
||||
agoraConfig.appId,
|
||||
agoraConfig.appCertificate,
|
||||
channelName, // 使用清理后的频道名
|
||||
uid,
|
||||
role,
|
||||
tokenExpirationInSecond,
|
||||
privilegeExpirationInSecond
|
||||
);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
const expiresAt = now + tokenExpirationInSecond * 1000;
|
||||
return { appid: agoraConfig.appId, rtcToken: token, expiresAt };
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
12
node_api/src/plugins/logger/logger.module.ts
Normal file
12
node_api/src/plugins/logger/logger.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { LoggerService } from './logger.service';
|
||||
|
||||
/**
|
||||
* 日志模块.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [LoggerService],
|
||||
exports: [LoggerService],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
159
node_api/src/plugins/logger/logger.service.ts
Normal file
159
node_api/src/plugins/logger/logger.service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
|
||||
import { join } from 'path';
|
||||
import pino, { type Logger } from 'pino';
|
||||
import pinoPretty from 'pino-pretty';
|
||||
import dayjs from 'dayjs';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
/**
|
||||
* 检测终端是否支持 UTF-8 编码
|
||||
* @returns 是否支持 UTF-8
|
||||
*/
|
||||
function isTerminalUTF8(): boolean {
|
||||
// Windows 系统检查代码页
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
// 检查是否设置了 NODE_SKIP_UTF8_CHECK 环境变量
|
||||
if (process.env.NODE_SKIP_UTF8_CHECK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 尝试执行 chcp 命令检查代码页
|
||||
const codePage = execSync('chcp', { encoding: 'utf8' }).toString();
|
||||
// 65001 是 UTF-8 代码页
|
||||
return codePage.includes('65001');
|
||||
} catch {
|
||||
// 默认 Windows 终端使用 GBK 编码
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// macOS 和 Linux 通常默认使用 UTF-8
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置终端为 UTF-8 编码(仅 Windows)
|
||||
*/
|
||||
function setTerminalToUTF8(): void {
|
||||
if (process.platform === 'win32' && !isTerminalUTF8()) {
|
||||
try {
|
||||
// 设置 stdout 为 UTF-8
|
||||
if (process.stdout && typeof process.stdout.setEncoding === 'function') {
|
||||
process.stdout.setEncoding('utf-8');
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志服务.
|
||||
*/
|
||||
@Injectable({ scope: Scope.TRANSIENT })
|
||||
export class LoggerService extends ConsoleLogger {
|
||||
/** 日志实例 */
|
||||
public logger: Logger | undefined = undefined;
|
||||
|
||||
/** 上下文 */
|
||||
public override context = '';
|
||||
|
||||
constructor(context?: string) {
|
||||
super(context || '');
|
||||
|
||||
// 设置终端编码
|
||||
setTerminalToUTF8();
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const prettyStream = pinoPretty({
|
||||
colorize: true,
|
||||
colorizeObjects: true,
|
||||
singleLine: true,
|
||||
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
|
||||
// 根据终端编码设置输出
|
||||
sync: true,
|
||||
customPrettifiers: {
|
||||
/** 自定义 err 的显示 */
|
||||
err: (err: unknown) => {
|
||||
return `\x1b[31m${JSON.stringify(err, null, 2)}\x1b[0m`;
|
||||
},
|
||||
|
||||
/** 自定义 err 的显示 */
|
||||
error: (err: unknown) => {
|
||||
return `\x1b[31m${JSON.stringify(err, null, 2)}\x1b[0m`;
|
||||
},
|
||||
},
|
||||
});
|
||||
this.logger = pino(prettyStream);
|
||||
} else {
|
||||
this.logger = pino({
|
||||
// level: 'warn',
|
||||
|
||||
/** 处理时间字段 */
|
||||
timestamp: () => `,"time":"${dayjs().format('YYYY-MM-DD HH:mm:ss.SSS')}"`,
|
||||
transport: {
|
||||
target: 'pino-roll',
|
||||
options: {
|
||||
file: join('logs', 'log'), // 日志文件的绝对或相对路径
|
||||
size: '40m', // 日志文件的最大大小
|
||||
dateFormat: 'yyyy-MM-dd',
|
||||
frequency: 'daily', // 周期
|
||||
mkdir: true,
|
||||
extension: `.log`,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置日志上下文.
|
||||
*/
|
||||
override setContext(context: string) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Info级别日志.
|
||||
*/
|
||||
info(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.info(obj, msg, ...args, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Error级别日志.
|
||||
*/
|
||||
override error(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.error(obj, msg, ...args, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn级别日志.
|
||||
*/
|
||||
override warn(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.warn(obj, msg, ...args, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Debug级别日志.
|
||||
*/
|
||||
override debug(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.debug(obj, msg, ...args, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trace级别日志.
|
||||
*/
|
||||
trace(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.trace(obj, msg, ...args, this.context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fatal级别日志
|
||||
* 由于 'fatal' 级别的消息旨在在退出进程之前记录,因此 fatal 方法将始终同步刷新目标。因此,重要的是不要滥用 fatal,因为如果将其用于进程崩溃或退出之前写入最终日志消息之外的任何其他目的,则会导致性能开销。.
|
||||
*/
|
||||
override fatal(obj: any, msg?: string, ...args: any[]) {
|
||||
this.logger!.fatal(obj, msg, ...args, this.context);
|
||||
}
|
||||
}
|
||||
9
node_api/src/plugins/mikro-orm/mikro-orm.module.ts
Normal file
9
node_api/src/plugins/mikro-orm/mikro-orm.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import config from '../../config/mikro-orm.config';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [MikroOrmModule.forRoot(config)],
|
||||
})
|
||||
export class MikroOrmConfigModule {}
|
||||
159
node_api/src/plugins/nacos/nacos-config.service.ts
Normal file
159
node_api/src/plugins/nacos/nacos-config.service.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Nacos 配置模块
|
||||
* 从 Nacos 配置中心加载和管理配置
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as nacos from 'nacos';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LoggerService } from '../logger/logger.service';
|
||||
|
||||
/** 声网应用配置接口 */
|
||||
export interface AgoraAppConfig {
|
||||
appId: string;
|
||||
appCertificate: string;
|
||||
}
|
||||
|
||||
/** 声网配置接口 */
|
||||
export interface AgoraConfig {
|
||||
apps: Record<string, AgoraAppConfig>;
|
||||
defaultAppId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nacos 配置服务
|
||||
* 提供配置的读取和缓存功能
|
||||
*/
|
||||
@Injectable()
|
||||
export class NacosConfigService {
|
||||
private readonly logger = new LoggerService();
|
||||
private agoraConfig: AgoraConfig | null = null;
|
||||
private configClient!: nacos.NacosConfigClient;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.initNacosClient();
|
||||
this.loadAgoraConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Nacos 客户端
|
||||
*/
|
||||
private initNacosClient(): void {
|
||||
const serverAddr = this.configService.get<string>('NACOS_SERVER_ADDR');
|
||||
const username = this.configService.get<string>('NACOS_USERNAME');
|
||||
const password = this.configService.get<string>('NACOS_PASSWORD');
|
||||
const namespace = this.configService.get<string>('NACOS_NAMESPACE_ID');
|
||||
|
||||
if (!serverAddr) {
|
||||
throw new Error('NACOS_SERVER_ADDR 环境变量未配置');
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建 Nacos 配置客户端
|
||||
this.configClient = new nacos.NacosConfigClient({
|
||||
serverAddr,
|
||||
username: username || undefined,
|
||||
password: password || undefined,
|
||||
namespace: namespace || undefined,
|
||||
});
|
||||
|
||||
this.logger.info(`Nacos 客户端初始化成功,服务器地址:${serverAddr}`);
|
||||
} catch (error) {
|
||||
this.logger.error('Nacos 客户端初始化失败:', error);
|
||||
throw new Error(`Nacos 客户端初始化失败:${error.message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Nacos 加载声网配置文件
|
||||
*/
|
||||
private async loadAgoraConfig(): Promise<void> {
|
||||
try {
|
||||
const dataId = this.configService.get<string>('NACOS_DATA_ID');
|
||||
const group = this.configService.get<string>('NACOS_GROUP') || 'DEFAULT_GROUP';
|
||||
|
||||
// 从 Nacos 获取配置内容
|
||||
const configContent = await this.configClient.getConfig(
|
||||
this.configService.get<string>('NACOS_DATA_ID')!,
|
||||
this.configService.get<string>('NACOS_GROUP') || 'DEFAULT_GROUP'
|
||||
);
|
||||
|
||||
if (!configContent) {
|
||||
throw new Error(`Nacos 中未找到配置:dataId=${dataId}, group=${group}`);
|
||||
}
|
||||
|
||||
// 解析 YAML 配置
|
||||
const config = yaml.load(configContent) as AgoraConfig;
|
||||
|
||||
if (!config.apps || !config.defaultAppId) {
|
||||
throw new Error('Nacos 配置格式错误:必须包含 apps 和 defaultAppId 字段');
|
||||
}
|
||||
|
||||
if (!config.apps[config.defaultAppId]) {
|
||||
throw new Error(`Nacos 配置错误:defaultAppId '${config.defaultAppId}' 在 apps 中不存在`);
|
||||
}
|
||||
|
||||
this.agoraConfig = config;
|
||||
this.logger.info(`声网配置从 Nacos 加载成功,默认应用:${config.defaultAppId}`);
|
||||
|
||||
// 监听配置变化(可选,实现热更新)
|
||||
try {
|
||||
// 使用已解析的 dataId 和 group,传入对象格式
|
||||
this.configClient.subscribe({ dataId, group }, (content: string) => {
|
||||
this.logger.info('检测到 Nacos 配置变更,重新加载...');
|
||||
try {
|
||||
const newConfig = yaml.load(content) as AgoraConfig;
|
||||
this.agoraConfig = newConfig;
|
||||
this.logger.info(`声网配置已更新,默认应用:${newConfig.defaultAppId}`);
|
||||
} catch (error) {
|
||||
this.logger.error('配置更新失败', error);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// 配置监听失败不影响主流程,记录警告日志
|
||||
this.logger.warn('Nacos 配置监听器设置失败,但配置已成功加载');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('从 Nacos 加载声网配置失败', error);
|
||||
throw new Error(`从 Nacos 加载声网配置失败:${error.message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取声网配置
|
||||
* @returns {AgoraConfig} 声网配置对象
|
||||
*/
|
||||
getAgoraConfig(): AgoraConfig {
|
||||
if (!this.agoraConfig) {
|
||||
throw new Error('声网配置未加载,请检查 Nacos 配置是否正确且格式有效');
|
||||
}
|
||||
return this.agoraConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定的声网应用配置
|
||||
* @param {string} appId - 应用 ID(可选,默认使用 defaultAppId)
|
||||
* @returns {AgoraAppConfig} 声网应用配置
|
||||
*/
|
||||
getAgoraAppConfig(appId?: string): AgoraAppConfig {
|
||||
const agoraConfig = this.getAgoraConfig();
|
||||
const targetAppId = appId || agoraConfig.defaultAppId;
|
||||
|
||||
const appConfig = agoraConfig.apps[targetAppId];
|
||||
if (!appConfig) {
|
||||
const errorMsg = `未找到声网应用配置:${targetAppId}。可用的应用 ID: ${Object.keys(agoraConfig.apps).join(', ')}`;
|
||||
this.logger.error(errorMsg);
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return appConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认的声网应用配置
|
||||
* @returns {AgoraAppConfig} 默认声网应用配置
|
||||
*/
|
||||
getDefaultAgoraAppConfig(): AgoraAppConfig {
|
||||
return this.getAgoraAppConfig();
|
||||
}
|
||||
}
|
||||
14
node_api/src/plugins/nacos/nacos.module.ts
Normal file
14
node_api/src/plugins/nacos/nacos.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Nacos 配置模块
|
||||
*/
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { NacosConfigService } from './nacos-config.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [NacosConfigService],
|
||||
exports: [NacosConfigService],
|
||||
})
|
||||
export class NacosConfigModule {}
|
||||
13
node_api/src/plugins/redis/redis.module.ts
Normal file
13
node_api/src/plugins/redis/redis.module.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// redis.module.ts
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { RedisService } from './redis.service';
|
||||
|
||||
/**
|
||||
* Redis模块。.
|
||||
*/
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [RedisService],
|
||||
exports: [RedisService],
|
||||
})
|
||||
export class RedisModule {}
|
||||
144
node_api/src/plugins/redis/redis.service.ts
Normal file
144
node_api/src/plugins/redis/redis.service.ts
Normal file
@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Redis 服务 - 统一管理多个 Redis 数据库连接
|
||||
* 提供全局 Redis 客户端和会议专用 Redis 客户端
|
||||
*/
|
||||
|
||||
import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { LoggerService } from '../logger/logger.service';
|
||||
|
||||
/**
|
||||
* Redis 数据库枚举
|
||||
*/
|
||||
export enum RedisDatabase {
|
||||
/** 全局业务数据库(DB 1) */
|
||||
GLOBAL = 1,
|
||||
/** 会议业务数据库(DB 0) */
|
||||
MEETING = 0,
|
||||
}
|
||||
|
||||
/**
|
||||
* Redis 配置选项
|
||||
*/
|
||||
interface RedisClientOptions {
|
||||
/** 数据库编号 */
|
||||
db: RedisDatabase;
|
||||
/** 是否为生产环境 */
|
||||
isProduction?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RedisService implements OnModuleInit, OnModuleDestroy {
|
||||
/** Redis 客户端实例 Map */
|
||||
private clients = new Map<RedisDatabase, Redis>();
|
||||
/** 日志服务实例 */
|
||||
private readonly logger = new LoggerService();
|
||||
|
||||
/**
|
||||
* 初始化 Redis 连接
|
||||
*/
|
||||
async onModuleInit() {
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// 创建全局业务 Redis 客户端(DB 0)
|
||||
await this.createClient({
|
||||
db: RedisDatabase.GLOBAL,
|
||||
isProduction,
|
||||
});
|
||||
|
||||
// 创建会议业务 Redis 客户端(DB 1)
|
||||
await this.createClient({
|
||||
db: RedisDatabase.MEETING,
|
||||
isProduction,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并初始化 Redis 客户端
|
||||
*/
|
||||
private async createClient(options: RedisClientOptions): Promise<void> {
|
||||
const redisConfig = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: Number(process.env.REDIS_PORT) || 6379,
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: options.db,
|
||||
retryStrategy: (times: number) => {
|
||||
if (times > 10) {
|
||||
this.logger.error(`Redis DB ${options.db} 重连次数过多,放弃重连`, undefined, 'RedisService');
|
||||
return null;
|
||||
}
|
||||
const delay = Math.min(times * 100, 3000);
|
||||
this.logger.warn(`Redis DB ${options.db} ${delay}ms 后重连...`, 'RedisService');
|
||||
return delay;
|
||||
},
|
||||
};
|
||||
|
||||
const client = new Redis(redisConfig);
|
||||
|
||||
// 监听事件
|
||||
client.on('ready', () => {
|
||||
this.logger.info(`Redis 连接成功,数据库编号为:${options.db}${options.isProduction ? ' (生产环境)' : ' (开发环境)'}`, 'RedisService');
|
||||
});
|
||||
|
||||
client.on('error', (error: Error) => {
|
||||
this.logger.error(error, `Redis DB ${options.db} 错误`, 'RedisService');
|
||||
});
|
||||
|
||||
// 存储客户端
|
||||
this.clients.set(options.db, client);
|
||||
|
||||
// 等待连接就绪(如果尚未就绪)
|
||||
if (client.status === 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.once('ready', () => resolve());
|
||||
client.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定数据库的 Redis 客户端
|
||||
* @param db - 数据库编号,默认为全局数据库
|
||||
*/
|
||||
getClient(db: RedisDatabase = RedisDatabase.GLOBAL): Redis {
|
||||
const client = this.clients.get(db);
|
||||
if (!client) {
|
||||
throw new Error(`Redis 客户端不存在,数据库编号:${db}`);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取全局业务 Redis 客户端(DB 0)
|
||||
*/
|
||||
getGlobalClient(): Redis {
|
||||
return this.getClient(RedisDatabase.GLOBAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会议业务 Redis 客户端(DB 1)
|
||||
*/
|
||||
getMeetingClient(): Redis {
|
||||
return this.getClient(RedisDatabase.MEETING);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁模块时关闭所有 Redis 连接
|
||||
*/
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
const quitPromises: Array<Promise<void>> = [];
|
||||
|
||||
for (const [db, client] of this.clients.entries()) {
|
||||
quitPromises.push(
|
||||
client.quit().then(() => {
|
||||
this.logger.info(`Redis DB ${db} 已关闭连接`, 'RedisService');
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(quitPromises);
|
||||
this.logger.info('所有 Redis 连接已关闭', 'RedisService');
|
||||
}
|
||||
}
|
||||
280
node_api/src/plugins/shengwang/AccessToken.ts
Normal file
280
node_api/src/plugins/shengwang/AccessToken.ts
Normal file
@ -0,0 +1,280 @@
|
||||
import crypto from 'node:crypto';
|
||||
import crc32 from 'crc-32';
|
||||
import { UINT32 } from 'cuint';
|
||||
|
||||
const version = '006';
|
||||
const randomInt = Math.floor(Math.random() * 0xffffffff);
|
||||
const VERSION_LENGTH = 3;
|
||||
const APP_ID_LENGTH = 32;
|
||||
|
||||
export const priviledges = {
|
||||
kJoinChannel: 1,
|
||||
kPublishAudioStream: 2,
|
||||
kPublishVideoStream: 3,
|
||||
kPublishDataStream: 4,
|
||||
kRtmLogin: 1000,
|
||||
};
|
||||
|
||||
type Messages = Record<number, number>;
|
||||
|
||||
interface MessageOptions {
|
||||
salt: number;
|
||||
ts: number;
|
||||
messages: Messages;
|
||||
pack?: () => Buffer;
|
||||
}
|
||||
|
||||
interface AccessTokenContentOptions {
|
||||
signature: Buffer | string;
|
||||
crc_channel: number;
|
||||
crc_uid: number;
|
||||
crc_channel_name?: number;
|
||||
m: Buffer | string;
|
||||
pack?: () => Buffer;
|
||||
}
|
||||
|
||||
interface ByteBufInterface {
|
||||
buffer: Buffer;
|
||||
position: number;
|
||||
pack: () => Buffer;
|
||||
putUint16: (v: number) => ByteBufInterface;
|
||||
putUint32: (v: number) => ByteBufInterface;
|
||||
putBytes: (bytes: Buffer) => ByteBufInterface;
|
||||
putString: (str: string) => ByteBufInterface;
|
||||
putTreeMap: (map?: Record<string, string>) => ByteBufInterface;
|
||||
putTreeMapUInt32: (map?: Messages) => ByteBufInterface;
|
||||
}
|
||||
|
||||
interface ReadByteBufInterface {
|
||||
buffer: Buffer;
|
||||
position: number;
|
||||
getUint16: () => number;
|
||||
getUint32: () => number;
|
||||
getString: () => Buffer;
|
||||
getTreeMapUInt32: () => Messages;
|
||||
}
|
||||
|
||||
const encodeHMac = (key: string, message: Buffer): Buffer => {
|
||||
return crypto.createHmac('sha256', key).update(message).digest();
|
||||
};
|
||||
const ByteBuf = (): ByteBufInterface => {
|
||||
const that: ByteBufInterface = {
|
||||
buffer: Buffer.alloc(1024),
|
||||
position: 0,
|
||||
|
||||
pack() {
|
||||
const out = Buffer.alloc(that.position);
|
||||
that.buffer.copy(out, 0, 0, out.length);
|
||||
return out;
|
||||
},
|
||||
|
||||
putUint16(v: number) {
|
||||
that.buffer.writeUInt16LE(v, that.position);
|
||||
that.position += 2;
|
||||
return that;
|
||||
},
|
||||
|
||||
putUint32(v: number) {
|
||||
that.buffer.writeUInt32LE(v, that.position);
|
||||
that.position += 4;
|
||||
return that;
|
||||
},
|
||||
|
||||
putBytes(bytes: Buffer) {
|
||||
that.putUint16(bytes.length);
|
||||
bytes.copy(that.buffer, that.position);
|
||||
that.position += bytes.length;
|
||||
return that;
|
||||
},
|
||||
|
||||
putString(str: string) {
|
||||
return that.putBytes(Buffer.from(str));
|
||||
},
|
||||
|
||||
putTreeMap(map?: Record<string, string>) {
|
||||
if (!map) {
|
||||
that.putUint16(0);
|
||||
return that;
|
||||
}
|
||||
|
||||
that.putUint16(Object.keys(map).length);
|
||||
for (const key in map) {
|
||||
that.putUint16(parseInt(key, 10));
|
||||
that.putString(map[key]);
|
||||
}
|
||||
|
||||
return that;
|
||||
},
|
||||
|
||||
putTreeMapUInt32(map?: Messages) {
|
||||
if (!map) {
|
||||
that.putUint16(0);
|
||||
return that;
|
||||
}
|
||||
|
||||
that.putUint16(Object.keys(map).length);
|
||||
for (const key in map) {
|
||||
that.putUint16(parseInt(key, 10));
|
||||
that.putUint32(map[key]);
|
||||
}
|
||||
|
||||
return that;
|
||||
},
|
||||
};
|
||||
|
||||
that.buffer.fill(0);
|
||||
return that;
|
||||
};
|
||||
const ReadByteBuf = (bytes: Buffer): ReadByteBufInterface => {
|
||||
const that: ReadByteBufInterface = {
|
||||
buffer: bytes,
|
||||
position: 0,
|
||||
|
||||
getUint16() {
|
||||
const ret = that.buffer.readUInt16LE(that.position);
|
||||
that.position += 2;
|
||||
return ret;
|
||||
},
|
||||
|
||||
getUint32() {
|
||||
const ret = that.buffer.readUInt32LE(that.position);
|
||||
that.position += 4;
|
||||
return ret;
|
||||
},
|
||||
|
||||
getString() {
|
||||
const len = that.getUint16();
|
||||
const out = Buffer.alloc(len);
|
||||
that.buffer.copy(out, 0, that.position, that.position + len);
|
||||
that.position += len;
|
||||
return out;
|
||||
},
|
||||
|
||||
getTreeMapUInt32() {
|
||||
const map: Messages = {};
|
||||
const len = that.getUint16();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = that.getUint16();
|
||||
const value = that.getUint32();
|
||||
map[key] = value;
|
||||
}
|
||||
return map;
|
||||
},
|
||||
};
|
||||
|
||||
return that;
|
||||
};
|
||||
const AccessTokenContent = (options: AccessTokenContentOptions): AccessTokenContentOptions => {
|
||||
options.pack = () => {
|
||||
const out = ByteBuf();
|
||||
return out
|
||||
.putBytes(options.signature as Buffer)
|
||||
.putUint32(options.crc_channel)
|
||||
.putUint32(options.crc_uid)
|
||||
.putBytes(options.m as Buffer)
|
||||
.pack();
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const unPackContent = (bytes: Buffer): AccessTokenContentOptions => {
|
||||
const readbuf = ReadByteBuf(bytes);
|
||||
return AccessTokenContent({
|
||||
signature: readbuf.getString(),
|
||||
crc_channel_name: readbuf.getUint32(),
|
||||
crc_uid: readbuf.getUint32(),
|
||||
m: readbuf.getString(),
|
||||
crc_channel: 0,
|
||||
});
|
||||
};
|
||||
const Message = (options: MessageOptions): MessageOptions => {
|
||||
options.pack = () => {
|
||||
const out = ByteBuf();
|
||||
return out.putUint32(options.salt).putUint32(options.ts).putTreeMapUInt32(options.messages).pack();
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const unPackMessages = (bytes: Buffer): MessageOptions => {
|
||||
const readbuf = ReadByteBuf(bytes);
|
||||
return Message({
|
||||
salt: readbuf.getUint32(),
|
||||
ts: readbuf.getUint32(),
|
||||
messages: readbuf.getTreeMapUInt32(),
|
||||
});
|
||||
};
|
||||
export class AccessToken {
|
||||
public appID: string;
|
||||
public appCertificate: string;
|
||||
public channelName: string;
|
||||
public uid: string;
|
||||
public messages: Messages;
|
||||
public salt: number;
|
||||
public ts: number;
|
||||
|
||||
public constructor(appID: string, appCertificate: string, channelName: string, uid: number | string) {
|
||||
this.appID = appID;
|
||||
this.appCertificate = appCertificate;
|
||||
this.channelName = channelName;
|
||||
this.messages = {};
|
||||
this.salt = randomInt;
|
||||
this.ts = Math.floor(new Date().getTime() / 1000) + 24 * 3600;
|
||||
if (uid === 0) {
|
||||
this.uid = '';
|
||||
} else {
|
||||
this.uid = `${uid}`;
|
||||
}
|
||||
}
|
||||
|
||||
public build(): string {
|
||||
const m = Message({
|
||||
salt: this.salt,
|
||||
ts: this.ts,
|
||||
messages: this.messages,
|
||||
}).pack!();
|
||||
|
||||
const toSign = Buffer.concat([Buffer.from(this.appID, 'utf8'), Buffer.from(this.channelName, 'utf8'), Buffer.from(this.uid, 'utf8'), m]);
|
||||
|
||||
const signature = encodeHMac(this.appCertificate, toSign);
|
||||
const crc_channel = UINT32(crc32.str(this.channelName)).and(UINT32(0xffffffff)).toNumber();
|
||||
const crc_uid = UINT32(crc32.str(this.uid)).and(UINT32(0xffffffff)).toNumber();
|
||||
const content = AccessTokenContent({
|
||||
signature,
|
||||
crc_channel,
|
||||
crc_uid,
|
||||
m,
|
||||
}).pack!();
|
||||
return version + this.appID + content.toString('base64');
|
||||
}
|
||||
|
||||
public addPriviledge(priviledge: number, expireTimestamp: number): void {
|
||||
this.messages[priviledge] = expireTimestamp;
|
||||
}
|
||||
|
||||
public fromString(originToken: string): boolean {
|
||||
try {
|
||||
const originVersion = originToken.substr(0, VERSION_LENGTH);
|
||||
if (originVersion !== version) {
|
||||
return false;
|
||||
}
|
||||
this.appID = originToken.substr(VERSION_LENGTH, APP_ID_LENGTH);
|
||||
const originContent = originToken.substr(VERSION_LENGTH + APP_ID_LENGTH);
|
||||
const originContentDecodedBuf = Buffer.from(originContent, 'base64');
|
||||
|
||||
const content = unPackContent(originContentDecodedBuf);
|
||||
const msgs = unPackMessages(content.m as Buffer);
|
||||
this.salt = msgs.salt;
|
||||
this.ts = msgs.ts;
|
||||
this.messages = msgs.messages;
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export { version };
|
||||
447
node_api/src/plugins/shengwang/AccessToken2.ts
Normal file
447
node_api/src/plugins/shengwang/AccessToken2.ts
Normal file
@ -0,0 +1,447 @@
|
||||
import crypto from 'node:crypto';
|
||||
import zlib from 'node:zlib';
|
||||
|
||||
interface ByteBufInterface {
|
||||
buffer: Buffer;
|
||||
position: number;
|
||||
pack: () => Buffer;
|
||||
putUint16: (v: number) => ByteBufInterface;
|
||||
putUint32: (v: number) => ByteBufInterface;
|
||||
putInt32: (v: number) => ByteBufInterface;
|
||||
putInt16: (v: number) => ByteBufInterface;
|
||||
putBytes: (bytes: Buffer) => ByteBufInterface;
|
||||
putString: (str: string) => ByteBufInterface;
|
||||
putTreeMap: (map?: Record<string, string>) => ByteBufInterface;
|
||||
putTreeMapUInt32: (map?: Privileges) => ByteBufInterface;
|
||||
}
|
||||
|
||||
const VERSION_LENGTH = 3;
|
||||
const APP_ID_LENGTH = 32;
|
||||
|
||||
const encodeHMac = (key: Buffer, message: Buffer | string): Buffer => {
|
||||
return crypto.createHmac('sha256', key).update(message).digest();
|
||||
};
|
||||
const getVersion = () => {
|
||||
return '007';
|
||||
};
|
||||
|
||||
type Privileges = Record<number, number>;
|
||||
|
||||
class ByteBuf implements ByteBufInterface {
|
||||
public buffer: Buffer;
|
||||
public position: number;
|
||||
|
||||
public constructor() {
|
||||
this.buffer = Buffer.alloc(1024);
|
||||
this.position = 0;
|
||||
this.buffer.fill(0);
|
||||
}
|
||||
|
||||
public pack(): Buffer {
|
||||
const out = Buffer.alloc(this.position);
|
||||
this.buffer.copy(out, 0, 0, out.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
public putUint16(v: number): ByteBufInterface {
|
||||
this.buffer.writeUInt16LE(v, this.position);
|
||||
this.position += 2;
|
||||
return this;
|
||||
}
|
||||
|
||||
public putUint32(v: number): ByteBufInterface {
|
||||
this.buffer.writeUInt32LE(v, this.position);
|
||||
this.position += 4;
|
||||
return this;
|
||||
}
|
||||
|
||||
public putInt32(v: number): ByteBufInterface {
|
||||
this.buffer.writeInt32LE(v, this.position);
|
||||
this.position += 4;
|
||||
return this;
|
||||
}
|
||||
|
||||
public putInt16(v: number): ByteBufInterface {
|
||||
this.buffer.writeInt16LE(v, this.position);
|
||||
this.position += 2;
|
||||
return this;
|
||||
}
|
||||
|
||||
public putBytes(bytes: Buffer): ByteBufInterface {
|
||||
this.putUint16(bytes.length);
|
||||
bytes.copy(this.buffer, this.position);
|
||||
this.position += bytes.length;
|
||||
return this;
|
||||
}
|
||||
|
||||
public putString(str: string): ByteBufInterface {
|
||||
return this.putBytes(Buffer.from(str));
|
||||
}
|
||||
|
||||
public putTreeMap(map?: Record<string, string>): ByteBufInterface {
|
||||
if (!map) {
|
||||
this.putUint16(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
this.putUint16(Object.keys(map).length);
|
||||
for (const key in map) {
|
||||
this.putUint16(parseInt(key, 10));
|
||||
this.putString(map[key]);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public putTreeMapUInt32(map?: Privileges): ByteBufInterface {
|
||||
if (!map) {
|
||||
this.putUint16(0);
|
||||
return this;
|
||||
}
|
||||
|
||||
this.putUint16(Object.keys(map).length);
|
||||
for (const key in map) {
|
||||
this.putUint16(parseInt(key, 10));
|
||||
this.putUint32(map[key]);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class ReadByteBuf {
|
||||
public buffer: Buffer;
|
||||
public position: number;
|
||||
|
||||
public constructor(bytes: Buffer) {
|
||||
this.buffer = bytes;
|
||||
this.position = 0;
|
||||
}
|
||||
|
||||
public getUint16(): number {
|
||||
const ret = this.buffer.readUInt16LE(this.position);
|
||||
this.position += 2;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getUint32(): number {
|
||||
const ret = this.buffer.readUInt32LE(this.position);
|
||||
this.position += 4;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getInt16(): number {
|
||||
const ret = this.buffer.readInt16LE(this.position);
|
||||
this.position += 2;
|
||||
return ret;
|
||||
}
|
||||
|
||||
public getString(): string {
|
||||
const len = this.getUint16();
|
||||
const out = Buffer.alloc(len);
|
||||
this.buffer.copy(out, 0, this.position, this.position + len);
|
||||
this.position += len;
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public getTreeMapUInt32(): Privileges {
|
||||
const map: Privileges = {};
|
||||
const len = this.getUint16();
|
||||
for (let i = 0; i < len; i++) {
|
||||
const key = this.getUint16();
|
||||
const value = this.getUint32();
|
||||
map[key] = value;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public pack(): Buffer {
|
||||
const length = this.buffer.length;
|
||||
const out = Buffer.alloc(length);
|
||||
this.buffer.copy(out, 0, this.position, length);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
class Service {
|
||||
protected __type: number;
|
||||
protected __privileges: Privileges;
|
||||
public constructor(service_type: number) {
|
||||
this.__type = service_type;
|
||||
this.__privileges = {};
|
||||
}
|
||||
|
||||
protected __pack_type(): Buffer {
|
||||
const buf = new ByteBuf();
|
||||
buf.putUint16(this.__type);
|
||||
return buf.pack();
|
||||
}
|
||||
|
||||
protected __pack_privileges(): Buffer {
|
||||
const buf = new ByteBuf();
|
||||
buf.putTreeMapUInt32(this.__privileges);
|
||||
return buf.pack();
|
||||
}
|
||||
|
||||
public service_type(): number {
|
||||
return this.__type;
|
||||
}
|
||||
|
||||
public add_privilege(privilege: number, expire: number): void {
|
||||
this.__privileges[privilege] = expire;
|
||||
}
|
||||
|
||||
public pack() {
|
||||
return Buffer.concat([this.__pack_type(), this.__pack_privileges()]);
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = new ReadByteBuf(buffer);
|
||||
this.__privileges = bufReader.getTreeMapUInt32();
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
const kRtcServiceType = 1;
|
||||
|
||||
export class ServiceRtc extends Service {
|
||||
protected __channel_name: string;
|
||||
protected __uid: string;
|
||||
public static kPrivilegeJoinChannel = 1;
|
||||
public static kPrivilegePublishAudioStream = 2;
|
||||
public static kPrivilegePublishVideoStream = 3;
|
||||
public static kPrivilegePublishDataStream = 4;
|
||||
public constructor(channel_name: string, uid: number | string) {
|
||||
super(kRtcServiceType);
|
||||
this.__channel_name = channel_name;
|
||||
this.__uid = uid === 0 ? '' : `${uid}`;
|
||||
}
|
||||
|
||||
public pack() {
|
||||
const buffer = new ByteBuf();
|
||||
buffer.putString(this.__channel_name).putString(this.__uid);
|
||||
return Buffer.concat([super.pack(), buffer.pack()]);
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = super.unpack(buffer);
|
||||
this.__channel_name = bufReader.getString();
|
||||
this.__uid = bufReader.getString();
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
const kRtmServiceType = 2;
|
||||
|
||||
export class ServiceRtm extends Service {
|
||||
protected __user_id: string;
|
||||
|
||||
public static kPrivilegeLogin = 1;
|
||||
|
||||
public constructor(user_id?: string) {
|
||||
super(kRtmServiceType);
|
||||
this.__user_id = user_id || '';
|
||||
}
|
||||
|
||||
public pack() {
|
||||
const buffer = new ByteBuf();
|
||||
buffer.putString(this.__user_id);
|
||||
return Buffer.concat([super.pack(), buffer.pack()]);
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = super.unpack(buffer);
|
||||
this.__user_id = bufReader.getString();
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
const kFpaServiceType = 4;
|
||||
|
||||
export class ServiceFpa extends Service {
|
||||
public static kPrivilegeLogin = 1;
|
||||
|
||||
public constructor() {
|
||||
super(kFpaServiceType);
|
||||
}
|
||||
|
||||
public pack() {
|
||||
return super.pack();
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = super.unpack(buffer);
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
const kChatServiceType = 5;
|
||||
|
||||
export class ServiceChat extends Service {
|
||||
protected __user_id: string;
|
||||
|
||||
public static kPrivilegeUser = 1;
|
||||
public static kPrivilegeApp = 2;
|
||||
public constructor(user_id?: string) {
|
||||
super(kChatServiceType);
|
||||
this.__user_id = user_id || '';
|
||||
}
|
||||
|
||||
public pack() {
|
||||
const buffer = new ByteBuf();
|
||||
buffer.putString(this.__user_id);
|
||||
return Buffer.concat([super.pack(), buffer.pack()]);
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = super.unpack(buffer);
|
||||
this.__user_id = bufReader.getString();
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
const kApaasServiceType = 7;
|
||||
|
||||
export class ServiceApaas extends Service {
|
||||
protected __room_uuid: string;
|
||||
protected __user_uuid: string;
|
||||
protected __role: number;
|
||||
|
||||
public static PRIVILEGE_ROOM_USER = 1;
|
||||
public static PRIVILEGE_USER = 2;
|
||||
public static PRIVILEGE_APP = 3;
|
||||
public constructor(roomUuid?: string, userUuid?: string, role?: number) {
|
||||
super(kApaasServiceType);
|
||||
this.__room_uuid = roomUuid || '';
|
||||
this.__user_uuid = userUuid || '';
|
||||
this.__role = role || -1;
|
||||
}
|
||||
|
||||
public pack() {
|
||||
const buffer = new ByteBuf();
|
||||
buffer.putString(this.__room_uuid);
|
||||
buffer.putString(this.__user_uuid);
|
||||
buffer.putInt16(this.__role);
|
||||
return Buffer.concat([super.pack(), buffer.pack()]);
|
||||
}
|
||||
|
||||
public unpack(buffer: Buffer): ReadByteBuf {
|
||||
const bufReader = super.unpack(buffer);
|
||||
this.__room_uuid = bufReader.getString();
|
||||
this.__user_uuid = bufReader.getString();
|
||||
this.__role = bufReader.getInt16();
|
||||
return bufReader;
|
||||
}
|
||||
}
|
||||
|
||||
type Services = Record<number, Service>;
|
||||
|
||||
export class AccessToken2 {
|
||||
public appId: string;
|
||||
public appCertificate: string;
|
||||
public issueTs: number;
|
||||
public expire: number;
|
||||
public salt: number;
|
||||
public services: Services;
|
||||
|
||||
public static kServices: Record<number, new (...args: any[]) => Service> = {};
|
||||
|
||||
public constructor(appId: string, appCertificate: string, issueTs?: number, expire?: number) {
|
||||
this.appId = appId;
|
||||
this.appCertificate = appCertificate;
|
||||
this.issueTs = issueTs || new Date().getTime() / 1000;
|
||||
this.expire = expire || 0;
|
||||
// salt ranges in (1, 99999999)
|
||||
this.salt = Math.floor(Math.random() * 99999999) + 1;
|
||||
this.services = {};
|
||||
}
|
||||
|
||||
private __signing() {
|
||||
let signing = encodeHMac(new ByteBuf().putUint32(this.issueTs).pack(), this.appCertificate);
|
||||
signing = encodeHMac(new ByteBuf().putUint32(this.salt).pack(), signing);
|
||||
return signing;
|
||||
}
|
||||
|
||||
private __build_check() {
|
||||
const is_uuid = (data: string): boolean => {
|
||||
if (data.length !== APP_ID_LENGTH) {
|
||||
return false;
|
||||
}
|
||||
const buf = Buffer.from(data, 'hex');
|
||||
return Boolean(buf);
|
||||
};
|
||||
|
||||
const { appId, appCertificate, services } = this;
|
||||
if (!is_uuid(appId) || !is_uuid(appCertificate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(services).length === 0) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public add_service(service: Service): void {
|
||||
this.services[service.service_type()] = service;
|
||||
}
|
||||
|
||||
public build() {
|
||||
if (!this.__build_check()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const signing = this.__signing();
|
||||
let signing_info = new ByteBuf()
|
||||
.putString(this.appId)
|
||||
.putUint32(this.issueTs)
|
||||
.putUint32(this.expire)
|
||||
.putUint32(this.salt)
|
||||
.putUint16(Object.keys(this.services).length)
|
||||
.pack();
|
||||
Object.values(this.services).forEach((service) => {
|
||||
signing_info = Buffer.concat([signing_info, service.pack()]);
|
||||
});
|
||||
|
||||
const signature = encodeHMac(signing, signing_info);
|
||||
const content = Buffer.concat([new ByteBuf().putBytes(signature).pack(), signing_info]);
|
||||
const compressed = zlib.deflateSync(content);
|
||||
return `${getVersion()}${Buffer.from(compressed).toString('base64')}`;
|
||||
}
|
||||
|
||||
public from_string(origin_token: string): boolean {
|
||||
const origin_version = origin_token.substring(0, VERSION_LENGTH);
|
||||
if (origin_version !== getVersion()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const origin_content = origin_token.substring(VERSION_LENGTH, origin_token.length);
|
||||
const buffer = zlib.inflateSync(Buffer.from(origin_content, 'base64'));
|
||||
const bufferReader = new ReadByteBuf(buffer);
|
||||
|
||||
this.appId = bufferReader.getString();
|
||||
this.issueTs = bufferReader.getUint32();
|
||||
this.expire = bufferReader.getUint32();
|
||||
this.salt = bufferReader.getUint32();
|
||||
const service_count = bufferReader.getUint16();
|
||||
|
||||
let remainBuf = bufferReader.pack();
|
||||
for (let i = 0; i < service_count; i++) {
|
||||
const bufferReaderService = new ReadByteBuf(remainBuf);
|
||||
const service_type = bufferReaderService.getUint16();
|
||||
const service = new AccessToken2.kServices[service_type]();
|
||||
remainBuf = service.unpack(bufferReaderService.pack()).pack();
|
||||
this.services[service_type] = service;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化 kServices
|
||||
AccessToken2.kServices[kApaasServiceType] = ServiceApaas;
|
||||
AccessToken2.kServices[kChatServiceType] = ServiceChat;
|
||||
AccessToken2.kServices[kFpaServiceType] = ServiceFpa;
|
||||
AccessToken2.kServices[kRtcServiceType] = ServiceRtc;
|
||||
AccessToken2.kServices[kRtmServiceType] = ServiceRtm;
|
||||
|
||||
export { kApaasServiceType, kChatServiceType, kFpaServiceType, kRtcServiceType, kRtmServiceType };
|
||||
65
node_api/src/plugins/shengwang/ApaasTokenBuilder.ts
Normal file
65
node_api/src/plugins/shengwang/ApaasTokenBuilder.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import md5 from 'md5';
|
||||
import { AccessToken2, ServiceApaas, ServiceChat, ServiceRtm } from './AccessToken2';
|
||||
|
||||
export class ApaasTokenBuilder {
|
||||
/**
|
||||
* build user room token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param roomUuid - The room's id, must be unique.
|
||||
* @param userUuid - The user's id, must be unique.
|
||||
* @param role - The user's role.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The user room token.
|
||||
*/
|
||||
public static buildRoomUserToken(appId: string, appCertificate: string, roomUuid: string, userUuid: string, role: number, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
|
||||
const chatUserId = md5(userUuid);
|
||||
const apaasService = new ServiceApaas(roomUuid, userUuid, role);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
const rtmService = new ServiceRtm(userUuid);
|
||||
rtmService.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
|
||||
accessToken.add_service(rtmService);
|
||||
|
||||
const chatService = new ServiceChat(chatUserId);
|
||||
chatService.add_privilege(ServiceChat.kPrivilegeUser, expire);
|
||||
accessToken.add_service(chatService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* build user token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param userUuid - The user's id, must be unique.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The user token.
|
||||
*/
|
||||
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
const apaasService = new ServiceApaas('', userUuid);
|
||||
apaasService.add_privilege(ServiceApaas.PRIVILEGE_USER, expire);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* build app token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The app token.
|
||||
*/
|
||||
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
const apaasService = new ServiceApaas();
|
||||
apaasService.add_privilege(ServiceApaas.PRIVILEGE_APP, expire);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
}
|
||||
34
node_api/src/plugins/shengwang/ChatTokenBuilder.ts
Normal file
34
node_api/src/plugins/shengwang/ChatTokenBuilder.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { AccessToken2, ServiceChat } from './AccessToken2';
|
||||
|
||||
export class ChatTokenBuilder {
|
||||
/**
|
||||
* Build the Chat user token.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param userUuid - The user's id, must be unique.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The Chat User token.
|
||||
*/
|
||||
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
|
||||
const token = new AccessToken2(appId, appCertificate, undefined, expire);
|
||||
const serviceChat = new ServiceChat(userUuid);
|
||||
serviceChat.add_privilege(ServiceChat.kPrivilegeUser, expire);
|
||||
token.add_service(serviceChat);
|
||||
return token.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Chat App token.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The Chat App token.
|
||||
*/
|
||||
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
|
||||
const token = new AccessToken2(appId, appCertificate, undefined, expire);
|
||||
const serviceChat = new ServiceChat();
|
||||
serviceChat.add_privilege(ServiceChat.kPrivilegeApp, expire);
|
||||
token.add_service(serviceChat);
|
||||
return token.build();
|
||||
}
|
||||
}
|
||||
251
node_api/src/plugins/shengwang/DynamicKey5.ts
Normal file
251
node_api/src/plugins/shengwang/DynamicKey5.ts
Normal file
@ -0,0 +1,251 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const version = '005';
|
||||
export const noUpload = '0';
|
||||
export const audioVideoUpload = '3';
|
||||
|
||||
// Service Type
|
||||
const MEDIA_CHANNEL_SERVICE = 1;
|
||||
const RECORDING_SERVICE = 2;
|
||||
const PUBLIC_SHARING_SERVICE = 3;
|
||||
const IN_CHANNEL_PERMISSION = 4;
|
||||
|
||||
// InChannelPermissionKey
|
||||
const ALLOW_UPLOAD_IN_CHANNEL = 1;
|
||||
|
||||
type ExtraMap = Record<number, string>;
|
||||
|
||||
interface MessageOptions {
|
||||
serviceType: number;
|
||||
appID: Buffer;
|
||||
unixTs: number;
|
||||
salt: number;
|
||||
channelName: string;
|
||||
uid: number;
|
||||
expiredTs: number;
|
||||
extra?: ExtraMap;
|
||||
pack?: () => Buffer;
|
||||
}
|
||||
|
||||
interface DynamicKey5ContentOptions {
|
||||
serviceType: number;
|
||||
signature: string;
|
||||
appID: Buffer;
|
||||
unixTs: number;
|
||||
salt: number;
|
||||
expiredTs: number;
|
||||
extra?: ExtraMap;
|
||||
pack?: () => Buffer;
|
||||
}
|
||||
|
||||
interface ByteBufInterface {
|
||||
buffer: Buffer;
|
||||
position: number;
|
||||
pack: () => Buffer;
|
||||
putUint16: (v: number) => ByteBufInterface;
|
||||
putUint32: (v: number) => ByteBufInterface;
|
||||
putBytes: (bytes: Buffer) => ByteBufInterface;
|
||||
putString: (str: string) => ByteBufInterface;
|
||||
putTreeMap: (map?: ExtraMap) => ByteBufInterface;
|
||||
}
|
||||
|
||||
const ByteBuf = (): ByteBufInterface => {
|
||||
const that: ByteBufInterface = {
|
||||
buffer: Buffer.alloc(1024),
|
||||
position: 0,
|
||||
|
||||
pack() {
|
||||
const out = Buffer.alloc(that.position);
|
||||
that.buffer.copy(out, 0, 0, out.length);
|
||||
return out;
|
||||
},
|
||||
|
||||
putUint16(v: number) {
|
||||
that.buffer.writeUInt16LE(v, that.position);
|
||||
that.position += 2;
|
||||
return that;
|
||||
},
|
||||
|
||||
putUint32(v: number) {
|
||||
that.buffer.writeUInt32LE(v, that.position);
|
||||
that.position += 4;
|
||||
return that;
|
||||
},
|
||||
|
||||
putBytes(bytes: Buffer) {
|
||||
that.putUint16(bytes.length);
|
||||
bytes.copy(that.buffer, that.position);
|
||||
that.position += bytes.length;
|
||||
return that;
|
||||
},
|
||||
|
||||
putString(str: string) {
|
||||
return that.putBytes(Buffer.from(str));
|
||||
},
|
||||
|
||||
putTreeMap(map?: ExtraMap) {
|
||||
if (!map) {
|
||||
that.putUint16(0);
|
||||
return that;
|
||||
}
|
||||
|
||||
that.putUint16(Object.keys(map).length);
|
||||
for (const key in map) {
|
||||
that.putUint16(parseInt(key, 10));
|
||||
that.putString(map[key]);
|
||||
}
|
||||
|
||||
return that;
|
||||
},
|
||||
};
|
||||
|
||||
that.buffer.fill(0);
|
||||
return that;
|
||||
};
|
||||
const hexDecode = (str: string): Buffer => {
|
||||
return Buffer.from(str, 'hex');
|
||||
};
|
||||
const encodeHMac = (key: Buffer, message: Buffer): string => {
|
||||
return crypto.createHmac('sha1', key).update(message).digest('hex').toUpperCase();
|
||||
};
|
||||
const Message = (options: MessageOptions): MessageOptions => {
|
||||
options.pack = () => {
|
||||
const out = ByteBuf();
|
||||
return out
|
||||
.putUint16(options.serviceType)
|
||||
.putBytes(options.appID)
|
||||
.putUint32(options.unixTs)
|
||||
.putUint32(options.salt)
|
||||
.putString(options.channelName)
|
||||
.putUint32(options.uid)
|
||||
.putUint32(options.expiredTs)
|
||||
.putTreeMap(options.extra)
|
||||
.pack();
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
const generateSignature5 = (
|
||||
appCertificate: string,
|
||||
serviceType: number,
|
||||
appID: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
channelName: string,
|
||||
uid: number,
|
||||
expiredTs: number,
|
||||
extra?: ExtraMap
|
||||
): string => {
|
||||
const rawAppID = hexDecode(appID);
|
||||
const rawAppCertificate = hexDecode(appCertificate);
|
||||
|
||||
const m = Message({
|
||||
serviceType,
|
||||
appID: rawAppID,
|
||||
unixTs,
|
||||
salt: randomInt,
|
||||
channelName,
|
||||
uid,
|
||||
expiredTs,
|
||||
extra,
|
||||
});
|
||||
|
||||
const toSign = m.pack!();
|
||||
return encodeHMac(rawAppCertificate, toSign);
|
||||
};
|
||||
|
||||
const DynamicKey5Content = (options: DynamicKey5ContentOptions): DynamicKey5ContentOptions => {
|
||||
options.pack = () => {
|
||||
const out = ByteBuf();
|
||||
return out
|
||||
.putUint16(options.serviceType)
|
||||
.putString(options.signature)
|
||||
.putBytes(options.appID)
|
||||
.putUint32(options.unixTs)
|
||||
.putUint32(options.salt)
|
||||
.putUint32(options.expiredTs)
|
||||
.putTreeMap(options.extra)
|
||||
.pack();
|
||||
};
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export const generateDynamicKey = (
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
uid: number,
|
||||
expiredTs: number,
|
||||
extra?: ExtraMap,
|
||||
serviceType: number = MEDIA_CHANNEL_SERVICE
|
||||
): string => {
|
||||
const signature = generateSignature5(appCertificate, serviceType, appID, unixTs, randomInt, channelName, uid, expiredTs, extra);
|
||||
const content = DynamicKey5Content({
|
||||
serviceType,
|
||||
signature,
|
||||
appID: hexDecode(appID),
|
||||
unixTs,
|
||||
salt: randomInt,
|
||||
expiredTs,
|
||||
extra,
|
||||
}).pack!();
|
||||
return version + content.toString('base64');
|
||||
};
|
||||
export const generatePublicSharingKey = (
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
uid: number,
|
||||
expiredTs: number
|
||||
): string => {
|
||||
const channelNameStr = channelName.toString();
|
||||
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, PUBLIC_SHARING_SERVICE);
|
||||
};
|
||||
|
||||
export const generateRecordingKey = (
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
uid: number,
|
||||
expiredTs: number
|
||||
): string => {
|
||||
const channelNameStr = channelName.toString();
|
||||
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, RECORDING_SERVICE);
|
||||
};
|
||||
|
||||
export const generateMediaChannelKey = (
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
uid: number,
|
||||
expiredTs: number
|
||||
): string => {
|
||||
const channelNameStr = channelName.toString();
|
||||
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, MEDIA_CHANNEL_SERVICE);
|
||||
};
|
||||
|
||||
export const generateInChannelPermissionKey = (
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
unixTs: number,
|
||||
randomInt: number,
|
||||
uid: number,
|
||||
expiredTs: number,
|
||||
permission: string
|
||||
): string => {
|
||||
const extra: ExtraMap = {};
|
||||
extra[ALLOW_UPLOAD_IN_CHANNEL] = permission;
|
||||
return generateDynamicKey(appID, appCertificate, channelName, unixTs, randomInt, uid, expiredTs, extra, IN_CHANNEL_PERMISSION);
|
||||
};
|
||||
|
||||
export { version };
|
||||
65
node_api/src/plugins/shengwang/EducationTokenBuilder.ts
Normal file
65
node_api/src/plugins/shengwang/EducationTokenBuilder.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import md5 from 'md5';
|
||||
import { AccessToken2, ServiceApaas, ServiceChat, ServiceRtm } from './AccessToken2';
|
||||
|
||||
export class EducationTokenBuilder {
|
||||
/**
|
||||
* build user room token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param roomUuid - The room's id, must be unique.
|
||||
* @param userUuid - The user's id, must be unique.
|
||||
* @param role - The user's role.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The user room token.
|
||||
*/
|
||||
public static buildRoomUserToken(appId: string, appCertificate: string, roomUuid: string, userUuid: string, role: number, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
|
||||
const chatUserId = md5(userUuid);
|
||||
const apaasService = new ServiceApaas(roomUuid, userUuid, role);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
const rtmService = new ServiceRtm(userUuid);
|
||||
rtmService.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
|
||||
accessToken.add_service(rtmService);
|
||||
|
||||
const chatService = new ServiceChat(chatUserId);
|
||||
chatService.add_privilege(ServiceChat.kPrivilegeUser, expire);
|
||||
accessToken.add_service(chatService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* build user token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param userUuid - The user's id, must be unique.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The user token.
|
||||
*/
|
||||
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
const apaasService = new ServiceApaas('', userUuid);
|
||||
apaasService.add_privilege(ServiceApaas.PRIVILEGE_USER, expire);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* build app token
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The app token.
|
||||
*/
|
||||
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
|
||||
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
|
||||
const apaasService = new ServiceApaas();
|
||||
apaasService.add_privilege(ServiceApaas.PRIVILEGE_APP, expire);
|
||||
accessToken.add_service(apaasService);
|
||||
|
||||
return accessToken.build();
|
||||
}
|
||||
}
|
||||
19
node_api/src/plugins/shengwang/FpaTokenBuilder.ts
Normal file
19
node_api/src/plugins/shengwang/FpaTokenBuilder.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { AccessToken2, ServiceFpa } from './AccessToken2';
|
||||
|
||||
export class FpaTokenBuilder {
|
||||
/**
|
||||
* Build the FPA token.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @returns The FPA token.
|
||||
*/
|
||||
public static buildToken(appId: string, appCertificate: string): string {
|
||||
const token = new AccessToken2(appId, appCertificate, 0, 24 * 3600);
|
||||
|
||||
const serviceFpa = new ServiceFpa();
|
||||
serviceFpa.add_privilege(ServiceFpa.kPrivilegeLogin, 0);
|
||||
token.add_service(serviceFpa);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
}
|
||||
59
node_api/src/plugins/shengwang/RtcTokenBuilder.ts
Normal file
59
node_api/src/plugins/shengwang/RtcTokenBuilder.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { AccessToken, priviledges } from './AccessToken';
|
||||
|
||||
export enum Role {
|
||||
// DEPRECATED. Role::ATTENDEE has the same privileges as Role.PUBLISHER.
|
||||
ATTENDEE = 0,
|
||||
|
||||
// RECOMMENDED. Use this role for a voice/video call or a live broadcast
|
||||
PUBLISHER = 1,
|
||||
|
||||
// Only use this role if your scenario require authentication for Co-host
|
||||
SUBSCRIBER = 2,
|
||||
|
||||
// DEPRECATED. Role.ADMIN has the same privileges as Role.PUBLISHER.
|
||||
ADMIN = 101,
|
||||
}
|
||||
|
||||
export class RtcTokenBuilder {
|
||||
/**
|
||||
* Builds an RTC token using an Integer uid.
|
||||
* @param appID - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
|
||||
* @param uid - User ID. A 32-bit unsigned integer with a value ranging from 1 to (2^32-1).
|
||||
* @param role - See #userRole.
|
||||
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
|
||||
* @returns The new Token.
|
||||
*/
|
||||
public static buildTokenWithUid(appID: string, appCertificate: string, channelName: string, uid: number, role: Role, privilegeExpiredTs: number): string {
|
||||
return this.buildTokenWithAccount(appID, appCertificate, channelName, uid, role, privilegeExpiredTs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an RTC token with account.
|
||||
* @param appID - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
|
||||
* @param account - The user account.
|
||||
* @param role - See #userRole.
|
||||
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
|
||||
* @returns The new Token.
|
||||
*/
|
||||
public static buildTokenWithAccount(
|
||||
appID: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
account: number | string,
|
||||
role: Role,
|
||||
privilegeExpiredTs: number
|
||||
): string {
|
||||
const key = new AccessToken(appID, appCertificate, channelName, account);
|
||||
key.addPriviledge(priviledges.kJoinChannel, privilegeExpiredTs);
|
||||
if (role === Role.ATTENDEE || role === Role.PUBLISHER || role === Role.ADMIN) {
|
||||
key.addPriviledge(priviledges.kPublishAudioStream, privilegeExpiredTs);
|
||||
key.addPriviledge(priviledges.kPublishVideoStream, privilegeExpiredTs);
|
||||
key.addPriviledge(priviledges.kPublishDataStream, privilegeExpiredTs);
|
||||
}
|
||||
return key.build();
|
||||
}
|
||||
}
|
||||
234
node_api/src/plugins/shengwang/RtcTokenBuilder2.ts
Normal file
234
node_api/src/plugins/shengwang/RtcTokenBuilder2.ts
Normal file
@ -0,0 +1,234 @@
|
||||
import { AccessToken2, ServiceRtc, ServiceRtm } from './AccessToken2';
|
||||
|
||||
export enum Role {
|
||||
/**
|
||||
* 推荐使用。如果您的场景不需要对联合主播进行身份验证,
|
||||
* 请使用此角色进行语音/视频通话或直播。
|
||||
*/
|
||||
PUBLISHER = 1,
|
||||
|
||||
/**
|
||||
* 仅当您的场景需要对联合主播进行身份验证时才使用此角色。
|
||||
* 为了使此角色生效,请联系我们的支持团队为您启用联合主播身份验证。
|
||||
* 否则,Role_Subscriber 仍然具有与 Role_Publisher 相同的权限。
|
||||
*/
|
||||
SUBSCRIBER = 2,
|
||||
}
|
||||
|
||||
export class RtcTokenBuilder {
|
||||
/**
|
||||
* 使用 uid 构建 RTC Token
|
||||
* @param appId - 声网颁发给您的 App ID
|
||||
* @param appCertificate - 您在声网控制台注册的应用程序证书
|
||||
* @param channelName - 字符串格式的 AgoraRTC 会话的唯一频道名称
|
||||
* @param uid - 用户 ID。范围从 1 到 (2^32-1) 的 32 位无符号整数
|
||||
* @param role - 用户角色
|
||||
* @param tokenExpire - 从现在开始经过的秒数表示
|
||||
* @param privilegeExpire - 从现在开始经过的秒数表示
|
||||
* @returns RTC Token
|
||||
*/
|
||||
public static buildTokenWithUid(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
uid: number | string,
|
||||
role: Role,
|
||||
tokenExpire: number,
|
||||
privilegeExpire = 0
|
||||
): string {
|
||||
return this.buildTokenWithUserAccount(appId, appCertificate, channelName, uid, role, tokenExpire, privilegeExpire);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用账户构建 RTC Token
|
||||
* @param appId - 声网颁发给您的 App ID
|
||||
* @param appCertificate - 您在声网控制台注册的应用程序证书
|
||||
* @param channelName - 字符串格式的 AgoraRTC 会话的唯一频道名称
|
||||
* @param account - 用户账户
|
||||
* @param role - 用户角色
|
||||
* @param tokenExpire - 从现在开始经过的秒数表示
|
||||
* @param privilegeExpire - 从现在开始经过的秒数表示
|
||||
* @returns RTC Token
|
||||
*/
|
||||
public static buildTokenWithUserAccount(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
account: number | string,
|
||||
role: Role,
|
||||
tokenExpire: number,
|
||||
privilegeExpire = 0
|
||||
): string {
|
||||
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
|
||||
|
||||
const serviceRtc = new ServiceRtc(channelName, account);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, privilegeExpire);
|
||||
if (role === Role.PUBLISHER) {
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, privilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, privilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, privilegeExpire);
|
||||
}
|
||||
token.add_service(serviceRtc);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an RTC token with the specified privilege.
|
||||
* @param appId - The App ID of your Agora project.
|
||||
* @param appCertificate - The App Certificate of your Agora project.
|
||||
* @param channelName - The unique channel name for the Agora RTC session in string format.
|
||||
* @param uid - The user ID.
|
||||
* @param tokenExpire - represented by the number of seconds elapsed since now.
|
||||
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @returns The RTC Token
|
||||
*/
|
||||
public static buildTokenWithUidAndPrivilege(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
uid: number | string,
|
||||
tokenExpire: number,
|
||||
joinChannelPrivilegeExpire: number,
|
||||
pubAudioPrivilegeExpire: number,
|
||||
pubVideoPrivilegeExpire: number,
|
||||
pubDataStreamPrivilegeExpire: number
|
||||
): string {
|
||||
return this.BuildTokenWithUserAccountAndPrivilege(
|
||||
appId,
|
||||
appCertificate,
|
||||
channelName,
|
||||
uid,
|
||||
tokenExpire,
|
||||
joinChannelPrivilegeExpire,
|
||||
pubAudioPrivilegeExpire,
|
||||
pubVideoPrivilegeExpire,
|
||||
pubDataStreamPrivilegeExpire
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an RTC token with the specified privilege.
|
||||
* @param appId - The App ID of your Agora project.
|
||||
* @param appCertificate - The App Certificate of your Agora project.
|
||||
* @param channelName - The unique channel name for the Agora RTC session in string format.
|
||||
* @param userAccount - The user account.
|
||||
* @param tokenExpire - represented by the number of seconds elapsed since now.
|
||||
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @returns The RTC Token.
|
||||
*/
|
||||
public static BuildTokenWithUserAccountAndPrivilege(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
account: number | string,
|
||||
tokenExpire: number,
|
||||
joinChannelPrivilegeExpire: number,
|
||||
pubAudioPrivilegeExpire: number,
|
||||
pubVideoPrivilegeExpire: number,
|
||||
pubDataStreamPrivilegeExpire: number
|
||||
): string {
|
||||
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
|
||||
|
||||
const serviceRtc = new ServiceRtc(channelName, account);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, joinChannelPrivilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, pubAudioPrivilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, pubVideoPrivilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, pubDataStreamPrivilegeExpire);
|
||||
token.add_service(serviceRtc);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RTC and RTM token with account.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
|
||||
* @param account - The user account.
|
||||
* @param role - See #userRole.
|
||||
* @param tokenExpire - represented by the number of seconds elapsed since now.
|
||||
* @param privilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @returns The RTC and RTM Token.
|
||||
*/
|
||||
public static buildTokenWithRtm(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
account: number | string,
|
||||
role: Role,
|
||||
tokenExpire: number,
|
||||
privilegeExpire = 0
|
||||
): string {
|
||||
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
|
||||
|
||||
const serviceRtc = new ServiceRtc(channelName, account);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, privilegeExpire);
|
||||
if (role === Role.PUBLISHER) {
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, privilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, privilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, privilegeExpire);
|
||||
}
|
||||
token.add_service(serviceRtc);
|
||||
|
||||
const serviceRtm = new ServiceRtm(String(account));
|
||||
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, tokenExpire);
|
||||
token.add_service(serviceRtm);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an RTC and RTM token with account.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
|
||||
* @param rtcAccount - The RTC user's account, max length is 255 Bytes.
|
||||
* @param rtcRole - See #userRole.
|
||||
* @param rtcTokenExpire - represented by the number of seconds elapsed since now.
|
||||
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
|
||||
* @param rtmUserId - The RTM user's account, max length is 255 Bytes.
|
||||
* @param rtmTokenExpire - represented by the number of seconds elapsed since now.
|
||||
* @returns The RTC and RTM Token.
|
||||
*/
|
||||
public static buildTokenWithRtm2(
|
||||
appId: string,
|
||||
appCertificate: string,
|
||||
channelName: string,
|
||||
rtcAccount: number | string,
|
||||
rtcRole: Role,
|
||||
rtcTokenExpire: number,
|
||||
joinChannelPrivilegeExpire: number,
|
||||
pubAudioPrivilegeExpire: number,
|
||||
pubVideoPrivilegeExpire: number,
|
||||
pubDataStreamPrivilegeExpire: number,
|
||||
rtmUserId: string,
|
||||
rtmTokenExpire: number
|
||||
): string {
|
||||
const token = new AccessToken2(appId, appCertificate, 0, rtcTokenExpire);
|
||||
|
||||
const serviceRtc = new ServiceRtc(channelName, rtcAccount);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, joinChannelPrivilegeExpire);
|
||||
if (rtcRole === Role.PUBLISHER) {
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, pubAudioPrivilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, pubVideoPrivilegeExpire);
|
||||
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, pubDataStreamPrivilegeExpire);
|
||||
}
|
||||
token.add_service(serviceRtc);
|
||||
|
||||
const serviceRtm = new ServiceRtm(rtmUserId);
|
||||
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, rtmTokenExpire);
|
||||
token.add_service(serviceRtm);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
}
|
||||
22
node_api/src/plugins/shengwang/RtmTokenBuilder.ts
Normal file
22
node_api/src/plugins/shengwang/RtmTokenBuilder.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { AccessToken, priviledges } from './AccessToken';
|
||||
|
||||
export enum Role {
|
||||
Rtm_User = 1,
|
||||
}
|
||||
|
||||
export class RtmTokenBuilder {
|
||||
/**
|
||||
* Build RTM token
|
||||
* @param appID - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param account - The user account.
|
||||
* @param role - User role
|
||||
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
|
||||
* @returns token
|
||||
*/
|
||||
public static buildToken(appID: string, appCertificate: string, account: string, role: Role, privilegeExpiredTs: number): string {
|
||||
const key = new AccessToken(appID, appCertificate, account, '');
|
||||
key.addPriviledge(priviledges.kRtmLogin, privilegeExpiredTs);
|
||||
return key.build();
|
||||
}
|
||||
}
|
||||
21
node_api/src/plugins/shengwang/RtmTokenBuilder2.ts
Normal file
21
node_api/src/plugins/shengwang/RtmTokenBuilder2.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { AccessToken2, ServiceRtm } from './AccessToken2';
|
||||
|
||||
export class RtmTokenBuilder {
|
||||
/**
|
||||
* Build the RTM token.
|
||||
* @param appId - The App ID issued to you by Agora.
|
||||
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
|
||||
* @param userId - The user's account, max length is 64 Bytes.
|
||||
* @param expire - represented by the number of seconds elapsed since now.
|
||||
* @returns The RTM token.
|
||||
*/
|
||||
public static buildToken(appId: string, appCertificate: string, userId: string, expire: number): string {
|
||||
const token = new AccessToken2(appId, appCertificate, undefined, expire);
|
||||
|
||||
const serviceRtm = new ServiceRtm(userId);
|
||||
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
|
||||
token.add_service(serviceRtm);
|
||||
|
||||
return token.build();
|
||||
}
|
||||
}
|
||||
43
node_api/src/plugins/shengwang/SignalingToken.ts
Normal file
43
node_api/src/plugins/shengwang/SignalingToken.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import md5 from 'md5';
|
||||
|
||||
export class SignalingToken {
|
||||
/**
|
||||
* Get Signaling Token
|
||||
* @param appid - The App ID
|
||||
* @param appcertificate - The App Certificate
|
||||
* @param account - The user account
|
||||
* @param validTimeInSeconds - Valid time in seconds
|
||||
* @returns The Signaling Token
|
||||
*/
|
||||
public static get(appid: string, appcertificate: string, account: string, validTimeInSeconds: number): string {
|
||||
const expiredTime = parseInt(String(new Date().getTime() / 1000), 10) + validTimeInSeconds;
|
||||
const token_items: string[] = [];
|
||||
|
||||
// append SDK VERSION
|
||||
token_items.push('1');
|
||||
|
||||
// append appid
|
||||
token_items.push(appid);
|
||||
|
||||
// expired time
|
||||
token_items.push(String(expiredTime));
|
||||
|
||||
// md5 account + appid + appcertificate + expiredtime
|
||||
token_items.push(md5(account + appid + appcertificate + expiredTime));
|
||||
|
||||
return token_items.join(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to get token valid within 1 day
|
||||
* @param appid - The App ID
|
||||
* @param appcertificate - The App Certificate
|
||||
* @param account - The user account
|
||||
* @returns The Signaling Token valid for 1 day
|
||||
*/
|
||||
public static get1DayToken(appid: string, appcertificate: string, account: string): string {
|
||||
return SignalingToken.get(appid, appcertificate, account, 3600 * 24);
|
||||
}
|
||||
}
|
||||
|
||||
export default SignalingToken;
|
||||
Reference in New Issue
Block a user