forked from xiangwang25/whale-town-end-v2
Initial WhaleTown V2 backend
This commit is contained in:
361
src/business/admin/admin.controller.ts
Normal file
361
src/business/admin/admin.controller.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* 管理员控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供管理员登录认证接口
|
||||
* - 提供用户管理相关接口(查询、重置密码)
|
||||
* - 提供系统日志查询和下载功能
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP请求处理和参数验证
|
||||
* - 业务逻辑委托给AdminService处理
|
||||
* - 权限控制通过AdminGuard实现
|
||||
*
|
||||
* API端点:
|
||||
* - POST /admin/auth/login 管理员登录
|
||||
* - GET /admin/users 用户列表(需要管理员Token)
|
||||
* - GET /admin/users/:id 用户详情(需要管理员Token)
|
||||
* - POST /admin/users/:id/reset-password 重置指定用户密码(需要管理员Token)
|
||||
* - GET /admin/logs/runtime 获取运行日志尾部(需要管理员Token)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 代码质量优化 - 将同步文件系统操作改为异步操作,避免阻塞事件循环 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
* - 2026-01-08: 注释规范优化 - 补充方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.4
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, UseGuards, ValidationPipe, UsePipes, Res, Logger } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiProduces, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminLoginDto, AdminResetPasswordDto } from './admin_login.dto';
|
||||
import {
|
||||
AdminLoginResponseDto,
|
||||
AdminUsersResponseDto,
|
||||
AdminCommonResponseDto,
|
||||
AdminUserResponseDto,
|
||||
AdminRuntimeLogsResponseDto
|
||||
} from './admin_response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
|
||||
import { getCurrentTimestamp } from './admin_utils';
|
||||
import type { Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
import { pipeline } from 'stream';
|
||||
|
||||
@ApiTags('admin')
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
private readonly logger = new Logger(AdminController.name);
|
||||
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*
|
||||
* 功能描述:
|
||||
* 验证管理员身份并生成JWT Token,仅允许role=9的账户登录后台
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证登录标识符和密码
|
||||
* 2. 检查用户角色是否为管理员(role=9)
|
||||
* 3. 生成JWT Token
|
||||
* 4. 返回登录结果和Token
|
||||
*
|
||||
* @param dto 登录请求数据
|
||||
* @returns 登录结果,包含Token和管理员信息
|
||||
*
|
||||
* @throws UnauthorizedException 当登录失败时
|
||||
* @throws ForbiddenException 当权限不足或账户被禁用时
|
||||
* @throws TooManyRequestsException 当登录尝试过于频繁时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminController.login({
|
||||
* identifier: 'admin',
|
||||
* password: 'YourStrongPassword123!'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({ summary: '管理员登录', description: '仅允许 role=9 的账户登录后台' })
|
||||
@ApiBody({ type: AdminLoginDto })
|
||||
@ApiResponse({ status: 200, description: '登录成功', type: AdminLoginResponseDto })
|
||||
@ApiResponse({ status: 401, description: '登录失败' })
|
||||
@ApiResponse({ status: 403, description: '权限不足或账户被禁用' })
|
||||
@ApiResponse({ status: 429, description: '登录尝试过于频繁' })
|
||||
@Throttle(ThrottlePresets.LOGIN)
|
||||
@Post('auth/login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async login(@Body() dto: AdminLoginDto) {
|
||||
return await this.adminService.login(dto.identifier, dto.password);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
*
|
||||
* 功能描述:
|
||||
* 分页获取系统中的用户列表,支持限制数量和偏移量参数
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 解析查询参数(limit和offset)
|
||||
* 2. 调用用户服务获取用户列表
|
||||
* 3. 格式化用户数据
|
||||
* 4. 返回分页结果
|
||||
*
|
||||
* @param limit 返回数量,默认100,可选参数
|
||||
* @param offset 偏移量,默认0,可选参数
|
||||
* @returns 用户列表和分页信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 获取前20个用户
|
||||
* const result = await adminController.listUsers('20', '0');
|
||||
* ```
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取用户列表', description: '后台用户管理:分页获取用户列表' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认100)' })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUsersResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('users')
|
||||
async listUsers(
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const parsedLimit = limit ? Number(limit) : 100;
|
||||
const parsedOffset = offset ? Number(offset) : 0;
|
||||
return await this.adminService.listUsers(parsedLimit, parsedOffset);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据用户ID获取指定用户的详细信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户ID格式
|
||||
* 2. 查询用户详细信息
|
||||
* 3. 格式化用户数据
|
||||
* 4. 返回用户详情
|
||||
*
|
||||
* @param id 用户ID字符串
|
||||
* @returns 用户详细信息
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminController.getUser('123');
|
||||
* ```
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户ID' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminUserResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('users/:id')
|
||||
async getUser(@Param('id') id: string) {
|
||||
return await this.adminService.getUser(BigInt(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员直接为指定用户设置新密码,新密码需满足密码强度规则
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户ID和新密码格式
|
||||
* 2. 检查用户是否存在
|
||||
* 3. 验证密码强度规则
|
||||
* 4. 更新用户密码
|
||||
* 5. 记录操作日志
|
||||
*
|
||||
* @param id 用户ID字符串
|
||||
* @param dto 密码重置请求数据
|
||||
* @returns 重置结果
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
* @throws BadRequestException 当密码不符合强度规则时
|
||||
* @throws TooManyRequestsException 当操作过于频繁时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminController.resetPassword('123', {
|
||||
* newPassword: 'NewPass1234'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '重置用户密码', description: '管理员直接为用户设置新密码(需满足密码强度规则)' })
|
||||
@ApiParam({ name: 'id', description: '用户ID' })
|
||||
@ApiBody({ type: AdminResetPasswordDto })
|
||||
@ApiResponse({ status: 200, description: '重置成功', type: AdminCommonResponseDto })
|
||||
@ApiResponse({ status: 429, description: '操作过于频繁' })
|
||||
@UseGuards(AdminGuard)
|
||||
@Throttle(ThrottlePresets.ADMIN_OPERATION)
|
||||
@Post('users/:id/reset-password')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async resetPassword(@Param('id') id: string, @Body() dto: AdminResetPasswordDto) {
|
||||
return await this.adminService.resetPassword(BigInt(id), dto.newPassword);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '获取运行日志尾部', description: '从 logs/ 目录读取最近的日志行(默认200行)' })
|
||||
@ApiQuery({ name: 'lines', required: false, description: '返回行数(默认200,最大2000)' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: AdminRuntimeLogsResponseDto })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/runtime')
|
||||
async getRuntimeLogs(@Query('lines') lines?: string) {
|
||||
const parsedLines = lines ? Number(lines) : undefined;
|
||||
return await this.adminService.getRuntimeLogs(parsedLines);
|
||||
}
|
||||
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: '下载全部运行日志', description: '将 logs/ 目录打包为 tar.gz 并下载(需要管理员Token)' })
|
||||
@ApiProduces('application/gzip')
|
||||
@ApiResponse({ status: 200, description: '打包下载成功(tar.gz 二进制流)' })
|
||||
@UseGuards(AdminGuard)
|
||||
@Get('logs/archive')
|
||||
async downloadLogsArchive(@Res() res: Response) {
|
||||
const logDir = this.adminService.getLogDirAbsolutePath();
|
||||
|
||||
// 验证日志目录
|
||||
const dirValidation = await this.validateLogDirectory(logDir, res);
|
||||
if (!dirValidation.isValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置响应头
|
||||
this.setArchiveResponseHeaders(res);
|
||||
|
||||
// 创建并处理tar进程
|
||||
await this.createAndHandleTarProcess(logDir, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证日志目录是否存在且可用
|
||||
*
|
||||
* @param logDir 日志目录路径
|
||||
* @param res 响应对象
|
||||
* @returns 验证结果
|
||||
*/
|
||||
private async validateLogDirectory(logDir: string, res: Response): Promise<{ isValid: boolean }> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(logDir);
|
||||
if (!stats.isDirectory()) {
|
||||
res.status(404).json({ success: false, message: '日志目录不可用' });
|
||||
return { isValid: false };
|
||||
}
|
||||
return { isValid: true };
|
||||
} catch (error) {
|
||||
res.status(404).json({ success: false, message: '日志目录不存在' });
|
||||
return { isValid: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置文件下载的响应头
|
||||
*
|
||||
* @param res 响应对象
|
||||
*/
|
||||
private setArchiveResponseHeaders(res: Response): void {
|
||||
const ts = getCurrentTimestamp().replace(/[:.]/g, '-');
|
||||
const filename = `logs-${ts}.tar.gz`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建并处理tar进程
|
||||
*
|
||||
* @param logDir 日志目录路径
|
||||
* @param res 响应对象
|
||||
*/
|
||||
private async createAndHandleTarProcess(logDir: string, res: Response): Promise<void> {
|
||||
const parentDir = path.dirname(logDir);
|
||||
const baseName = path.basename(logDir);
|
||||
|
||||
const tar = spawn('tar', ['-czf', '-', '-C', parentDir, baseName], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// 处理tar进程的stderr输出
|
||||
tar.stderr.on('data', (chunk: Buffer) => {
|
||||
const msg = chunk.toString('utf8').trim();
|
||||
if (msg) {
|
||||
this.logger.warn(`tar stderr: ${msg}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 处理tar进程错误
|
||||
tar.on('error', (err: any) => {
|
||||
this.handleTarProcessError(err, res);
|
||||
});
|
||||
|
||||
// 处理数据流和进程退出
|
||||
await this.handleTarStreams(tar, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理tar进程错误
|
||||
*
|
||||
* @param err 错误对象
|
||||
* @param res 响应对象
|
||||
*/
|
||||
private handleTarProcessError(err: any, res: Response): void {
|
||||
this.logger.error('打包日志失败(tar 进程启动失败)', err?.stack || String(err));
|
||||
if (!res.headersSent) {
|
||||
const msg = err?.code === 'ENOENT' ? '服务器缺少 tar 命令,无法打包日志' : '日志打包失败';
|
||||
res.status(500).json({ success: false, message: msg });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理tar进程的数据流和退出
|
||||
*
|
||||
* @param tar tar进程
|
||||
* @param res 响应对象
|
||||
*/
|
||||
private async handleTarStreams(tar: any, res: Response): Promise<void> {
|
||||
const pipelinePromise = new Promise<void>((resolve, reject) => {
|
||||
pipeline(tar.stdout, res, (err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
|
||||
const exitPromise = new Promise<void>((resolve, reject) => {
|
||||
tar.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`tar exited with code ${code ?? 'unknown'}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await pipelinePromise;
|
||||
await exitPromise;
|
||||
} catch (err) {
|
||||
this.logger.error('打包日志失败(tar 执行或输出失败)', err instanceof Error ? err.stack : String(err));
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ success: false, message: '日志打包失败' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
97
src/business/admin/admin.guard.ts
Normal file
97
src/business/admin/admin.guard.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 管理员鉴权守卫
|
||||
*
|
||||
* 功能描述:
|
||||
* - 保护后台管理接口的访问权限
|
||||
* - 验证Authorization Bearer Token
|
||||
* - 确保只有role=9的管理员可以访问
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP请求权限验证
|
||||
* - Token解析和验证
|
||||
* - 管理员身份确认
|
||||
*
|
||||
* 主要方法:
|
||||
* - canActivate() - 权限验证核心逻辑
|
||||
*
|
||||
* 使用场景:
|
||||
* - 后台管理API的权限保护
|
||||
* - 管理员身份验证
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 为接口添加注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
* - 2026-01-08: 注释规范优化 - 补充方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { AdminCoreService, AdminAuthPayload } from '../../core/admin_core/admin_core.service';
|
||||
|
||||
/**
|
||||
* 管理员请求接口
|
||||
*
|
||||
* 功能描述:
|
||||
* 扩展Express Request接口,添加管理员认证信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - AdminGuard验证通过后,将管理员信息附加到请求对象
|
||||
* - 控制器方法中获取当前管理员信息
|
||||
*/
|
||||
export interface AdminRequest extends Request {
|
||||
admin?: AdminAuthPayload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminGuard implements CanActivate {
|
||||
constructor(private readonly adminCoreService: AdminCoreService) {}
|
||||
|
||||
/**
|
||||
* 权限验证核心逻辑
|
||||
*
|
||||
* 功能描述:
|
||||
* 验证HTTP请求的Authorization头,确保只有管理员可以访问
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 提取Authorization头
|
||||
* 2. 验证Bearer Token格式
|
||||
* 3. 调用核心服务验证Token
|
||||
* 4. 将管理员信息附加到请求对象
|
||||
*
|
||||
* @param context 执行上下文,包含HTTP请求信息
|
||||
* @returns 是否允许访问,true表示允许
|
||||
*
|
||||
* @throws UnauthorizedException 当缺少Authorization头或格式错误时
|
||||
* @throws UnauthorizedException 当Token无效或过期时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 在控制器方法上使用
|
||||
* @UseGuards(AdminGuard)
|
||||
* @Get('users')
|
||||
* async getUsers() { ... }
|
||||
* ```
|
||||
*/
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const req = context.switchToHttp().getRequest<AdminRequest>();
|
||||
const auth = req.headers['authorization'];
|
||||
|
||||
if (!auth || Array.isArray(auth)) {
|
||||
throw new UnauthorizedException('缺少Authorization头');
|
||||
}
|
||||
|
||||
const [scheme, token] = auth.split(' ');
|
||||
if (scheme !== 'Bearer' || !token) {
|
||||
throw new UnauthorizedException('Authorization格式错误');
|
||||
}
|
||||
|
||||
const payload = this.adminCoreService.verifyToken(token);
|
||||
req.admin = payload;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
86
src/business/admin/admin.module.ts
Normal file
86
src/business/admin/admin.module.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 管理员业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供后台管理的HTTP API(管理员登录、用户管理、密码重置等)
|
||||
* - 集成管理员核心服务和日志管理服务
|
||||
* - 导出管理员服务供其他模块使用
|
||||
*
|
||||
* 职责分离:
|
||||
* - 模块依赖管理和服务注册
|
||||
* - HTTP层与业务流程编排
|
||||
* - 核心鉴权与密码策略由AdminCoreService提供
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正import路径,创建缺失的控制器和服务文件 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminCoreModule } from '../../core/admin_core/admin_core.module';
|
||||
import { LoggerModule } from '../../core/utils/logger/logger.module';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.module';
|
||||
import { SessionCoreModule } from '../../core/session_core/session_core.module';
|
||||
import { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
import { AdminDatabaseController } from './admin_database.controller';
|
||||
import { AdminOperationLogController } from './admin_operation_log.controller';
|
||||
import { DatabaseManagementService } from './database_management.service';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { AdminOperationLogMemoryService } from './admin_operation_log_memory.service';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
|
||||
/**
|
||||
* 检查数据库配置是否完整
|
||||
*
|
||||
* @returns 是否配置了数据库
|
||||
*/
|
||||
function isDatabaseConfigured(): boolean {
|
||||
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||
return requiredEnvVars.every(varName => process.env[varName]);
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AdminCoreModule,
|
||||
LoggerModule,
|
||||
UsersModule,
|
||||
SessionCoreModule,
|
||||
UserProfilesModule,
|
||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||
// 注册AdminOperationLog实体
|
||||
...(isDatabaseConfigured() ? [TypeOrmModule.forFeature([AdminOperationLog])] : [])
|
||||
],
|
||||
controllers: [
|
||||
AdminController,
|
||||
AdminDatabaseController,
|
||||
AdminOperationLogController
|
||||
],
|
||||
providers: [
|
||||
AdminService,
|
||||
DatabaseManagementService,
|
||||
{
|
||||
provide: AdminOperationLogService,
|
||||
useClass: isDatabaseConfigured()
|
||||
? AdminOperationLogService
|
||||
: AdminOperationLogMemoryService,
|
||||
},
|
||||
AdminDatabaseExceptionFilter,
|
||||
AdminOperationLogInterceptor
|
||||
],
|
||||
exports: [
|
||||
AdminService,
|
||||
DatabaseManagementService,
|
||||
AdminOperationLogService
|
||||
], // 导出服务供其他模块使用
|
||||
})
|
||||
export class AdminModule {}
|
||||
592
src/business/admin/admin.service.ts
Normal file
592
src/business/admin/admin.service.ts
Normal file
@@ -0,0 +1,592 @@
|
||||
/**
|
||||
* 管理员业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理员登录认证业务逻辑
|
||||
* - 用户管理业务功能(查询、密码重置、状态管理)
|
||||
* - 系统日志管理功能
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务逻辑编排和数据格式化
|
||||
* - 调用核心服务完成具体操作
|
||||
* - 异常处理和日志记录
|
||||
*
|
||||
* 主要方法:
|
||||
* - login() - 管理员登录认证
|
||||
* - listUsers() - 用户列表查询
|
||||
* - getUser() - 单个用户查询
|
||||
* - resetPassword() - 重置用户密码
|
||||
* - updateUserStatus() - 修改用户状态
|
||||
* - batchUpdateUserStatus() - 批量修改用户状态
|
||||
* - getUserStatusStats() - 获取用户状态统计
|
||||
* - getRuntimeLogs() - 获取运行日志
|
||||
*
|
||||
* 使用场景:
|
||||
* - 后台管理系统的业务逻辑处理
|
||||
* - 管理员权限相关的业务操作
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
* - 2026-01-08: 注释规范优化 - 补充方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Inject, Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { AdminCoreService } from '../../core/admin_core/admin_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UsersMemoryService } from '../../core/db/users/users_memory.service';
|
||||
import { LogManagementService } from '../../core/utils/logger/log_management.service';
|
||||
import { UserStatus, getUserStatusDescription } from '../user_mgmt/user_status.enum';
|
||||
import { UserStatusDto, BatchUserStatusDto } from '../user_mgmt/user_status.dto';
|
||||
import { getCurrentTimestamp } from './admin_utils';
|
||||
import { USER_QUERY_LIMITS } from './admin_constants';
|
||||
import {
|
||||
UserStatusResponseDto,
|
||||
BatchUserStatusResponseDto,
|
||||
UserStatusStatsResponseDto,
|
||||
UserStatusInfoDto,
|
||||
BatchOperationResultDto
|
||||
} from '../user_mgmt/user_status_response.dto';
|
||||
|
||||
export interface AdminApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(
|
||||
private readonly adminCoreService: AdminCoreService,
|
||||
@Inject('UsersService') private readonly usersService: UsersService | UsersMemoryService,
|
||||
private readonly logManagementService: LogManagementService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 记录操作日志
|
||||
*
|
||||
* @param level 日志级别
|
||||
* @param message 日志消息
|
||||
* @param context 日志上下文
|
||||
*/
|
||||
private logOperation(level: 'log' | 'warn' | 'error', message: string, context: Record<string, any>): void {
|
||||
this.logger[level](message, {
|
||||
...context,
|
||||
timestamp: getCurrentTimestamp()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日志目录绝对路径
|
||||
*
|
||||
* @returns 日志目录的绝对路径
|
||||
*/
|
||||
getLogDirAbsolutePath(): string {
|
||||
return this.logManagementService.getLogDirAbsolutePath();
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*
|
||||
* 功能描述:
|
||||
* 验证管理员身份并生成JWT Token
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用核心服务验证登录信息
|
||||
* 2. 生成JWT Token
|
||||
* 3. 返回登录结果
|
||||
*
|
||||
* @param identifier 登录标识符(用户名/邮箱/手机号)
|
||||
* @param password 密码
|
||||
* @returns 登录结果,包含Token和管理员信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminService.login('admin', 'password123');
|
||||
* ```
|
||||
*/
|
||||
async login(identifier: string, password: string): Promise<AdminApiResponse> {
|
||||
try {
|
||||
const result = await this.adminCoreService.login({ identifier, password });
|
||||
return { success: true, data: result, message: '管理员登录成功' };
|
||||
} catch (error) {
|
||||
this.logger.error(`管理员登录失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '管理员登录失败',
|
||||
error_code: 'ADMIN_LOGIN_FAILED',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
*
|
||||
* 功能描述:
|
||||
* 分页获取系统中的用户列表
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用用户服务获取用户数据
|
||||
* 2. 格式化用户信息
|
||||
* 3. 返回分页结果
|
||||
*
|
||||
* @param limit 返回数量限制
|
||||
* @param offset 偏移量
|
||||
* @returns 用户列表和分页信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminService.listUsers(20, 0);
|
||||
* ```
|
||||
*/
|
||||
async listUsers(limit: number, offset: number): Promise<AdminApiResponse<{ users: any[]; limit: number; offset: number }>> {
|
||||
const users = await this.usersService.findAll(limit, offset);
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: users.map((u: Users) => this.formatUser(u)),
|
||||
limit,
|
||||
offset,
|
||||
},
|
||||
message: '用户列表获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户详情
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据用户ID获取指定用户的详细信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 查询用户信息
|
||||
* 2. 格式化用户数据
|
||||
* 3. 返回用户详情
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @returns 用户详细信息
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminService.getUser(BigInt(123));
|
||||
* ```
|
||||
*/
|
||||
async getUser(id: bigint): Promise<AdminApiResponse<{ user: any }>> {
|
||||
const user = await this.usersService.findOne(id);
|
||||
return {
|
||||
success: true,
|
||||
data: { user: this.formatUser(user) },
|
||||
message: '用户信息获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户密码
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员直接为指定用户设置新密码
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 调用核心服务重置密码
|
||||
* 3. 记录操作日志
|
||||
* 4. 返回重置结果
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param newPassword 新密码
|
||||
* @returns 重置结果
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminService.resetPassword(BigInt(123), 'NewPass1234');
|
||||
* ```
|
||||
*/
|
||||
async resetPassword(id: bigint, newPassword: string): Promise<AdminApiResponse> {
|
||||
// 确认用户存在
|
||||
const user = await this.usersService.findOne(id).catch((): null => null);
|
||||
if (!user) {
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
await this.adminCoreService.resetUserPassword(id, newPassword);
|
||||
|
||||
this.logger.log(`管理员重置密码成功: userId=${id.toString()}`);
|
||||
|
||||
return { success: true, message: '密码重置成功' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取运行日志
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取系统运行日志的尾部内容
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用日志管理服务获取日志
|
||||
* 2. 返回日志内容和元信息
|
||||
*
|
||||
* @param lines 返回的日志行数,可选参数
|
||||
* @returns 日志内容和元信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await adminService.getRuntimeLogs(200);
|
||||
* ```
|
||||
*/
|
||||
async getRuntimeLogs(lines?: number): Promise<AdminApiResponse<{ file: string; updated_at: string; lines: string[] }>> {
|
||||
const result = await this.logManagementService.getRuntimeLogTail({ lines });
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
message: '运行日志获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
private formatUser(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
email_verified: user.email_verified,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
status: user.status || UserStatus.ACTIVE, // 兼容旧数据
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户状态信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户状态信息
|
||||
*/
|
||||
private formatUserStatus(user: Users): UserStatusInfoDto {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
status: user.status || UserStatus.ACTIVE,
|
||||
status_description: getUserStatusDescription(user.status || UserStatus.ACTIVE),
|
||||
updated_at: user.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改用户状态
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员修改指定用户的账户状态,支持激活、锁定、禁用等操作
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否存在
|
||||
* 2. 检查状态变更的合法性
|
||||
* 3. 更新用户状态
|
||||
* 4. 记录状态变更日志
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param userStatusDto 状态修改数据
|
||||
* @returns 修改结果
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
* @throws BadRequestException 当状态变更不合法时
|
||||
*/
|
||||
async updateUserStatus(userId: bigint, userStatusDto: UserStatusDto): Promise<UserStatusResponseDto> {
|
||||
try {
|
||||
this.logOperation('log', '开始修改用户状态', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason
|
||||
});
|
||||
|
||||
// 1. 验证用户是否存在
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
this.logOperation('warn', '修改用户状态失败:用户不存在', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString()
|
||||
});
|
||||
throw new NotFoundException('用户不存在');
|
||||
}
|
||||
|
||||
// 2. 检查状态变更的合法性
|
||||
if (user.status === userStatusDto.status) {
|
||||
this.logOperation('warn', '修改用户状态失败:状态未发生变化', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
currentStatus: user.status,
|
||||
newStatus: userStatusDto.status
|
||||
});
|
||||
throw new BadRequestException('用户状态未发生变化');
|
||||
}
|
||||
|
||||
// 3. 更新用户状态
|
||||
const updatedUser = await this.usersService.update(userId, {
|
||||
status: userStatusDto.status
|
||||
});
|
||||
|
||||
// 4. 记录状态变更日志
|
||||
this.logOperation('log', '用户状态修改成功', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
oldStatus: user.status,
|
||||
newStatus: userStatusDto.status,
|
||||
reason: userStatusDto.reason
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
user: this.formatUserStatus(updatedUser),
|
||||
reason: userStatusDto.reason
|
||||
},
|
||||
message: '用户状态修改成功'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logOperation('error', '修改用户状态失败', {
|
||||
operation: 'update_user_status',
|
||||
userId: userId.toString(),
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException || error instanceof BadRequestException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '用户状态修改失败',
|
||||
error_code: 'USER_STATUS_UPDATE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个用户状态修改
|
||||
*
|
||||
* @param userIdStr 用户ID字符串
|
||||
* @param newStatus 新状态
|
||||
* @returns 处理结果
|
||||
*/
|
||||
private async processSingleUserStatus(
|
||||
userIdStr: string,
|
||||
newStatus: UserStatus
|
||||
): Promise<{ success: true; user: UserStatusInfoDto } | { success: false; error: string }> {
|
||||
try {
|
||||
const userId = BigInt(userIdStr);
|
||||
|
||||
// 验证用户是否存在
|
||||
const user = await this.usersService.findOne(userId);
|
||||
if (!user) {
|
||||
return { success: false, error: '用户不存在' };
|
||||
}
|
||||
|
||||
// 检查状态是否需要变更
|
||||
if (user.status === newStatus) {
|
||||
return { success: false, error: '用户状态未发生变化' };
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
const updatedUser = await this.usersService.update(userId, { status: newStatus });
|
||||
return { success: true, user: this.formatUserStatus(updatedUser) };
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '未知错误'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量修改用户状态
|
||||
*
|
||||
* 功能描述:
|
||||
* 管理员批量修改多个用户的账户状态
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户ID列表
|
||||
* 2. 逐个处理用户状态修改
|
||||
* 3. 收集成功和失败的结果
|
||||
* 4. 返回批量操作结果
|
||||
*
|
||||
* @param batchUserStatusDto 批量状态修改数据
|
||||
* @returns 批量修改结果
|
||||
*/
|
||||
async batchUpdateUserStatus(batchUserStatusDto: BatchUserStatusDto): Promise<BatchUserStatusResponseDto> {
|
||||
try {
|
||||
this.logOperation('log', '开始批量修改用户状态', {
|
||||
operation: 'batch_update_user_status',
|
||||
userCount: batchUserStatusDto.userIds.length,
|
||||
newStatus: batchUserStatusDto.status,
|
||||
reason: batchUserStatusDto.reason
|
||||
});
|
||||
|
||||
const successUsers: UserStatusInfoDto[] = [];
|
||||
const failedUsers: Array<{ user_id: string; error: string }> = [];
|
||||
|
||||
// 逐个处理用户状态修改
|
||||
for (const userIdStr of batchUserStatusDto.userIds) {
|
||||
const result = await this.processSingleUserStatus(userIdStr, batchUserStatusDto.status);
|
||||
|
||||
if (result.success) {
|
||||
successUsers.push(result.user);
|
||||
} else {
|
||||
failedUsers.push({ user_id: userIdStr, error: (result as { success: false; error: string }).error });
|
||||
}
|
||||
}
|
||||
|
||||
// 构建批量操作结果
|
||||
const operationResult: BatchOperationResultDto = {
|
||||
success_users: successUsers,
|
||||
failed_users: failedUsers,
|
||||
success_count: successUsers.length,
|
||||
failed_count: failedUsers.length,
|
||||
total_count: batchUserStatusDto.userIds.length
|
||||
};
|
||||
|
||||
this.logOperation('log', '批量修改用户状态完成', {
|
||||
operation: 'batch_update_user_status',
|
||||
successCount: operationResult.success_count,
|
||||
failedCount: operationResult.failed_count,
|
||||
totalCount: operationResult.total_count
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
result: operationResult,
|
||||
reason: batchUserStatusDto.reason
|
||||
},
|
||||
message: `批量用户状态修改完成,成功:${operationResult.success_count},失败:${operationResult.failed_count}`
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logOperation('error', '批量修改用户状态失败', {
|
||||
operation: 'batch_update_user_status',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '批量用户状态修改失败',
|
||||
error_code: 'BATCH_USER_STATUS_UPDATE_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算用户状态统计
|
||||
*
|
||||
* @param users 用户列表
|
||||
* @returns 状态统计结果
|
||||
*/
|
||||
private calculateUserStatusStats(users: Users[]) {
|
||||
const stats = {
|
||||
active: 0,
|
||||
inactive: 0,
|
||||
locked: 0,
|
||||
banned: 0,
|
||||
deleted: 0,
|
||||
pending: 0,
|
||||
total: users.length
|
||||
};
|
||||
|
||||
users.forEach((user: Users) => {
|
||||
const status = user.status || UserStatus.ACTIVE;
|
||||
switch (status) {
|
||||
case UserStatus.ACTIVE:
|
||||
stats.active++;
|
||||
break;
|
||||
case UserStatus.INACTIVE:
|
||||
stats.inactive++;
|
||||
break;
|
||||
case UserStatus.LOCKED:
|
||||
stats.locked++;
|
||||
break;
|
||||
case UserStatus.BANNED:
|
||||
stats.banned++;
|
||||
break;
|
||||
case UserStatus.DELETED:
|
||||
stats.deleted++;
|
||||
break;
|
||||
case UserStatus.PENDING:
|
||||
stats.pending++;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户状态统计
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取各种用户状态的数量统计信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 查询所有用户
|
||||
* 2. 按状态分组统计
|
||||
* 3. 计算各状态数量
|
||||
* 4. 返回统计结果
|
||||
*
|
||||
* @returns 状态统计信息
|
||||
*/
|
||||
async getUserStatusStats(): Promise<UserStatusStatsResponseDto> {
|
||||
try {
|
||||
this.logOperation('log', '开始获取用户状态统计', {
|
||||
operation: 'get_user_status_stats'
|
||||
});
|
||||
|
||||
// 查询所有用户(这里可以优化为直接查询统计信息)
|
||||
const allUsers = await this.usersService.findAll(USER_QUERY_LIMITS.MAX_USERS_FOR_STATS, 0);
|
||||
|
||||
// 计算各状态数量
|
||||
const stats = this.calculateUserStatusStats(allUsers);
|
||||
|
||||
this.logOperation('log', '用户状态统计获取成功', {
|
||||
operation: 'get_user_status_stats',
|
||||
stats
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
stats,
|
||||
timestamp: getCurrentTimestamp()
|
||||
},
|
||||
message: '用户状态统计获取成功'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logOperation('error', '获取用户状态统计失败', {
|
||||
operation: 'get_user_status_stats',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: '用户状态统计获取失败',
|
||||
error_code: 'USER_STATUS_STATS_FAILED'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
185
src/business/admin/admin_constants.ts
Normal file
185
src/business/admin/admin_constants.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 管理员模块常量定义
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员模块使用的所有常量
|
||||
* - 统一管理配置参数和限制值
|
||||
* - 避免魔法数字的使用
|
||||
* - 提供类型安全的常量访问
|
||||
*
|
||||
* 职责分离:
|
||||
* - 常量集中管理
|
||||
* - 配置参数定义
|
||||
* - 限制值设定
|
||||
* - 敏感字段标识
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码质量优化 - 添加日志查询限制和请求ID配置常量,补充用户查询限制常量 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员模块常量定义文件 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
/**
|
||||
* 分页限制常量
|
||||
*/
|
||||
export const PAGINATION_LIMITS = {
|
||||
/** 默认每页数量 */
|
||||
DEFAULT_LIMIT: 20,
|
||||
/** 默认偏移量 */
|
||||
DEFAULT_OFFSET: 0,
|
||||
/** 用户列表最大每页数量 */
|
||||
USER_LIST_MAX_LIMIT: 100,
|
||||
/** 搜索结果最大每页数量 */
|
||||
SEARCH_MAX_LIMIT: 50,
|
||||
/** 日志列表最大每页数量 */
|
||||
LOG_LIST_MAX_LIMIT: 200,
|
||||
/** 批量操作最大数量 */
|
||||
BATCH_OPERATION_MAX_SIZE: 100
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 请求ID前缀常量
|
||||
*/
|
||||
export const REQUEST_ID_PREFIXES = {
|
||||
/** 通用请求 */
|
||||
GENERAL: 'req',
|
||||
/** 错误请求 */
|
||||
ERROR: 'err',
|
||||
/** 管理员操作 */
|
||||
ADMIN_OPERATION: 'admin',
|
||||
/** 数据库操作 */
|
||||
DATABASE_OPERATION: 'db',
|
||||
/** 健康检查 */
|
||||
HEALTH_CHECK: 'health',
|
||||
/** 日志操作 */
|
||||
LOG_OPERATION: 'log'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 敏感字段列表
|
||||
*/
|
||||
export const SENSITIVE_FIELDS = [
|
||||
'password',
|
||||
'password_hash',
|
||||
'newPassword',
|
||||
'oldPassword',
|
||||
'token',
|
||||
'api_key',
|
||||
'secret',
|
||||
'private_key',
|
||||
'zulipApiKeyEncrypted'
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 日志保留策略常量
|
||||
*/
|
||||
export const LOG_RETENTION = {
|
||||
/** 默认保留天数 */
|
||||
DEFAULT_DAYS: 90,
|
||||
/** 最少保留天数 */
|
||||
MIN_DAYS: 7,
|
||||
/** 最多保留天数 */
|
||||
MAX_DAYS: 365,
|
||||
/** 敏感操作日志保留天数 */
|
||||
SENSITIVE_OPERATION_DAYS: 180
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 操作类型常量
|
||||
*/
|
||||
export const OPERATION_TYPES = {
|
||||
CREATE: 'CREATE',
|
||||
UPDATE: 'UPDATE',
|
||||
DELETE: 'DELETE',
|
||||
QUERY: 'QUERY',
|
||||
BATCH: 'BATCH'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 目标类型常量
|
||||
*/
|
||||
export const TARGET_TYPES = {
|
||||
USERS: 'users',
|
||||
USER_PROFILES: 'user_profiles',
|
||||
ZULIP_ACCOUNTS: 'zulip_accounts',
|
||||
ADMIN_LOGS: 'admin_logs'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 操作结果常量
|
||||
*/
|
||||
export const OPERATION_RESULTS = {
|
||||
SUCCESS: 'SUCCESS',
|
||||
FAILED: 'FAILED'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 错误码常量
|
||||
*/
|
||||
export const ERROR_CODES = {
|
||||
BAD_REQUEST: 'BAD_REQUEST',
|
||||
UNAUTHORIZED: 'UNAUTHORIZED',
|
||||
FORBIDDEN: 'FORBIDDEN',
|
||||
NOT_FOUND: 'NOT_FOUND',
|
||||
CONFLICT: 'CONFLICT',
|
||||
UNPROCESSABLE_ENTITY: 'UNPROCESSABLE_ENTITY',
|
||||
TOO_MANY_REQUESTS: 'TOO_MANY_REQUESTS',
|
||||
INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR',
|
||||
BAD_GATEWAY: 'BAD_GATEWAY',
|
||||
SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',
|
||||
GATEWAY_TIMEOUT: 'GATEWAY_TIMEOUT',
|
||||
UNKNOWN_ERROR: 'UNKNOWN_ERROR'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* HTTP状态码常量
|
||||
*/
|
||||
export const HTTP_STATUS = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
GATEWAY_TIMEOUT: 504
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 缓存键前缀常量
|
||||
*/
|
||||
export const CACHE_KEYS = {
|
||||
USER_LIST: 'admin:users:list',
|
||||
USER_PROFILE_LIST: 'admin:profiles:list',
|
||||
ZULIP_ACCOUNT_LIST: 'admin:zulip:list',
|
||||
STATISTICS: 'admin:stats'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 日志查询限制常量
|
||||
*/
|
||||
export const LOG_QUERY_LIMITS = {
|
||||
/** 默认日志查询每页数量 */
|
||||
DEFAULT_LOG_QUERY_LIMIT: 50,
|
||||
/** 敏感操作日志默认查询数量 */
|
||||
SENSITIVE_LOG_DEFAULT_LIMIT: 50
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 用户查询限制常量
|
||||
*/
|
||||
export const USER_QUERY_LIMITS = {
|
||||
/** 用户状态统计查询的最大用户数 */
|
||||
MAX_USERS_FOR_STATS: 10000,
|
||||
/** 管理员操作历史默认查询数量 */
|
||||
ADMIN_HISTORY_DEFAULT_LIMIT: 20
|
||||
} as const;
|
||||
404
src/business/admin/admin_database.controller.ts
Normal file
404
src/business/admin/admin_database.controller.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* 管理员数据库管理控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供管理员专用的数据库管理HTTP接口
|
||||
* - 集成用户、用户档案、Zulip账号关联的CRUD操作
|
||||
* - 实现统一的权限控制和参数验证
|
||||
* - 支持分页查询和搜索功能
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP请求处理:接收和验证HTTP请求参数
|
||||
* - 权限控制:通过AdminGuard确保只有管理员可以访问
|
||||
* - 业务委托:将业务逻辑委托给DatabaseManagementService处理
|
||||
* - 响应格式化:返回统一格式的HTTP响应
|
||||
*
|
||||
* API端点分组:
|
||||
* - /admin/database/users/* 用户管理相关接口
|
||||
* - /admin/database/user-profiles/* 用户档案管理相关接口
|
||||
* - /admin/database/zulip-accounts/* Zulip账号关联管理相关接口
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 代码质量优化 - 清理未使用的导入 (修改者: moyin)
|
||||
* - 2026-01-08: 文件夹扁平化 - 从controllers/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员数据库管理控制器 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
UseFilters,
|
||||
UseInterceptors,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse,
|
||||
ApiBody
|
||||
} from '@nestjs/swagger';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { LogAdminOperation } from './log_admin_operation.decorator';
|
||||
import { DatabaseManagementService, AdminApiResponse, AdminListResponse } from './database_management.service';
|
||||
import {
|
||||
AdminCreateUserDto,
|
||||
AdminUpdateUserDto,
|
||||
AdminBatchUpdateStatusDto,
|
||||
AdminDatabaseResponseDto,
|
||||
AdminHealthCheckResponseDto,
|
||||
AdminCreateUserProfileDto,
|
||||
AdminUpdateUserProfileDto,
|
||||
AdminCreateZulipAccountDto,
|
||||
AdminUpdateZulipAccountDto
|
||||
} from './admin_database.dto';
|
||||
import { PAGINATION_LIMITS, REQUEST_ID_PREFIXES } from './admin_constants';
|
||||
import { safeLimitValue, createSuccessResponse, getCurrentTimestamp } from './admin_utils';
|
||||
|
||||
@ApiTags('admin-database')
|
||||
@Controller('admin/database')
|
||||
@UseGuards(AdminGuard)
|
||||
@UseFilters(AdminDatabaseExceptionFilter)
|
||||
@UseInterceptors(AdminOperationLogInterceptor)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class AdminDatabaseController {
|
||||
constructor(
|
||||
private readonly databaseManagementService: DatabaseManagementService
|
||||
) {}
|
||||
|
||||
// ==================== 用户管理接口 ====================
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取用户列表',
|
||||
description: '分页获取用户列表,支持管理员查看所有用户信息'
|
||||
})
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认20,最大100)', example: 20 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'QUERY',
|
||||
targetType: 'users',
|
||||
description: '获取用户列表',
|
||||
isSensitive: false
|
||||
})
|
||||
@Get('users')
|
||||
async getUserList(
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number
|
||||
): Promise<AdminListResponse> {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.USER_LIST_MAX_LIMIT);
|
||||
return await this.databaseManagementService.getUserList(safeLimit, offset);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取用户详情',
|
||||
description: '根据用户ID获取详细的用户信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@Get('users/:id')
|
||||
async getUserById(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.getUserById(BigInt(id));
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '搜索用户',
|
||||
description: '根据关键词搜索用户,支持用户名、邮箱、昵称模糊匹配'
|
||||
})
|
||||
@ApiQuery({ name: 'keyword', description: '搜索关键词', example: 'admin' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认20,最大50)', example: 20 })
|
||||
@ApiResponse({ status: 200, description: '搜索成功' })
|
||||
@Get('users/search')
|
||||
async searchUsers(
|
||||
@Query('keyword') keyword: string,
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number
|
||||
): Promise<AdminListResponse> {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.SEARCH_MAX_LIMIT);
|
||||
return await this.databaseManagementService.searchUsers(keyword, safeLimit);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '创建用户',
|
||||
description: '创建新用户,需要提供用户名和昵称等基本信息'
|
||||
})
|
||||
@ApiBody({ type: AdminCreateUserDto, description: '用户创建数据' })
|
||||
@ApiResponse({ status: 201, description: '创建成功', type: AdminDatabaseResponseDto })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '用户名或邮箱已存在' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'CREATE',
|
||||
targetType: 'users',
|
||||
description: '创建用户',
|
||||
isSensitive: true
|
||||
})
|
||||
@Post('users')
|
||||
async createUser(@Body() createUserDto: AdminCreateUserDto): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.createUser(createUserDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '更新用户',
|
||||
description: '根据用户ID更新用户信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', example: '1' })
|
||||
@ApiBody({ type: AdminUpdateUserDto, description: '用户更新数据' })
|
||||
@ApiResponse({ status: 200, description: '更新成功', type: AdminDatabaseResponseDto })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@Put('users/:id')
|
||||
async updateUser(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: AdminUpdateUserDto
|
||||
): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.updateUser(BigInt(id), updateUserDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '删除用户',
|
||||
description: '根据用户ID删除用户(软删除)'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '用户ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '用户不存在' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'DELETE',
|
||||
targetType: 'users',
|
||||
description: '删除用户',
|
||||
isSensitive: true
|
||||
})
|
||||
@Delete('users/:id')
|
||||
async deleteUser(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.deleteUser(BigInt(id));
|
||||
}
|
||||
|
||||
// ==================== 用户档案管理接口 ====================
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取用户档案列表',
|
||||
description: '分页获取用户档案列表,包含位置信息和档案数据'
|
||||
})
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认20,最大100)', example: 20 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@Get('user-profiles')
|
||||
async getUserProfileList(
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number
|
||||
): Promise<AdminListResponse> {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.USER_LIST_MAX_LIMIT);
|
||||
return await this.databaseManagementService.getUserProfileList(safeLimit, offset);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取用户档案详情',
|
||||
description: '根据档案ID获取详细的用户档案信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '档案ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '档案不存在' })
|
||||
@Get('user-profiles/:id')
|
||||
async getUserProfileById(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.getUserProfileById(BigInt(id));
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '根据地图获取用户档案',
|
||||
description: '获取指定地图中的所有用户档案信息'
|
||||
})
|
||||
@ApiParam({ name: 'mapId', description: '地图ID', example: 'plaza' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认20,最大100)', example: 20 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@Get('user-profiles/by-map/:mapId')
|
||||
async getUserProfilesByMap(
|
||||
@Param('mapId') mapId: string,
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number
|
||||
): Promise<AdminListResponse> {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.USER_LIST_MAX_LIMIT);
|
||||
return await this.databaseManagementService.getUserProfilesByMap(mapId, safeLimit, offset);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '创建用户档案',
|
||||
description: '为指定用户创建档案信息'
|
||||
})
|
||||
@ApiBody({ type: AdminCreateUserProfileDto, description: '用户档案创建数据' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '用户档案已存在' })
|
||||
@Post('user-profiles')
|
||||
async createUserProfile(@Body() createProfileDto: AdminCreateUserProfileDto): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.createUserProfile(createProfileDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '更新用户档案',
|
||||
description: '根据档案ID更新用户档案信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '档案ID', example: '1' })
|
||||
@ApiBody({ type: AdminUpdateUserProfileDto, description: '用户档案更新数据' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '档案不存在' })
|
||||
@Put('user-profiles/:id')
|
||||
async updateUserProfile(
|
||||
@Param('id') id: string,
|
||||
@Body() updateProfileDto: AdminUpdateUserProfileDto
|
||||
): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.updateUserProfile(BigInt(id), updateProfileDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '删除用户档案',
|
||||
description: '根据档案ID删除用户档案'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '档案ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '档案不存在' })
|
||||
@Delete('user-profiles/:id')
|
||||
async deleteUserProfile(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.deleteUserProfile(BigInt(id));
|
||||
}
|
||||
|
||||
// ==================== Zulip账号关联管理接口 ====================
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取Zulip账号关联列表',
|
||||
description: '分页获取Zulip账号关联列表,包含关联状态和错误信息'
|
||||
})
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认20,最大100)', example: 20 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@Get('zulip-accounts')
|
||||
async getZulipAccountList(
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number
|
||||
): Promise<AdminListResponse> {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.USER_LIST_MAX_LIMIT);
|
||||
return await this.databaseManagementService.getZulipAccountList(safeLimit, offset);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取Zulip账号关联详情',
|
||||
description: '根据关联ID获取详细的Zulip账号关联信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '关联ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '关联不存在' })
|
||||
@Get('zulip-accounts/:id')
|
||||
async getZulipAccountById(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.getZulipAccountById(id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取Zulip账号关联统计',
|
||||
description: '获取各种状态的Zulip账号关联数量统计信息'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@Get('zulip-accounts/statistics')
|
||||
async getZulipAccountStatistics(): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.getZulipAccountStatistics();
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '创建Zulip账号关联',
|
||||
description: '创建游戏用户与Zulip账号的关联'
|
||||
})
|
||||
@ApiBody({ type: AdminCreateZulipAccountDto, description: 'Zulip账号关联创建数据' })
|
||||
@ApiResponse({ status: 201, description: '创建成功' })
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '关联已存在' })
|
||||
@Post('zulip-accounts')
|
||||
async createZulipAccount(@Body() createAccountDto: AdminCreateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.createZulipAccount(createAccountDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '更新Zulip账号关联',
|
||||
description: '根据关联ID更新Zulip账号关联信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '关联ID', example: '1' })
|
||||
@ApiBody({ type: AdminUpdateZulipAccountDto, description: 'Zulip账号关联更新数据' })
|
||||
@ApiResponse({ status: 200, description: '更新成功' })
|
||||
@ApiResponse({ status: 404, description: '关联不存在' })
|
||||
@Put('zulip-accounts/:id')
|
||||
async updateZulipAccount(
|
||||
@Param('id') id: string,
|
||||
@Body() updateAccountDto: AdminUpdateZulipAccountDto
|
||||
): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.updateZulipAccount(id, updateAccountDto);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '删除Zulip账号关联',
|
||||
description: '根据关联ID删除Zulip账号关联'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '关联ID', example: '1' })
|
||||
@ApiResponse({ status: 200, description: '删除成功' })
|
||||
@ApiResponse({ status: 404, description: '关联不存在' })
|
||||
@Delete('zulip-accounts/:id')
|
||||
async deleteZulipAccount(@Param('id') id: string): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.deleteZulipAccount(id);
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '批量更新Zulip账号状态',
|
||||
description: '批量更新多个Zulip账号关联的状态'
|
||||
})
|
||||
@ApiBody({ type: AdminBatchUpdateStatusDto, description: '批量更新数据' })
|
||||
@ApiResponse({ status: 200, description: '批量更新完成', type: AdminDatabaseResponseDto })
|
||||
@LogAdminOperation({
|
||||
operationType: 'BATCH',
|
||||
targetType: 'zulip_accounts',
|
||||
description: '批量更新Zulip账号状态',
|
||||
isSensitive: true
|
||||
})
|
||||
@Post('zulip-accounts/batch-update-status')
|
||||
async batchUpdateZulipAccountStatus(@Body() batchUpdateDto: AdminBatchUpdateStatusDto): Promise<AdminApiResponse> {
|
||||
return await this.databaseManagementService.batchUpdateZulipAccountStatus(
|
||||
batchUpdateDto.ids,
|
||||
batchUpdateDto.status,
|
||||
batchUpdateDto.reason
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 系统健康检查接口 ====================
|
||||
|
||||
@ApiOperation({
|
||||
summary: '数据库管理系统健康检查',
|
||||
description: '检查数据库管理系统的运行状态和连接情况'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '系统正常', type: AdminHealthCheckResponseDto })
|
||||
@Get('health')
|
||||
async healthCheck(): Promise<AdminApiResponse> {
|
||||
return createSuccessResponse({
|
||||
status: 'healthy',
|
||||
timestamp: getCurrentTimestamp(),
|
||||
services: {
|
||||
users: 'connected',
|
||||
user_profiles: 'connected',
|
||||
zulip_accounts: 'connected'
|
||||
}
|
||||
}, '数据库管理系统运行正常', REQUEST_ID_PREFIXES.HEALTH_CHECK);
|
||||
}
|
||||
}
|
||||
570
src/business/admin/admin_database.dto.ts
Normal file
570
src/business/admin/admin_database.dto.ts
Normal file
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* 管理员数据库管理 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员数据库管理相关的请求和响应数据结构
|
||||
* - 提供完整的数据验证规则
|
||||
* - 支持Swagger文档自动生成
|
||||
*
|
||||
* 职责分离:
|
||||
* - 请求数据结构定义和验证
|
||||
* - 响应数据结构定义
|
||||
* - API文档生成支持
|
||||
* - 类型安全保障
|
||||
*
|
||||
* DTO分类:
|
||||
* - Query DTOs: 查询参数验证
|
||||
* - Create DTOs: 创建操作数据验证
|
||||
* - Update DTOs: 更新操作数据验证
|
||||
* - Response DTOs: 响应数据结构定义
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 为所有DTO类添加类注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 文件夹扁平化 - 从dto/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员数据库管理DTO (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsInt, Min, Max, IsEnum, IsEmail, IsArray, IsBoolean, IsNumber } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { UserStatus } from '../../core/db/users/user_status.enum';
|
||||
|
||||
// ==================== 通用查询 DTOs ====================
|
||||
|
||||
/**
|
||||
* 管理员分页查询DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义分页查询的通用参数结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - 作为其他查询DTO的基类
|
||||
* - 提供统一的分页参数验证
|
||||
*/
|
||||
export class AdminPaginationDto {
|
||||
@ApiPropertyOptional({ description: '返回数量(默认20,最大100)', example: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
limit?: number = 20;
|
||||
|
||||
@ApiPropertyOptional({ description: '偏移量(默认0)', example: 0, minimum: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => parseInt(value))
|
||||
offset?: number = 0;
|
||||
}
|
||||
|
||||
// ==================== 用户管理 DTOs ====================
|
||||
|
||||
/**
|
||||
* 管理员查询用户DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义用户查询接口的请求参数结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/database/users 接口的查询参数
|
||||
* - 支持关键词搜索和分页查询
|
||||
*/
|
||||
export class AdminQueryUsersDto extends AdminPaginationDto {
|
||||
@ApiPropertyOptional({ description: '搜索关键词(用户名、邮箱、昵称)', example: 'admin' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '用户状态过滤', enum: UserStatus, example: UserStatus.ACTIVE })
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
|
||||
@ApiPropertyOptional({ description: '角色过滤', example: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(9)
|
||||
role?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建用户DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义创建用户接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/database/users 接口的请求体
|
||||
* - 包含用户创建所需的所有必要信息
|
||||
*/
|
||||
export class AdminCreateUserDto {
|
||||
@ApiProperty({ description: '用户名', example: 'newuser' })
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邮箱', example: 'user@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '手机号', example: '13800138000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiProperty({ description: '昵称', example: '新用户' })
|
||||
@IsString()
|
||||
nickname: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '密码哈希', example: 'hashed_password' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
password_hash?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'GitHub ID', example: 'github123' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
github_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像URL', example: 'https://example.com/avatar.jpg' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatar_url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '角色', example: 1, minimum: 0, maximum: 9 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(9)
|
||||
role?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '邮箱是否已验证', example: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
email_verified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '用户状态', enum: UserStatus, example: UserStatus.ACTIVE })
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新用户DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义更新用户接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - PUT /admin/database/users/:id 接口的请求体
|
||||
* - 支持部分字段更新,所有字段都是可选的
|
||||
*/
|
||||
export class AdminUpdateUserDto {
|
||||
@ApiPropertyOptional({ description: '用户名', example: 'updateduser' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '邮箱', example: 'updated@example.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
email?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '手机号', example: '13900139000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '昵称', example: '更新用户' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
nickname?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像URL', example: 'https://example.com/new-avatar.jpg' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
avatar_url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '角色', example: 2, minimum: 0, maximum: 9 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(9)
|
||||
role?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '邮箱是否已验证', example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
email_verified?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ description: '用户状态', enum: UserStatus, example: UserStatus.INACTIVE })
|
||||
@IsOptional()
|
||||
@IsEnum(UserStatus)
|
||||
status?: UserStatus;
|
||||
}
|
||||
|
||||
// ==================== 用户档案管理 DTOs ====================
|
||||
|
||||
/**
|
||||
* 管理员查询用户档案DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义用户档案查询接口的请求参数结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/database/user-profiles 接口的查询参数
|
||||
* - 支持地图过滤和分页查询
|
||||
*/
|
||||
export class AdminQueryUserProfileDto extends AdminPaginationDto {
|
||||
@ApiPropertyOptional({ description: '当前地图过滤', example: 'plaza' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
current_map?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态过滤', example: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
status?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '用户ID过滤', example: '1' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
user_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建用户档案DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义创建用户档案接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/database/user-profiles 接口的请求体
|
||||
* - 包含用户档案创建所需的所有信息
|
||||
*/
|
||||
export class AdminCreateUserProfileDto {
|
||||
@ApiProperty({ description: '用户ID', example: '1' })
|
||||
@IsString()
|
||||
user_id: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '个人简介', example: '这是我的个人简介' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '简历内容', example: '工作经历和技能' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resume_content?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '标签', example: '["开发者", "游戏爱好者"]' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tags?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '社交链接', example: '{"github": "https://github.com/user"}' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
social_links?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '皮肤ID', example: 'skin_001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skin_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '当前地图', example: 'plaza' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
current_map?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'X坐标', example: 100.5 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pos_x?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Y坐标', example: 200.3 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pos_y?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态', example: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
status?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新用户档案DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义更新用户档案接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - PUT /admin/database/user-profiles/:id 接口的请求体
|
||||
* - 支持部分字段更新,所有字段都是可选的
|
||||
*/
|
||||
export class AdminUpdateUserProfileDto {
|
||||
@ApiPropertyOptional({ description: '个人简介', example: '更新后的个人简介' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bio?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '简历内容', example: '更新后的简历内容' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
resume_content?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '标签', example: '["高级开发者", "技术专家"]' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
tags?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '社交链接', example: '{"linkedin": "https://linkedin.com/in/user"}' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
social_links?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '皮肤ID', example: 'skin_002' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
skin_id?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '当前地图', example: 'forest' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
current_map?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'X坐标', example: 150.7 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pos_x?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Y坐标', example: 250.9 })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
pos_y?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态', example: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
status?: number;
|
||||
}
|
||||
|
||||
// ==================== Zulip账号关联管理 DTOs ====================
|
||||
|
||||
/**
|
||||
* 管理员查询Zulip账号DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义Zulip账号关联查询接口的请求参数结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/database/zulip-accounts 接口的查询参数
|
||||
* - 支持用户ID过滤和分页查询
|
||||
*/
|
||||
export class AdminQueryZulipAccountDto extends AdminPaginationDto {
|
||||
@ApiPropertyOptional({ description: '游戏用户ID过滤', example: '1' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gameUserId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Zulip用户ID过滤', example: 12345 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
zulipUserId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Zulip邮箱过滤', example: 'user@zulip.com' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
zulipEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态过滤', example: 'active', enum: ['active', 'inactive', 'suspended', 'error'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'suspended', 'error'])
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员创建Zulip账号DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义创建Zulip账号关联接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/database/zulip-accounts 接口的请求体
|
||||
* - 包含Zulip账号关联创建所需的所有信息
|
||||
*/
|
||||
export class AdminCreateZulipAccountDto {
|
||||
@ApiProperty({ description: '游戏用户ID', example: '1' })
|
||||
@IsString()
|
||||
gameUserId: string;
|
||||
|
||||
@ApiProperty({ description: 'Zulip用户ID', example: 12345 })
|
||||
@IsInt()
|
||||
zulipUserId: number;
|
||||
|
||||
@ApiProperty({ description: 'Zulip邮箱', example: 'user@zulip.com' })
|
||||
@IsEmail()
|
||||
zulipEmail: string;
|
||||
|
||||
@ApiProperty({ description: 'Zulip全名', example: '张三' })
|
||||
@IsString()
|
||||
zulipFullName: string;
|
||||
|
||||
@ApiProperty({ description: 'Zulip API密钥(加密)', example: 'encrypted_api_key' })
|
||||
@IsString()
|
||||
zulipApiKeyEncrypted: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态', example: 'active', enum: ['active', 'inactive', 'suspended', 'error'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'suspended', 'error'])
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员更新Zulip账号DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义更新Zulip账号关联接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - PUT /admin/database/zulip-accounts/:id 接口的请求体
|
||||
* - 支持部分字段更新,所有字段都是可选的
|
||||
*/
|
||||
export class AdminUpdateZulipAccountDto {
|
||||
@ApiPropertyOptional({ description: 'Zulip全名', example: '李四' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
zulipFullName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Zulip API密钥(加密)', example: 'new_encrypted_api_key' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
zulipApiKeyEncrypted?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '状态', example: 'suspended', enum: ['active', 'inactive', 'suspended', 'error'] })
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'inactive', 'suspended', 'error'])
|
||||
status?: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
|
||||
@ApiPropertyOptional({ description: '错误信息', example: '连接超时' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
errorMessage?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '重试次数', example: 3 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
retryCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员批量更新状态DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义批量更新状态接口的请求数据结构和验证规则
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/database/zulip-accounts/batch-update-status 接口的请求体
|
||||
* - 支持批量更新多个记录的状态
|
||||
*/
|
||||
export class AdminBatchUpdateStatusDto {
|
||||
@ApiProperty({ description: 'ID列表', example: ['1', '2', '3'] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ids: string[];
|
||||
|
||||
@ApiProperty({ description: '目标状态', example: 'active', enum: ['active', 'inactive', 'suspended', 'error'] })
|
||||
@IsEnum(['active', 'inactive', 'suspended', 'error'])
|
||||
status: 'active' | 'inactive' | 'suspended' | 'error';
|
||||
|
||||
@ApiPropertyOptional({ description: '操作原因', example: '批量激活账号' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// ==================== 响应 DTOs ====================
|
||||
|
||||
/**
|
||||
* 管理员数据库响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员数据库操作的通用响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - 各种数据库管理接口的响应体基类
|
||||
* - 包含操作状态、数据和消息信息
|
||||
*/
|
||||
export class AdminDatabaseResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '操作成功' })
|
||||
message: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '数据' })
|
||||
data?: any;
|
||||
|
||||
@ApiPropertyOptional({ description: '错误码', example: 'RESOURCE_NOT_FOUND' })
|
||||
error_code?: string;
|
||||
|
||||
@ApiProperty({ description: '时间戳', example: '2026-01-08T10:30:00.000Z' })
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({ description: '请求ID', example: 'req_1641636600000_abc123' })
|
||||
request_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员数据库列表响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员数据库列表查询的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - 各种列表查询接口的响应体
|
||||
* - 包含列表数据和分页信息
|
||||
*/
|
||||
export class AdminDatabaseListResponseDto extends AdminDatabaseResponseDto {
|
||||
@ApiProperty({ description: '列表数据' })
|
||||
data: {
|
||||
items: any[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员健康检查响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义系统健康检查接口的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/database/health 接口的响应体
|
||||
* - 包含系统健康状态信息
|
||||
*/
|
||||
export class AdminHealthCheckResponseDto extends AdminDatabaseResponseDto {
|
||||
@ApiProperty({ description: '健康检查数据' })
|
||||
data: {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
services: {
|
||||
users: string;
|
||||
user_profiles: string;
|
||||
zulip_accounts: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
271
src/business/admin/admin_database_exception.filter.ts
Normal file
271
src/business/admin/admin_database_exception.filter.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 管理员数据库操作异常过滤器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 统一处理管理员数据库管理操作中的异常
|
||||
* - 标准化错误响应格式
|
||||
* - 记录详细的错误日志
|
||||
* - 提供用户友好的错误信息
|
||||
*
|
||||
* 职责分离:
|
||||
* - 异常捕获:捕获所有未处理的异常
|
||||
* - 错误转换:将系统异常转换为用户友好的错误信息
|
||||
* - 日志记录:记录详细的错误信息用于调试
|
||||
* - 响应格式化:统一错误响应的格式
|
||||
*
|
||||
* 支持的异常类型:
|
||||
* - BadRequestException: 400 - 请求参数错误
|
||||
* - UnauthorizedException: 401 - 未授权访问
|
||||
* - ForbiddenException: 403 - 权限不足
|
||||
* - NotFoundException: 404 - 资源不存在
|
||||
* - ConflictException: 409 - 资源冲突
|
||||
* - UnprocessableEntityException: 422 - 数据验证失败
|
||||
* - InternalServerErrorException: 500 - 系统内部错误
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员数据库异常过滤器 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
UnprocessableEntityException,
|
||||
InternalServerErrorException
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { SENSITIVE_FIELDS } from './admin_constants';
|
||||
import { generateRequestId, getCurrentTimestamp } from './admin_utils';
|
||||
|
||||
/**
|
||||
* 错误响应接口
|
||||
*/
|
||||
interface ErrorResponse {
|
||||
success: false;
|
||||
message: string;
|
||||
error_code: string;
|
||||
details?: {
|
||||
field?: string;
|
||||
constraint?: string;
|
||||
received_value?: any;
|
||||
}[];
|
||||
timestamp: string;
|
||||
request_id: string;
|
||||
path: string;
|
||||
method: string;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class AdminDatabaseExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AdminDatabaseExceptionFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const errorResponse = this.buildErrorResponse(exception, request);
|
||||
|
||||
// 记录错误日志
|
||||
this.logError(exception, request, errorResponse);
|
||||
|
||||
response.status(errorResponse.status).json({
|
||||
success: errorResponse.body.success,
|
||||
message: errorResponse.body.message,
|
||||
error_code: errorResponse.body.error_code,
|
||||
details: errorResponse.body.details,
|
||||
timestamp: errorResponse.body.timestamp,
|
||||
request_id: errorResponse.body.request_id,
|
||||
path: errorResponse.body.path,
|
||||
method: errorResponse.body.method
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建错误响应
|
||||
*
|
||||
* @param exception 异常对象
|
||||
* @param request 请求对象
|
||||
* @returns 错误响应对象
|
||||
*/
|
||||
private buildErrorResponse(exception: any, request: Request): { status: number; body: ErrorResponse } {
|
||||
let status: number;
|
||||
let message: string;
|
||||
let error_code: string;
|
||||
let details: any[] | undefined;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
const responseObj = exceptionResponse as any;
|
||||
message = responseObj.message || responseObj.error || exception.message;
|
||||
details = responseObj.details;
|
||||
} else {
|
||||
message = exception.message;
|
||||
}
|
||||
|
||||
// 根据异常类型设置错误码
|
||||
error_code = this.getErrorCodeByException(exception);
|
||||
} else {
|
||||
// 未知异常,返回500
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message = '系统内部错误,请稍后重试';
|
||||
error_code = 'INTERNAL_SERVER_ERROR';
|
||||
}
|
||||
|
||||
const body: ErrorResponse = {
|
||||
success: false,
|
||||
message,
|
||||
error_code,
|
||||
details,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId('err'),
|
||||
path: request.url,
|
||||
method: request.method
|
||||
};
|
||||
|
||||
return { status, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据异常类型获取错误码
|
||||
*
|
||||
* @param exception 异常对象
|
||||
* @returns 错误码
|
||||
*/
|
||||
private getErrorCodeByException(exception: HttpException): string {
|
||||
if (exception instanceof BadRequestException) {
|
||||
return 'BAD_REQUEST';
|
||||
}
|
||||
if (exception instanceof UnauthorizedException) {
|
||||
return 'UNAUTHORIZED';
|
||||
}
|
||||
if (exception instanceof ForbiddenException) {
|
||||
return 'FORBIDDEN';
|
||||
}
|
||||
if (exception instanceof NotFoundException) {
|
||||
return 'NOT_FOUND';
|
||||
}
|
||||
if (exception instanceof ConflictException) {
|
||||
return 'CONFLICT';
|
||||
}
|
||||
if (exception instanceof UnprocessableEntityException) {
|
||||
return 'UNPROCESSABLE_ENTITY';
|
||||
}
|
||||
if (exception instanceof InternalServerErrorException) {
|
||||
return 'INTERNAL_SERVER_ERROR';
|
||||
}
|
||||
|
||||
// 根据HTTP状态码设置错误码
|
||||
const status = exception.getStatus();
|
||||
switch (status) {
|
||||
case HttpStatus.BAD_REQUEST:
|
||||
return 'BAD_REQUEST';
|
||||
case HttpStatus.UNAUTHORIZED:
|
||||
return 'UNAUTHORIZED';
|
||||
case HttpStatus.FORBIDDEN:
|
||||
return 'FORBIDDEN';
|
||||
case HttpStatus.NOT_FOUND:
|
||||
return 'NOT_FOUND';
|
||||
case HttpStatus.CONFLICT:
|
||||
return 'CONFLICT';
|
||||
case HttpStatus.UNPROCESSABLE_ENTITY:
|
||||
return 'UNPROCESSABLE_ENTITY';
|
||||
case HttpStatus.TOO_MANY_REQUESTS:
|
||||
return 'TOO_MANY_REQUESTS';
|
||||
case HttpStatus.INTERNAL_SERVER_ERROR:
|
||||
return 'INTERNAL_SERVER_ERROR';
|
||||
case HttpStatus.BAD_GATEWAY:
|
||||
return 'BAD_GATEWAY';
|
||||
case HttpStatus.SERVICE_UNAVAILABLE:
|
||||
return 'SERVICE_UNAVAILABLE';
|
||||
case HttpStatus.GATEWAY_TIMEOUT:
|
||||
return 'GATEWAY_TIMEOUT';
|
||||
default:
|
||||
return 'UNKNOWN_ERROR';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误日志
|
||||
*
|
||||
* @param exception 异常对象
|
||||
* @param request 请求对象
|
||||
* @param errorResponse 错误响应对象
|
||||
*/
|
||||
private logError(exception: any, request: Request, errorResponse: { status: number; body: ErrorResponse }): void {
|
||||
const { status, body } = errorResponse;
|
||||
|
||||
const logContext = {
|
||||
request_id: body.request_id,
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
user_agent: request.get('User-Agent'),
|
||||
ip: request.ip,
|
||||
status,
|
||||
error_code: body.error_code,
|
||||
message: body.message,
|
||||
timestamp: body.timestamp
|
||||
};
|
||||
|
||||
if (status >= 500) {
|
||||
// 服务器错误,记录详细的错误信息
|
||||
this.logger.error('服务器内部错误', {
|
||||
...logContext,
|
||||
stack: exception instanceof Error ? exception.stack : undefined,
|
||||
exception_type: exception.constructor?.name,
|
||||
details: body.details
|
||||
});
|
||||
} else if (status >= 400) {
|
||||
// 客户端错误,记录警告信息
|
||||
this.logger.warn('客户端请求错误', {
|
||||
...logContext,
|
||||
request_body: this.sanitizeRequestBody(request.body),
|
||||
query_params: request.query
|
||||
});
|
||||
} else {
|
||||
// 其他情况,记录普通日志
|
||||
this.logger.log('请求处理异常', logContext);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理请求体中的敏感信息
|
||||
*
|
||||
* @param body 请求体
|
||||
* @returns 清理后的请求体
|
||||
*/
|
||||
private sanitizeRequestBody(body: any): any {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return body;
|
||||
}
|
||||
|
||||
const sanitized = { ...body };
|
||||
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = '[REDACTED]';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
71
src/business/admin/admin_login.dto.ts
Normal file
71
src/business/admin/admin_login.dto.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 管理员相关 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员登录与用户密码重置的请求结构
|
||||
* - 提供完整的数据验证规则
|
||||
* - 支持Swagger文档自动生成
|
||||
*
|
||||
* 职责分离:
|
||||
* - 请求数据结构定义
|
||||
* - 输入参数验证规则
|
||||
* - API文档生成支持
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 文件夹扁平化 - 从dto/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
* - 2026-01-08: 注释规范优化 - 补充类注释,完善DTO文档说明 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 管理员登录请求DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员登录接口的请求数据结构和验证规则
|
||||
*
|
||||
* 验证规则:
|
||||
* - identifier: 必填字符串,支持用户名/邮箱/手机号
|
||||
* - password: 必填字符串,管理员密码
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/auth/login 接口的请求体
|
||||
*/
|
||||
export class AdminLoginDto {
|
||||
@ApiProperty({ description: '登录标识符(用户名/邮箱/手机号)', example: 'admin' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
identifier: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'YourStrongPassword123!' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员重置密码请求DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员重置用户密码接口的请求数据结构和验证规则
|
||||
*
|
||||
* 验证规则:
|
||||
* - newPassword: 必填字符串,至少8位,需包含字母和数字
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/users/:id/reset-password 接口的请求体
|
||||
*/
|
||||
export class AdminResetPasswordDto {
|
||||
@ApiProperty({ description: '新密码(至少8位,包含字母和数字)', example: 'NewPass1234' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(8)
|
||||
newPassword: string;
|
||||
}
|
||||
373
src/business/admin/admin_operation_log.controller.ts
Normal file
373
src/business/admin/admin_operation_log.controller.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* 管理员操作日志控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供管理员操作日志的查询和管理接口
|
||||
* - 支持日志的分页查询和过滤
|
||||
* - 提供操作统计和分析功能
|
||||
* - 支持敏感操作日志的特殊查询
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP请求处理:接收和验证HTTP请求参数
|
||||
* - 权限控制:通过AdminGuard确保只有管理员可以访问
|
||||
* - 业务委托:将业务逻辑委托给AdminOperationLogService处理
|
||||
* - 响应格式化:返回统一格式的HTTP响应
|
||||
*
|
||||
* API端点:
|
||||
* - GET /admin/operation-logs 获取操作日志列表
|
||||
* - GET /admin/operation-logs/:id 获取操作日志详情
|
||||
* - GET /admin/operation-logs/statistics 获取操作统计
|
||||
* - GET /admin/operation-logs/sensitive 获取敏感操作日志
|
||||
* - DELETE /admin/operation-logs/cleanup 清理过期日志
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志控制器 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseFilters,
|
||||
UseInterceptors,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
BadRequestException
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiResponse
|
||||
} from '@nestjs/swagger';
|
||||
import { AdminGuard } from './admin.guard';
|
||||
import { AdminDatabaseExceptionFilter } from './admin_database_exception.filter';
|
||||
import { AdminOperationLogInterceptor } from './admin_operation_log.interceptor';
|
||||
import { LogAdminOperation } from './log_admin_operation.decorator';
|
||||
import { AdminOperationLogService, LogQueryParams } from './admin_operation_log.service';
|
||||
import { PAGINATION_LIMITS, LOG_RETENTION, USER_QUERY_LIMITS } from './admin_constants';
|
||||
import { safeLimitValue, safeOffsetValue, safeDaysToKeep, createSuccessResponse, createListResponse } from './admin_utils';
|
||||
|
||||
@ApiTags('admin-operation-logs')
|
||||
@Controller('admin/operation-logs')
|
||||
@UseGuards(AdminGuard)
|
||||
@UseFilters(AdminDatabaseExceptionFilter)
|
||||
@UseInterceptors(AdminOperationLogInterceptor)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
export class AdminOperationLogController {
|
||||
constructor(
|
||||
private readonly logService: AdminOperationLogService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 获取操作日志列表
|
||||
*
|
||||
* 功能描述:
|
||||
* 分页获取管理员操作日志,支持多种过滤条件
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证查询参数
|
||||
* 2. 构建查询条件
|
||||
* 3. 调用日志服务查询
|
||||
* 4. 返回分页结果
|
||||
*
|
||||
* @param limit 返回数量,默认50,最大200
|
||||
* @param offset 偏移量,默认0
|
||||
* @param adminUserId 管理员用户ID过滤,可选
|
||||
* @param operationType 操作类型过滤,可选
|
||||
* @param targetType 目标类型过滤,可选
|
||||
* @param operationResult 操作结果过滤,可选
|
||||
* @param startDate 开始日期过滤,可选
|
||||
* @param endDate 结束日期过滤,可选
|
||||
* @param isSensitive 是否敏感操作过滤,可选
|
||||
* @returns 操作日志列表和分页信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 获取最近50条操作日志
|
||||
* GET /admin/operation-logs?limit=50&offset=0
|
||||
*
|
||||
* // 获取特定管理员的操作日志
|
||||
* GET /admin/operation-logs?adminUserId=123&limit=20
|
||||
*
|
||||
* // 获取敏感操作日志
|
||||
* GET /admin/operation-logs?isSensitive=true
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '获取操作日志列表',
|
||||
description: '分页获取管理员操作日志,支持多种过滤条件'
|
||||
})
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认50,最大200)', example: 50 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiQuery({ name: 'adminUserId', required: false, description: '管理员用户ID过滤', example: '123' })
|
||||
@ApiQuery({ name: 'operationType', required: false, description: '操作类型过滤', example: 'CREATE' })
|
||||
@ApiQuery({ name: 'targetType', required: false, description: '目标类型过滤', example: 'users' })
|
||||
@ApiQuery({ name: 'operationResult', required: false, description: '操作结果过滤', example: 'SUCCESS' })
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期(ISO格式)', example: '2026-01-01T00:00:00.000Z' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期(ISO格式)', example: '2026-01-08T23:59:59.999Z' })
|
||||
@ApiQuery({ name: 'isSensitive', required: false, description: '是否敏感操作', example: true })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 401, description: '未授权访问' })
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'QUERY',
|
||||
targetType: 'admin_logs',
|
||||
description: '获取操作日志列表',
|
||||
isSensitive: false
|
||||
})
|
||||
@Get()
|
||||
async getOperationLogs(
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number,
|
||||
@Query('adminUserId') adminUserId?: string,
|
||||
@Query('operationType') operationType?: string,
|
||||
@Query('targetType') targetType?: string,
|
||||
@Query('operationResult') operationResult?: string,
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string,
|
||||
@Query('isSensitive') isSensitive?: string
|
||||
) {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.LOG_LIST_MAX_LIMIT);
|
||||
const safeOffset = safeOffsetValue(offset);
|
||||
|
||||
const queryParams: LogQueryParams = {
|
||||
limit: safeLimit,
|
||||
offset: safeOffset
|
||||
};
|
||||
|
||||
if (adminUserId) queryParams.adminUserId = adminUserId;
|
||||
if (operationType) queryParams.operationType = operationType;
|
||||
if (targetType) queryParams.targetType = targetType;
|
||||
if (operationResult) queryParams.operationResult = operationResult;
|
||||
if (isSensitive !== undefined) queryParams.isSensitive = isSensitive === 'true';
|
||||
|
||||
if (startDate && endDate) {
|
||||
queryParams.startDate = new Date(startDate);
|
||||
queryParams.endDate = new Date(endDate);
|
||||
|
||||
if (isNaN(queryParams.startDate.getTime()) || isNaN(queryParams.endDate.getTime())) {
|
||||
throw new BadRequestException('日期格式无效,请使用ISO格式');
|
||||
}
|
||||
}
|
||||
|
||||
const { logs, total } = await this.logService.queryLogs(queryParams);
|
||||
|
||||
return createListResponse(
|
||||
logs,
|
||||
total,
|
||||
safeLimit,
|
||||
safeOffset,
|
||||
'操作日志列表获取成功'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作日志详情
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据日志ID获取操作日志的详细信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证日志ID格式
|
||||
* 2. 查询日志详细信息
|
||||
* 3. 返回日志详情
|
||||
*
|
||||
* @param id 日志ID
|
||||
* @returns 操作日志详细信息
|
||||
*
|
||||
* @throws NotFoundException 当日志不存在时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await controller.getOperationLogById('uuid-123');
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '获取操作日志详情',
|
||||
description: '根据日志ID获取操作日志的详细信息'
|
||||
})
|
||||
@ApiParam({ name: 'id', description: '日志ID', example: 'uuid-123' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@ApiResponse({ status: 404, description: '日志不存在' })
|
||||
@Get(':id')
|
||||
async getOperationLogById(@Param('id') id: string) {
|
||||
const log = await this.logService.getLogById(id);
|
||||
|
||||
if (!log) {
|
||||
throw new BadRequestException('操作日志不存在');
|
||||
}
|
||||
|
||||
return createSuccessResponse(log, '操作日志详情获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作统计信息
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取管理员操作的统计信息,包括操作数量、类型分布等
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 解析时间范围参数
|
||||
* 2. 调用统计服务
|
||||
* 3. 返回统计结果
|
||||
*
|
||||
* @param startDate 开始日期,可选
|
||||
* @param endDate 结束日期,可选
|
||||
* @returns 操作统计信息
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 获取全部统计
|
||||
* GET /admin/operation-logs/statistics
|
||||
*
|
||||
* // 获取指定时间范围的统计
|
||||
* GET /admin/operation-logs/statistics?startDate=2026-01-01&endDate=2026-01-08
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '获取操作统计信息',
|
||||
description: '获取管理员操作的统计信息,包括操作数量、类型分布等'
|
||||
})
|
||||
@ApiQuery({ name: 'startDate', required: false, description: '开始日期(ISO格式)', example: '2026-01-01T00:00:00.000Z' })
|
||||
@ApiQuery({ name: 'endDate', required: false, description: '结束日期(ISO格式)', example: '2026-01-08T23:59:59.999Z' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@Get('statistics')
|
||||
async getOperationStatistics(
|
||||
@Query('startDate') startDate?: string,
|
||||
@Query('endDate') endDate?: string
|
||||
) {
|
||||
let parsedStartDate: Date | undefined;
|
||||
let parsedEndDate: Date | undefined;
|
||||
|
||||
if (startDate && endDate) {
|
||||
parsedStartDate = new Date(startDate);
|
||||
parsedEndDate = new Date(endDate);
|
||||
|
||||
if (isNaN(parsedStartDate.getTime()) || isNaN(parsedEndDate.getTime())) {
|
||||
throw new BadRequestException('日期格式无效,请使用ISO格式');
|
||||
}
|
||||
}
|
||||
|
||||
const statistics = await this.logService.getStatistics(parsedStartDate, parsedEndDate);
|
||||
|
||||
return createSuccessResponse(statistics, '操作统计信息获取成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取敏感操作日志
|
||||
*
|
||||
* 功能描述:
|
||||
* 获取标记为敏感的操作日志,用于安全审计
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证查询参数
|
||||
* 2. 查询敏感操作日志
|
||||
* 3. 返回分页结果
|
||||
*
|
||||
* @param limit 返回数量,默认50,最大200
|
||||
* @param offset 偏移量,默认0
|
||||
* @returns 敏感操作日志列表
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 获取最近50条敏感操作日志
|
||||
* GET /admin/operation-logs/sensitive?limit=50
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '获取敏感操作日志',
|
||||
description: '获取标记为敏感的操作日志,用于安全审计'
|
||||
})
|
||||
@ApiQuery({ name: 'limit', required: false, description: '返回数量(默认50,最大200)', example: 50 })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '偏移量(默认0)', example: 0 })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'QUERY',
|
||||
targetType: 'admin_logs',
|
||||
description: '获取敏感操作日志',
|
||||
isSensitive: true
|
||||
})
|
||||
@Get('sensitive')
|
||||
async getSensitiveOperations(
|
||||
@Query('limit', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_LIMIT), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(PAGINATION_LIMITS.DEFAULT_OFFSET), ParseIntPipe) offset: number
|
||||
) {
|
||||
const safeLimit = safeLimitValue(limit, PAGINATION_LIMITS.LOG_LIST_MAX_LIMIT);
|
||||
const safeOffset = safeOffsetValue(offset);
|
||||
|
||||
const { logs, total } = await this.logService.getSensitiveOperations(safeLimit, safeOffset);
|
||||
|
||||
return createListResponse(
|
||||
logs,
|
||||
total,
|
||||
safeLimit,
|
||||
safeOffset,
|
||||
'敏感操作日志获取成功'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期日志
|
||||
*
|
||||
* 功能描述:
|
||||
* 清理超过指定天数的操作日志,释放存储空间
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证保留天数参数
|
||||
* 2. 调用清理服务
|
||||
* 3. 返回清理结果
|
||||
*
|
||||
* @param daysToKeep 保留天数,默认90天,最少7天,最多365天
|
||||
* @returns 清理结果,包含删除的记录数
|
||||
*
|
||||
* @throws BadRequestException 当保留天数超出范围时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // 清理90天前的日志
|
||||
* DELETE /admin/operation-logs/cleanup?daysToKeep=90
|
||||
* ```
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '清理过期日志',
|
||||
description: '清理超过指定天数的操作日志,释放存储空间'
|
||||
})
|
||||
@ApiQuery({ name: 'daysToKeep', required: false, description: '保留天数(默认90,最少7,最多365)', example: 90 })
|
||||
@ApiResponse({ status: 200, description: '清理成功' })
|
||||
@ApiResponse({ status: 400, description: '参数错误' })
|
||||
@LogAdminOperation({
|
||||
operationType: 'DELETE',
|
||||
targetType: 'admin_logs',
|
||||
description: '清理过期操作日志',
|
||||
isSensitive: true
|
||||
})
|
||||
@Delete('cleanup')
|
||||
async cleanupExpiredLogs(
|
||||
@Query('daysToKeep', new DefaultValuePipe(LOG_RETENTION.DEFAULT_DAYS), ParseIntPipe) daysToKeep: number
|
||||
) {
|
||||
const safeDays = safeDaysToKeep(daysToKeep, LOG_RETENTION.MIN_DAYS, LOG_RETENTION.MAX_DAYS);
|
||||
|
||||
if (safeDays !== daysToKeep) {
|
||||
throw new BadRequestException(`保留天数必须在${LOG_RETENTION.MIN_DAYS}-${LOG_RETENTION.MAX_DAYS}天之间`);
|
||||
}
|
||||
|
||||
const deletedCount = await this.logService.cleanupExpiredLogs(safeDays);
|
||||
|
||||
return createSuccessResponse({
|
||||
deleted_count: deletedCount,
|
||||
days_to_keep: safeDays,
|
||||
cleanup_date: new Date().toISOString()
|
||||
}, `过期日志清理完成,删除了${deletedCount}条记录`);
|
||||
}
|
||||
}
|
||||
103
src/business/admin/admin_operation_log.entity.ts
Normal file
103
src/business/admin/admin_operation_log.entity.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 管理员操作日志实体
|
||||
*
|
||||
* 功能描述:
|
||||
* - 记录管理员的所有数据库操作
|
||||
* - 提供详细的审计跟踪
|
||||
* - 支持操作前后数据状态记录
|
||||
* - 便于安全审计和问题排查
|
||||
*
|
||||
* 职责分离:
|
||||
* - 数据持久化:操作日志的数据库存储
|
||||
* - 审计跟踪:完整的操作历史记录
|
||||
* - 安全监控:敏感操作的详细记录
|
||||
* - 问题排查:操作异常的详细信息
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志实体 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm';
|
||||
import { OPERATION_TYPES, OPERATION_RESULTS } from './admin_constants';
|
||||
|
||||
@Entity('admin_operation_logs')
|
||||
@Index(['admin_user_id', 'created_at'])
|
||||
@Index(['operation_type', 'created_at'])
|
||||
@Index(['target_type', 'target_id'])
|
||||
export class AdminOperationLog {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, comment: '管理员用户ID' })
|
||||
@Index()
|
||||
admin_user_id: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, comment: '管理员用户名' })
|
||||
admin_username: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, comment: '操作类型 (CREATE/UPDATE/DELETE/QUERY/BATCH)' })
|
||||
operation_type: keyof typeof OPERATION_TYPES;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, comment: '目标资源类型 (users/user_profiles/zulip_accounts)' })
|
||||
target_type: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, comment: '目标资源ID' })
|
||||
target_id?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200, comment: '操作描述' })
|
||||
operation_description: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, comment: 'HTTP方法和路径' })
|
||||
http_method_path: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true, comment: '请求参数' })
|
||||
request_params?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'json', nullable: true, comment: '操作前数据状态' })
|
||||
before_data?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'json', nullable: true, comment: '操作后数据状态' })
|
||||
after_data?: Record<string, any>;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, comment: '操作结果 (SUCCESS/FAILED)' })
|
||||
operation_result: keyof typeof OPERATION_RESULTS;
|
||||
|
||||
@Column({ type: 'text', nullable: true, comment: '错误信息' })
|
||||
error_message?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true, comment: '错误码' })
|
||||
error_code?: string;
|
||||
|
||||
@Column({ type: 'int', comment: '操作耗时(毫秒)' })
|
||||
duration_ms: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 45, nullable: true, comment: '客户端IP地址' })
|
||||
client_ip?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true, comment: '用户代理' })
|
||||
user_agent?: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, comment: '请求ID' })
|
||||
request_id: string;
|
||||
|
||||
@Column({ type: 'json', nullable: true, comment: '额外的上下文信息' })
|
||||
context?: Record<string, any>;
|
||||
|
||||
@CreateDateColumn({ comment: '创建时间' })
|
||||
created_at: Date;
|
||||
|
||||
@Column({ type: 'boolean', default: false, comment: '是否为敏感操作' })
|
||||
is_sensitive: boolean;
|
||||
|
||||
@Column({ type: 'int', default: 0, comment: '影响的记录数量' })
|
||||
affected_records: number;
|
||||
|
||||
@Column({ type: 'varchar', length: 100, nullable: true, comment: '批量操作的批次ID' })
|
||||
batch_id?: string;
|
||||
}
|
||||
203
src/business/admin/admin_operation_log.interceptor.ts
Normal file
203
src/business/admin/admin_operation_log.interceptor.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 管理员操作日志拦截器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 自动拦截管理员操作并记录日志
|
||||
* - 记录操作前后的数据状态
|
||||
* - 监控操作性能和错误
|
||||
* - 支持敏感操作的特殊处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 操作拦截:拦截控制器方法的执行
|
||||
* - 数据捕获:记录请求参数和响应数据
|
||||
* - 日志记录:调用日志服务记录操作
|
||||
* - 错误处理:记录操作异常信息
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志拦截器 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { Observable, throwError } from 'rxjs';
|
||||
import { tap, catchError } from 'rxjs/operators';
|
||||
import { AdminOperationLogService } from './admin_operation_log.service';
|
||||
import { LOG_ADMIN_OPERATION_KEY, LogAdminOperationOptions } from './log_admin_operation.decorator';
|
||||
import { SENSITIVE_FIELDS, OPERATION_RESULTS } from './admin_constants';
|
||||
import { extractClientIp, generateRequestId, sanitizeRequestBody } from './admin_utils';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOperationLogInterceptor implements NestInterceptor {
|
||||
private readonly logger = new Logger(AdminOperationLogInterceptor.name);
|
||||
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly logService: AdminOperationLogService,
|
||||
) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const logOptions = this.reflector.get<LogAdminOperationOptions>(
|
||||
LOG_ADMIN_OPERATION_KEY,
|
||||
context.getHandler(),
|
||||
);
|
||||
|
||||
// 如果没有日志配置,直接执行
|
||||
if (!logOptions) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
const startTime = Date.now();
|
||||
|
||||
// 提取请求信息
|
||||
const adminUser = request.user;
|
||||
const clientIp = extractClientIp(request);
|
||||
const userAgent = request.headers['user-agent'] || 'unknown';
|
||||
const httpMethodPath = `${request.method} ${request.route?.path || request.url}`;
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// 提取请求参数
|
||||
const requestParams = logOptions.captureRequestParams !== false ? {
|
||||
params: request.params,
|
||||
query: request.query,
|
||||
body: sanitizeRequestBody(request.body)
|
||||
} : undefined;
|
||||
|
||||
// 提取目标ID(如果存在)
|
||||
const targetId = request.params?.id || request.body?.id || request.query?.id;
|
||||
|
||||
let beforeData: any = undefined;
|
||||
let operationError: any = null;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap((responseData) => {
|
||||
// 操作成功,记录日志
|
||||
this.recordLog({
|
||||
logOptions,
|
||||
adminUser,
|
||||
clientIp,
|
||||
userAgent,
|
||||
httpMethodPath,
|
||||
requestId,
|
||||
requestParams,
|
||||
targetId,
|
||||
beforeData,
|
||||
afterData: logOptions.captureAfterData !== false ? responseData : undefined,
|
||||
operationResult: OPERATION_RESULTS.SUCCESS,
|
||||
durationMs: Date.now() - startTime,
|
||||
affectedRecords: this.extractAffectedRecords(responseData),
|
||||
});
|
||||
}),
|
||||
catchError((error) => {
|
||||
// 操作失败,记录错误日志
|
||||
operationError = error;
|
||||
this.recordLog({
|
||||
logOptions,
|
||||
adminUser,
|
||||
clientIp,
|
||||
userAgent,
|
||||
httpMethodPath,
|
||||
requestId,
|
||||
requestParams,
|
||||
targetId,
|
||||
beforeData,
|
||||
operationResult: OPERATION_RESULTS.FAILED,
|
||||
errorMessage: error.message || String(error),
|
||||
errorCode: error.code || error.status || 'UNKNOWN_ERROR',
|
||||
durationMs: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录操作日志
|
||||
*/
|
||||
private async recordLog(params: {
|
||||
logOptions: LogAdminOperationOptions;
|
||||
adminUser: any;
|
||||
clientIp: string;
|
||||
userAgent: string;
|
||||
httpMethodPath: string;
|
||||
requestId: string;
|
||||
requestParams?: any;
|
||||
targetId?: string;
|
||||
beforeData?: any;
|
||||
afterData?: any;
|
||||
operationResult: keyof typeof OPERATION_RESULTS;
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
durationMs: number;
|
||||
affectedRecords?: number;
|
||||
}) {
|
||||
try {
|
||||
await this.logService.createLog({
|
||||
adminUserId: params.adminUser?.id || 'unknown',
|
||||
adminUsername: params.adminUser?.username || 'unknown',
|
||||
operationType: params.logOptions.operationType,
|
||||
targetType: params.logOptions.targetType,
|
||||
targetId: params.targetId,
|
||||
operationDescription: params.logOptions.description,
|
||||
httpMethodPath: params.httpMethodPath,
|
||||
requestParams: params.requestParams,
|
||||
beforeData: params.beforeData,
|
||||
afterData: params.afterData,
|
||||
operationResult: params.operationResult,
|
||||
errorMessage: params.errorMessage,
|
||||
errorCode: params.errorCode,
|
||||
durationMs: params.durationMs,
|
||||
clientIp: params.clientIp,
|
||||
userAgent: params.userAgent,
|
||||
requestId: params.requestId,
|
||||
isSensitive: params.logOptions.isSensitive || false,
|
||||
affectedRecords: params.affectedRecords || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error('记录操作日志失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
adminUserId: params.adminUser?.id,
|
||||
operationType: params.logOptions.operationType,
|
||||
targetType: params.logOptions.targetType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取影响的记录数量
|
||||
*/
|
||||
private extractAffectedRecords(responseData: any): number {
|
||||
if (!responseData || typeof responseData !== 'object') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 从响应数据中提取影响的记录数
|
||||
if (responseData.data) {
|
||||
if (Array.isArray(responseData.data.items)) {
|
||||
return responseData.data.items.length;
|
||||
}
|
||||
if (responseData.data.total !== undefined) {
|
||||
return responseData.data.total;
|
||||
}
|
||||
if (responseData.data.success !== undefined && responseData.data.failed !== undefined) {
|
||||
return responseData.data.success + responseData.data.failed;
|
||||
}
|
||||
}
|
||||
|
||||
return 1; // 默认为1条记录
|
||||
}
|
||||
}
|
||||
575
src/business/admin/admin_operation_log.service.ts
Normal file
575
src/business/admin/admin_operation_log.service.ts
Normal file
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* 管理员操作日志服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 记录管理员的所有数据库操作
|
||||
* - 提供操作日志的查询和统计功能
|
||||
* - 支持敏感操作的特殊标记
|
||||
* - 实现日志的自动清理和归档
|
||||
*
|
||||
* 职责分离:
|
||||
* - 日志记录:记录操作的详细信息
|
||||
* - 日志查询:提供灵活的日志查询接口
|
||||
* - 日志统计:生成操作统计报告
|
||||
* - 日志管理:自动清理和归档功能
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 代码质量优化 - 使用常量替代硬编码字符串,提高代码一致性 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 拆分getStatistics长方法为多个私有方法,提高可读性 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 为接口添加注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 添加类注释,完善服务文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 代码质量优化 - 提取魔法数字为常量,重构长方法,补充导入 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志服务 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.4.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
import { LOG_QUERY_LIMITS, USER_QUERY_LIMITS, LOG_RETENTION, OPERATION_TYPES, OPERATION_RESULTS } from './admin_constants';
|
||||
|
||||
/**
|
||||
* 创建日志参数接口
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义创建管理员操作日志所需的所有参数
|
||||
*
|
||||
* 使用场景:
|
||||
* - AdminOperationLogService.createLog()方法的参数类型
|
||||
* - 记录管理员操作的详细信息
|
||||
*/
|
||||
export interface CreateLogParams {
|
||||
adminUserId: string;
|
||||
adminUsername: string;
|
||||
operationType: keyof typeof OPERATION_TYPES;
|
||||
targetType: string;
|
||||
targetId?: string;
|
||||
operationDescription: string;
|
||||
httpMethodPath: string;
|
||||
requestParams?: Record<string, any>;
|
||||
beforeData?: Record<string, any>;
|
||||
afterData?: Record<string, any>;
|
||||
operationResult: keyof typeof OPERATION_RESULTS;
|
||||
errorMessage?: string;
|
||||
errorCode?: string;
|
||||
durationMs: number;
|
||||
clientIp?: string;
|
||||
userAgent?: string;
|
||||
requestId: string;
|
||||
context?: Record<string, any>;
|
||||
isSensitive?: boolean;
|
||||
affectedRecords?: number;
|
||||
batchId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志查询参数接口
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义查询管理员操作日志的过滤条件
|
||||
*
|
||||
* 使用场景:
|
||||
* - AdminOperationLogService.queryLogs()方法的参数类型
|
||||
* - 支持多维度的日志查询和过滤
|
||||
*/
|
||||
export interface LogQueryParams {
|
||||
adminUserId?: string;
|
||||
operationType?: string;
|
||||
targetType?: string;
|
||||
operationResult?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isSensitive?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志统计信息接口
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员操作日志的统计数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - AdminOperationLogService.getStatistics()方法的返回类型
|
||||
* - 提供操作统计和分析数据
|
||||
*/
|
||||
export interface LogStatistics {
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
operationsByType: Record<string, number>;
|
||||
operationsByTarget: Record<string, number>;
|
||||
operationsByAdmin: Record<string, number>;
|
||||
averageDuration: number;
|
||||
sensitiveOperations: number;
|
||||
uniqueAdmins: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员操作日志服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 记录管理员的所有数据库操作
|
||||
* - 提供操作日志的查询和统计功能
|
||||
* - 支持敏感操作的特殊标记
|
||||
* - 实现日志的自动清理和归档
|
||||
*
|
||||
* 职责分离:
|
||||
* - 日志记录:记录操作的详细信息
|
||||
* - 日志查询:提供灵活的日志查询接口
|
||||
* - 日志统计:生成操作统计报告
|
||||
* - 日志管理:自动清理和归档功能
|
||||
*
|
||||
* 主要方法:
|
||||
* - createLog() - 创建操作日志记录
|
||||
* - queryLogs() - 查询操作日志
|
||||
* - getLogById() - 获取单个日志详情
|
||||
* - getStatistics() - 获取操作统计
|
||||
* - getSensitiveOperations() - 获取敏感操作日志
|
||||
* - getAdminOperationHistory() - 获取管理员操作历史
|
||||
* - cleanupExpiredLogs() - 清理过期日志
|
||||
*
|
||||
* 使用场景:
|
||||
* - 管理员操作审计
|
||||
* - 安全监控和异常检测
|
||||
* - 系统操作统计分析
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminOperationLogService {
|
||||
private readonly logger = new Logger(AdminOperationLogService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AdminOperationLog)
|
||||
private readonly logRepository: Repository<AdminOperationLog>,
|
||||
) {
|
||||
this.logger.log('AdminOperationLogService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建操作日志
|
||||
*
|
||||
* @param params 日志参数
|
||||
* @returns 创建的日志记录
|
||||
*/
|
||||
async createLog(params: CreateLogParams): Promise<AdminOperationLog> {
|
||||
try {
|
||||
const log = this.logRepository.create({
|
||||
admin_user_id: params.adminUserId,
|
||||
admin_username: params.adminUsername,
|
||||
operation_type: params.operationType,
|
||||
target_type: params.targetType,
|
||||
target_id: params.targetId,
|
||||
operation_description: params.operationDescription,
|
||||
http_method_path: params.httpMethodPath,
|
||||
request_params: params.requestParams,
|
||||
before_data: params.beforeData,
|
||||
after_data: params.afterData,
|
||||
operation_result: params.operationResult,
|
||||
error_message: params.errorMessage,
|
||||
error_code: params.errorCode,
|
||||
duration_ms: params.durationMs,
|
||||
client_ip: params.clientIp,
|
||||
user_agent: params.userAgent,
|
||||
request_id: params.requestId,
|
||||
context: params.context,
|
||||
is_sensitive: params.isSensitive || false,
|
||||
affected_records: params.affectedRecords || 0,
|
||||
batch_id: params.batchId,
|
||||
});
|
||||
|
||||
const savedLog = await this.logRepository.save(log);
|
||||
|
||||
this.logger.log('操作日志记录成功', {
|
||||
logId: savedLog.id,
|
||||
adminUserId: params.adminUserId,
|
||||
operationType: params.operationType,
|
||||
targetType: params.targetType,
|
||||
operationResult: params.operationResult
|
||||
});
|
||||
|
||||
return savedLog;
|
||||
} catch (error) {
|
||||
this.logger.error('操作日志记录失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
params
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建查询条件
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @param params 查询参数
|
||||
*/
|
||||
private buildQueryConditions(queryBuilder: any, params: LogQueryParams): void {
|
||||
if (params.adminUserId) {
|
||||
queryBuilder.andWhere('log.admin_user_id = :adminUserId', { adminUserId: params.adminUserId });
|
||||
}
|
||||
|
||||
if (params.operationType) {
|
||||
queryBuilder.andWhere('log.operation_type = :operationType', { operationType: params.operationType });
|
||||
}
|
||||
|
||||
if (params.targetType) {
|
||||
queryBuilder.andWhere('log.target_type = :targetType', { targetType: params.targetType });
|
||||
}
|
||||
|
||||
if (params.operationResult) {
|
||||
queryBuilder.andWhere('log.operation_result = :operationResult', { operationResult: params.operationResult });
|
||||
}
|
||||
|
||||
if (params.startDate && params.endDate) {
|
||||
queryBuilder.andWhere('log.created_at BETWEEN :startDate AND :endDate', {
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate
|
||||
});
|
||||
}
|
||||
|
||||
if (params.isSensitive !== undefined) {
|
||||
queryBuilder.andWhere('log.is_sensitive = :isSensitive', { isSensitive: params.isSensitive });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询操作日志
|
||||
*
|
||||
* @param params 查询参数
|
||||
* @returns 日志列表和总数
|
||||
*/
|
||||
async queryLogs(params: LogQueryParams): Promise<{ logs: AdminOperationLog[]; total: number }> {
|
||||
try {
|
||||
const queryBuilder = this.logRepository.createQueryBuilder('log');
|
||||
|
||||
// 构建查询条件
|
||||
this.buildQueryConditions(queryBuilder, params);
|
||||
|
||||
// 排序
|
||||
queryBuilder.orderBy('log.created_at', 'DESC');
|
||||
|
||||
// 分页
|
||||
const limit = params.limit || LOG_QUERY_LIMITS.DEFAULT_LOG_QUERY_LIMIT;
|
||||
const offset = params.offset || 0;
|
||||
queryBuilder.limit(limit).offset(offset);
|
||||
|
||||
const [logs, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
this.logger.log('操作日志查询成功', {
|
||||
total,
|
||||
returned: logs.length,
|
||||
params
|
||||
});
|
||||
|
||||
return { logs, total };
|
||||
} catch (error) {
|
||||
this.logger.error('操作日志查询失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
params
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取操作日志详情
|
||||
*
|
||||
* @param id 日志ID
|
||||
* @returns 日志详情
|
||||
*/
|
||||
async getLogById(id: string): Promise<AdminOperationLog | null> {
|
||||
try {
|
||||
const log = await this.logRepository.findOne({ where: { id } });
|
||||
|
||||
if (log) {
|
||||
this.logger.log('操作日志详情获取成功', { logId: id });
|
||||
} else {
|
||||
this.logger.warn('操作日志不存在', { logId: id });
|
||||
}
|
||||
|
||||
return log;
|
||||
} catch (error) {
|
||||
this.logger.error('操作日志详情获取失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
logId: id
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础统计数据
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 基础统计数据
|
||||
*/
|
||||
private async getBasicStatistics(queryBuilder: any): Promise<{
|
||||
totalOperations: number;
|
||||
successfulOperations: number;
|
||||
failedOperations: number;
|
||||
sensitiveOperations: number;
|
||||
}> {
|
||||
const totalOperations = await queryBuilder.getCount();
|
||||
|
||||
const successfulOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.operation_result = :result', { result: OPERATION_RESULTS.SUCCESS })
|
||||
.getCount();
|
||||
|
||||
const failedOperations = totalOperations - successfulOperations;
|
||||
|
||||
const sensitiveOperations = await queryBuilder
|
||||
.clone()
|
||||
.andWhere('log.is_sensitive = :sensitive', { sensitive: true })
|
||||
.getCount();
|
||||
|
||||
return {
|
||||
totalOperations,
|
||||
successfulOperations,
|
||||
failedOperations,
|
||||
sensitiveOperations
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作类型统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 操作类型统计
|
||||
*/
|
||||
private async getOperationTypeStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const operationTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.operation_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.operation_type')
|
||||
.getRawMany();
|
||||
|
||||
return operationTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取目标类型统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 目标类型统计
|
||||
*/
|
||||
private async getTargetTypeStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const targetTypeStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.target_type', 'type')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.target_type')
|
||||
.getRawMany();
|
||||
|
||||
return targetTypeStats.reduce((acc, stat) => {
|
||||
acc[stat.type] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 管理员统计
|
||||
*/
|
||||
private async getAdminStatistics(queryBuilder: any): Promise<Record<string, number>> {
|
||||
const adminStats = await queryBuilder
|
||||
.clone()
|
||||
.select('log.admin_user_id', 'admin')
|
||||
.addSelect('COUNT(*)', 'count')
|
||||
.groupBy('log.admin_user_id')
|
||||
.getRawMany();
|
||||
|
||||
if (!adminStats || !Array.isArray(adminStats)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return adminStats.reduce((acc, stat) => {
|
||||
acc[stat.admin] = parseInt(stat.count);
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能统计
|
||||
*
|
||||
* @param queryBuilder 查询构建器
|
||||
* @returns 性能统计
|
||||
*/
|
||||
private async getPerformanceStatistics(queryBuilder: any): Promise<{
|
||||
averageDuration: number;
|
||||
uniqueAdmins: number;
|
||||
}> {
|
||||
// 平均耗时
|
||||
const avgDurationResult = await queryBuilder
|
||||
.clone()
|
||||
.select('AVG(log.duration_ms)', 'avgDuration')
|
||||
.getRawOne();
|
||||
|
||||
const averageDuration = parseFloat(avgDurationResult?.avgDuration || '0');
|
||||
|
||||
// 唯一管理员数量
|
||||
const uniqueAdminsResult = await queryBuilder
|
||||
.clone()
|
||||
.select('COUNT(DISTINCT log.admin_user_id)', 'uniqueAdmins')
|
||||
.getRawOne();
|
||||
|
||||
const uniqueAdmins = parseInt(uniqueAdminsResult?.uniqueAdmins || '0');
|
||||
|
||||
return { averageDuration, uniqueAdmins };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作统计信息
|
||||
*
|
||||
* @param startDate 开始日期
|
||||
* @param endDate 结束日期
|
||||
* @returns 统计信息
|
||||
*/
|
||||
async getStatistics(startDate?: Date, endDate?: Date): Promise<LogStatistics> {
|
||||
try {
|
||||
const queryBuilder = this.logRepository.createQueryBuilder('log');
|
||||
|
||||
if (startDate && endDate) {
|
||||
queryBuilder.where('log.created_at BETWEEN :startDate AND :endDate', {
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
}
|
||||
|
||||
// 获取各类统计数据
|
||||
const basicStats = await this.getBasicStatistics(queryBuilder);
|
||||
const operationsByType = await this.getOperationTypeStatistics(queryBuilder);
|
||||
const operationsByTarget = await this.getTargetTypeStatistics(queryBuilder);
|
||||
const operationsByAdmin = await this.getAdminStatistics(queryBuilder);
|
||||
const performanceStats = await this.getPerformanceStatistics(queryBuilder);
|
||||
|
||||
const statistics: LogStatistics = {
|
||||
...basicStats,
|
||||
operationsByType,
|
||||
operationsByTarget,
|
||||
operationsByAdmin,
|
||||
...performanceStats
|
||||
};
|
||||
|
||||
this.logger.log('操作统计获取成功', statistics);
|
||||
|
||||
return statistics;
|
||||
} catch (error) {
|
||||
this.logger.error('操作统计获取失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
startDate,
|
||||
endDate
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期日志
|
||||
*
|
||||
* @param daysToKeep 保留天数
|
||||
* @returns 清理的记录数
|
||||
*/
|
||||
async cleanupExpiredLogs(daysToKeep: number = LOG_RETENTION.DEFAULT_DAYS): Promise<number> {
|
||||
try {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
const result = await this.logRepository
|
||||
.createQueryBuilder()
|
||||
.delete()
|
||||
.where('created_at < :cutoffDate', { cutoffDate })
|
||||
.andWhere('is_sensitive = :sensitive', { sensitive: false }) // 保留敏感操作日志
|
||||
.execute();
|
||||
|
||||
const deletedCount = result.affected || 0;
|
||||
|
||||
this.logger.log('过期日志清理完成', {
|
||||
deletedCount,
|
||||
cutoffDate,
|
||||
daysToKeep
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
this.logger.error('过期日志清理失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
daysToKeep
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取管理员操作历史
|
||||
*
|
||||
* @param adminUserId 管理员用户ID
|
||||
* @param limit 限制数量
|
||||
* @returns 操作历史
|
||||
*/
|
||||
async getAdminOperationHistory(adminUserId: string, limit: number = USER_QUERY_LIMITS.ADMIN_HISTORY_DEFAULT_LIMIT): Promise<AdminOperationLog[]> {
|
||||
try {
|
||||
const logs = await this.logRepository.find({
|
||||
where: { admin_user_id: adminUserId },
|
||||
order: { created_at: 'DESC' },
|
||||
take: limit
|
||||
});
|
||||
|
||||
this.logger.log('管理员操作历史获取成功', {
|
||||
adminUserId,
|
||||
count: logs.length
|
||||
});
|
||||
|
||||
return logs;
|
||||
} catch (error) {
|
||||
this.logger.error('管理员操作历史获取失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
adminUserId
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取敏感操作日志
|
||||
*
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @returns 敏感操作日志
|
||||
*/
|
||||
async getSensitiveOperations(limit: number = LOG_QUERY_LIMITS.SENSITIVE_LOG_DEFAULT_LIMIT, offset: number = 0): Promise<{ logs: AdminOperationLog[]; total: number }> {
|
||||
try {
|
||||
const [logs, total] = await this.logRepository.findAndCount({
|
||||
where: { is_sensitive: true },
|
||||
order: { created_at: 'DESC' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
});
|
||||
|
||||
this.logger.log('敏感操作日志获取成功', {
|
||||
total,
|
||||
returned: logs.length
|
||||
});
|
||||
|
||||
return { logs, total };
|
||||
} catch (error) {
|
||||
this.logger.error('敏感操作日志获取失败', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/business/admin/admin_operation_log_memory.service.ts
Normal file
152
src/business/admin/admin_operation_log_memory.service.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { AdminOperationLog } from './admin_operation_log.entity';
|
||||
import {
|
||||
CreateLogParams,
|
||||
LogQueryParams,
|
||||
LogStatistics,
|
||||
} from './admin_operation_log.service';
|
||||
import {
|
||||
LOG_QUERY_LIMITS,
|
||||
USER_QUERY_LIMITS,
|
||||
LOG_RETENTION,
|
||||
OPERATION_RESULTS,
|
||||
} from './admin_constants';
|
||||
|
||||
@Injectable()
|
||||
export class AdminOperationLogMemoryService {
|
||||
private readonly logger = new Logger(AdminOperationLogMemoryService.name);
|
||||
private readonly logs: AdminOperationLog[] = [];
|
||||
|
||||
async createLog(params: CreateLogParams): Promise<AdminOperationLog> {
|
||||
const log = Object.assign(new AdminOperationLog(), {
|
||||
id: randomUUID(),
|
||||
admin_user_id: params.adminUserId,
|
||||
admin_username: params.adminUsername,
|
||||
operation_type: params.operationType,
|
||||
target_type: params.targetType,
|
||||
target_id: params.targetId,
|
||||
operation_description: params.operationDescription,
|
||||
http_method_path: params.httpMethodPath,
|
||||
request_params: params.requestParams,
|
||||
before_data: params.beforeData,
|
||||
after_data: params.afterData,
|
||||
operation_result: params.operationResult,
|
||||
error_message: params.errorMessage,
|
||||
error_code: params.errorCode,
|
||||
duration_ms: params.durationMs,
|
||||
client_ip: params.clientIp,
|
||||
user_agent: params.userAgent,
|
||||
request_id: params.requestId,
|
||||
context: params.context,
|
||||
is_sensitive: params.isSensitive || false,
|
||||
affected_records: params.affectedRecords || 0,
|
||||
batch_id: params.batchId,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
this.logs.push(log);
|
||||
this.logger.debug('内存操作日志记录成功', { logId: log.id });
|
||||
return log;
|
||||
}
|
||||
|
||||
async queryLogs(params: LogQueryParams): Promise<{ logs: AdminOperationLog[]; total: number }> {
|
||||
const filteredLogs = this.filterLogs(params).sort(
|
||||
(a, b) => b.created_at.getTime() - a.created_at.getTime(),
|
||||
);
|
||||
const limit = params.limit || LOG_QUERY_LIMITS.DEFAULT_LOG_QUERY_LIMIT;
|
||||
const offset = params.offset || 0;
|
||||
|
||||
return {
|
||||
logs: filteredLogs.slice(offset, offset + limit),
|
||||
total: filteredLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogById(id: string): Promise<AdminOperationLog | null> {
|
||||
return this.logs.find(log => log.id === id) || null;
|
||||
}
|
||||
|
||||
async getStatistics(startDate?: Date, endDate?: Date): Promise<LogStatistics> {
|
||||
const logs = this.filterLogs({ startDate, endDate });
|
||||
const successfulOperations = logs.filter(
|
||||
log => log.operation_result === OPERATION_RESULTS.SUCCESS,
|
||||
).length;
|
||||
const totalDuration = logs.reduce((sum, log) => sum + (log.duration_ms || 0), 0);
|
||||
|
||||
return {
|
||||
totalOperations: logs.length,
|
||||
successfulOperations,
|
||||
failedOperations: logs.length - successfulOperations,
|
||||
operationsByType: this.countBy(logs, log => String(log.operation_type)),
|
||||
operationsByTarget: this.countBy(logs, log => log.target_type),
|
||||
operationsByAdmin: this.countBy(logs, log => log.admin_user_id),
|
||||
averageDuration: logs.length > 0 ? totalDuration / logs.length : 0,
|
||||
sensitiveOperations: logs.filter(log => log.is_sensitive).length,
|
||||
uniqueAdmins: new Set(logs.map(log => log.admin_user_id)).size,
|
||||
};
|
||||
}
|
||||
|
||||
async cleanupExpiredLogs(daysToKeep: number = LOG_RETENTION.DEFAULT_DAYS): Promise<number> {
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - daysToKeep);
|
||||
|
||||
const originalCount = this.logs.length;
|
||||
for (let index = this.logs.length - 1; index >= 0; index--) {
|
||||
const log = this.logs[index];
|
||||
if (!log.is_sensitive && log.created_at < cutoffDate) {
|
||||
this.logs.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
return originalCount - this.logs.length;
|
||||
}
|
||||
|
||||
async getAdminOperationHistory(
|
||||
adminUserId: string,
|
||||
limit: number = USER_QUERY_LIMITS.ADMIN_HISTORY_DEFAULT_LIMIT,
|
||||
): Promise<AdminOperationLog[]> {
|
||||
return this.logs
|
||||
.filter(log => log.admin_user_id === adminUserId)
|
||||
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getSensitiveOperations(
|
||||
limit: number = LOG_QUERY_LIMITS.SENSITIVE_LOG_DEFAULT_LIMIT,
|
||||
offset: number = 0,
|
||||
): Promise<{ logs: AdminOperationLog[]; total: number }> {
|
||||
const sensitiveLogs = this.logs
|
||||
.filter(log => log.is_sensitive)
|
||||
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
|
||||
|
||||
return {
|
||||
logs: sensitiveLogs.slice(offset, offset + limit),
|
||||
total: sensitiveLogs.length,
|
||||
};
|
||||
}
|
||||
|
||||
private filterLogs(params: LogQueryParams): AdminOperationLog[] {
|
||||
return this.logs.filter(log => {
|
||||
if (params.adminUserId && log.admin_user_id !== params.adminUserId) return false;
|
||||
if (params.operationType && log.operation_type !== params.operationType) return false;
|
||||
if (params.targetType && log.target_type !== params.targetType) return false;
|
||||
if (params.operationResult && log.operation_result !== params.operationResult) return false;
|
||||
if (params.isSensitive !== undefined && log.is_sensitive !== params.isSensitive) return false;
|
||||
if (params.startDate && log.created_at < params.startDate) return false;
|
||||
if (params.endDate && log.created_at > params.endDate) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private countBy(
|
||||
logs: AdminOperationLog[],
|
||||
getKey: (log: AdminOperationLog) => string,
|
||||
): Record<string, number> {
|
||||
return logs.reduce((acc, log) => {
|
||||
const key = getKey(log) || 'unknown';
|
||||
acc[key] = (acc[key] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
}
|
||||
}
|
||||
166
src/business/admin/admin_response.dto.ts
Normal file
166
src/business/admin/admin_response.dto.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 管理员响应 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义管理员相关接口的响应格式
|
||||
* - 提供统一的API响应结构
|
||||
* - 支持Swagger文档自动生成
|
||||
*
|
||||
* 职责分离:
|
||||
* - 响应数据结构定义
|
||||
* - API文档生成支持
|
||||
* - 类型安全保障
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 为所有DTO类添加类注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 文件夹扁平化 - 从dto/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2025-12-19
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 管理员登录响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员登录接口的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - POST /admin/auth/login 接口的响应体
|
||||
* - 包含登录状态、Token和管理员基本信息
|
||||
*/
|
||||
export class AdminLoginResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '登录成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: 'JWT Token', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' })
|
||||
token?: string;
|
||||
|
||||
@ApiProperty({ description: '管理员信息', required: false })
|
||||
admin?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员用户列表响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义获取用户列表接口的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/users 接口的响应体
|
||||
* - 包含用户列表和分页信息
|
||||
*/
|
||||
export class AdminUsersResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取用户列表成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '用户列表', type: 'array' })
|
||||
users?: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
|
||||
@ApiProperty({ description: '总数', example: 100 })
|
||||
total?: number;
|
||||
|
||||
@ApiProperty({ description: '偏移量', example: 0 })
|
||||
offset?: number;
|
||||
|
||||
@ApiProperty({ description: '限制数量', example: 100 })
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员用户详情响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义获取单个用户详情接口的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/users/:id 接口的响应体
|
||||
* - 包含用户的详细信息
|
||||
*/
|
||||
export class AdminUserResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取用户详情成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '用户信息', required: false })
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
role: number;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_login_at?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员通用响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员操作的通用响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - 各种管理员操作接口的通用响应体
|
||||
* - 包含操作状态和消息信息
|
||||
*/
|
||||
export class AdminCommonResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '操作成功' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员运行日志响应DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义获取系统运行日志接口的响应数据结构
|
||||
*
|
||||
* 使用场景:
|
||||
* - GET /admin/logs/runtime 接口的响应体
|
||||
* - 包含系统运行日志内容
|
||||
*/
|
||||
export class AdminRuntimeLogsResponseDto {
|
||||
@ApiProperty({ description: '是否成功', example: true })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: '消息', example: '获取日志成功' })
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: '日志内容', type: 'array', items: { type: 'string' } })
|
||||
logs?: string[];
|
||||
|
||||
@ApiProperty({ description: '返回行数', example: 200 })
|
||||
lines?: number;
|
||||
}
|
||||
316
src/business/admin/admin_utils.ts
Normal file
316
src/business/admin/admin_utils.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* 管理员模块工具函数
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供管理员模块通用的工具函数
|
||||
* - 消除重复代码,提高代码复用性
|
||||
* - 统一处理常见的业务逻辑
|
||||
*
|
||||
* 职责分离:
|
||||
* - 工具函数集中管理
|
||||
* - 重复逻辑抽象
|
||||
* - 通用功能封装
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 重构 - 文件夹扁平化,移动到上级目录并更新import路径 (修改者: moyin)
|
||||
* - 2026-01-08: 代码质量优化 - 提取魔法数字为常量,添加用户格式化工具和操作监控工具 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员模块工具函数 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.3.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { PAGINATION_LIMITS, REQUEST_ID_PREFIXES, SENSITIVE_FIELDS } from './admin_constants';
|
||||
|
||||
/**
|
||||
* 请求ID生成常量
|
||||
*/
|
||||
const REQUEST_ID_RANDOM_LENGTH = 9; // 随机字符串长度
|
||||
const REQUEST_ID_RANDOM_START = 2; // 跳过'0.'前缀
|
||||
|
||||
/**
|
||||
* 安全限制查询数量
|
||||
*
|
||||
* @param limit 请求的限制数量
|
||||
* @param maxLimit 最大允许的限制数量
|
||||
* @returns 安全的限制数量
|
||||
*/
|
||||
export function safeLimitValue(limit: number, maxLimit: number): number {
|
||||
return Math.min(Math.max(limit, 1), maxLimit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全限制偏移量
|
||||
*
|
||||
* @param offset 请求的偏移量
|
||||
* @returns 安全的偏移量(不小于0)
|
||||
*/
|
||||
export function safeOffsetValue(offset: number): number {
|
||||
return Math.max(offset, PAGINATION_LIMITS.DEFAULT_OFFSET);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的请求ID
|
||||
*
|
||||
* @param prefix 请求ID前缀
|
||||
* @returns 唯一的请求ID
|
||||
*/
|
||||
export function generateRequestId(prefix: string = REQUEST_ID_PREFIXES.GENERAL): string {
|
||||
return `${prefix}_${Date.now()}_${Math.random().toString(36).substring(REQUEST_ID_RANDOM_START, REQUEST_ID_RANDOM_START + REQUEST_ID_RANDOM_LENGTH)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前时间戳字符串
|
||||
*
|
||||
* @returns ISO格式的时间戳字符串
|
||||
*/
|
||||
export function getCurrentTimestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理请求体中的敏感信息
|
||||
*
|
||||
* @param body 请求体对象
|
||||
* @returns 清理后的请求体
|
||||
*/
|
||||
export function sanitizeRequestBody(body: any): any {
|
||||
if (!body || typeof body !== 'object') {
|
||||
return body;
|
||||
}
|
||||
|
||||
const sanitized = { ...body };
|
||||
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
if (sanitized[field]) {
|
||||
sanitized[field] = '***REDACTED***';
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取客户端IP地址
|
||||
*
|
||||
* @param request 请求对象
|
||||
* @returns 客户端IP地址
|
||||
*/
|
||||
export function extractClientIp(request: any): string {
|
||||
return request.ip ||
|
||||
request.connection?.remoteAddress ||
|
||||
request.socket?.remoteAddress ||
|
||||
(request.connection?.socket as any)?.remoteAddress ||
|
||||
request.headers['x-forwarded-for']?.split(',')[0] ||
|
||||
request.headers['x-real-ip'] ||
|
||||
'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的成功响应
|
||||
*
|
||||
* @param data 响应数据
|
||||
* @param message 响应消息
|
||||
* @param requestIdPrefix 请求ID前缀
|
||||
* @returns 标准格式的成功响应
|
||||
*/
|
||||
export function createSuccessResponse<T>(
|
||||
data: T,
|
||||
message: string,
|
||||
requestIdPrefix?: string
|
||||
): {
|
||||
success: true;
|
||||
data: T;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
request_id: string;
|
||||
} {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId(requestIdPrefix)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的错误响应
|
||||
*
|
||||
* @param message 错误消息
|
||||
* @param errorCode 错误码
|
||||
* @param requestIdPrefix 请求ID前缀
|
||||
* @returns 标准格式的错误响应
|
||||
*/
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
errorCode?: string,
|
||||
requestIdPrefix?: string
|
||||
): {
|
||||
success: false;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
timestamp: string;
|
||||
request_id: string;
|
||||
} {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
error_code: errorCode,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId(requestIdPrefix)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建标准的列表响应
|
||||
*
|
||||
* @param items 列表项
|
||||
* @param total 总数
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @param message 响应消息
|
||||
* @param requestIdPrefix 请求ID前缀
|
||||
* @returns 标准格式的列表响应
|
||||
*/
|
||||
export function createListResponse<T>(
|
||||
items: T[],
|
||||
total: number,
|
||||
limit: number,
|
||||
offset: number,
|
||||
message: string,
|
||||
requestIdPrefix?: string
|
||||
): {
|
||||
success: true;
|
||||
data: {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
message: string;
|
||||
timestamp: string;
|
||||
request_id: string;
|
||||
} {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
has_more: offset + items.length < total
|
||||
},
|
||||
message,
|
||||
timestamp: getCurrentTimestamp(),
|
||||
request_id: generateRequestId(requestIdPrefix)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制保留天数在合理范围内
|
||||
*
|
||||
* @param daysToKeep 请求的保留天数
|
||||
* @param minDays 最少保留天数
|
||||
* @param maxDays 最多保留天数
|
||||
* @returns 安全的保留天数
|
||||
*/
|
||||
export function safeDaysToKeep(daysToKeep: number, minDays: number, maxDays: number): number {
|
||||
return Math.max(minDays, Math.min(daysToKeep, maxDays));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户数据格式化工具
|
||||
*/
|
||||
export class UserFormatter {
|
||||
/**
|
||||
* 格式化用户基本信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户信息
|
||||
*/
|
||||
static formatBasicUser(user: any) {
|
||||
return {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
role: user.role,
|
||||
status: user.status,
|
||||
email_verified: user.email_verified,
|
||||
avatar_url: user.avatar_url,
|
||||
created_at: user.created_at,
|
||||
updated_at: user.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户详细信息(包含GitHub ID)
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户详细信息
|
||||
*/
|
||||
static formatDetailedUser(user: any) {
|
||||
return {
|
||||
...this.formatBasicUser(user),
|
||||
github_id: user.github_id
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作性能监控工具
|
||||
*/
|
||||
export class OperationMonitor {
|
||||
/**
|
||||
* 执行带性能监控的操作
|
||||
*
|
||||
* @param operationName 操作名称
|
||||
* @param context 操作上下文
|
||||
* @param operation 要执行的操作
|
||||
* @param logger 日志记录器
|
||||
* @returns 操作结果
|
||||
*/
|
||||
static async executeWithMonitoring<T>(
|
||||
operationName: string,
|
||||
context: Record<string, any>,
|
||||
operation: () => Promise<T>,
|
||||
logger: (level: 'log' | 'warn' | 'error', message: string, context: Record<string, any>) => void
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
|
||||
logger('log', `开始${operationName}`, {
|
||||
operation: operationName,
|
||||
...context
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await operation();
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger('log', `${operationName}成功`, {
|
||||
operation: operationName,
|
||||
duration,
|
||||
...context
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
logger('error', `${operationName}失败`, {
|
||||
operation: operationName,
|
||||
duration,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...context
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
706
src/business/admin/database_management.service.ts
Normal file
706
src/business/admin/database_management.service.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
/**
|
||||
* 数据库管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供统一的数据库管理接口,集成所有数据库服务的CRUD操作
|
||||
* - 实现管理员专用的数据库操作功能
|
||||
* - 提供统一的响应格式和错误处理
|
||||
* - 支持操作日志记录和审计功能
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务逻辑编排:协调各个数据库服务的操作
|
||||
* - 数据转换:DTO与实体之间的转换
|
||||
* - 权限控制:确保只有管理员可以执行操作
|
||||
* - 日志记录:记录所有数据库操作的详细日志
|
||||
*
|
||||
* 集成的服务:
|
||||
* - UsersService: 用户数据管理
|
||||
* - UserProfilesService: 用户档案管理
|
||||
* - ZulipAccountsService: Zulip账号关联管理
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: Bug修复 - 修复类型错误,正确处理skin_id类型转换和Zulip账号查询参数 (修改者: moyin)
|
||||
* - 2026-01-09: 功能实现 - 实现所有TODO项,完成UserProfiles和ZulipAccounts的CRUD操作 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 替换any类型为具体的DTO类型,提高类型安全性 (修改者: moyin)
|
||||
* - 2026-01-09: 代码质量优化 - 统一使用admin_utils中的响应创建函数,消除重复代码 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 完善方法注释,添加@param、@returns、@throws和@example (修改者: moyin)
|
||||
* - 2026-01-08: 代码规范优化 - 将魔法数字20提取为常量DEFAULT_PAGE_SIZE (修改者: moyin)
|
||||
* - 2026-01-08: 代码质量优化 - 提取用户格式化逻辑,补充缺失方法实现,使用操作监控工具 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建数据库管理服务,支持管理员数据库操作 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.6.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException, BadRequestException, ConflictException, Inject, Optional } from '@nestjs/common';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { UserProfilesService } from '../../core/db/user_profiles/user_profiles.service';
|
||||
import { UserProfiles } from '../../core/db/user_profiles/user_profiles.entity';
|
||||
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
|
||||
import { ZulipAccountResponseDto } from '../../core/db/zulip_accounts/zulip_accounts.dto';
|
||||
import { UserSocialCleanupService } from '../../core/session_core/user_social_cleanup.service';
|
||||
import { getCurrentTimestamp, UserFormatter, OperationMonitor, createSuccessResponse, createErrorResponse, createListResponse } from './admin_utils';
|
||||
import {
|
||||
AdminCreateUserDto,
|
||||
AdminUpdateUserDto,
|
||||
AdminCreateUserProfileDto,
|
||||
AdminUpdateUserProfileDto,
|
||||
AdminCreateZulipAccountDto,
|
||||
AdminUpdateZulipAccountDto
|
||||
} from './admin_database.dto';
|
||||
|
||||
/**
|
||||
* 常量定义
|
||||
*/
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* 管理员API统一响应格式
|
||||
*/
|
||||
export interface AdminApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message: string;
|
||||
error_code?: string;
|
||||
timestamp?: string;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员列表响应格式
|
||||
*/
|
||||
export interface AdminListResponse<T = any> {
|
||||
success: boolean;
|
||||
data: {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
};
|
||||
message: string;
|
||||
error_code?: string;
|
||||
timestamp?: string;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseManagementService {
|
||||
private readonly logger = new Logger(DatabaseManagementService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('UsersService') private readonly usersService: UsersService,
|
||||
@Inject('IUserProfilesService') private readonly userProfilesService: UserProfilesService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: any,
|
||||
@Optional() private readonly userSocialCleanupService?: UserSocialCleanupService,
|
||||
) {
|
||||
this.logger.log('DatabaseManagementService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录操作日志
|
||||
*
|
||||
* @param level 日志级别
|
||||
* @param message 日志消息
|
||||
* @param context 日志上下文
|
||||
*/
|
||||
private logOperation(level: 'log' | 'warn' | 'error', message: string, context: Record<string, any>): void {
|
||||
this.logger[level](message, {
|
||||
...context,
|
||||
timestamp: getCurrentTimestamp()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理服务异常
|
||||
*
|
||||
* @param error 异常对象
|
||||
* @param operation 操作名称
|
||||
* @param context 操作上下文
|
||||
* @returns 错误响应
|
||||
*/
|
||||
private handleServiceError(error: any, operation: string, context: Record<string, any>): AdminApiResponse {
|
||||
this.logOperation('error', `${operation}失败`, {
|
||||
operation,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context
|
||||
});
|
||||
|
||||
if (error instanceof NotFoundException) {
|
||||
return createErrorResponse(error.message, 'RESOURCE_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (error instanceof ConflictException) {
|
||||
return createErrorResponse(error.message, 'RESOURCE_CONFLICT');
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestException) {
|
||||
return createErrorResponse(error.message, 'INVALID_REQUEST');
|
||||
}
|
||||
|
||||
return createErrorResponse(`${operation}失败,请稍后重试`, 'INTERNAL_ERROR');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理列表查询异常
|
||||
*
|
||||
* @param error 异常对象
|
||||
* @param operation 操作名称
|
||||
* @param context 操作上下文
|
||||
* @returns 空列表响应
|
||||
*/
|
||||
private handleListError(error: any, operation: string, context: Record<string, any>): AdminListResponse {
|
||||
this.logOperation('error', `${operation}失败`, {
|
||||
operation,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
context
|
||||
});
|
||||
|
||||
return createListResponse([], 0, context.limit || DEFAULT_PAGE_SIZE, context.offset || 0, `${operation}失败,返回空列表`);
|
||||
}
|
||||
|
||||
// ==================== 用户管理方法 ====================
|
||||
|
||||
/**
|
||||
* 获取用户列表
|
||||
*
|
||||
* 功能描述:
|
||||
* 分页获取系统中的用户列表,支持限制数量和偏移量参数
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 记录操作开始时间和参数
|
||||
* 2. 调用用户服务获取用户数据和总数
|
||||
* 3. 格式化用户信息,隐藏敏感字段
|
||||
* 4. 记录操作成功日志和性能数据
|
||||
* 5. 返回标准化的列表响应
|
||||
*
|
||||
* @param limit 限制数量,默认20,最大100
|
||||
* @param offset 偏移量,默认0,用于分页
|
||||
* @returns 包含用户列表、总数和分页信息的响应对象
|
||||
*
|
||||
* @throws NotFoundException 当查询条件无效时
|
||||
* @throws InternalServerErrorException 当数据库操作失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.getUserList(20, 0);
|
||||
* console.log(result.data.items.length); // 用户数量
|
||||
* console.log(result.data.total); // 总用户数
|
||||
* ```
|
||||
*/
|
||||
async getUserList(limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户列表',
|
||||
{ limit, offset },
|
||||
async () => {
|
||||
const users = await this.usersService.findAll(limit, offset);
|
||||
const total = await this.usersService.count();
|
||||
const formattedUsers = users.map(user => UserFormatter.formatBasicUser(user));
|
||||
return createListResponse(formattedUsers, total, limit, offset, '用户列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取用户列表', { limit, offset }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取用户详情
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据用户ID获取指定用户的详细信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 记录操作开始时间和用户ID
|
||||
* 2. 调用用户服务查询用户信息
|
||||
* 3. 格式化用户详细信息
|
||||
* 4. 记录操作成功日志和性能数据
|
||||
* 5. 返回标准化的详情响应
|
||||
*
|
||||
* @param id 用户ID,必须是有效的bigint类型
|
||||
* @returns 包含用户详细信息的响应对象
|
||||
*
|
||||
* @throws NotFoundException 当用户不存在时
|
||||
* @throws BadRequestException 当用户ID格式无效时
|
||||
* @throws InternalServerErrorException 当数据库操作失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.getUserById(BigInt(123));
|
||||
* console.log(result.data.username); // 用户名
|
||||
* console.log(result.data.email); // 邮箱
|
||||
* ```
|
||||
*/
|
||||
async getUserById(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户详情',
|
||||
{ userId: id.toString() },
|
||||
async () => {
|
||||
const user = await this.usersService.findOne(id);
|
||||
const formattedUser = UserFormatter.formatDetailedUser(user);
|
||||
return createSuccessResponse(formattedUser, '用户详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取用户详情', { userId: id.toString() }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索用户
|
||||
*
|
||||
* 功能描述:
|
||||
* 根据关键词搜索用户,支持用户名、邮箱、昵称等字段的模糊匹配
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 记录搜索操作开始时间和关键词
|
||||
* 2. 调用用户服务执行搜索查询
|
||||
* 3. 格式化搜索结果
|
||||
* 4. 记录搜索成功日志和性能数据
|
||||
* 5. 返回标准化的搜索响应
|
||||
*
|
||||
* @param keyword 搜索关键词,支持用户名、邮箱、昵称的模糊匹配
|
||||
* @param limit 返回结果数量限制,默认20,最大50
|
||||
* @returns 包含搜索结果的响应对象
|
||||
*
|
||||
* @throws BadRequestException 当关键词为空或格式无效时
|
||||
* @throws InternalServerErrorException 当搜索操作失败时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await service.searchUsers('admin', 10);
|
||||
* console.log(result.data.items); // 搜索结果列表
|
||||
* ```
|
||||
*/
|
||||
async searchUsers(keyword: string, limit: number = DEFAULT_PAGE_SIZE): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'搜索用户',
|
||||
{ keyword, limit },
|
||||
async () => {
|
||||
const users = await this.usersService.search(keyword, limit);
|
||||
const formattedUsers = users.map(user => UserFormatter.formatBasicUser(user));
|
||||
return createListResponse(formattedUsers, users.length, limit, 0, '用户搜索成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '搜索用户', { keyword, limit }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户
|
||||
*
|
||||
* @param userData 用户数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createUser(userData: AdminCreateUserDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建用户',
|
||||
{ username: userData.username },
|
||||
async () => {
|
||||
const newUser = await this.usersService.create(userData);
|
||||
await this.userSocialCleanupService?.clearUserSocialData(newUser.id.toString());
|
||||
const formattedUser = UserFormatter.formatBasicUser(newUser);
|
||||
return createSuccessResponse(formattedUser, '用户创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建用户', { username: userData.username }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @param updateData 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateUser(id: bigint, updateData: AdminUpdateUserDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新用户',
|
||||
{ userId: id.toString(), updateFields: Object.keys(updateData) },
|
||||
async () => {
|
||||
const updatedUser = await this.usersService.update(id, updateData);
|
||||
const formattedUser = UserFormatter.formatBasicUser(updatedUser);
|
||||
return createSuccessResponse(formattedUser, '用户更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新用户', { userId: id.toString(), updateData }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户
|
||||
*
|
||||
* @param id 用户ID
|
||||
* @returns 删除结果响应
|
||||
*/
|
||||
async deleteUser(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'删除用户',
|
||||
{ userId: id.toString() },
|
||||
async () => {
|
||||
await this.userSocialCleanupService?.clearUserSocialData(id.toString());
|
||||
await this.usersService.remove(id);
|
||||
return createSuccessResponse({ deleted: true, id: id.toString() }, '用户删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除用户', { userId: id.toString() }));
|
||||
}
|
||||
|
||||
// ==================== 用户档案管理方法 ====================
|
||||
|
||||
/**
|
||||
* 获取用户档案列表
|
||||
*
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @returns 用户档案列表响应
|
||||
*/
|
||||
async getUserProfileList(limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户档案列表',
|
||||
{ limit, offset },
|
||||
async () => {
|
||||
const profiles = await this.userProfilesService.findAll({ limit, offset });
|
||||
const total = await this.userProfilesService.count();
|
||||
const formattedProfiles = profiles.map(profile => this.formatUserProfile(profile));
|
||||
return createListResponse(formattedProfiles, total, limit, offset, '用户档案列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取用户档案列表', { limit, offset }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取用户档案详情
|
||||
*
|
||||
* @param id 档案ID
|
||||
* @returns 用户档案详情响应
|
||||
*/
|
||||
async getUserProfileById(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取用户档案详情',
|
||||
{ profileId: id.toString() },
|
||||
async () => {
|
||||
const profile = await this.userProfilesService.findOne(id);
|
||||
const formattedProfile = this.formatUserProfile(profile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取用户档案详情', { profileId: id.toString() }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据地图获取用户档案
|
||||
*
|
||||
* @param mapId 地图ID
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @returns 用户档案列表响应
|
||||
*/
|
||||
async getUserProfilesByMap(mapId: string, limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'根据地图获取用户档案',
|
||||
{ mapId, limit, offset },
|
||||
async () => {
|
||||
const profiles = await this.userProfilesService.findByMap(mapId, undefined, limit, offset);
|
||||
const total = await this.userProfilesService.count();
|
||||
const formattedProfiles = profiles.map(profile => this.formatUserProfile(profile));
|
||||
return createListResponse(formattedProfiles, total, limit, offset, `地图 ${mapId} 的用户档案列表获取成功`);
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '根据地图获取用户档案', { mapId, limit, offset }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建用户档案
|
||||
*
|
||||
* @param createProfileDto 创建数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createUserProfile(createProfileDto: AdminCreateUserProfileDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建用户档案',
|
||||
{ userId: createProfileDto.user_id },
|
||||
async () => {
|
||||
const profileData = {
|
||||
user_id: BigInt(createProfileDto.user_id),
|
||||
bio: createProfileDto.bio,
|
||||
resume_content: createProfileDto.resume_content,
|
||||
tags: createProfileDto.tags ? JSON.parse(createProfileDto.tags) : undefined,
|
||||
social_links: createProfileDto.social_links ? JSON.parse(createProfileDto.social_links) : undefined,
|
||||
skin_id: createProfileDto.skin_id,
|
||||
current_map: createProfileDto.current_map,
|
||||
pos_x: createProfileDto.pos_x,
|
||||
pos_y: createProfileDto.pos_y,
|
||||
status: createProfileDto.status
|
||||
};
|
||||
|
||||
const newProfile = await this.userProfilesService.create(profileData);
|
||||
const formattedProfile = this.formatUserProfile(newProfile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建用户档案', { userId: createProfileDto.user_id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户档案
|
||||
*
|
||||
* @param id 档案ID
|
||||
* @param updateProfileDto 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateUserProfile(id: bigint, updateProfileDto: AdminUpdateUserProfileDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新用户档案',
|
||||
{ profileId: id.toString(), updateFields: Object.keys(updateProfileDto) },
|
||||
async () => {
|
||||
// 转换AdminUpdateUserProfileDto为UpdateUserProfileDto
|
||||
const updateData: any = {};
|
||||
|
||||
if (updateProfileDto.bio !== undefined) {
|
||||
updateData.bio = updateProfileDto.bio;
|
||||
}
|
||||
if (updateProfileDto.resume_content !== undefined) {
|
||||
updateData.resume_content = updateProfileDto.resume_content;
|
||||
}
|
||||
if (updateProfileDto.tags !== undefined) {
|
||||
updateData.tags = JSON.parse(updateProfileDto.tags);
|
||||
}
|
||||
if (updateProfileDto.social_links !== undefined) {
|
||||
updateData.social_links = JSON.parse(updateProfileDto.social_links);
|
||||
}
|
||||
if (updateProfileDto.skin_id !== undefined) {
|
||||
updateData.skin_id = updateProfileDto.skin_id;
|
||||
}
|
||||
if (updateProfileDto.current_map !== undefined) {
|
||||
updateData.current_map = updateProfileDto.current_map;
|
||||
}
|
||||
if (updateProfileDto.pos_x !== undefined) {
|
||||
updateData.pos_x = updateProfileDto.pos_x;
|
||||
}
|
||||
if (updateProfileDto.pos_y !== undefined) {
|
||||
updateData.pos_y = updateProfileDto.pos_y;
|
||||
}
|
||||
if (updateProfileDto.status !== undefined) {
|
||||
updateData.status = updateProfileDto.status;
|
||||
}
|
||||
|
||||
const updatedProfile = await this.userProfilesService.update(id, updateData);
|
||||
const formattedProfile = this.formatUserProfile(updatedProfile);
|
||||
return createSuccessResponse(formattedProfile, '用户档案更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新用户档案', { profileId: id.toString(), updateData: updateProfileDto }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户档案
|
||||
*
|
||||
* @param id 档案ID
|
||||
* @returns 删除结果响应
|
||||
*/
|
||||
async deleteUserProfile(id: bigint): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'删除用户档案',
|
||||
{ profileId: id.toString() },
|
||||
async () => {
|
||||
const result = await this.userProfilesService.remove(id);
|
||||
return createSuccessResponse({ deleted: true, id: id.toString(), affected: result.affected }, '用户档案删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除用户档案', { profileId: id.toString() }));
|
||||
}
|
||||
|
||||
// ==================== Zulip账号关联管理方法 ====================
|
||||
|
||||
/**
|
||||
* 获取Zulip账号关联列表
|
||||
*
|
||||
* @param limit 限制数量
|
||||
* @param offset 偏移量
|
||||
* @returns Zulip账号关联列表响应
|
||||
*/
|
||||
async getZulipAccountList(limit: number = DEFAULT_PAGE_SIZE, offset: number = 0): Promise<AdminListResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联列表',
|
||||
{ limit, offset },
|
||||
async () => {
|
||||
// ZulipAccountsService的findMany方法目前不支持分页参数
|
||||
// 先获取所有数据,然后手动分页
|
||||
const result = await this.zulipAccountsService.findMany({});
|
||||
|
||||
// 手动实现分页
|
||||
const startIndex = offset;
|
||||
const endIndex = offset + limit;
|
||||
const paginatedAccounts = result.accounts.slice(startIndex, endIndex);
|
||||
|
||||
const formattedAccounts = paginatedAccounts.map(account => this.formatZulipAccount(account));
|
||||
return createListResponse(formattedAccounts, result.total, limit, offset, 'Zulip账号关联列表获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleListError(error, '获取Zulip账号关联列表', { limit, offset }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取Zulip账号关联详情
|
||||
*
|
||||
* @param id 关联ID
|
||||
* @returns Zulip账号关联详情响应
|
||||
*/
|
||||
async getZulipAccountById(id: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联详情',
|
||||
{ accountId: id },
|
||||
async () => {
|
||||
const account = await this.zulipAccountsService.findById(id, true);
|
||||
const formattedAccount = this.formatZulipAccount(account);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联详情获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取Zulip账号关联详情', { accountId: id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Zulip账号关联统计
|
||||
*
|
||||
* @returns 统计信息响应
|
||||
*/
|
||||
async getZulipAccountStatistics(): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'获取Zulip账号关联统计',
|
||||
{},
|
||||
async () => {
|
||||
const stats = await this.zulipAccountsService.getStatusStatistics();
|
||||
return createSuccessResponse(stats, 'Zulip账号关联统计获取成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '获取Zulip账号关联统计', {}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建Zulip账号关联
|
||||
*
|
||||
* @param createAccountDto 创建数据
|
||||
* @returns 创建结果响应
|
||||
*/
|
||||
async createZulipAccount(createAccountDto: AdminCreateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'创建Zulip账号关联',
|
||||
{ gameUserId: createAccountDto.gameUserId },
|
||||
async () => {
|
||||
const newAccount = await this.zulipAccountsService.create(createAccountDto);
|
||||
const formattedAccount = this.formatZulipAccount(newAccount);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联创建成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '创建Zulip账号关联', { gameUserId: createAccountDto.gameUserId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新Zulip账号关联
|
||||
*
|
||||
* @param id 关联ID
|
||||
* @param updateAccountDto 更新数据
|
||||
* @returns 更新结果响应
|
||||
*/
|
||||
async updateZulipAccount(id: string, updateAccountDto: AdminUpdateZulipAccountDto): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'更新Zulip账号关联',
|
||||
{ accountId: id, updateFields: Object.keys(updateAccountDto) },
|
||||
async () => {
|
||||
const updatedAccount = await this.zulipAccountsService.update(id, updateAccountDto);
|
||||
const formattedAccount = this.formatZulipAccount(updatedAccount);
|
||||
return createSuccessResponse(formattedAccount, 'Zulip账号关联更新成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '更新Zulip账号关联', { accountId: id, updateData: updateAccountDto }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除Zulip账号关联
|
||||
*
|
||||
* @param id 关联ID
|
||||
* @returns 删除结果响应
|
||||
*/
|
||||
async deleteZulipAccount(id: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'删除Zulip账号关联',
|
||||
{ accountId: id },
|
||||
async () => {
|
||||
const result = await this.zulipAccountsService.delete(id);
|
||||
return createSuccessResponse({ deleted: result, id }, 'Zulip账号关联删除成功');
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '删除Zulip账号关联', { accountId: id }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新Zulip账号状态
|
||||
*
|
||||
* @param ids ID列表
|
||||
* @param status 新状态
|
||||
* @param reason 操作原因
|
||||
* @returns 批量更新结果响应
|
||||
*/
|
||||
async batchUpdateZulipAccountStatus(ids: string[], status: string, reason?: string): Promise<AdminApiResponse> {
|
||||
return await OperationMonitor.executeWithMonitoring(
|
||||
'批量更新Zulip账号状态',
|
||||
{ count: ids.length, status, reason },
|
||||
async () => {
|
||||
const result = await this.zulipAccountsService.batchUpdateStatus(ids, status as any);
|
||||
return createSuccessResponse({
|
||||
success_count: result.updatedCount,
|
||||
failed_count: ids.length - result.updatedCount,
|
||||
total_count: ids.length,
|
||||
reason
|
||||
}, `Zulip账号关联批量状态更新完成,成功:${result.updatedCount},失败:${ids.length - result.updatedCount}`);
|
||||
},
|
||||
this.logOperation.bind(this)
|
||||
).catch(error => this.handleServiceError(error, '批量更新Zulip账号状态', { count: ids.length, status, reason }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户档案信息
|
||||
*
|
||||
* @param profile 用户档案实体
|
||||
* @returns 格式化的用户档案信息
|
||||
*/
|
||||
private formatUserProfile(profile: UserProfiles) {
|
||||
return {
|
||||
id: profile.id.toString(),
|
||||
user_id: profile.user_id.toString(),
|
||||
bio: profile.bio,
|
||||
resume_content: profile.resume_content,
|
||||
tags: profile.tags,
|
||||
social_links: profile.social_links,
|
||||
skin_id: profile.skin_id,
|
||||
current_map: profile.current_map,
|
||||
pos_x: profile.pos_x,
|
||||
pos_y: profile.pos_y,
|
||||
status: profile.status,
|
||||
last_login_at: profile.last_login_at,
|
||||
last_position_update: profile.last_position_update
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化Zulip账号关联信息
|
||||
*
|
||||
* @param account Zulip账号关联实体
|
||||
* @returns 格式化的Zulip账号关联信息
|
||||
*/
|
||||
private formatZulipAccount(account: ZulipAccountResponseDto) {
|
||||
return {
|
||||
id: account.id,
|
||||
gameUserId: account.gameUserId,
|
||||
zulipUserId: account.zulipUserId,
|
||||
zulipEmail: account.zulipEmail,
|
||||
zulipFullName: account.zulipFullName,
|
||||
status: account.status,
|
||||
lastVerifiedAt: account.lastVerifiedAt,
|
||||
lastSyncedAt: account.lastSyncedAt,
|
||||
errorMessage: account.errorMessage,
|
||||
retryCount: account.retryCount,
|
||||
createdAt: account.createdAt,
|
||||
updatedAt: account.updatedAt,
|
||||
gameUser: account.gameUser
|
||||
};
|
||||
}
|
||||
}
|
||||
33
src/business/admin/index.ts
Normal file
33
src/business/admin/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 管理员模块统一导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 导出管理员相关的所有组件
|
||||
* - 提供统一的导入入口
|
||||
* - 简化其他模块的依赖管理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 模块接口统一管理
|
||||
* - 导出控制和版本管理
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-07: 代码规范优化 - 修正文件命名规范,更新作者信息和修改记录
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2025-12-24
|
||||
* @lastModified 2026-01-07
|
||||
*/
|
||||
|
||||
// 控制器
|
||||
export * from './admin.controller';
|
||||
|
||||
// 服务
|
||||
export * from './admin.service';
|
||||
|
||||
// DTO
|
||||
export * from './admin_login.dto';
|
||||
export * from './admin_response.dto';
|
||||
|
||||
// 模块
|
||||
export * from './admin.module';
|
||||
98
src/business/admin/log_admin_operation.decorator.ts
Normal file
98
src/business/admin/log_admin_operation.decorator.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 管理员操作日志装饰器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 自动记录管理员的数据库操作
|
||||
* - 支持操作前后数据状态记录
|
||||
* - 提供灵活的配置选项
|
||||
* - 集成错误处理和性能监控
|
||||
*
|
||||
* 使用方式:
|
||||
* @LogAdminOperation({
|
||||
* operationType: 'CREATE',
|
||||
* targetType: 'users',
|
||||
* description: '创建用户',
|
||||
* isSensitive: true
|
||||
* })
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 注释规范优化 - 修正@author字段,更新版本号和修改记录 (修改者: moyin)
|
||||
* - 2026-01-08: 注释规范优化 - 为接口添加注释,完善文档说明 (修改者: moyin)
|
||||
* - 2026-01-08: 功能新增 - 创建管理员操作日志装饰器 (修改者: assistant)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { OPERATION_TYPES } from './admin_constants';
|
||||
|
||||
/**
|
||||
* 管理员操作日志装饰器配置选项
|
||||
*
|
||||
* 功能描述:
|
||||
* 定义管理员操作日志装饰器的配置参数
|
||||
*
|
||||
* 使用场景:
|
||||
* - 配置@LogAdminOperation装饰器的行为
|
||||
* - 指定操作类型、目标类型和敏感性等属性
|
||||
*/
|
||||
export interface LogAdminOperationOptions {
|
||||
operationType: keyof typeof OPERATION_TYPES;
|
||||
targetType: string;
|
||||
description: string;
|
||||
isSensitive?: boolean;
|
||||
captureBeforeData?: boolean;
|
||||
captureAfterData?: boolean;
|
||||
captureRequestParams?: boolean;
|
||||
}
|
||||
|
||||
export const LOG_ADMIN_OPERATION_KEY = 'log_admin_operation';
|
||||
|
||||
/**
|
||||
* 管理员操作日志装饰器
|
||||
*
|
||||
* @param options 日志配置选项
|
||||
* @returns 装饰器函数
|
||||
*/
|
||||
export const LogAdminOperation = (options: LogAdminOperationOptions) => {
|
||||
return SetMetadata(LOG_ADMIN_OPERATION_KEY, options);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前管理员信息的参数装饰器
|
||||
*/
|
||||
export const CurrentAdmin = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user; // 假设JWT认证后用户信息存储在request.user中
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取客户端IP地址的参数装饰器
|
||||
*/
|
||||
export const ClientIP = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.ip ||
|
||||
request.connection?.remoteAddress ||
|
||||
request.socket?.remoteAddress ||
|
||||
(request.connection?.socket as any)?.remoteAddress ||
|
||||
request.headers['x-forwarded-for']?.split(',')[0] ||
|
||||
request.headers['x-real-ip'] ||
|
||||
'unknown';
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取用户代理的参数装饰器
|
||||
*/
|
||||
export const UserAgent = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.headers['user-agent'] || 'unknown';
|
||||
},
|
||||
);
|
||||
672
src/business/auth/account_profile.service.ts
Normal file
672
src/business/auth/account_profile.service.ts
Normal file
@@ -0,0 +1,672 @@
|
||||
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { UsersService } from '../../core/db/users/users.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { UserProfiles } from '../../core/db/user_profiles/user_profiles.entity';
|
||||
import { CreateUserProfileDto, UpdateUserProfileDto } from '../../core/db/user_profiles/user_profiles.dto';
|
||||
import { PlayerAssets } from '../../core/db/player_assets/player_assets.entity';
|
||||
import { EmailService } from '../../core/utils/email/email.service';
|
||||
|
||||
interface IUserProfilesService {
|
||||
create(createUserProfileDto: CreateUserProfileDto): Promise<UserProfiles>;
|
||||
findByUserId(userId: bigint): Promise<UserProfiles | null>;
|
||||
update(id: bigint, updateData: UpdateUserProfileDto): Promise<UserProfiles>;
|
||||
}
|
||||
|
||||
interface IPlayerAssetsService {
|
||||
grantAsset(userId: bigint, assetType: 'skin' | 'room_decor', assetId: string, source?: string, metadata?: Record<string, unknown>): Promise<PlayerAssets>;
|
||||
hasAsset(userId: bigint, assetType: 'skin' | 'room_decor', assetId: string): Promise<boolean>;
|
||||
hasAssetFromSource(userId: bigint, assetType: 'skin' | 'room_decor', source: string): Promise<boolean>;
|
||||
listAssetIds(userId: bigint, assetType: 'skin' | 'room_decor'): Promise<string[]>;
|
||||
}
|
||||
|
||||
interface IUserWalletsService {
|
||||
ensureWallet(userId: bigint): Promise<unknown>;
|
||||
}
|
||||
|
||||
export interface AccountProfilePayload {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
profile: {
|
||||
user_id: string;
|
||||
skin_id: string;
|
||||
avatar_id: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
current_map: string;
|
||||
pos_x: number;
|
||||
pos_y: number;
|
||||
status: number;
|
||||
owned_skin_ids: string[];
|
||||
owned_skins: AccountSkinAsset[];
|
||||
settings: AccountSettings;
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountSettings = Record<string, boolean | number>;
|
||||
|
||||
export interface UpdateAccountProfileRequest {
|
||||
skin_id?: string;
|
||||
avatar_url?: string;
|
||||
avatar_image_base64?: string;
|
||||
avatar_mime_type?: string;
|
||||
skin_image_base64?: string;
|
||||
skin_mime_type?: string;
|
||||
skin_name?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const FALLBACK_SKIN_ID = 'classic_whale';
|
||||
const PENDING_INITIAL_SKIN_ID = 'pending_initial_skin';
|
||||
const INITIAL_SKIN_IDS = new Set([
|
||||
'classic_whale',
|
||||
'human_whale_directional_v2_8x4',
|
||||
'girl_sailor_turnaround_v2_8x4',
|
||||
]);
|
||||
const GENERATED_ASSETS_DIR = 'generated/account-assets';
|
||||
const PUBLIC_ASSETS_PREFIX = '/assets/account';
|
||||
const AVATAR_SIZE = 256;
|
||||
const CUSTOM_SKIN_HFRAMES = 8;
|
||||
const CUSTOM_SKIN_VFRAMES = 4;
|
||||
const REGISTRATION_GENERATED_SKIN_SOURCE = 'generated_registration';
|
||||
const PROFILE_SETTINGS_TAG_KEY = 'whaletown_settings';
|
||||
const REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY = 'registration_skin_generation_available';
|
||||
const WELCOME_EMAIL_SENT_TAG_KEY = 'welcome_email_sent';
|
||||
const DEFAULT_ACCOUNT_SETTINGS: AccountSettings = {
|
||||
master_volume: 0.80,
|
||||
music_volume: 0.60,
|
||||
effects_volume: 0.90,
|
||||
ui_scale: 1.00,
|
||||
fullscreen: false,
|
||||
show_interaction_hints: true,
|
||||
show_name_always: false,
|
||||
show_chat_bubbles: true,
|
||||
world_notifications: true,
|
||||
private_notifications: true,
|
||||
friend_request_notifications: true,
|
||||
allow_nearby_private: true,
|
||||
allow_nearby_friend_requests: true,
|
||||
mute_ui_sfx: false,
|
||||
};
|
||||
const ACCOUNT_SETTING_NUMBER_KEYS = new Set(['master_volume', 'music_volume', 'effects_volume', 'ui_scale']);
|
||||
const ACCOUNT_SETTING_BOOLEAN_KEYS = new Set([
|
||||
'fullscreen',
|
||||
'show_interaction_hints',
|
||||
'show_name_always',
|
||||
'show_chat_bubbles',
|
||||
'world_notifications',
|
||||
'private_notifications',
|
||||
'friend_request_notifications',
|
||||
'allow_nearby_private',
|
||||
'allow_nearby_friend_requests',
|
||||
'mute_ui_sfx',
|
||||
]);
|
||||
|
||||
export interface AccountSkinAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
texture_url?: string;
|
||||
texture_base64?: string;
|
||||
mime_type?: string;
|
||||
hframes: number;
|
||||
vframes: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AccountProfileService {
|
||||
private readonly logger = new Logger(AccountProfileService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('UsersService') private readonly usersService: UsersService,
|
||||
@Inject('IUserProfilesService') private readonly userProfilesService: IUserProfilesService,
|
||||
@Inject('IPlayerAssetsService') private readonly playerAssetsService: IPlayerAssetsService,
|
||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly emailService: EmailService,
|
||||
) {}
|
||||
|
||||
async getAccountProfile(userId: bigint): Promise<AccountProfilePayload> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
const profile = await this.ensureProfile(userId);
|
||||
return this.formatAccountProfile(user, profile, await this.getOwnedSkinIds(userId));
|
||||
}
|
||||
|
||||
async updateAccountProfile(userId: bigint, update: UpdateAccountProfileRequest): Promise<AccountProfilePayload> {
|
||||
const user = await this.usersService.findOne(userId);
|
||||
let profile = await this.ensureProfile(userId);
|
||||
const isInitialCharacterCreation = this.isInitialCharacterPending(profile);
|
||||
|
||||
let normalizedSkinId = this.normalizeSkinId(update.skin_id);
|
||||
if (update.skin_image_base64) {
|
||||
const skinAsset = await this.saveCustomSkinAsset(
|
||||
userId,
|
||||
update.skin_image_base64,
|
||||
update.skin_mime_type,
|
||||
update.skin_name,
|
||||
);
|
||||
normalizedSkinId = skinAsset.skinId;
|
||||
await this.playerAssetsService.grantAsset(userId, 'skin', normalizedSkinId, 'custom_upload');
|
||||
}
|
||||
|
||||
if (normalizedSkinId) {
|
||||
await this.ensureSkinCanBeSelected(userId, normalizedSkinId);
|
||||
profile = await this.userProfilesService.update(profile.id, {
|
||||
skin_id: normalizedSkinId,
|
||||
});
|
||||
if (isInitialCharacterCreation) {
|
||||
profile = await this.sendWelcomeEmailAfterInitialCharacterCreation(user, profile);
|
||||
}
|
||||
}
|
||||
|
||||
if (update.settings && typeof update.settings === 'object' && !Array.isArray(update.settings)) {
|
||||
profile = await this.userProfilesService.update(profile.id, {
|
||||
tags: this.mergeProfileTagsWithSettings(profile, update.settings),
|
||||
});
|
||||
}
|
||||
|
||||
let avatarUrl = update.avatar_url;
|
||||
if (update.avatar_image_base64) {
|
||||
avatarUrl = await this.saveAvatarAsset(userId, update.avatar_image_base64, update.avatar_mime_type);
|
||||
}
|
||||
const normalizedAvatarUrl = this.normalizeAvatarUrl(avatarUrl);
|
||||
const avatarUrlWasProvided = typeof avatarUrl === 'string';
|
||||
const updatedUser = avatarUrlWasProvided
|
||||
? await this.usersService.update(userId, { avatar_url: normalizedAvatarUrl || null } as any)
|
||||
: user;
|
||||
|
||||
return this.formatAccountProfile(updatedUser, profile, await this.getOwnedSkinIds(userId));
|
||||
}
|
||||
|
||||
async sendWelcomeEmailAfterInitialCharacterCreation(user: Users, profile: UserProfiles): Promise<UserProfiles> {
|
||||
if (!this.isInitialCharacterCreated(profile)) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
const tags = this.getProfileTags(profile);
|
||||
if (tags[WELCOME_EMAIL_SENT_TAG_KEY] === true) {
|
||||
return profile;
|
||||
}
|
||||
|
||||
// Record the event before delivery so subsequent appearance saves do not repeat the welcome email.
|
||||
tags[WELCOME_EMAIL_SENT_TAG_KEY] = true;
|
||||
const updatedProfile = await this.userProfilesService.update(profile.id, { tags });
|
||||
|
||||
if (!user.email) {
|
||||
this.logger.warn('初始角色已创建,但账号未绑定邮箱,跳过欢迎邮件', {
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.emailService.sendWelcomeEmail(user.email, user.nickname);
|
||||
} catch (error) {
|
||||
this.logger.warn('初始角色创建后的欢迎邮件发送失败', {
|
||||
userId: user.id.toString(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
return updatedProfile;
|
||||
}
|
||||
|
||||
async ensureProfile(userId: bigint, initialSkinId?: string): Promise<UserProfiles> {
|
||||
const existing = await this.userProfilesService.findByUserId(userId);
|
||||
if (existing) {
|
||||
await this.userWalletsService.ensureWallet(userId);
|
||||
return await this.ensureProfileSkinIsOwned(userId, existing);
|
||||
}
|
||||
|
||||
const skinId = this.resolveInitialSkinId(initialSkinId);
|
||||
if (skinId !== PENDING_INITIAL_SKIN_ID) {
|
||||
await this.grantInitialSkins(userId, skinId);
|
||||
}
|
||||
await this.userWalletsService.ensureWallet(userId);
|
||||
this.logger.log('创建账号初始用户档案', {
|
||||
userId: userId.toString(),
|
||||
skinId,
|
||||
});
|
||||
|
||||
return await this.userProfilesService.create({
|
||||
user_id: userId,
|
||||
skin_id: skinId,
|
||||
tags: {
|
||||
[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY]: true,
|
||||
},
|
||||
current_map: 'plaza',
|
||||
pos_x: 0,
|
||||
pos_y: 0,
|
||||
status: 0,
|
||||
});
|
||||
}
|
||||
|
||||
async formatAccountProfileAsync(user: Users, profile: UserProfiles): Promise<AccountProfilePayload> {
|
||||
return this.formatAccountProfile(user, profile, await this.getOwnedSkinIds(profile.user_id));
|
||||
}
|
||||
|
||||
formatAccountProfile(user: Users, profile: UserProfiles, ownedSkinIds: string[] = []): AccountProfilePayload {
|
||||
const skinId = profile.skin_id || '';
|
||||
const normalizedOwnedSkinIds = this.mergeUniqueSkinIds(ownedSkinIds);
|
||||
const avatarUrl = user.avatar_url || '';
|
||||
const avatarBase64 = this.readAccountAvatarBase64(user.id, avatarUrl);
|
||||
return {
|
||||
user: {
|
||||
id: user.id.toString(),
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar_url: avatarUrl,
|
||||
avatar_base64: avatarBase64,
|
||||
role: user.role,
|
||||
created_at: user.created_at,
|
||||
},
|
||||
profile: {
|
||||
user_id: profile.user_id.toString(),
|
||||
skin_id: skinId,
|
||||
avatar_id: avatarUrl ? 'custom' : 'default',
|
||||
avatar_url: avatarUrl,
|
||||
avatar_base64: avatarBase64,
|
||||
current_map: profile.current_map,
|
||||
pos_x: profile.pos_x,
|
||||
pos_y: profile.pos_y,
|
||||
status: profile.status,
|
||||
owned_skin_ids: normalizedOwnedSkinIds,
|
||||
owned_skins: this.getOwnedSkinAssets(user.id, normalizedOwnedSkinIds),
|
||||
settings: this.getAccountSettings(profile),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async saveGeneratedSkinForUser(
|
||||
userId: bigint,
|
||||
sourcePngPath: string,
|
||||
displayName = '生成角色',
|
||||
source = 'generated',
|
||||
): Promise<AccountSkinAsset & { skinId: string }> {
|
||||
if (!sourcePngPath || !existsSync(sourcePngPath)) {
|
||||
throw new BadRequestException('生成皮肤文件不存在');
|
||||
}
|
||||
|
||||
const image = await readFile(sourcePngPath);
|
||||
this.validatePngImage(image, '角色皮肤');
|
||||
const skinId = this.createCustomSkinId('generated', displayName);
|
||||
const targetPath = this.getAccountSkinPath(userId, skinId);
|
||||
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
||||
await writeFile(targetPath, image);
|
||||
await this.writeSkinMetadata(userId, skinId, displayName, source);
|
||||
await this.playerAssetsService.grantAsset(userId, 'skin', skinId, source);
|
||||
await this.updateAccountProfile(userId, { skin_id: skinId });
|
||||
return {
|
||||
...await this.buildSkinAsset(userId, skinId),
|
||||
skinId,
|
||||
};
|
||||
}
|
||||
|
||||
async hasRegistrationGeneratedSkin(userId: bigint): Promise<boolean> {
|
||||
if (this.playerAssetsService.hasAssetFromSource) {
|
||||
return this.playerAssetsService.hasAssetFromSource(userId, 'skin', REGISTRATION_GENERATED_SKIN_SOURCE);
|
||||
}
|
||||
const skinIds = await this.playerAssetsService.listAssetIds(userId, 'skin');
|
||||
return skinIds.some((skinId) => skinId.startsWith('generated_'));
|
||||
}
|
||||
|
||||
async canUseRegistrationSkinGeneration(userId: bigint): Promise<boolean> {
|
||||
const profile = await this.ensureProfile(userId);
|
||||
const tags = this.getProfileTags(profile);
|
||||
return tags[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY] === true;
|
||||
}
|
||||
|
||||
async consumeRegistrationSkinGeneration(userId: bigint): Promise<void> {
|
||||
const profile = await this.ensureProfile(userId);
|
||||
const tags = this.getProfileTags(profile);
|
||||
tags[REGISTRATION_SKIN_GENERATION_AVAILABLE_TAG_KEY] = false;
|
||||
await this.userProfilesService.update(profile.id, { tags });
|
||||
}
|
||||
|
||||
private async grantInitialSkins(userId: bigint, selectedSkinId: string): Promise<void> {
|
||||
if (this.isInitialSkinId(selectedSkinId)) {
|
||||
await this.playerAssetsService.grantAsset(userId, 'skin', selectedSkinId, 'registration');
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureProfileSkinIsOwned(userId: bigint, profile: UserProfiles): Promise<UserProfiles> {
|
||||
const selectedSkinId = this.normalizeSkinId(profile.skin_id || '');
|
||||
if (!selectedSkinId || selectedSkinId === PENDING_INITIAL_SKIN_ID) {
|
||||
return profile;
|
||||
}
|
||||
if (await this.playerAssetsService.hasAsset(userId, 'skin', selectedSkinId)) {
|
||||
return profile;
|
||||
}
|
||||
if (this.isInitialSkinId(selectedSkinId)) {
|
||||
await this.playerAssetsService.grantAsset(userId, 'skin', selectedSkinId, 'registration');
|
||||
return profile;
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
|
||||
private async ensureSkinCanBeSelected(userId: bigint, skinId: string): Promise<void> {
|
||||
if (this.isInitialSkinId(skinId)) {
|
||||
const profile = await this.userProfilesService.findByUserId(userId);
|
||||
const currentSkinId = this.normalizeSkinId(profile?.skin_id || '');
|
||||
const ownedSkinIds = await this.playerAssetsService.listAssetIds(userId, 'skin');
|
||||
if ((currentSkinId === PENDING_INITIAL_SKIN_ID || ownedSkinIds.length === 0) && !(await this.playerAssetsService.hasAsset(userId, 'skin', skinId))) {
|
||||
await this.playerAssetsService.grantAsset(userId, 'skin', skinId, 'registration');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!(await this.playerAssetsService.hasAsset(userId, 'skin', skinId))) {
|
||||
throw new ForbiddenException('尚未拥有该皮肤,请先在商城购买');
|
||||
}
|
||||
}
|
||||
|
||||
private async getOwnedSkinIds(userId: bigint): Promise<string[]> {
|
||||
return this.mergeUniqueSkinIds(await this.playerAssetsService.listAssetIds(userId, 'skin'));
|
||||
}
|
||||
|
||||
private mergeUniqueSkinIds(skinIds: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const skinId of skinIds) {
|
||||
const normalized = this.normalizeSkinId(skinId);
|
||||
if (normalized && !seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
result.push(normalized);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private normalizeSkinId(skinId?: string): string {
|
||||
const normalized = (skinId || '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (!/^[A-Za-z0-9_:-]{1,100}$/.test(normalized)) {
|
||||
throw new BadRequestException('皮肤ID格式不正确');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private resolveInitialSkinId(skinId?: string): string {
|
||||
const normalized = this.normalizeSkinId(skinId);
|
||||
if (!normalized) {
|
||||
return PENDING_INITIAL_SKIN_ID;
|
||||
}
|
||||
return this.isInitialSkinId(normalized) ? normalized : PENDING_INITIAL_SKIN_ID;
|
||||
}
|
||||
|
||||
private isInitialSkinId(skinId: string): boolean {
|
||||
return INITIAL_SKIN_IDS.has((skinId || '').trim());
|
||||
}
|
||||
|
||||
private isInitialCharacterPending(profile: UserProfiles): boolean {
|
||||
const skinId = this.normalizeSkinId(profile.skin_id || '');
|
||||
return !skinId || skinId === PENDING_INITIAL_SKIN_ID;
|
||||
}
|
||||
|
||||
private isInitialCharacterCreated(profile: UserProfiles): boolean {
|
||||
return !this.isInitialCharacterPending(profile);
|
||||
}
|
||||
|
||||
private normalizeAvatarUrl(avatarUrl?: string): string {
|
||||
const normalized = (avatarUrl || '').trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length > 255) {
|
||||
throw new BadRequestException('头像URL长度不能超过255字符');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private async saveAvatarAsset(userId: bigint, base64: string, mimeType?: string): Promise<string> {
|
||||
const buffer = this.decodeBase64Image(base64, mimeType, ['image/png', 'image/jpeg', 'image/webp'], '头像');
|
||||
if (buffer.length > 3 * 1024 * 1024) {
|
||||
throw new BadRequestException('头像图片不能超过3MB');
|
||||
}
|
||||
const extension = this.extensionForMime(mimeType || 'image/png');
|
||||
const relativePath = `${userId.toString()}/avatar_${Date.now()}.${extension}`;
|
||||
const targetPath = join(this.getAccountAssetRoot(), relativePath);
|
||||
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
||||
await writeFile(targetPath, buffer);
|
||||
return `${PUBLIC_ASSETS_PREFIX}/${relativePath}`;
|
||||
}
|
||||
|
||||
private async saveCustomSkinAsset(
|
||||
userId: bigint,
|
||||
base64: string,
|
||||
mimeType?: string,
|
||||
skinName?: string,
|
||||
): Promise<{ skinId: string; textureUrl: string }> {
|
||||
const buffer = this.decodeBase64Image(base64, mimeType, ['image/png'], '角色皮肤');
|
||||
if (buffer.length > 8 * 1024 * 1024) {
|
||||
throw new BadRequestException('角色皮肤PNG不能超过8MB');
|
||||
}
|
||||
this.validatePngImage(buffer, '角色皮肤');
|
||||
const skinId = this.createCustomSkinId('custom', skinName || '自定义角色');
|
||||
const targetPath = this.getAccountSkinPath(userId, skinId);
|
||||
await mkdir(resolve(targetPath, '..'), { recursive: true });
|
||||
await writeFile(targetPath, buffer);
|
||||
await this.writeSkinMetadata(userId, skinId, skinName || '自定义角色', 'custom_upload');
|
||||
return {
|
||||
skinId,
|
||||
textureUrl: this.getAccountSkinUrl(userId, skinId),
|
||||
};
|
||||
}
|
||||
|
||||
private getOwnedSkinAssets(userId: bigint, skinIds: string[]): AccountSkinAsset[] {
|
||||
const assets: AccountSkinAsset[] = [];
|
||||
for (const skinId of skinIds) {
|
||||
if (!this.isCustomAssetSkinId(skinId)) {
|
||||
continue;
|
||||
}
|
||||
const filePath = this.getAccountSkinPath(userId, skinId);
|
||||
if (!existsSync(filePath)) {
|
||||
continue;
|
||||
}
|
||||
assets.push({
|
||||
id: skinId,
|
||||
name: this.skinNameFromId(skinId),
|
||||
texture_url: this.getAccountSkinUrl(userId, skinId),
|
||||
texture_base64: readFileSync(filePath).toString('base64'),
|
||||
hframes: CUSTOM_SKIN_HFRAMES,
|
||||
vframes: CUSTOM_SKIN_VFRAMES,
|
||||
mime_type: 'image/png',
|
||||
source: skinId.startsWith('generated_') ? 'generated' : 'custom_upload',
|
||||
});
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
private async buildSkinAsset(userId: bigint, skinId: string): Promise<AccountSkinAsset> {
|
||||
const texturePath = this.getAccountSkinPath(userId, skinId);
|
||||
let textureBase64 = '';
|
||||
if (existsSync(texturePath)) {
|
||||
textureBase64 = (await readFile(texturePath)).toString('base64');
|
||||
}
|
||||
return {
|
||||
id: skinId,
|
||||
name: this.skinNameFromId(skinId),
|
||||
texture_url: this.getAccountSkinUrl(userId, skinId),
|
||||
texture_base64: textureBase64,
|
||||
mime_type: 'image/png',
|
||||
hframes: CUSTOM_SKIN_HFRAMES,
|
||||
vframes: CUSTOM_SKIN_VFRAMES,
|
||||
source: skinId.startsWith('generated_') ? 'generated' : 'custom_upload',
|
||||
};
|
||||
}
|
||||
|
||||
private decodeBase64Image(base64: string, mimeType: string | undefined, allowedMimeTypes: string[], label: string): Buffer {
|
||||
const normalizedMimeType = (mimeType || 'image/png').trim().toLowerCase();
|
||||
if (!allowedMimeTypes.includes(normalizedMimeType)) {
|
||||
throw new BadRequestException(`${label}图片格式不支持`);
|
||||
}
|
||||
const cleaned = base64.includes(',') ? base64.split(',').pop() || '' : base64;
|
||||
if (!cleaned.trim()) {
|
||||
throw new BadRequestException(`${label}图片内容不能为空`);
|
||||
}
|
||||
try {
|
||||
return Buffer.from(cleaned, 'base64');
|
||||
} catch {
|
||||
throw new BadRequestException(`${label}图片Base64解析失败`);
|
||||
}
|
||||
}
|
||||
|
||||
private validatePngImage(buffer: Buffer, label: string): void {
|
||||
const pngSignature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (buffer.length < 24 || !buffer.subarray(0, 8).equals(pngSignature)) {
|
||||
throw new BadRequestException(`${label}必须是PNG文件`);
|
||||
}
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new BadRequestException(`${label}尺寸无效`);
|
||||
}
|
||||
if (width % CUSTOM_SKIN_HFRAMES !== 0 || height % CUSTOM_SKIN_VFRAMES !== 0) {
|
||||
throw new BadRequestException(`${label}必须是${CUSTOM_SKIN_HFRAMES}列x${CUSTOM_SKIN_VFRAMES}行PNG`);
|
||||
}
|
||||
}
|
||||
|
||||
private createCustomSkinId(prefix: string, name: string): string {
|
||||
const namePart = (name || 'skin')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-zA-Z0-9_]+/g, '_')
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_+|_+$/g, '')
|
||||
.slice(0, 24) || 'skin';
|
||||
return `${prefix}_${namePart}_${randomUUID().replace(/-/g, '').slice(0, 12)}`;
|
||||
}
|
||||
|
||||
private isCustomAssetSkinId(skinId: string): boolean {
|
||||
return skinId.startsWith('custom_') || skinId.startsWith('generated_');
|
||||
}
|
||||
|
||||
private skinNameFromId(skinId: string): string {
|
||||
if (skinId.startsWith('generated_')) {
|
||||
return '生成角色';
|
||||
}
|
||||
if (skinId.startsWith('custom_')) {
|
||||
return '自定义角色';
|
||||
}
|
||||
return skinId;
|
||||
}
|
||||
|
||||
private async writeSkinMetadata(userId: bigint, skinId: string, skinName: string, source: string): Promise<void> {
|
||||
const metadataPath = this.getAccountSkinMetadataPath(userId, skinId);
|
||||
await writeFile(metadataPath, JSON.stringify({
|
||||
skin_id: skinId,
|
||||
name: skinName,
|
||||
source,
|
||||
hframes: CUSTOM_SKIN_HFRAMES,
|
||||
vframes: CUSTOM_SKIN_VFRAMES,
|
||||
created_at: new Date().toISOString(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
private getAccountAssetRoot(): string {
|
||||
return resolve(process.cwd(), this.configService.get<string>('ACCOUNT_ASSET_DIR') || GENERATED_ASSETS_DIR);
|
||||
}
|
||||
|
||||
private getAccountSkinPath(userId: bigint, skinId: string): string {
|
||||
return join(this.getAccountAssetRoot(), userId.toString(), 'skins', `${skinId}.png`);
|
||||
}
|
||||
|
||||
private getAccountSkinMetadataPath(userId: bigint, skinId: string): string {
|
||||
return join(this.getAccountAssetRoot(), userId.toString(), 'skins', `${skinId}.json`);
|
||||
}
|
||||
|
||||
private getAccountSkinUrl(userId: bigint, skinId: string): string {
|
||||
return `${PUBLIC_ASSETS_PREFIX}/${userId.toString()}/skins/${skinId}.png`;
|
||||
}
|
||||
|
||||
private readAccountAvatarBase64(userId: bigint, avatarUrl: string): string {
|
||||
if (!avatarUrl.startsWith(`${PUBLIC_ASSETS_PREFIX}/${userId.toString()}/`)) {
|
||||
return '';
|
||||
}
|
||||
const relativePath = avatarUrl.slice(PUBLIC_ASSETS_PREFIX.length + 1);
|
||||
if (relativePath.includes('..')) {
|
||||
return '';
|
||||
}
|
||||
const assetRoot = this.getAccountAssetRoot();
|
||||
const avatarPath = resolve(assetRoot, relativePath);
|
||||
const normalizedRoot = assetRoot.endsWith('/') ? assetRoot : `${assetRoot}/`;
|
||||
if (!avatarPath.startsWith(normalizedRoot)) {
|
||||
return '';
|
||||
}
|
||||
if (!existsSync(avatarPath)) {
|
||||
return '';
|
||||
}
|
||||
return readFileSync(avatarPath).toString('base64');
|
||||
}
|
||||
|
||||
private extensionForMime(mimeType: string): string {
|
||||
switch (mimeType.toLowerCase()) {
|
||||
case 'image/jpeg':
|
||||
return 'jpg';
|
||||
case 'image/webp':
|
||||
return 'webp';
|
||||
default:
|
||||
return 'png';
|
||||
}
|
||||
}
|
||||
|
||||
private getAccountSettings(profile: UserProfiles): AccountSettings {
|
||||
const tags = this.getProfileTags(profile);
|
||||
const storedSettings = tags[PROFILE_SETTINGS_TAG_KEY];
|
||||
return this.sanitizeAccountSettings(storedSettings && typeof storedSettings === 'object' && !Array.isArray(storedSettings)
|
||||
? storedSettings as Record<string, unknown>
|
||||
: {});
|
||||
}
|
||||
|
||||
private mergeProfileTagsWithSettings(profile: UserProfiles, incomingSettings: Record<string, unknown>): Record<string, any> {
|
||||
const tags = this.getProfileTags(profile);
|
||||
tags[PROFILE_SETTINGS_TAG_KEY] = this.sanitizeAccountSettings({
|
||||
...this.getAccountSettings(profile),
|
||||
...incomingSettings,
|
||||
});
|
||||
return tags;
|
||||
}
|
||||
|
||||
private getProfileTags(profile: UserProfiles): Record<string, any> {
|
||||
if (!profile.tags || typeof profile.tags !== 'object' || Array.isArray(profile.tags)) {
|
||||
return {};
|
||||
}
|
||||
return { ...profile.tags };
|
||||
}
|
||||
|
||||
private sanitizeAccountSettings(settings: Record<string, unknown>): AccountSettings {
|
||||
const sanitized: AccountSettings = { ...DEFAULT_ACCOUNT_SETTINGS };
|
||||
for (const key of Object.keys(DEFAULT_ACCOUNT_SETTINGS)) {
|
||||
if (!Object.prototype.hasOwnProperty.call(settings, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = settings[key];
|
||||
if (ACCOUNT_SETTING_NUMBER_KEYS.has(key)) {
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (Number.isFinite(numberValue)) {
|
||||
sanitized[key] = key === 'ui_scale'
|
||||
? Math.min(1.2, Math.max(0.8, numberValue))
|
||||
: Math.min(1, Math.max(0, numberValue));
|
||||
}
|
||||
} else if (ACCOUNT_SETTING_BOOLEAN_KEYS.has(key)) {
|
||||
sanitized[key] = typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
69
src/business/auth/auth.module.ts
Normal file
69
src/business/auth/auth.module.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 用户认证业务模块
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合所有用户认证相关的业务逻辑
|
||||
* - 用户登录、注册、密码管理业务流程
|
||||
* - GitHub OAuth业务集成
|
||||
* - 邮箱验证业务功能
|
||||
* - Zulip账号关联业务
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务逻辑实现和流程控制
|
||||
* - 整合核心服务完成业务功能
|
||||
* - 不包含HTTP协议处理(由Gateway层负责)
|
||||
* - 不包含数据访问细节(由Core层负责)
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 Core Layer 的 LoginCoreModule
|
||||
* - 依赖 Core Layer 的 ZulipCoreModule
|
||||
* - 被 Gateway Layer 的 AuthGatewayModule 使用
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构重构 - 移除Controller,专注于业务逻辑层
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @since 2025-12-24
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { LoginService } from './login.service';
|
||||
import { RegisterService } from './register.service';
|
||||
import { AccountProfileService } from './account_profile.service';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { UsersModule } from '../../core/db/users/users.module';
|
||||
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.module';
|
||||
import { EmailModule } from '../../core/utils/email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// 导入核心层模块
|
||||
LoginCoreModule,
|
||||
ConfigModule,
|
||||
ZulipCoreModule,
|
||||
UserProfilesModule,
|
||||
// 注意:ZulipAccountsModule 是全局模块,已在 AppModule 中导入,无需重复导入
|
||||
UsersModule,
|
||||
EmailModule,
|
||||
],
|
||||
providers: [
|
||||
// 业务服务
|
||||
LoginService,
|
||||
RegisterService,
|
||||
AccountProfileService,
|
||||
],
|
||||
exports: [
|
||||
// 导出业务服务供Gateway层使用
|
||||
LoginService,
|
||||
RegisterService,
|
||||
AccountProfileService,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
31
src/business/auth/index.ts
Normal file
31
src/business/auth/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 用户认证业务模块导出
|
||||
*
|
||||
* 功能概述:
|
||||
* - 用户登录和注册业务逻辑
|
||||
* - GitHub OAuth集成
|
||||
* - 密码管理(忘记密码、重置密码、修改密码)
|
||||
* - 邮箱验证功能
|
||||
* - JWT Token管理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务层模块导出
|
||||
* - 提供统一的业务服务入口点
|
||||
* - 简化外部模块的引用方式
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 架构重构 - 移除Controller和DTO导出(已移至Gateway层)(修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
// 模块
|
||||
export * from './auth.module';
|
||||
|
||||
// 服务(业务层)
|
||||
export { LoginService } from './login.service';
|
||||
export { RegisterService } from './register.service';
|
||||
750
src/business/auth/login.service.ts
Normal file
750
src/business/auth/login.service.ts
Normal file
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* 登录业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理用户登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的登录功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
* - 管理JWT令牌刷新和验证码登录
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于登录业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供登录业务接口
|
||||
* - JWT技术实现已移至Core层,符合架构分层原则
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块,符合架构分层原则
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-12
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
import { AccountProfilePayload, AccountProfileService } from './account_profile.service';
|
||||
|
||||
// Import the interface types we need
|
||||
interface IZulipAccountsService {
|
||||
findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<any>;
|
||||
create(createDto: any): Promise<any>;
|
||||
deleteByGameUserId(gameUserId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
const ERROR_CODES = {
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
|
||||
SEND_CODE_FAILED: 'SEND_CODE_FAILED',
|
||||
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
|
||||
CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED',
|
||||
VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
|
||||
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
|
||||
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
|
||||
DEBUG_VERIFICATION_CODE_FAILED: 'DEBUG_VERIFICATION_CODE_FAILED',
|
||||
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
|
||||
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
|
||||
} as const;
|
||||
|
||||
const MESSAGES = {
|
||||
LOGIN_SUCCESS: '登录成功',
|
||||
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
|
||||
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
|
||||
PASSWORD_RESET_SUCCESS: '密码重置成功',
|
||||
PASSWORD_CHANGE_SUCCESS: '密码修改成功',
|
||||
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
|
||||
TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
|
||||
DEBUG_INFO_SUCCESS: '调试信息获取成功',
|
||||
CODE_SENT: '验证码已发送,请查收',
|
||||
VERIFICATION_CODE_ERROR: '验证码错误',
|
||||
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
} as const;
|
||||
|
||||
// JWT相关接口已移至Core层,通过import导入
|
||||
|
||||
/**
|
||||
* 登录响应数据接口
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** 用户信息 */
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
/** 游戏内账号资料 */
|
||||
profile?: AccountProfilePayload['profile'];
|
||||
/** 访问令牌 */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token: string;
|
||||
/** 访问令牌过期时间(秒) */
|
||||
expires_in: number;
|
||||
/** 令牌类型 */
|
||||
token_type: string;
|
||||
/** 是否为新用户 */
|
||||
is_new_user?: boolean;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应接口
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
/** 错误代码 */
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LoginService {
|
||||
private readonly logger = new Logger(LoginService.name);
|
||||
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
private readonly accountProfileService: AccountProfileService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* 功能描述:
|
||||
* 处理用户登录请求,验证用户凭据并生成JWT令牌
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 调用核心服务进行用户认证
|
||||
* 2. 生成JWT访问令牌和刷新令牌
|
||||
* 3. 记录登录日志和安全审计
|
||||
* 4. 返回用户信息和令牌
|
||||
*
|
||||
* @param loginRequest 登录请求数据
|
||||
* @returns Promise<ApiResponse<LoginResponse>> 登录响应
|
||||
*
|
||||
* @throws BadRequestException 当登录参数无效时
|
||||
* @throws UnauthorizedException 当用户凭据错误时
|
||||
* @throws InternalServerErrorException 当系统错误时
|
||||
*/
|
||||
async login(loginRequest: LoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
this.logger.log('用户登录尝试', {
|
||||
operation: 'login',
|
||||
identifier: loginRequest.identifier,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 2. 验证和更新Zulip API Key(如果用户有Zulip账号关联)
|
||||
try {
|
||||
const isZulipValid = await this.validateAndUpdateZulipApiKey(authResult.user);
|
||||
if (!isZulipValid) {
|
||||
// 尝试重新生成API Key(需要密码)
|
||||
const regenerated = await this.regenerateZulipApiKey(authResult.user, loginRequest.password);
|
||||
if (regenerated) {
|
||||
this.logger.log('用户Zulip API Key已重新生成', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('用户Zulip API Key重新生成失败', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (zulipError) {
|
||||
// Zulip验证失败不影响登录流程,只记录日志
|
||||
const err = zulipError as Error;
|
||||
this.logger.warn('Zulip API Key验证失败,但不影响登录', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
const profile = await this.accountProfileService.ensureProfile(authResult.user.id);
|
||||
|
||||
// 4. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
profile: (await this.accountProfileService.formatAccountProfileAsync(authResult.user, profile)).profile,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: MESSAGES.LOGIN_SUCCESS
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户登录成功', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
isNewUser: authResult.isNewUser,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: MESSAGES.LOGIN_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error('用户登录失败', {
|
||||
operation: 'login',
|
||||
identifier: loginRequest.identifier,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: err.message || '登录失败',
|
||||
error_code: ERROR_CODES.LOGIN_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub OAuth登录
|
||||
*
|
||||
* @param oauthRequest OAuth请求
|
||||
* @returns 登录响应
|
||||
*/
|
||||
async githubOAuth(oauthRequest: GitHubOAuthRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`GitHub OAuth登录尝试: ${oauthRequest.github_id}`);
|
||||
|
||||
// 调用核心服务进行OAuth认证
|
||||
const authResult = await this.loginCoreService.githubOAuth(oauthRequest);
|
||||
|
||||
// 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: authResult.isNewUser ? MESSAGES.GITHUB_BIND_SUCCESS : MESSAGES.GITHUB_LOGIN_SUCCESS
|
||||
};
|
||||
|
||||
this.logger.log(`GitHub OAuth成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`GitHub OAuth失败: ${oauthRequest.github_id}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'GitHub登录失败',
|
||||
error_code: ERROR_CODES.GITHUB_OAUTH_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送密码重置验证码
|
||||
*
|
||||
* @param identifier 邮箱或手机号
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送密码重置验证码: ${identifier}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendPasswordResetCode(identifier);
|
||||
|
||||
this.logger.log(`密码重置验证码已发送: ${identifier}`);
|
||||
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: ERROR_CODES.SEND_CODE_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
*
|
||||
* @param resetRequest 重置请求
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resetPassword(resetRequest: PasswordResetRequest): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`密码重置尝试: ${resetRequest.identifier}`);
|
||||
|
||||
// 调用核心服务重置密码
|
||||
await this.loginCoreService.resetPassword(resetRequest);
|
||||
|
||||
this.logger.log(`密码重置成功: ${resetRequest.identifier}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: MESSAGES.PASSWORD_RESET_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`密码重置失败: ${resetRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '密码重置失败',
|
||||
error_code: ERROR_CODES.RESET_PASSWORD_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param oldPassword 旧密码
|
||||
* @param newPassword 新密码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async changePassword(userId: bigint, oldPassword: string, newPassword: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`修改密码尝试: 用户ID ${userId}`);
|
||||
|
||||
// 调用核心服务修改密码
|
||||
await this.loginCoreService.changePassword(userId, oldPassword, newPassword);
|
||||
|
||||
this.logger.log(`修改密码成功: 用户ID ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: MESSAGES.PASSWORD_CHANGE_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`修改密码失败: 用户ID ${userId}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '密码修改失败',
|
||||
error_code: ERROR_CODES.CHANGE_PASSWORD_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户信息
|
||||
*/
|
||||
private formatUserInfo(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(), // 将bigint转换为字符串
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
* @param loginRequest 验证码登录请求
|
||||
* @returns 登录响应
|
||||
*/
|
||||
async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise<ApiResponse<LoginResponse>> {
|
||||
try {
|
||||
this.logger.log(`验证码登录尝试: ${loginRequest.identifier}`);
|
||||
|
||||
// 调用核心服务进行验证码认证
|
||||
const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest);
|
||||
|
||||
// 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: authResult.isNewUser,
|
||||
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
|
||||
};
|
||||
|
||||
this.logger.log(`验证码登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: MESSAGES.VERIFICATION_CODE_LOGIN_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`验证码登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '验证码登录失败',
|
||||
error_code: ERROR_CODES.VERIFICATION_CODE_LOGIN_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送登录验证码
|
||||
*
|
||||
* @param identifier 邮箱或手机号
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendLoginVerificationCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送登录验证码: ${identifier}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendLoginVerificationCode(identifier);
|
||||
|
||||
this.logger.log(`登录验证码已发送: ${identifier}`);
|
||||
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: ERROR_CODES.SEND_LOGIN_CODE_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新访问令牌
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证刷新令牌的有效性和格式
|
||||
* 2. 检查用户状态是否正常
|
||||
* 3. 生成新的JWT令牌对
|
||||
* 4. 返回新的访问令牌和刷新令牌
|
||||
*
|
||||
* @param refreshToken 刷新令牌字符串
|
||||
* @returns Promise<ApiResponse<TokenPair>> 新的令牌对
|
||||
*
|
||||
* @throws UnauthorizedException 当刷新令牌无效或已过期时
|
||||
* @throws NotFoundException 当用户不存在或已被禁用时
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await loginService.refreshAccessToken('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...');
|
||||
* ```
|
||||
*/
|
||||
async refreshAccessToken(refreshToken: string): Promise<ApiResponse<TokenPair>> {
|
||||
try {
|
||||
this.logger.log(`刷新访问令牌尝试`);
|
||||
|
||||
// 调用核心服务刷新令牌
|
||||
const tokenPair = await this.loginCoreService.refreshAccessToken(refreshToken);
|
||||
|
||||
this.logger.log(`访问令牌刷新成功`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: tokenPair,
|
||||
message: MESSAGES.TOKEN_REFRESH_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`访问令牌刷新失败`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '令牌刷新失败',
|
||||
error_code: ERROR_CODES.TOKEN_REFRESH_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 验证码调试信息
|
||||
*/
|
||||
async debugVerificationCode(email: string): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`调试验证码信息: ${email}`);
|
||||
|
||||
const debugInfo = await this.loginCoreService.debugVerificationCode(email);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: debugInfo,
|
||||
message: MESSAGES.DEBUG_INFO_SUCCESS
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`获取验证码调试信息失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '获取调试信息失败',
|
||||
error_code: ERROR_CODES.DEBUG_VERIFICATION_CODE_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并更新用户的Zulip API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 在用户登录时验证其Zulip账号的API Key是否有效,如果无效则重新获取
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 查找用户的Zulip账号关联
|
||||
* 2. 从Redis获取API Key
|
||||
* 3. 验证API Key是否有效
|
||||
* 4. 如果无效,重新生成API Key并更新存储
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @returns Promise<boolean> 是否验证/更新成功
|
||||
* @private
|
||||
*/
|
||||
private async validateAndUpdateZulipApiKey(user: Users): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始验证用户Zulip API Key', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.log('用户没有Zulip账号关联,跳过验证', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return true; // 没有关联不算错误
|
||||
}
|
||||
|
||||
// 2. 从Redis获取API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(user.id.toString());
|
||||
if (!apiKeyResult.success || !apiKeyResult.apiKey) {
|
||||
this.logger.warn('用户Zulip API Key不存在,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.message,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
}
|
||||
|
||||
// 3. 验证API Key是否有效
|
||||
const validationResult = await this.zulipAccountService.validateZulipAccount(
|
||||
zulipAccount.zulipEmail,
|
||||
apiKeyResult.apiKey
|
||||
);
|
||||
|
||||
if (validationResult.success && validationResult.isValid) {
|
||||
this.logger.log('用户Zulip API Key验证成功', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. API Key无效,需要重新生成
|
||||
this.logger.warn('用户Zulip API Key无效,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
validationError: validationResult.error,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('验证用户Zulip API Key失败', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新生成并更新用户的Zulip API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用用户密码重新生成Zulip API Key并更新存储
|
||||
*
|
||||
* @param user 用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @returns Promise<boolean> 是否更新成功
|
||||
* @private
|
||||
*/
|
||||
private async regenerateZulipApiKey(user: Users, password: string): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始重新生成用户Zulip API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.warn('用户没有Zulip账号关联,无法重新生成API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 重新生成API Key
|
||||
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
|
||||
zulipAccount.zulipEmail,
|
||||
password
|
||||
);
|
||||
|
||||
if (!apiKeyResult.success) {
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 更新Redis中的API Key
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
user.id.toString(),
|
||||
apiKeyResult.apiKey!
|
||||
);
|
||||
|
||||
// 注意:不在登录时建立内存关联,Zulip客户端将在WebSocket连接时创建
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('重新生成Zulip API Key成功', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
duration,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
687
src/business/auth/register.service.ts
Normal file
687
src/business/auth/register.service.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* 注册业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理用户注册相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的注册功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
* - 集成Zulip账号创建和关联
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于注册业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供注册业务接口
|
||||
* - 处理注册相关的邮箱验证和Zulip集成
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-15: 代码规范优化 - 清理未使用的导入TokenPair,增强userId非空验证 (修改者: moyin)
|
||||
* - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-12
|
||||
* @lastModified 2026-01-15
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, RegisterRequest } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
import { AccountProfilePayload, AccountProfileService } from './account_profile.service';
|
||||
|
||||
// Import the interface types we need
|
||||
interface IZulipAccountsService {
|
||||
findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise<any>;
|
||||
create(createDto: any): Promise<any>;
|
||||
deleteByGameUserId(gameUserId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
const ERROR_CODES = {
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
|
||||
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
|
||||
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
|
||||
TEST_MODE_ONLY: 'TEST_MODE_ONLY',
|
||||
INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE',
|
||||
} as const;
|
||||
|
||||
const MESSAGES = {
|
||||
REGISTER_SUCCESS: '注册成功',
|
||||
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
|
||||
CODE_SENT: '验证码已发送,请查收',
|
||||
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
|
||||
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
|
||||
VERIFICATION_CODE_ERROR: '验证码错误',
|
||||
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 注册响应数据接口
|
||||
*/
|
||||
export interface RegisterResponse {
|
||||
/** 用户信息 */
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
/** 游戏内账号资料 */
|
||||
profile?: AccountProfilePayload['profile'];
|
||||
/** 访问令牌 */
|
||||
access_token: string;
|
||||
/** 刷新令牌 */
|
||||
refresh_token: string;
|
||||
/** 访问令牌过期时间(秒) */
|
||||
expires_in: number;
|
||||
/** 令牌类型 */
|
||||
token_type: string;
|
||||
/** 是否为新用户 */
|
||||
is_new_user?: boolean;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用响应接口
|
||||
*/
|
||||
export interface ApiResponse<T = any> {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 响应数据 */
|
||||
data?: T;
|
||||
/** 消息 */
|
||||
message: string;
|
||||
/** 错误代码 */
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RegisterService {
|
||||
private readonly logger = new Logger(RegisterService.name);
|
||||
|
||||
constructor(
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
private readonly zulipAccountService: ZulipAccountService,
|
||||
@Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService,
|
||||
private readonly apiKeySecurityService: ApiKeySecurityService,
|
||||
private readonly accountProfileService: AccountProfileService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerRequest 注册请求
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<ApiResponse<RegisterResponse>> {
|
||||
const startTime = Date.now();
|
||||
const operationId = `register_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
||||
|
||||
try {
|
||||
this.logger.log(`开始用户注册流程`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 1. 初始化Zulip管理员客户端
|
||||
const zulipUnavailableForLocalDebug = this.isZulipUnavailableForLocalDebug();
|
||||
if (!zulipUnavailableForLocalDebug) {
|
||||
await this.initializeZulipAdminClient();
|
||||
} else {
|
||||
this.logger.warn('本地调试模式跳过Zulip管理员客户端初始化', {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 调用核心服务进行注册
|
||||
const authResult = await this.loginCoreService.register(registerRequest);
|
||||
|
||||
// 3. 创建Zulip账号(使用相同的邮箱和密码)- 异步处理,不影响注册流程
|
||||
if (registerRequest.email && registerRequest.password && !zulipUnavailableForLocalDebug) {
|
||||
// 异步创建Zulip账号,不阻塞注册流程
|
||||
this.createZulipAccountWithRetry(
|
||||
authResult.user,
|
||||
registerRequest.password,
|
||||
operationId
|
||||
).then(success => {
|
||||
if (success) {
|
||||
this.logger.log(`Zulip账号异步创建成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
}
|
||||
}).catch(err => {
|
||||
// 错误已在重试方法中记录,这里只是确保Promise不会未处理
|
||||
this.logger.warn(`Zulip账号异步创建最终失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
});
|
||||
|
||||
this.logger.log(`Zulip账号创建已提交到后台异步处理`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
email: registerRequest.email,
|
||||
});
|
||||
} else {
|
||||
this.logger.log(`跳过Zulip账号创建:缺少邮箱或密码`, {
|
||||
operation: 'register',
|
||||
username: registerRequest.username,
|
||||
hasEmail: !!registerRequest.email,
|
||||
hasPassword: !!registerRequest.password,
|
||||
zulipUnavailableForLocalDebug,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 生成JWT令牌对
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
const profile = await this.accountProfileService.ensureProfile(authResult.user.id, registerRequest.skin_id);
|
||||
await this.accountProfileService.sendWelcomeEmailAfterInitialCharacterCreation(authResult.user, profile);
|
||||
|
||||
// 5. 格式化响应数据
|
||||
const response: RegisterResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
profile: (await this.accountProfileService.formatAccountProfileAsync(authResult.user, profile)).profile,
|
||||
access_token: tokenPair.access_token,
|
||||
refresh_token: tokenPair.refresh_token,
|
||||
expires_in: tokenPair.expires_in,
|
||||
token_type: tokenPair.token_type,
|
||||
is_new_user: true,
|
||||
message: MESSAGES.REGISTER_SUCCESS
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log(`用户注册成功`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
gameUserId: authResult.user.id.toString(),
|
||||
username: authResult.user.username,
|
||||
email: authResult.user.email,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response,
|
||||
message: response.message
|
||||
};
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const err = error as Error;
|
||||
|
||||
this.logger.error(`用户注册失败`, {
|
||||
operation: 'register',
|
||||
operationId,
|
||||
username: registerRequest.username,
|
||||
email: registerRequest.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
}, err.stack);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: err.message || '注册失败',
|
||||
error_code: ERROR_CODES.REGISTER_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务发送验证码
|
||||
const result = await this.loginCoreService.sendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '发送验证码失败',
|
||||
error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @param code 验证码
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async verifyEmailCode(email: string, code: string): Promise<ApiResponse> {
|
||||
try {
|
||||
this.logger.log(`验证邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务验证验证码
|
||||
const isValid = await this.loginCoreService.verifyEmailCode(email, code);
|
||||
|
||||
if (isValid) {
|
||||
this.logger.log(`邮箱验证成功: ${email}`);
|
||||
return {
|
||||
success: true,
|
||||
message: MESSAGES.EMAIL_VERIFICATION_SUCCESS
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: MESSAGES.VERIFICATION_CODE_ERROR,
|
||||
error_code: ERROR_CODES.INVALID_VERIFICATION_CODE
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '邮箱验证失败',
|
||||
error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
|
||||
try {
|
||||
this.logger.log(`重新发送邮箱验证码: ${email}`);
|
||||
|
||||
// 调用核心服务重新发送验证码
|
||||
const result = await this.loginCoreService.resendEmailVerification(email);
|
||||
|
||||
this.logger.log(`邮箱验证码已重新发送: ${email}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT);
|
||||
} catch (error) {
|
||||
this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '重新发送验证码失败',
|
||||
error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化用户信息
|
||||
*
|
||||
* @param user 用户实体
|
||||
* @returns 格式化的用户信息
|
||||
*/
|
||||
private formatUserInfo(user: Users) {
|
||||
return {
|
||||
id: user.id.toString(), // 将bigint转换为字符串
|
||||
username: user.username,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
phone: user.phone,
|
||||
avatar_url: user.avatar_url,
|
||||
role: user.role,
|
||||
created_at: user.created_at
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理测试模式响应
|
||||
*
|
||||
* @param result 核心服务返回的结果
|
||||
* @param successMessage 成功时的消息
|
||||
* @param emailMessage 邮件发送成功时的消息
|
||||
* @returns 格式化的响应
|
||||
* @private
|
||||
*/
|
||||
private handleTestModeResponse(
|
||||
result: { code: string; isTestMode: boolean },
|
||||
successMessage: string,
|
||||
emailMessage?: string
|
||||
): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> {
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: emailMessage || successMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Zulip管理员客户端
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 从环境变量获取管理员配置
|
||||
* 2. 验证配置完整性
|
||||
* 3. 初始化ZulipAccountService的管理员客户端
|
||||
*
|
||||
* @throws Error 当配置缺失或初始化失败时
|
||||
* @private
|
||||
*/
|
||||
private async initializeZulipAdminClient(): Promise<void> {
|
||||
try {
|
||||
// 从环境变量获取管理员配置
|
||||
const adminConfig = {
|
||||
realm: process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '',
|
||||
username: process.env.ZULIP_BOT_EMAIL || process.env.ZULIP_ADMIN_EMAIL || '',
|
||||
apiKey: process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '',
|
||||
};
|
||||
|
||||
// 验证配置完整性
|
||||
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||
throw new Error('Zulip管理员配置不完整,请检查环境变量');
|
||||
}
|
||||
|
||||
// 初始化管理员客户端
|
||||
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error('Zulip管理员客户端初始化失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
error: err.message,
|
||||
}, err.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地调试时允许没有真实Zulip服务。
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private isZulipUnavailableForLocalDebug(): boolean {
|
||||
const serverUrl = process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '';
|
||||
const apiKey = process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '';
|
||||
const degradedModeEnabled = process.env.ZULIP_DEGRADED_MODE_ENABLED === 'true';
|
||||
const hasPlaceholderServer = serverUrl.includes('your-zulip-server.com');
|
||||
const hasPlaceholderApiKey = !apiKey || apiKey === 'your_bot_api_key';
|
||||
|
||||
return degradedModeEnabled || hasPlaceholderServer || hasPlaceholderApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* 带重试机制的异步创建Zulip账号
|
||||
*
|
||||
* 功能描述:
|
||||
* 异步创建Zulip账号,失败时自动重试最多3次
|
||||
* 所有错误只记录日志,不影响用户注册流程
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param password 用户密码
|
||||
* @param operationId 操作ID(用于日志追踪)
|
||||
* @param maxRetries 最大重试次数,默认3次
|
||||
* @returns Promise<boolean> 是否创建成功
|
||||
* @private
|
||||
*/
|
||||
private async createZulipAccountWithRetry(
|
||||
gameUser: Users,
|
||||
password: string,
|
||||
operationId: string,
|
||||
maxRetries: number = 3
|
||||
): Promise<boolean> {
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
this.logger.log(`尝试创建Zulip账号 (第${attempt}/${maxRetries}次)`, {
|
||||
operation: 'createZulipAccountWithRetry',
|
||||
operationId,
|
||||
attempt,
|
||||
maxRetries,
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
});
|
||||
|
||||
await this.createZulipAccountForUser(gameUser, password);
|
||||
|
||||
this.logger.log(`Zulip账号创建成功 (第${attempt}次尝试)`, {
|
||||
operation: 'createZulipAccountWithRetry',
|
||||
operationId,
|
||||
attempt,
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
this.logger.warn(`Zulip账号创建失败 (第${attempt}/${maxRetries}次尝试)`, {
|
||||
operation: 'createZulipAccountWithRetry',
|
||||
operationId,
|
||||
attempt,
|
||||
maxRetries,
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
error: lastError.message,
|
||||
});
|
||||
|
||||
// 如果不是最后一次尝试,等待后重试
|
||||
if (attempt < maxRetries) {
|
||||
const delayMs = attempt * 1000; // 递增延迟:1秒、2秒、3秒
|
||||
this.logger.log(`等待${delayMs}ms后重试`, {
|
||||
operation: 'createZulipAccountWithRetry',
|
||||
operationId,
|
||||
attempt,
|
||||
delayMs,
|
||||
});
|
||||
await this.delay(delayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败
|
||||
this.logger.error(`Zulip账号创建最终失败,已尝试${maxRetries}次`, {
|
||||
operation: 'createZulipAccountWithRetry',
|
||||
operationId,
|
||||
maxRetries,
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
finalError: lastError?.message,
|
||||
note: '用户注册已成功,但Zulip账号创建失败。用户可以正常使用游戏,但无法使用聊天功能。',
|
||||
}, lastError?.stack);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 延迟工具方法
|
||||
*
|
||||
* @param ms 延迟毫秒数
|
||||
* @returns Promise<void>
|
||||
* @private
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户创建或绑定Zulip账号
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新注册的游戏用户创建对应的Zulip账号或绑定已有账号并建立关联
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 检查是否已存在Zulip账号关联
|
||||
* 2. 尝试创建Zulip账号(如果已存在则自动绑定)
|
||||
* 3. 获取或生成API Key并存储到Redis
|
||||
* 4. 在数据库中创建关联记录
|
||||
* 5. 建立内存关联(用于当前会话)
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @throws Error 当Zulip账号创建/绑定失败时
|
||||
* @private
|
||||
*/
|
||||
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始为用户创建或绑定Zulip账号', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
nickname: gameUser.nickname,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 检查是否已存在Zulip账号关联
|
||||
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
|
||||
if (existingAccount) {
|
||||
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
existingZulipUserId: existingAccount.zulipUserId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 尝试创建或绑定Zulip账号
|
||||
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||
email: gameUser.email,
|
||||
fullName: gameUser.nickname,
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error || 'Zulip账号创建/绑定失败');
|
||||
}
|
||||
|
||||
// 验证必须获取到 userId(数据库字段 NOT NULL)
|
||||
if (createResult.userId === undefined || createResult.userId === null) {
|
||||
throw new Error('Zulip账号创建成功但未能获取用户ID,无法建立关联');
|
||||
}
|
||||
|
||||
// 3. 处理API Key
|
||||
let finalApiKey = createResult.apiKey;
|
||||
|
||||
// 如果是绑定已有账号但没有API Key,尝试重新获取
|
||||
if (createResult.isExistingUser && !finalApiKey) {
|
||||
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
|
||||
createResult.email!,
|
||||
password
|
||||
);
|
||||
|
||||
if (apiKeyResult.success) {
|
||||
finalApiKey = apiKeyResult.apiKey;
|
||||
} else {
|
||||
this.logger.warn('无法获取已有Zulip账号的API Key', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipEmail: createResult.email,
|
||||
error: apiKeyResult.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 存储API Key到Redis
|
||||
if (finalApiKey) {
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
gameUser.id.toString(),
|
||||
finalApiKey
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 在数据库中创建关联记录
|
||||
await this.zulipAccountsService.create({
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId, // 已在上面验证不为 undefined
|
||||
zulipEmail: createResult.email!,
|
||||
zulipFullName: gameUser.nickname,
|
||||
zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 注意:不在注册时建立内存关联,Zulip客户端将在WebSocket连接时创建
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip账号创建/绑定和关联成功', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId,
|
||||
zulipEmail: createResult.email,
|
||||
isExistingUser: createResult.isExistingUser,
|
||||
hasApiKey: !!finalApiKey,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('为用户创建/绑定Zulip账号失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
// 清理可能创建的部分数据
|
||||
try {
|
||||
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
cleanupError: (cleanupError as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
154
src/business/cafe_companion/cafe_companion.controller.ts
Normal file
154
src/business/cafe_companion/cafe_companion.controller.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { Body, Controller, Get, HttpStatus, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { ListCafeCompanionModelsDto } from './dto/list_cafe_companion_models.dto';
|
||||
import { PurchaseCafeCompanionChatTimeDto } from './dto/purchase_cafe_companion_chat_time.dto';
|
||||
import { RegisterCafeCompanionAgentDto } from './dto/register_cafe_companion_agent.dto';
|
||||
import { ResignCafeCompanionEmploymentDto } from './dto/resign_cafe_companion_employment.dto';
|
||||
import { SendCafeCompanionMessageDto } from './dto/send_cafe_companion_message.dto';
|
||||
import { CafeCompanionService } from './cafe_companion.service';
|
||||
|
||||
@ApiTags('cafe-companion')
|
||||
@ApiBearerAuth()
|
||||
@Controller('cafe-companion')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class CafeCompanionController {
|
||||
constructor(private readonly cafeCompanionService: CafeCompanionService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取咖啡店陪伴机器人服务点',
|
||||
description: '返回鲸鱼咖啡馆内可站岗的陪伴机器人服务点和当前占位角色。',
|
||||
})
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店陪伴机器人服务点获取成功' })
|
||||
@Get('service-points')
|
||||
async getServicePoints(@Res() res: Response): Promise<void> {
|
||||
const data = this.cafeCompanionService.getServicePoints();
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店陪伴机器人服务点获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取咖啡店陪伴聊天时长商品',
|
||||
description: '返回玩家点击陪伴机器人后可以购买的聊天时长和鲸币价格。',
|
||||
})
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店陪伴聊天时长获取成功' })
|
||||
@Get('chat-time-products')
|
||||
async getChatTimeProducts(@Res() res: Response): Promise<void> {
|
||||
const data = this.cafeCompanionService.getChatTimeProducts();
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店陪伴聊天时长获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取咖啡店雇佣代理可用模型',
|
||||
description: '玩家填写OpenAI-compatible URL和token后,后端向上游 /models 拉取可用模型列表,token不下发给其他客户端。',
|
||||
})
|
||||
@ApiBody({ type: ListCafeCompanionModelsDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店雇佣代理模型列表获取成功' })
|
||||
@Post('employment/models')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async listEmploymentAgentModels(
|
||||
@Body() listDto: ListCafeCompanionModelsDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.cafeCompanionService.listEmploymentAgentModels(listDto);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店雇佣代理模型列表获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '购买咖啡店陪伴聊天时长',
|
||||
description: '玩家选择陪伴机器人后购买聊天时长。购买成功才会返回可聊天会话。',
|
||||
})
|
||||
@ApiBody({ type: PurchaseCafeCompanionChatTimeDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店陪伴聊天时长购买成功' })
|
||||
@Post('chat-time/purchase')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async purchaseChatTime(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() purchaseDto: PurchaseCafeCompanionChatTimeDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.cafeCompanionService.purchaseChatTime(BigInt(user.sub), purchaseDto);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店陪伴聊天时长购买成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '发送咖啡店陪伴聊天消息',
|
||||
description: '后端根据已购买时长和会话绑定的人设代理调用OpenAI-compatible接口,并维护会话历史。',
|
||||
})
|
||||
@ApiBody({ type: SendCafeCompanionMessageDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店陪伴聊天消息发送成功' })
|
||||
@Post('chat/messages')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async sendChatMessage(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() messageDto: SendCafeCompanionMessageDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.cafeCompanionService.sendChatMessage(BigInt(user.sub), messageDto);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店陪伴聊天消息发送成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '注册被雇佣玩家的咖啡店陪伴机器人',
|
||||
description: '玩家接受咖啡店雇佣时提交人设名称、人设指令、OpenAI-compatible URL、token和模型。token只保存在后端。',
|
||||
})
|
||||
@ApiBody({ type: RegisterCafeCompanionAgentDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店陪伴机器人注册成功' })
|
||||
@Post('employment/agents')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async registerEmploymentAgent(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() registerDto: RegisterCafeCompanionAgentDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.cafeCompanionService.registerEmploymentAgent(BigInt(user.sub), registerDto);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店陪伴机器人注册成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '主动结束咖啡店雇佣',
|
||||
description: '被雇佣玩家点击自己后可主动离职。若早于承诺工时结束,会按剩余比例扣回部分本次雇佣收益,并清理后端保存的代理URL和Token。',
|
||||
})
|
||||
@ApiBody({ type: ResignCafeCompanionEmploymentDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '咖啡店雇佣已结束' })
|
||||
@Post('employment/resign')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async resignEmployment(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() resignDto: ResignCafeCompanionEmploymentDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.cafeCompanionService.resignEmployment(BigInt(user.sub), resignDto);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '咖啡店雇佣已结束',
|
||||
});
|
||||
}
|
||||
}
|
||||
14
src/business/cafe_companion/cafe_companion.module.ts
Normal file
14
src/business/cafe_companion/cafe_companion.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { ChatModule } from '../chat/chat.module';
|
||||
import { CafeCompanionController } from './cafe_companion.controller';
|
||||
import { CafeCompanionService } from './cafe_companion.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoginCoreModule, RedisModule, ChatModule],
|
||||
controllers: [CafeCompanionController],
|
||||
providers: [CafeCompanionService],
|
||||
exports: [CafeCompanionService],
|
||||
})
|
||||
export class CafeCompanionModule {}
|
||||
1221
src/business/cafe_companion/cafe_companion.service.ts
Normal file
1221
src/business/cafe_companion/cafe_companion.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
71
src/business/cafe_companion/cafe_companion.types.ts
Normal file
71
src/business/cafe_companion/cafe_companion.types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type CafeCompanionOwnerType = 'npc' | 'hired_player';
|
||||
export type CafeCompanionChatRole = 'system' | 'user' | 'assistant';
|
||||
export type CafeCompanionAgentProtocol = 'openai' | 'anthropic';
|
||||
|
||||
export interface CafeCompanionServicePoint {
|
||||
id: string;
|
||||
role_type: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CafeCompanionAgent {
|
||||
id: string;
|
||||
owner_type: CafeCompanionOwnerType;
|
||||
owner_id: string;
|
||||
persona_name: string;
|
||||
protocol: CafeCompanionAgentProtocol;
|
||||
base_url: string;
|
||||
token: string;
|
||||
model: string;
|
||||
persona_prompt: string;
|
||||
welcome_message: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface CafeCompanionModelOption {
|
||||
id: string;
|
||||
label: string;
|
||||
object?: string;
|
||||
owned_by?: string;
|
||||
}
|
||||
|
||||
export interface CafeCompanionOccupant {
|
||||
id: string;
|
||||
service_point_id: string;
|
||||
occupant_type: CafeCompanionOwnerType;
|
||||
persona_name: string;
|
||||
chat_agent_id: string;
|
||||
owner_user_id?: string;
|
||||
employment_starts_at?: string;
|
||||
employment_ends_at?: string;
|
||||
employment_minutes?: number;
|
||||
employment_status?: 'active' | 'ended';
|
||||
earned_whale_coin?: number;
|
||||
}
|
||||
|
||||
export interface CafeCompanionChatProduct {
|
||||
minutes: number;
|
||||
price: number;
|
||||
currency: 'whale_coin';
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CafeCompanionChatMessage {
|
||||
id: string;
|
||||
role: CafeCompanionChatRole;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CafeCompanionChatSession {
|
||||
id: string;
|
||||
user_id: string;
|
||||
service_point_id: string;
|
||||
occupant_id: string;
|
||||
chat_agent_id: string;
|
||||
purchased_minutes: number;
|
||||
expires_at: string;
|
||||
messages: CafeCompanionChatMessage[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsIn, IsOptional, IsString, IsUrl, Length } from 'class-validator';
|
||||
|
||||
export class ListCafeCompanionModelsDto {
|
||||
@IsOptional()
|
||||
@IsIn(['openai', 'anthropic'], { message: '代理协议必须是 openai 或 anthropic' })
|
||||
protocol?: 'openai' | 'anthropic';
|
||||
|
||||
@IsUrl({ require_tld: false }, { message: '接口URL格式不正确' })
|
||||
@Length(1, 240, { message: '接口URL长度需在1-240字符之间' })
|
||||
base_url!: string;
|
||||
|
||||
@IsString({ message: '接口Token必须是字符串' })
|
||||
@Length(1, 2000, { message: '接口Token长度需在1-2000字符之间' })
|
||||
token!: string;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsInt, IsOptional, IsString, Length, Matches, Min } from 'class-validator';
|
||||
|
||||
export class PurchaseCafeCompanionChatTimeDto {
|
||||
@IsString({ message: '服务点ID必须是字符串' })
|
||||
@Length(1, 80, { message: '服务点ID长度需在1-80字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '服务点ID格式不正确' })
|
||||
service_point_id!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: '陪伴机器人ID必须是字符串' })
|
||||
@Length(1, 120, { message: '陪伴机器人ID长度需在1-120字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '陪伴机器人ID格式不正确' })
|
||||
companion_id?: string;
|
||||
|
||||
@IsInt({ message: '聊天时长必须是整数分钟' })
|
||||
@Min(1, { message: '聊天时长必须大于0分钟' })
|
||||
minutes!: number;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUrl, Length, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
export class RegisterCafeCompanionAgentDto {
|
||||
@IsString({ message: '服务点ID必须是字符串' })
|
||||
@Length(1, 80, { message: '服务点ID长度需在1-80字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '服务点ID格式不正确' })
|
||||
service_point_id!: string;
|
||||
|
||||
@IsString({ message: '人设名称必须是字符串' })
|
||||
@Length(1, 80, { message: '人设名称长度需在1-80字符之间' })
|
||||
persona_name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['openai', 'anthropic'], { message: '代理协议必须是 openai 或 anthropic' })
|
||||
protocol?: 'openai' | 'anthropic';
|
||||
|
||||
@IsUrl({ require_tld: false }, { message: '接口URL格式不正确' })
|
||||
@Length(1, 240, { message: '接口URL长度需在1-240字符之间' })
|
||||
base_url!: string;
|
||||
|
||||
@IsString({ message: '接口Token必须是字符串' })
|
||||
@Length(1, 2000, { message: '接口Token长度需在1-2000字符之间' })
|
||||
token!: string;
|
||||
|
||||
@IsString({ message: '模型名称必须是字符串' })
|
||||
@Length(1, 120, { message: '模型名称长度需在1-120字符之间' })
|
||||
model!: string;
|
||||
|
||||
@IsString({ message: '人设指令必须是字符串' })
|
||||
@Length(1, 4000, { message: '人设指令长度需在1-4000字符之间' })
|
||||
persona_prompt!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: '欢迎语必须是字符串' })
|
||||
@Length(0, 300, { message: '欢迎语不能超过300字符' })
|
||||
welcome_message?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '启用状态必须是布尔值' })
|
||||
enabled?: boolean;
|
||||
|
||||
@IsInt({ message: '打工时间必须是整数分钟' })
|
||||
@Min(30, { message: '打工时间不能少于30分钟' })
|
||||
@Max(480, { message: '打工时间不能超过8小时' })
|
||||
employment_minutes!: number;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class ResignCafeCompanionEmploymentDto {
|
||||
@IsString({ message: '服务点ID必须是字符串' })
|
||||
@Length(1, 80, { message: '服务点ID长度需在1-80字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '服务点ID格式不正确' })
|
||||
service_point_id!: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class SendCafeCompanionMessageDto {
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
@Length(1, 120, { message: '会话ID长度需在1-120字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '会话ID格式不正确' })
|
||||
session_id!: string;
|
||||
|
||||
@IsString({ message: '消息内容必须是字符串' })
|
||||
@Length(1, 1200, { message: '消息内容长度需在1-1200字符之间' })
|
||||
content!: string;
|
||||
}
|
||||
79
src/business/chat/chat.module.ts
Normal file
79
src/business/chat/chat.module.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 聊天业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合聊天相关的业务逻辑服务
|
||||
* - 提供会话管理、消息过滤、清理等功能
|
||||
* - 通过 SESSION_QUERY_SERVICE 接口向其他模块提供会话查询能力
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务
|
||||
* - 依赖 RedisModule(核心层)提供缓存服务
|
||||
* - 依赖 LoginCoreModule(核心层)提供Token验证
|
||||
* - 依赖 ZulipAccountsModule(核心层)提供Zulip账号数据访问
|
||||
*
|
||||
* 导出接口:
|
||||
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-15: 功能完善 - 添加ZulipAccountsModule依赖,支持登录时初始化Zulip客户端 (修改者: AI)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-15
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { ChatCleanupService } from './services/chat_cleanup.service';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Zulip核心服务模块
|
||||
ZulipCoreModule,
|
||||
// Redis缓存模块
|
||||
RedisModule,
|
||||
// 登录核心模块
|
||||
LoginCoreModule,
|
||||
// Zulip账号数据库模块
|
||||
ZulipAccountsModule.forRoot(),
|
||||
// 账号资料服务:用于初始化在线 presence 外观
|
||||
AuthModule,
|
||||
],
|
||||
providers: [
|
||||
// 主聊天服务
|
||||
ChatService,
|
||||
// 会话管理服务
|
||||
ChatSessionService,
|
||||
// 消息过滤服务
|
||||
ChatFilterService,
|
||||
// 会话清理服务
|
||||
ChatCleanupService,
|
||||
// 会话查询接口(供其他模块依赖)
|
||||
{
|
||||
provide: SESSION_QUERY_SERVICE,
|
||||
useExisting: ChatSessionService,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ChatService,
|
||||
ChatSessionService,
|
||||
ChatFilterService,
|
||||
ChatCleanupService,
|
||||
// 导出会话查询接口
|
||||
SESSION_QUERY_SERVICE,
|
||||
],
|
||||
})
|
||||
export class ChatModule {}
|
||||
1103
src/business/chat/chat.service.ts
Normal file
1103
src/business/chat/chat.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
113
src/business/chat/services/chat_cleanup.service.ts
Normal file
113
src/business/chat/services/chat_cleanup.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 聊天会话清理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定时清理过期会话
|
||||
* - 释放相关资源
|
||||
* - 管理Zulip队列清理
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 移除未使用的依赖 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
/**
|
||||
* 聊天会话清理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 定时检测和清理过期会话
|
||||
* - 释放Zulip队列等相关资源
|
||||
* - 维护系统资源的健康状态
|
||||
*
|
||||
* 主要方法:
|
||||
* - triggerCleanup() - 手动触发会话清理
|
||||
*
|
||||
* 使用场景:
|
||||
* - 系统启动时自动开始定时清理任务
|
||||
* - 管理员手动触发清理操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChatCleanupService.name);
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private readonly CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分钟
|
||||
private readonly SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
constructor(
|
||||
private readonly sessionService: ChatSessionService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('启动会话清理定时任务');
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('停止会话清理定时任务');
|
||||
this.stopCleanupTask();
|
||||
}
|
||||
|
||||
private startCleanupTask() {
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
await this.performCleanup();
|
||||
}, this.CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopCleanupTask() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performCleanup() {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始执行会话清理');
|
||||
|
||||
try {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
|
||||
// 清理Zulip队列
|
||||
for (const queueId of result.zulipQueueIds) {
|
||||
try {
|
||||
// 这里可以添加Zulip队列清理逻辑
|
||||
this.logger.debug('清理Zulip队列', { queueId });
|
||||
} catch (error) {
|
||||
this.logger.warn('清理Zulip队列失败', { queueId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('会话清理完成', {
|
||||
cleanedCount: result.cleanedCount,
|
||||
zulipQueueCount: result.zulipQueueIds.length,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话清理失败', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发清理
|
||||
* @returns 清理结果,包含清理的会话数量
|
||||
*/
|
||||
async triggerCleanup(): Promise<{ cleanedCount: number }> {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
return { cleanedCount: result.cleanedCount };
|
||||
}
|
||||
}
|
||||
264
src/business/chat/services/chat_filter.service.ts
Normal file
264
src/business/chat/services/chat_filter.service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 聊天消息过滤服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 实施内容审核和频率控制
|
||||
* - 敏感词过滤和权限验证
|
||||
* - 防止恶意操作和滥用
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||
|
||||
/**
|
||||
* 内容过滤结果接口
|
||||
*/
|
||||
export interface ContentFilterResult {
|
||||
allowed: boolean;
|
||||
filtered?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感词配置接口
|
||||
*/
|
||||
interface SensitiveWordConfig {
|
||||
word: string;
|
||||
level: 'block' | 'replace';
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息过滤服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 实施消息内容审核和敏感词过滤
|
||||
* - 控制用户发送消息的频率
|
||||
* - 验证用户发送消息的权限
|
||||
*
|
||||
* 主要方法:
|
||||
* - validateMessage() - 综合验证消息(频率+内容+权限)
|
||||
* - filterContent() - 过滤消息内容中的敏感词
|
||||
* - checkRateLimit() - 检查用户发送频率
|
||||
* - validatePermission() - 验证用户发送权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户发送聊天消息前的预处理
|
||||
* - 防止恶意刷屏和不当内容传播
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatFilterService {
|
||||
private readonly RATE_LIMIT_PREFIX = 'chat:rate_limit:';
|
||||
private readonly DEFAULT_RATE_LIMIT = 10;
|
||||
private readonly RATE_LIMIT_WINDOW = 60;
|
||||
private readonly MAX_MESSAGE_LENGTH = 1000;
|
||||
private readonly logger = new Logger(ChatFilterService.name);
|
||||
|
||||
private sensitiveWords: SensitiveWordConfig[] = [
|
||||
{ word: '垃圾', level: 'replace', category: 'offensive' },
|
||||
{ word: '广告', level: 'replace', category: 'spam' },
|
||||
{ word: '刷屏', level: 'replace', category: 'spam' },
|
||||
];
|
||||
|
||||
private readonly BLACKLISTED_DOMAINS = ['malware.com', 'phishing.net'];
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_SERVICE')
|
||||
private readonly redisService: IRedisService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('ChatFilterService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合消息验证
|
||||
* @param userId 用户ID
|
||||
* @param content 消息内容
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 验证结果,包含是否允许、原因和过滤后的内容
|
||||
*/
|
||||
async validateMessage(
|
||||
userId: string,
|
||||
content: string,
|
||||
targetStream: string,
|
||||
currentMap: string
|
||||
): Promise<{ allowed: boolean; reason?: string; filteredContent?: string }> {
|
||||
// 1. 频率限制检查
|
||||
const rateLimitOk = await this.checkRateLimit(userId);
|
||||
if (!rateLimitOk) {
|
||||
return { allowed: false, reason: '发送频率过高,请稍后重试' };
|
||||
}
|
||||
|
||||
// 2. 内容过滤
|
||||
const contentResult = await this.filterContent(content);
|
||||
if (!contentResult.allowed) {
|
||||
return { allowed: false, reason: contentResult.reason };
|
||||
}
|
||||
|
||||
// 3. 权限验证
|
||||
const permissionOk = await this.validatePermission(userId, targetStream, currentMap);
|
||||
if (!permissionOk) {
|
||||
return { allowed: false, reason: '您当前位置无法向该频道发送消息' };
|
||||
}
|
||||
|
||||
return { allowed: true, filteredContent: contentResult.filtered };
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容过滤
|
||||
* @param content 待过滤的消息内容
|
||||
* @returns 过滤结果,包含是否允许、过滤后内容和原因
|
||||
*/
|
||||
async filterContent(content: string): Promise<ContentFilterResult> {
|
||||
// 空内容检查
|
||||
if (!content?.trim()) {
|
||||
return { allowed: false, reason: '消息内容不能为空' };
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (content.length > this.MAX_MESSAGE_LENGTH) {
|
||||
return { allowed: false, reason: `消息内容过长,最多${this.MAX_MESSAGE_LENGTH}字符` };
|
||||
}
|
||||
|
||||
// 空白字符检查
|
||||
if (/^\s+$/.test(content)) {
|
||||
return { allowed: false, reason: '消息不能只包含空白字符' };
|
||||
}
|
||||
|
||||
// 敏感词检查
|
||||
let filteredContent = content;
|
||||
let hasBlockedWord = false;
|
||||
|
||||
for (const wordConfig of this.sensitiveWords) {
|
||||
if (content.toLowerCase().includes(wordConfig.word.toLowerCase())) {
|
||||
if (wordConfig.level === 'block') {
|
||||
hasBlockedWord = true;
|
||||
break;
|
||||
} else {
|
||||
const replacement = '*'.repeat(wordConfig.word.length);
|
||||
filteredContent = filteredContent.replace(
|
||||
new RegExp(this.escapeRegExp(wordConfig.word), 'gi'),
|
||||
replacement
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockedWord) {
|
||||
return { allowed: false, reason: '消息包含不允许的内容' };
|
||||
}
|
||||
|
||||
// 重复字符检查
|
||||
if (this.hasExcessiveRepetition(content)) {
|
||||
return { allowed: false, reason: '消息包含过多重复字符' };
|
||||
}
|
||||
|
||||
// 恶意链接检查
|
||||
if (!this.checkLinks(content)) {
|
||||
return { allowed: false, reason: '消息包含不允许的链接' };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filtered: filteredContent !== content ? filteredContent : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 频率限制检查
|
||||
* @param userId 用户ID
|
||||
* @returns 是否通过频率限制检查
|
||||
*/
|
||||
async checkRateLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`;
|
||||
const currentCount = await this.redisService.get(rateLimitKey);
|
||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
||||
|
||||
if (count >= this.DEFAULT_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await this.redisService.setex(rateLimitKey, this.RATE_LIMIT_WINDOW, '1');
|
||||
} else {
|
||||
await this.redisService.incr(rateLimitKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('频率检查失败', { error: (error as Error).message });
|
||||
return true; // 失败时允许,避免影响正常用户
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限验证
|
||||
* @param userId 用户ID
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 是否有权限发送消息
|
||||
*/
|
||||
async validatePermission(userId: string, targetStream: string, currentMap: string): Promise<boolean> {
|
||||
if (!userId?.trim() || !targetStream?.trim() || !currentMap?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedStream = this.configManager.getStreamByMap(currentMap);
|
||||
if (!allowedStream) return false;
|
||||
|
||||
return targetStream.toLowerCase() === allowedStream.toLowerCase();
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private hasExcessiveRepetition(content: string): boolean {
|
||||
// 连续重复字符检查
|
||||
if (/(.)\1{4,}/.test(content)) return true;
|
||||
|
||||
// 重复短语检查
|
||||
if (/(.{2,})\1{2,}/.test(content)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private checkLinks(content: string): boolean {
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/gi;
|
||||
const urls = content.match(urlPattern);
|
||||
|
||||
if (!urls) return true;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.toLowerCase();
|
||||
|
||||
for (const blacklisted of this.BLACKLISTED_DOMAINS) {
|
||||
if (domain.includes(blacklisted)) return false;
|
||||
}
|
||||
} catch {
|
||||
// URL解析失败,允许通过
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
865
src/business/chat/services/chat_session.service.ts
Normal file
865
src/business/chat/services/chat_session.service.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* 聊天会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 维护WebSocket连接ID与Zulip队列ID的映射关系
|
||||
* - 管理玩家位置跟踪和上下文注入
|
||||
* - 提供空间过滤和会话查询功能
|
||||
* - 实现 ISessionManagerService 接口,供其他模块依赖
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.3
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||||
import {
|
||||
ISessionManagerService,
|
||||
IPosition,
|
||||
IGameSession,
|
||||
IContextInfo,
|
||||
IPlayerAppearance,
|
||||
ICafeCompanionPresence,
|
||||
} from '../../../core/session_core/session_core.interfaces';
|
||||
|
||||
// 常量定义
|
||||
const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const;
|
||||
const SESSION_TIMEOUT = 3600; // 1小时
|
||||
const NEARBY_OBJECT_RADIUS = 50; // 附近对象搜索半径
|
||||
|
||||
/**
|
||||
* 位置信息接口(兼容旧代码)
|
||||
*/
|
||||
export type Position = IPosition;
|
||||
|
||||
/**
|
||||
* 游戏会话接口(兼容旧代码)
|
||||
*/
|
||||
export type GameSession = IGameSession;
|
||||
|
||||
/**
|
||||
* 上下文信息接口(兼容旧代码)
|
||||
*/
|
||||
export type ContextInfo = IContextInfo;
|
||||
|
||||
/**
|
||||
* 聊天好友信息
|
||||
*/
|
||||
export interface ChatFriendInfo {
|
||||
/** 好友用户ID */
|
||||
userId: string;
|
||||
/** 好友用户名 */
|
||||
username: string;
|
||||
/** 当前是否在线 */
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天好友请求信息
|
||||
*/
|
||||
export interface ChatFriendRequestInfo {
|
||||
/** 发起请求的用户ID */
|
||||
userId: string;
|
||||
/** 发起请求的用户名 */
|
||||
username: string;
|
||||
/** 请求创建时间 */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 地图在线玩家信息
|
||||
*/
|
||||
export interface MapPlayerPresence {
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 当前地图ID */
|
||||
mapId: string;
|
||||
/** X坐标 */
|
||||
x: number;
|
||||
/** Y坐标 */
|
||||
y: number;
|
||||
/** 外观同步信息 */
|
||||
appearance?: IPlayerAppearance;
|
||||
/** 咖啡店陪伴服务状态 */
|
||||
cafeCompanion?: ICafeCompanionPresence | null;
|
||||
/** 是否锁定移动 */
|
||||
movementLocked?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayerPresenceMetadata {
|
||||
appearance?: IPlayerAppearance;
|
||||
}
|
||||
|
||||
export interface BusinessPresenceUpdate {
|
||||
mapId?: string;
|
||||
position?: Position;
|
||||
cafeCompanion?: ICafeCompanionPresence | null;
|
||||
movementLocked?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天会话管理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 管理WebSocket连接与用户会话的映射
|
||||
* - 跟踪玩家在游戏地图中的位置
|
||||
* - 根据位置注入聊天上下文(Stream/Topic)
|
||||
*
|
||||
* 主要方法:
|
||||
* - createSession() - 创建新的游戏会话
|
||||
* - getSession() - 获取会话信息
|
||||
* - updatePlayerPosition() - 更新玩家位置
|
||||
* - destroySession() - 销毁会话
|
||||
* - injectContext() - 注入聊天上下文
|
||||
*
|
||||
* 使用场景:
|
||||
* - 玩家登录游戏后的会话管理
|
||||
* - 基于位置的聊天频道自动切换
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatSessionService implements ISessionManagerService {
|
||||
private readonly SESSION_PREFIX = 'chat:session:';
|
||||
private readonly MAP_PLAYERS_PREFIX = 'chat:map_players:';
|
||||
private readonly USER_SESSION_PREFIX = 'chat:user_session:';
|
||||
private readonly FRIENDS_PREFIX = 'chat:friends:';
|
||||
private readonly FRIEND_DATA_PREFIX = 'chat:friend_data:';
|
||||
private readonly FRIEND_REQUESTS_PREFIX = 'chat:friend_requests:';
|
||||
private readonly FRIEND_REQUEST_DATA_PREFIX = 'chat:friend_request_data:';
|
||||
private readonly FRIEND_REQUEST_TIMEOUT = 7 * 24 * 60 * 60; // 7天
|
||||
private readonly DEFAULT_MAP = 'novice_village';
|
||||
private readonly DEFAULT_POSITION: Position = { x: 400, y: 300 };
|
||||
private readonly logger = new Logger(ChatSessionService.name);
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_SERVICE')
|
||||
private readonly redisService: IRedisService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('ChatSessionService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建会话
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param userId 用户ID
|
||||
* @param zulipQueueId Zulip队列ID
|
||||
* @param username 用户名(可选)
|
||||
* @param initialMap 初始地图ID(可选)
|
||||
* @param initialPosition 初始位置(可选)
|
||||
* @returns 创建的游戏会话
|
||||
* @throws Error 参数为空时抛出异常
|
||||
*/
|
||||
async createSession(
|
||||
socketId: string,
|
||||
userId: string,
|
||||
zulipQueueId: string,
|
||||
username?: string,
|
||||
initialMap?: string,
|
||||
initialPosition?: Position,
|
||||
initialAppearance?: IPlayerAppearance,
|
||||
): Promise<GameSession> {
|
||||
this.logger.log('创建游戏会话', { socketId, userId });
|
||||
|
||||
// 参数验证
|
||||
if (!socketId?.trim() || !userId?.trim() || !zulipQueueId?.trim()) {
|
||||
throw new Error('参数不能为空');
|
||||
}
|
||||
|
||||
// 检查并清理旧会话
|
||||
const existingSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${userId}`);
|
||||
if (existingSocketId) {
|
||||
await this.destroySession(existingSocketId);
|
||||
}
|
||||
|
||||
// 创建会话对象
|
||||
const now = new Date();
|
||||
const session: GameSession = {
|
||||
socketId,
|
||||
userId,
|
||||
username: username || `user_${userId}`,
|
||||
zulipQueueId,
|
||||
currentMap: initialMap || this.DEFAULT_MAP,
|
||||
position: initialPosition || { ...this.DEFAULT_POSITION },
|
||||
appearance: this.mergeAppearance(undefined, initialAppearance),
|
||||
lastActivity: now,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// 存储到Redis
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
// 添加到地图玩家列表
|
||||
const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`;
|
||||
await this.redisService.sadd(mapKey, socketId);
|
||||
await this.redisService.expire(mapKey, SESSION_TIMEOUT);
|
||||
|
||||
// 建立用户到会话的映射
|
||||
const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`;
|
||||
await this.redisService.setex(userSessionKey, SESSION_TIMEOUT, socketId);
|
||||
|
||||
this.logger.log('会话创建成功', { socketId, userId, currentMap: session.currentMap });
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 会话信息或null
|
||||
*/
|
||||
async getSession(socketId: string): Promise<GameSession | null> {
|
||||
if (!socketId?.trim()) return null;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return null;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
|
||||
// 更新最后活动时间
|
||||
session.lastActivity = new Date();
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
return session;
|
||||
} catch (error) {
|
||||
this.logger.error('获取会话失败', { socketId, error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID获取当前在线会话的Socket ID
|
||||
* @param userId 用户ID
|
||||
* @returns 在线Socket ID或null
|
||||
*/
|
||||
async getSocketIdByUserId(userId: string): Promise<string | null> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (!normalizedUserId) return null;
|
||||
|
||||
try {
|
||||
const userSessionKey = `${this.USER_SESSION_PREFIX}${normalizedUserId}`;
|
||||
const socketId = await this.redisService.get(userSessionKey);
|
||||
if (!socketId) return null;
|
||||
|
||||
const session = await this.getSession(socketId);
|
||||
if (!session || session.userId !== normalizedUserId) {
|
||||
await this.redisService.del(userSessionKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return socketId;
|
||||
} catch (error) {
|
||||
this.logger.error('获取用户Socket失败', { userId: normalizedUserId, error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加好友
|
||||
* @param userId 当前用户ID
|
||||
* @param friendUserId 好友用户ID
|
||||
* @param friendUsername 好友用户名
|
||||
* @returns 好友信息
|
||||
*/
|
||||
async addFriend(userId: string, friendUserId: string, friendUsername?: string): Promise<ChatFriendInfo> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
const normalizedFriendUserId = friendUserId?.trim();
|
||||
if (!normalizedUserId || !normalizedFriendUserId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
if (normalizedUserId === normalizedFriendUserId) {
|
||||
throw new Error('不能添加自己为好友');
|
||||
}
|
||||
|
||||
const friend: ChatFriendInfo = {
|
||||
userId: normalizedFriendUserId,
|
||||
username: friendUsername?.trim() || `user_${normalizedFriendUserId}`,
|
||||
online: (await this.getSocketIdByUserId(normalizedFriendUserId)) != null,
|
||||
};
|
||||
|
||||
await this.redisService.sadd(this.getFriendsKey(normalizedUserId), normalizedFriendUserId);
|
||||
await this.redisService.set(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId), JSON.stringify({
|
||||
userId: friend.userId,
|
||||
username: friend.username,
|
||||
}));
|
||||
|
||||
return friend;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建好友请求
|
||||
* @param requesterUserId 请求发起人用户ID
|
||||
* @param requesterUsername 请求发起人用户名
|
||||
* @param targetUserId 目标用户ID
|
||||
* @returns 好友请求信息
|
||||
*/
|
||||
async createFriendRequest(
|
||||
requesterUserId: string,
|
||||
requesterUsername: string,
|
||||
targetUserId: string,
|
||||
): Promise<ChatFriendRequestInfo> {
|
||||
const normalizedRequesterUserId = requesterUserId?.trim();
|
||||
const normalizedTargetUserId = targetUserId?.trim();
|
||||
if (!normalizedRequesterUserId || !normalizedTargetUserId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
if (normalizedRequesterUserId === normalizedTargetUserId) {
|
||||
throw new Error('不能添加自己为好友');
|
||||
}
|
||||
if (await this.isFriend(normalizedRequesterUserId, normalizedTargetUserId)) {
|
||||
throw new Error('已经是好友');
|
||||
}
|
||||
|
||||
const request: ChatFriendRequestInfo = {
|
||||
userId: normalizedRequesterUserId,
|
||||
username: requesterUsername?.trim() || `user_${normalizedRequesterUserId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const requestsKey = this.getFriendRequestsKey(normalizedTargetUserId);
|
||||
const requestDataKey = this.getFriendRequestDataKey(normalizedTargetUserId, normalizedRequesterUserId);
|
||||
await this.redisService.sadd(requestsKey, normalizedRequesterUserId);
|
||||
await this.redisService.expire(requestsKey, this.FRIEND_REQUEST_TIMEOUT);
|
||||
await this.redisService.setex(requestDataKey, this.FRIEND_REQUEST_TIMEOUT, JSON.stringify(request));
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接受好友请求,并建立双向好友关系
|
||||
* @param userId 当前用户ID
|
||||
* @param requesterUserId 请求发起人用户ID
|
||||
* @param currentUsername 当前用户名
|
||||
* @returns 双方好友信息
|
||||
*/
|
||||
async acceptFriendRequest(
|
||||
userId: string,
|
||||
requesterUserId: string,
|
||||
currentUsername?: string,
|
||||
): Promise<{ friend: ChatFriendInfo; reciprocalFriend: ChatFriendInfo }> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
const normalizedRequesterUserId = requesterUserId?.trim();
|
||||
if (!normalizedUserId || !normalizedRequesterUserId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
|
||||
const request = await this.getFriendRequestData(normalizedUserId, normalizedRequesterUserId);
|
||||
if (!request) {
|
||||
throw new Error('好友请求不存在或已过期');
|
||||
}
|
||||
|
||||
const friend = await this.addFriend(normalizedUserId, normalizedRequesterUserId, request.username);
|
||||
const reciprocalFriend = await this.addFriend(
|
||||
normalizedRequesterUserId,
|
||||
normalizedUserId,
|
||||
currentUsername?.trim() || `user_${normalizedUserId}`,
|
||||
);
|
||||
await this.clearFriendRequest(normalizedUserId, normalizedRequesterUserId);
|
||||
|
||||
return { friend, reciprocalFriend };
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒绝好友请求
|
||||
* @param userId 当前用户ID
|
||||
* @param requesterUserId 请求发起人用户ID
|
||||
*/
|
||||
async rejectFriendRequest(userId: string, requesterUserId: string): Promise<void> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
const normalizedRequesterUserId = requesterUserId?.trim();
|
||||
if (!normalizedUserId || !normalizedRequesterUserId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
|
||||
await this.clearFriendRequest(normalizedUserId, normalizedRequesterUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取收到的好友请求
|
||||
* @param userId 当前用户ID
|
||||
* @returns 收到的好友请求列表
|
||||
*/
|
||||
async getFriendRequests(userId: string): Promise<ChatFriendRequestInfo[]> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (!normalizedUserId) return [];
|
||||
|
||||
const requesterIds = await this.redisService.smembers(this.getFriendRequestsKey(normalizedUserId));
|
||||
const requests: ChatFriendRequestInfo[] = [];
|
||||
|
||||
for (const requesterUserId of requesterIds) {
|
||||
const normalizedRequesterUserId = requesterUserId?.trim();
|
||||
if (!normalizedRequesterUserId) continue;
|
||||
|
||||
const request = await this.getFriendRequestData(normalizedUserId, normalizedRequesterUserId);
|
||||
if (request) {
|
||||
requests.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
return requests.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除好友
|
||||
* @param userId 当前用户ID
|
||||
* @param friendUserId 好友用户ID
|
||||
*/
|
||||
async removeFriend(userId: string, friendUserId: string): Promise<void> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
const normalizedFriendUserId = friendUserId?.trim();
|
||||
if (!normalizedUserId || !normalizedFriendUserId) {
|
||||
throw new Error('用户ID不能为空');
|
||||
}
|
||||
|
||||
await this.redisService.srem(this.getFriendsKey(normalizedUserId), normalizedFriendUserId);
|
||||
await this.redisService.del(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取好友列表
|
||||
* @param userId 当前用户ID
|
||||
* @returns 好友列表,包含在线状态
|
||||
*/
|
||||
async getFriends(userId: string): Promise<ChatFriendInfo[]> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (!normalizedUserId) return [];
|
||||
|
||||
const friendIds = await this.redisService.smembers(this.getFriendsKey(normalizedUserId));
|
||||
const friends: ChatFriendInfo[] = [];
|
||||
|
||||
for (const friendUserId of friendIds) {
|
||||
const normalizedFriendUserId = friendUserId?.trim();
|
||||
if (!normalizedFriendUserId) continue;
|
||||
|
||||
const data = await this.redisService.get(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId));
|
||||
let username = `user_${normalizedFriendUserId}`;
|
||||
if (data) {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
username = parsed.username || username;
|
||||
} catch (error) {
|
||||
this.logger.warn('好友数据解析失败', { userId: normalizedUserId, friendUserId: normalizedFriendUserId });
|
||||
}
|
||||
}
|
||||
|
||||
friends.push({
|
||||
userId: normalizedFriendUserId,
|
||||
username,
|
||||
online: (await this.getSocketIdByUserId(normalizedFriendUserId)) != null,
|
||||
});
|
||||
}
|
||||
|
||||
return friends.sort((a, b) => {
|
||||
if (a.online !== b.online) return a.online ? -1 : 1;
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上下文注入:根据位置确定Stream/Topic
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param mapId 地图ID(可选,默认使用会话当前地图)
|
||||
* @returns 上下文信息,包含stream和topic
|
||||
*/
|
||||
async injectContext(socketId: string, mapId?: string): Promise<ContextInfo> {
|
||||
try {
|
||||
const session = await this.getSession(socketId);
|
||||
if (!session) throw new Error('会话不存在');
|
||||
|
||||
const targetMapId = mapId || session.currentMap;
|
||||
const stream = this.configManager.getStreamByMap(targetMapId) || 'General';
|
||||
|
||||
let topic = 'General';
|
||||
if (session.position) {
|
||||
const nearbyObject = this.configManager.findNearbyObject(
|
||||
targetMapId,
|
||||
session.position.x,
|
||||
session.position.y,
|
||||
NEARBY_OBJECT_RADIUS
|
||||
);
|
||||
if (nearbyObject) topic = nearbyObject.zulipTopic;
|
||||
}
|
||||
|
||||
return { stream, topic };
|
||||
} catch (error) {
|
||||
this.logger.error('上下文注入失败', { socketId, error: (error as Error).message });
|
||||
return { stream: 'General' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定地图的所有Socket
|
||||
* @param mapId 地图ID
|
||||
* @returns Socket ID列表
|
||||
*/
|
||||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||||
try {
|
||||
const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`;
|
||||
return await this.redisService.smembers(mapKey);
|
||||
} catch (error) {
|
||||
this.logger.error('获取地图玩家失败', { mapId, error: (error as Error).message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定地图内的在线玩家快照
|
||||
* @param mapId 地图ID
|
||||
* @returns 在线玩家列表
|
||||
*/
|
||||
async getPlayersInMap(mapId: string): Promise<MapPlayerPresence[]> {
|
||||
const normalizedMapId = mapId?.trim();
|
||||
if (!normalizedMapId) return [];
|
||||
|
||||
const socketIds = await this.getSocketsInMap(normalizedMapId);
|
||||
const players: MapPlayerPresence[] = [];
|
||||
|
||||
for (const socketId of socketIds) {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${normalizedMapId}`, socketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
if (session.currentMap !== normalizedMapId) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${normalizedMapId}`, socketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const activeSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${session.userId}`);
|
||||
if (activeSocketId !== socketId) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${normalizedMapId}`, socketId);
|
||||
await this.redisService.del(sessionKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
players.push({
|
||||
socketId: session.socketId,
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
mapId: session.currentMap,
|
||||
x: Number(session.position?.x ?? 0),
|
||||
y: Number(session.position?.y ?? 0),
|
||||
appearance: session.appearance,
|
||||
cafeCompanion: session.cafeCompanion ?? null,
|
||||
movementLocked: Boolean(session.movementLocked),
|
||||
});
|
||||
}
|
||||
|
||||
return players;
|
||||
}
|
||||
|
||||
async updateBusinessPresenceByUserId(
|
||||
userId: string,
|
||||
update: BusinessPresenceUpdate,
|
||||
): Promise<MapPlayerPresence | null> {
|
||||
const normalizedUserId = userId?.trim();
|
||||
if (!normalizedUserId) return null;
|
||||
|
||||
const socketId = await this.getSocketIdByUserId(normalizedUserId);
|
||||
if (!socketId) return null;
|
||||
|
||||
return this.updateBusinessPresence(socketId, update);
|
||||
}
|
||||
|
||||
async updateBusinessPresence(
|
||||
socketId: string,
|
||||
update: BusinessPresenceUpdate,
|
||||
): Promise<MapPlayerPresence | null> {
|
||||
if (!socketId?.trim()) return null;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return null;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const oldMapId = session.currentMap;
|
||||
const nextMapId = update.mapId?.trim() || session.currentMap;
|
||||
const mapChanged = oldMapId !== nextMapId;
|
||||
|
||||
session.currentMap = nextMapId;
|
||||
if (update.position) {
|
||||
session.position = { x: update.position.x, y: update.position.y };
|
||||
}
|
||||
if (update.cafeCompanion !== undefined) {
|
||||
session.cafeCompanion = update.cafeCompanion;
|
||||
}
|
||||
if (update.movementLocked !== undefined) {
|
||||
session.movementLocked = update.movementLocked;
|
||||
}
|
||||
session.lastActivity = new Date();
|
||||
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
if (mapChanged) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${oldMapId}`, socketId);
|
||||
const newMapKey = `${this.MAP_PLAYERS_PREFIX}${nextMapId}`;
|
||||
await this.redisService.sadd(newMapKey, socketId);
|
||||
await this.redisService.expire(newMapKey, SESSION_TIMEOUT);
|
||||
}
|
||||
|
||||
return {
|
||||
socketId: session.socketId,
|
||||
userId: session.userId,
|
||||
username: session.username,
|
||||
mapId: session.currentMap,
|
||||
x: Number(session.position?.x ?? 0),
|
||||
y: Number(session.position?.y ?? 0),
|
||||
appearance: session.appearance,
|
||||
cafeCompanion: session.cafeCompanion ?? null,
|
||||
movementLocked: Boolean(session.movementLocked),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('更新玩家业务状态失败', { socketId, error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新玩家位置
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param mapId 地图ID
|
||||
* @param x X坐标
|
||||
* @param y Y坐标
|
||||
* @returns 更新是否成功
|
||||
*/
|
||||
async updatePlayerPosition(
|
||||
socketId: string,
|
||||
mapId: string,
|
||||
x: number,
|
||||
y: number,
|
||||
metadata: PlayerPresenceMetadata = {},
|
||||
): Promise<boolean> {
|
||||
if (!socketId?.trim() || !mapId?.trim()) return false;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return false;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const oldMapId = session.currentMap;
|
||||
const mapChanged = oldMapId !== mapId;
|
||||
|
||||
// 更新会话
|
||||
session.currentMap = mapId;
|
||||
if (!session.movementLocked || mapChanged) {
|
||||
session.position = { x, y };
|
||||
}
|
||||
session.appearance = this.mergeAppearance(session.appearance, metadata.appearance);
|
||||
if (mapId !== 'whale_cafe') {
|
||||
session.cafeCompanion = null;
|
||||
session.movementLocked = false;
|
||||
}
|
||||
session.lastActivity = new Date();
|
||||
await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session));
|
||||
|
||||
// 如果切换地图,更新地图玩家列表
|
||||
if (mapChanged) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${oldMapId}`, socketId);
|
||||
const newMapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`;
|
||||
await this.redisService.sadd(newMapKey, socketId);
|
||||
await this.redisService.expire(newMapKey, SESSION_TIMEOUT);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('更新位置失败', { socketId, error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁会话
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 销毁是否成功
|
||||
*/
|
||||
async destroySession(socketId: string): Promise<boolean> {
|
||||
if (!socketId?.trim()) return false;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
|
||||
if (!sessionData) return true;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
|
||||
// 从地图玩家列表移除
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${session.currentMap}`, socketId);
|
||||
|
||||
// 旧连接的延迟清理不能删除同账号的新会话映射。
|
||||
const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`;
|
||||
const activeSocketId = await this.redisService.get(userSessionKey);
|
||||
if (activeSocketId === socketId) {
|
||||
await this.redisService.del(userSessionKey);
|
||||
}
|
||||
|
||||
// 删除会话数据
|
||||
await this.redisService.del(sessionKey);
|
||||
|
||||
this.logger.log('会话销毁成功', { socketId, userId: session.userId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('销毁会话失败', { socketId, error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期会话
|
||||
* @param timeoutMinutes 超时时间(分钟),默认30分钟
|
||||
* @returns 清理结果,包含清理数量和Zulip队列ID列表
|
||||
*/
|
||||
async cleanupExpiredSessions(timeoutMinutes: number = 30): Promise<{ cleanedCount: number; zulipQueueIds: string[] }> {
|
||||
const expiredSessions: GameSession[] = [];
|
||||
const zulipQueueIds: string[] = [];
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
try {
|
||||
const mapIds = this.configManager.getAllMapIds().length > 0
|
||||
? this.configManager.getAllMapIds()
|
||||
: DEFAULT_MAP_IDS;
|
||||
|
||||
for (const mapId of mapIds) {
|
||||
const socketIds = await this.getSocketsInMap(mapId);
|
||||
|
||||
for (const socketId of socketIds) {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
|
||||
if (!sessionData) {
|
||||
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${mapId}`, socketId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const lastActivityTime = session.lastActivity.getTime();
|
||||
|
||||
if (now - lastActivityTime > timeoutMs) {
|
||||
expiredSessions.push(session);
|
||||
zulipQueueIds.push(session.zulipQueueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of expiredSessions) {
|
||||
await this.destroySession(session.socketId);
|
||||
}
|
||||
|
||||
return { cleanedCount: expiredSessions.length, zulipQueueIds };
|
||||
} catch (error) {
|
||||
this.logger.error('清理过期会话失败', { error: (error as Error).message });
|
||||
return { cleanedCount: 0, zulipQueueIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private serializeSession(session: GameSession): string {
|
||||
return JSON.stringify({
|
||||
...session,
|
||||
lastActivity: session.lastActivity.toISOString(),
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private deserializeSession(data: string): GameSession {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
...parsed,
|
||||
lastActivity: new Date(parsed.lastActivity),
|
||||
createdAt: new Date(parsed.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
private mergeAppearance(
|
||||
current: IPlayerAppearance | undefined,
|
||||
next: IPlayerAppearance | undefined,
|
||||
): IPlayerAppearance | undefined {
|
||||
if (!next) return current;
|
||||
|
||||
const appearance: IPlayerAppearance = { ...(current ?? {}) };
|
||||
if (next.skinId !== undefined) {
|
||||
appearance.skinId = next.skinId;
|
||||
if (next.skinAsset === undefined && current?.skinId !== next.skinId) {
|
||||
delete appearance.skinAsset;
|
||||
}
|
||||
}
|
||||
if (next.avatarId !== undefined) {
|
||||
appearance.avatarId = next.avatarId;
|
||||
}
|
||||
if (next.skinAsset !== undefined) {
|
||||
appearance.skinAsset = next.skinAsset;
|
||||
}
|
||||
|
||||
return appearance;
|
||||
}
|
||||
|
||||
private getFriendsKey(userId: string): string {
|
||||
return `${this.FRIENDS_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
private getFriendDataKey(userId: string, friendUserId: string): string {
|
||||
return `${this.FRIEND_DATA_PREFIX}${userId}:${friendUserId}`;
|
||||
}
|
||||
|
||||
private getFriendRequestsKey(userId: string): string {
|
||||
return `${this.FRIEND_REQUESTS_PREFIX}${userId}`;
|
||||
}
|
||||
|
||||
private getFriendRequestDataKey(userId: string, requesterUserId: string): string {
|
||||
return `${this.FRIEND_REQUEST_DATA_PREFIX}${userId}:${requesterUserId}`;
|
||||
}
|
||||
|
||||
private async isFriend(userId: string, friendUserId: string): Promise<boolean> {
|
||||
const friendIds = await this.redisService.smembers(this.getFriendsKey(userId));
|
||||
return friendIds.includes(friendUserId);
|
||||
}
|
||||
|
||||
private async getFriendRequestData(userId: string, requesterUserId: string): Promise<ChatFriendRequestInfo | null> {
|
||||
const data = await this.redisService.get(this.getFriendRequestDataKey(userId, requesterUserId));
|
||||
if (!data) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
return {
|
||||
userId: parsed.userId || requesterUserId,
|
||||
username: parsed.username || `user_${requesterUserId}`,
|
||||
createdAt: parsed.createdAt || new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('好友请求数据解析失败', { userId, requesterUserId });
|
||||
return {
|
||||
userId: requesterUserId,
|
||||
username: `user_${requesterUserId}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async clearFriendRequest(userId: string, requesterUserId: string): Promise<void> {
|
||||
await this.redisService.srem(this.getFriendRequestsKey(userId), requesterUserId);
|
||||
await this.redisService.del(this.getFriendRequestDataKey(userId, requesterUserId));
|
||||
}
|
||||
}
|
||||
37
src/business/course_resources/course_resources.controller.ts
Normal file
37
src/business/course_resources/course_resources.controller.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { CourseResourcesService } from './course_resources.service';
|
||||
|
||||
@ApiTags('course-resources')
|
||||
@Controller('course-resources')
|
||||
export class CourseResourcesController {
|
||||
constructor(private readonly courseResourcesService: CourseResourcesService) {}
|
||||
|
||||
@Get('datawhale')
|
||||
@ApiOperation({
|
||||
summary: '获取Datawhale课程资源',
|
||||
description: '返回后端定时同步缓存的Datawhale学习中心课程列表。',
|
||||
})
|
||||
async getDatawhaleCourses() {
|
||||
const data = await this.courseResourcesService.getDatawhaleCourses();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '课程资源获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('datawhale/sync')
|
||||
@ApiOperation({
|
||||
summary: '手动同步Datawhale课程资源',
|
||||
description: '开发调试用:立即从Datawhale同步最新课程并返回缓存结果。',
|
||||
})
|
||||
async syncDatawhaleCourses() {
|
||||
const data = await this.courseResourcesService.syncNow();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '课程资源同步成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/business/course_resources/course_resources.module.ts
Normal file
12
src/business/course_resources/course_resources.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { CourseResourcesController } from './course_resources.controller';
|
||||
import { CourseResourcesService } from './course_resources.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [CourseResourcesController],
|
||||
providers: [CourseResourcesService],
|
||||
exports: [CourseResourcesService],
|
||||
})
|
||||
export class CourseResourcesModule {}
|
||||
144
src/business/course_resources/course_resources.service.ts
Normal file
144
src/business/course_resources/course_resources.service.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
CourseResourceItem,
|
||||
CourseResourcesPayload,
|
||||
DatawhaleCourseRow,
|
||||
} from './course_resources.types';
|
||||
|
||||
const DATAWHALE_LEARN_API = 'https://backend.datawhale.cn/api/learn/v2/queryLearnPage';
|
||||
const DATAWHALE_COURSE_DETAIL_URL = 'https://www.datawhale.cn/learn/summary';
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
|
||||
const DIFFICULTY_LABELS: Record<string, string> = {
|
||||
BEGINNER: '初级',
|
||||
INTERMEDIATE: '中级',
|
||||
ADVANCED: '高级',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
GITHUB_TUTORIAL: 'github开源教程',
|
||||
AI_PLUS_X: '高校行',
|
||||
LEARN_CAMP: '学习营',
|
||||
COMPETITION_TUTORIAL: '竞赛教程',
|
||||
TEENAGER: '青少年',
|
||||
AI_EDUCATION_POPULARIZATION: 'AI教育与科普',
|
||||
AI_TOOL_APPLICATION: 'AI工具应用',
|
||||
PROGRAMMING_BASICS: '编程基础',
|
||||
AI_APPLICATION_DEVELOPMENT: 'AI应用开发',
|
||||
AI_PLUS_X_PROFESSIONAL: 'AI+X专业领域',
|
||||
AI_CORE_PRINCIPLES: '人工智能核心原理',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CourseResourcesService implements OnModuleInit {
|
||||
private readonly logger = new Logger(CourseResourcesService.name);
|
||||
private courses: CourseResourceItem[] = [];
|
||||
private syncedAt: Date | null = null;
|
||||
private syncing: Promise<void> | null = null;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.syncNow().catch(error => {
|
||||
this.logger.warn(`Datawhale课程启动同步失败:${this.errorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@Cron('0 3 * * *')
|
||||
async syncDaily(): Promise<void> {
|
||||
await this.syncNow();
|
||||
}
|
||||
|
||||
async getDatawhaleCourses(): Promise<CourseResourcesPayload> {
|
||||
if (this.courses.length === 0) {
|
||||
await this.syncNow();
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'datawhale',
|
||||
courses: this.courses,
|
||||
total: this.courses.length,
|
||||
syncedAt: this.syncedAt ? this.syncedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
async syncNow(): Promise<CourseResourcesPayload> {
|
||||
if (this.syncing) {
|
||||
await this.syncing;
|
||||
return this.getCachedPayload();
|
||||
}
|
||||
|
||||
this.syncing = this.fetchAndReplace();
|
||||
try {
|
||||
await this.syncing;
|
||||
} finally {
|
||||
this.syncing = null;
|
||||
}
|
||||
return this.getCachedPayload();
|
||||
}
|
||||
|
||||
private getCachedPayload(): CourseResourcesPayload {
|
||||
return {
|
||||
source: 'datawhale',
|
||||
courses: this.courses,
|
||||
total: this.courses.length,
|
||||
syncedAt: this.syncedAt ? this.syncedAt.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchAndReplace(): Promise<void> {
|
||||
try {
|
||||
const response = await axios.get(DATAWHALE_LEARN_API, {
|
||||
params: {
|
||||
page: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
sort: 'listpagePrio,createTime,desc',
|
||||
},
|
||||
timeout: 15000,
|
||||
});
|
||||
const rows = response.data?.data?.rows;
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new BadGatewayException('Datawhale课程接口返回格式异常');
|
||||
}
|
||||
|
||||
this.courses = rows.map((row: DatawhaleCourseRow) => this.normalizeCourse(row));
|
||||
this.syncedAt = new Date();
|
||||
this.logger.log(`Datawhale课程同步完成,共 ${this.courses.length} 条`);
|
||||
} catch (error) {
|
||||
if (this.courses.length > 0) {
|
||||
this.logger.warn(`Datawhale课程同步失败,继续使用缓存:${this.errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeCourse(row: DatawhaleCourseRow): CourseResourceItem {
|
||||
const category = row.category || 'OTHER';
|
||||
const difficulty = row.difficulty || 'OTHER';
|
||||
const id = Number(row.id || 0);
|
||||
return {
|
||||
id,
|
||||
title: row.title || '未命名课程',
|
||||
intro: row.intro || '',
|
||||
coverUrl: row.coverUrl || '',
|
||||
category,
|
||||
categoryLabel: CATEGORY_LABELS[category] || category,
|
||||
difficulty,
|
||||
difficultyLabel: DIFFICULTY_LABELS[difficulty] || difficulty,
|
||||
viewCount: Number(row.viewCount || 0),
|
||||
detailUrl: `${DATAWHALE_COURSE_DETAIL_URL}/${id}`,
|
||||
source: 'datawhale',
|
||||
updatedAt: row.updateTime,
|
||||
};
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
32
src/business/course_resources/course_resources.types.ts
Normal file
32
src/business/course_resources/course_resources.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface CourseResourceItem {
|
||||
id: number;
|
||||
title: string;
|
||||
intro: string;
|
||||
coverUrl: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
difficulty: string;
|
||||
difficultyLabel: string;
|
||||
viewCount: number;
|
||||
detailUrl: string;
|
||||
source: 'datawhale';
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CourseResourcesPayload {
|
||||
source: 'datawhale';
|
||||
courses: CourseResourceItem[];
|
||||
total: number;
|
||||
syncedAt: string | null;
|
||||
}
|
||||
|
||||
export interface DatawhaleCourseRow {
|
||||
id?: number;
|
||||
title?: string;
|
||||
intro?: string;
|
||||
coverUrl?: string;
|
||||
category?: string;
|
||||
difficulty?: string;
|
||||
viewCount?: number;
|
||||
updateTime?: string;
|
||||
}
|
||||
3
src/business/course_resources/index.ts
Normal file
3
src/business/course_resources/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './course_resources.module';
|
||||
export * from './course_resources.service';
|
||||
export * from './course_resources.types';
|
||||
460
src/business/location_broadcast/controllers/health.controller.ts
Normal file
460
src/business/location_broadcast/controllers/health.controller.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
/**
|
||||
* 健康检查控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供位置广播系统的健康检查接口
|
||||
* - 监控系统各组件的运行状态
|
||||
* - 提供详细的健康报告和性能指标
|
||||
* - 支持负载均衡器的健康检查需求
|
||||
*
|
||||
* 职责分离:
|
||||
* - 健康检查:检查系统各组件的运行状态
|
||||
* - 性能监控:收集和报告系统性能指标
|
||||
* - 状态报告:提供详细的系统状态信息
|
||||
* - 告警支持:为监控系统提供状态数据
|
||||
*
|
||||
* 技术实现:
|
||||
* - 多层次检查:基础、详细、就绪、存活检查
|
||||
* - 异步检查:并行检查多个组件状态
|
||||
* - 缓存机制:避免频繁的健康检查影响性能
|
||||
* - 标准化响应:符合健康检查标准的响应格式
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建健康检查控制器
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
Inject,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 健康检查控制器
|
||||
*
|
||||
* 提供以下健康检查端点:
|
||||
* - 基础健康检查:简单的服务可用性检查
|
||||
* - 详细健康报告:包含各组件状态的详细报告
|
||||
* - 就绪检查:检查服务是否准备好接收请求
|
||||
* - 存活检查:检查服务是否仍在运行
|
||||
* - 性能指标:系统性能和资源使用情况
|
||||
*/
|
||||
@ApiTags('健康检查')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
private readonly logger = new Logger(HealthController.name);
|
||||
private lastHealthCheck: any = null;
|
||||
private lastHealthCheckTime = 0;
|
||||
private readonly HEALTH_CHECK_CACHE_TTL = 30000; // 30秒缓存
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
@Inject('IUserPositionCore')
|
||||
private readonly userPositionCore: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 基础健康检查
|
||||
*
|
||||
* 提供简单的服务可用性检查,适用于负载均衡器
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: '基础健康检查',
|
||||
description: '检查位置广播服务的基本可用性',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '服务正常',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
timestamp: { type: 'number', example: 1641234567890 },
|
||||
service: { type: 'string', example: 'location-broadcast' },
|
||||
version: { type: 'string', example: '1.0.0' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 503, description: '服务不可用' })
|
||||
async healthCheck() {
|
||||
try {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
service: 'location-broadcast',
|
||||
version: '1.0.0',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('健康检查失败', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
service: 'location-broadcast',
|
||||
error: error?.message || '未知错误',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详细健康报告
|
||||
*
|
||||
* 提供包含各组件状态的详细健康报告
|
||||
*/
|
||||
@Get('detailed')
|
||||
@ApiOperation({
|
||||
summary: '详细健康报告',
|
||||
description: '获取位置广播系统各组件的详细健康状态',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '健康报告获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ok' },
|
||||
timestamp: { type: 'number', example: 1641234567890 },
|
||||
service: { type: 'string', example: 'location-broadcast' },
|
||||
components: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
redis: { type: 'object' },
|
||||
database: { type: 'object' },
|
||||
core_services: { type: 'object' },
|
||||
},
|
||||
},
|
||||
metrics: { type: 'object' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async detailedHealth() {
|
||||
try {
|
||||
// 使用缓存避免频繁检查
|
||||
const now = Date.now();
|
||||
if (this.lastHealthCheck && (now - this.lastHealthCheckTime) < this.HEALTH_CHECK_CACHE_TTL) {
|
||||
return this.lastHealthCheck;
|
||||
}
|
||||
|
||||
const healthReport = await this.performDetailedHealthCheck();
|
||||
|
||||
this.lastHealthCheck = healthReport;
|
||||
this.lastHealthCheckTime = now;
|
||||
|
||||
return healthReport;
|
||||
} catch (error: any) {
|
||||
this.logger.error('详细健康检查失败', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
service: 'location-broadcast',
|
||||
error: error?.message || '未知错误',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 就绪检查
|
||||
*
|
||||
* 检查服务是否准备好接收请求
|
||||
*/
|
||||
@Get('ready')
|
||||
@ApiOperation({
|
||||
summary: '就绪检查',
|
||||
description: '检查位置广播服务是否准备好接收请求',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '服务已就绪',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'ready' },
|
||||
timestamp: { type: 'number', example: 1641234567890 },
|
||||
checks: { type: 'object' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async readinessCheck() {
|
||||
try {
|
||||
const checks = await this.performReadinessChecks();
|
||||
|
||||
const allReady = Object.values(checks).every(check => (check as any).status === 'ok');
|
||||
|
||||
if (!allReady) {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'not_ready',
|
||||
timestamp: Date.now(),
|
||||
checks,
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: Date.now(),
|
||||
checks,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('就绪检查失败', error);
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存活检查
|
||||
*
|
||||
* 检查服务是否仍在运行
|
||||
*/
|
||||
@Get('live')
|
||||
@ApiOperation({
|
||||
summary: '存活检查',
|
||||
description: '检查位置广播服务是否仍在运行',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '服务存活',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', example: 'alive' },
|
||||
timestamp: { type: 'number', example: 1641234567890 },
|
||||
uptime: { type: 'number', example: 3600000 },
|
||||
},
|
||||
},
|
||||
})
|
||||
async livenessCheck() {
|
||||
try {
|
||||
return {
|
||||
status: 'alive',
|
||||
timestamp: Date.now(),
|
||||
uptime: process.uptime() * 1000,
|
||||
memory: process.memoryUsage(),
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('存活检查失败', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
},
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能指标
|
||||
*
|
||||
* 获取系统性能和资源使用情况
|
||||
*/
|
||||
@Get('metrics')
|
||||
@ApiOperation({
|
||||
summary: '性能指标',
|
||||
description: '获取位置广播系统的性能指标和资源使用情况',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '指标获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
timestamp: { type: 'number', example: 1641234567890 },
|
||||
system: { type: 'object' },
|
||||
application: { type: 'object' },
|
||||
performance: { type: 'object' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getMetrics() {
|
||||
try {
|
||||
const metrics = await this.collectMetrics();
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
...metrics,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('获取性能指标失败', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行详细健康检查
|
||||
*/
|
||||
private async performDetailedHealthCheck() {
|
||||
const components = {
|
||||
redis: await this.checkRedisHealth(),
|
||||
database: await this.checkDatabaseHealth(),
|
||||
core_services: await this.checkCoreServicesHealth(),
|
||||
};
|
||||
|
||||
const allHealthy = Object.values(components).every(component => component.status === 'ok');
|
||||
|
||||
return {
|
||||
status: allHealthy ? 'ok' : 'degraded',
|
||||
timestamp: Date.now(),
|
||||
service: 'location-broadcast',
|
||||
version: '1.0.0',
|
||||
components,
|
||||
metrics: await this.collectBasicMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行就绪检查
|
||||
*/
|
||||
private async performReadinessChecks() {
|
||||
return {
|
||||
redis: await this.checkRedisHealth(),
|
||||
database: await this.checkDatabaseHealth(),
|
||||
core_services: await this.checkCoreServicesHealth(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Redis健康状态
|
||||
*/
|
||||
private async checkRedisHealth() {
|
||||
try {
|
||||
// 这里应该实际检查Redis连接
|
||||
// 由于没有直接的Redis服务引用,我们模拟检查
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
response_time: Math.random() * 10,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库健康状态
|
||||
*/
|
||||
private async checkDatabaseHealth() {
|
||||
try {
|
||||
// 这里应该实际检查数据库连接
|
||||
// 由于没有直接的数据库服务引用,我们模拟检查
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: Date.now(),
|
||||
response_time: Math.random() * 20,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查核心服务健康状态
|
||||
*/
|
||||
private async checkCoreServicesHealth() {
|
||||
try {
|
||||
// 检查核心服务是否可用
|
||||
const services = {
|
||||
location_broadcast_core: this.locationBroadcastCore ? 'ok' : 'error',
|
||||
user_position_core: this.userPositionCore ? 'ok' : 'error',
|
||||
};
|
||||
|
||||
const allOk = Object.values(services).every(status => status === 'ok');
|
||||
|
||||
return {
|
||||
status: allOk ? 'ok' : 'error',
|
||||
timestamp: Date.now(),
|
||||
services,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
status: 'error',
|
||||
timestamp: Date.now(),
|
||||
error: error?.message || '未知错误',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集基础指标
|
||||
*/
|
||||
private async collectBasicMetrics() {
|
||||
return {
|
||||
memory: process.memoryUsage(),
|
||||
uptime: process.uptime() * 1000,
|
||||
cpu_usage: process.cpuUsage(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 收集详细指标
|
||||
*/
|
||||
private async collectMetrics() {
|
||||
return {
|
||||
system: {
|
||||
memory: process.memoryUsage(),
|
||||
uptime: process.uptime() * 1000,
|
||||
cpu_usage: process.cpuUsage(),
|
||||
platform: process.platform,
|
||||
node_version: process.version,
|
||||
},
|
||||
application: {
|
||||
service: 'location-broadcast',
|
||||
version: '1.0.0',
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
},
|
||||
performance: {
|
||||
// 这里可以添加应用特定的性能指标
|
||||
// 例如:活跃会话数、位置更新频率等
|
||||
active_sessions: 0, // 实际应该从服务中获取
|
||||
position_updates_per_minute: 0, // 实际应该从服务中获取
|
||||
websocket_connections: 0, // 实际应该从网关中获取
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* 位置广播HTTP API控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供位置广播系统的REST API接口
|
||||
* - 处理HTTP请求和响应格式化
|
||||
* - 集成JWT认证和权限验证
|
||||
* - 提供完整的API文档和错误处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP处理:专注于HTTP请求和响应的处理
|
||||
* - 数据转换:请求参数和响应数据的格式转换
|
||||
* - 权限验证:API访问权限的验证和控制
|
||||
* - 文档生成:Swagger API文档的自动生成
|
||||
*
|
||||
* 技术实现:
|
||||
* - NestJS控制器:使用装饰器定义API端点
|
||||
* - Swagger集成:自动生成API文档
|
||||
* - 数据验证:使用DTO进行请求数据验证
|
||||
* - 异常处理:统一的HTTP异常处理机制
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建位置广播HTTP API控制器
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../../gateway/auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../../core/login_core/login_core.service';
|
||||
|
||||
// 导入业务服务
|
||||
import {
|
||||
LocationBroadcastService,
|
||||
LocationSessionService,
|
||||
LocationPositionService,
|
||||
} from '../services';
|
||||
|
||||
// 导入DTO
|
||||
import {
|
||||
CreateSessionDto,
|
||||
SessionQueryDto,
|
||||
PositionQueryDto,
|
||||
UpdateSessionConfigDto,
|
||||
} from '../dto/api.dto';
|
||||
|
||||
/**
|
||||
* 位置广播API控制器
|
||||
*
|
||||
* 提供以下API端点:
|
||||
* - 会话管理:创建、查询、配置会话
|
||||
* - 位置管理:查询位置、获取统计信息
|
||||
* - 用户管理:获取用户状态、清理数据
|
||||
*/
|
||||
@ApiTags('位置广播')
|
||||
@Controller('location-broadcast')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LocationBroadcastController {
|
||||
private readonly logger = new Logger(LocationBroadcastController.name);
|
||||
|
||||
constructor(
|
||||
private readonly locationBroadcastService: LocationBroadcastService,
|
||||
private readonly locationSessionService: LocationSessionService,
|
||||
private readonly locationPositionService: LocationPositionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
@Post('sessions')
|
||||
@ApiOperation({
|
||||
summary: '创建新游戏会话',
|
||||
description: '创建一个新的位置广播会话,支持自定义配置',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '会话创建成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
sessionId: { type: 'string', example: 'session_12345' },
|
||||
message: { type: 'string', example: '会话创建成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '会话ID已存在' })
|
||||
async createSession(
|
||||
@Body() createSessionDto: CreateSessionDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationSessionService.createSession({
|
||||
...createSessionDto,
|
||||
creatorId: user.sub,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session: result,
|
||||
message: '会话创建成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('创建会话失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '创建会话失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会话列表
|
||||
*/
|
||||
@Get('sessions')
|
||||
@ApiOperation({
|
||||
summary: '查询会话列表',
|
||||
description: '根据条件查询游戏会话列表,支持分页和过滤',
|
||||
})
|
||||
@ApiQuery({ name: 'status', required: false, description: '会话状态' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '分页大小' })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '分页偏移' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
sessions: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number', example: 10 },
|
||||
message: { type: 'string', example: '查询成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async querySessions(
|
||||
@Query() query: SessionQueryDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationSessionService.querySessions(query as any);
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
message: '查询成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('查询会话失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '查询会话失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*/
|
||||
@Get('sessions/:sessionId')
|
||||
@ApiOperation({
|
||||
summary: '获取会话详情',
|
||||
description: '获取指定会话的详细信息,包括用户列表和位置信息',
|
||||
})
|
||||
@ApiParam({ name: 'sessionId', description: '会话ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
session: { type: 'object' },
|
||||
users: { type: 'array', items: { type: 'object' } },
|
||||
message: { type: 'string', example: '获取成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '会话不存在' })
|
||||
async getSessionDetail(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationSessionService.getSessionDetail(sessionId);
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
message: '获取成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('获取会话详情失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '获取会话详情失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询位置信息
|
||||
*/
|
||||
@Get('positions')
|
||||
@ApiOperation({
|
||||
summary: '查询位置信息',
|
||||
description: '根据条件查询用户位置信息,支持范围查询和地图过滤',
|
||||
})
|
||||
@ApiQuery({ name: 'mapId', required: false, description: '地图ID' })
|
||||
@ApiQuery({ name: 'sessionId', required: false, description: '会话ID' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '分页大小' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
positions: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number', example: 5 },
|
||||
message: { type: 'string', example: '查询成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async queryPositions(
|
||||
@Query() query: PositionQueryDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationPositionService.queryPositions(query as any);
|
||||
return {
|
||||
success: true,
|
||||
...result,
|
||||
message: '查询成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('查询位置失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '查询位置失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取位置统计信息
|
||||
*/
|
||||
@Get('positions/stats')
|
||||
@ApiOperation({
|
||||
summary: '获取位置统计信息',
|
||||
description: '获取系统位置数据的统计信息,包括用户分布和活跃度',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
stats: { type: 'object' },
|
||||
message: { type: 'string', example: '获取成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async getPositionStats(@CurrentUser() user: JwtPayload) {
|
||||
try {
|
||||
const stats = await this.locationPositionService.getPositionStats({});
|
||||
return {
|
||||
success: true,
|
||||
stats,
|
||||
message: '获取成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('获取位置统计失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '获取位置统计失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户数据
|
||||
*/
|
||||
@Delete('users/:userId/data')
|
||||
@ApiOperation({
|
||||
summary: '清理用户数据',
|
||||
description: '清理指定用户的位置数据和会话信息',
|
||||
})
|
||||
@ApiParam({ name: 'userId', description: '用户ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '清理成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: '清理成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async cleanupUserData(
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
// 只允许用户清理自己的数据,或管理员清理任意用户数据
|
||||
if (user.sub !== userId && user.role !== 2) {
|
||||
throw new HttpException('权限不足', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
await this.locationBroadcastService.cleanupUserData(userId);
|
||||
return {
|
||||
success: true,
|
||||
message: '清理成功',
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('清理用户数据失败', error);
|
||||
throw new HttpException(
|
||||
error.message || '清理用户数据失败',
|
||||
error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
522
src/business/location_broadcast/dto/api.dto.ts
Normal file
522
src/business/location_broadcast/dto/api.dto.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* API数据传输对象
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义HTTP API的请求和响应数据格式
|
||||
* - 提供数据验证规则和类型约束
|
||||
* - 支持Swagger API文档自动生成
|
||||
* - 实现统一的API数据交换标准
|
||||
*
|
||||
* 职责分离:
|
||||
* - 请求验证:HTTP请求数据的格式验证
|
||||
* - 类型安全:TypeScript类型约束和检查
|
||||
* - 文档生成:Swagger API文档的自动生成
|
||||
* - 数据转换:前端和后端数据格式的标准化
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建API DTO,支持位置广播系统
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { IsString, IsNumber, IsOptional, IsBoolean, IsArray, Length, Min, Max, IsEnum } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 创建会话DTO
|
||||
*/
|
||||
export class CreateSessionDto {
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
@Length(1, 100, { message: '会话ID长度必须在1-100个字符之间' })
|
||||
sessionId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话名称',
|
||||
example: '我的游戏会话'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话名称必须是字符串' })
|
||||
@Length(1, 200, { message: '会话名称长度必须在1-200个字符之间' })
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话描述',
|
||||
example: '这是一个多人游戏会话'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话描述必须是字符串' })
|
||||
@Length(0, 500, { message: '会话描述长度不能超过500个字符' })
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最大用户数',
|
||||
example: 100,
|
||||
minimum: 1,
|
||||
maximum: 1000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '最大用户数必须是数字' })
|
||||
@Min(1, { message: '最大用户数不能小于1' })
|
||||
@Max(1000, { message: '最大用户数不能超过1000' })
|
||||
@Type(() => Number)
|
||||
maxUsers?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否允许观察者',
|
||||
example: true
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '允许观察者必须是布尔值' })
|
||||
allowObservers?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话密码',
|
||||
example: 'password123'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话密码必须是字符串' })
|
||||
@Length(1, 50, { message: '会话密码长度必须在1-50个字符之间' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '允许的地图列表',
|
||||
example: ['plaza', 'forest', 'mountain'],
|
||||
type: [String]
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: '允许的地图必须是数组' })
|
||||
@IsString({ each: true, message: '地图ID必须是字符串' })
|
||||
allowedMaps?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '广播范围(像素)',
|
||||
example: 1000,
|
||||
minimum: 0,
|
||||
maximum: 10000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '广播范围必须是数字' })
|
||||
@Min(0, { message: '广播范围不能小于0' })
|
||||
@Max(10000, { message: '广播范围不能超过10000' })
|
||||
@Type(() => Number)
|
||||
broadcastRange?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '扩展元数据',
|
||||
example: { theme: 'dark', language: 'zh-CN' }
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话DTO
|
||||
*/
|
||||
export class JoinSessionDto {
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
@Length(1, 100, { message: '会话ID长度必须在1-100个字符之间' })
|
||||
sessionId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话密码',
|
||||
example: 'password123'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话密码必须是字符串' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '初始位置',
|
||||
example: {
|
||||
mapId: 'plaza',
|
||||
x: 100,
|
||||
y: 200
|
||||
}
|
||||
})
|
||||
@IsOptional()
|
||||
initialPosition?: {
|
||||
mapId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新位置DTO
|
||||
*/
|
||||
export class UpdatePositionDto {
|
||||
@ApiProperty({
|
||||
description: '地图ID',
|
||||
example: 'plaza'
|
||||
})
|
||||
@IsString({ message: '地图ID必须是字符串' })
|
||||
@Length(1, 50, { message: '地图ID长度必须在1-50个字符之间' })
|
||||
mapId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'X轴坐标',
|
||||
example: 100.5
|
||||
})
|
||||
@IsNumber({}, { message: 'X坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
x: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Y轴坐标',
|
||||
example: 200.3
|
||||
})
|
||||
@IsNumber({}, { message: 'Y坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
y: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '时间戳必须是数字' })
|
||||
@Type(() => Number)
|
||||
timestamp?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '扩展元数据',
|
||||
example: { speed: 5.2, direction: 'north' }
|
||||
})
|
||||
@IsOptional()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话查询DTO
|
||||
*/
|
||||
export class SessionQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '会话状态',
|
||||
example: 'active',
|
||||
enum: ['active', 'idle', 'paused', 'ended']
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(['active', 'idle', 'paused', 'ended'], { message: '会话状态值无效' })
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最小用户数',
|
||||
example: 1,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '最小用户数必须是数字' })
|
||||
@Min(0, { message: '最小用户数不能小于0' })
|
||||
@Type(() => Number)
|
||||
minUsers?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '最大用户数',
|
||||
example: 100,
|
||||
minimum: 1
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '最大用户数必须是数字' })
|
||||
@Min(1, { message: '最大用户数不能小于1' })
|
||||
@Type(() => Number)
|
||||
maxUsers?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '只显示公开会话',
|
||||
example: true
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '公开会话标志必须是布尔值' })
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
publicOnly?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '创建者ID',
|
||||
example: 'user123'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '创建者ID必须是字符串' })
|
||||
creatorId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '分页偏移',
|
||||
example: 0,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '分页偏移必须是数字' })
|
||||
@Min(0, { message: '分页偏移不能小于0' })
|
||||
@Type(() => Number)
|
||||
offset?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '分页大小',
|
||||
example: 10,
|
||||
minimum: 1,
|
||||
maximum: 100
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '分页大小必须是数字' })
|
||||
@Min(1, { message: '分页大小不能小于1' })
|
||||
@Max(100, { message: '分页大小不能超过100' })
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置查询DTO
|
||||
*/
|
||||
export class PositionQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '用户ID列表(逗号分隔)',
|
||||
example: 'user1,user2,user3'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '用户ID列表必须是字符串' })
|
||||
userIds?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '地图ID',
|
||||
example: 'plaza'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '地图ID必须是字符串' })
|
||||
mapId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
sessionId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '范围查询中心X坐标',
|
||||
example: 100
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '中心X坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
centerX?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '范围查询中心Y坐标',
|
||||
example: 200
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '中心Y坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
centerY?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '范围查询半径',
|
||||
example: 500,
|
||||
minimum: 0,
|
||||
maximum: 10000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '查询半径必须是数字' })
|
||||
@Min(0, { message: '查询半径不能小于0' })
|
||||
@Max(10000, { message: '查询半径不能超过10000' })
|
||||
@Type(() => Number)
|
||||
radius?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '分页偏移',
|
||||
example: 0,
|
||||
minimum: 0
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '分页偏移必须是数字' })
|
||||
@Min(0, { message: '分页偏移不能小于0' })
|
||||
@Type(() => Number)
|
||||
offset?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '分页大小',
|
||||
example: 50,
|
||||
minimum: 1,
|
||||
maximum: 1000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '分页大小必须是数字' })
|
||||
@Min(1, { message: '分页大小不能小于1' })
|
||||
@Max(1000, { message: '分页大小不能超过1000' })
|
||||
@Type(() => Number)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话配置DTO
|
||||
*/
|
||||
export class UpdateSessionConfigDto {
|
||||
@ApiPropertyOptional({
|
||||
description: '最大用户数',
|
||||
example: 150,
|
||||
minimum: 1,
|
||||
maximum: 1000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '最大用户数必须是数字' })
|
||||
@Min(1, { message: '最大用户数不能小于1' })
|
||||
@Max(1000, { message: '最大用户数不能超过1000' })
|
||||
@Type(() => Number)
|
||||
maxUsers?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否允许观察者',
|
||||
example: false
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '允许观察者必须是布尔值' })
|
||||
allowObservers?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '会话密码',
|
||||
example: 'newpassword123'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话密码必须是字符串' })
|
||||
@Length(0, 50, { message: '会话密码长度不能超过50个字符' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '允许的地图列表',
|
||||
example: ['plaza', 'forest'],
|
||||
type: [String]
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray({ message: '允许的地图必须是数组' })
|
||||
@IsString({ each: true, message: '地图ID必须是字符串' })
|
||||
allowedMaps?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '广播范围(像素)',
|
||||
example: 1500,
|
||||
minimum: 0,
|
||||
maximum: 10000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '广播范围必须是数字' })
|
||||
@Min(0, { message: '广播范围不能小于0' })
|
||||
@Max(10000, { message: '广播范围不能超过10000' })
|
||||
@Type(() => Number)
|
||||
broadcastRange?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '是否公开',
|
||||
example: true
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: '公开标志必须是布尔值' })
|
||||
isPublic?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '自动清理时间(分钟)',
|
||||
example: 120,
|
||||
minimum: 1,
|
||||
maximum: 1440
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '自动清理时间必须是数字' })
|
||||
@Min(1, { message: '自动清理时间不能小于1分钟' })
|
||||
@Max(1440, { message: '自动清理时间不能超过1440分钟(24小时)' })
|
||||
@Type(() => Number)
|
||||
autoCleanupMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用API响应DTO
|
||||
*/
|
||||
export class ApiResponseDto<T = any> {
|
||||
@ApiProperty({
|
||||
description: '操作是否成功',
|
||||
example: true
|
||||
})
|
||||
success: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '响应数据'
|
||||
})
|
||||
data?: T;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '响应消息',
|
||||
example: '操作成功'
|
||||
})
|
||||
message?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '错误信息',
|
||||
example: '参数验证失败'
|
||||
})
|
||||
error?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应DTO
|
||||
*/
|
||||
export class PaginatedResponseDto<T = any> {
|
||||
@ApiProperty({
|
||||
description: '数据列表',
|
||||
type: 'array'
|
||||
})
|
||||
items: T[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '总记录数',
|
||||
example: 100
|
||||
})
|
||||
total: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前页码',
|
||||
example: 1
|
||||
})
|
||||
page: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '每页大小',
|
||||
example: 10
|
||||
})
|
||||
pageSize: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '总页数',
|
||||
example: 10
|
||||
})
|
||||
totalPages: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否有下一页',
|
||||
example: true
|
||||
})
|
||||
hasNext: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
description: '是否有上一页',
|
||||
example: false
|
||||
})
|
||||
hasPrev: boolean;
|
||||
}
|
||||
36
src/business/location_broadcast/dto/index.ts
Normal file
36
src/business/location_broadcast/dto/index.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 位置广播DTO导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 统一导出所有位置广播相关的DTO
|
||||
* - 提供便捷的DTO导入接口
|
||||
* - 支持模块化的数据传输对象管理
|
||||
* - 简化数据类型的使用和维护
|
||||
*
|
||||
* 职责分离:
|
||||
* - 类型导出:统一管理所有数据传输对象的导出
|
||||
* - 接口简化:为外部模块提供简洁的导入方式
|
||||
* - 版本管理:统一管理DTO的版本变更和兼容性
|
||||
* - 文档支持:为DTO使用提供清晰的类型指南
|
||||
*
|
||||
* 技术实现:
|
||||
* - TypeScript导出:充分利用TypeScript的类型系统
|
||||
* - 分类导出:按功能和用途分类导出不同的DTO
|
||||
* - 命名规范:遵循统一的DTO命名和导出规范
|
||||
* - 类型安全:确保导出的类型定义完整和准确
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 规范优化 - 完善文件头注释,符合代码检查规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
// WebSocket消息DTO
|
||||
export * from './websocket_message.dto';
|
||||
export * from './websocket_response.dto';
|
||||
|
||||
// API请求响应DTO
|
||||
export * from './api.dto';
|
||||
334
src/business/location_broadcast/dto/websocket_message.dto.ts
Normal file
334
src/business/location_broadcast/dto/websocket_message.dto.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* WebSocket消息数据传输对象
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义WebSocket通信的消息格式和验证规则
|
||||
* - 提供客户端和服务端之间的数据交换标准
|
||||
* - 支持位置广播系统的实时通信需求
|
||||
* - 实现消息类型的统一管理和验证
|
||||
*
|
||||
* 职责分离:
|
||||
* - 消息格式:定义WebSocket消息的标准结构
|
||||
* - 数据验证:使用class-validator进行输入验证
|
||||
* - 类型安全:提供TypeScript类型约束
|
||||
* - 接口规范:统一的消息交换格式
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建WebSocket消息DTO,支持位置广播系统
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { IsString, IsNumber, IsNotEmpty, IsOptional, IsObject, Length } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 加入会话消息DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义用户加入游戏会话的请求数据
|
||||
* - 验证会话ID和认证token的格式
|
||||
* - 支持可选的初始位置设置
|
||||
*/
|
||||
export class JoinSessionMessage {
|
||||
/**
|
||||
* 消息类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息类型',
|
||||
example: 'join_session',
|
||||
enum: ['join_session']
|
||||
})
|
||||
@IsString({ message: '消息类型必须是字符串' })
|
||||
@IsOptional()
|
||||
type?: 'join_session' = 'join_session';
|
||||
|
||||
/**
|
||||
* 游戏会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '游戏会话ID',
|
||||
example: 'session_12345',
|
||||
minLength: 1,
|
||||
maxLength: 100
|
||||
})
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
@IsNotEmpty({ message: '会话ID不能为空' })
|
||||
@Length(1, 100, { message: '会话ID长度必须在1-100个字符之间' })
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* JWT认证token
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'JWT认证token',
|
||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
|
||||
})
|
||||
@IsString({ message: 'Token必须是字符串' })
|
||||
@IsNotEmpty({ message: 'Token不能为空' })
|
||||
token: string;
|
||||
|
||||
/**
|
||||
* 会话密码(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '会话密码(如果会话需要密码)',
|
||||
example: 'password123'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '会话密码必须是字符串' })
|
||||
password?: string;
|
||||
|
||||
/**
|
||||
* 初始位置(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '用户初始位置',
|
||||
example: {
|
||||
mapId: 'plaza',
|
||||
x: 100,
|
||||
y: 200
|
||||
}
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject({ message: '初始位置必须是对象格式' })
|
||||
initialPosition?: {
|
||||
mapId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 离开会话消息DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义用户离开游戏会话的请求数据
|
||||
* - 支持主动离开和被动断开的区分
|
||||
* - 提供离开原因的记录
|
||||
*/
|
||||
export class LeaveSessionMessage {
|
||||
/**
|
||||
* 消息类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息类型',
|
||||
example: 'leave_session',
|
||||
enum: ['leave_session']
|
||||
})
|
||||
@IsString({ message: '消息类型必须是字符串' })
|
||||
@IsOptional()
|
||||
type?: 'leave_session' = 'leave_session';
|
||||
|
||||
/**
|
||||
* 游戏会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '游戏会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
@IsString({ message: '会话ID必须是字符串' })
|
||||
@IsNotEmpty({ message: '会话ID不能为空' })
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* 离开原因(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '离开原因',
|
||||
example: 'user_left',
|
||||
enum: ['user_left', 'connection_lost', 'kicked', 'error']
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '离开原因必须是字符串' })
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新消息DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义用户位置更新的请求数据
|
||||
* - 验证位置坐标和地图ID的有效性
|
||||
* - 支持位置元数据的扩展
|
||||
*/
|
||||
export class PositionUpdateMessage {
|
||||
/**
|
||||
* 消息类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息类型',
|
||||
example: 'position_update',
|
||||
enum: ['position_update']
|
||||
})
|
||||
@IsString({ message: '消息类型必须是字符串' })
|
||||
@IsOptional()
|
||||
type?: 'position_update' = 'position_update';
|
||||
|
||||
/**
|
||||
* 地图ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '地图ID',
|
||||
example: 'plaza',
|
||||
minLength: 1,
|
||||
maxLength: 50
|
||||
})
|
||||
@IsString({ message: '地图ID必须是字符串' })
|
||||
@IsNotEmpty({ message: '地图ID不能为空' })
|
||||
@Length(1, 50, { message: '地图ID长度必须在1-50个字符之间' })
|
||||
mapId: string;
|
||||
|
||||
/**
|
||||
* X轴坐标
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'X轴坐标',
|
||||
example: 100.5,
|
||||
type: 'number'
|
||||
})
|
||||
@IsNumber({}, { message: 'X坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
x: number;
|
||||
|
||||
/**
|
||||
* Y轴坐标
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Y轴坐标',
|
||||
example: 200.3,
|
||||
type: 'number'
|
||||
})
|
||||
@IsNumber({}, { message: 'Y坐标必须是数字' })
|
||||
@Type(() => Number)
|
||||
y: number;
|
||||
|
||||
/**
|
||||
* 时间戳(可选,服务端会自动设置)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '位置更新时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '时间戳必须是数字' })
|
||||
@Type(() => Number)
|
||||
timestamp?: number;
|
||||
|
||||
/**
|
||||
* 扩展元数据(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '位置扩展元数据',
|
||||
example: {
|
||||
speed: 5.2,
|
||||
direction: 'north'
|
||||
}
|
||||
})
|
||||
@IsOptional()
|
||||
@IsObject({ message: '元数据必须是对象格式' })
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳消息DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义WebSocket连接的心跳检测消息
|
||||
* - 维持连接活跃状态
|
||||
* - 检测连接质量和延迟
|
||||
*/
|
||||
export class HeartbeatMessage {
|
||||
/**
|
||||
* 消息类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息类型',
|
||||
example: 'heartbeat',
|
||||
enum: ['heartbeat']
|
||||
})
|
||||
@IsString({ message: '消息类型必须是字符串' })
|
||||
@IsOptional()
|
||||
type?: 'heartbeat' = 'heartbeat';
|
||||
|
||||
/**
|
||||
* 客户端时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '客户端发送时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
@IsNumber({}, { message: '时间戳必须是数字' })
|
||||
@Type(() => Number)
|
||||
timestamp: number;
|
||||
|
||||
/**
|
||||
* 序列号(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '心跳序列号',
|
||||
example: 1
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '序列号必须是数字' })
|
||||
@Type(() => Number)
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用WebSocket消息DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义所有WebSocket消息的基础结构
|
||||
* - 提供消息类型的统一管理
|
||||
* - 支持消息的路由和处理
|
||||
*/
|
||||
export class WebSocketMessage {
|
||||
/**
|
||||
* 消息类型
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息类型',
|
||||
example: 'join_session',
|
||||
enum: ['join_session', 'leave_session', 'position_update', 'heartbeat']
|
||||
})
|
||||
@IsString({ message: '消息类型必须是字符串' })
|
||||
@IsNotEmpty({ message: '消息类型不能为空' })
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* 消息数据
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息数据',
|
||||
example: {}
|
||||
})
|
||||
@IsObject({ message: '消息数据必须是对象格式' })
|
||||
data: any;
|
||||
|
||||
/**
|
||||
* 消息ID(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '消息唯一标识',
|
||||
example: 'msg_12345'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: '消息ID必须是字符串' })
|
||||
messageId?: string;
|
||||
|
||||
/**
|
||||
* 时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '消息时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
@IsNumber({}, { message: '时间戳必须是数字' })
|
||||
@Type(() => Number)
|
||||
timestamp: number;
|
||||
}
|
||||
524
src/business/location_broadcast/dto/websocket_response.dto.ts
Normal file
524
src/business/location_broadcast/dto/websocket_response.dto.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* WebSocket响应数据传输对象
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义WebSocket服务端响应的消息格式
|
||||
* - 提供统一的响应结构和错误处理格式
|
||||
* - 支持位置广播系统的实时响应需求
|
||||
* - 实现响应类型的标准化管理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 响应格式:定义服务端响应的标准结构
|
||||
* - 错误处理:统一的错误响应格式
|
||||
* - 类型安全:提供TypeScript类型约束
|
||||
* - 数据完整性:确保响应数据的完整性
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建WebSocket响应DTO,支持位置广播系统
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 会话加入成功响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义用户成功加入会话后的响应数据
|
||||
* - 包含会话信息和其他用户的位置数据
|
||||
* - 提供完整的会话状态视图
|
||||
*/
|
||||
export class SessionJoinedResponse {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'session_joined',
|
||||
enum: ['session_joined']
|
||||
})
|
||||
type: 'session_joined' = 'session_joined';
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* 会话中的用户列表
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '会话中的用户列表',
|
||||
example: [
|
||||
{
|
||||
userId: 'user1',
|
||||
socketId: 'socket1',
|
||||
joinedAt: 1641024000000,
|
||||
lastSeen: 1641024000000,
|
||||
status: 'online'
|
||||
}
|
||||
]
|
||||
})
|
||||
users: Array<{
|
||||
userId: string;
|
||||
socketId: string;
|
||||
joinedAt: number;
|
||||
lastSeen: number;
|
||||
status: string;
|
||||
position?: {
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
timestamp: number;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 其他用户的位置信息
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '其他用户的位置信息',
|
||||
example: [
|
||||
{
|
||||
userId: 'user2',
|
||||
x: 150,
|
||||
y: 250,
|
||||
mapId: 'plaza',
|
||||
timestamp: 1641024000000
|
||||
}
|
||||
]
|
||||
})
|
||||
positions: Array<{
|
||||
userId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 会话配置信息
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '会话配置信息',
|
||||
example: {
|
||||
maxUsers: 100,
|
||||
allowObservers: true,
|
||||
broadcastRange: 1000
|
||||
}
|
||||
})
|
||||
config?: {
|
||||
maxUsers: number;
|
||||
allowObservers: boolean;
|
||||
broadcastRange?: number;
|
||||
mapRestriction?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户加入通知响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 通知会话中其他用户有新用户加入
|
||||
* - 包含新用户的基本信息和位置
|
||||
* - 支持实时用户状态更新
|
||||
*/
|
||||
export class UserJoinedNotification {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'user_joined',
|
||||
enum: ['user_joined']
|
||||
})
|
||||
type: 'user_joined' = 'user_joined';
|
||||
|
||||
/**
|
||||
* 加入的用户信息
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '加入的用户信息',
|
||||
example: {
|
||||
userId: 'user3',
|
||||
socketId: 'socket3',
|
||||
joinedAt: 1641024000000,
|
||||
status: 'online'
|
||||
}
|
||||
})
|
||||
user: {
|
||||
userId: string;
|
||||
socketId: string;
|
||||
joinedAt: number;
|
||||
status: string;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户位置信息(如果有)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '用户位置信息',
|
||||
example: {
|
||||
x: 100,
|
||||
y: 200,
|
||||
mapId: 'plaza',
|
||||
timestamp: 1641024000000
|
||||
}
|
||||
})
|
||||
position?: {
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户离开通知响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 通知会话中其他用户有用户离开
|
||||
* - 包含离开用户的ID和离开原因
|
||||
* - 支持会话状态的实时更新
|
||||
*/
|
||||
export class UserLeftNotification {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'user_left',
|
||||
enum: ['user_left']
|
||||
})
|
||||
type: 'user_left' = 'user_left';
|
||||
|
||||
/**
|
||||
* 离开的用户ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '离开的用户ID',
|
||||
example: 'user3'
|
||||
})
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* 离开原因
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '离开原因',
|
||||
example: 'user_left',
|
||||
enum: ['user_left', 'connection_lost', 'kicked', 'timeout', 'error']
|
||||
})
|
||||
reason: string;
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置广播响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 广播用户位置更新给会话中的其他用户
|
||||
* - 包含完整的位置信息和时间戳
|
||||
* - 支持位置数据的实时同步
|
||||
*/
|
||||
export class PositionBroadcast {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'position_broadcast',
|
||||
enum: ['position_broadcast']
|
||||
})
|
||||
type: 'position_broadcast' = 'position_broadcast';
|
||||
|
||||
/**
|
||||
* 更新位置的用户ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '更新位置的用户ID',
|
||||
example: 'user1'
|
||||
})
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* 位置信息
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '位置信息',
|
||||
example: {
|
||||
x: 150,
|
||||
y: 250,
|
||||
mapId: 'forest',
|
||||
timestamp: 1641024000000
|
||||
}
|
||||
})
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
mapId: string;
|
||||
timestamp: number;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 会话ID
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '会话ID',
|
||||
example: 'session_12345'
|
||||
})
|
||||
sessionId: string;
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 心跳响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 响应客户端的心跳检测请求
|
||||
* - 提供服务端时间戳用于延迟计算
|
||||
* - 维持WebSocket连接的活跃状态
|
||||
*/
|
||||
export class HeartbeatResponse {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'heartbeat_response',
|
||||
enum: ['heartbeat_response']
|
||||
})
|
||||
type: 'heartbeat_response' = 'heartbeat_response';
|
||||
|
||||
/**
|
||||
* 客户端时间戳(回显)
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '客户端时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
clientTimestamp: number;
|
||||
|
||||
/**
|
||||
* 服务端时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '服务端时间戳',
|
||||
example: 1641024000100
|
||||
})
|
||||
serverTimestamp: number;
|
||||
|
||||
/**
|
||||
* 序列号(回显)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '心跳序列号',
|
||||
example: 1
|
||||
})
|
||||
sequence?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义WebSocket通信中的错误响应格式
|
||||
* - 提供详细的错误信息和错误代码
|
||||
* - 支持客户端的错误处理和用户提示
|
||||
*/
|
||||
export class ErrorResponse {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'error',
|
||||
enum: ['error']
|
||||
})
|
||||
type: 'error' = 'error';
|
||||
|
||||
/**
|
||||
* 错误代码
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'INVALID_TOKEN',
|
||||
enum: [
|
||||
'INVALID_TOKEN',
|
||||
'SESSION_NOT_FOUND',
|
||||
'SESSION_FULL',
|
||||
'INVALID_POSITION',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
'INTERNAL_ERROR',
|
||||
'VALIDATION_ERROR',
|
||||
'PERMISSION_DENIED'
|
||||
]
|
||||
})
|
||||
code: string;
|
||||
|
||||
/**
|
||||
* 错误消息
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '错误消息',
|
||||
example: '无效的认证令牌'
|
||||
})
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* 错误详情(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '错误详情',
|
||||
example: {
|
||||
field: 'token',
|
||||
reason: 'expired'
|
||||
}
|
||||
})
|
||||
details?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* 原始消息(可选,用于错误追踪)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '引起错误的原始消息',
|
||||
example: {
|
||||
type: 'join_session',
|
||||
sessionId: 'invalid_session'
|
||||
}
|
||||
})
|
||||
originalMessage?: any;
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功响应DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义通用的成功响应格式
|
||||
* - 用于确认操作成功完成
|
||||
* - 提供操作结果的反馈
|
||||
*/
|
||||
export class SuccessResponse {
|
||||
/**
|
||||
* 响应类型标识
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应类型',
|
||||
example: 'success',
|
||||
enum: ['success']
|
||||
})
|
||||
type: 'success' = 'success';
|
||||
|
||||
/**
|
||||
* 成功消息
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '成功消息',
|
||||
example: '操作成功完成'
|
||||
})
|
||||
message: string;
|
||||
|
||||
/**
|
||||
* 操作类型
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '操作类型',
|
||||
example: 'position_update',
|
||||
enum: ['join_session', 'leave_session', 'position_update', 'heartbeat']
|
||||
})
|
||||
operation: string;
|
||||
|
||||
/**
|
||||
* 结果数据(可选)
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
description: '操作结果数据',
|
||||
example: {
|
||||
affected: 1,
|
||||
duration: 50
|
||||
}
|
||||
})
|
||||
data?: Record<string, any>;
|
||||
|
||||
/**
|
||||
* 响应时间戳
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: '响应时间戳',
|
||||
example: 1641024000000
|
||||
})
|
||||
timestamp: number;
|
||||
}
|
||||
666
src/business/location_broadcast/health.controller.ts
Normal file
666
src/business/location_broadcast/health.controller.ts
Normal file
@@ -0,0 +1,666 @@
|
||||
/**
|
||||
* 健康检查控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供系统健康状态检查接口
|
||||
* - 监控各个组件的运行状态
|
||||
* - 提供性能指标和统计信息
|
||||
* - 支持负载均衡器的健康检查
|
||||
*
|
||||
* 职责分离:
|
||||
* - 健康检查:检查系统各组件状态
|
||||
* - 性能监控:提供实时性能指标
|
||||
* - 统计报告:生成系统运行统计
|
||||
* - 诊断信息:提供故障排查信息
|
||||
*
|
||||
* 技术实现:
|
||||
* - HTTP接口:提供RESTful健康检查API
|
||||
* - 组件检查:验证Redis、数据库等依赖
|
||||
* - 性能指标:收集和展示关键指标
|
||||
* - 缓存机制:避免频繁检查影响性能
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: Bug修复 - 清理未使用的导入,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Controller, Get, HttpStatus, Inject, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
|
||||
// 导入中间件和服务
|
||||
import { PerformanceMonitorMiddleware } from './performance_monitor.middleware';
|
||||
import { RateLimitMiddleware } from './rate_limit.middleware';
|
||||
|
||||
/**
|
||||
* 健康检查状态枚举
|
||||
*/
|
||||
enum HealthStatus {
|
||||
HEALTHY = 'healthy',
|
||||
DEGRADED = 'degraded',
|
||||
UNHEALTHY = 'unhealthy',
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件健康状态接口
|
||||
*/
|
||||
interface ComponentHealth {
|
||||
/** 组件名称 */
|
||||
name: string;
|
||||
/** 健康状态 */
|
||||
status: HealthStatus;
|
||||
/** 响应时间(毫秒) */
|
||||
responseTime?: number;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
/** 详细信息 */
|
||||
details?: any;
|
||||
/** 检查时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统健康检查响应接口
|
||||
*/
|
||||
interface HealthCheckResponse {
|
||||
/** 整体状态 */
|
||||
status: HealthStatus;
|
||||
/** 检查时间戳 */
|
||||
timestamp: number;
|
||||
/** 系统版本 */
|
||||
version: string;
|
||||
/** 运行时间(毫秒) */
|
||||
uptime: number;
|
||||
/** 组件状态列表 */
|
||||
components: ComponentHealth[];
|
||||
/** 性能指标 */
|
||||
metrics?: {
|
||||
/** 活跃连接数 */
|
||||
activeConnections: number;
|
||||
/** 总事件数 */
|
||||
totalEvents: number;
|
||||
/** 平均响应时间 */
|
||||
avgResponseTime: number;
|
||||
/** 错误率 */
|
||||
errorRate: number;
|
||||
/** 内存使用情况 */
|
||||
memoryUsage: {
|
||||
used: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 详细健康报告接口
|
||||
*/
|
||||
interface DetailedHealthReport extends HealthCheckResponse {
|
||||
/** 系统信息 */
|
||||
system: {
|
||||
/** Node.js版本 */
|
||||
nodeVersion: string;
|
||||
/** 平台信息 */
|
||||
platform: string;
|
||||
/** CPU架构 */
|
||||
arch: string;
|
||||
/** 进程ID */
|
||||
pid: number;
|
||||
};
|
||||
/** 性能统计 */
|
||||
performance: {
|
||||
/** 事件统计 */
|
||||
eventStats: any[];
|
||||
/** 限流统计 */
|
||||
rateLimitStats: any;
|
||||
/** 系统性能 */
|
||||
systemPerformance: any;
|
||||
};
|
||||
/** 配置信息 */
|
||||
configuration: {
|
||||
/** 环境变量 */
|
||||
environment: string;
|
||||
/** 功能开关 */
|
||||
features: {
|
||||
rateLimitEnabled: boolean;
|
||||
performanceMonitorEnabled: boolean;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('健康检查')
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
private readonly logger = new Logger(HealthController.name);
|
||||
private readonly startTime = Date.now();
|
||||
|
||||
// 健康检查缓存
|
||||
private healthCache: HealthCheckResponse | null = null;
|
||||
private cacheExpiry = 0;
|
||||
private readonly cacheTimeout = 30000; // 30秒缓存
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
private readonly performanceMonitor: PerformanceMonitorMiddleware,
|
||||
private readonly rateLimitMiddleware: RateLimitMiddleware,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 基础健康检查
|
||||
*
|
||||
* 提供快速的健康状态检查,适用于负载均衡器
|
||||
*
|
||||
* @returns 基础健康状态
|
||||
*/
|
||||
@Get()
|
||||
@ApiOperation({ summary: '基础健康检查' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '系统健康',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: { type: 'string', enum: ['healthy', 'degraded', 'unhealthy'] },
|
||||
timestamp: { type: 'number' },
|
||||
uptime: { type: 'number' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
description: '系统不健康',
|
||||
})
|
||||
async getHealth() {
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// 检查缓存
|
||||
if (this.healthCache && now < this.cacheExpiry) {
|
||||
return this.formatHealthResponse(this.healthCache);
|
||||
}
|
||||
|
||||
// 执行健康检查
|
||||
const healthCheck = await this.performHealthCheck();
|
||||
|
||||
// 更新缓存
|
||||
this.healthCache = healthCheck;
|
||||
this.cacheExpiry = now + this.cacheTimeout;
|
||||
|
||||
return this.formatHealthResponse(healthCheck);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('健康检查失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const unhealthyResponse: HealthCheckResponse = {
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
timestamp: Date.now(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
uptime: Date.now() - this.startTime,
|
||||
components: [{
|
||||
name: 'system',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
};
|
||||
|
||||
return this.formatHealthResponse(unhealthyResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详细健康检查
|
||||
*
|
||||
* 提供完整的系统健康状态和性能指标
|
||||
*
|
||||
* @returns 详细健康报告
|
||||
*/
|
||||
@Get('detailed')
|
||||
@ApiOperation({ summary: '详细健康检查' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '详细健康报告',
|
||||
})
|
||||
async getDetailedHealth(): Promise<DetailedHealthReport> {
|
||||
try {
|
||||
const basicHealth = await this.performHealthCheck();
|
||||
const systemPerformance = this.performanceMonitor.getSystemPerformance();
|
||||
const eventStats = this.performanceMonitor.getEventStats();
|
||||
const rateLimitStats = this.rateLimitMiddleware.getStats();
|
||||
|
||||
const detailedReport: DetailedHealthReport = {
|
||||
...basicHealth,
|
||||
system: {
|
||||
nodeVersion: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
pid: process.pid,
|
||||
},
|
||||
performance: {
|
||||
eventStats,
|
||||
rateLimitStats,
|
||||
systemPerformance,
|
||||
},
|
||||
configuration: {
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
features: {
|
||||
rateLimitEnabled: true,
|
||||
performanceMonitorEnabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return detailedReport;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('详细健康检查失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
*
|
||||
* 提供实时性能监控数据
|
||||
*
|
||||
* @returns 性能指标
|
||||
*/
|
||||
@Get('metrics')
|
||||
@ApiOperation({ summary: '获取性能指标' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '性能指标数据',
|
||||
})
|
||||
async getMetrics() {
|
||||
try {
|
||||
const systemPerformance = this.performanceMonitor.getSystemPerformance();
|
||||
const eventStats = this.performanceMonitor.getEventStats();
|
||||
const rateLimitStats = this.rateLimitMiddleware.getStats();
|
||||
|
||||
return {
|
||||
timestamp: Date.now(),
|
||||
system: systemPerformance,
|
||||
events: eventStats,
|
||||
rateLimit: rateLimitStats,
|
||||
uptime: Date.now() - this.startTime,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取性能指标失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 就绪检查
|
||||
*
|
||||
* 检查系统是否准备好接收请求
|
||||
*
|
||||
* @returns 就绪状态
|
||||
*/
|
||||
@Get('ready')
|
||||
@ApiOperation({ summary: '就绪检查' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '系统就绪',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
description: '系统未就绪',
|
||||
})
|
||||
async getReadiness() {
|
||||
try {
|
||||
// 检查关键组件
|
||||
const components = await this.checkComponents();
|
||||
const criticalComponents = components.filter(c =>
|
||||
['redis', 'database', 'core_service'].includes(c.name)
|
||||
);
|
||||
|
||||
const allCriticalHealthy = criticalComponents.every(c =>
|
||||
c.status === HealthStatus.HEALTHY
|
||||
);
|
||||
|
||||
const status = allCriticalHealthy ? HealthStatus.HEALTHY : HealthStatus.UNHEALTHY;
|
||||
|
||||
const response = {
|
||||
status,
|
||||
timestamp: Date.now(),
|
||||
components: criticalComponents,
|
||||
};
|
||||
|
||||
if (status === HealthStatus.UNHEALTHY) {
|
||||
return this.formatHealthResponse(response, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('就绪检查失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return this.formatHealthResponse({
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
timestamp: Date.now(),
|
||||
components: [{
|
||||
name: 'system',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
}, HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 存活检查
|
||||
*
|
||||
* 简单的存活状态检查
|
||||
*
|
||||
* @returns 存活状态
|
||||
*/
|
||||
@Get('live')
|
||||
@ApiOperation({ summary: '存活检查' })
|
||||
@ApiResponse({
|
||||
status: HttpStatus.OK,
|
||||
description: '系统存活',
|
||||
})
|
||||
async getLiveness() {
|
||||
return {
|
||||
status: 'alive',
|
||||
timestamp: Date.now(),
|
||||
uptime: Date.now() - this.startTime,
|
||||
pid: process.pid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的健康检查
|
||||
*
|
||||
* @returns 健康检查结果
|
||||
* @private
|
||||
*/
|
||||
private async performHealthCheck(): Promise<HealthCheckResponse> {
|
||||
const components = await this.checkComponents();
|
||||
const systemPerformance = this.performanceMonitor.getSystemPerformance();
|
||||
|
||||
// 确定整体状态
|
||||
const unhealthyComponents = components.filter(c => c.status === HealthStatus.UNHEALTHY);
|
||||
const degradedComponents = components.filter(c => c.status === HealthStatus.DEGRADED);
|
||||
|
||||
let overallStatus: HealthStatus;
|
||||
if (unhealthyComponents.length > 0) {
|
||||
overallStatus = HealthStatus.UNHEALTHY;
|
||||
} else if (degradedComponents.length > 0) {
|
||||
overallStatus = HealthStatus.DEGRADED;
|
||||
} else {
|
||||
overallStatus = HealthStatus.HEALTHY;
|
||||
}
|
||||
|
||||
return {
|
||||
status: overallStatus,
|
||||
timestamp: Date.now(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
uptime: Date.now() - this.startTime,
|
||||
components,
|
||||
metrics: {
|
||||
activeConnections: systemPerformance.activeConnections,
|
||||
totalEvents: systemPerformance.totalEvents,
|
||||
avgResponseTime: systemPerformance.avgResponseTime,
|
||||
errorRate: systemPerformance.errorRate,
|
||||
memoryUsage: systemPerformance.memoryUsage,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查各个组件的健康状态
|
||||
*
|
||||
* @returns 组件健康状态列表
|
||||
* @private
|
||||
*/
|
||||
private async checkComponents(): Promise<ComponentHealth[]> {
|
||||
const components: ComponentHealth[] = [];
|
||||
|
||||
// 检查Redis连接
|
||||
components.push(await this.checkRedis());
|
||||
|
||||
// 检查数据库连接
|
||||
components.push(await this.checkDatabase());
|
||||
|
||||
// 检查核心服务
|
||||
components.push(await this.checkCoreService());
|
||||
|
||||
// 检查性能监控
|
||||
components.push(this.checkPerformanceMonitor());
|
||||
|
||||
// 检查限流中间件
|
||||
components.push(this.checkRateLimitMiddleware());
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查Redis连接状态
|
||||
*
|
||||
* @returns Redis健康状态
|
||||
* @private
|
||||
*/
|
||||
private async checkRedis(): Promise<ComponentHealth> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 这里应该实际检查Redis连接
|
||||
// 暂时返回健康状态
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
name: 'redis',
|
||||
status: HealthStatus.HEALTHY,
|
||||
responseTime,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
connected: true,
|
||||
responseTime,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'redis',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查数据库连接状态
|
||||
*
|
||||
* @returns 数据库健康状态
|
||||
* @private
|
||||
*/
|
||||
private async checkDatabase(): Promise<ComponentHealth> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 这里应该实际检查数据库连接
|
||||
// 暂时返回健康状态
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
name: 'database',
|
||||
status: HealthStatus.HEALTHY,
|
||||
responseTime,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
connected: true,
|
||||
responseTime,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'database',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查核心服务状态
|
||||
*
|
||||
* @returns 核心服务健康状态
|
||||
* @private
|
||||
*/
|
||||
private async checkCoreService(): Promise<ComponentHealth> {
|
||||
try {
|
||||
// 检查核心服务是否可用
|
||||
if (!this.locationBroadcastCore) {
|
||||
return {
|
||||
name: 'core_service',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: 'Core service not available',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'core_service',
|
||||
status: HealthStatus.HEALTHY,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
available: true,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'core_service',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查性能监控状态
|
||||
*
|
||||
* @returns 性能监控健康状态
|
||||
* @private
|
||||
*/
|
||||
private checkPerformanceMonitor(): ComponentHealth {
|
||||
try {
|
||||
const systemPerf = this.performanceMonitor.getSystemPerformance();
|
||||
|
||||
// 根据性能指标判断状态
|
||||
let status = HealthStatus.HEALTHY;
|
||||
if (systemPerf.errorRate > 10) {
|
||||
status = HealthStatus.DEGRADED;
|
||||
}
|
||||
if (systemPerf.errorRate > 25 || systemPerf.avgResponseTime > 2000) {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'performance_monitor',
|
||||
status,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
avgResponseTime: systemPerf.avgResponseTime,
|
||||
errorRate: systemPerf.errorRate,
|
||||
throughput: systemPerf.throughput,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'performance_monitor',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查限流中间件状态
|
||||
*
|
||||
* @returns 限流中间件健康状态
|
||||
* @private
|
||||
*/
|
||||
private checkRateLimitMiddleware(): ComponentHealth {
|
||||
try {
|
||||
const stats = this.rateLimitMiddleware.getStats();
|
||||
|
||||
// 根据限流统计判断状态
|
||||
let status = HealthStatus.HEALTHY;
|
||||
if (stats.limitRate > 20) {
|
||||
status = HealthStatus.DEGRADED;
|
||||
}
|
||||
if (stats.limitRate > 50) {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'rate_limit',
|
||||
status,
|
||||
timestamp: Date.now(),
|
||||
details: {
|
||||
limitRate: stats.limitRate,
|
||||
activeUsers: stats.activeUsers,
|
||||
totalRequests: stats.totalRequests,
|
||||
},
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
name: 'rate_limit',
|
||||
status: HealthStatus.UNHEALTHY,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化健康检查响应
|
||||
*
|
||||
* @param health 健康检查结果
|
||||
* @param statusCode HTTP状态码
|
||||
* @returns 格式化的响应
|
||||
* @private
|
||||
*/
|
||||
private formatHealthResponse(health: any, statusCode?: number) {
|
||||
if (statusCode === HttpStatus.SERVICE_UNAVAILABLE) {
|
||||
// 返回503状态码
|
||||
const response = new Response(JSON.stringify(health), {
|
||||
status: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
return health;
|
||||
}
|
||||
}
|
||||
48
src/business/location_broadcast/index.ts
Normal file
48
src/business/location_broadcast/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 位置广播业务模块导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 统一导出位置广播业务模块的所有公共接口
|
||||
* - 提供便捷的模块导入方式
|
||||
* - 支持模块化的系统集成
|
||||
* - 简化外部模块对位置广播功能的使用
|
||||
*
|
||||
* 职责分离:
|
||||
* - 接口导出:统一管理模块对外暴露的接口
|
||||
* - 依赖简化:减少外部模块的导入复杂度
|
||||
* - 版本控制:统一管理模块接口的版本变更
|
||||
* - 文档支持:为模块使用提供清晰的导入指南
|
||||
*
|
||||
* 技术实现:
|
||||
* - ES6模块:使用标准的ES6导入导出语法
|
||||
* - 类型导出:同时导出类型定义和实现
|
||||
* - 分类导出:按功能分类导出不同类型的组件
|
||||
* - 命名空间:避免命名冲突的导出策略
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 规范优化 - 完善文件头注释,符合代码检查规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
// 导出主模块
|
||||
export { LocationBroadcastModule } from './location_broadcast.module';
|
||||
|
||||
// 导出业务服务
|
||||
export * from './services';
|
||||
|
||||
// 导出控制器
|
||||
export { LocationBroadcastController } from './controllers/location_broadcast.controller';
|
||||
export { HealthController } from './controllers/health.controller';
|
||||
|
||||
// 导出WebSocket网关
|
||||
export { LocationBroadcastGateway } from './location_broadcast.gateway';
|
||||
|
||||
// 导出守卫
|
||||
export { WebSocketAuthGuard, AuthenticatedSocket } from './websocket_auth.guard';
|
||||
|
||||
// 导出DTO
|
||||
export * from './dto';
|
||||
727
src/business/location_broadcast/location_broadcast.controller.ts
Normal file
727
src/business/location_broadcast/location_broadcast.controller.ts
Normal file
@@ -0,0 +1,727 @@
|
||||
/**
|
||||
* 位置广播HTTP API控制器
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供位置广播系统的REST API接口
|
||||
* - 处理HTTP请求和响应格式化
|
||||
* - 集成JWT认证和权限验证
|
||||
* - 提供完整的API文档和错误处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - HTTP处理:专注于HTTP请求和响应的处理
|
||||
* - 数据转换:请求参数和响应数据的格式转换
|
||||
* - 权限验证:API访问权限的验证和控制
|
||||
* - 文档生成:Swagger API文档的自动生成
|
||||
*
|
||||
* 技术实现:
|
||||
* - NestJS控制器:使用装饰器定义API端点
|
||||
* - Swagger集成:自动生成API文档
|
||||
* - 数据验证:使用DTO进行请求数据验证
|
||||
* - 异常处理:统一的HTTP异常处理机制
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建位置广播HTTP API控制器
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard, AuthenticatedRequest } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
|
||||
// 导入业务服务
|
||||
import {
|
||||
LocationBroadcastService,
|
||||
LocationSessionService,
|
||||
LocationPositionService,
|
||||
} from './services';
|
||||
|
||||
// 导入DTO
|
||||
import {
|
||||
CreateSessionDto,
|
||||
JoinSessionDto,
|
||||
UpdatePositionDto,
|
||||
SessionQueryDto,
|
||||
PositionQueryDto,
|
||||
UpdateSessionConfigDto,
|
||||
} from './dto/api.dto';
|
||||
|
||||
/**
|
||||
* 位置广播API控制器
|
||||
*
|
||||
* 提供以下API端点:
|
||||
* - 会话管理:创建、查询、配置会话
|
||||
* - 位置管理:查询位置、获取统计信息
|
||||
* - 用户管理:获取用户状态、清理数据
|
||||
*/
|
||||
@ApiTags('位置广播')
|
||||
@Controller('location-broadcast')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class LocationBroadcastController {
|
||||
private readonly logger = new Logger(LocationBroadcastController.name);
|
||||
|
||||
constructor(
|
||||
private readonly locationBroadcastService: LocationBroadcastService,
|
||||
private readonly locationSessionService: LocationSessionService,
|
||||
private readonly locationPositionService: LocationPositionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
@Post('sessions')
|
||||
@ApiOperation({
|
||||
summary: '创建新会话',
|
||||
description: '创建一个新的游戏会话,用于多人位置广播',
|
||||
})
|
||||
@ApiBody({ type: CreateSessionDto })
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: '会话创建成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessionId: { type: 'string', example: 'session_12345' },
|
||||
createdAt: { type: 'number', example: 1641024000000 },
|
||||
config: { type: 'object' },
|
||||
},
|
||||
},
|
||||
message: { type: 'string', example: '会话创建成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: '请求参数错误' })
|
||||
@ApiResponse({ status: 409, description: '会话ID已存在' })
|
||||
async createSession(
|
||||
@Body() createSessionDto: CreateSessionDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
this.logger.log('创建会话API请求', {
|
||||
operation: 'createSession',
|
||||
sessionId: createSessionDto.sessionId,
|
||||
userId: user.sub,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const session = await this.locationSessionService.createSession({
|
||||
sessionId: createSessionDto.sessionId,
|
||||
creatorId: user.sub,
|
||||
name: createSessionDto.name,
|
||||
description: createSessionDto.description,
|
||||
maxUsers: createSessionDto.maxUsers,
|
||||
allowObservers: createSessionDto.allowObservers,
|
||||
password: createSessionDto.password,
|
||||
allowedMaps: createSessionDto.allowedMaps,
|
||||
broadcastRange: createSessionDto.broadcastRange,
|
||||
metadata: createSessionDto.metadata,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId: session.sessionId,
|
||||
createdAt: session.createdAt,
|
||||
config: session.config,
|
||||
metadata: session.metadata,
|
||||
},
|
||||
message: '会话创建成功',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('创建会话失败', {
|
||||
operation: 'createSession',
|
||||
sessionId: createSessionDto.sessionId,
|
||||
userId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '会话创建失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会话列表
|
||||
*/
|
||||
@Get('sessions')
|
||||
@ApiOperation({
|
||||
summary: '查询会话列表',
|
||||
description: '根据条件查询游戏会话列表',
|
||||
})
|
||||
@ApiQuery({ name: 'status', required: false, description: '会话状态' })
|
||||
@ApiQuery({ name: 'minUsers', required: false, description: '最小用户数' })
|
||||
@ApiQuery({ name: 'maxUsers', required: false, description: '最大用户数' })
|
||||
@ApiQuery({ name: 'publicOnly', required: false, description: '只显示公开会话' })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '分页偏移' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '分页大小' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sessions: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number', example: 10 },
|
||||
page: { type: 'number', example: 1 },
|
||||
pageSize: { type: 'number', example: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async querySessions(@Query() query: SessionQueryDto) {
|
||||
try {
|
||||
const result = await this.locationSessionService.querySessions({
|
||||
status: query.status as any, // 类型转换,因为DTO中是string类型
|
||||
minUsers: query.minUsers,
|
||||
maxUsers: query.maxUsers,
|
||||
publicOnly: query.publicOnly,
|
||||
offset: query.offset || 0,
|
||||
limit: query.limit || 10,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('查询会话列表失败', {
|
||||
operation: 'querySessions',
|
||||
query,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '查询会话列表失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*/
|
||||
@Get('sessions/:sessionId')
|
||||
@ApiOperation({
|
||||
summary: '获取会话详情',
|
||||
description: '获取指定会话的详细信息,包括用户列表和位置信息',
|
||||
})
|
||||
@ApiParam({ name: 'sessionId', description: '会话ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
session: { type: 'object' },
|
||||
users: { type: 'array', items: { type: 'object' } },
|
||||
onlineCount: { type: 'number', example: 5 },
|
||||
activeMaps: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 404, description: '会话不存在' })
|
||||
async getSessionDetail(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationSessionService.getSessionDetail(
|
||||
sessionId,
|
||||
user.sub,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('获取会话详情失败', {
|
||||
operation: 'getSessionDetail',
|
||||
sessionId,
|
||||
userId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '获取会话详情失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话配置
|
||||
*/
|
||||
@Put('sessions/:sessionId/config')
|
||||
@ApiOperation({
|
||||
summary: '更新会话配置',
|
||||
description: '更新指定会话的配置参数(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'sessionId', description: '会话ID' })
|
||||
@ApiBody({ type: UpdateSessionConfigDto })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '更新成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: { type: 'object' },
|
||||
message: { type: 'string', example: '会话配置更新成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
@ApiResponse({ status: 404, description: '会话不存在' })
|
||||
async updateSessionConfig(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Body() updateConfigDto: UpdateSessionConfigDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
const session = await this.locationSessionService.updateSessionConfig(
|
||||
sessionId,
|
||||
updateConfigDto,
|
||||
user.sub,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: session,
|
||||
message: '会话配置更新成功',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('更新会话配置失败', {
|
||||
operation: 'updateSessionConfig',
|
||||
sessionId,
|
||||
userId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '更新会话配置失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
*/
|
||||
@Delete('sessions/:sessionId')
|
||||
@ApiOperation({
|
||||
summary: '结束会话',
|
||||
description: '结束指定的游戏会话(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'sessionId', description: '会话ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '会话结束成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: '会话结束成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
@ApiResponse({ status: 404, description: '会话不存在' })
|
||||
async endSession(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
await this.locationSessionService.endSession(sessionId, user.sub);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '会话结束成功',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('结束会话失败', {
|
||||
operation: 'endSession',
|
||||
sessionId,
|
||||
userId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '结束会话失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询位置信息
|
||||
*/
|
||||
@Get('positions')
|
||||
@ApiOperation({
|
||||
summary: '查询位置信息',
|
||||
description: '根据条件查询用户位置信息',
|
||||
})
|
||||
@ApiQuery({ name: 'userIds', required: false, description: '用户ID列表(逗号分隔)' })
|
||||
@ApiQuery({ name: 'mapId', required: false, description: '地图ID' })
|
||||
@ApiQuery({ name: 'sessionId', required: false, description: '会话ID' })
|
||||
@ApiQuery({ name: 'centerX', required: false, description: '范围查询中心X坐标' })
|
||||
@ApiQuery({ name: 'centerY', required: false, description: '范围查询中心Y坐标' })
|
||||
@ApiQuery({ name: 'radius', required: false, description: '范围查询半径' })
|
||||
@ApiQuery({ name: 'offset', required: false, description: '分页偏移' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '分页大小' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '查询成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
positions: { type: 'array', items: { type: 'object' } },
|
||||
total: { type: 'number', example: 20 },
|
||||
timestamp: { type: 'number', example: 1641024000000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async queryPositions(@Query() query: PositionQueryDto) {
|
||||
try {
|
||||
const userIds = query.userIds ? query.userIds.split(',') : undefined;
|
||||
const range = (query.centerX !== undefined && query.centerY !== undefined && query.radius !== undefined) ? {
|
||||
centerX: query.centerX,
|
||||
centerY: query.centerY,
|
||||
radius: query.radius,
|
||||
} : undefined;
|
||||
|
||||
const result = await this.locationPositionService.queryPositions({
|
||||
userIds,
|
||||
mapId: query.mapId,
|
||||
sessionId: query.sessionId,
|
||||
range,
|
||||
pagination: {
|
||||
offset: query.offset || 0,
|
||||
limit: query.limit || 50,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('查询位置信息失败', {
|
||||
operation: 'queryPositions',
|
||||
query,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '查询位置信息失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取位置统计信息
|
||||
*/
|
||||
@Get('positions/stats')
|
||||
@ApiOperation({
|
||||
summary: '获取位置统计信息',
|
||||
description: '获取位置数据的统计信息,包括用户分布、活跃地图等',
|
||||
})
|
||||
@ApiQuery({ name: 'mapId', required: false, description: '地图ID' })
|
||||
@ApiQuery({ name: 'sessionId', required: false, description: '会话ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalUsers: { type: 'number', example: 100 },
|
||||
onlineUsers: { type: 'number', example: 85 },
|
||||
activeMaps: { type: 'number', example: 5 },
|
||||
mapDistribution: { type: 'object' },
|
||||
updateFrequency: { type: 'number', example: 2.5 },
|
||||
timestamp: { type: 'number', example: 1641024000000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getPositionStats(
|
||||
@Query('mapId') mapId?: string,
|
||||
@Query('sessionId') sessionId?: string,
|
||||
) {
|
||||
try {
|
||||
const result = await this.locationPositionService.getPositionStats({
|
||||
mapId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('获取位置统计失败', {
|
||||
operation: 'getPositionStats',
|
||||
mapId,
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '获取位置统计失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户位置历史
|
||||
*/
|
||||
@Get('users/:userId/position-history')
|
||||
@ApiOperation({
|
||||
summary: '获取用户位置历史',
|
||||
description: '获取指定用户的位置历史记录',
|
||||
})
|
||||
@ApiParam({ name: 'userId', description: '用户ID' })
|
||||
@ApiQuery({ name: 'mapId', required: false, description: '地图ID过滤' })
|
||||
@ApiQuery({ name: 'limit', required: false, description: '最大记录数' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '获取成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { type: 'object' },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
async getUserPositionHistory(
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('mapId') mapId?: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
try {
|
||||
// 权限检查:只能查看自己的历史记录,或者管理员可以查看所有
|
||||
if (userId !== user.sub && user.role < 2) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '权限不足,只能查看自己的位置历史',
|
||||
},
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.locationPositionService.getPositionHistory({
|
||||
userId,
|
||||
mapId,
|
||||
limit: limit || 100,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('获取用户位置历史失败', {
|
||||
operation: 'getUserPositionHistory',
|
||||
userId,
|
||||
requestUserId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '获取用户位置历史失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户数据
|
||||
*/
|
||||
@Delete('users/:userId/data')
|
||||
@ApiOperation({
|
||||
summary: '清理用户数据',
|
||||
description: '清理指定用户的位置广播相关数据(需要管理员权限)',
|
||||
})
|
||||
@ApiParam({ name: 'userId', description: '用户ID' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: '清理成功',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean', example: true },
|
||||
message: { type: 'string', example: '用户数据清理成功' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 403, description: '权限不足' })
|
||||
async cleanupUserData(
|
||||
@Param('userId') userId: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
try {
|
||||
// 权限检查:只有管理员或用户本人可以清理数据
|
||||
if (userId !== user.sub && user.role < 2) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '权限不足,只能清理自己的数据',
|
||||
},
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const success = await this.locationBroadcastService.cleanupUserData(userId);
|
||||
|
||||
if (!success) {
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '用户数据清理失败',
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '用户数据清理成功',
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error('清理用户数据失败', {
|
||||
operation: 'cleanupUserData',
|
||||
userId,
|
||||
operatorId: user.sub,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
{
|
||||
success: false,
|
||||
message: '清理用户数据失败',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
876
src/business/location_broadcast/location_broadcast.gateway.ts
Normal file
876
src/business/location_broadcast/location_broadcast.gateway.ts
Normal file
@@ -0,0 +1,876 @@
|
||||
/**
|
||||
* 位置广播WebSocket网关
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理WebSocket连接和断开事件
|
||||
* - 管理用户会话的加入和离开
|
||||
* - 实时广播用户位置更新
|
||||
* - 提供心跳检测和连接状态管理
|
||||
*
|
||||
* 职责分离:
|
||||
* - WebSocket连接管理:处理连接建立、断开和错误
|
||||
* - 消息路由:根据消息类型分发到对应的处理器
|
||||
* - 认证集成:使用JWT认证守卫保护WebSocket事件
|
||||
* - 实时广播:向会话中的其他用户广播位置更新
|
||||
*
|
||||
* 技术实现:
|
||||
* - 原生WebSocket:提供WebSocket通信能力
|
||||
* - JWT认证:保护需要认证的WebSocket事件
|
||||
* - 核心服务集成:调用位置广播核心服务处理业务逻辑
|
||||
* - 异常处理:统一的WebSocket异常处理和错误响应
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 重构为原生WebSocket - 移除Socket.IO依赖,使用原生WebSocket (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
ConnectedSocket,
|
||||
MessageBody,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
OnGatewayInit,
|
||||
WsException,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server } from 'ws';
|
||||
import * as WebSocket from 'ws';
|
||||
import { Logger, UseFilters, UseGuards, UsePipes, ValidationPipe, ArgumentsHost, Inject } from '@nestjs/common';
|
||||
import { BaseWsExceptionFilter } from '@nestjs/websockets';
|
||||
|
||||
// 导入中间件
|
||||
import { RateLimitMiddleware } from './rate_limit.middleware';
|
||||
import { PerformanceMonitorMiddleware } from './performance_monitor.middleware';
|
||||
|
||||
// 导入DTO和守卫
|
||||
import { WebSocketAuthGuard, AuthenticatedSocket } from './websocket_auth.guard';
|
||||
import {
|
||||
JoinSessionMessage,
|
||||
LeaveSessionMessage,
|
||||
PositionUpdateMessage,
|
||||
HeartbeatMessage,
|
||||
} from './dto/websocket_message.dto';
|
||||
import {
|
||||
SessionJoinedResponse,
|
||||
UserJoinedNotification,
|
||||
UserLeftNotification,
|
||||
PositionBroadcast,
|
||||
HeartbeatResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
} from './dto/websocket_response.dto';
|
||||
|
||||
// 导入核心服务接口
|
||||
import { Position } from '../../core/location_broadcast_core/position.interface';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口,包含用户信息
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
sessionIds?: Set<string>;
|
||||
connectionTimeout?: NodeJS.Timeout;
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket异常过滤器
|
||||
*
|
||||
* 职责:
|
||||
* - 捕获WebSocket通信中的异常
|
||||
* - 格式化错误响应
|
||||
* - 记录错误日志
|
||||
*/
|
||||
class WebSocketExceptionFilter extends BaseWsExceptionFilter {
|
||||
private readonly logger = new Logger(WebSocketExceptionFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const client = host.switchToWs().getClient<ExtendedWebSocket>();
|
||||
|
||||
const error: ErrorResponse = {
|
||||
type: 'error',
|
||||
code: exception.code || 'INTERNAL_ERROR',
|
||||
message: exception.message || '服务器内部错误',
|
||||
details: exception.details,
|
||||
originalMessage: exception.originalMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.logger.error('WebSocket异常', {
|
||||
socketId: client.id,
|
||||
error: exception.message,
|
||||
code: exception.code,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
this.sendMessage(client, 'error', error);
|
||||
}
|
||||
|
||||
private sendMessage(client: ExtendedWebSocket, event: string, data: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*', // 生产环境中应该配置具体的域名
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
path: '/location-broadcast', // WebSocket路径
|
||||
})
|
||||
@UseFilters(new WebSocketExceptionFilter())
|
||||
export class LocationBroadcastGateway
|
||||
implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(LocationBroadcastGateway.name);
|
||||
private clients = new Map<string, ExtendedWebSocket>();
|
||||
private sessionRooms = new Map<string, Set<string>>(); // sessionId -> Set<clientId>
|
||||
|
||||
/** 连接超时时间(分钟) */
|
||||
private static readonly CONNECTION_TIMEOUT_MINUTES = 30;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
||||
/** 心跳间隔(毫秒) */
|
||||
private static readonly HEARTBEAT_INTERVAL = 30000;
|
||||
|
||||
// 中间件实例
|
||||
private readonly rateLimitMiddleware = new RateLimitMiddleware();
|
||||
private readonly performanceMonitor = new PerformanceMonitorMiddleware();
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any, // 使用依赖注入获取核心服务
|
||||
) {}
|
||||
|
||||
/**
|
||||
* WebSocket服务器初始化
|
||||
*/
|
||||
afterInit(server: Server) {
|
||||
this.logger.log('位置广播WebSocket服务器初始化完成', {
|
||||
path: '/location-broadcast',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 设置心跳检测
|
||||
this.setupHeartbeat();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端连接
|
||||
*/
|
||||
handleConnection(client: ExtendedWebSocket) {
|
||||
// 生成唯一ID
|
||||
client.id = this.generateClientId();
|
||||
client.sessionIds = new Set();
|
||||
client.isAlive = true;
|
||||
|
||||
this.clients.set(client.id, client);
|
||||
|
||||
this.logger.log('WebSocket客户端连接', {
|
||||
socketId: client.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 记录连接事件到性能监控
|
||||
this.performanceMonitor.recordConnection(client as any, true);
|
||||
|
||||
// 发送连接确认消息
|
||||
const welcomeMessage = {
|
||||
type: 'connection_established',
|
||||
message: '连接已建立',
|
||||
socketId: client.id,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'welcome', welcomeMessage);
|
||||
|
||||
// 设置连接超时
|
||||
this.setConnectionTimeout(client);
|
||||
|
||||
// 设置消息处理
|
||||
client.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.handleMessage(client, message);
|
||||
} catch (error) {
|
||||
this.logger.error('解析消息失败', {
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 设置pong响应
|
||||
client.on('pong', () => {
|
||||
client.isAlive = true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理客户端断开连接
|
||||
*/
|
||||
async handleDisconnect(client: ExtendedWebSocket) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('WebSocket客户端断开连接', {
|
||||
socketId: client.id,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 记录断开连接事件到性能监控
|
||||
this.performanceMonitor.recordConnection(client as any, false);
|
||||
|
||||
try {
|
||||
// 清理连接超时
|
||||
if (client.connectionTimeout) {
|
||||
clearTimeout(client.connectionTimeout);
|
||||
}
|
||||
|
||||
// 如果是已认证的客户端,进行清理
|
||||
if (client.userId) {
|
||||
await this.handleUserDisconnection(client, 'connection_lost');
|
||||
}
|
||||
|
||||
// 从客户端列表中移除
|
||||
this.clients.delete(client.id);
|
||||
|
||||
// 从所有会话房间中移除
|
||||
if (client.sessionIds) {
|
||||
for (const sessionId of client.sessionIds) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (room) {
|
||||
room.delete(client.id);
|
||||
if (room.size === 0) {
|
||||
this.sessionRooms.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('客户端断开连接处理完成', {
|
||||
socketId: client.id,
|
||||
userId: client.userId || 'unknown',
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('处理客户端断开连接时发生错误', {
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理消息路由
|
||||
*/
|
||||
private async handleMessage(client: ExtendedWebSocket, message: any) {
|
||||
const { event, data } = message;
|
||||
|
||||
switch (event) {
|
||||
case 'join_session':
|
||||
await this.handleJoinSession(client, data);
|
||||
break;
|
||||
case 'leave_session':
|
||||
await this.handleLeaveSession(client, data);
|
||||
break;
|
||||
case 'position_update':
|
||||
await this.handlePositionUpdate(client, data);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
await this.handleHeartbeat(client, data);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn('未知消息类型', {
|
||||
socketId: client.id,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理加入会话消息
|
||||
*/
|
||||
async handleJoinSession(client: ExtendedWebSocket, message: JoinSessionMessage) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理加入会话请求', {
|
||||
operation: 'join_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 将用户添加到会话
|
||||
await this.locationBroadcastCore.addUserToSession(
|
||||
message.sessionId,
|
||||
client.userId,
|
||||
client.id,
|
||||
);
|
||||
|
||||
// 2. 如果提供了初始位置,设置用户位置
|
||||
if (message.initialPosition) {
|
||||
const position: Position = {
|
||||
userId: client.userId,
|
||||
x: message.initialPosition.x,
|
||||
y: message.initialPosition.y,
|
||||
mapId: message.initialPosition.mapId,
|
||||
timestamp: Date.now(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
await this.locationBroadcastCore.setUserPosition(client.userId, position);
|
||||
}
|
||||
|
||||
// 3. 获取会话中的用户列表和位置信息
|
||||
const [sessionUsers, sessionPositions] = await Promise.all([
|
||||
this.locationBroadcastCore.getSessionUsers(message.sessionId),
|
||||
this.locationBroadcastCore.getSessionPositions(message.sessionId),
|
||||
]);
|
||||
|
||||
// 4. 向客户端发送加入成功响应
|
||||
const joinResponse: SessionJoinedResponse = {
|
||||
type: 'session_joined',
|
||||
sessionId: message.sessionId,
|
||||
users: sessionUsers.map(user => ({
|
||||
userId: user.userId,
|
||||
socketId: user.socketId,
|
||||
joinedAt: user.joinedAt,
|
||||
lastSeen: user.lastSeen,
|
||||
status: user.status,
|
||||
position: user.position ? {
|
||||
x: user.position.x,
|
||||
y: user.position.y,
|
||||
mapId: user.position.mapId,
|
||||
timestamp: user.position.timestamp,
|
||||
} : undefined,
|
||||
})),
|
||||
positions: sessionPositions.map(pos => ({
|
||||
userId: pos.userId,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
mapId: pos.mapId,
|
||||
timestamp: pos.timestamp,
|
||||
metadata: pos.metadata,
|
||||
})),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'session_joined', joinResponse);
|
||||
|
||||
// 5. 向会话中其他用户广播新用户加入通知
|
||||
const userJoinedNotification: UserJoinedNotification = {
|
||||
type: 'user_joined',
|
||||
user: {
|
||||
userId: client.userId,
|
||||
socketId: client.id,
|
||||
joinedAt: Date.now(),
|
||||
status: 'online',
|
||||
},
|
||||
position: message.initialPosition ? {
|
||||
x: message.initialPosition.x,
|
||||
y: message.initialPosition.y,
|
||||
mapId: message.initialPosition.mapId,
|
||||
timestamp: Date.now(),
|
||||
} : undefined,
|
||||
sessionId: message.sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 广播给会话中的其他用户(排除当前用户)
|
||||
this.broadcastToSession(message.sessionId, 'user_joined', userJoinedNotification, client.id);
|
||||
|
||||
// 将客户端加入会话房间
|
||||
this.joinRoom(client, message.sessionId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('用户成功加入会话', {
|
||||
operation: 'join_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
userCount: sessionUsers.length,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.error('加入会话失败', {
|
||||
operation: 'join_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
type: 'error',
|
||||
code: 'JOIN_SESSION_FAILED',
|
||||
message: '加入会话失败',
|
||||
details: {
|
||||
sessionId: message.sessionId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理离开会话消息
|
||||
*/
|
||||
async handleLeaveSession(client: ExtendedWebSocket, message: LeaveSessionMessage) {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理离开会话请求', {
|
||||
operation: 'leave_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
reason: message.reason,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 从会话中移除用户
|
||||
await this.locationBroadcastCore.removeUserFromSession(
|
||||
message.sessionId,
|
||||
client.userId,
|
||||
);
|
||||
|
||||
// 2. 向会话中其他用户广播用户离开通知
|
||||
const userLeftNotification: UserLeftNotification = {
|
||||
type: 'user_left',
|
||||
userId: client.userId,
|
||||
reason: message.reason || 'user_left',
|
||||
sessionId: message.sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.broadcastToSession(message.sessionId, 'user_left', userLeftNotification, client.id);
|
||||
|
||||
// 3. 从会话房间中移除客户端
|
||||
this.leaveRoom(client, message.sessionId);
|
||||
|
||||
// 4. 发送离开成功确认
|
||||
const successResponse: SuccessResponse = {
|
||||
type: 'success',
|
||||
message: '成功离开会话',
|
||||
operation: 'leave_session',
|
||||
data: {
|
||||
sessionId: message.sessionId,
|
||||
reason: message.reason || 'user_left',
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'leave_session_success', successResponse);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.log('用户成功离开会话', {
|
||||
operation: 'leave_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
reason: message.reason,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.error('离开会话失败', {
|
||||
operation: 'leave_session',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId: message.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
type: 'error',
|
||||
code: 'LEAVE_SESSION_FAILED',
|
||||
message: '离开会话失败',
|
||||
details: {
|
||||
sessionId: message.sessionId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理位置更新消息
|
||||
*/
|
||||
async handlePositionUpdate(client: ExtendedWebSocket, message: PositionUpdateMessage) {
|
||||
// 开始性能监控
|
||||
const perfContext = this.performanceMonitor.startMonitoring('position_update', client as any);
|
||||
|
||||
// 检查频率限制
|
||||
const rateLimitAllowed = this.rateLimitMiddleware.checkRateLimit(client.userId || '', client.id);
|
||||
if (!rateLimitAllowed) {
|
||||
this.rateLimitMiddleware.handleRateLimit(client as any, client.userId || '');
|
||||
this.performanceMonitor.endMonitoring(perfContext, false, 'Rate limit exceeded');
|
||||
return;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.debug('处理位置更新请求', {
|
||||
operation: 'position_update',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
mapId: message.mapId,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
// 验证认证状态
|
||||
if (!client.userId) {
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '用户未认证',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
// 1. 构建位置对象
|
||||
const position: Position = {
|
||||
userId: client.userId,
|
||||
x: message.x,
|
||||
y: message.y,
|
||||
mapId: message.mapId,
|
||||
timestamp: message.timestamp || Date.now(),
|
||||
metadata: message.metadata || {},
|
||||
};
|
||||
|
||||
// 2. 更新用户位置
|
||||
await this.locationBroadcastCore.setUserPosition(client.userId, position);
|
||||
|
||||
// 3. 向用户所在的所有会话广播位置更新
|
||||
if (client.sessionIds) {
|
||||
for (const sessionId of client.sessionIds) {
|
||||
const positionBroadcast: PositionBroadcast = {
|
||||
type: 'position_broadcast',
|
||||
userId: client.userId,
|
||||
position: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
mapId: position.mapId,
|
||||
timestamp: position.timestamp,
|
||||
metadata: position.metadata,
|
||||
},
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.broadcastToSession(sessionId, 'position_update', positionBroadcast, client.id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 发送位置更新成功确认
|
||||
const successResponse: SuccessResponse = {
|
||||
type: 'success',
|
||||
message: '位置更新成功',
|
||||
operation: 'position_update',
|
||||
data: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
mapId: position.mapId,
|
||||
timestamp: position.timestamp,
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'position_update_success', successResponse);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.debug('位置更新处理完成', {
|
||||
operation: 'position_update',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
mapId: message.mapId,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 结束性能监控
|
||||
this.performanceMonitor.endMonitoring(perfContext, true);
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.logger.error('位置更新失败', {
|
||||
operation: 'position_update',
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
mapId: message.mapId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 结束性能监控(失败)
|
||||
this.performanceMonitor.endMonitoring(perfContext, false, error instanceof Error ? error.message : String(error));
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
type: 'error',
|
||||
code: 'POSITION_UPDATE_FAILED',
|
||||
message: '位置更新失败',
|
||||
details: {
|
||||
mapId: message.mapId,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
originalMessage: message,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.sendMessage(client, 'error', errorResponse);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理心跳消息
|
||||
*/
|
||||
async handleHeartbeat(client: ExtendedWebSocket, message: HeartbeatMessage) {
|
||||
this.logger.debug('处理心跳请求', {
|
||||
operation: 'heartbeat',
|
||||
socketId: client.id,
|
||||
clientTimestamp: message.timestamp,
|
||||
sequence: message.sequence,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 重置连接超时
|
||||
this.setConnectionTimeout(client);
|
||||
|
||||
// 2. 构建心跳响应
|
||||
const heartbeatResponse: HeartbeatResponse = {
|
||||
type: 'heartbeat_response',
|
||||
clientTimestamp: message.timestamp,
|
||||
serverTimestamp: Date.now(),
|
||||
sequence: message.sequence,
|
||||
};
|
||||
|
||||
// 3. 发送心跳响应
|
||||
this.sendMessage(client, 'heartbeat_response', heartbeatResponse);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('心跳处理失败', {
|
||||
operation: 'heartbeat',
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户断开连接的清理工作
|
||||
*/
|
||||
private async handleUserDisconnection(client: ExtendedWebSocket, reason: string): Promise<void> {
|
||||
try {
|
||||
// 1. 获取用户所在的所有会话
|
||||
const sessionIds = Array.from(client.sessionIds || []);
|
||||
|
||||
// 2. 从所有会话中移除用户并通知其他用户
|
||||
for (const sessionId of sessionIds) {
|
||||
try {
|
||||
// 从会话中移除用户
|
||||
await this.locationBroadcastCore.removeUserFromSession(
|
||||
sessionId,
|
||||
client.userId!,
|
||||
);
|
||||
|
||||
// 通知会话中的其他用户
|
||||
const userLeftNotification: UserLeftNotification = {
|
||||
type: 'user_left',
|
||||
userId: client.userId!,
|
||||
reason,
|
||||
sessionId,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.broadcastToSession(sessionId, 'user_left', userLeftNotification, client.id);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('从会话中移除用户失败', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理用户的所有数据
|
||||
await this.locationBroadcastCore.cleanupUserData(client.userId!);
|
||||
|
||||
this.logger.log('用户断开连接清理完成', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
reason,
|
||||
sessionCount: sessionIds.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('用户断开连接清理失败', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息给客户端
|
||||
*/
|
||||
private sendMessage(client: ExtendedWebSocket, event: string, data: any) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event, data }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 向会话房间广播消息
|
||||
*/
|
||||
private broadcastToSession(sessionId: string, event: string, data: any, excludeClientId?: string) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (!room) return;
|
||||
|
||||
for (const clientId of room) {
|
||||
if (excludeClientId && clientId === excludeClientId) continue;
|
||||
|
||||
const client = this.clients.get(clientId);
|
||||
if (client) {
|
||||
this.sendMessage(client, event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将客户端加入会话房间
|
||||
*/
|
||||
private joinRoom(client: ExtendedWebSocket, sessionId: string) {
|
||||
if (!this.sessionRooms.has(sessionId)) {
|
||||
this.sessionRooms.set(sessionId, new Set());
|
||||
}
|
||||
|
||||
this.sessionRooms.get(sessionId)!.add(client.id);
|
||||
client.sessionIds!.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将客户端从会话房间移除
|
||||
*/
|
||||
private leaveRoom(client: ExtendedWebSocket, sessionId: string) {
|
||||
const room = this.sessionRooms.get(sessionId);
|
||||
if (room) {
|
||||
room.delete(client.id);
|
||||
if (room.size === 0) {
|
||||
this.sessionRooms.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
client.sessionIds!.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端ID
|
||||
*/
|
||||
private generateClientId(): string {
|
||||
return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置连接超时
|
||||
*/
|
||||
private setConnectionTimeout(client: ExtendedWebSocket) {
|
||||
if (client.connectionTimeout) {
|
||||
clearTimeout(client.connectionTimeout);
|
||||
}
|
||||
|
||||
client.connectionTimeout = setTimeout(() => {
|
||||
this.logger.warn('客户端连接超时,自动断开', {
|
||||
socketId: client.id,
|
||||
timeout: `${LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES}分钟`,
|
||||
});
|
||||
client.close();
|
||||
}, LocationBroadcastGateway.CONNECTION_TIMEOUT_MINUTES * LocationBroadcastGateway.MILLISECONDS_PER_MINUTE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置心跳检测
|
||||
*/
|
||||
private setupHeartbeat() {
|
||||
setInterval(() => {
|
||||
this.clients.forEach((client) => {
|
||||
if (!client.isAlive) {
|
||||
this.logger.warn('客户端心跳超时,断开连接', {
|
||||
socketId: client.id,
|
||||
});
|
||||
client.close();
|
||||
return;
|
||||
}
|
||||
|
||||
client.isAlive = false;
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.ping();
|
||||
}
|
||||
});
|
||||
}, LocationBroadcastGateway.HEARTBEAT_INTERVAL);
|
||||
}
|
||||
}
|
||||
123
src/business/location_broadcast/location_broadcast.module.ts
Normal file
123
src/business/location_broadcast/location_broadcast.module.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* 位置广播业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合位置广播系统的所有业务组件
|
||||
* - 配置模块依赖关系和服务注入
|
||||
* - 提供统一的模块导出接口
|
||||
* - 支持模块化的系统架构
|
||||
*
|
||||
* 职责分离:
|
||||
* - 模块配置:定义模块的提供者、控制器和导出
|
||||
* - 依赖管理:管理模块间的依赖关系
|
||||
* - 服务注入:配置依赖注入和服务绑定
|
||||
* - 接口暴露:向外部模块提供服务接口
|
||||
*
|
||||
* 技术实现:
|
||||
* - NestJS模块:使用@Module装饰器定义模块
|
||||
* - 依赖注入:配置服务的依赖注入关系
|
||||
* - 模块导入:导入所需的核心模块和外部模块
|
||||
* - 接口导出:导出供其他模块使用的服务
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 功能新增 - 创建位置广播业务模块
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
// 导入核心模块
|
||||
import { LocationBroadcastCoreModule } from '../../core/location_broadcast_core/location_broadcast_core.module';
|
||||
import { UserProfilesModule } from '../../core/db/user_profiles/user_profiles.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
|
||||
// 导入业务服务
|
||||
import {
|
||||
LocationBroadcastService,
|
||||
LocationSessionService,
|
||||
LocationPositionService,
|
||||
} from './services';
|
||||
import { CleanupService } from './services/cleanup.service';
|
||||
|
||||
// 导入控制器
|
||||
import { LocationBroadcastController } from './controllers/location_broadcast.controller';
|
||||
import { HealthController } from './controllers/health.controller';
|
||||
|
||||
// 导入WebSocket网关
|
||||
import { LocationBroadcastGateway } from './location_broadcast.gateway';
|
||||
|
||||
// 导入守卫
|
||||
import { WebSocketAuthGuard } from './websocket_auth.guard';
|
||||
|
||||
// 导入中间件
|
||||
import { RateLimitMiddleware } from './rate_limit.middleware';
|
||||
import { PerformanceMonitorMiddleware } from './performance_monitor.middleware';
|
||||
|
||||
/**
|
||||
* 位置广播业务模块
|
||||
*
|
||||
* 模块职责:
|
||||
* - 提供完整的位置广播业务功能
|
||||
* - 集成WebSocket实时通信和HTTP API
|
||||
* - 管理会话、位置和用户相关的业务逻辑
|
||||
* - 提供统一的认证和权限验证
|
||||
*
|
||||
* 模块结构:
|
||||
* - 服务层:业务逻辑处理和数据协调
|
||||
* - 控制器层:HTTP API端点和请求处理
|
||||
* - 网关层:WebSocket实时通信处理
|
||||
* - 守卫层:认证和权限验证
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
// 导入核心模块
|
||||
LocationBroadcastCoreModule,
|
||||
UserProfilesModule,
|
||||
LoginCoreModule,
|
||||
],
|
||||
providers: [
|
||||
// 业务服务
|
||||
LocationBroadcastService,
|
||||
LocationSessionService,
|
||||
LocationPositionService,
|
||||
CleanupService,
|
||||
|
||||
// 中间件
|
||||
RateLimitMiddleware,
|
||||
PerformanceMonitorMiddleware,
|
||||
|
||||
// WebSocket网关
|
||||
LocationBroadcastGateway,
|
||||
|
||||
// 守卫
|
||||
WebSocketAuthGuard,
|
||||
],
|
||||
controllers: [
|
||||
// HTTP API控制器
|
||||
LocationBroadcastController,
|
||||
HealthController,
|
||||
],
|
||||
exports: [
|
||||
// 导出业务服务供其他模块使用
|
||||
LocationBroadcastService,
|
||||
LocationSessionService,
|
||||
LocationPositionService,
|
||||
CleanupService,
|
||||
|
||||
// 导出中间件
|
||||
RateLimitMiddleware,
|
||||
PerformanceMonitorMiddleware,
|
||||
|
||||
// 导出WebSocket网关
|
||||
LocationBroadcastGateway,
|
||||
],
|
||||
})
|
||||
export class LocationBroadcastModule {
|
||||
constructor() {
|
||||
console.log('位置广播业务模块已初始化');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,665 @@
|
||||
/**
|
||||
* 性能监控中间件
|
||||
*
|
||||
* 功能描述:
|
||||
* - 监控WebSocket事件处理的性能指标
|
||||
* - 收集响应时间、吞吐量等关键数据
|
||||
* - 提供实时性能统计和报告
|
||||
* - 支持性能预警和异常检测
|
||||
*
|
||||
* 职责分离:
|
||||
* - 性能收集:记录事件处理的时间和资源消耗
|
||||
* - 数据分析:计算平均值、百分位数等统计指标
|
||||
* - 监控报警:检测性能异常和瓶颈
|
||||
* - 报告生成:提供详细的性能分析报告
|
||||
*
|
||||
* 技术实现:
|
||||
* - 高精度计时:使用process.hrtime进行精确测量
|
||||
* - 内存优化:循环缓冲区存储历史数据
|
||||
* - 异步处理:不影响正常业务流程
|
||||
* - 统计算法:实时计算各种性能指标
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能指标接口
|
||||
*/
|
||||
interface PerformanceMetric {
|
||||
/** 事件名称 */
|
||||
eventName: string;
|
||||
/** 处理时间(毫秒) */
|
||||
duration: number;
|
||||
/** 时间戳 */
|
||||
timestamp: number;
|
||||
/** 用户ID */
|
||||
userId?: string;
|
||||
/** Socket ID */
|
||||
socketId: string;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件统计信息
|
||||
*/
|
||||
export interface EventStats {
|
||||
/** 事件名称 */
|
||||
eventName: string;
|
||||
/** 总请求数 */
|
||||
totalRequests: number;
|
||||
/** 成功请求数 */
|
||||
successRequests: number;
|
||||
/** 失败请求数 */
|
||||
failedRequests: number;
|
||||
/** 平均响应时间 */
|
||||
avgDuration: number;
|
||||
/** 最小响应时间 */
|
||||
minDuration: number;
|
||||
/** 最大响应时间 */
|
||||
maxDuration: number;
|
||||
/** 95百分位响应时间 */
|
||||
p95Duration: number;
|
||||
/** 99百分位响应时间 */
|
||||
p99Duration: number;
|
||||
/** 每秒请求数 */
|
||||
requestsPerSecond: number;
|
||||
/** 成功率 */
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统性能概览
|
||||
*/
|
||||
export interface SystemPerformance {
|
||||
/** 总连接数 */
|
||||
totalConnections: number;
|
||||
/** 活跃连接数 */
|
||||
activeConnections: number;
|
||||
/** 总事件数 */
|
||||
totalEvents: number;
|
||||
/** 平均响应时间 */
|
||||
avgResponseTime: number;
|
||||
/** 系统吞吐量(事件/秒) */
|
||||
throughput: number;
|
||||
/** 错误率 */
|
||||
errorRate: number;
|
||||
/** 内存使用情况 */
|
||||
memoryUsage: {
|
||||
used: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
};
|
||||
/** 统计时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能预警配置
|
||||
*/
|
||||
interface AlertConfig {
|
||||
/** 响应时间阈值(毫秒) */
|
||||
responseTimeThreshold: number;
|
||||
/** 错误率阈值(百分比) */
|
||||
errorRateThreshold: number;
|
||||
/** 吞吐量下限 */
|
||||
throughputThreshold: number;
|
||||
/** 内存使用率阈值 */
|
||||
memoryThreshold: number;
|
||||
/** 是否启用预警 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceMonitorMiddleware {
|
||||
private readonly logger = new Logger(PerformanceMonitorMiddleware.name);
|
||||
|
||||
/** 性能指标缓存最大数量 */
|
||||
private static readonly MAX_METRICS = 10000;
|
||||
/** 统计更新间隔(毫秒) */
|
||||
private static readonly STATS_UPDATE_INTERVAL = 10000;
|
||||
/** 清理间隔(毫秒) */
|
||||
private static readonly CLEANUP_INTERVAL = 300000;
|
||||
/** 响应时间阈值(毫秒) */
|
||||
private static readonly RESPONSE_TIME_THRESHOLD = 1000;
|
||||
/** 错误率阈值(百分比) */
|
||||
private static readonly ERROR_RATE_THRESHOLD = 5;
|
||||
/** 吞吐量阈值(事件/秒) */
|
||||
private static readonly THROUGHPUT_THRESHOLD = 10;
|
||||
/** 内存使用率阈值(百分比) */
|
||||
private static readonly MEMORY_THRESHOLD = 80;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_SECOND = 1000;
|
||||
private static readonly SECONDS_PER_MINUTE = 60;
|
||||
private static readonly MINUTES_PER_HOUR = 60;
|
||||
private static readonly HOURS_PER_DAY = 24;
|
||||
/** 百分位数计算常量 */
|
||||
private static readonly PERCENTILE_95 = 95;
|
||||
private static readonly PERCENTILE_99 = 99;
|
||||
/** 精度计算常量 */
|
||||
private static readonly PRECISION_MULTIPLIER = 100;
|
||||
private static readonly HIGH_PRECISION_MULTIPLIER = 10000;
|
||||
/** 内存单位转换 */
|
||||
private static readonly BYTES_PER_KB = 1024;
|
||||
private static readonly KB_PER_MB = 1024;
|
||||
/** 性能趋势间隔(分钟) */
|
||||
private static readonly TREND_INTERVAL_MINUTES = 5;
|
||||
/** 窗口数据保留倍数 */
|
||||
private static readonly WINDOW_RETENTION_MULTIPLIER = 10;
|
||||
/** 报告默认时间范围(小时) */
|
||||
private static readonly DEFAULT_REPORT_HOURS = 1;
|
||||
/** 慢事件默认限制数量 */
|
||||
private static readonly DEFAULT_SLOW_EVENTS_LIMIT = 10;
|
||||
|
||||
/** 性能指标缓存(循环缓冲区) */
|
||||
private readonly metrics: PerformanceMetric[] = [];
|
||||
private readonly maxMetrics = PerformanceMonitorMiddleware.MAX_METRICS;
|
||||
private metricsIndex = 0;
|
||||
|
||||
/** 事件统计缓存 */
|
||||
private readonly eventStats = new Map<string, EventStats>();
|
||||
|
||||
/** 连接统计 */
|
||||
private connectionCount = 0;
|
||||
private activeConnections = new Set<string>();
|
||||
|
||||
/** 预警配置 */
|
||||
private alertConfig: AlertConfig = {
|
||||
responseTimeThreshold: PerformanceMonitorMiddleware.RESPONSE_TIME_THRESHOLD,
|
||||
errorRateThreshold: PerformanceMonitorMiddleware.ERROR_RATE_THRESHOLD,
|
||||
throughputThreshold: PerformanceMonitorMiddleware.THROUGHPUT_THRESHOLD,
|
||||
memoryThreshold: PerformanceMonitorMiddleware.MEMORY_THRESHOLD,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// 定期更新统计信息
|
||||
setInterval(() => {
|
||||
this.updateEventStats();
|
||||
this.checkAlerts();
|
||||
}, PerformanceMonitorMiddleware.STATS_UPDATE_INTERVAL);
|
||||
|
||||
// 定期清理过期数据
|
||||
setInterval(() => {
|
||||
this.cleanupOldMetrics();
|
||||
}, PerformanceMonitorMiddleware.CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始监控事件处理
|
||||
*
|
||||
* @param eventName 事件名称
|
||||
* @param client WebSocket客户端
|
||||
* @returns 监控上下文
|
||||
*/
|
||||
startMonitoring(eventName: string, client: ExtendedWebSocket): { startTime: [number, number]; eventName: string; client: ExtendedWebSocket } {
|
||||
const startTime = process.hrtime();
|
||||
|
||||
// 记录连接
|
||||
this.activeConnections.add(client.id);
|
||||
|
||||
return { startTime, eventName, client };
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束监控并记录指标
|
||||
*
|
||||
* @param context 监控上下文
|
||||
* @param success 是否成功
|
||||
* @param error 错误信息
|
||||
*/
|
||||
endMonitoring(
|
||||
context: { startTime: [number, number]; eventName: string; client: ExtendedWebSocket },
|
||||
success: boolean = true,
|
||||
error?: string,
|
||||
): void {
|
||||
const endTime = process.hrtime(context.startTime);
|
||||
const duration = endTime[0] * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND + endTime[1] / (PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND);
|
||||
|
||||
const metric: PerformanceMetric = {
|
||||
eventName: context.eventName,
|
||||
duration,
|
||||
timestamp: Date.now(),
|
||||
userId: context.client.userId,
|
||||
socketId: context.client.id,
|
||||
success,
|
||||
error,
|
||||
};
|
||||
|
||||
this.recordMetric(metric);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录连接事件
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param connected 是否连接
|
||||
*/
|
||||
recordConnection(client: ExtendedWebSocket, connected: boolean): void {
|
||||
if (connected) {
|
||||
this.connectionCount++;
|
||||
this.activeConnections.add(client.id);
|
||||
} else {
|
||||
this.activeConnections.delete(client.id);
|
||||
}
|
||||
|
||||
this.logger.debug('连接状态变更', {
|
||||
socketId: client.id,
|
||||
connected,
|
||||
totalConnections: this.connectionCount,
|
||||
activeConnections: this.activeConnections.size,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件统计信息
|
||||
*
|
||||
* @param eventName 事件名称
|
||||
* @returns 统计信息
|
||||
*/
|
||||
getEventStats(eventName?: string): EventStats[] {
|
||||
if (eventName) {
|
||||
const stats = this.eventStats.get(eventName);
|
||||
return stats ? [stats] : [];
|
||||
}
|
||||
|
||||
return Array.from(this.eventStats.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统性能概览
|
||||
*
|
||||
* @returns 系统性能信息
|
||||
*/
|
||||
getSystemPerformance(): SystemPerformance {
|
||||
const now = Date.now();
|
||||
const recentMetrics = this.getRecentMetrics(PerformanceMonitorMiddleware.SECONDS_PER_MINUTE * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND); // 最近1分钟的数据
|
||||
|
||||
const totalEvents = recentMetrics.length;
|
||||
const successfulEvents = recentMetrics.filter(m => m.success).length;
|
||||
const avgResponseTime = totalEvents > 0
|
||||
? recentMetrics.reduce((sum, m) => sum + m.duration, 0) / totalEvents
|
||||
: 0;
|
||||
|
||||
const throughput = totalEvents / PerformanceMonitorMiddleware.SECONDS_PER_MINUTE; // 每秒事件数
|
||||
const errorRate = totalEvents > 0 ? ((totalEvents - successfulEvents) / totalEvents) * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER : 0;
|
||||
|
||||
// 获取内存使用情况
|
||||
const memUsage = process.memoryUsage();
|
||||
const memoryUsage = {
|
||||
used: Math.round(memUsage.heapUsed / PerformanceMonitorMiddleware.BYTES_PER_KB / PerformanceMonitorMiddleware.KB_PER_MB), // MB
|
||||
total: Math.round(memUsage.heapTotal / PerformanceMonitorMiddleware.BYTES_PER_KB / PerformanceMonitorMiddleware.KB_PER_MB), // MB
|
||||
percentage: Math.round((memUsage.heapUsed / memUsage.heapTotal) * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER),
|
||||
};
|
||||
|
||||
return {
|
||||
totalConnections: this.connectionCount,
|
||||
activeConnections: this.activeConnections.size,
|
||||
totalEvents,
|
||||
avgResponseTime: Math.round(avgResponseTime * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
throughput: Math.round(throughput * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
errorRate: Math.round(errorRate * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
memoryUsage,
|
||||
timestamp: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能报告
|
||||
*
|
||||
* @param timeRange 时间范围(毫秒)
|
||||
* @returns 性能报告
|
||||
*/
|
||||
getPerformanceReport(timeRange: number = PerformanceMonitorMiddleware.DEFAULT_REPORT_HOURS * PerformanceMonitorMiddleware.MINUTES_PER_HOUR * PerformanceMonitorMiddleware.SECONDS_PER_MINUTE * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND): any {
|
||||
const metrics = this.getRecentMetrics(timeRange);
|
||||
const eventGroups = this.groupMetricsByEvent(metrics);
|
||||
|
||||
const report = {
|
||||
timeRange,
|
||||
totalMetrics: metrics.length,
|
||||
systemPerformance: this.getSystemPerformance(),
|
||||
eventStats: this.getEventStats(),
|
||||
topSlowEvents: this.getTopSlowEvents(metrics, PerformanceMonitorMiddleware.DEFAULT_SLOW_EVENTS_LIMIT),
|
||||
errorSummary: this.getErrorSummary(metrics),
|
||||
performanceTrends: this.getPerformanceTrends(metrics),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新预警配置
|
||||
*
|
||||
* @param config 新配置
|
||||
*/
|
||||
updateAlertConfig(config: Partial<AlertConfig>): void {
|
||||
this.alertConfig = { ...this.alertConfig, ...config };
|
||||
|
||||
this.logger.log('性能预警配置已更新', {
|
||||
config: this.alertConfig,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理性能数据
|
||||
*/
|
||||
clearMetrics(): void {
|
||||
this.metrics.length = 0;
|
||||
this.metricsIndex = 0;
|
||||
this.eventStats.clear();
|
||||
|
||||
this.logger.log('性能监控数据已清理', {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录性能指标
|
||||
*
|
||||
* @param metric 性能指标
|
||||
* @private
|
||||
*/
|
||||
private recordMetric(metric: PerformanceMetric): void {
|
||||
// 使用循环缓冲区存储指标
|
||||
this.metrics[this.metricsIndex] = metric;
|
||||
this.metricsIndex = (this.metricsIndex + 1) % this.maxMetrics;
|
||||
|
||||
// 记录慢请求
|
||||
if (metric.duration > this.alertConfig.responseTimeThreshold) {
|
||||
this.logger.warn('检测到慢请求', {
|
||||
eventName: metric.eventName,
|
||||
duration: metric.duration,
|
||||
userId: metric.userId,
|
||||
socketId: metric.socketId,
|
||||
threshold: this.alertConfig.responseTimeThreshold,
|
||||
});
|
||||
}
|
||||
|
||||
// 记录错误
|
||||
if (!metric.success) {
|
||||
this.logger.error('事件处理失败', {
|
||||
eventName: metric.eventName,
|
||||
error: metric.error,
|
||||
userId: metric.userId,
|
||||
socketId: metric.socketId,
|
||||
duration: metric.duration,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新事件统计信息
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private updateEventStats(): void {
|
||||
const recentMetrics = this.getRecentMetrics(60000); // 最近1分钟
|
||||
const eventGroups = this.groupMetricsByEvent(recentMetrics);
|
||||
|
||||
for (const [eventName, metrics] of eventGroups.entries()) {
|
||||
const durations = metrics.map(m => m.duration).sort((a, b) => a - b);
|
||||
const successCount = metrics.filter(m => m.success).length;
|
||||
|
||||
const stats: EventStats = {
|
||||
eventName,
|
||||
totalRequests: metrics.length,
|
||||
successRequests: successCount,
|
||||
failedRequests: metrics.length - successCount,
|
||||
avgDuration: Math.round((durations.reduce((sum, d) => sum + d, 0) / durations.length) * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
minDuration: durations[0] || 0,
|
||||
maxDuration: durations[durations.length - 1] || 0,
|
||||
p95Duration: this.getPercentile(durations, PerformanceMonitorMiddleware.PERCENTILE_95),
|
||||
p99Duration: this.getPercentile(durations, PerformanceMonitorMiddleware.PERCENTILE_99),
|
||||
requestsPerSecond: Math.round((metrics.length / PerformanceMonitorMiddleware.SECONDS_PER_MINUTE) * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
successRate: Math.round((successCount / metrics.length) * PerformanceMonitorMiddleware.HIGH_PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER,
|
||||
};
|
||||
|
||||
this.eventStats.set(eventName, stats);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查性能预警
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private checkAlerts(): void {
|
||||
if (!this.alertConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const systemPerf = this.getSystemPerformance();
|
||||
|
||||
// 检查响应时间
|
||||
if (systemPerf.avgResponseTime > this.alertConfig.responseTimeThreshold) {
|
||||
this.logger.warn('响应时间过高预警', {
|
||||
current: systemPerf.avgResponseTime,
|
||||
threshold: this.alertConfig.responseTimeThreshold,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查错误率
|
||||
if (systemPerf.errorRate > this.alertConfig.errorRateThreshold) {
|
||||
this.logger.warn('错误率过高预警', {
|
||||
current: systemPerf.errorRate,
|
||||
threshold: this.alertConfig.errorRateThreshold,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查吞吐量
|
||||
if (systemPerf.throughput < this.alertConfig.throughputThreshold) {
|
||||
this.logger.warn('吞吐量过低预警', {
|
||||
current: systemPerf.throughput,
|
||||
threshold: this.alertConfig.throughputThreshold,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 检查内存使用
|
||||
if (systemPerf.memoryUsage.percentage > this.alertConfig.memoryThreshold) {
|
||||
this.logger.warn('内存使用率过高预警', {
|
||||
current: systemPerf.memoryUsage.percentage,
|
||||
threshold: this.alertConfig.memoryThreshold,
|
||||
used: systemPerf.memoryUsage.used,
|
||||
total: systemPerf.memoryUsage.total,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的性能指标
|
||||
*
|
||||
* @param timeRange 时间范围(毫秒)
|
||||
* @returns 性能指标列表
|
||||
* @private
|
||||
*/
|
||||
private getRecentMetrics(timeRange: number): PerformanceMetric[] {
|
||||
const now = Date.now();
|
||||
const cutoff = now - timeRange;
|
||||
|
||||
return this.metrics.filter(metric => metric && metric.timestamp > cutoff);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按事件名称分组指标
|
||||
*
|
||||
* @param metrics 性能指标列表
|
||||
* @returns 分组后的指标
|
||||
* @private
|
||||
*/
|
||||
private groupMetricsByEvent(metrics: PerformanceMetric[]): Map<string, PerformanceMetric[]> {
|
||||
const groups = new Map<string, PerformanceMetric[]>();
|
||||
|
||||
for (const metric of metrics) {
|
||||
if (!groups.has(metric.eventName)) {
|
||||
groups.set(metric.eventName, []);
|
||||
}
|
||||
groups.get(metric.eventName)!.push(metric);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算百分位数
|
||||
*
|
||||
* @param values 数值数组(已排序)
|
||||
* @param percentile 百分位数
|
||||
* @returns 百分位值
|
||||
* @private
|
||||
*/
|
||||
private getPercentile(values: number[], percentile: number): number {
|
||||
if (values.length === 0) return 0;
|
||||
|
||||
const index = Math.ceil((percentile / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) * values.length) - 1;
|
||||
return Math.round(values[Math.max(0, index)] * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER) / PerformanceMonitorMiddleware.PRECISION_MULTIPLIER;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最慢的事件
|
||||
*
|
||||
* @param metrics 性能指标
|
||||
* @param limit 限制数量
|
||||
* @returns 最慢事件列表
|
||||
* @private
|
||||
*/
|
||||
private getTopSlowEvents(metrics: PerformanceMetric[], limit: number): PerformanceMetric[] {
|
||||
return metrics
|
||||
.sort((a, b) => b.duration - a.duration)
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误摘要
|
||||
*
|
||||
* @param metrics 性能指标
|
||||
* @returns 错误摘要
|
||||
* @private
|
||||
*/
|
||||
private getErrorSummary(metrics: PerformanceMetric[]): any {
|
||||
const errors = metrics.filter(m => !m.success);
|
||||
const errorGroups = new Map<string, number>();
|
||||
|
||||
for (const error of errors) {
|
||||
const key = error.error || 'Unknown Error';
|
||||
errorGroups.set(key, (errorGroups.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
totalErrors: errors.length,
|
||||
errorRate: metrics.length > 0 ? (errors.length / metrics.length) * PerformanceMonitorMiddleware.PRECISION_MULTIPLIER : 0,
|
||||
errorTypes: Array.from(errorGroups.entries()).map(([error, count]) => ({ error, count })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取性能趋势
|
||||
*
|
||||
* @param metrics 性能指标
|
||||
* @returns 性能趋势数据
|
||||
* @private
|
||||
*/
|
||||
private getPerformanceTrends(metrics: PerformanceMetric[]): any {
|
||||
// 按5分钟间隔分组
|
||||
const intervals = new Map<number, PerformanceMetric[]>();
|
||||
const intervalSize = PerformanceMonitorMiddleware.TREND_INTERVAL_MINUTES * PerformanceMonitorMiddleware.SECONDS_PER_MINUTE * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND;
|
||||
|
||||
for (const metric of metrics) {
|
||||
const interval = Math.floor(metric.timestamp / intervalSize) * intervalSize;
|
||||
if (!intervals.has(interval)) {
|
||||
intervals.set(interval, []);
|
||||
}
|
||||
intervals.get(interval)!.push(metric);
|
||||
}
|
||||
|
||||
return Array.from(intervals.entries()).map(([interval, intervalMetrics]) => ({
|
||||
timestamp: interval,
|
||||
avgDuration: intervalMetrics.reduce((sum, m) => sum + m.duration, 0) / intervalMetrics.length,
|
||||
requestCount: intervalMetrics.length,
|
||||
errorCount: intervalMetrics.filter(m => !m.success).length,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期指标
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private cleanupOldMetrics(): void {
|
||||
const cutoff = Date.now() - (PerformanceMonitorMiddleware.HOURS_PER_DAY * PerformanceMonitorMiddleware.MINUTES_PER_HOUR * PerformanceMonitorMiddleware.SECONDS_PER_MINUTE * PerformanceMonitorMiddleware.MILLISECONDS_PER_SECOND);
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (let i = 0; i < this.metrics.length; i++) {
|
||||
if (this.metrics[i] && this.metrics[i].timestamp < cutoff) {
|
||||
delete this.metrics[i];
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
this.logger.debug('清理过期性能指标', {
|
||||
cleanedCount,
|
||||
remainingCount: this.metrics.filter(m => m).length,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能监控装饰器
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* @PerformanceMonitor('position_update')
|
||||
* @SubscribeMessage('position_update')
|
||||
* async handlePositionUpdate(@ConnectedSocket() client: AuthenticatedSocket, @MessageBody() message: PositionUpdateMessage) {
|
||||
* // 处理位置更新
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function PerformanceMonitor(eventName?: string) {
|
||||
return function (_target: any, propertyName: string, descriptor: PropertyDescriptor) {
|
||||
const method = descriptor.value;
|
||||
const finalEventName = eventName || propertyName;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const client = args[0] as ExtendedWebSocket;
|
||||
const performanceMonitor = new PerformanceMonitorMiddleware();
|
||||
|
||||
const context = performanceMonitor.startMonitoring(finalEventName, client);
|
||||
|
||||
try {
|
||||
const result = await method.apply(this, args);
|
||||
performanceMonitor.endMonitoring(context, true);
|
||||
return result;
|
||||
} catch (error) {
|
||||
performanceMonitor.endMonitoring(context, false, error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
357
src/business/location_broadcast/rate_limit.middleware.ts
Normal file
357
src/business/location_broadcast/rate_limit.middleware.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 位置更新频率限制中间件
|
||||
*
|
||||
* 功能描述:
|
||||
* - 限制用户位置更新的频率,防止过度请求
|
||||
* - 基于用户ID和时间窗口的限流算法
|
||||
* - 支持动态配置和监控统计
|
||||
* - 提供优雅的限流响应和错误处理
|
||||
*
|
||||
* 职责分离:
|
||||
* - 频率控制:实现基于时间窗口的请求限制
|
||||
* - 用户隔离:每个用户独立的限流计数
|
||||
* - 配置管理:支持动态调整限流参数
|
||||
* - 监控统计:记录限流事件和性能指标
|
||||
*
|
||||
* 技术实现:
|
||||
* - 滑动窗口算法:精确控制请求频率
|
||||
* - 内存缓存:高性能的计数器存储
|
||||
* - 异步处理:不阻塞正常请求流程
|
||||
* - 错误恢复:处理异常情况的降级策略
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket接口
|
||||
*/
|
||||
interface ExtendedWebSocket extends WebSocket {
|
||||
id: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流配置接口
|
||||
*/
|
||||
interface RateLimitConfig {
|
||||
/** 时间窗口(毫秒) */
|
||||
windowMs: number;
|
||||
/** 窗口内最大请求数 */
|
||||
maxRequests: number;
|
||||
/** 是否启用限流 */
|
||||
enabled: boolean;
|
||||
/** 限流消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户限流状态
|
||||
*/
|
||||
interface UserRateLimit {
|
||||
/** 请求时间戳列表 */
|
||||
requests: number[];
|
||||
/** 最后更新时间 */
|
||||
lastUpdate: number;
|
||||
/** 总请求数 */
|
||||
totalRequests: number;
|
||||
/** 被限流次数 */
|
||||
limitedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 限流统计信息
|
||||
*/
|
||||
export interface RateLimitStats {
|
||||
/** 总请求数 */
|
||||
totalRequests: number;
|
||||
/** 被限流请求数 */
|
||||
limitedRequests: number;
|
||||
/** 活跃用户数 */
|
||||
activeUsers: number;
|
||||
/** 限流率 */
|
||||
limitRate: number;
|
||||
/** 统计时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RateLimitMiddleware {
|
||||
private readonly logger = new Logger(RateLimitMiddleware.name);
|
||||
|
||||
/** 默认时间窗口(毫秒) */
|
||||
private static readonly DEFAULT_WINDOW_MS = 1000;
|
||||
/** 默认最大请求数 */
|
||||
private static readonly DEFAULT_MAX_REQUESTS = 10;
|
||||
/** 清理间隔(毫秒) */
|
||||
private static readonly CLEANUP_INTERVAL = 60000;
|
||||
/** 统计更新间隔(毫秒) */
|
||||
private static readonly STATS_UPDATE_INTERVAL = 10000;
|
||||
/** 窗口数据保留倍数 */
|
||||
private static readonly WINDOW_RETENTION_MULTIPLIER = 10;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_SECOND = 1000;
|
||||
|
||||
/** 用户限流状态缓存 */
|
||||
private readonly userLimits = new Map<string, UserRateLimit>();
|
||||
|
||||
/** 默认配置 */
|
||||
private config: RateLimitConfig = {
|
||||
windowMs: RateLimitMiddleware.DEFAULT_WINDOW_MS,
|
||||
maxRequests: RateLimitMiddleware.DEFAULT_MAX_REQUESTS,
|
||||
enabled: true,
|
||||
message: '位置更新频率过高,请稍后重试',
|
||||
};
|
||||
|
||||
/** 统计信息 */
|
||||
private stats: RateLimitStats = {
|
||||
totalRequests: 0,
|
||||
limitedRequests: 0,
|
||||
activeUsers: 0,
|
||||
limitRate: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
// 定期清理过期的限流记录
|
||||
setInterval(() => {
|
||||
this.cleanupExpiredRecords();
|
||||
}, RateLimitMiddleware.CLEANUP_INTERVAL);
|
||||
|
||||
// 定期更新统计信息
|
||||
setInterval(() => {
|
||||
this.updateStats();
|
||||
}, RateLimitMiddleware.STATS_UPDATE_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否被限流
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param socketId Socket连接ID
|
||||
* @returns 是否允许请求
|
||||
*/
|
||||
checkRateLimit(userId: string, socketId: string): boolean {
|
||||
if (!this.config.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.stats.totalRequests++;
|
||||
|
||||
// 获取或创建用户限流状态
|
||||
let userLimit = this.userLimits.get(userId);
|
||||
if (!userLimit) {
|
||||
userLimit = {
|
||||
requests: [],
|
||||
lastUpdate: now,
|
||||
totalRequests: 0,
|
||||
limitedCount: 0,
|
||||
};
|
||||
this.userLimits.set(userId, userLimit);
|
||||
}
|
||||
|
||||
// 清理过期的请求记录
|
||||
const windowStart = now - this.config.windowMs;
|
||||
userLimit.requests = userLimit.requests.filter(timestamp => timestamp > windowStart);
|
||||
|
||||
// 检查是否超过限制
|
||||
if (userLimit.requests.length >= this.config.maxRequests) {
|
||||
userLimit.limitedCount++;
|
||||
this.stats.limitedRequests++;
|
||||
|
||||
this.logger.warn('用户位置更新被限流', {
|
||||
userId,
|
||||
socketId,
|
||||
requestCount: userLimit.requests.length,
|
||||
maxRequests: this.config.maxRequests,
|
||||
windowMs: this.config.windowMs,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 记录请求
|
||||
userLimit.requests.push(now);
|
||||
userLimit.totalRequests++;
|
||||
userLimit.lastUpdate = now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理限流异常
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
handleRateLimit(client: ExtendedWebSocket, userId: string): void {
|
||||
const error = {
|
||||
type: 'error',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
message: this.config.message,
|
||||
details: {
|
||||
windowMs: this.config.windowMs,
|
||||
maxRequests: this.config.maxRequests,
|
||||
retryAfter: Math.ceil(this.config.windowMs / RateLimitMiddleware.MILLISECONDS_PER_SECOND),
|
||||
},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify({ event: 'error', data: error }));
|
||||
}
|
||||
|
||||
this.logger.debug('发送限流错误响应', {
|
||||
userId,
|
||||
socketId: client.id,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户限流状态
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @returns 用户限流状态
|
||||
*/
|
||||
getUserRateLimit(userId: string): UserRateLimit | null {
|
||||
return this.userLimits.get(userId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取限流统计信息
|
||||
*
|
||||
* @returns 统计信息
|
||||
*/
|
||||
getStats(): RateLimitStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新限流配置
|
||||
*
|
||||
* @param newConfig 新配置
|
||||
*/
|
||||
updateConfig(newConfig: Partial<RateLimitConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
|
||||
this.logger.log('限流配置已更新', {
|
||||
config: this.config,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户限流状态
|
||||
*
|
||||
* @param userId 用户ID
|
||||
*/
|
||||
resetUserLimit(userId: string): void {
|
||||
this.userLimits.delete(userId);
|
||||
|
||||
this.logger.debug('重置用户限流状态', {
|
||||
userId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理所有限流记录
|
||||
*/
|
||||
clearAllLimits(): void {
|
||||
this.userLimits.clear();
|
||||
this.stats = {
|
||||
totalRequests: 0,
|
||||
limitedRequests: 0,
|
||||
activeUsers: 0,
|
||||
limitRate: 0,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
this.logger.log('清理所有限流记录', {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期的限流记录
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private cleanupExpiredRecords(): void {
|
||||
const now = Date.now();
|
||||
const expireTime = now - (this.config.windowMs * RateLimitMiddleware.WINDOW_RETENTION_MULTIPLIER);
|
||||
let cleanedCount = 0;
|
||||
|
||||
for (const [userId, userLimit] of this.userLimits.entries()) {
|
||||
if (userLimit.lastUpdate < expireTime) {
|
||||
this.userLimits.delete(userId);
|
||||
cleanedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
this.logger.debug('清理过期限流记录', {
|
||||
cleanedCount,
|
||||
remainingUsers: this.userLimits.size,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新统计信息
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
private updateStats(): void {
|
||||
this.stats.activeUsers = this.userLimits.size;
|
||||
this.stats.limitRate = this.stats.totalRequests > 0
|
||||
? (this.stats.limitedRequests / this.stats.totalRequests) * 100
|
||||
: 0;
|
||||
this.stats.timestamp = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新限流装饰器
|
||||
*
|
||||
* 使用示例:
|
||||
* ```typescript
|
||||
* @PositionUpdateRateLimit()
|
||||
* @SubscribeMessage('position_update')
|
||||
* async handlePositionUpdate(@ConnectedSocket() client: AuthenticatedSocket, @MessageBody() message: PositionUpdateMessage) {
|
||||
* // 处理位置更新
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function PositionUpdateRateLimit() {
|
||||
return function (_target: any, _propertyName: string, descriptor: PropertyDescriptor) {
|
||||
const method = descriptor.value;
|
||||
|
||||
descriptor.value = async function (...args: any[]) {
|
||||
const client = args[0] as ExtendedWebSocket;
|
||||
const rateLimitMiddleware = new RateLimitMiddleware();
|
||||
|
||||
if (client.userId) {
|
||||
const allowed = rateLimitMiddleware.checkRateLimit(client.userId, client.id);
|
||||
|
||||
if (!allowed) {
|
||||
rateLimitMiddleware.handleRateLimit(client, client.userId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
return method.apply(this, args);
|
||||
};
|
||||
};
|
||||
}
|
||||
626
src/business/location_broadcast/services/cleanup.service.ts
Normal file
626
src/business/location_broadcast/services/cleanup.service.ts
Normal file
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* 自动清理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定期清理过期的会话数据
|
||||
* - 清理断开连接用户的位置信息
|
||||
* - 清理过期的缓存数据
|
||||
* - 优化Redis内存使用
|
||||
*
|
||||
* 职责分离:
|
||||
* - 数据清理:清理过期和无效数据
|
||||
* - 内存优化:释放不再使用的内存
|
||||
* - 定时任务:按计划执行清理操作
|
||||
* - 监控报告:记录清理操作的统计信息
|
||||
*
|
||||
* 技术实现:
|
||||
* - 定时器:使用setInterval执行定期清理
|
||||
* - 批量操作:批量删除数据提高效率
|
||||
* - 异常处理:确保清理失败不影响系统
|
||||
* - 统计记录:记录清理操作的详细信息
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 清理配置接口
|
||||
*/
|
||||
interface CleanupConfig {
|
||||
/** 会话过期时间(毫秒) */
|
||||
sessionExpiry: number;
|
||||
/** 位置数据过期时间(毫秒) */
|
||||
positionExpiry: number;
|
||||
/** 用户离线超时时间(毫秒) */
|
||||
userOfflineTimeout: number;
|
||||
/** 清理间隔时间(毫秒) */
|
||||
cleanupInterval: number;
|
||||
/** 批量清理大小 */
|
||||
batchSize: number;
|
||||
/** 是否启用清理 */
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理统计信息接口
|
||||
*/
|
||||
interface CleanupStats {
|
||||
/** 总清理次数 */
|
||||
totalCleanups: number;
|
||||
/** 清理的会话数 */
|
||||
cleanedSessions: number;
|
||||
/** 清理的位置记录数 */
|
||||
cleanedPositions: number;
|
||||
/** 清理的用户数 */
|
||||
cleanedUsers: number;
|
||||
/** 最后清理时间 */
|
||||
lastCleanupTime: number;
|
||||
/** 平均清理时间(毫秒) */
|
||||
avgCleanupTime: number;
|
||||
/** 清理错误次数 */
|
||||
errorCount: number;
|
||||
/** 最后错误信息 */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理操作结果接口
|
||||
*/
|
||||
interface CleanupResult {
|
||||
/** 操作类型 */
|
||||
operation: string;
|
||||
/** 清理数量 */
|
||||
count: number;
|
||||
/** 耗时(毫秒) */
|
||||
duration: number;
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 错误信息 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CleanupService.name);
|
||||
|
||||
/** 会话过期时间(小时) */
|
||||
private static readonly SESSION_EXPIRY_HOURS = 24;
|
||||
/** 位置数据过期时间(小时) */
|
||||
private static readonly POSITION_EXPIRY_HOURS = 2;
|
||||
/** 用户离线超时时间(分钟) */
|
||||
private static readonly USER_OFFLINE_TIMEOUT_MINUTES = 30;
|
||||
/** 清理间隔时间(分钟) */
|
||||
private static readonly CLEANUP_INTERVAL_MINUTES = 5;
|
||||
/** 批量清理大小 */
|
||||
private static readonly BATCH_SIZE = 100;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
||||
private static readonly MILLISECONDS_PER_HOUR = 60 * 60 * 1000;
|
||||
/** 模拟清理最大会话数 */
|
||||
private static readonly MAX_SIMULATED_SESSION_CLEANUP = 5;
|
||||
/** 模拟清理最大位置数 */
|
||||
private static readonly MAX_SIMULATED_POSITION_CLEANUP = 20;
|
||||
/** 模拟清理最大用户数 */
|
||||
private static readonly MAX_SIMULATED_USER_CLEANUP = 10;
|
||||
/** 模拟清理最大缓存数 */
|
||||
private static readonly MAX_SIMULATED_CACHE_CLEANUP = 50;
|
||||
/** 清理时间记录最大数量 */
|
||||
private static readonly MAX_CLEANUP_TIME_RECORDS = 100;
|
||||
/** 健康检查间隔倍数 */
|
||||
private static readonly HEALTH_CHECK_INTERVAL_MULTIPLIER = 2;
|
||||
/** 错误率阈值 */
|
||||
private static readonly ERROR_RATE_THRESHOLD = 0.1;
|
||||
|
||||
/** 清理定时器 */
|
||||
private cleanupTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
/** 清理配置 */
|
||||
private config: CleanupConfig = {
|
||||
sessionExpiry: CleanupService.SESSION_EXPIRY_HOURS * CleanupService.MILLISECONDS_PER_HOUR,
|
||||
positionExpiry: CleanupService.POSITION_EXPIRY_HOURS * CleanupService.MILLISECONDS_PER_HOUR,
|
||||
userOfflineTimeout: CleanupService.USER_OFFLINE_TIMEOUT_MINUTES * CleanupService.MILLISECONDS_PER_MINUTE,
|
||||
cleanupInterval: CleanupService.CLEANUP_INTERVAL_MINUTES * CleanupService.MILLISECONDS_PER_MINUTE,
|
||||
batchSize: CleanupService.BATCH_SIZE,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
/** 清理统计 */
|
||||
private stats: CleanupStats = {
|
||||
totalCleanups: 0,
|
||||
cleanedSessions: 0,
|
||||
cleanedPositions: 0,
|
||||
cleanedUsers: 0,
|
||||
lastCleanupTime: 0,
|
||||
avgCleanupTime: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
|
||||
/** 清理时间记录 */
|
||||
private cleanupTimes: number[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 模块初始化
|
||||
*/
|
||||
onModuleInit() {
|
||||
if (this.config.enabled) {
|
||||
this.startCleanupScheduler();
|
||||
this.logger.log('自动清理服务已启动', {
|
||||
interval: this.config.cleanupInterval,
|
||||
sessionExpiry: this.config.sessionExpiry,
|
||||
positionExpiry: this.config.positionExpiry,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
this.logger.log('自动清理服务已禁用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块销毁
|
||||
*/
|
||||
onModuleDestroy() {
|
||||
this.stopCleanupScheduler();
|
||||
this.logger.log('自动清理服务已停止');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动清理调度器
|
||||
*/
|
||||
startCleanupScheduler(): void {
|
||||
if (this.cleanupTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cleanupTimer = setInterval(async () => {
|
||||
await this.performCleanup();
|
||||
}, this.config.cleanupInterval);
|
||||
|
||||
this.logger.log('清理调度器已启动', {
|
||||
interval: this.config.cleanupInterval,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止清理调度器
|
||||
*/
|
||||
stopCleanupScheduler(): void {
|
||||
if (this.cleanupTimer) {
|
||||
clearInterval(this.cleanupTimer);
|
||||
this.cleanupTimer = null;
|
||||
this.logger.log('清理调度器已停止');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动执行清理
|
||||
*
|
||||
* @returns 清理结果
|
||||
*/
|
||||
async manualCleanup(): Promise<CleanupResult[]> {
|
||||
this.logger.log('开始手动清理操作');
|
||||
return await this.performCleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取清理统计信息
|
||||
*
|
||||
* @returns 统计信息
|
||||
*/
|
||||
getStats(): CleanupStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新清理配置
|
||||
*
|
||||
* @param newConfig 新配置
|
||||
*/
|
||||
updateConfig(newConfig: Partial<CleanupConfig>): void {
|
||||
const oldConfig = { ...this.config };
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
|
||||
this.logger.log('清理配置已更新', {
|
||||
oldConfig,
|
||||
newConfig: this.config,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 如果间隔时间改变,重启调度器
|
||||
if (oldConfig.cleanupInterval !== this.config.cleanupInterval) {
|
||||
this.stopCleanupScheduler();
|
||||
if (this.config.enabled) {
|
||||
this.startCleanupScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果启用状态改变
|
||||
if (oldConfig.enabled !== this.config.enabled) {
|
||||
if (this.config.enabled) {
|
||||
this.startCleanupScheduler();
|
||||
} else {
|
||||
this.stopCleanupScheduler();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置统计信息
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = {
|
||||
totalCleanups: 0,
|
||||
cleanedSessions: 0,
|
||||
cleanedPositions: 0,
|
||||
cleanedUsers: 0,
|
||||
lastCleanupTime: 0,
|
||||
avgCleanupTime: 0,
|
||||
errorCount: 0,
|
||||
};
|
||||
this.cleanupTimes = [];
|
||||
|
||||
this.logger.log('清理统计信息已重置');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行清理操作
|
||||
*
|
||||
* @returns 清理结果列表
|
||||
* @private
|
||||
*/
|
||||
private async performCleanup(): Promise<CleanupResult[]> {
|
||||
const startTime = Date.now();
|
||||
const results: CleanupResult[] = [];
|
||||
|
||||
try {
|
||||
this.logger.debug('开始执行清理操作', {
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// 清理过期会话
|
||||
const sessionResult = await this.cleanupExpiredSessions();
|
||||
results.push(sessionResult);
|
||||
|
||||
// 清理过期位置数据
|
||||
const positionResult = await this.cleanupExpiredPositions();
|
||||
results.push(positionResult);
|
||||
|
||||
// 清理离线用户
|
||||
const userResult = await this.cleanupOfflineUsers();
|
||||
results.push(userResult);
|
||||
|
||||
// 清理缓存数据
|
||||
const cacheResult = await this.cleanupCacheData();
|
||||
results.push(cacheResult);
|
||||
|
||||
// 更新统计信息
|
||||
const duration = Date.now() - startTime;
|
||||
this.updateStats(results, duration);
|
||||
|
||||
this.logger.log('清理操作完成', {
|
||||
duration,
|
||||
results: results.map(r => ({ operation: r.operation, count: r.count, success: r.success })),
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.stats.errorCount++;
|
||||
this.stats.lastError = error instanceof Error ? error.message : String(error);
|
||||
|
||||
this.logger.error('清理操作失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
results.push({
|
||||
operation: 'cleanup_error',
|
||||
count: 0,
|
||||
duration,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期会话
|
||||
*
|
||||
* @returns 清理结果
|
||||
* @private
|
||||
*/
|
||||
private async cleanupExpiredSessions(): Promise<CleanupResult> {
|
||||
const startTime = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
try {
|
||||
const cutoffTime = Date.now() - this.config.sessionExpiry;
|
||||
|
||||
// 这里应该实际清理Redis中的过期会话
|
||||
// 暂时模拟清理操作
|
||||
cleanedCount = Math.floor(Math.random() * CleanupService.MAX_SIMULATED_SESSION_CLEANUP); // 模拟清理会话
|
||||
|
||||
this.logger.debug('清理过期会话', {
|
||||
cutoffTime: new Date(cutoffTime).toISOString(),
|
||||
cleanedCount,
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_expired_sessions',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: true,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理过期会话失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_expired_sessions',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期位置数据
|
||||
*
|
||||
* @returns 清理结果
|
||||
* @private
|
||||
*/
|
||||
private async cleanupExpiredPositions(): Promise<CleanupResult> {
|
||||
const startTime = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
try {
|
||||
const cutoffTime = Date.now() - this.config.positionExpiry;
|
||||
|
||||
// 这里应该实际清理Redis中的过期位置数据
|
||||
// 暂时模拟清理操作
|
||||
cleanedCount = Math.floor(Math.random() * CleanupService.MAX_SIMULATED_POSITION_CLEANUP); // 模拟清理位置记录
|
||||
|
||||
this.logger.debug('清理过期位置数据', {
|
||||
cutoffTime: new Date(cutoffTime).toISOString(),
|
||||
cleanedCount,
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_expired_positions',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: true,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理过期位置数据失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_expired_positions',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理离线用户
|
||||
*
|
||||
* @returns 清理结果
|
||||
* @private
|
||||
*/
|
||||
private async cleanupOfflineUsers(): Promise<CleanupResult> {
|
||||
const startTime = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
try {
|
||||
const cutoffTime = Date.now() - this.config.userOfflineTimeout;
|
||||
|
||||
// 这里应该实际清理离线用户的数据
|
||||
// 暂时模拟清理操作
|
||||
cleanedCount = Math.floor(Math.random() * CleanupService.MAX_SIMULATED_USER_CLEANUP); // 模拟清理离线用户
|
||||
|
||||
this.logger.debug('清理离线用户', {
|
||||
cutoffTime: new Date(cutoffTime).toISOString(),
|
||||
cleanedCount,
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_offline_users',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: true,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理离线用户失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_offline_users',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理缓存数据
|
||||
*
|
||||
* @returns 清理结果
|
||||
* @private
|
||||
*/
|
||||
private async cleanupCacheData(): Promise<CleanupResult> {
|
||||
const startTime = Date.now();
|
||||
let cleanedCount = 0;
|
||||
|
||||
try {
|
||||
// 清理内存中的缓存数据
|
||||
// 这里可以清理性能监控数据、限流数据等
|
||||
|
||||
// 模拟清理操作
|
||||
cleanedCount = Math.floor(Math.random() * CleanupService.MAX_SIMULATED_CACHE_CLEANUP); // 模拟清理缓存项
|
||||
|
||||
this.logger.debug('清理缓存数据', {
|
||||
cleanedCount,
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_cache_data',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: true,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('清理缓存数据失败', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
|
||||
return {
|
||||
operation: 'cleanup_cache_data',
|
||||
count: cleanedCount,
|
||||
duration: Date.now() - startTime,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新统计信息
|
||||
*
|
||||
* @param results 清理结果列表
|
||||
* @param totalDuration 总耗时
|
||||
* @private
|
||||
*/
|
||||
private updateStats(results: CleanupResult[], totalDuration: number): void {
|
||||
this.stats.totalCleanups++;
|
||||
this.stats.lastCleanupTime = Date.now();
|
||||
|
||||
// 累计清理数量
|
||||
results.forEach(result => {
|
||||
switch (result.operation) {
|
||||
case 'cleanup_expired_sessions':
|
||||
this.stats.cleanedSessions += result.count;
|
||||
break;
|
||||
case 'cleanup_expired_positions':
|
||||
this.stats.cleanedPositions += result.count;
|
||||
break;
|
||||
case 'cleanup_offline_users':
|
||||
this.stats.cleanedUsers += result.count;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
this.stats.errorCount++;
|
||||
this.stats.lastError = result.error;
|
||||
}
|
||||
});
|
||||
|
||||
// 更新平均清理时间
|
||||
this.cleanupTimes.push(totalDuration);
|
||||
if (this.cleanupTimes.length > CleanupService.MAX_CLEANUP_TIME_RECORDS) {
|
||||
this.cleanupTimes = this.cleanupTimes.slice(-CleanupService.MAX_CLEANUP_TIME_RECORDS); // 只保留最近记录
|
||||
}
|
||||
|
||||
this.stats.avgCleanupTime = this.cleanupTimes.reduce((sum, time) => sum + time, 0) / this.cleanupTimes.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取清理配置
|
||||
*
|
||||
* @returns 当前配置
|
||||
*/
|
||||
getConfig(): CleanupConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下次清理时间
|
||||
*
|
||||
* @returns 下次清理时间戳
|
||||
*/
|
||||
getNextCleanupTime(): number {
|
||||
if (!this.config.enabled || !this.cleanupTimer) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.stats.lastCleanupTime + this.config.cleanupInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否需要立即清理
|
||||
*
|
||||
* @returns 是否需要清理
|
||||
*/
|
||||
shouldCleanupNow(): boolean {
|
||||
if (!this.config.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeSinceLastCleanup = Date.now() - this.stats.lastCleanupTime;
|
||||
return timeSinceLastCleanup >= this.config.cleanupInterval;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取清理健康状态
|
||||
*
|
||||
* @returns 健康状态信息
|
||||
*/
|
||||
getHealthStatus(): {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy';
|
||||
details: any;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCleanup = now - this.stats.lastCleanupTime;
|
||||
const maxInterval = this.config.cleanupInterval * CleanupService.HEALTH_CHECK_INTERVAL_MULTIPLIER; // 允许延迟间隔
|
||||
|
||||
let status: 'healthy' | 'degraded' | 'unhealthy' = 'healthy';
|
||||
|
||||
if (!this.config.enabled) {
|
||||
status = 'degraded';
|
||||
} else if (timeSinceLastCleanup > maxInterval) {
|
||||
status = 'unhealthy';
|
||||
} else if (this.stats.errorCount > 0 && this.stats.errorCount / this.stats.totalCleanups > CleanupService.ERROR_RATE_THRESHOLD) {
|
||||
status = 'degraded';
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
details: {
|
||||
enabled: this.config.enabled,
|
||||
timeSinceLastCleanup,
|
||||
errorRate: this.stats.totalCleanups > 0 ? this.stats.errorCount / this.stats.totalCleanups : 0,
|
||||
avgCleanupTime: this.stats.avgCleanupTime,
|
||||
nextCleanupIn: this.getNextCleanupTime() - now,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
59
src/business/location_broadcast/services/index.ts
Normal file
59
src/business/location_broadcast/services/index.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 位置广播业务服务导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 统一导出所有位置广播相关的业务服务
|
||||
* - 提供便捷的服务导入接口
|
||||
* - 支持模块化的服务管理
|
||||
* - 简化业务服务的使用和依赖注入
|
||||
*
|
||||
* 职责分离:
|
||||
* - 服务导出:统一管理所有业务服务的导出
|
||||
* - 类型导出:同时导出服务类和相关的类型定义
|
||||
* - 依赖简化:为外部模块提供简洁的服务导入方式
|
||||
* - 接口管理:统一管理服务接口的版本和兼容性
|
||||
*
|
||||
* 技术实现:
|
||||
* - 服务导出:使用ES6模块语法导出所有业务服务
|
||||
* - 类型导出:导出服务相关的DTO和接口类型
|
||||
* - 分类管理:按功能分类导出不同类型的服务
|
||||
* - 依赖注入:支持NestJS的依赖注入机制
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 规范优化 - 完善文件头注释,符合代码检查规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.1
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
export { LocationBroadcastService } from './location_broadcast.service';
|
||||
export { LocationSessionService } from './location_session.service';
|
||||
export { LocationPositionService } from './location_position.service';
|
||||
|
||||
// 导出相关的DTO类型
|
||||
export type {
|
||||
JoinSessionRequest,
|
||||
JoinSessionResponse,
|
||||
PositionUpdateRequest,
|
||||
PositionUpdateResponse,
|
||||
SessionStatsResponse
|
||||
} from './location_broadcast.service';
|
||||
|
||||
export type {
|
||||
CreateSessionRequest,
|
||||
SessionConfigDTO,
|
||||
SessionQueryRequest,
|
||||
SessionListResponse,
|
||||
SessionDetailResponse
|
||||
} from './location_session.service';
|
||||
|
||||
export type {
|
||||
PositionQueryRequest,
|
||||
PositionQueryResponse,
|
||||
PositionStatsRequest,
|
||||
PositionStatsResponse,
|
||||
PositionHistoryRequest,
|
||||
PositionValidationResult
|
||||
} from './location_position.service';
|
||||
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 位置广播业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 提供位置广播系统的主要业务逻辑
|
||||
* - 协调会话管理和位置更新的业务流程
|
||||
* - 处理业务规则验证和权限检查
|
||||
* - 为控制器层提供统一的业务接口
|
||||
*
|
||||
* 职责分离:
|
||||
* - 业务逻辑:实现位置广播的核心业务规则
|
||||
* - 数据协调:协调核心服务层的数据操作
|
||||
* - 权限验证:处理用户权限和业务规则验证
|
||||
* - 异常处理:统一的业务异常处理和转换
|
||||
*
|
||||
* 技术实现:
|
||||
* - 依赖注入:使用核心服务层提供的基础功能
|
||||
* - 业务验证:实现复杂的业务规则和数据验证
|
||||
* - 事务管理:确保数据操作的一致性
|
||||
* - 性能优化:批量操作和缓存策略
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger, BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { Position } from '../../../core/location_broadcast_core/position.interface';
|
||||
import { GameSession, SessionUser, SessionStatus } from '../../../core/location_broadcast_core/session.interface';
|
||||
|
||||
/**
|
||||
* 加入会话请求DTO
|
||||
*/
|
||||
export interface JoinSessionRequest {
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 会话ID */
|
||||
sessionId: string;
|
||||
/** Socket连接ID */
|
||||
socketId: string;
|
||||
/** 初始位置(可选) */
|
||||
initialPosition?: {
|
||||
mapId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
/** 会话密码(可选) */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加入会话响应DTO
|
||||
*/
|
||||
export interface JoinSessionResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 会话信息 */
|
||||
session: GameSession;
|
||||
/** 会话中的用户列表 */
|
||||
users: SessionUser[];
|
||||
/** 其他用户的位置信息 */
|
||||
positions: Position[];
|
||||
/** 响应消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新请求DTO
|
||||
*/
|
||||
export interface PositionUpdateRequest {
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 位置信息 */
|
||||
position: {
|
||||
mapId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
timestamp?: number;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新响应DTO
|
||||
*/
|
||||
export interface PositionUpdateResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 更新后的位置 */
|
||||
position: Position;
|
||||
/** 需要广播的用户列表 */
|
||||
broadcastTargets: string[];
|
||||
/** 响应消息 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话统计信息DTO
|
||||
*/
|
||||
export interface SessionStatsResponse {
|
||||
/** 会话ID */
|
||||
sessionId: string;
|
||||
/** 在线用户数 */
|
||||
onlineUsers: number;
|
||||
/** 总用户数 */
|
||||
totalUsers: number;
|
||||
/** 活跃地图列表 */
|
||||
activeMaps: string[];
|
||||
/** 会话创建时间 */
|
||||
createdAt: number;
|
||||
/** 最后活动时间 */
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocationBroadcastService {
|
||||
private readonly logger = new Logger(LocationBroadcastService.name);
|
||||
|
||||
/** 坐标最大值 */
|
||||
private static readonly MAX_COORDINATE = 999999;
|
||||
/** 坐标最小值 */
|
||||
private static readonly MIN_COORDINATE = -999999;
|
||||
/** 默认会话配置 */
|
||||
private static readonly DEFAULT_MAX_USERS = 100;
|
||||
private static readonly DEFAULT_TIMEOUT_SECONDS = 3600;
|
||||
private static readonly DEFAULT_BROADCAST_RANGE = 1000;
|
||||
/** 会话ID最大长度 */
|
||||
private static readonly MAX_SESSION_ID_LENGTH = 100;
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
@Inject('IUserPositionCore')
|
||||
private readonly userPositionCore: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 用户加入会话
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证会话是否存在和可加入
|
||||
* 2. 检查用户权限和会话容量
|
||||
* 3. 处理用户从其他会话的迁移
|
||||
* 4. 设置初始位置(如果提供)
|
||||
* 5. 返回完整的会话状态
|
||||
*
|
||||
* @param request 加入会话请求
|
||||
* @returns 加入会话响应
|
||||
*/
|
||||
async joinSession(request: JoinSessionRequest): Promise<JoinSessionResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理用户加入会话业务逻辑', {
|
||||
operation: 'joinSession',
|
||||
userId: request.userId,
|
||||
sessionId: request.sessionId,
|
||||
socketId: request.socketId,
|
||||
hasInitialPosition: !!request.initialPosition,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证请求参数
|
||||
this.validateJoinSessionRequest(request);
|
||||
|
||||
// 2. 检查用户是否已在其他会话中
|
||||
await this.handleUserSessionMigration(request.userId, request.sessionId);
|
||||
|
||||
// 3. 将用户添加到会话
|
||||
await this.locationBroadcastCore.addUserToSession(
|
||||
request.sessionId,
|
||||
request.userId,
|
||||
request.socketId
|
||||
);
|
||||
|
||||
// 4. 设置初始位置(如果提供)
|
||||
if (request.initialPosition) {
|
||||
const position: Position = {
|
||||
userId: request.userId,
|
||||
x: request.initialPosition.x,
|
||||
y: request.initialPosition.y,
|
||||
mapId: request.initialPosition.mapId,
|
||||
timestamp: Date.now(),
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
await this.locationBroadcastCore.setUserPosition(request.userId, position);
|
||||
}
|
||||
|
||||
// 5. 获取会话完整状态
|
||||
const [sessionUsers, sessionPositions] = await Promise.all([
|
||||
this.locationBroadcastCore.getSessionUsers(request.sessionId),
|
||||
this.locationBroadcastCore.getSessionPositions(request.sessionId)
|
||||
]);
|
||||
|
||||
// 6. 构建会话信息
|
||||
const session: GameSession = {
|
||||
sessionId: request.sessionId,
|
||||
users: sessionUsers,
|
||||
createdAt: Date.now(), // 这里应该从实际存储中获取
|
||||
lastActivity: Date.now(),
|
||||
status: SessionStatus.ACTIVE,
|
||||
config: {
|
||||
maxUsers: LocationBroadcastService.DEFAULT_MAX_USERS,
|
||||
timeoutSeconds: LocationBroadcastService.DEFAULT_TIMEOUT_SECONDS,
|
||||
allowObservers: true,
|
||||
requirePassword: false,
|
||||
broadcastRange: LocationBroadcastService.DEFAULT_BROADCAST_RANGE
|
||||
},
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户加入会话业务处理成功', {
|
||||
operation: 'joinSession',
|
||||
userId: request.userId,
|
||||
sessionId: request.sessionId,
|
||||
userCount: sessionUsers.length,
|
||||
positionCount: sessionPositions.length,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
session,
|
||||
users: sessionUsers,
|
||||
positions: sessionPositions,
|
||||
message: '成功加入会话'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('用户加入会话业务处理失败', {
|
||||
operation: 'joinSession',
|
||||
userId: request.userId,
|
||||
sessionId: request.sessionId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户离开会话
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证用户是否在指定会话中
|
||||
* 2. 处理位置数据的持久化
|
||||
* 3. 从会话中移除用户
|
||||
* 4. 清理相关缓存数据
|
||||
* 5. 返回操作结果
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param sessionId 会话ID
|
||||
* @param reason 离开原因
|
||||
* @returns 操作是否成功
|
||||
*/
|
||||
async leaveSession(userId: string, sessionId: string, reason: string = 'user_left'): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('处理用户离开会话业务逻辑', {
|
||||
operation: 'leaveSession',
|
||||
userId,
|
||||
sessionId,
|
||||
reason,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证参数
|
||||
if (!userId || !sessionId) {
|
||||
throw new BadRequestException('用户ID和会话ID不能为空');
|
||||
}
|
||||
|
||||
// 2. 获取用户当前位置并持久化
|
||||
const currentPosition = await this.locationBroadcastCore.getUserPosition(userId);
|
||||
if (currentPosition) {
|
||||
await this.userPositionCore.saveUserPosition(userId, currentPosition);
|
||||
}
|
||||
|
||||
// 3. 从会话中移除用户
|
||||
await this.locationBroadcastCore.removeUserFromSession(sessionId, userId);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('用户离开会话业务处理成功', {
|
||||
operation: 'leaveSession',
|
||||
userId,
|
||||
sessionId,
|
||||
reason,
|
||||
hadPosition: !!currentPosition,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('用户离开会话业务处理失败', {
|
||||
operation: 'leaveSession',
|
||||
userId,
|
||||
sessionId,
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户位置
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证位置数据的有效性
|
||||
* 2. 检查用户权限和地图限制
|
||||
* 3. 更新Redis缓存中的位置
|
||||
* 4. 确定需要广播的目标用户
|
||||
* 5. 可选:触发位置历史记录
|
||||
*
|
||||
* @param request 位置更新请求
|
||||
* @returns 位置更新响应
|
||||
*/
|
||||
async updatePosition(request: PositionUpdateRequest): Promise<PositionUpdateResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.debug('处理位置更新业务逻辑', {
|
||||
operation: 'updatePosition',
|
||||
userId: request.userId,
|
||||
mapId: request.position.mapId,
|
||||
x: request.position.x,
|
||||
y: request.position.y,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证位置数据
|
||||
this.validatePositionData(request.position);
|
||||
|
||||
// 2. 构建位置对象
|
||||
const position: Position = {
|
||||
userId: request.userId,
|
||||
x: request.position.x,
|
||||
y: request.position.y,
|
||||
mapId: request.position.mapId,
|
||||
timestamp: request.position.timestamp || Date.now(),
|
||||
metadata: request.position.metadata || {}
|
||||
};
|
||||
|
||||
// 3. 更新位置缓存
|
||||
await this.locationBroadcastCore.setUserPosition(request.userId, position);
|
||||
|
||||
// 获取需要广播的目标用户
|
||||
const broadcastTargets = await this.getBroadcastTargets(request.userId, position.mapId);
|
||||
|
||||
// 5. 可选:保存位置历史(每隔一定时间或距离)
|
||||
if (this.shouldSavePositionHistory(position)) {
|
||||
try {
|
||||
await this.userPositionCore.savePositionHistory(request.userId, position);
|
||||
} catch (error) {
|
||||
// 历史记录保存失败不影响主流程
|
||||
this.logger.warn('位置历史记录保存失败', {
|
||||
userId: request.userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.debug('位置更新业务处理成功', {
|
||||
operation: 'updatePosition',
|
||||
userId: request.userId,
|
||||
mapId: position.mapId,
|
||||
broadcastTargetCount: broadcastTargets.length,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
position,
|
||||
broadcastTargets,
|
||||
message: '位置更新成功'
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('位置更新业务处理失败', {
|
||||
operation: 'updatePosition',
|
||||
userId: request.userId,
|
||||
mapId: request.position.mapId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话统计信息
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @returns 会话统计信息
|
||||
*/
|
||||
async getSessionStats(sessionId: string): Promise<SessionStatsResponse> {
|
||||
try {
|
||||
const [sessionUsers, sessionPositions] = await Promise.all([
|
||||
this.locationBroadcastCore.getSessionUsers(sessionId),
|
||||
this.locationBroadcastCore.getSessionPositions(sessionId)
|
||||
]);
|
||||
|
||||
// 统计活跃地图
|
||||
const activeMaps = [...new Set(sessionPositions.map(pos => pos.mapId as string))];
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
onlineUsers: sessionUsers.length,
|
||||
totalUsers: sessionUsers.length, // 这里可以从数据库获取历史总数
|
||||
activeMaps: activeMaps as string[],
|
||||
createdAt: Date.now(), // 这里应该从实际存储中获取
|
||||
lastActivity: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取会话统计信息失败', {
|
||||
operation: 'getSessionStats',
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw new NotFoundException('会话不存在或获取统计信息失败');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取地图中的所有用户位置
|
||||
*
|
||||
* @param mapId 地图ID
|
||||
* @returns 位置列表
|
||||
*/
|
||||
async getMapPositions(mapId: string): Promise<Position[]> {
|
||||
try {
|
||||
return await this.locationBroadcastCore.getMapPositions(mapId);
|
||||
} catch (error) {
|
||||
this.logger.error('获取地图位置信息失败', {
|
||||
operation: 'getMapPositions',
|
||||
mapId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理用户数据
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @returns 清理是否成功
|
||||
*/
|
||||
async cleanupUserData(userId: string): Promise<boolean> {
|
||||
try {
|
||||
await this.locationBroadcastCore.cleanupUserData(userId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('清理用户数据失败', {
|
||||
operation: 'cleanupUserData',
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加入会话请求
|
||||
*
|
||||
* @param request 加入会话请求
|
||||
* @private
|
||||
*/
|
||||
private validateJoinSessionRequest(request: JoinSessionRequest): void {
|
||||
if (!request.userId) {
|
||||
throw new BadRequestException('用户ID不能为空');
|
||||
}
|
||||
|
||||
if (!request.sessionId) {
|
||||
throw new BadRequestException('会话ID不能为空');
|
||||
}
|
||||
|
||||
if (!request.socketId) {
|
||||
throw new BadRequestException('Socket连接ID不能为空');
|
||||
}
|
||||
|
||||
// 验证会话ID格式
|
||||
if (request.sessionId.length > LocationBroadcastService.MAX_SESSION_ID_LENGTH) {
|
||||
throw new BadRequestException(`会话ID长度不能超过${LocationBroadcastService.MAX_SESSION_ID_LENGTH}个字符`);
|
||||
}
|
||||
|
||||
// 验证初始位置(如果提供)
|
||||
if (request.initialPosition) {
|
||||
this.validatePositionData(request.initialPosition);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证位置数据
|
||||
*
|
||||
* @param position 位置数据
|
||||
* @private
|
||||
*/
|
||||
private validatePositionData(position: { mapId: string; x: number; y: number }): void {
|
||||
if (!position.mapId) {
|
||||
throw new BadRequestException('地图ID不能为空');
|
||||
}
|
||||
|
||||
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
|
||||
throw new BadRequestException('位置坐标必须是数字');
|
||||
}
|
||||
|
||||
if (!isFinite(position.x) || !isFinite(position.y)) {
|
||||
throw new BadRequestException('位置坐标必须是有效的数字');
|
||||
}
|
||||
|
||||
// 可以添加更多的位置验证规则,比如地图边界检查
|
||||
if (position.x > LocationBroadcastService.MAX_COORDINATE || position.x < LocationBroadcastService.MIN_COORDINATE ||
|
||||
position.y > LocationBroadcastService.MAX_COORDINATE || position.y < LocationBroadcastService.MIN_COORDINATE) {
|
||||
throw new BadRequestException('位置坐标超出允许范围');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户会话迁移
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param newSessionId 新会话ID
|
||||
* @private
|
||||
*/
|
||||
private async handleUserSessionMigration(userId: string, newSessionId: string): Promise<void> {
|
||||
try {
|
||||
// 这里可以实现用户从旧会话迁移到新会话的逻辑
|
||||
// 目前简单处理:清理用户的所有会话数据
|
||||
await this.locationBroadcastCore.cleanupUserData(userId);
|
||||
} catch (error) {
|
||||
this.logger.warn('用户会话迁移处理失败', {
|
||||
userId,
|
||||
newSessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
// 迁移失败不阻止加入新会话
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取需要广播的目标用户
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param mapId 地图ID
|
||||
* @returns 目标用户ID列表
|
||||
* @private
|
||||
*/
|
||||
private async getBroadcastTargets(userId: string, mapId: string): Promise<string[]> {
|
||||
try {
|
||||
// 获取同地图的所有用户位置
|
||||
const mapPositions = await this.locationBroadcastCore.getMapPositions(mapId);
|
||||
|
||||
// 排除当前用户,返回其他用户的ID
|
||||
return mapPositions
|
||||
.filter(pos => pos.userId !== userId)
|
||||
.map(pos => pos.userId as string);
|
||||
|
||||
} catch (error) {
|
||||
this.logger.warn('获取广播目标失败', {
|
||||
userId,
|
||||
mapId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否应该保存位置历史
|
||||
*
|
||||
* @param position 位置信息
|
||||
* @returns 是否应该保存
|
||||
* @private
|
||||
*/
|
||||
private shouldSavePositionHistory(position: Position): boolean {
|
||||
// 简单策略:每隔30秒保存一次历史记录
|
||||
// 实际项目中可以根据移动距离、时间间隔等更复杂的规则
|
||||
const now = Date.now();
|
||||
const lastSaveKey = `lastHistorySave:${position.userId}`;
|
||||
|
||||
// 这里应该使用缓存来记录上次保存时间
|
||||
// 为了简化,暂时返回false,可以后续优化
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
/**
|
||||
* 位置管理业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理用户位置数据的业务逻辑
|
||||
* - 处理位置验证、过滤和转换
|
||||
* - 提供位置查询和统计功能
|
||||
* - 实现位置相关的业务规则
|
||||
*
|
||||
* 职责分离:
|
||||
* - 位置业务:专注于位置数据的业务逻辑处理
|
||||
* - 数据验证:位置数据的格式验证和业务规则验证
|
||||
* - 查询服务:提供灵活的位置数据查询接口
|
||||
* - 统计分析:位置数据的统计和分析功能
|
||||
*
|
||||
* 技术实现:
|
||||
* - 位置验证:多层次的位置数据验证机制
|
||||
* - 性能优化:高效的位置查询和缓存策略
|
||||
* - 数据转换:位置数据格式的标准化处理
|
||||
* - 业务规则:复杂的位置相关业务逻辑实现
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.2.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Position, PositionHistory } from '../../../core/location_broadcast_core/position.interface';
|
||||
|
||||
/**
|
||||
* 位置查询请求DTO
|
||||
*/
|
||||
export interface PositionQueryRequest {
|
||||
/** 用户ID列表 */
|
||||
userIds?: string[];
|
||||
/** 地图ID */
|
||||
mapId?: string;
|
||||
/** 会话ID */
|
||||
sessionId?: string;
|
||||
/** 查询范围(中心点和半径) */
|
||||
range?: {
|
||||
centerX: number;
|
||||
centerY: number;
|
||||
radius: number;
|
||||
};
|
||||
/** 时间范围 */
|
||||
timeRange?: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
/** 是否包含离线用户 */
|
||||
includeOffline?: boolean;
|
||||
/** 分页参数 */
|
||||
pagination?: {
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置查询响应DTO
|
||||
*/
|
||||
export interface PositionQueryResponse {
|
||||
/** 位置列表 */
|
||||
positions: Position[];
|
||||
/** 总数 */
|
||||
total: number;
|
||||
/** 查询时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置统计请求DTO
|
||||
*/
|
||||
export interface PositionStatsRequest {
|
||||
/** 地图ID */
|
||||
mapId?: string;
|
||||
/** 会话ID */
|
||||
sessionId?: string;
|
||||
/** 时间范围 */
|
||||
timeRange?: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置统计响应DTO
|
||||
*/
|
||||
export interface PositionStatsResponse {
|
||||
/** 总用户数 */
|
||||
totalUsers: number;
|
||||
/** 在线用户数 */
|
||||
onlineUsers: number;
|
||||
/** 活跃地图数 */
|
||||
activeMaps: number;
|
||||
/** 地图用户分布 */
|
||||
mapDistribution: Record<string, number>;
|
||||
/** 位置更新频率(每分钟) */
|
||||
updateFrequency: number;
|
||||
/** 统计时间戳 */
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置历史查询请求DTO
|
||||
*/
|
||||
export interface PositionHistoryRequest {
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 时间范围 */
|
||||
timeRange?: {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
/** 地图ID过滤 */
|
||||
mapId?: string;
|
||||
/** 最大记录数 */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置验证结果DTO
|
||||
*/
|
||||
export interface PositionValidationResult {
|
||||
/** 是否有效 */
|
||||
isValid: boolean;
|
||||
/** 错误信息 */
|
||||
errors: string[];
|
||||
/** 警告信息 */
|
||||
warnings: string[];
|
||||
/** 修正后的位置(如果有) */
|
||||
correctedPosition?: Position;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocationPositionService {
|
||||
private readonly logger = new Logger(LocationPositionService.name);
|
||||
|
||||
/** 坐标最大值 */
|
||||
/** 坐标最大值 */
|
||||
private static readonly MAX_COORDINATE = 999999;
|
||||
/** 坐标最小值 */
|
||||
private static readonly MIN_COORDINATE = -999999;
|
||||
/** 默认查询限制 */
|
||||
private static readonly DEFAULT_QUERY_LIMIT = 100;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
||||
/** 位置时间戳最大偏差(毫秒) */
|
||||
private static readonly MAX_TIMESTAMP_DIFF = 5 * LocationPositionService.MILLISECONDS_PER_MINUTE;
|
||||
/** 地图ID最大长度 */
|
||||
private static readonly MAX_MAP_ID_LENGTH = 50;
|
||||
/** 用户ID列表最大数量 */
|
||||
private static readonly MAX_USER_IDS_COUNT = 1000;
|
||||
/** 查询半径最大值 */
|
||||
private static readonly MAX_QUERY_RADIUS = 10000;
|
||||
/** 分页限制最大值 */
|
||||
private static readonly MAX_PAGINATION_LIMIT = 1000;
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
@Inject('IUserPositionCore')
|
||||
private readonly userPositionCore: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 查询位置信息
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证查询参数
|
||||
* 2. 根据条件构建查询策略
|
||||
* 3. 执行位置数据查询
|
||||
* 4. 过滤和排序结果
|
||||
* 5. 返回格式化的查询结果
|
||||
*
|
||||
* @param request 位置查询请求
|
||||
* @returns 位置查询响应
|
||||
*/
|
||||
async queryPositions(request: PositionQueryRequest): Promise<PositionQueryResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('查询位置信息', {
|
||||
operation: 'queryPositions',
|
||||
userIds: request.userIds?.length,
|
||||
mapId: request.mapId,
|
||||
sessionId: request.sessionId,
|
||||
hasRange: !!request.range,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证查询参数
|
||||
this.validatePositionQuery(request);
|
||||
|
||||
let positions: Position[] = [];
|
||||
|
||||
// 2. 根据查询条件执行不同的查询策略
|
||||
if (request.sessionId) {
|
||||
// 按会话查询
|
||||
positions = await this.locationBroadcastCore.getSessionPositions(request.sessionId);
|
||||
} else if (request.mapId) {
|
||||
// 按地图查询
|
||||
positions = await this.locationBroadcastCore.getMapPositions(request.mapId);
|
||||
} else if (request.userIds && request.userIds.length > 0) {
|
||||
// 按用户ID列表查询
|
||||
positions = await this.queryPositionsByUserIds(request.userIds);
|
||||
} else {
|
||||
// 全量查询(需要谨慎使用)
|
||||
this.logger.warn('执行全量位置查询', { request });
|
||||
positions = [];
|
||||
}
|
||||
|
||||
// 3. 应用过滤条件
|
||||
positions = this.applyPositionFilters(positions, request);
|
||||
|
||||
// 4. 应用分页
|
||||
const total = positions.length;
|
||||
if (request.pagination) {
|
||||
const { offset, limit } = request.pagination;
|
||||
positions = positions.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('位置查询完成', {
|
||||
operation: 'queryPositions',
|
||||
resultCount: positions.length,
|
||||
total,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
positions,
|
||||
total,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('位置查询失败', {
|
||||
operation: 'queryPositions',
|
||||
request,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取位置统计信息
|
||||
*
|
||||
* @param request 统计请求
|
||||
* @returns 统计结果
|
||||
*/
|
||||
async getPositionStats(request: PositionStatsRequest): Promise<PositionStatsResponse> {
|
||||
try {
|
||||
let positions: Position[] = [];
|
||||
|
||||
// 根据条件获取位置数据
|
||||
if (request.sessionId) {
|
||||
positions = await this.locationBroadcastCore.getSessionPositions(request.sessionId);
|
||||
} else if (request.mapId) {
|
||||
positions = await this.locationBroadcastCore.getMapPositions(request.mapId);
|
||||
}
|
||||
|
||||
// 应用时间过滤
|
||||
if (request.timeRange) {
|
||||
positions = positions.filter(pos =>
|
||||
pos.timestamp >= request.timeRange!.startTime &&
|
||||
pos.timestamp <= request.timeRange!.endTime
|
||||
);
|
||||
}
|
||||
|
||||
// 计算统计信息
|
||||
const totalUsers = positions.length;
|
||||
const onlineUsers = totalUsers; // 缓存中的都是在线用户
|
||||
|
||||
// 统计地图分布
|
||||
const mapDistribution: Record<string, number> = {};
|
||||
positions.forEach(pos => {
|
||||
mapDistribution[pos.mapId] = (mapDistribution[pos.mapId] || 0) + 1;
|
||||
});
|
||||
|
||||
const activeMaps = Object.keys(mapDistribution).length;
|
||||
|
||||
// 计算更新频率(简化计算)
|
||||
const updateFrequency = positions.length > 0 ?
|
||||
positions.length / Math.max(1, (Date.now() - Math.min(...positions.map(p => p.timestamp))) / LocationPositionService.MILLISECONDS_PER_MINUTE) : 0;
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
onlineUsers,
|
||||
activeMaps,
|
||||
mapDistribution,
|
||||
updateFrequency,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取位置统计失败', {
|
||||
operation: 'getPositionStats',
|
||||
request,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户位置历史
|
||||
*
|
||||
* @param request 历史查询请求
|
||||
* @returns 位置历史列表
|
||||
*/
|
||||
async getPositionHistory(request: PositionHistoryRequest): Promise<PositionHistory[]> {
|
||||
try {
|
||||
this.logger.log('查询用户位置历史', {
|
||||
operation: 'getPositionHistory',
|
||||
userId: request.userId,
|
||||
mapId: request.mapId,
|
||||
limit: request.limit,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// 从核心服务获取位置历史
|
||||
const history = await this.userPositionCore.getPositionHistory(
|
||||
request.userId,
|
||||
request.limit || LocationPositionService.DEFAULT_QUERY_LIMIT
|
||||
);
|
||||
|
||||
// 应用过滤条件
|
||||
let filteredHistory = history;
|
||||
|
||||
if (request.timeRange) {
|
||||
filteredHistory = filteredHistory.filter(h =>
|
||||
h.timestamp >= request.timeRange!.startTime &&
|
||||
h.timestamp <= request.timeRange!.endTime
|
||||
);
|
||||
}
|
||||
|
||||
if (request.mapId) {
|
||||
filteredHistory = filteredHistory.filter(h => h.mapId === request.mapId);
|
||||
}
|
||||
|
||||
return filteredHistory;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取位置历史失败', {
|
||||
operation: 'getPositionHistory',
|
||||
request,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证位置数据
|
||||
*
|
||||
* @param position 位置数据
|
||||
* @returns 验证结果
|
||||
*/
|
||||
async validatePosition(position: Position): Promise<PositionValidationResult> {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
try {
|
||||
// 1. 基础数据验证
|
||||
if (!position.userId) {
|
||||
errors.push('用户ID不能为空');
|
||||
}
|
||||
|
||||
if (!position.mapId) {
|
||||
errors.push('地图ID不能为空');
|
||||
}
|
||||
|
||||
if (typeof position.x !== 'number' || typeof position.y !== 'number') {
|
||||
errors.push('坐标必须是数字');
|
||||
}
|
||||
|
||||
if (!isFinite(position.x) || !isFinite(position.y)) {
|
||||
errors.push('坐标必须是有效的数字');
|
||||
}
|
||||
|
||||
// 2. 坐标范围验证
|
||||
if (position.x > LocationPositionService.MAX_COORDINATE || position.x < LocationPositionService.MIN_COORDINATE ||
|
||||
position.y > LocationPositionService.MAX_COORDINATE || position.y < LocationPositionService.MIN_COORDINATE) {
|
||||
errors.push('坐标超出允许范围');
|
||||
}
|
||||
|
||||
// 3. 时间戳验证
|
||||
if (position.timestamp) {
|
||||
const now = Date.now();
|
||||
const timeDiff = Math.abs(now - position.timestamp);
|
||||
|
||||
if (timeDiff > LocationPositionService.MAX_TIMESTAMP_DIFF) {
|
||||
warnings.push('位置时间戳与当前时间差异较大');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 地图ID格式验证
|
||||
if (position.mapId && position.mapId.length > 50) {
|
||||
errors.push('地图ID长度不能超过50个字符');
|
||||
}
|
||||
|
||||
// 5. 元数据验证
|
||||
if (position.metadata) {
|
||||
try {
|
||||
JSON.stringify(position.metadata);
|
||||
} catch {
|
||||
errors.push('位置元数据格式无效');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('位置验证失败', {
|
||||
operation: 'validatePosition',
|
||||
position,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: false,
|
||||
errors: ['位置验证过程中发生错误'],
|
||||
warnings
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算两个位置之间的距离
|
||||
*
|
||||
* @param pos1 位置1
|
||||
* @param pos2 位置2
|
||||
* @returns 距离(像素单位)
|
||||
*/
|
||||
calculateDistance(pos1: Position, pos2: Position): number {
|
||||
if (pos1.mapId !== pos2.mapId) {
|
||||
return Infinity; // 不同地图距离为无穷大
|
||||
}
|
||||
|
||||
const dx = pos1.x - pos2.x;
|
||||
const dy = pos1.y - pos2.y;
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定范围内的用户
|
||||
*
|
||||
* @param centerPosition 中心位置
|
||||
* @param radius 半径
|
||||
* @returns 范围内的位置列表
|
||||
*/
|
||||
async getUsersInRange(centerPosition: Position, radius: number): Promise<Position[]> {
|
||||
try {
|
||||
// 获取同地图的所有用户
|
||||
const mapPositions = await this.locationBroadcastCore.getMapPositions(centerPosition.mapId);
|
||||
|
||||
// 过滤范围内的用户
|
||||
return mapPositions.filter(pos => {
|
||||
if (pos.userId === centerPosition.userId) {
|
||||
return false; // 排除自己
|
||||
}
|
||||
|
||||
const distance = this.calculateDistance(centerPosition, pos);
|
||||
return distance <= radius;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取范围内用户失败', {
|
||||
operation: 'getUsersInRange',
|
||||
centerPosition,
|
||||
radius,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新用户位置
|
||||
*
|
||||
* @param positions 位置列表
|
||||
* @returns 更新结果
|
||||
*/
|
||||
async batchUpdatePositions(positions: Position[]): Promise<{ success: number; failed: number }> {
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const position of positions) {
|
||||
try {
|
||||
// 验证位置
|
||||
const validation = await this.validatePosition(position);
|
||||
if (!validation.isValid) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 更新位置
|
||||
await this.locationBroadcastCore.setUserPosition(position.userId, position);
|
||||
success++;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.warn('批量更新位置失败', {
|
||||
userId: position.userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log('批量位置更新完成', {
|
||||
operation: 'batchUpdatePositions',
|
||||
total: positions.length,
|
||||
success,
|
||||
failed
|
||||
});
|
||||
|
||||
return { success, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据用户ID列表查询位置
|
||||
*
|
||||
* @param userIds 用户ID列表
|
||||
* @returns 位置列表
|
||||
* @private
|
||||
*/
|
||||
private async queryPositionsByUserIds(userIds: string[]): Promise<Position[]> {
|
||||
const positions: Position[] = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const position = await this.locationBroadcastCore.getUserPosition(userId);
|
||||
if (position) {
|
||||
positions.push(position);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('获取用户位置失败', {
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用位置过滤条件
|
||||
*
|
||||
* @param positions 原始位置列表
|
||||
* @param request 查询请求
|
||||
* @returns 过滤后的位置列表
|
||||
* @private
|
||||
*/
|
||||
private applyPositionFilters(positions: Position[], request: PositionQueryRequest): Position[] {
|
||||
let filtered = positions;
|
||||
|
||||
// 时间范围过滤
|
||||
if (request.timeRange) {
|
||||
filtered = filtered.filter(pos =>
|
||||
pos.timestamp >= request.timeRange!.startTime &&
|
||||
pos.timestamp <= request.timeRange!.endTime
|
||||
);
|
||||
}
|
||||
|
||||
// 地图过滤
|
||||
if (request.mapId) {
|
||||
filtered = filtered.filter(pos => pos.mapId === request.mapId);
|
||||
}
|
||||
|
||||
// 用户ID过滤
|
||||
if (request.userIds && request.userIds.length > 0) {
|
||||
const userIdSet = new Set(request.userIds);
|
||||
filtered = filtered.filter(pos => userIdSet.has(pos.userId));
|
||||
}
|
||||
|
||||
// 范围过滤
|
||||
if (request.range) {
|
||||
const { centerX, centerY, radius } = request.range;
|
||||
filtered = filtered.filter(pos => {
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(pos.x - centerX, 2) + Math.pow(pos.y - centerY, 2)
|
||||
);
|
||||
return distance <= radius;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证位置查询参数
|
||||
*
|
||||
* @param request 查询请求
|
||||
* @private
|
||||
*/
|
||||
private validatePositionQuery(request: PositionQueryRequest): void {
|
||||
if (request.userIds && request.userIds.length > 1000) {
|
||||
throw new BadRequestException('用户ID列表不能超过1000个');
|
||||
}
|
||||
|
||||
if (request.range) {
|
||||
const { centerX, centerY, radius } = request.range;
|
||||
|
||||
if (typeof centerX !== 'number' || typeof centerY !== 'number' || typeof radius !== 'number') {
|
||||
throw new BadRequestException('范围查询参数必须是数字');
|
||||
}
|
||||
|
||||
if (radius < 0 || radius > 10000) {
|
||||
throw new BadRequestException('查询半径必须在0-10000之间');
|
||||
}
|
||||
}
|
||||
|
||||
if (request.pagination) {
|
||||
const { offset, limit } = request.pagination;
|
||||
|
||||
if (offset < 0 || limit < 1 || limit > 1000) {
|
||||
throw new BadRequestException('分页参数无效');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
/**
|
||||
* 位置广播会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 管理游戏会话的创建、配置和生命周期
|
||||
* - 处理会话权限验证和用户管理
|
||||
* - 提供会话查询和统计功能
|
||||
* - 实现会话相关的业务规则
|
||||
*
|
||||
* 职责分离:
|
||||
* - 会话管理:专注于会话的创建、配置和状态管理
|
||||
* - 权限控制:处理会话访问权限和用户权限验证
|
||||
* - 业务规则:实现会话相关的复杂业务逻辑
|
||||
* - 数据查询:提供会话信息的查询和统计接口
|
||||
*
|
||||
* 技术实现:
|
||||
* - 会话配置:支持灵活的会话参数配置
|
||||
* - 权限验证:多层次的权限验证机制
|
||||
* - 状态管理:会话状态的实时跟踪和更新
|
||||
* - 性能优化:高效的会话查询和缓存策略
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 代码重构 - 提取魔法数字为常量,优化代码质量 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { Injectable, Inject, Logger, BadRequestException, NotFoundException, ForbiddenException, ConflictException } from '@nestjs/common';
|
||||
import { GameSession, SessionUser, SessionStatus, SessionConfig } from '../../../core/location_broadcast_core/session.interface';
|
||||
|
||||
/**
|
||||
* 创建会话请求DTO
|
||||
*/
|
||||
export interface CreateSessionRequest {
|
||||
/** 会话ID */
|
||||
sessionId: string;
|
||||
/** 创建者用户ID */
|
||||
creatorId: string;
|
||||
/** 会话名称 */
|
||||
name?: string;
|
||||
/** 会话描述 */
|
||||
description?: string;
|
||||
/** 最大用户数 */
|
||||
maxUsers?: number;
|
||||
/** 是否允许观察者 */
|
||||
allowObservers?: boolean;
|
||||
/** 会话密码 */
|
||||
password?: string;
|
||||
/** 地图限制 */
|
||||
allowedMaps?: string[];
|
||||
/** 广播范围 */
|
||||
broadcastRange?: number;
|
||||
/** 扩展配置 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话配置DTO
|
||||
*/
|
||||
export interface SessionConfigDTO {
|
||||
/** 最大用户数 */
|
||||
maxUsers: number;
|
||||
/** 是否允许观察者 */
|
||||
allowObservers: boolean;
|
||||
/** 会话密码 */
|
||||
password?: string;
|
||||
/** 地图限制 */
|
||||
allowedMaps?: string[];
|
||||
/** 广播范围 */
|
||||
broadcastRange?: number;
|
||||
/** 是否公开 */
|
||||
isPublic: boolean;
|
||||
/** 自动清理时间(分钟) */
|
||||
autoCleanupMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话查询条件DTO
|
||||
*/
|
||||
export interface SessionQueryRequest {
|
||||
/** 会话状态过滤 */
|
||||
status?: SessionStatus;
|
||||
/** 最小用户数 */
|
||||
minUsers?: number;
|
||||
/** 最大用户数 */
|
||||
maxUsers?: number;
|
||||
/** 是否只显示公开会话 */
|
||||
publicOnly?: boolean;
|
||||
/** 创建者ID */
|
||||
creatorId?: string;
|
||||
/** 分页偏移 */
|
||||
offset?: number;
|
||||
/** 分页大小 */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话列表响应DTO
|
||||
*/
|
||||
export interface SessionListResponse {
|
||||
/** 会话列表 */
|
||||
sessions: GameSession[];
|
||||
/** 总数 */
|
||||
total: number;
|
||||
/** 当前页 */
|
||||
page: number;
|
||||
/** 页大小 */
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话详情响应DTO
|
||||
*/
|
||||
export interface SessionDetailResponse {
|
||||
/** 会话信息 */
|
||||
session: GameSession;
|
||||
/** 用户列表 */
|
||||
users: SessionUser[];
|
||||
/** 在线用户数 */
|
||||
onlineCount: number;
|
||||
/** 活跃地图 */
|
||||
activeMaps: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocationSessionService {
|
||||
private readonly logger = new Logger(LocationSessionService.name);
|
||||
|
||||
/** 默认最大用户数 */
|
||||
private static readonly DEFAULT_MAX_USERS = 100;
|
||||
/** 默认广播范围 */
|
||||
private static readonly DEFAULT_BROADCAST_RANGE = 1000;
|
||||
/** 默认自动清理时间(分钟) */
|
||||
private static readonly DEFAULT_AUTO_CLEANUP_MINUTES = 60;
|
||||
/** 默认超时时间(秒) */
|
||||
private static readonly DEFAULT_TIMEOUT_SECONDS = 3600;
|
||||
/** 会话ID最大长度 */
|
||||
private static readonly MAX_SESSION_ID_LENGTH = 100;
|
||||
/** 最大用户数限制 */
|
||||
private static readonly MAX_USERS_LIMIT = 1000;
|
||||
/** 最小用户数限制 */
|
||||
private static readonly MIN_USERS_LIMIT = 1;
|
||||
/** 广播范围最大值 */
|
||||
private static readonly MAX_BROADCAST_RANGE = 10000;
|
||||
/** 默认分页大小 */
|
||||
private static readonly DEFAULT_PAGE_SIZE = 10;
|
||||
/** 自动清理时间最小值(分钟) */
|
||||
private static readonly MIN_AUTO_CLEANUP_MINUTES = 1;
|
||||
/** 自动清理时间最大值(分钟) */
|
||||
private static readonly MAX_AUTO_CLEANUP_MINUTES = 1440;
|
||||
/** 时间转换常量 */
|
||||
private static readonly MILLISECONDS_PER_MINUTE = 60 * 1000;
|
||||
private static readonly SECONDS_PER_MINUTE = 60;
|
||||
|
||||
constructor(
|
||||
@Inject('ILocationBroadcastCore')
|
||||
private readonly locationBroadcastCore: any,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 验证会话ID的唯一性
|
||||
* 2. 验证创建者权限
|
||||
* 3. 构建会话配置
|
||||
* 4. 创建会话并设置初始状态
|
||||
* 5. 返回创建的会话信息
|
||||
*
|
||||
* @param request 创建会话请求
|
||||
* @returns 创建的会话信息
|
||||
*/
|
||||
async createSession(request: CreateSessionRequest): Promise<GameSession> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('创建新会话', {
|
||||
operation: 'createSession',
|
||||
sessionId: request.sessionId,
|
||||
creatorId: request.creatorId,
|
||||
maxUsers: request.maxUsers,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证请求参数
|
||||
this.validateCreateSessionRequest(request);
|
||||
|
||||
// 2. 检查会话ID是否已存在
|
||||
const existingUsers = await this.locationBroadcastCore.getSessionUsers(request.sessionId);
|
||||
if (existingUsers.length > 0) {
|
||||
throw new ConflictException('会话ID已存在');
|
||||
}
|
||||
|
||||
// 3. 构建会话配置
|
||||
const configDTO: SessionConfigDTO = {
|
||||
maxUsers: request.maxUsers || LocationSessionService.DEFAULT_MAX_USERS,
|
||||
allowObservers: request.allowObservers !== false,
|
||||
password: request.password,
|
||||
allowedMaps: request.allowedMaps,
|
||||
broadcastRange: request.broadcastRange || LocationSessionService.DEFAULT_BROADCAST_RANGE,
|
||||
isPublic: !request.password,
|
||||
autoCleanupMinutes: LocationSessionService.DEFAULT_AUTO_CLEANUP_MINUTES
|
||||
};
|
||||
|
||||
const config: SessionConfig = {
|
||||
maxUsers: configDTO.maxUsers,
|
||||
timeoutSeconds: (configDTO.autoCleanupMinutes || LocationSessionService.DEFAULT_AUTO_CLEANUP_MINUTES) * LocationSessionService.SECONDS_PER_MINUTE,
|
||||
allowObservers: configDTO.allowObservers,
|
||||
requirePassword: !!configDTO.password,
|
||||
password: configDTO.password,
|
||||
mapRestriction: configDTO.allowedMaps,
|
||||
broadcastRange: configDTO.broadcastRange
|
||||
};
|
||||
|
||||
// 4. 创建会话对象
|
||||
const session: GameSession = {
|
||||
sessionId: request.sessionId,
|
||||
users: [], // 初始为空
|
||||
createdAt: Date.now(),
|
||||
lastActivity: Date.now(),
|
||||
status: SessionStatus.ACTIVE,
|
||||
config,
|
||||
metadata: {
|
||||
name: request.name || request.sessionId,
|
||||
description: request.description,
|
||||
creatorId: request.creatorId,
|
||||
isPublic: configDTO.isPublic,
|
||||
...request.metadata
|
||||
}
|
||||
};
|
||||
|
||||
// 5. 这里应该将会话信息保存到持久化存储
|
||||
// 目前暂时只在内存中管理,后续可以扩展到Redis或数据库
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('会话创建成功', {
|
||||
operation: 'createSession',
|
||||
sessionId: request.sessionId,
|
||||
creatorId: request.creatorId,
|
||||
config: configDTO,
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return session;
|
||||
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('会话创建失败', {
|
||||
operation: 'createSession',
|
||||
sessionId: request.sessionId,
|
||||
creatorId: request.creatorId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
duration,
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话详情
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param requestUserId 请求用户ID(用于权限验证)
|
||||
* @returns 会话详情
|
||||
*/
|
||||
async getSessionDetail(sessionId: string, requestUserId?: string): Promise<SessionDetailResponse> {
|
||||
try {
|
||||
// 1. 获取会话用户列表
|
||||
const users = await this.locationBroadcastCore.getSessionUsers(sessionId);
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new NotFoundException('会话不存在或已结束');
|
||||
}
|
||||
|
||||
// 2. 获取会话位置信息
|
||||
const positions = await this.locationBroadcastCore.getSessionPositions(sessionId);
|
||||
|
||||
// 3. 统计活跃地图
|
||||
const activeMaps = [...new Set(positions.map(pos => pos.mapId as string))];
|
||||
|
||||
// 4. 构建会话信息(这里应该从实际存储中获取)
|
||||
const session: GameSession = {
|
||||
sessionId,
|
||||
users,
|
||||
createdAt: Date.now(), // 应该从存储中获取
|
||||
lastActivity: Date.now(),
|
||||
status: SessionStatus.ACTIVE,
|
||||
config: {
|
||||
maxUsers: LocationSessionService.DEFAULT_MAX_USERS,
|
||||
timeoutSeconds: LocationSessionService.DEFAULT_TIMEOUT_SECONDS,
|
||||
allowObservers: true,
|
||||
requirePassword: false,
|
||||
broadcastRange: LocationSessionService.DEFAULT_BROADCAST_RANGE
|
||||
},
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
// 5. 统计在线用户
|
||||
const onlineCount = users.filter(user => user.status === 'online').length;
|
||||
|
||||
return {
|
||||
session,
|
||||
users,
|
||||
onlineCount,
|
||||
activeMaps: activeMaps as string[]
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('获取会话详情失败', {
|
||||
operation: 'getSessionDetail',
|
||||
sessionId,
|
||||
requestUserId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询会话列表
|
||||
*
|
||||
* @param query 查询条件
|
||||
* @returns 会话列表
|
||||
*/
|
||||
async querySessions(query: SessionQueryRequest): Promise<SessionListResponse> {
|
||||
try {
|
||||
// 这里应该实现实际的会话查询逻辑
|
||||
// 目前返回空列表,后续需要实现持久化存储
|
||||
|
||||
this.logger.log('查询会话列表', {
|
||||
operation: 'querySessions',
|
||||
query,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: [],
|
||||
total: 0,
|
||||
page: Math.floor((query.offset || 0) / (query.limit || LocationSessionService.DEFAULT_PAGE_SIZE)) + 1,
|
||||
pageSize: query.limit || LocationSessionService.DEFAULT_PAGE_SIZE
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('查询会话列表失败', {
|
||||
operation: 'querySessions',
|
||||
query,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话配置
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param config 新配置
|
||||
* @param operatorId 操作者ID
|
||||
* @returns 更新后的会话信息
|
||||
*/
|
||||
async updateSessionConfig(sessionId: string, config: Partial<SessionConfigDTO>, operatorId: string): Promise<GameSession> {
|
||||
try {
|
||||
// 1. 验证操作权限
|
||||
await this.validateSessionOperatorPermission(sessionId, operatorId);
|
||||
|
||||
// 2. 验证配置参数
|
||||
this.validateSessionConfig(config);
|
||||
|
||||
// 3. 这里应该更新持久化存储中的会话配置
|
||||
// 目前暂时跳过实际更新逻辑
|
||||
|
||||
// 4. 获取更新后的会话信息
|
||||
const sessionDetail = await this.getSessionDetail(sessionId, operatorId);
|
||||
|
||||
this.logger.log('会话配置更新成功', {
|
||||
operation: 'updateSessionConfig',
|
||||
sessionId,
|
||||
operatorId,
|
||||
config,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return sessionDetail.session;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话配置更新失败', {
|
||||
operation: 'updateSessionConfig',
|
||||
sessionId,
|
||||
operatorId,
|
||||
config,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束会话
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param operatorId 操作者ID
|
||||
* @param reason 结束原因
|
||||
* @returns 操作是否成功
|
||||
*/
|
||||
async endSession(sessionId: string, operatorId: string, reason: string = 'manual_end'): Promise<boolean> {
|
||||
try {
|
||||
// 1. 验证操作权限
|
||||
await this.validateSessionOperatorPermission(sessionId, operatorId);
|
||||
|
||||
// 2. 获取会话中的所有用户
|
||||
const users = await this.locationBroadcastCore.getSessionUsers(sessionId);
|
||||
|
||||
// 3. 移除所有用户
|
||||
for (const user of users) {
|
||||
try {
|
||||
await this.locationBroadcastCore.removeUserFromSession(sessionId, user.userId);
|
||||
} catch (error) {
|
||||
this.logger.warn('移除用户失败', {
|
||||
sessionId,
|
||||
userId: user.userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 清理空会话
|
||||
await this.locationBroadcastCore.cleanupEmptySession(sessionId);
|
||||
|
||||
this.logger.log('会话结束成功', {
|
||||
operation: 'endSession',
|
||||
sessionId,
|
||||
operatorId,
|
||||
reason,
|
||||
userCount: users.length,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话结束失败', {
|
||||
operation: 'endSession',
|
||||
sessionId,
|
||||
operatorId,
|
||||
reason,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话密码
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param password 密码
|
||||
* @returns 验证是否成功
|
||||
*/
|
||||
async validateSessionPassword(sessionId: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
// 这里应该从持久化存储中获取会话配置
|
||||
// 目前暂时返回true,表示验证通过
|
||||
|
||||
this.logger.debug('验证会话密码', {
|
||||
operation: 'validateSessionPassword',
|
||||
sessionId,
|
||||
hasPassword: !!password
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话密码验证失败', {
|
||||
operation: 'validateSessionPassword',
|
||||
sessionId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否可以加入会话
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param userId 用户ID
|
||||
* @returns 是否可以加入
|
||||
*/
|
||||
async canUserJoinSession(sessionId: string, userId: string): Promise<{ canJoin: boolean; reason?: string }> {
|
||||
try {
|
||||
// 1. 获取会话信息
|
||||
const sessionDetail = await this.getSessionDetail(sessionId);
|
||||
|
||||
// 2. 检查会话状态
|
||||
if (sessionDetail.session.status !== SessionStatus.ACTIVE) {
|
||||
return { canJoin: false, reason: '会话已结束或暂停' };
|
||||
}
|
||||
|
||||
// 3. 检查用户数量限制
|
||||
if (sessionDetail.users.length >= sessionDetail.session.config.maxUsers) {
|
||||
return { canJoin: false, reason: '会话已满' };
|
||||
}
|
||||
|
||||
// 4. 检查用户是否已在会话中
|
||||
const existingUser = sessionDetail.users.find(user => user.userId === userId);
|
||||
if (existingUser) {
|
||||
return { canJoin: false, reason: '用户已在会话中' };
|
||||
}
|
||||
|
||||
return { canJoin: true };
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('检查用户加入权限失败', {
|
||||
operation: 'canUserJoinSession',
|
||||
sessionId,
|
||||
userId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
|
||||
return { canJoin: false, reason: '权限检查失败' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证创建会话请求
|
||||
*
|
||||
* @param request 创建会话请求
|
||||
* @private
|
||||
*/
|
||||
private validateCreateSessionRequest(request: CreateSessionRequest): void {
|
||||
if (!request.sessionId) {
|
||||
throw new BadRequestException('会话ID不能为空');
|
||||
}
|
||||
|
||||
if (!request.creatorId) {
|
||||
throw new BadRequestException('创建者ID不能为空');
|
||||
}
|
||||
|
||||
if (request.sessionId.length > LocationSessionService.MAX_SESSION_ID_LENGTH) {
|
||||
throw new BadRequestException(`会话ID长度不能超过${LocationSessionService.MAX_SESSION_ID_LENGTH}个字符`);
|
||||
}
|
||||
|
||||
if (request.maxUsers !== undefined && (request.maxUsers < LocationSessionService.MIN_USERS_LIMIT || request.maxUsers > LocationSessionService.MAX_USERS_LIMIT)) {
|
||||
throw new BadRequestException(`最大用户数必须在${LocationSessionService.MIN_USERS_LIMIT}-${LocationSessionService.MAX_USERS_LIMIT}之间`);
|
||||
}
|
||||
|
||||
if (request.broadcastRange !== undefined && (request.broadcastRange < 0 || request.broadcastRange > LocationSessionService.MAX_BROADCAST_RANGE)) {
|
||||
throw new BadRequestException(`广播范围必须在0-${LocationSessionService.MAX_BROADCAST_RANGE}之间`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话配置
|
||||
*
|
||||
* @param config 会话配置
|
||||
* @private
|
||||
*/
|
||||
private validateSessionConfig(config: Partial<SessionConfigDTO>): void {
|
||||
if (config.maxUsers !== undefined && (config.maxUsers < LocationSessionService.MIN_USERS_LIMIT || config.maxUsers > LocationSessionService.MAX_USERS_LIMIT)) {
|
||||
throw new BadRequestException(`最大用户数必须在${LocationSessionService.MIN_USERS_LIMIT}-${LocationSessionService.MAX_USERS_LIMIT}之间`);
|
||||
}
|
||||
|
||||
if (config.broadcastRange !== undefined && (config.broadcastRange < 0 || config.broadcastRange > LocationSessionService.MAX_BROADCAST_RANGE)) {
|
||||
throw new BadRequestException(`广播范围必须在0-${LocationSessionService.MAX_BROADCAST_RANGE}之间`);
|
||||
}
|
||||
|
||||
if (config.autoCleanupMinutes !== undefined && (config.autoCleanupMinutes < LocationSessionService.MIN_AUTO_CLEANUP_MINUTES || config.autoCleanupMinutes > LocationSessionService.MAX_AUTO_CLEANUP_MINUTES)) {
|
||||
throw new BadRequestException(`自动清理时间必须在${LocationSessionService.MIN_AUTO_CLEANUP_MINUTES}-${LocationSessionService.MAX_AUTO_CLEANUP_MINUTES}分钟之间`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话操作权限
|
||||
*
|
||||
* @param sessionId 会话ID
|
||||
* @param operatorId 操作者ID
|
||||
* @private
|
||||
*/
|
||||
private async validateSessionOperatorPermission(sessionId: string, operatorId: string): Promise<void> {
|
||||
// 这里应该实现实际的权限验证逻辑
|
||||
// 比如检查操作者是否是会话创建者或管理员
|
||||
|
||||
// 目前暂时跳过权限验证
|
||||
this.logger.debug('验证会话操作权限', {
|
||||
sessionId,
|
||||
operatorId
|
||||
});
|
||||
}
|
||||
}
|
||||
274
src/business/location_broadcast/websocket_auth.guard.ts
Normal file
274
src/business/location_broadcast/websocket_auth.guard.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* WebSocket认证守卫
|
||||
*
|
||||
* 功能描述:
|
||||
* - 验证WebSocket连接中的JWT令牌
|
||||
* - 提取用户信息并添加到WebSocket客户端上下文
|
||||
* - 保护需要认证的WebSocket事件处理器
|
||||
* - 处理WebSocket特有的认证流程
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于WebSocket环境下的JWT令牌验证
|
||||
* - 提供统一的WebSocket认证守卫机制
|
||||
* - 处理WebSocket认证失败的异常情况
|
||||
* - 支持实时通信的安全认证
|
||||
*
|
||||
* 技术实现:
|
||||
* - 从WebSocket消息中提取JWT令牌
|
||||
* - 使用现有的LoginCore服务进行令牌验证
|
||||
* - 将用户信息附加到WebSocket客户端对象
|
||||
* - 提供错误处理和日志记录
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-09: 重构为原生WebSocket - 适配原生WebSocket接口 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 2.0.0
|
||||
* @since 2026-01-08
|
||||
* @lastModified 2026-01-09
|
||||
*/
|
||||
|
||||
import { Injectable, CanActivate, ExecutionContext, Logger } from '@nestjs/common';
|
||||
import { WsException } from '@nestjs/websockets';
|
||||
import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service';
|
||||
|
||||
/**
|
||||
* 扩展的WebSocket客户端接口,包含用户信息
|
||||
*
|
||||
* 职责:
|
||||
* - 扩展原生WebSocket接口
|
||||
* - 添加用户认证信息到客户端对象
|
||||
* - 提供类型安全的用户数据访问
|
||||
*/
|
||||
export interface AuthenticatedSocket extends WebSocket {
|
||||
/** 客户端ID */
|
||||
id: string;
|
||||
/** 认证用户信息 */
|
||||
user?: JwtPayload;
|
||||
/** 用户ID(便于快速访问) */
|
||||
userId?: string;
|
||||
/** 认证时间戳 */
|
||||
authenticatedAt?: number;
|
||||
/** 会话ID集合 */
|
||||
sessionIds?: Set<string>;
|
||||
/** 连接超时 */
|
||||
connectionTimeout?: NodeJS.Timeout;
|
||||
/** 心跳状态 */
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WebSocketAuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(WebSocketAuthGuard.name);
|
||||
|
||||
constructor(private readonly loginCoreService: LoginCoreService) {}
|
||||
|
||||
/**
|
||||
* WebSocket JWT令牌验证和用户认证
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 从WebSocket客户端获取认证信息
|
||||
* 2. 提取JWT令牌(支持多种提取方式)
|
||||
* 3. 验证令牌的有效性和签名
|
||||
* 4. 解码令牌获取用户信息
|
||||
* 5. 将用户信息添加到Socket客户端对象
|
||||
* 6. 记录认证成功或失败的日志
|
||||
* 7. 返回认证结果或抛出WebSocket异常
|
||||
*
|
||||
* @param context 执行上下文,包含WebSocket客户端信息
|
||||
* @returns Promise<boolean> 认证是否成功
|
||||
* @throws WsException 当令牌缺失或无效时
|
||||
*/
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const client = context.switchToWs().getClient<AuthenticatedSocket>();
|
||||
const data = context.switchToWs().getData();
|
||||
|
||||
this.logAuthStart(client, context);
|
||||
|
||||
try {
|
||||
const token = this.extractToken(client, data);
|
||||
|
||||
if (!token) {
|
||||
this.handleMissingToken(client);
|
||||
}
|
||||
|
||||
// 如果是缓存的认证信息,直接返回成功
|
||||
if (token === 'cached' && client.user && client.userId) {
|
||||
this.logger.debug('使用缓存的认证信息', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||||
this.attachUserToClient(client, payload);
|
||||
this.logAuthSuccess(client, payload);
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
this.handleAuthError(client, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录认证开始日志
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param context 执行上下文
|
||||
* @private
|
||||
*/
|
||||
private logAuthStart(client: AuthenticatedSocket, context: ExecutionContext): void {
|
||||
this.logger.log('开始WebSocket认证验证', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
eventName: context.getHandler().name,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理缺少令牌的情况
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @throws WsException
|
||||
* @private
|
||||
*/
|
||||
private handleMissingToken(client: AuthenticatedSocket): never {
|
||||
this.logger.warn('WebSocket认证失败:缺少认证令牌', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
reason: 'missing_token'
|
||||
});
|
||||
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'INVALID_TOKEN',
|
||||
message: '缺少认证令牌',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户信息附加到客户端
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param payload JWT载荷
|
||||
* @private
|
||||
*/
|
||||
private attachUserToClient(client: AuthenticatedSocket, payload: JwtPayload): void {
|
||||
client.user = payload;
|
||||
client.userId = payload.sub;
|
||||
client.authenticatedAt = Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录认证成功日志
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param payload JWT载荷
|
||||
* @private
|
||||
*/
|
||||
private logAuthSuccess(client: AuthenticatedSocket, payload: JwtPayload): void {
|
||||
this.logger.log('WebSocket认证成功', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
userId: payload.sub,
|
||||
username: payload.username,
|
||||
role: payload.role,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理认证错误
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
* @param error 错误对象
|
||||
* @throws WsException
|
||||
* @private
|
||||
*/
|
||||
private handleAuthError(client: AuthenticatedSocket, error: any): never {
|
||||
this.logger.error('WebSocket认证失败', {
|
||||
operation: 'websocket_auth',
|
||||
socketId: client.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
timestamp: new Date().toISOString()
|
||||
}, error instanceof Error ? error.stack : undefined);
|
||||
|
||||
// 如果已经是WsException,直接抛出
|
||||
if (error instanceof WsException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 转换为WebSocket异常
|
||||
throw new WsException({
|
||||
type: 'error',
|
||||
code: 'INVALID_TOKEN',
|
||||
message: '无效的认证令牌',
|
||||
details: {
|
||||
reason: error instanceof Error ? error.message : String(error)
|
||||
},
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 从WebSocket连接中提取JWT令牌
|
||||
*
|
||||
* 技术实现:
|
||||
* 1. 优先从消息数据中提取token字段
|
||||
* 2. 检查是否已经认证过(用于后续消息)
|
||||
* 3. 从URL查询参数中提取token(如果可用)
|
||||
*
|
||||
* 支持的令牌传递方式:
|
||||
* - 消息数据: { token: "jwt_token" }
|
||||
* - 缓存认证: 使用已验证的用户信息
|
||||
*
|
||||
* @param client WebSocket客户端对象
|
||||
* @param data 消息数据
|
||||
* @returns JWT令牌字符串或undefined
|
||||
*/
|
||||
private extractToken(client: AuthenticatedSocket, data: any): string | undefined {
|
||||
// 1. 优先从消息数据中提取token
|
||||
if (data && typeof data === 'object' && data.token) {
|
||||
this.logger.debug('从消息数据中提取到token', {
|
||||
socketId: client.id,
|
||||
source: 'message_data'
|
||||
});
|
||||
return data.token;
|
||||
}
|
||||
|
||||
// 2. 检查是否已经认证过(用于后续消息)
|
||||
if (client.user && client.userId) {
|
||||
this.logger.debug('使用已认证的用户信息', {
|
||||
socketId: client.id,
|
||||
userId: client.userId,
|
||||
source: 'cached_auth'
|
||||
});
|
||||
return 'cached'; // 返回特殊标识,表示使用缓存的认证信息
|
||||
}
|
||||
|
||||
this.logger.warn('未找到有效的认证令牌', {
|
||||
socketId: client.id,
|
||||
availableSources: {
|
||||
messageData: !!data?.token,
|
||||
cachedAuth: !!(client.user && client.userId)
|
||||
}
|
||||
});
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理客户端的认证信息
|
||||
*
|
||||
* @param client WebSocket客户端
|
||||
*/
|
||||
static clearAuthentication(client: AuthenticatedSocket): void {
|
||||
delete client.user;
|
||||
delete client.userId;
|
||||
delete client.authenticatedAt;
|
||||
}
|
||||
}
|
||||
8
src/business/mall/dto/purchase_mall_item.dto.ts
Normal file
8
src/business/mall/dto/purchase_mall_item.dto.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class PurchaseMallItemDto {
|
||||
@IsString({ message: '商品ID必须是字符串' })
|
||||
@Length(1, 100, { message: '商品ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '商品ID格式不正确' })
|
||||
item_id!: string;
|
||||
}
|
||||
59
src/business/mall/mall.controller.ts
Normal file
59
src/business/mall/mall.controller.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Body, Controller, Get, HttpStatus, Post, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { MallService } from './mall.service';
|
||||
import { PurchaseMallItemDto } from './dto/purchase_mall_item.dto';
|
||||
|
||||
@ApiTags('shop')
|
||||
@ApiBearerAuth()
|
||||
@Controller('shop')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MallController {
|
||||
constructor(private readonly mallService: MallService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取当前账号商城数据',
|
||||
description: '返回当前账号钱包余额、商城分类和每个商品的用户维度状态。',
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '商城数据获取成功',
|
||||
})
|
||||
@Get('catalog')
|
||||
async getCatalog(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.mallService.getCatalog(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '商城数据获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '购买商城商品',
|
||||
description: '当前阶段支持购买角色皮肤,并返回账号已拥有皮肤列表。',
|
||||
})
|
||||
@ApiBody({ type: PurchaseMallItemDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '购买成功',
|
||||
})
|
||||
@Post('purchases')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async purchase(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() purchaseDto: PurchaseMallItemDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.mallService.purchaseItem(BigInt(user.sub), purchaseDto.item_id);
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '购买成功',
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
16
src/business/mall/mall.module.ts
Normal file
16
src/business/mall/mall.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { PlayerModule } from '../player/player.module';
|
||||
import { MallController } from './mall.controller';
|
||||
import { MallService } from './mall.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
LoginCoreModule,
|
||||
PlayerModule,
|
||||
],
|
||||
controllers: [MallController],
|
||||
providers: [MallService],
|
||||
exports: [MallService],
|
||||
})
|
||||
export class MallModule {}
|
||||
176
src/business/mall/mall.service.ts
Normal file
176
src/business/mall/mall.service.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { MALL_CATEGORIES, MALL_ITEMS, findMallItem } from './mall_catalog';
|
||||
import { InventoryService } from '../player/inventory.service';
|
||||
import { EconomyService } from '../player/economy.service';
|
||||
import { PlayerStateService } from '../player/player_state.service';
|
||||
import { PlayerInventoryPayload, PlayerSnapshotPayload, PlayerWalletPayload } from '../player/player.types';
|
||||
|
||||
interface IUserWalletsService {
|
||||
getBalance(userId: bigint): Promise<{ balance: number; currency: 'whale_coin'; user_id: string }>;
|
||||
}
|
||||
|
||||
export interface PurchaseMallItemResult {
|
||||
item_id: string;
|
||||
item_type: string;
|
||||
skin_id?: string;
|
||||
decor_id?: string;
|
||||
price: number;
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
owned_skin_ids: string[];
|
||||
owned_decor_ids: string[];
|
||||
already_owned: boolean;
|
||||
wallet: PlayerWalletPayload;
|
||||
inventory: PlayerInventoryPayload;
|
||||
snapshot: PlayerSnapshotPayload;
|
||||
}
|
||||
|
||||
export interface MallCatalogItemPayload {
|
||||
id: string;
|
||||
itemType: string;
|
||||
skinId?: string;
|
||||
decorId?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
price: number;
|
||||
status: 'owned' | 'available';
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface MallCatalogPayload {
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
categories: Array<{ id: string; label: string; icon: string }>;
|
||||
items: MallCatalogItemPayload[];
|
||||
owned_skin_ids: string[];
|
||||
owned_decor_ids: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MallService {
|
||||
constructor(
|
||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
private readonly economyService: EconomyService,
|
||||
private readonly playerStateService: PlayerStateService,
|
||||
) {}
|
||||
|
||||
async getWallet(userId: bigint) {
|
||||
return await this.userWalletsService.getBalance(userId);
|
||||
}
|
||||
|
||||
async getCatalog(userId: bigint): Promise<MallCatalogPayload> {
|
||||
const [wallet, inventory] = await Promise.all([
|
||||
this.userWalletsService.getBalance(userId),
|
||||
this.inventoryService.listInventory(userId),
|
||||
]);
|
||||
const ownedSkinIds = inventory.skin_ids;
|
||||
const ownedDecorIds = inventory.room_decor_ids;
|
||||
const ownedSet = new Set(ownedSkinIds);
|
||||
const ownedDecorSet = new Set(ownedDecorIds);
|
||||
const items = MALL_ITEMS
|
||||
.map((item) => ({
|
||||
id: item.itemId,
|
||||
itemType: item.itemType,
|
||||
skinId: item.skinId,
|
||||
decorId: item.decorId,
|
||||
icon: item.icon,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
description: item.description,
|
||||
price: item.price,
|
||||
status: (
|
||||
(item.skinId && ownedSet.has(item.skinId)) ||
|
||||
(item.decorId && ownedDecorSet.has(item.decorId))
|
||||
) ? 'owned' as const : 'available' as const,
|
||||
tags: item.tags,
|
||||
sortOrder: item.sortOrder,
|
||||
}))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return {
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
categories: MALL_CATEGORIES,
|
||||
items,
|
||||
owned_skin_ids: ownedSkinIds,
|
||||
owned_decor_ids: ownedDecorIds,
|
||||
};
|
||||
}
|
||||
|
||||
async purchaseItem(userId: bigint, itemId: string): Promise<PurchaseMallItemResult> {
|
||||
const item = findMallItem(itemId);
|
||||
if (!item) {
|
||||
throw new BadRequestException('商品不存在或暂未开放');
|
||||
}
|
||||
if (item.itemType === 'skin' && item.skinId) {
|
||||
return await this.purchaseSkinItem(userId, item);
|
||||
}
|
||||
if (item.itemType === 'room_decor' && item.decorId) {
|
||||
return await this.purchaseRoomDecorItem(userId, item);
|
||||
}
|
||||
throw new BadRequestException('商品类型暂未开放');
|
||||
}
|
||||
|
||||
private async purchaseSkinItem(userId: bigint, item: NonNullable<ReturnType<typeof findMallItem>>): Promise<PurchaseMallItemResult> {
|
||||
const alreadyOwned = await this.inventoryService.hasAsset(userId, 'skin', item.skinId as string);
|
||||
let wallet = await this.economyService.getWallet(userId);
|
||||
if (!alreadyOwned && item.price > 0) {
|
||||
wallet = await this.economyService.spend(userId, item.price, 'shop_purchase', item.itemId, `购买皮肤:${item.name}`);
|
||||
}
|
||||
|
||||
await this.inventoryService.grantAsset(userId, 'skin', item.skinId as string, 'purchase');
|
||||
const [inventory, snapshot] = await Promise.all([
|
||||
this.inventoryService.listInventory(userId),
|
||||
this.playerStateService.getSnapshot(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
item_id: item.itemId,
|
||||
item_type: item.itemType,
|
||||
skin_id: item.skinId,
|
||||
price: item.price,
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
owned_skin_ids: inventory.skin_ids,
|
||||
owned_decor_ids: inventory.room_decor_ids,
|
||||
already_owned: alreadyOwned,
|
||||
wallet,
|
||||
inventory,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
private async purchaseRoomDecorItem(userId: bigint, item: NonNullable<ReturnType<typeof findMallItem>>): Promise<PurchaseMallItemResult> {
|
||||
const decorId = item.decorId as string;
|
||||
const alreadyOwned = await this.inventoryService.hasAsset(userId, 'room_decor', decorId);
|
||||
let wallet = await this.economyService.getWallet(userId);
|
||||
if (!alreadyOwned && item.price > 0) {
|
||||
wallet = await this.economyService.spend(userId, item.price, 'shop_purchase', item.itemId, `购买房间摆件:${item.name}`);
|
||||
}
|
||||
|
||||
await this.inventoryService.grantAsset(userId, 'room_decor', decorId, 'purchase');
|
||||
const [inventory, snapshot] = await Promise.all([
|
||||
this.inventoryService.listInventory(userId),
|
||||
this.playerStateService.getSnapshot(userId),
|
||||
]);
|
||||
|
||||
return {
|
||||
item_id: item.itemId,
|
||||
item_type: item.itemType,
|
||||
decor_id: decorId,
|
||||
price: item.price,
|
||||
balance: wallet.balance,
|
||||
currency: wallet.currency,
|
||||
owned_skin_ids: inventory.skin_ids,
|
||||
owned_decor_ids: inventory.room_decor_ids,
|
||||
already_owned: alreadyOwned,
|
||||
wallet,
|
||||
inventory,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
}
|
||||
216
src/business/mall/mall_catalog.ts
Normal file
216
src/business/mall/mall_catalog.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export type MallItemType = 'skin' | 'room_decor';
|
||||
|
||||
export interface MallCatalogItem {
|
||||
itemId: string;
|
||||
itemType: MallItemType;
|
||||
skinId?: string;
|
||||
decorId?: string;
|
||||
icon?: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
price: number;
|
||||
tags: string[];
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export const MALL_CATEGORIES = [
|
||||
{ id: 'recommended', label: '推荐', icon: 'recommended' },
|
||||
{ id: 'outfit', label: '装扮', icon: 'outfit' },
|
||||
{ id: 'items', label: '道具', icon: 'items' },
|
||||
{ id: 'companion', label: '伙伴', icon: 'companion' },
|
||||
{ id: 'space', label: '空间', icon: 'space' },
|
||||
{ id: 'limited', label: '限时', icon: 'limited' },
|
||||
];
|
||||
|
||||
export const MALL_ITEMS: MallCatalogItem[] = [
|
||||
{
|
||||
itemId: 'skin_classic_whale',
|
||||
itemType: 'skin',
|
||||
skinId: 'classic_whale',
|
||||
name: '经典鲸鱼',
|
||||
category: 'outfit',
|
||||
description: '圆润、轻快的鲸鱼居民皮肤,适合喜欢海洋感角色的玩家。',
|
||||
price: 680,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 10,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_human_whale_directional_v2_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'human_whale_directional_v2_8x4',
|
||||
name: '海风行者',
|
||||
category: 'outfit',
|
||||
description: '蓝白海风主题的人类角色皮肤,带有鲸鱼小镇风格的服装细节。',
|
||||
price: 680,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 20,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_girl_sailor_turnaround_v2_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'girl_sailor_turnaround_v2_8x4',
|
||||
name: '海风少女',
|
||||
category: 'outfit',
|
||||
description: '水手风格的人类角色皮肤,适合轻松、清爽的 WhaleTown 日常。',
|
||||
price: 880,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 30,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_panda_hero_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'panda_hero_8x4',
|
||||
icon: 'res://assets/ui/mall/skins/panda_hero_8x4_product.png',
|
||||
name: '熊猫侠',
|
||||
category: 'outfit',
|
||||
description: '黑白连帽外观的人类角色皮肤,四方向8帧动作,适合想要更鲜明角色辨识度的玩家。',
|
||||
price: 980,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 40,
|
||||
},
|
||||
{
|
||||
itemId: 'skin_ordinary_man_male_8x4',
|
||||
itemType: 'skin',
|
||||
skinId: 'ordinary_man_male_8x4',
|
||||
icon: 'res://assets/ui/mall/skins/ordinary_man_male_8x4_product.png',
|
||||
name: '普通人(男)',
|
||||
category: 'outfit',
|
||||
description: '男性日常角色皮肤,四方向8帧动作,适合普通玩家形象。',
|
||||
price: 980,
|
||||
tags: ['可预览', '永久', '皮肤'],
|
||||
sortOrder: 50,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_floor_rug',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_floor_rug',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||
name: '鲸浪地毯',
|
||||
category: 'space',
|
||||
description: '蓝白鲸鱼主题地毯,适合铺在个人房间地板区域。',
|
||||
price: 260,
|
||||
tags: ['房间家具', '可拖拽', '地面'],
|
||||
sortOrder: 110,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_memory_board',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_memory_board',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||
name: '鲸语记忆板',
|
||||
category: 'space',
|
||||
description: '挂在房间里的鲸鱼木质装饰板,适合点缀窗边墙面。',
|
||||
price: 220,
|
||||
tags: ['房间家具', '可拖拽', '挂件'],
|
||||
sortOrder: 120,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_lamp',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_lamp',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||
name: '鲸尾暖灯',
|
||||
category: 'space',
|
||||
description: '鲸尾造型的温暖装饰灯,可自由摆放在个人房间中。',
|
||||
price: 360,
|
||||
tags: ['房间家具', '可拖拽', '灯具'],
|
||||
sortOrder: 130,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_boat_cabin_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'boat_cabin_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||
name: '船舱小床',
|
||||
category: 'space',
|
||||
description: '白木船舱造型的小床,适合放在个人房间地面区域。',
|
||||
price: 520,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 140,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_low_wave_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'low_wave_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||
name: '海浪低床',
|
||||
category: 'space',
|
||||
description: '蓝白海浪被面的低矮小床,适合轻松的海风房间。',
|
||||
price: 500,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 150,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_whale_tail_headboard_bed',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'whale_tail_headboard_bed',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||
name: '鲸尾床头床',
|
||||
category: 'space',
|
||||
description: '鲸尾床头和深蓝被面的主题小床,鲸镇特色更明显。',
|
||||
price: 580,
|
||||
tags: ['房间家具', '可拖拽', '床'],
|
||||
sortOrder: 180,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_dev_whale_bookshelf',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'dev_whale_bookshelf',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
name: '程序员鲸书架',
|
||||
category: 'space',
|
||||
description: '带 GitHub、Datawhale 和代码小物件的蓝白书架,适合程序员风格的个人房间。',
|
||||
price: 620,
|
||||
tags: ['房间家具', '可拖拽', '书架'],
|
||||
sortOrder: 190,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_bug_feature_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_bug_feature_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||
name: 'BUG特性徽章',
|
||||
category: 'space',
|
||||
description: '写着“这不是BUG 这是feature”的佛系学习小徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 200,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_buddhist_learning_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_buddhist_learning_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||
name: '佛系学习徽章',
|
||||
category: 'space',
|
||||
description: 'Datawhale 佛系学习主题徽章,适合贴在个人房间墙面。',
|
||||
price: 140,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 210,
|
||||
},
|
||||
{
|
||||
itemId: 'decor_datawhale_ok_working_badge',
|
||||
itemType: 'room_decor',
|
||||
decorId: 'datawhale_ok_working_badge',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||
name: '已经在做徽章',
|
||||
category: 'space',
|
||||
description: '写着“OKKKK 已经在做了”的工作状态徽章,适合贴在个人房间墙面。',
|
||||
price: 120,
|
||||
tags: ['房间家具', '可拖拽', '徽章'],
|
||||
sortOrder: 220,
|
||||
},
|
||||
];
|
||||
|
||||
export const MALL_SKIN_ITEMS = MALL_ITEMS.filter((item) => item.itemType === 'skin' && item.skinId);
|
||||
|
||||
export function findMallItem(itemId?: string): MallCatalogItem | undefined {
|
||||
const normalizedItemId = (itemId || '').trim();
|
||||
return MALL_ITEMS.find((item) => normalizedItemId && item.itemId === normalizedItemId);
|
||||
}
|
||||
|
||||
export function findMallSkinItem(itemId?: string): MallCatalogItem | undefined {
|
||||
const item = findMallItem(itemId);
|
||||
return item?.itemType === 'skin' && item.skinId ? item : undefined;
|
||||
}
|
||||
38
src/business/notice/dto/create-notice.dto.ts
Normal file
38
src/business/notice/dto/create-notice.dto.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { IsString, IsOptional, IsNumber, IsEnum, IsDateString, IsObject } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { NoticeType } from '../notice.entity';
|
||||
|
||||
export class CreateNoticeDto {
|
||||
@ApiProperty({ description: '通知标题' })
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@ApiProperty({ description: '通知内容' })
|
||||
@IsString()
|
||||
content: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: NoticeType, description: '通知类型' })
|
||||
@IsOptional()
|
||||
@IsEnum(NoticeType)
|
||||
type?: NoticeType;
|
||||
|
||||
@ApiPropertyOptional({ description: '接收者用户ID,不填表示广播' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
userId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '发送者用户ID' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
senderId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '计划发送时间' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
scheduledAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '额外元数据' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
43
src/business/notice/dto/notice-response.dto.ts
Normal file
43
src/business/notice/dto/notice-response.dto.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { NoticeType, NoticeStatus } from '../notice.entity';
|
||||
|
||||
export class NoticeResponseDto {
|
||||
@ApiProperty()
|
||||
id: number;
|
||||
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
@ApiProperty()
|
||||
content: string;
|
||||
|
||||
@ApiProperty({ enum: NoticeType })
|
||||
type: NoticeType;
|
||||
|
||||
@ApiProperty({ enum: NoticeStatus })
|
||||
status: NoticeStatus;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
userId: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
senderId: number | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
scheduledAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
sentAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
readAt: Date | null;
|
||||
|
||||
@ApiProperty({ nullable: true })
|
||||
metadata: Record<string, any> | null;
|
||||
|
||||
@ApiProperty()
|
||||
createdAt: Date;
|
||||
|
||||
@ApiProperty()
|
||||
updatedAt: Date;
|
||||
}
|
||||
7
src/business/notice/index.ts
Normal file
7
src/business/notice/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './notice.entity';
|
||||
export * from './notice.service';
|
||||
export * from './notice.controller';
|
||||
export * from './notice.gateway';
|
||||
export * from './notice.module';
|
||||
export * from './dto/create-notice.dto';
|
||||
export * from './dto/notice-response.dto';
|
||||
21
src/business/notice/migrations/create-notices-table.sql
Normal file
21
src/business/notice/migrations/create-notices-table.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- 创建通知表
|
||||
CREATE TABLE IF NOT EXISTS `notices` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`title` varchar(255) NOT NULL COMMENT '通知标题',
|
||||
`content` text NOT NULL COMMENT '通知内容',
|
||||
`type` enum('system','user','broadcast') NOT NULL DEFAULT 'system' COMMENT '通知类型',
|
||||
`status` enum('pending','sent','read','failed') NOT NULL DEFAULT 'pending' COMMENT '通知状态',
|
||||
`userId` int DEFAULT NULL COMMENT '接收者用户ID,NULL表示广播',
|
||||
`senderId` int DEFAULT NULL COMMENT '发送者用户ID',
|
||||
`scheduledAt` datetime DEFAULT NULL COMMENT '计划发送时间',
|
||||
`sentAt` datetime DEFAULT NULL COMMENT '实际发送时间',
|
||||
`readAt` datetime DEFAULT NULL COMMENT '阅读时间',
|
||||
`metadata` json DEFAULT NULL COMMENT '额外数据',
|
||||
`createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) COMMENT '创建时间',
|
||||
`updatedAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_notices_user_id` (`userId`),
|
||||
KEY `idx_notices_status` (`status`),
|
||||
KEY `idx_notices_scheduled_at` (`scheduledAt`),
|
||||
KEY `idx_notices_created_at` (`createdAt`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通知表';
|
||||
87
src/business/notice/notice.controller.ts
Normal file
87
src/business/notice/notice.controller.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Patch,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { NoticeService } from './notice.service';
|
||||
import { CreateNoticeDto } from './dto/create-notice.dto';
|
||||
import { NoticeResponseDto } from './dto/notice-response.dto';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
|
||||
@ApiTags('通知管理')
|
||||
@Controller('api/notices')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class NoticeController {
|
||||
constructor(private readonly noticeService: NoticeService) {}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: '创建通知' })
|
||||
@ApiResponse({ status: 201, description: '通知创建成功', type: NoticeResponseDto })
|
||||
async create(@Body() createNoticeDto: CreateNoticeDto): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.create(createNoticeDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '获取通知列表' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: [NoticeResponseDto] })
|
||||
async findAll(
|
||||
@CurrentUser() user: any,
|
||||
@Query('all') all?: string,
|
||||
): Promise<NoticeResponseDto[]> {
|
||||
// 如果是管理员且指定了all参数,返回所有通知
|
||||
const userId = all === 'true' && user.isAdmin ? undefined : user.id;
|
||||
return this.noticeService.findAll(userId);
|
||||
}
|
||||
|
||||
@Get('unread-count')
|
||||
@ApiOperation({ summary: '获取未读通知数量' })
|
||||
@ApiResponse({ status: 200, description: '获取成功' })
|
||||
async getUnreadCount(@CurrentUser() user: any): Promise<{ count: number }> {
|
||||
const count = await this.noticeService.getUserUnreadCount(user.id);
|
||||
return { count };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: '获取通知详情' })
|
||||
@ApiResponse({ status: 200, description: '获取成功', type: NoticeResponseDto })
|
||||
async findOne(@Param('id', ParseIntPipe) id: number): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id/read')
|
||||
@ApiOperation({ summary: '标记通知为已读' })
|
||||
@ApiResponse({ status: 200, description: '标记成功', type: NoticeResponseDto })
|
||||
async markAsRead(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@CurrentUser() user: any,
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.markAsRead(id, user.id);
|
||||
}
|
||||
|
||||
@Post('system')
|
||||
@ApiOperation({ summary: '发送系统通知' })
|
||||
@ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto })
|
||||
async sendSystemNotice(
|
||||
@Body() body: { title: string; content: string; userId?: number },
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.sendSystemNotice(body.title, body.content, body.userId);
|
||||
}
|
||||
|
||||
@Post('broadcast')
|
||||
@ApiOperation({ summary: '发送广播通知' })
|
||||
@ApiResponse({ status: 201, description: '发送成功', type: NoticeResponseDto })
|
||||
async sendBroadcast(
|
||||
@Body() body: { title: string; content: string },
|
||||
): Promise<NoticeResponseDto> {
|
||||
return this.noticeService.sendBroadcast(body.title, body.content);
|
||||
}
|
||||
}
|
||||
64
src/business/notice/notice.entity.ts
Normal file
64
src/business/notice/notice.entity.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
export enum NoticeType {
|
||||
SYSTEM = 'system',
|
||||
USER = 'user',
|
||||
BROADCAST = 'broadcast',
|
||||
}
|
||||
|
||||
export enum NoticeStatus {
|
||||
PENDING = 'pending',
|
||||
SENT = 'sent',
|
||||
READ = 'read',
|
||||
FAILED = 'failed',
|
||||
}
|
||||
|
||||
@Entity('notices')
|
||||
export class Notice {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
title: string;
|
||||
|
||||
@Column('text')
|
||||
content: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: NoticeType,
|
||||
default: NoticeType.SYSTEM,
|
||||
})
|
||||
type: NoticeType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: NoticeStatus,
|
||||
default: NoticeStatus.PENDING,
|
||||
})
|
||||
status: NoticeStatus;
|
||||
|
||||
@Column({ nullable: true })
|
||||
userId: number; // 接收者ID,null表示广播通知
|
||||
|
||||
@Column({ nullable: true })
|
||||
senderId: number; // 发送者ID
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
scheduledAt: Date; // 计划发送时间
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
sentAt: Date; // 实际发送时间
|
||||
|
||||
@Column({ type: 'datetime', nullable: true })
|
||||
readAt: Date; // 阅读时间
|
||||
|
||||
@Column({ type: 'json', nullable: true })
|
||||
metadata: Record<string, any>; // 额外数据
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
}
|
||||
117
src/business/notice/notice.gateway.ts
Normal file
117
src/business/notice/notice.gateway.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
MessageBody,
|
||||
ConnectedSocket,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server } from 'ws';
|
||||
import * as WebSocket from 'ws';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
interface AuthenticatedSocket extends WebSocket {
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: '*',
|
||||
},
|
||||
path: '/ws/notice',
|
||||
})
|
||||
export class NoticeGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
private readonly logger = new Logger(NoticeGateway.name);
|
||||
private readonly userSockets = new Map<number, Set<AuthenticatedSocket>>();
|
||||
|
||||
handleConnection(client: AuthenticatedSocket) {
|
||||
this.logger.log(`Client connected: ${client.readyState}`);
|
||||
}
|
||||
|
||||
handleDisconnect(client: AuthenticatedSocket) {
|
||||
this.logger.log(`Client disconnected`);
|
||||
|
||||
if (client.userId) {
|
||||
const userSockets = this.userSockets.get(client.userId);
|
||||
if (userSockets) {
|
||||
userSockets.delete(client);
|
||||
if (userSockets.size === 0) {
|
||||
this.userSockets.delete(client.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeMessage('authenticate')
|
||||
handleAuthenticate(
|
||||
@MessageBody() data: { userId: number },
|
||||
@ConnectedSocket() client: AuthenticatedSocket,
|
||||
) {
|
||||
const { userId } = data;
|
||||
|
||||
if (!userId) {
|
||||
client.send(JSON.stringify({ error: 'User ID is required' }));
|
||||
return;
|
||||
}
|
||||
|
||||
client.userId = userId;
|
||||
|
||||
if (!this.userSockets.has(userId)) {
|
||||
this.userSockets.set(userId, new Set());
|
||||
}
|
||||
this.userSockets.get(userId)!.add(client);
|
||||
|
||||
client.send(JSON.stringify({
|
||||
type: 'authenticated',
|
||||
data: { userId }
|
||||
}));
|
||||
|
||||
this.logger.log(`User ${userId} authenticated`);
|
||||
}
|
||||
|
||||
@SubscribeMessage('ping')
|
||||
handlePing(@ConnectedSocket() client: AuthenticatedSocket) {
|
||||
client.send(JSON.stringify({ type: 'pong' }));
|
||||
}
|
||||
|
||||
// 发送消息给特定用户
|
||||
sendToUser(userId: number, message: any) {
|
||||
const userSockets = this.userSockets.get(userId);
|
||||
if (userSockets) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
userSockets.forEach(socket => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(messageStr);
|
||||
}
|
||||
});
|
||||
this.logger.log(`Message sent to user ${userId}`);
|
||||
} else {
|
||||
this.logger.warn(`User ${userId} not connected`);
|
||||
}
|
||||
}
|
||||
|
||||
// 广播消息给所有连接的用户
|
||||
broadcast(message: any) {
|
||||
const messageStr = JSON.stringify(message);
|
||||
this.server.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageStr);
|
||||
}
|
||||
});
|
||||
this.logger.log('Message broadcasted to all clients');
|
||||
}
|
||||
|
||||
// 获取在线用户数量
|
||||
getOnlineUsersCount(): number {
|
||||
return this.userSockets.size;
|
||||
}
|
||||
|
||||
// 获取在线用户列表
|
||||
getOnlineUsers(): number[] {
|
||||
return Array.from(this.userSockets.keys());
|
||||
}
|
||||
}
|
||||
32
src/business/notice/notice.module.ts
Normal file
32
src/business/notice/notice.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { Notice } from './notice.entity';
|
||||
import { NoticeService } from './notice.service';
|
||||
import { NoticeMemoryService } from './notice_memory.service';
|
||||
import { NoticeController } from './notice.controller';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
|
||||
function isDatabaseConfigured(): boolean {
|
||||
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
|
||||
return requiredEnvVars.every(varName => process.env[varName]);
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
...(isDatabaseConfigured() ? [TypeOrmModule.forFeature([Notice])] : []),
|
||||
ScheduleModule.forRoot(),
|
||||
LoginCoreModule,
|
||||
],
|
||||
controllers: [NoticeController],
|
||||
providers: [
|
||||
{
|
||||
provide: NoticeService,
|
||||
useClass: isDatabaseConfigured() ? NoticeService : NoticeMemoryService,
|
||||
},
|
||||
NoticeGateway,
|
||||
],
|
||||
exports: [NoticeService, NoticeGateway],
|
||||
})
|
||||
export class NoticeModule {}
|
||||
145
src/business/notice/notice.service.ts
Normal file
145
src/business/notice/notice.service.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, LessThanOrEqual } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { Notice, NoticeStatus, NoticeType } from './notice.entity';
|
||||
import { CreateNoticeDto } from './dto/create-notice.dto';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class NoticeService {
|
||||
private readonly logger = new Logger(NoticeService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notice)
|
||||
private readonly noticeRepository: Repository<Notice>,
|
||||
private readonly noticeGateway: NoticeGateway,
|
||||
) {}
|
||||
|
||||
async create(createNoticeDto: CreateNoticeDto): Promise<Notice> {
|
||||
const notice = this.noticeRepository.create({
|
||||
...createNoticeDto,
|
||||
scheduledAt: createNoticeDto.scheduledAt ? new Date(createNoticeDto.scheduledAt) : null,
|
||||
});
|
||||
|
||||
const savedNotice = await this.noticeRepository.save(notice);
|
||||
|
||||
// 如果没有设置计划时间,立即发送
|
||||
if (!savedNotice.scheduledAt) {
|
||||
await this.sendNotice(savedNotice);
|
||||
}
|
||||
|
||||
return savedNotice;
|
||||
}
|
||||
|
||||
async findAll(userId?: number): Promise<Notice[]> {
|
||||
const query = this.noticeRepository.createQueryBuilder('notice');
|
||||
|
||||
if (userId) {
|
||||
query.where('notice.userId = :userId OR notice.userId IS NULL', { userId });
|
||||
}
|
||||
|
||||
return query.orderBy('notice.createdAt', 'DESC').getMany();
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Notice> {
|
||||
const notice = await this.noticeRepository.findOne({ where: { id } });
|
||||
if (!notice) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
async markAsRead(id: number, userId?: number): Promise<Notice> {
|
||||
const notice = await this.findById(id);
|
||||
|
||||
// 检查权限:只能标记自己的通知或广播通知为已读
|
||||
if (notice.userId && userId && notice.userId !== userId) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
|
||||
notice.status = NoticeStatus.READ;
|
||||
notice.readAt = new Date();
|
||||
|
||||
return this.noticeRepository.save(notice);
|
||||
}
|
||||
|
||||
async getUserUnreadCount(userId: number): Promise<number> {
|
||||
return this.noticeRepository.count({
|
||||
where: [
|
||||
{ userId, status: NoticeStatus.SENT },
|
||||
{ userId: null, status: NoticeStatus.SENT }, // 广播通知
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async sendNotice(notice: Notice): Promise<void> {
|
||||
try {
|
||||
// 通过WebSocket发送通知
|
||||
if (notice.userId) {
|
||||
// 发送给特定用户
|
||||
this.noticeGateway.sendToUser(notice.userId, {
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
} else {
|
||||
// 广播通知
|
||||
this.noticeGateway.broadcast({
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
}
|
||||
|
||||
// 更新状态
|
||||
notice.status = NoticeStatus.SENT;
|
||||
notice.sentAt = new Date();
|
||||
await this.noticeRepository.save(notice);
|
||||
|
||||
this.logger.log(`Notice ${notice.id} sent successfully`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send notice ${notice.id}:`, error);
|
||||
|
||||
notice.status = NoticeStatus.FAILED;
|
||||
await this.noticeRepository.save(notice);
|
||||
}
|
||||
}
|
||||
|
||||
// 定时任务:每分钟检查需要发送的通知
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async handleScheduledNotices(): Promise<void> {
|
||||
const now = new Date();
|
||||
const pendingNotices = await this.noticeRepository.find({
|
||||
where: {
|
||||
status: NoticeStatus.PENDING,
|
||||
scheduledAt: LessThanOrEqual(now),
|
||||
},
|
||||
});
|
||||
|
||||
for (const notice of pendingNotices) {
|
||||
await this.sendNotice(notice);
|
||||
}
|
||||
|
||||
if (pendingNotices.length > 0) {
|
||||
this.logger.log(`Processed ${pendingNotices.length} scheduled notices`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送系统通知的便捷方法
|
||||
async sendSystemNotice(title: string, content: string, userId?: number): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.SYSTEM,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
// 发送广播通知的便捷方法
|
||||
async sendBroadcast(title: string, content: string): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.BROADCAST,
|
||||
});
|
||||
}
|
||||
}
|
||||
136
src/business/notice/notice_memory.service.ts
Normal file
136
src/business/notice/notice_memory.service.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { CreateNoticeDto } from './dto/create-notice.dto';
|
||||
import { Notice, NoticeStatus, NoticeType } from './notice.entity';
|
||||
import { NoticeGateway } from './notice.gateway';
|
||||
|
||||
@Injectable()
|
||||
export class NoticeMemoryService {
|
||||
private readonly logger = new Logger(NoticeMemoryService.name);
|
||||
private readonly notices: Notice[] = [];
|
||||
private nextId = 1;
|
||||
|
||||
constructor(private readonly noticeGateway: NoticeGateway) {}
|
||||
|
||||
async create(createNoticeDto: CreateNoticeDto): Promise<Notice> {
|
||||
const now = new Date();
|
||||
const notice = Object.assign(new Notice(), {
|
||||
id: this.nextId++,
|
||||
title: createNoticeDto.title,
|
||||
content: createNoticeDto.content,
|
||||
type: createNoticeDto.type || NoticeType.SYSTEM,
|
||||
status: NoticeStatus.PENDING,
|
||||
userId: createNoticeDto.userId ?? null,
|
||||
senderId: createNoticeDto.senderId ?? null,
|
||||
scheduledAt: createNoticeDto.scheduledAt ? new Date(createNoticeDto.scheduledAt) : null,
|
||||
sentAt: null,
|
||||
readAt: null,
|
||||
metadata: createNoticeDto.metadata || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
this.notices.push(notice);
|
||||
|
||||
if (!notice.scheduledAt) {
|
||||
await this.sendNotice(notice);
|
||||
}
|
||||
|
||||
return notice;
|
||||
}
|
||||
|
||||
async findAll(userId?: number): Promise<Notice[]> {
|
||||
const notices = userId
|
||||
? this.notices.filter(notice => notice.userId === userId || notice.userId === null)
|
||||
: this.notices;
|
||||
|
||||
return [...notices].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<Notice> {
|
||||
const notice = this.notices.find(item => item.id === id);
|
||||
if (!notice) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
async markAsRead(id: number, userId?: number): Promise<Notice> {
|
||||
const notice = await this.findById(id);
|
||||
|
||||
if (notice.userId && userId && notice.userId !== userId) {
|
||||
throw new NotFoundException(`Notice with ID ${id} not found`);
|
||||
}
|
||||
|
||||
notice.status = NoticeStatus.READ;
|
||||
notice.readAt = new Date();
|
||||
notice.updatedAt = new Date();
|
||||
return notice;
|
||||
}
|
||||
|
||||
async getUserUnreadCount(userId: number): Promise<number> {
|
||||
return this.notices.filter(notice => (
|
||||
(notice.userId === userId || notice.userId === null) &&
|
||||
notice.status === NoticeStatus.SENT
|
||||
)).length;
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async handleScheduledNotices(): Promise<void> {
|
||||
const now = new Date();
|
||||
const pendingNotices = this.notices.filter(notice => (
|
||||
notice.status === NoticeStatus.PENDING &&
|
||||
notice.scheduledAt &&
|
||||
notice.scheduledAt <= now
|
||||
));
|
||||
|
||||
for (const notice of pendingNotices) {
|
||||
await this.sendNotice(notice);
|
||||
}
|
||||
|
||||
if (pendingNotices.length > 0) {
|
||||
this.logger.log(`Processed ${pendingNotices.length} scheduled notices`);
|
||||
}
|
||||
}
|
||||
|
||||
async sendSystemNotice(title: string, content: string, userId?: number): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.SYSTEM,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
async sendBroadcast(title: string, content: string): Promise<Notice> {
|
||||
return this.create({
|
||||
title,
|
||||
content,
|
||||
type: NoticeType.BROADCAST,
|
||||
});
|
||||
}
|
||||
|
||||
private async sendNotice(notice: Notice): Promise<void> {
|
||||
try {
|
||||
if (notice.userId) {
|
||||
this.noticeGateway.sendToUser(notice.userId, {
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
} else {
|
||||
this.noticeGateway.broadcast({
|
||||
type: 'notice',
|
||||
data: notice,
|
||||
});
|
||||
}
|
||||
|
||||
notice.status = NoticeStatus.SENT;
|
||||
notice.sentAt = new Date();
|
||||
notice.updatedAt = new Date();
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send notice ${notice.id}:`, error);
|
||||
notice.status = NoticeStatus.FAILED;
|
||||
notice.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/business/player/dto/update_player_appearance.dto.ts
Normal file
13
src/business/player/dto/update_player_appearance.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerAppearanceDto {
|
||||
@ApiProperty({
|
||||
description: '要穿戴的角色皮肤ID',
|
||||
example: 'classic_whale',
|
||||
})
|
||||
@IsString({ message: '皮肤ID必须是字符串' })
|
||||
@Length(1, 100, { message: '皮肤ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '皮肤ID格式不正确' })
|
||||
skin_id!: string;
|
||||
}
|
||||
40
src/business/player/dto/update_player_profile_assets.dto.ts
Normal file
40
src/business/player/dto/update_player_profile_assets.dto.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length, MaxLength } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerProfileAssetsDto {
|
||||
@ApiPropertyOptional({ description: '当前账号头像URL', maxLength: 255 })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像URL必须是字符串' })
|
||||
@Length(0, 255, { message: '头像URL长度不能超过255字符' })
|
||||
avatar_url?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像图片Base64,服务端保存后写入账号头像URL' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像图片必须是Base64字符串' })
|
||||
@MaxLength(5_000_000, { message: '头像图片内容过大' })
|
||||
avatar_image_base64?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '头像图片MIME类型', example: 'image/png' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '头像MIME类型必须是字符串' })
|
||||
@Length(1, 40, { message: '头像MIME类型长度不正确' })
|
||||
avatar_mime_type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '8x4角色皮肤PNG Base64,服务端保存后授予账号自定义皮肤' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤图片必须是Base64字符串' })
|
||||
@MaxLength(12_000_000, { message: '角色皮肤图片内容过大' })
|
||||
skin_image_base64?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '角色皮肤MIME类型', example: 'image/png' })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤MIME类型必须是字符串' })
|
||||
@Length(1, 40, { message: '角色皮肤MIME类型长度不正确' })
|
||||
skin_mime_type?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '自定义角色皮肤名称', maxLength: 40 })
|
||||
@IsOptional()
|
||||
@IsString({ message: '角色皮肤名称必须是字符串' })
|
||||
@Length(1, 40, { message: '角色皮肤名称长度需在1-40字符之间' })
|
||||
skin_name?: string;
|
||||
}
|
||||
11
src/business/player/dto/update_player_settings.dto.ts
Normal file
11
src/business/player/dto/update_player_settings.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsObject } from 'class-validator';
|
||||
|
||||
export class UpdatePlayerSettingsDto {
|
||||
@ApiProperty({
|
||||
description: '账号级游戏设置',
|
||||
example: { master_volume: 0.8, show_chat_bubbles: true },
|
||||
})
|
||||
@IsObject({ message: '账号设置必须是对象格式' })
|
||||
settings!: Record<string, unknown>;
|
||||
}
|
||||
37
src/business/player/economy.service.ts
Normal file
37
src/business/player/economy.service.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PlayerWalletPayload } from './player.types';
|
||||
|
||||
interface IUserWalletsService {
|
||||
getBalance(userId: bigint): Promise<PlayerWalletPayload>;
|
||||
spend(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
|
||||
earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<{ wallet: { balance: number } }>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EconomyService {
|
||||
constructor(
|
||||
@Inject('IUserWalletsService') private readonly userWalletsService: IUserWalletsService,
|
||||
) {}
|
||||
|
||||
async getWallet(userId: bigint): Promise<PlayerWalletPayload> {
|
||||
return await this.userWalletsService.getBalance(userId);
|
||||
}
|
||||
|
||||
async spend(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<PlayerWalletPayload> {
|
||||
const result = await this.userWalletsService.spend(userId, amount, referenceType, referenceId, note);
|
||||
return {
|
||||
user_id: userId.toString(),
|
||||
balance: result.wallet.balance,
|
||||
currency: 'whale_coin',
|
||||
};
|
||||
}
|
||||
|
||||
async earn(userId: bigint, amount: number, referenceType: string, referenceId: string, note?: string): Promise<PlayerWalletPayload> {
|
||||
const result = await this.userWalletsService.earn(userId, amount, referenceType, referenceId, note);
|
||||
return {
|
||||
user_id: userId.toString(),
|
||||
balance: result.wallet.balance,
|
||||
currency: 'whale_coin',
|
||||
};
|
||||
}
|
||||
}
|
||||
28
src/business/player/inventory.controller.ts
Normal file
28
src/business/player/inventory.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, HttpStatus, Query, Res, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerAssetType } from './player.types';
|
||||
|
||||
@ApiTags('inventory')
|
||||
@ApiBearerAuth()
|
||||
@Controller('inventory')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InventoryController {
|
||||
constructor(private readonly inventoryService: InventoryService) {}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家背包资产' })
|
||||
@SwaggerApiResponse({ status: 200, description: '背包资产获取成功' })
|
||||
@Get()
|
||||
async listInventory(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Query('type') type: PlayerAssetType | undefined,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.inventoryService.listInventory(BigInt(user.sub), type);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '背包资产获取成功' });
|
||||
}
|
||||
}
|
||||
50
src/business/player/inventory.service.ts
Normal file
50
src/business/player/inventory.service.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { PlayerAssets, PlayerAssetType as CorePlayerAssetType } from '../../core/db/player_assets/player_assets.entity';
|
||||
import { PlayerAsset, PlayerAssetType, PlayerInventoryPayload } from './player.types';
|
||||
|
||||
interface IPlayerAssetsService {
|
||||
grantAsset(userId: bigint, assetType: CorePlayerAssetType, assetId: string, source?: string, metadata?: Record<string, unknown>): Promise<PlayerAssets>;
|
||||
hasAsset(userId: bigint, assetType: CorePlayerAssetType, assetId: string): Promise<boolean>;
|
||||
listAssets(userId: bigint, assetType?: CorePlayerAssetType): Promise<PlayerAssets[]>;
|
||||
listAssetIds(userId: bigint, assetType: CorePlayerAssetType): Promise<string[]>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InventoryService {
|
||||
constructor(
|
||||
@Inject('IPlayerAssetsService') private readonly playerAssetsService: IPlayerAssetsService,
|
||||
) {}
|
||||
|
||||
async listInventory(userId: bigint, assetType?: PlayerAssetType): Promise<PlayerInventoryPayload> {
|
||||
const [skinIds, roomDecorIds] = await Promise.all([
|
||||
assetType && assetType !== 'skin' ? Promise.resolve([]) : this.playerAssetsService.listAssetIds(userId, 'skin'),
|
||||
assetType && assetType !== 'room_decor' ? Promise.resolve([]) : this.playerAssetsService.listAssetIds(userId, 'room_decor'),
|
||||
]);
|
||||
|
||||
const rows = await this.playerAssetsService.listAssets(userId, assetType as CorePlayerAssetType | undefined);
|
||||
const assets: PlayerAsset[] = rows.map((row) => ({
|
||||
asset_type: row.asset_type,
|
||||
asset_id: row.asset_id,
|
||||
source: row.source,
|
||||
}));
|
||||
|
||||
return {
|
||||
assets,
|
||||
skin_ids: skinIds,
|
||||
room_decor_ids: roomDecorIds,
|
||||
};
|
||||
}
|
||||
|
||||
async hasAsset(userId: bigint, assetType: PlayerAssetType, assetId: string): Promise<boolean> {
|
||||
return await this.playerAssetsService.hasAsset(userId, assetType, assetId);
|
||||
}
|
||||
|
||||
async grantAsset(userId: bigint, assetType: PlayerAssetType, assetId: string, source = 'system'): Promise<PlayerAsset> {
|
||||
await this.playerAssetsService.grantAsset(userId, assetType, assetId, source);
|
||||
return {
|
||||
asset_type: assetType,
|
||||
asset_id: assetId,
|
||||
source,
|
||||
};
|
||||
}
|
||||
}
|
||||
80
src/business/player/player.controller.ts
Normal file
80
src/business/player/player.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Body, Controller, Get, HttpStatus, Patch, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { PlayerStateService } from './player_state.service';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { UpdatePlayerAppearanceDto } from './dto/update_player_appearance.dto';
|
||||
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
||||
import { UpdatePlayerSettingsDto } from './dto/update_player_settings.dto';
|
||||
|
||||
@ApiTags('player')
|
||||
@ApiBearerAuth()
|
||||
@Controller('player')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PlayerController {
|
||||
constructor(
|
||||
private readonly playerStateService: PlayerStateService,
|
||||
private readonly economyService: EconomyService,
|
||||
) {}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家快照' })
|
||||
@SwaggerApiResponse({ status: 200, description: '玩家快照获取成功' })
|
||||
@Get('snapshot')
|
||||
async getSnapshot(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.playerStateService.getSnapshot(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家快照获取成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '获取当前玩家钱包' })
|
||||
@SwaggerApiResponse({ status: 200, description: '钱包获取成功' })
|
||||
@Get('wallet')
|
||||
async getWallet(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.economyService.getWallet(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '钱包获取成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前穿戴皮肤' })
|
||||
@ApiBody({ type: UpdatePlayerAppearanceDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '外观更新成功' })
|
||||
@Patch('appearance')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateAppearance(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerAppearanceDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateAppearance(BigInt(user.sub), dto.skin_id);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '外观更新成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前玩家设置' })
|
||||
@ApiBody({ type: UpdatePlayerSettingsDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '设置更新成功' })
|
||||
@Patch('settings')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateSettings(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerSettingsDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateSettings(BigInt(user.sub), dto.settings);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '设置更新成功' });
|
||||
}
|
||||
|
||||
@ApiOperation({ summary: '更新当前玩家头像或自定义皮肤资源' })
|
||||
@ApiBody({ type: UpdatePlayerProfileAssetsDto })
|
||||
@SwaggerApiResponse({ status: 200, description: '玩家资源更新成功' })
|
||||
@Patch('profile-assets')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async updateProfileAssets(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: UpdatePlayerProfileAssetsDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.playerStateService.updateProfileAssets(BigInt(user.sub), dto);
|
||||
res.status(HttpStatus.OK).json({ success: true, data, message: '玩家资源更新成功' });
|
||||
}
|
||||
}
|
||||
16
src/business/player/player.module.ts
Normal file
16
src/business/player/player.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { InventoryController } from './inventory.controller';
|
||||
import { PlayerController } from './player.controller';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerStateService } from './player_state.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, LoginCoreModule],
|
||||
controllers: [PlayerController, InventoryController],
|
||||
providers: [EconomyService, InventoryService, PlayerStateService],
|
||||
exports: [EconomyService, InventoryService, PlayerStateService],
|
||||
})
|
||||
export class PlayerModule {}
|
||||
52
src/business/player/player.types.ts
Normal file
52
src/business/player/player.types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export type PlayerAssetType = 'skin' | 'room_decor';
|
||||
|
||||
export interface PlayerAsset {
|
||||
asset_type: PlayerAssetType;
|
||||
asset_id: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface PlayerInventoryPayload {
|
||||
assets: PlayerAsset[];
|
||||
skin_ids: string[];
|
||||
room_decor_ids: string[];
|
||||
}
|
||||
|
||||
export interface PlayerWalletPayload {
|
||||
user_id: string;
|
||||
balance: number;
|
||||
currency: 'whale_coin';
|
||||
}
|
||||
|
||||
export interface PlayerSnapshotPayload {
|
||||
user: {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
role: number;
|
||||
created_at: Date;
|
||||
};
|
||||
profile: {
|
||||
user_id: string;
|
||||
selected_skin_id: string;
|
||||
avatar_id: string;
|
||||
avatar_url?: string;
|
||||
avatar_base64?: string;
|
||||
current_map: string;
|
||||
pos_x: number;
|
||||
pos_y: number;
|
||||
status: number;
|
||||
};
|
||||
wallet: PlayerWalletPayload;
|
||||
inventory: PlayerInventoryPayload;
|
||||
appearance: {
|
||||
selected_skin_id: string;
|
||||
owned_skin_ids: string[];
|
||||
owned_skins: unknown[];
|
||||
};
|
||||
settings: Record<string, boolean | number>;
|
||||
}
|
||||
62
src/business/player/player_state.service.ts
Normal file
62
src/business/player/player_state.service.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountProfileService } from '../auth/account_profile.service';
|
||||
import { EconomyService } from './economy.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { PlayerSnapshotPayload } from './player.types';
|
||||
import { UpdatePlayerProfileAssetsDto } from './dto/update_player_profile_assets.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PlayerStateService {
|
||||
constructor(
|
||||
private readonly accountProfileService: AccountProfileService,
|
||||
private readonly economyService: EconomyService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
) {}
|
||||
|
||||
async getSnapshot(userId: bigint): Promise<PlayerSnapshotPayload> {
|
||||
const [accountProfile, wallet, inventory] = await Promise.all([
|
||||
this.accountProfileService.getAccountProfile(userId),
|
||||
this.economyService.getWallet(userId),
|
||||
this.inventoryService.listInventory(userId),
|
||||
]);
|
||||
const selectedSkinId = accountProfile.profile.skin_id || '';
|
||||
|
||||
return {
|
||||
user: accountProfile.user,
|
||||
profile: {
|
||||
user_id: accountProfile.profile.user_id,
|
||||
selected_skin_id: selectedSkinId,
|
||||
avatar_id: accountProfile.profile.avatar_id,
|
||||
avatar_url: accountProfile.profile.avatar_url,
|
||||
avatar_base64: accountProfile.profile.avatar_base64,
|
||||
current_map: accountProfile.profile.current_map,
|
||||
pos_x: accountProfile.profile.pos_x,
|
||||
pos_y: accountProfile.profile.pos_y,
|
||||
status: accountProfile.profile.status,
|
||||
},
|
||||
wallet,
|
||||
inventory,
|
||||
appearance: {
|
||||
selected_skin_id: selectedSkinId,
|
||||
owned_skin_ids: accountProfile.profile.owned_skin_ids,
|
||||
owned_skins: accountProfile.profile.owned_skins,
|
||||
},
|
||||
settings: accountProfile.profile.settings,
|
||||
};
|
||||
}
|
||||
|
||||
async updateAppearance(userId: bigint, skinId: string): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, { skin_id: skinId });
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
|
||||
async updateSettings(userId: bigint, settings: Record<string, unknown>): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, { settings });
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
|
||||
async updateProfileAssets(userId: bigint, update: UpdatePlayerProfileAssetsDto): Promise<PlayerSnapshotPayload> {
|
||||
await this.accountProfileService.updateAccountProfile(userId, update);
|
||||
return await this.getSnapshot(userId);
|
||||
}
|
||||
}
|
||||
61
src/business/rankings/rankings.controller.ts
Normal file
61
src/business/rankings/rankings.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { RankingsService } from './rankings.service';
|
||||
import { RankingCategoryId } from './rankings.types';
|
||||
|
||||
@ApiTags('rankings')
|
||||
@Controller('rankings')
|
||||
export class RankingsController {
|
||||
constructor(private readonly rankingsService: RankingsService) {}
|
||||
|
||||
@Get('datawhale-honor')
|
||||
@ApiOperation({
|
||||
summary: '获取Datawhale荣誉榜',
|
||||
description: '返回后端同步并计算后的Datawhale贡献者排行榜数据,供游戏荣誉榜UI使用。',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'category',
|
||||
required: false,
|
||||
description: '榜单分类:weekly_commits/night_owl/popularity/productive/social/rising/comprehensive',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'limit',
|
||||
required: false,
|
||||
description: '返回数量,范围 3-10,默认 10',
|
||||
})
|
||||
@ApiQuery({
|
||||
name: 'refresh',
|
||||
required: false,
|
||||
description: '为 true 时先实时同步 Datawhale 公开数据,再返回当前榜单',
|
||||
})
|
||||
async getDatawhaleHonorRanking(
|
||||
@Query('category') category?: RankingCategoryId,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('refresh') refresh?: string,
|
||||
) {
|
||||
const data = await this.rankingsService.getDatawhaleHonorRanking(
|
||||
category,
|
||||
Number(limit || 10),
|
||||
refresh === 'true',
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '荣誉榜获取成功',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('datawhale-honor/sync')
|
||||
@ApiOperation({
|
||||
summary: '手动同步Datawhale荣誉榜',
|
||||
description: '开发调试用:立即从Datawhale公开数据源同步并返回默认榜单。',
|
||||
})
|
||||
async syncDatawhaleHonorRanking() {
|
||||
const data = await this.rankingsService.syncNow();
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
message: '荣誉榜同步成功',
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/business/rankings/rankings.module.ts
Normal file
12
src/business/rankings/rankings.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { RankingsController } from './rankings.controller';
|
||||
import { RankingsService } from './rankings.service';
|
||||
|
||||
@Module({
|
||||
imports: [ScheduleModule.forRoot()],
|
||||
controllers: [RankingsController],
|
||||
providers: [RankingsService],
|
||||
exports: [RankingsService],
|
||||
})
|
||||
export class RankingsModule {}
|
||||
436
src/business/rankings/rankings.service.ts
Normal file
436
src/business/rankings/rankings.service.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { BadGatewayException, Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
DatawhaleCommitStats,
|
||||
DatawhaleHonorRankingPayload,
|
||||
DatawhaleMemberRow,
|
||||
DatawhaleWeeklyCommitsPayload,
|
||||
RankingCategory,
|
||||
RankingCategoryId,
|
||||
RankingEntry,
|
||||
RankingUser,
|
||||
} from './rankings.types';
|
||||
|
||||
const DATAWHALE_MEMBERS_URL = 'https://mv.datawhale.cc/data/members.json';
|
||||
const DATAWHALE_WEEKLY_COMMITS_URL = 'https://mv.datawhale.cc/data/commits_weekly.json';
|
||||
const DATAWHALE_ASSET_BASE_URL = 'https://mv.datawhale.cc/';
|
||||
const DEFAULT_CATEGORY: RankingCategoryId = 'weekly_commits';
|
||||
const DEFAULT_LIMIT = 10;
|
||||
|
||||
const CATEGORIES: RankingCategory[] = [
|
||||
{
|
||||
id: 'weekly_commits',
|
||||
label: '一周卷王',
|
||||
title: '一周卷王',
|
||||
description: '近 7 天 commit 数 + 连续性、多仓库、质量奖励',
|
||||
icon: '🔥',
|
||||
},
|
||||
{
|
||||
id: 'night_owl',
|
||||
label: '夜猫榜',
|
||||
title: '夜猫榜',
|
||||
description: '深夜提交数量与深夜活跃比例排行',
|
||||
icon: '🌙',
|
||||
},
|
||||
{
|
||||
id: 'popularity',
|
||||
label: '人气王',
|
||||
title: '人气王',
|
||||
description: 'Followers 与组织仓库 Stars 的综合影响力',
|
||||
icon: '👑',
|
||||
},
|
||||
{
|
||||
id: 'productive',
|
||||
label: '多产榜',
|
||||
title: '多产榜',
|
||||
description: '参与 Datawhale 组织仓库数量排行',
|
||||
icon: '🏆',
|
||||
},
|
||||
{
|
||||
id: 'social',
|
||||
label: '社交达人',
|
||||
title: '社交达人',
|
||||
description: 'GitHub Following 数量排行',
|
||||
icon: '💬',
|
||||
},
|
||||
{
|
||||
id: 'rising',
|
||||
label: '新星榜',
|
||||
title: '新星榜',
|
||||
description: '按仓库数量归一后的潜力新星排行',
|
||||
icon: '🌠',
|
||||
},
|
||||
{
|
||||
id: 'comprehensive',
|
||||
label: '综合实力',
|
||||
title: '综合实力',
|
||||
description: 'Stars、Followers、仓库数、社交和贡献数综合评分',
|
||||
icon: '🌟',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class RankingsService implements OnModuleInit {
|
||||
private readonly logger = new Logger(RankingsService.name);
|
||||
private members: DatawhaleMemberRow[] = [];
|
||||
private weeklyCommits: DatawhaleWeeklyCommitsPayload | null = null;
|
||||
private syncedAt: Date | null = null;
|
||||
private syncing: Promise<void> | null = null;
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
this.syncNow().catch(error => {
|
||||
this.logger.warn(`Datawhale荣誉榜启动同步失败:${this.errorMessage(error)}`);
|
||||
});
|
||||
}
|
||||
|
||||
@Cron('15 3 * * *')
|
||||
async syncDaily(): Promise<void> {
|
||||
await this.syncNow();
|
||||
}
|
||||
|
||||
async getDatawhaleHonorRanking(
|
||||
category: RankingCategoryId = DEFAULT_CATEGORY,
|
||||
limit: number = DEFAULT_LIMIT,
|
||||
refresh = false,
|
||||
): Promise<DatawhaleHonorRankingPayload> {
|
||||
if (refresh || this.members.length === 0) {
|
||||
await this.syncNow();
|
||||
}
|
||||
return this.getCachedPayload(category, limit);
|
||||
}
|
||||
|
||||
async syncNow(): Promise<DatawhaleHonorRankingPayload> {
|
||||
if (this.syncing) {
|
||||
await this.syncing;
|
||||
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
this.syncing = this.fetchAndReplace();
|
||||
try {
|
||||
await this.syncing;
|
||||
} finally {
|
||||
this.syncing = null;
|
||||
}
|
||||
return this.getCachedPayload(DEFAULT_CATEGORY, DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
private getCachedPayload(
|
||||
category: RankingCategoryId,
|
||||
limit: number,
|
||||
): DatawhaleHonorRankingPayload {
|
||||
const normalizedCategory = this.normalizeCategory(category);
|
||||
const normalizedLimit = this.normalizeLimit(limit);
|
||||
const entries = this.buildEntries(normalizedCategory, normalizedLimit);
|
||||
return {
|
||||
source: 'datawhale-members-visualization',
|
||||
activeCategory: normalizedCategory,
|
||||
categories: CATEGORIES,
|
||||
topRankers: entries.slice(0, 3),
|
||||
rankers: entries.slice(3, normalizedLimit),
|
||||
myRank: {
|
||||
rank: null,
|
||||
score: 0,
|
||||
reward: 0,
|
||||
},
|
||||
total: entries.length,
|
||||
syncedAt: this.syncedAt ? this.syncedAt.toISOString() : null,
|
||||
sourceUpdatedAt: this.weeklyCommits?.update_time ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async fetchAndReplace(): Promise<void> {
|
||||
try {
|
||||
const [membersResponse, commitsResponse] = await Promise.all([
|
||||
axios.get(DATAWHALE_MEMBERS_URL, { timeout: 15000 }),
|
||||
axios.get(DATAWHALE_WEEKLY_COMMITS_URL, { timeout: 15000 }),
|
||||
]);
|
||||
|
||||
if (!Array.isArray(membersResponse.data)) {
|
||||
throw new BadGatewayException('Datawhale成员接口返回格式异常');
|
||||
}
|
||||
const commitsPayload = commitsResponse.data as DatawhaleWeeklyCommitsPayload;
|
||||
if (!commitsPayload || typeof commitsPayload !== 'object' || !commitsPayload.user_commits) {
|
||||
throw new BadGatewayException('Datawhale周贡献接口返回格式异常');
|
||||
}
|
||||
|
||||
this.members = membersResponse.data as DatawhaleMemberRow[];
|
||||
this.weeklyCommits = commitsPayload;
|
||||
this.syncedAt = new Date();
|
||||
this.logger.log(`Datawhale荣誉榜同步完成,共 ${this.members.length} 位成员`);
|
||||
} catch (error) {
|
||||
if (this.members.length > 0) {
|
||||
this.logger.warn(`Datawhale荣誉榜同步失败,继续使用缓存:${this.errorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildEntries(category: RankingCategoryId, limit: number): RankingEntry[] {
|
||||
const users = category === 'weekly_commits' || category === 'night_owl'
|
||||
? this.buildCommitDrivenUsers(category)
|
||||
: this.members.map(member => this.toRankingUser(member, category));
|
||||
|
||||
return users
|
||||
.filter(user => user.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map((user, index) => ({
|
||||
...user,
|
||||
rank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
private buildCommitDrivenUsers(category: RankingCategoryId): RankingUser[] {
|
||||
const memberById = new Map(
|
||||
this.members
|
||||
.map(member => [this.cleanString(member.id), member] as const)
|
||||
.filter(([id]) => id.length > 0),
|
||||
);
|
||||
const userCommits = this.weeklyCommits?.user_commits ?? {};
|
||||
|
||||
return Object.entries(userCommits).map(([rawId, commits]) => {
|
||||
const id = this.cleanString(rawId);
|
||||
const member = memberById.get(id) ?? { id };
|
||||
return this.toRankingUser(member, category, commits);
|
||||
});
|
||||
}
|
||||
|
||||
private toRankingUser(
|
||||
member: DatawhaleMemberRow,
|
||||
category: RankingCategoryId,
|
||||
commitsOverride?: DatawhaleCommitStats,
|
||||
): RankingUser {
|
||||
const id = this.cleanString(member.id);
|
||||
const commits = commitsOverride ?? this.weeklyCommits?.user_commits?.[id] ?? {};
|
||||
const score = this.scoreMember(member, commits, category);
|
||||
const domains = this.domainList(member);
|
||||
return {
|
||||
id,
|
||||
name: this.displayName(member),
|
||||
avatarText: this.avatarText(member),
|
||||
avatarUrl: this.avatarUrl(member),
|
||||
githubUrl: this.githubUrl(member),
|
||||
domain: domains[0] ?? '',
|
||||
domains,
|
||||
location: this.cleanString(member.location),
|
||||
score,
|
||||
scoreLabel: this.scoreLabel(member, commits, category),
|
||||
contrib: this.numberValue(member.org_total_contributions) || this.numberValue(commits.total_commits),
|
||||
answers: this.numberValue(commits.repo_count) || this.numberValue(member.org_repos_count),
|
||||
likes: this.numberValue(member.org_total_stars),
|
||||
reward: this.rewardForScore(score),
|
||||
};
|
||||
}
|
||||
|
||||
private scoreMember(
|
||||
member: DatawhaleMemberRow,
|
||||
commits: DatawhaleCommitStats,
|
||||
category: RankingCategoryId,
|
||||
): number {
|
||||
switch (category) {
|
||||
case 'weekly_commits':
|
||||
return this.weeklyCommitScore(commits);
|
||||
case 'night_owl':
|
||||
return this.nightOwlScore(commits);
|
||||
case 'popularity':
|
||||
return Math.round(
|
||||
this.numberValue(member.followers ?? member.followers_count) * 0.6 +
|
||||
this.numberValue(member.org_total_stars) * 0.4,
|
||||
);
|
||||
case 'productive':
|
||||
return this.numberValue(member.org_repos_count);
|
||||
case 'social':
|
||||
return this.numberValue(member.following);
|
||||
case 'rising': {
|
||||
const repoCount = Math.max(this.numberValue(member.org_repos_count), 1);
|
||||
const bonus = repoCount < 5 ? 1.5 : 1.0;
|
||||
return Math.round((this.numberValue(member.followers ?? member.followers_count) + this.numberValue(member.org_total_stars)) / repoCount * bonus);
|
||||
}
|
||||
case 'comprehensive':
|
||||
return Math.round(
|
||||
this.numberValue(member.org_total_stars) * 0.3 +
|
||||
this.numberValue(member.followers ?? member.followers_count) * 0.25 +
|
||||
this.numberValue(member.org_repos_count) * 0.2 +
|
||||
this.numberValue(member.following) * 0.15 +
|
||||
this.numberValue(member.org_total_contributions) * 0.1,
|
||||
);
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private weeklyCommitScore(commits: DatawhaleCommitStats): number {
|
||||
let score = this.numberValue(commits.total_commits);
|
||||
const activeDays = this.numberValue(commits.active_days);
|
||||
if (activeDays >= 7) {
|
||||
score += 10;
|
||||
} else if (activeDays >= 5) {
|
||||
score += 5;
|
||||
} else if (activeDays >= 3) {
|
||||
score += 2;
|
||||
}
|
||||
|
||||
const repoCount = this.numberValue(commits.repo_count);
|
||||
if (repoCount >= 5) {
|
||||
score += 5;
|
||||
} else if (repoCount >= 3) {
|
||||
score += 3;
|
||||
} else if (repoCount >= 2) {
|
||||
score += 1;
|
||||
}
|
||||
|
||||
const avgCommitsPerDay = this.numberValue(commits.avg_commits_per_day);
|
||||
if (avgCommitsPerDay >= 5) {
|
||||
score += 8;
|
||||
} else if (avgCommitsPerDay >= 3) {
|
||||
score += 5;
|
||||
} else if (avgCommitsPerDay >= 2) {
|
||||
score += 2;
|
||||
}
|
||||
return Math.round(score);
|
||||
}
|
||||
|
||||
private nightOwlScore(commits: DatawhaleCommitStats): number {
|
||||
let score = this.numberValue(commits.night_owl_commits) * 2;
|
||||
const percentage = this.numberValue(commits.night_owl_percentage);
|
||||
if (percentage >= 50) {
|
||||
score += 10;
|
||||
} else if (percentage >= 30) {
|
||||
score += 5;
|
||||
} else if (percentage >= 20) {
|
||||
score += 2;
|
||||
}
|
||||
const activeDays = this.numberValue(commits.active_days);
|
||||
if (activeDays >= 5) {
|
||||
score += 8;
|
||||
} else if (activeDays >= 3) {
|
||||
score += 4;
|
||||
}
|
||||
const repoCount = this.numberValue(commits.repo_count);
|
||||
if (repoCount >= 3) {
|
||||
score += 3;
|
||||
} else if (repoCount >= 2) {
|
||||
score += 1;
|
||||
}
|
||||
return Math.round(score);
|
||||
}
|
||||
|
||||
private scoreLabel(
|
||||
member: DatawhaleMemberRow,
|
||||
commits: DatawhaleCommitStats,
|
||||
category: RankingCategoryId,
|
||||
): string {
|
||||
switch (category) {
|
||||
case 'weekly_commits':
|
||||
return `${this.numberValue(commits.total_commits)} commits`;
|
||||
case 'night_owl':
|
||||
return `${this.numberValue(commits.night_owl_commits)} 深夜`;
|
||||
case 'popularity':
|
||||
return `${this.numberValue(member.followers ?? member.followers_count)} followers`;
|
||||
case 'productive':
|
||||
return `${this.numberValue(member.org_repos_count)} 仓库`;
|
||||
case 'social':
|
||||
return `${this.numberValue(member.following)} following`;
|
||||
case 'rising':
|
||||
return '活跃度';
|
||||
case 'comprehensive':
|
||||
return '综合分';
|
||||
default:
|
||||
return '分数';
|
||||
}
|
||||
}
|
||||
|
||||
private domainList(member: DatawhaleMemberRow): string[] {
|
||||
const primaryDomain = this.cleanString(member.primary_domain);
|
||||
const domains = this.cleanString(member.domain)
|
||||
.split(';')
|
||||
.map(domain => this.cleanString(domain))
|
||||
.filter(Boolean);
|
||||
const result = primaryDomain ? [primaryDomain, ...domains] : domains;
|
||||
return [...new Set(result)].slice(0, 3);
|
||||
}
|
||||
|
||||
private displayName(member: DatawhaleMemberRow): string {
|
||||
const name = this.cleanString(member.name);
|
||||
if (name && !['null', 'undefined', 'none'].includes(name.toLowerCase())) {
|
||||
return name;
|
||||
}
|
||||
return this.cleanString(member.id) || '未知用户';
|
||||
}
|
||||
|
||||
private avatarText(member: DatawhaleMemberRow): string {
|
||||
const name = this.displayName(member);
|
||||
return name.length > 0 ? name.slice(0, 1).toUpperCase() : '鲸';
|
||||
}
|
||||
|
||||
private avatarUrl(member: DatawhaleMemberRow): string {
|
||||
const avatar = this.cleanString(member.avatar);
|
||||
if (!avatar) {
|
||||
return '';
|
||||
}
|
||||
if (avatar.startsWith('http://') || avatar.startsWith('https://')) {
|
||||
return avatar;
|
||||
}
|
||||
if (avatar.startsWith('/')) {
|
||||
return `${DATAWHALE_ASSET_BASE_URL.replace(/\/$/, '')}${avatar}`;
|
||||
}
|
||||
return `${DATAWHALE_ASSET_BASE_URL}${avatar}`;
|
||||
}
|
||||
|
||||
private githubUrl(member: DatawhaleMemberRow): string {
|
||||
const github = this.cleanString(member.github);
|
||||
if (github.startsWith('http')) {
|
||||
return github;
|
||||
}
|
||||
const id = this.cleanString(member.id);
|
||||
return id ? `https://github.com/${id}` : '';
|
||||
}
|
||||
|
||||
private normalizeCategory(category: RankingCategoryId): RankingCategoryId {
|
||||
return CATEGORIES.some(item => item.id === category) ? category : DEFAULT_CATEGORY;
|
||||
}
|
||||
|
||||
private normalizeLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit)) {
|
||||
return DEFAULT_LIMIT;
|
||||
}
|
||||
return Math.min(Math.max(Math.floor(limit), 3), DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
private rewardForScore(score: number): number {
|
||||
if (score >= 1000) {
|
||||
return 80;
|
||||
}
|
||||
if (score >= 500) {
|
||||
return 60;
|
||||
}
|
||||
if (score >= 120) {
|
||||
return 50;
|
||||
}
|
||||
if (score >= 50) {
|
||||
return 40;
|
||||
}
|
||||
if (score >= 20) {
|
||||
return 30;
|
||||
}
|
||||
return 20;
|
||||
}
|
||||
|
||||
private numberValue(value: unknown): number {
|
||||
const parsed = Number(value || 0);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
private cleanString(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
private errorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
88
src/business/rankings/rankings.types.ts
Normal file
88
src/business/rankings/rankings.types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type RankingCategoryId =
|
||||
| 'weekly_commits'
|
||||
| 'night_owl'
|
||||
| 'popularity'
|
||||
| 'productive'
|
||||
| 'social'
|
||||
| 'rising'
|
||||
| 'comprehensive';
|
||||
|
||||
export interface DatawhaleMemberRow {
|
||||
id?: string;
|
||||
name?: string;
|
||||
github?: string;
|
||||
domain?: string;
|
||||
primary_domain?: string;
|
||||
public_repos?: number;
|
||||
total_stars?: number;
|
||||
followers?: number;
|
||||
followers_count?: number;
|
||||
following?: number;
|
||||
org_repos_count?: number;
|
||||
org_total_stars?: number;
|
||||
org_total_contributions?: number;
|
||||
avatar?: string;
|
||||
location?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
export interface DatawhaleCommitStats {
|
||||
total_commits?: number;
|
||||
repo_count?: number;
|
||||
active_days?: number;
|
||||
avg_commits_per_day?: number;
|
||||
night_owl_commits?: number;
|
||||
night_owl_percentage?: number;
|
||||
}
|
||||
|
||||
export interface DatawhaleWeeklyCommitsPayload {
|
||||
update_time?: string;
|
||||
days_range?: number;
|
||||
total_commits?: number;
|
||||
user_commits?: Record<string, DatawhaleCommitStats>;
|
||||
}
|
||||
|
||||
export interface RankingCategory {
|
||||
id: RankingCategoryId;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface RankingUser {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarText: string;
|
||||
avatarUrl: string;
|
||||
githubUrl: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
location: string;
|
||||
score: number;
|
||||
scoreLabel: string;
|
||||
contrib: number;
|
||||
answers: number;
|
||||
likes: number;
|
||||
reward: number;
|
||||
}
|
||||
|
||||
export interface RankingEntry extends RankingUser {
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface DatawhaleHonorRankingPayload {
|
||||
source: 'datawhale-members-visualization';
|
||||
activeCategory: RankingCategoryId;
|
||||
categories: RankingCategory[];
|
||||
topRankers: RankingEntry[];
|
||||
rankers: RankingEntry[];
|
||||
myRank: {
|
||||
rank: number | null;
|
||||
score: number;
|
||||
reward: number;
|
||||
};
|
||||
total: number;
|
||||
syncedAt: string | null;
|
||||
sourceUpdatedAt: string | null;
|
||||
}
|
||||
35
src/business/room_decor/dto/save_room_decor_placement.dto.ts
Normal file
35
src/business/room_decor/dto/save_room_decor_placement.dto.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { IsBoolean, IsNumber, IsOptional, IsString, Length, Matches, Max, Min } from 'class-validator';
|
||||
|
||||
export class SaveRoomDecorPlacementDto {
|
||||
@IsString({ message: '摆件ID必须是字符串' })
|
||||
@Length(1, 100, { message: '摆件ID长度需在1-100字符之间' })
|
||||
@Matches(/^[A-Za-z0-9_:-]+$/, { message: '摆件ID格式不正确' })
|
||||
decor_id!: string;
|
||||
|
||||
@IsBoolean({ message: '摆放状态必须是布尔值' })
|
||||
placed!: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'X坐标必须是数字' })
|
||||
@Min(-2000, { message: 'X坐标超出范围' })
|
||||
@Max(2000, { message: 'X坐标超出范围' })
|
||||
position_x?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: 'Y坐标必须是数字' })
|
||||
@Min(-2000, { message: 'Y坐标超出范围' })
|
||||
@Max(2000, { message: 'Y坐标超出范围' })
|
||||
position_y?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '缩放必须是数字' })
|
||||
@Min(0.01, { message: '缩放不能太小' })
|
||||
@Max(4, { message: '缩放不能太大' })
|
||||
scale?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({}, { message: '层级必须是数字' })
|
||||
@Min(-1000, { message: '层级超出范围' })
|
||||
@Max(1000, { message: '层级超出范围' })
|
||||
z_index?: number;
|
||||
}
|
||||
61
src/business/room_decor/room_decor.controller.ts
Normal file
61
src/business/room_decor/room_decor.controller.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Body, Controller, Get, HttpStatus, Param, Put, Res, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiResponse as SwaggerApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { JwtPayload } from '../../core/login_core/login_core.service';
|
||||
import { CurrentUser } from '../../gateway/auth/current_user.decorator';
|
||||
import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard';
|
||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||
import { RoomDecorService } from './room_decor.service';
|
||||
|
||||
@ApiTags('room-decor')
|
||||
@ApiBearerAuth()
|
||||
@Controller('rooms/me/decor-placements')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class RoomDecorController {
|
||||
constructor(private readonly roomDecorService: RoomDecorService) {}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '获取房间家具背包和摆放状态',
|
||||
description: '返回当前账号已拥有的房间摆件,以及每个摆件的摆放位置。',
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '房间家具背包获取成功',
|
||||
})
|
||||
@Get()
|
||||
async getInventory(@CurrentUser() user: JwtPayload, @Res() res: Response): Promise<void> {
|
||||
const data = await this.roomDecorService.getInventory(BigInt(user.sub));
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '房间家具背包获取成功',
|
||||
});
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: '保存房间家具摆放',
|
||||
description: '保存当前账号某个家具的摆放状态、位置、缩放和层级。',
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '家具摆放保存成功',
|
||||
})
|
||||
@Put(':decorId')
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||
async savePlacement(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('decorId') decorId: string,
|
||||
@Body() placementDto: SaveRoomDecorPlacementDto,
|
||||
@Res() res: Response,
|
||||
): Promise<void> {
|
||||
const data = await this.roomDecorService.savePlacement(BigInt(user.sub), {
|
||||
...placementDto,
|
||||
decor_id: decorId,
|
||||
});
|
||||
res.status(HttpStatus.OK).json({
|
||||
success: true,
|
||||
data,
|
||||
message: '家具摆放保存成功',
|
||||
});
|
||||
}
|
||||
}
|
||||
13
src/business/room_decor/room_decor.module.ts
Normal file
13
src/business/room_decor/room_decor.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { PlayerModule } from '../player/player.module';
|
||||
import { RoomDecorController } from './room_decor.controller';
|
||||
import { RoomDecorService } from './room_decor.service';
|
||||
|
||||
@Module({
|
||||
imports: [LoginCoreModule, PlayerModule],
|
||||
controllers: [RoomDecorController],
|
||||
providers: [RoomDecorService],
|
||||
exports: [RoomDecorService],
|
||||
})
|
||||
export class RoomDecorModule {}
|
||||
172
src/business/room_decor/room_decor.service.ts
Normal file
172
src/business/room_decor/room_decor.service.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { InventoryService } from '../player/inventory.service';
|
||||
import { SaveRoomDecorPlacementDto } from './dto/save_room_decor_placement.dto';
|
||||
import {
|
||||
ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
ROOM_DECOR_DEFINITIONS,
|
||||
ROOM_DECOR_LEGACY_DEFAULTS,
|
||||
ROOM_DECOR_LEGACY_BED_MAX_SCALE,
|
||||
ROOM_DECOR_LEGACY_WALL_DECOR_SCALES,
|
||||
ROOM_DECOR_ROOM_SCALE,
|
||||
findRoomDecorDefinition,
|
||||
} from './room_decor_catalog';
|
||||
|
||||
interface UserRoomDecorRow {
|
||||
decor_id: string;
|
||||
placed: boolean;
|
||||
position_x: number | null;
|
||||
position_y: number | null;
|
||||
scale: number;
|
||||
z_index: number;
|
||||
}
|
||||
|
||||
interface IRoomDecorPlacementsService {
|
||||
listPlacements(userId: bigint): Promise<UserRoomDecorRow[]>;
|
||||
savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto): Promise<UserRoomDecorRow>;
|
||||
}
|
||||
|
||||
interface RoomDecorPayloadPlacement {
|
||||
position_x: number | null;
|
||||
position_y: number | null;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RoomDecorService {
|
||||
constructor(
|
||||
@Inject('IRoomDecorPlacementsService') private readonly roomDecorPlacementsService: IRoomDecorPlacementsService,
|
||||
private readonly inventoryService: InventoryService,
|
||||
) {}
|
||||
|
||||
async getInventory(userId: bigint) {
|
||||
const [inventory, placements] = await Promise.all([
|
||||
this.inventoryService.listInventory(userId, 'room_decor'),
|
||||
this.roomDecorPlacementsService.listPlacements(userId),
|
||||
]);
|
||||
const placementByDecorId = new Map(placements.map((row) => [row.decor_id, row]));
|
||||
const rows = inventory.room_decor_ids.map((decorId) => {
|
||||
const definition = findRoomDecorDefinition(decorId);
|
||||
const placement = placementByDecorId.get(decorId);
|
||||
return placement ?? {
|
||||
decor_id: decorId,
|
||||
placed: false,
|
||||
position_x: definition?.default_position.x ?? null,
|
||||
position_y: definition?.default_position.y ?? null,
|
||||
scale: definition?.default_scale ?? 1,
|
||||
z_index: definition?.default_z_index ?? 0,
|
||||
};
|
||||
});
|
||||
return {
|
||||
items: rows
|
||||
.filter((row) => findRoomDecorDefinition(row.decor_id))
|
||||
.map((row) => this.toPayload(row)),
|
||||
definitions: ROOM_DECOR_DEFINITIONS,
|
||||
};
|
||||
}
|
||||
|
||||
async savePlacement(userId: bigint, placement: SaveRoomDecorPlacementDto) {
|
||||
const definition = findRoomDecorDefinition(placement.decor_id);
|
||||
if (!definition) {
|
||||
throw new BadRequestException('摆件不存在或暂未开放');
|
||||
}
|
||||
if (!(await this.inventoryService.hasAsset(userId, 'room_decor', placement.decor_id))) {
|
||||
throw new BadRequestException('尚未拥有该房间摆件');
|
||||
}
|
||||
const row = await this.roomDecorPlacementsService.savePlacement(userId, {
|
||||
...placement,
|
||||
scale: placement.scale ?? definition.default_scale,
|
||||
z_index: placement.z_index ?? definition.default_z_index,
|
||||
});
|
||||
return this.toPayload(row);
|
||||
}
|
||||
|
||||
private toPayload(row: UserRoomDecorRow) {
|
||||
const definition = findRoomDecorDefinition(row.decor_id);
|
||||
const placement = this.normalizedPlacement(row, definition);
|
||||
return {
|
||||
decor_id: row.decor_id,
|
||||
name: definition?.name ?? row.decor_id,
|
||||
item_id: definition?.item_id ?? '',
|
||||
icon: definition?.icon ?? '',
|
||||
texture: definition?.texture ?? definition?.icon ?? '',
|
||||
placed: row.placed,
|
||||
position_x: placement.position_x,
|
||||
position_y: placement.position_y,
|
||||
scale: placement.scale,
|
||||
z_index: row.z_index ?? definition?.default_z_index ?? 0,
|
||||
default_position: definition?.default_position ?? { x: 0, y: 0 },
|
||||
default_scale: definition?.default_scale ?? 1,
|
||||
default_z_index: definition?.default_z_index ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizedPlacement(
|
||||
row: UserRoomDecorRow,
|
||||
definition?: { default_scale: number; default_position: { x: number; y: number } },
|
||||
): RoomDecorPayloadPlacement {
|
||||
const usesLegacyPlacement = this.usesLegacyPlacement(row);
|
||||
return {
|
||||
position_x: this.normalizedPositionValue(row.position_x, definition?.default_position.x ?? 0, usesLegacyPlacement),
|
||||
position_y: this.normalizedPositionValue(row.position_y, definition?.default_position.y ?? 0, usesLegacyPlacement),
|
||||
scale: this.normalizedScale(row, definition),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizedScale(row: UserRoomDecorRow, definition?: { default_scale: number }) {
|
||||
const scale = row.scale ?? definition?.default_scale ?? 1;
|
||||
if (!row.placed && definition) {
|
||||
return definition.default_scale;
|
||||
}
|
||||
if (this.usesLegacyPlacement(row)) {
|
||||
return definition?.default_scale ?? scale;
|
||||
}
|
||||
if (this.isBedDecor(row.decor_id) && scale <= ROOM_DECOR_LEGACY_BED_MAX_SCALE) {
|
||||
return ROOM_DECOR_BED_DEFAULT_SCALE;
|
||||
}
|
||||
if (row.decor_id === 'whale_floor_rug' && scale >= 0.22 && scale <= 0.30) {
|
||||
return definition?.default_scale ?? scale;
|
||||
}
|
||||
if (row.decor_id === 'dev_whale_bookshelf' && (Math.abs(scale - 0.4) <= 0.001 || (scale >= 0.51 && scale <= 0.53))) {
|
||||
// Preserve the old room-fit footprint after switching to the larger mall texture.
|
||||
return definition?.default_scale ?? scale;
|
||||
}
|
||||
if (this.isWallBadgeDecor(row.decor_id) && this.isLegacyWallDecorScale(scale)) {
|
||||
return definition?.default_scale ?? scale;
|
||||
}
|
||||
if (row.decor_id === 'whale_tail_lamp' && scale <= 0.2) {
|
||||
return definition?.default_scale ?? scale;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
private normalizedPositionValue(value: number | null, fallback: number, usesLegacyPlacement: boolean) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return usesLegacyPlacement ? Math.round(value * ROOM_DECOR_ROOM_SCALE) : value;
|
||||
}
|
||||
|
||||
private usesLegacyPlacement(row: UserRoomDecorRow) {
|
||||
const legacy = ROOM_DECOR_LEGACY_DEFAULTS[row.decor_id];
|
||||
if (!legacy) {
|
||||
return false;
|
||||
}
|
||||
const scale = row.scale ?? legacy.scale;
|
||||
if (this.isBedDecor(row.decor_id) && scale <= ROOM_DECOR_LEGACY_BED_MAX_SCALE) {
|
||||
return true;
|
||||
}
|
||||
return Math.abs(scale - legacy.scale) <= 0.001;
|
||||
}
|
||||
|
||||
private isBedDecor(decorId: string) {
|
||||
return decorId.endsWith('_bed');
|
||||
}
|
||||
|
||||
private isWallBadgeDecor(decorId: string) {
|
||||
return decorId.startsWith('datawhale_') && decorId.endsWith('_badge');
|
||||
}
|
||||
|
||||
private isLegacyWallDecorScale(scale: number) {
|
||||
return ROOM_DECOR_LEGACY_WALL_DECOR_SCALES.some((legacyScale) => Math.abs(scale - legacyScale) <= 0.001);
|
||||
}
|
||||
}
|
||||
197
src/business/room_decor/room_decor_catalog.ts
Normal file
197
src/business/room_decor/room_decor_catalog.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
export interface RoomDecorDefinition {
|
||||
decor_id: string;
|
||||
name: string;
|
||||
item_id: string;
|
||||
icon: string;
|
||||
texture?: string;
|
||||
default_scale: number;
|
||||
default_position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
default_z_index: number;
|
||||
collision_size?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
collision_offset?: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RoomDecorLegacyDefault {
|
||||
scale: number;
|
||||
default_position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const ROOM_DECOR_ROOM_SCALE = 0.7;
|
||||
export const ROOM_DECOR_BED_DEFAULT_SCALE = ROOM_DECOR_ROOM_SCALE;
|
||||
export const ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE = 0.12;
|
||||
export const ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE = 1.0;
|
||||
export const ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE = 0.04;
|
||||
export const ROOM_DECOR_LEGACY_BED_MAX_SCALE = 0.35;
|
||||
export const ROOM_DECOR_LEGACY_WALL_DECOR_SCALES = [0.7, 0.18];
|
||||
|
||||
export const ROOM_DECOR_LEGACY_DEFAULTS: Record<string, RoomDecorLegacyDefault> = {
|
||||
whale_floor_rug: {
|
||||
scale: 0.42,
|
||||
default_position: { x: 0, y: 230 },
|
||||
},
|
||||
whale_memory_board: {
|
||||
scale: 0.16,
|
||||
default_position: { x: 260, y: -295 },
|
||||
},
|
||||
whale_tail_lamp: {
|
||||
scale: 0.16,
|
||||
default_position: { x: 330, y: -250 },
|
||||
},
|
||||
boat_cabin_bed: {
|
||||
scale: 1,
|
||||
default_position: { x: -230, y: 35 },
|
||||
},
|
||||
low_wave_bed: {
|
||||
scale: 1,
|
||||
default_position: { x: -140, y: 55 },
|
||||
},
|
||||
whale_tail_headboard_bed: {
|
||||
scale: 1,
|
||||
default_position: { x: 0, y: 45 },
|
||||
},
|
||||
dev_whale_bookshelf: {
|
||||
scale: 1,
|
||||
default_position: { x: -300, y: -55 },
|
||||
},
|
||||
datawhale_bug_feature_badge: {
|
||||
scale: 1,
|
||||
default_position: { x: -300, y: -290 },
|
||||
},
|
||||
datawhale_buddhist_learning_badge: {
|
||||
scale: 1,
|
||||
default_position: { x: 0, y: -290 },
|
||||
},
|
||||
datawhale_ok_working_badge: {
|
||||
scale: 1,
|
||||
default_position: { x: 300, y: -290 },
|
||||
},
|
||||
};
|
||||
|
||||
export const ROOM_DECOR_DEFINITIONS: RoomDecorDefinition[] = [
|
||||
{
|
||||
decor_id: 'whale_floor_rug',
|
||||
item_id: 'decor_whale_floor_rug',
|
||||
name: '鲸浪地毯',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_floor_rug.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_floor_rug_roomfit.png',
|
||||
default_scale: ROOM_DECOR_FLOOR_RUG_DEFAULT_SCALE,
|
||||
default_position: { x: 0, y: 161 },
|
||||
default_z_index: -8,
|
||||
},
|
||||
{
|
||||
decor_id: 'whale_memory_board',
|
||||
item_id: 'decor_whale_memory_board',
|
||||
name: '鲸语记忆板',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_memory_board.png',
|
||||
default_scale: 0.11,
|
||||
default_position: { x: 182, y: -207 },
|
||||
default_z_index: -14,
|
||||
},
|
||||
{
|
||||
decor_id: 'whale_tail_lamp',
|
||||
item_id: 'decor_whale_tail_lamp',
|
||||
name: '鲸尾暖灯',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_lamp.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_lamp_roomfit.png',
|
||||
default_scale: 1,
|
||||
default_position: { x: 231, y: -175 },
|
||||
default_z_index: -10,
|
||||
collision_size: { x: 50, y: 32 },
|
||||
collision_offset: { x: 0, y: 56 },
|
||||
},
|
||||
{
|
||||
decor_id: 'boat_cabin_bed',
|
||||
item_id: 'decor_boat_cabin_bed',
|
||||
name: '船舱小床',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_boat_cabin_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_boat_cabin_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_position: { x: -161, y: 25 },
|
||||
default_z_index: -9,
|
||||
collision_size: { x: 220, y: 112 },
|
||||
collision_offset: { x: 0, y: 52 },
|
||||
},
|
||||
{
|
||||
decor_id: 'low_wave_bed',
|
||||
item_id: 'decor_low_wave_bed',
|
||||
name: '海浪低床',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_low_wave_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_low_wave_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_position: { x: -98, y: 39 },
|
||||
default_z_index: -9,
|
||||
collision_size: { x: 220, y: 112 },
|
||||
collision_offset: { x: 0, y: 56 },
|
||||
},
|
||||
{
|
||||
decor_id: 'whale_tail_headboard_bed',
|
||||
item_id: 'decor_whale_tail_headboard_bed',
|
||||
name: '鲸尾床头床',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_whale_tail_headboard_bed.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_whale_tail_headboard_bed_roomfit.png',
|
||||
default_scale: ROOM_DECOR_BED_DEFAULT_SCALE,
|
||||
default_position: { x: 0, y: 32 },
|
||||
default_z_index: -9,
|
||||
collision_size: { x: 214, y: 112 },
|
||||
collision_offset: { x: 0, y: 62 },
|
||||
},
|
||||
{
|
||||
decor_id: 'dev_whale_bookshelf',
|
||||
item_id: 'decor_dev_whale_bookshelf',
|
||||
name: '程序员鲸书架',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
texture: 'res://assets/ui/mall/items/room_decor_dev_whale_bookshelf.png',
|
||||
default_scale: ROOM_DECOR_BOOKSHELF_DEFAULT_SCALE,
|
||||
default_position: { x: -210, y: -39 },
|
||||
default_z_index: -10,
|
||||
collision_size: { x: 626.667, y: 226.667 },
|
||||
collision_offset: { x: 0, y: 580 },
|
||||
},
|
||||
{
|
||||
decor_id: 'datawhale_bug_feature_badge',
|
||||
item_id: 'decor_datawhale_bug_feature_badge',
|
||||
name: 'BUG特性徽章',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_bug_feature_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_bug_feature_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_position: { x: -210, y: -203 },
|
||||
default_z_index: -14,
|
||||
},
|
||||
{
|
||||
decor_id: 'datawhale_buddhist_learning_badge',
|
||||
item_id: 'decor_datawhale_buddhist_learning_badge',
|
||||
name: '佛系学习徽章',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_buddhist_learning_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_buddhist_learning_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_position: { x: 0, y: -203 },
|
||||
default_z_index: -14,
|
||||
},
|
||||
{
|
||||
decor_id: 'datawhale_ok_working_badge',
|
||||
item_id: 'decor_datawhale_ok_working_badge',
|
||||
name: '已经在做徽章',
|
||||
icon: 'res://assets/ui/mall/items/room_decor_datawhale_ok_working_badge.png',
|
||||
texture: 'res://assets/maps/personal_space/v1/decor/room_decor_datawhale_ok_working_badge_hires_clean.png',
|
||||
default_scale: ROOM_DECOR_WALL_DECOR_DEFAULT_SCALE,
|
||||
default_position: { x: 210, y: -203 },
|
||||
default_z_index: -14,
|
||||
},
|
||||
];
|
||||
|
||||
export function findRoomDecorDefinition(decorId?: string): RoomDecorDefinition | undefined {
|
||||
const normalizedDecorId = (decorId || '').trim();
|
||||
return ROOM_DECOR_DEFINITIONS.find((decor) => normalizedDecorId && decor.decor_id === normalizedDecorId);
|
||||
}
|
||||
100
src/business/shared/app_status.dto.ts
Normal file
100
src/business/shared/app_status.dto.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 应用状态响应 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义应用状态接口的响应格式
|
||||
* - 提供 Swagger 文档生成支持
|
||||
* - 标准化应用健康检查响应结构
|
||||
*
|
||||
* 职责分离:
|
||||
* - 数据传输对象:定义API响应的数据结构
|
||||
* - 文档生成:提供Swagger API文档支持
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 文件夹扁平化 - 从dto/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范、修正属性命名(storage_mode->storageMode)和作者信息
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 应用状态响应 DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义应用状态查询接口的响应数据结构
|
||||
* - 提供完整的应用运行时信息
|
||||
*
|
||||
* 主要属性:
|
||||
* - service - 服务名称标识
|
||||
* - version - 当前服务版本
|
||||
* - status - 运行状态枚举
|
||||
* - timestamp - 响应时间戳
|
||||
* - uptime - 服务运行时长
|
||||
* - environment - 运行环境标识
|
||||
* - storageMode - 数据存储模式
|
||||
*
|
||||
* 使用场景:
|
||||
* - 健康检查接口响应
|
||||
* - 系统监控数据收集
|
||||
* - 运维状态查询
|
||||
*/
|
||||
export class AppStatusResponseDto {
|
||||
@ApiProperty({
|
||||
description: '服务名称',
|
||||
example: 'Pixel Game Server',
|
||||
type: String
|
||||
})
|
||||
service: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '服务版本',
|
||||
example: '1.0.0',
|
||||
type: String
|
||||
})
|
||||
version: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '运行状态',
|
||||
example: 'running',
|
||||
enum: ['running', 'starting', 'stopping', 'error'],
|
||||
type: String
|
||||
})
|
||||
status: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '当前时间戳',
|
||||
example: '2025-12-17T15:00:00.000Z',
|
||||
type: String,
|
||||
format: 'date-time'
|
||||
})
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '运行时间(秒)',
|
||||
example: 3600,
|
||||
type: Number,
|
||||
minimum: 0
|
||||
})
|
||||
uptime: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '运行环境',
|
||||
example: 'development',
|
||||
enum: ['development', 'production', 'test'],
|
||||
type: String
|
||||
})
|
||||
environment: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '存储模式',
|
||||
example: 'memory',
|
||||
enum: ['database', 'memory'],
|
||||
type: String
|
||||
})
|
||||
storageMode: 'database' | 'memory';
|
||||
}
|
||||
82
src/business/shared/error_response.dto.ts
Normal file
82
src/business/shared/error_response.dto.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 通用错误响应 DTO
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定义统一的错误响应格式
|
||||
* - 提供 Swagger 文档生成支持
|
||||
* - 标准化全局异常处理响应结构
|
||||
*
|
||||
* 职责分离:
|
||||
* - 错误数据结构:定义统一的错误响应格式
|
||||
* - 文档生成:提供Swagger错误响应文档
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 文件夹扁平化 - 从dto/子文件夹移动到上级目录 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范和作者信息
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* 通用错误响应 DTO
|
||||
*
|
||||
* 职责:
|
||||
* - 定义全局异常处理的统一响应格式
|
||||
* - 提供完整的错误信息结构
|
||||
*
|
||||
* 主要属性:
|
||||
* - statusCode - HTTP状态码
|
||||
* - message - 错误描述信息
|
||||
* - timestamp - 错误发生时间
|
||||
* - path - 请求路径(可选)
|
||||
* - error - 错误代码(可选)
|
||||
*
|
||||
* 使用场景:
|
||||
* - 全局异常过滤器响应
|
||||
* - API错误信息标准化
|
||||
* - 客户端错误处理
|
||||
*/
|
||||
export class ErrorResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'HTTP 状态码',
|
||||
example: 500,
|
||||
type: Number
|
||||
})
|
||||
statusCode: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误消息',
|
||||
example: 'Internal server error',
|
||||
type: String
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误发生时间',
|
||||
example: '2025-12-17T15:00:00.000Z',
|
||||
type: String,
|
||||
format: 'date-time'
|
||||
})
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '请求路径',
|
||||
example: '/api/status',
|
||||
type: String,
|
||||
required: false
|
||||
})
|
||||
path?: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '错误代码',
|
||||
example: 'INTERNAL_ERROR',
|
||||
type: String,
|
||||
required: false
|
||||
})
|
||||
error?: string;
|
||||
}
|
||||
27
src/business/shared/index.ts
Normal file
27
src/business/shared/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 共享模块统一导出
|
||||
*
|
||||
* 功能描述:
|
||||
* - 导出所有共享的组件和类型
|
||||
* - 提供统一的导入入口
|
||||
* - 简化其他模块的导入路径
|
||||
*
|
||||
* 职责分离:
|
||||
* - 统一导出接口:提供单一的导入入口点
|
||||
* - 模块封装:隐藏内部文件结构细节
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-08: 文件夹扁平化 - 更新导入路径,移除dto/子文件夹 (修改者: moyin)
|
||||
* - 2026-01-07: 代码规范优化 - 更新注释规范和作者信息
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @since 2025-12-24
|
||||
* @lastModified 2026-01-08
|
||||
*/
|
||||
|
||||
// 应用状态相关
|
||||
export * from './app_status.dto';
|
||||
|
||||
// 错误响应相关
|
||||
export * from './error_response.dto';
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateSkinGenerationJobDto {
|
||||
@ApiProperty({
|
||||
description: '玩家上传的角色参考图PNG/JPG/WebP,base64,不包含data URL前缀',
|
||||
example: 'iVBORw0KGgoAAAANSUhEUgAA...',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(100)
|
||||
@MaxLength(12_000_000)
|
||||
source_image_base64: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '上传图片的MIME类型',
|
||||
example: 'image/png',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
source_mime_type?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '玩家给生成角色起的名字',
|
||||
example: '鲸纹水手',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
name?: string;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user