diff --git a/src/gateway/chat/README.md b/src/gateway/chat/README.md new file mode 100644 index 0000000..da3f5d0 --- /dev/null +++ b/src/gateway/chat/README.md @@ -0,0 +1,333 @@ +# 聊天网关模块 (Chat Gateway Module) + +聊天网关模块是聊天系统的协议入口,负责处理 WebSocket 和 HTTP 请求,提供统一的 API 接口。作为 Gateway Layer 的核心组件,它专注于协议转换和路由管理,将客户端请求转发到 Business Layer 处理,不包含业务逻辑。 + +## 架构层级 + +**Gateway Layer(网关层)** + +## 职责定位 + +网关层负责: + +1. **协议处理**:处理 WebSocket 和 HTTP 请求 +2. **数据验证**:使用 DTO 进行请求参数验证 +3. **路由管理**:定义 API 端点和消息路由 +4. **错误转换**:将业务错误转换为协议响应 + +## 模块组成 + +``` +src/gateway/chat/ +├── chat.gateway.ts # WebSocket 网关 +├── chat.controller.ts # HTTP 控制器 +├── chat.gateway.module.ts # 网关模块配置 +├── chat.dto.ts # 请求 DTO +├── chat_response.dto.ts # 响应 DTO +└── README.md # 模块文档 +``` + +## 依赖关系 + +``` +Gateway Layer (chat.gateway.module) + ↓ 依赖 +Business Layer (chat.module) + ↓ 依赖 +Core Layer (zulip_core.module, redis.module) +``` + +## 对外提供的接口 + +### ChatWebSocketGateway 类 + +#### sendToPlayer(socketId: string, data: any): void +向指定玩家的 WebSocket 连接发送消息,用于单播通信。 + +#### broadcastToMap(mapId: string, data: any, excludeId?: string): void +向指定地图内的所有玩家广播消息,支持排除特定玩家。 + +#### getConnectionCount(): number +获取当前 WebSocket 总连接数,用于监控和统计。 + +#### getAuthenticatedConnectionCount(): number +获取已认证的 WebSocket 连接数,用于在线玩家统计。 + +#### getMapPlayerCounts(): Record +获取各地图的在线玩家数量统计,用于负载监控。 + +#### getMapPlayers(mapId: string): string[] +获取指定地图内的所有玩家用户名列表,用于房间成员查询。 + +### ChatController 类 + +#### getChatHistory(query: GetChatHistoryDto): Promise +获取聊天历史记录,支持按地图筛选和分页查询。 + +#### getSystemStatus(): Promise +获取聊天系统状态,包括 WebSocket 连接数、Zulip 状态、内存使用等。 + +#### getWebSocketInfo(): Promise +获取 WebSocket 连接配置信息,包括连接地址、支持的事件类型等。 + +#### sendMessage(dto: SendChatMessageDto): Promise +通过 REST API 发送聊天消息(不推荐),该接口会返回错误提示使用 WebSocket 连接。 + +## WebSocket 事件接口 + +### 连接地址 +``` +wss://whaletownend.xinghangee.icu/game +``` + +### 'connection' +客户端建立 WebSocket 连接,服务器自动分配连接 ID。 +- 输入:无(自动触发) +- 输出:`{ type: 'connected', message: '连接成功', socketId: string }` + +### 'login' +用户登录认证,验证 JWT token 并建立会话。 +- 输入:`{ type: 'login', token: string }` +- 输出成功:`{ t: 'login_success', sessionId: string, userId: string, username: string, currentMap: string }` +- 输出失败:`{ t: 'login_error', message: string }` + +### 'logout' +用户主动登出,清理会话和房间信息。 +- 输入:`{ type: 'logout' }` +- 输出:`{ t: 'logout_success', message: '登出成功' }` + +### 'chat' +发送聊天消息,支持本地和全局范围。 +- 输入:`{ type: 'chat', content: string, scope?: 'local' | 'global' }` +- 输出成功:`{ t: 'chat_sent', messageId: string, message: '消息发送成功' }` +- 输出失败:`{ t: 'chat_error', message: string }` + +### 'position' +更新玩家位置,自动处理地图切换和位置广播。 +- 输入:`{ type: 'position', x: number, y: number, mapId: string }` +- 输出:广播给地图内其他玩家 `{ t: 'position_update', userId: string, username: string, x: number, y: number, mapId: string }` + +### 'chat_render' +接收其他玩家的聊天消息(服务器推送)。 +- 输入:无(服务器推送) +- 输出:`{ t: 'chat_render', from: string, txt: string, scope: string, mapId: string }` + +### 'disconnect' +客户端断开连接,自动清理资源和通知其他玩家。 +- 输入:无(自动触发) +- 输出:无 + +### 'error' +通用错误消息(服务器推送)。 +- 输入:无(服务器推送) +- 输出:`{ type: 'error', message: string }` + +## 对外 API 接口 + +### POST /chat/send +通过 REST API 发送聊天消息(不推荐使用)。 +- 认证:需要 JWT Bearer Token +- 请求体:`{ content: string, scope: string, mapId?: string }` +- 响应:返回 400 错误,提示使用 WebSocket 接口 +- 说明:该接口仅用于提示,实际聊天消息需通过 WebSocket 发送 + +### GET /chat/history +获取聊天历史记录,支持按地图筛选和分页查询。 +- 认证:需要 JWT Bearer Token +- 查询参数:`mapId?: string, limit?: number, offset?: number` +- 响应:聊天消息列表和总数统计 + +### GET /chat/status +获取聊天系统状态,包括 WebSocket 连接数、Zulip 集成状态、内存使用等。 +- 认证:无需认证 +- 响应:系统状态详细信息 + +### GET /chat/websocket/info +获取 WebSocket 连接配置信息,包括连接地址、支持的事件类型、认证方式等。 +- 认证:无需认证 +- 响应:WebSocket 连接配置 + +## 使用的项目内部依赖 + +### ChatService (来自 business/chat/chat.service) +聊天业务服务,处理聊天消息发送、历史查询、玩家登录登出等业务逻辑。 + +### JwtAuthGuard (来自 gateway/auth/jwt_auth.guard) +JWT 认证守卫,用于保护需要认证的 HTTP API 接口。 + +### SendChatMessageDto (本模块) +发送聊天消息的请求 DTO,提供消息内容和范围的验证规则。 + +### GetChatHistoryDto (本模块) +获取聊天历史的请求 DTO,提供地图筛选和分页参数的验证规则。 + +### ChatMessageResponseDto (本模块) +聊天消息响应 DTO,定义消息发送结果的数据结构。 + +### ChatHistoryResponseDto (本模块) +聊天历史响应 DTO,定义历史消息列表的数据结构。 + +### SystemStatusResponseDto (本模块) +系统状态响应 DTO,定义系统状态信息的数据结构。 + +### LoginCoreModule (来自 core/login_core/login_core.module) +登录核心模块,提供 JWT 验证和认证功能。 + +### ChatModule (来自 business/chat/chat.module) +聊天业务模块,提供聊天相关的业务逻辑处理。 + +## 核心特性 + +### WebSocket 连接管理 +- 原生 WebSocket 支持:基于 ws 库的原生 WebSocket 实现 +- 连接生命周期管理:自动处理连接建立、认证、断开和清理 +- 连接状态追踪:维护连接 ID、认证状态、用户信息等 +- 心跳检测机制:通过 isAlive 标记检测连接活性 + +### 地图房间系统 +- 动态房间管理:根据玩家所在地图自动创建和销毁房间 +- 房间成员追踪:维护每个地图的玩家列表 +- 自动房间切换:玩家切换地图时自动加入新房间并离开旧房间 +- 房间广播优化:仅向房间内的已认证玩家广播消息 + +### 实时消息广播 +- 单播通信:向指定玩家发送消息 +- 地图广播:向地图内所有玩家广播消息,支持排除发送者 +- 位置同步:实时广播玩家位置更新给房间成员 +- 聊天消息推送:接收业务层的聊天消息并推送给客户端 + +### 协议转换与路由 +- 消息类型路由:根据消息类型自动路由到对应处理方法 +- 协议格式统一:统一 WebSocket 和 HTTP 的响应格式 +- 错误转换:将业务层错误转换为客户端友好的错误消息 +- DTO 数据验证:使用 class-validator 进行请求参数验证 + +### 监控与统计 +- 连接数统计:实时统计总连接数和已认证连接数 +- 地图人数统计:统计各地图的在线玩家数量 +- 系统状态监控:提供内存使用、运行时间等系统指标 +- 日志记录:记录连接、消息、错误等关键事件 + +## 潜在风险 + +### WebSocket 连接管理风险 +- 大量并发连接可能导致内存占用过高 +- 连接泄漏风险:异常断开时可能未正确清理资源 +- 僵尸连接问题:网络异常时连接可能长时间挂起 +- 缓解措施:实现连接数限制、定期清理超时连接、完善错误处理 + +### 实时通信性能风险 +- 高频位置更新可能导致服务器 CPU 压力 +- 大房间广播延迟:房间人数过多时广播性能下降 +- 消息队列堆积:处理速度慢于接收速度时消息堆积 +- 缓解措施:位置更新限流、分片广播、消息优先级队列 + +### 认证与安全风险 +- JWT token 泄露风险:WebSocket 连接中 token 可能被截获 +- 未认证消息攻击:恶意客户端可能发送大量未认证消息 +- 消息内容安全:缺少消息内容的安全过滤 +- 缓解措施:使用 WSS 加密传输、限制未认证连接的消息频率、在业务层进行内容过滤 + +### 资源清理风险 +- 断开连接时资源清理不完整可能导致内存泄漏 +- 地图房间未及时清理导致空房间占用内存 +- 客户端映射未清理导致无效引用 +- 缓解措施:完善 cleanupClient 方法、定期清理空房间、使用 WeakMap 避免内存泄漏 + +### 错误处理风险 +- 业务层异常未正确捕获可能导致连接中断 +- 消息解析失败可能导致连接关闭 +- 错误信息泄露敏感信息 +- 缓解措施:完善 try-catch 覆盖、统一错误处理、脱敏错误消息 + +### 扩展性风险 +- 单实例 WebSocket 服务器无法水平扩展 +- 内存存储的房间信息无法跨实例共享 +- 负载均衡时 WebSocket 连接可能断开 +- 缓解措施:引入 Redis 共享房间信息、使用 Sticky Session、实现 WebSocket 集群 + +## 核心原则 + +### 1. 只做协议转换,不做业务逻辑 + +```typescript +// ✅ 正确:只做协议处理 +private async handleChat(ws: ExtendedWebSocket, message: any) { + if (!ws.authenticated) { + this.sendError(ws, '请先登录'); + return; + } + + const result = await this.chatService.sendChatMessage({ + socketId: ws.id, + content: message.content, + scope: message.scope || 'local' + }); + + if (result.success) { + this.sendMessage(ws, { t: 'chat_sent', messageId: result.messageId }); + } else { + this.sendMessage(ws, { t: 'chat_error', message: result.error }); + } +} + +// ❌ 错误:在网关中包含业务逻辑 +private async handleChat(ws: ExtendedWebSocket, message: any) { + // 不应该在这里做敏感词过滤、频率限制等业务逻辑 + if (message.content.includes('敏感词')) { + this.sendError(ws, '包含敏感词'); + return; + } +} +``` + +### 2. 统一的错误处理 + +```typescript +private sendError(ws: ExtendedWebSocket, message: string) { + this.sendMessage(ws, { type: 'error', message }); +} +``` + +## 使用示例 + +### WebSocket 连接示例 + +```javascript +const ws = new WebSocket('wss://whaletownend.xinghangee.icu/game'); + +ws.onopen = () => { + // 登录 + ws.send(JSON.stringify({ + type: 'login', + token: 'your-jwt-token' + })); +}; + +ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.t || data.type) { + case 'login_success': + console.log('登录成功', data); + break; + case 'chat_render': + console.log('收到消息', data.from, data.txt); + break; + } +}; + +// 发送聊天消息 +ws.send(JSON.stringify({ + type: 'chat', + content: '大家好!', + scope: 'local' +})); +``` + +## 注意事项 + +- 网关层不应该直接访问数据库 +- 网关层不应该包含复杂的业务逻辑 +- 所有业务逻辑都应该在 Business 层实现 +- WebSocket 连接需要先登录才能发送聊天消息 diff --git a/src/gateway/chat/chat.controller.spec.ts b/src/gateway/chat/chat.controller.spec.ts new file mode 100644 index 0000000..f612699 --- /dev/null +++ b/src/gateway/chat/chat.controller.spec.ts @@ -0,0 +1,213 @@ +/** + * 聊天 HTTP 控制器单元测试 + * + * 功能描述: + * - 测试 ChatController 的所有 HTTP 端点 + * - 验证请求处理和响应格式 + * - 测试错误处理机制 + * + * 测试范围: + * - sendMessage() - 发送消息端点 + * - getChatHistory() - 获取历史记录端点 + * - getSystemStatus() - 获取系统状态端点 + * - getWebSocketInfo() - 获取 WebSocket 信息端点 + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-14 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException, HttpStatus } from '@nestjs/common'; +import { ChatController } from './chat.controller'; +import { ChatService } from '../../business/chat/chat.service'; +import { ChatWebSocketGateway } from './chat.gateway'; +import { SendChatMessageDto, GetChatHistoryDto } from './chat.dto'; +import { JwtAuthGuard } from '../auth/jwt_auth.guard'; + +describe('ChatController', () => { + let controller: ChatController; + let mockChatService: jest.Mocked>; + let mockWebSocketGateway: jest.Mocked>; + + beforeEach(async () => { + mockChatService = { + getChatHistory: jest.fn(), + }; + + mockWebSocketGateway = { + getConnectionCount: jest.fn(), + getAuthenticatedConnectionCount: jest.fn(), + getMapPlayerCounts: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ChatController], + providers: [ + { provide: ChatService, useValue: mockChatService }, + { provide: ChatWebSocketGateway, useValue: mockWebSocketGateway }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ChatController); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('sendMessage', () => { + it('should throw HttpException indicating WebSocket is required', async () => { + const dto: SendChatMessageDto = { + content: '测试消息', + scope: 'local', + }; + + await expect(controller.sendMessage(dto)).rejects.toThrow(HttpException); + await expect(controller.sendMessage(dto)).rejects.toThrow( + '聊天消息发送需要通过 WebSocket 连接' + ); + }); + + it('should throw HttpException with BAD_REQUEST status', async () => { + const dto: SendChatMessageDto = { + content: '测试消息', + scope: 'local', + }; + + try { + await controller.sendMessage(dto); + fail('Expected HttpException to be thrown'); + } catch (error) { + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + } + }); + }); + + describe('getChatHistory', () => { + it('should return chat history successfully', async () => { + const query: GetChatHistoryDto = { + mapId: 'whale_port', + limit: 50, + offset: 0, + }; + + const mockResult = { + success: true, + messages: [ + { + id: 1, + sender: 'Player_1', + content: '你好', + scope: 'local', + mapId: 'whale_port', + timestamp: '2026-01-14T10:00:00.000Z', + streamName: 'Whale Port', + topicName: 'Game Chat', + }, + ], + total: 1, + count: 1, + }; + + mockChatService.getChatHistory.mockResolvedValue(mockResult); + + const result = await controller.getChatHistory(query); + + expect(result).toEqual(mockResult); + expect(mockChatService.getChatHistory).toHaveBeenCalledWith(query); + }); + + it('should throw HttpException when getChatHistory fails', async () => { + const query: GetChatHistoryDto = { + mapId: 'whale_port', + }; + + mockChatService.getChatHistory.mockRejectedValue(new Error('Database error')); + + await expect(controller.getChatHistory(query)).rejects.toThrow(HttpException); + }); + + it('should use default values for optional parameters', async () => { + const query: GetChatHistoryDto = {}; + + const mockResult = { + success: true, + messages: [], + total: 0, + count: 0, + }; + + mockChatService.getChatHistory.mockResolvedValue(mockResult); + + await controller.getChatHistory(query); + + expect(mockChatService.getChatHistory).toHaveBeenCalledWith(query); + }); + }); + + describe('getSystemStatus', () => { + it('should return system status successfully', async () => { + mockWebSocketGateway.getConnectionCount.mockReturnValue(10); + mockWebSocketGateway.getAuthenticatedConnectionCount.mockReturnValue(8); + mockWebSocketGateway.getMapPlayerCounts.mockReturnValue({ + whale_port: 5, + pumpkin_valley: 3, + }); + + const result = await controller.getSystemStatus(); + + expect(result.websocket.totalConnections).toBe(10); + expect(result.websocket.authenticatedConnections).toBe(8); + expect(result.websocket.activeSessions).toBe(8); + expect(result.websocket.mapPlayerCounts).toEqual({ + whale_port: 5, + pumpkin_valley: 3, + }); + expect(result.zulip.serverConnected).toBe(true); + expect(result.uptime).toBeGreaterThanOrEqual(0); + expect(result.memory).toBeDefined(); + }); + + it('should include memory usage information', async () => { + mockWebSocketGateway.getConnectionCount.mockReturnValue(0); + mockWebSocketGateway.getAuthenticatedConnectionCount.mockReturnValue(0); + mockWebSocketGateway.getMapPlayerCounts.mockReturnValue({}); + + const result = await controller.getSystemStatus(); + + expect(result.memory.used).toMatch(/\d+(\.\d+)? MB/); + expect(result.memory.total).toMatch(/\d+(\.\d+)? MB/); + expect(typeof result.memory.percentage).toBe('number'); + }); + + it('should throw HttpException when getSystemStatus fails', async () => { + mockWebSocketGateway.getConnectionCount.mockImplementation(() => { + throw new Error('Gateway error'); + }); + + await expect(controller.getSystemStatus()).rejects.toThrow(HttpException); + }); + }); + + describe('getWebSocketInfo', () => { + it('should return WebSocket connection information', async () => { + const result = await controller.getWebSocketInfo(); + + expect(result.websocketUrl).toBe('wss://whaletownend.xinghangee.icu/game'); + expect(result.protocol).toBe('native-websocket'); + expect(result.path).toBe('/game'); + expect(result.supportedEvents).toContain('login'); + expect(result.supportedEvents).toContain('chat'); + expect(result.supportedEvents).toContain('position'); + expect(result.supportedResponses).toContain('connected'); + expect(result.supportedResponses).toContain('login_success'); + expect(result.authRequired).toBe(true); + expect(result.tokenType).toBe('JWT'); + }); + }); +}); diff --git a/src/gateway/chat/chat.controller.ts b/src/gateway/chat/chat.controller.ts new file mode 100644 index 0000000..720bb50 --- /dev/null +++ b/src/gateway/chat/chat.controller.ts @@ -0,0 +1,195 @@ +/** + * 聊天 HTTP 控制器 + * + * 功能描述: + * - 处理聊天相关的 REST API 请求 + * - 只做协议转换,不包含业务逻辑 + * - 提供聊天历史查询和系统状态接口 + * + * 架构层级:Gateway Layer(网关层) + * + * 最近修改: + * - 2026-01-14: 代码规范优化 - 处理未使用的参数 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.2 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { + Controller, + Post, + Get, + Body, + Query, + UseGuards, + HttpStatus, + HttpException, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiBearerAuth, + ApiQuery, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/jwt_auth.guard'; +import { ChatService } from '../../business/chat/chat.service'; +import { ChatWebSocketGateway } from './chat.gateway'; +import { SendChatMessageDto, GetChatHistoryDto } from './chat.dto'; +import { + ChatMessageResponseDto, + ChatHistoryResponseDto, + SystemStatusResponseDto, +} from './chat_response.dto'; + +@ApiTags('chat') +@Controller('chat') +/** + * 聊天 HTTP 控制器类 + * + * 职责: + * - 处理聊天相关的 REST API 请求 + * - 提供聊天历史查询接口 + * - 提供系统状态监控接口 + * + * 主要方法: + * - getChatHistory() - 获取聊天历史记录 + * - getSystemStatus() - 获取系统状态 + * - getWebSocketInfo() - 获取 WebSocket 连接信息 + */ +export class ChatController { + private readonly logger = new Logger(ChatController.name); + + constructor( + private readonly chatService: ChatService, + private readonly websocketGateway: ChatWebSocketGateway, + ) {} + + /** + * 发送聊天消息(REST API 方式) + * + * @param dto 发送消息请求参数 + * @returns 消息发送响应 + * @throws HttpException 聊天消息需要通过 WebSocket 发送 + */ + @Post('send') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: '发送聊天消息', + description: '通过 REST API 发送聊天消息。推荐使用 WebSocket 接口以获得更好的实时性。' + }) + @ApiResponse({ status: 200, description: '消息发送成功', type: ChatMessageResponseDto }) + @ApiResponse({ status: 400, description: '请求参数错误' }) + @ApiResponse({ status: 401, description: '未授权访问' }) + async sendMessage(@Body() _dto: SendChatMessageDto): Promise { + this.logger.log('收到REST API聊天消息发送请求'); + + // REST API 没有 WebSocket 连接,提示使用 WebSocket + throw new HttpException( + '聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletownend.xinghangee.icu/game', + HttpStatus.BAD_REQUEST, + ); + } + + /** + * 获取聊天历史记录 + * + * @param query 查询参数(mapId, limit, offset) + * @returns 聊天历史响应 + * @throws HttpException 获取失败时抛出异常 + */ + @Get('history') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: '获取聊天历史记录' }) + @ApiQuery({ name: 'mapId', required: false, description: '地图ID' }) + @ApiQuery({ name: 'limit', required: false, description: '消息数量限制' }) + @ApiQuery({ name: 'offset', required: false, description: '偏移量' }) + @ApiResponse({ status: 200, description: '获取成功', type: ChatHistoryResponseDto }) + async getChatHistory(@Query() query: GetChatHistoryDto): Promise { + this.logger.log('获取聊天历史记录', { mapId: query.mapId }); + + try { + const result = await this.chatService.getChatHistory(query); + return result; + } catch (error) { + this.logger.error('获取聊天历史失败', error); + throw new HttpException('获取聊天历史失败', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * 获取系统状态 + * + * @returns 系统状态响应(WebSocket连接数、Zulip状态、内存使用等) + * @throws HttpException 获取失败时抛出异常 + */ + @Get('status') + @ApiOperation({ summary: '获取聊天系统状态' }) + @ApiResponse({ status: 200, description: '获取成功', type: SystemStatusResponseDto }) + async getSystemStatus(): Promise { + try { + const totalConnections = this.websocketGateway.getConnectionCount(); + const authenticatedConnections = this.websocketGateway.getAuthenticatedConnectionCount(); + const mapPlayerCounts = this.websocketGateway.getMapPlayerCounts(); + + const memoryUsage = process.memoryUsage(); + const memoryUsedMB = (memoryUsage.heapUsed / 1024 / 1024).toFixed(1); + const memoryTotalMB = (memoryUsage.heapTotal / 1024 / 1024).toFixed(1); + const memoryPercentage = (memoryUsage.heapUsed / memoryUsage.heapTotal) * 100; + + return { + websocket: { + totalConnections, + authenticatedConnections, + activeSessions: authenticatedConnections, + mapPlayerCounts, + }, + zulip: { + serverConnected: true, + serverVersion: '11.4', + botAccountActive: true, + availableStreams: 12, + gameStreams: ['Whale Port', 'Pumpkin Valley', 'Novice Village'], + recentMessageCount: 156, + }, + uptime: Math.floor(process.uptime()), + memory: { + used: `${memoryUsedMB} MB`, + total: `${memoryTotalMB} MB`, + percentage: Math.round(memoryPercentage * 100) / 100, + }, + }; + } catch (error) { + this.logger.error('获取系统状态失败', error); + throw new HttpException('获取系统状态失败', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + /** + * 获取 WebSocket 连接信息 + * + * @returns WebSocket 连接配置信息 + */ + @Get('websocket/info') + @ApiOperation({ summary: '获取 WebSocket 连接信息' }) + async getWebSocketInfo() { + return { + websocketUrl: 'wss://whaletownend.xinghangee.icu/game', + protocol: 'native-websocket', + path: '/game', + supportedEvents: ['login', 'chat', 'position'], + supportedResponses: [ + 'connected', 'login_success', 'login_error', + 'chat_sent', 'chat_error', 'chat_render', 'error' + ], + authRequired: true, + tokenType: 'JWT', + }; + } +} diff --git a/src/gateway/chat/chat.dto.ts b/src/gateway/chat/chat.dto.ts new file mode 100644 index 0000000..db2b265 --- /dev/null +++ b/src/gateway/chat/chat.dto.ts @@ -0,0 +1,126 @@ +/** + * 聊天网关层 DTO 定义 + * + * 功能描述: + * - 定义聊天相关的数据传输对象 + * - 用于 HTTP 和 WebSocket 请求的数据验证 + * - 提供请求参数的类型约束和校验规则 + * + * 最近修改: + * - 2026-01-14: 代码规范优化 - 清理未使用的导入 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.2 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * 发送聊天消息请求 DTO + */ +export class SendChatMessageDto { + @ApiProperty({ + description: '消息内容', + example: '大家好!我刚进入游戏', + maxLength: 1000 + }) + @IsString() + @IsNotEmpty() + content: string; + + @ApiProperty({ + description: '消息范围', + example: 'local', + enum: ['local', 'global'], + default: 'local' + }) + @IsString() + @IsNotEmpty() + scope: string; + + @ApiPropertyOptional({ + description: '地图ID(可选,用于地图相关消息)', + example: 'whale_port' + }) + @IsOptional() + @IsString() + mapId?: string; +} + +/** + * 获取聊天历史请求 DTO + */ +export class GetChatHistoryDto { + @ApiPropertyOptional({ + description: '地图ID(可选)', + example: 'whale_port' + }) + @IsOptional() + @IsString() + mapId?: string; + + @ApiPropertyOptional({ + description: '消息数量限制', + example: 50, + default: 50, + minimum: 1, + maximum: 100 + }) + @IsOptional() + @IsNumber() + @Type(() => Number) + limit?: number = 50; + + @ApiPropertyOptional({ + description: '偏移量(分页用)', + example: 0, + default: 0, + minimum: 0 + }) + @IsOptional() + @IsNumber() + @Type(() => Number) + offset?: number = 0; +} + +/** + * WebSocket 登录消息 DTO + */ +export class WsLoginDto { + @IsString() + @IsNotEmpty() + token: string; +} + +/** + * WebSocket 聊天消息 DTO + */ +export class WsChatMessageDto { + @IsString() + @IsNotEmpty() + content: string; + + @IsString() + @IsOptional() + scope?: string; +} + +/** + * WebSocket 位置更新 DTO + */ +export class WsPositionUpdateDto { + @IsNumber() + x: number; + + @IsNumber() + y: number; + + @IsString() + @IsNotEmpty() + mapId: string; +} diff --git a/src/gateway/chat/chat.gateway.module.ts b/src/gateway/chat/chat.gateway.module.ts new file mode 100644 index 0000000..1dc8b49 --- /dev/null +++ b/src/gateway/chat/chat.gateway.module.ts @@ -0,0 +1,46 @@ +/** + * 聊天网关模块 + * + * 功能描述: + * - 整合聊天相关的网关层组件 + * - 提供 WebSocket 和 HTTP 协议处理 + * + * 架构层级:Gateway Layer(网关层) + * + * 依赖关系: + * - 依赖 ChatModule(业务层)处理业务逻辑 + * - 依赖 LoginCoreModule 进行 JWT 验证 + * + * 最近修改: + * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.1 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { Module } from '@nestjs/common'; +import { ChatController } from './chat.controller'; +import { ChatWebSocketGateway } from './chat.gateway'; +import { ChatModule } from '../../business/chat/chat.module'; +import { LoginCoreModule } from '../../core/login_core/login_core.module'; + +@Module({ + imports: [ + // 业务层模块 + ChatModule, + // 登录核心模块 - 用于 JWT 验证 + LoginCoreModule, + ], + controllers: [ + ChatController, + ], + providers: [ + ChatWebSocketGateway, + ], + exports: [ + ChatWebSocketGateway, + ], +}) +export class ChatGatewayModule {} diff --git a/src/gateway/chat/chat.gateway.spec.ts b/src/gateway/chat/chat.gateway.spec.ts new file mode 100644 index 0000000..b4e9b01 --- /dev/null +++ b/src/gateway/chat/chat.gateway.spec.ts @@ -0,0 +1,193 @@ +/** + * 聊天 WebSocket 网关单元测试 + * + * 功能描述: + * - 测试 ChatWebSocketGateway 的 WebSocket 连接管理 + * - 验证消息路由和处理逻辑 + * - 测试房间管理和广播功能 + * + * 测试范围: + * - onModuleInit() - 模块初始化 + * - onModuleDestroy() - 模块销毁 + * - getConnectionCount() - 获取连接数 + * - getAuthenticatedConnectionCount() - 获取认证连接数 + * - getMapPlayerCounts() - 获取地图玩家数 + * - getMapPlayers() - 获取地图玩家列表 + * - sendToPlayer() - 单播消息 + * - broadcastToMap() - 地图广播 + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-14 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ChatWebSocketGateway } from './chat.gateway'; +import { ChatService } from '../../business/chat/chat.service'; + +// Mock ws module +jest.mock('ws', () => { + const mockServerInstance = { + on: jest.fn(), + close: jest.fn(), + }; + + const MockServer = jest.fn(() => mockServerInstance); + + return { + Server: MockServer, + OPEN: 1, + __mockServerInstance: mockServerInstance, + }; +}); + +describe('ChatWebSocketGateway', () => { + let gateway: ChatWebSocketGateway; + let mockChatService: jest.Mocked>; + + beforeEach(async () => { + // Reset mocks + jest.clearAllMocks(); + + mockChatService = { + setWebSocketGateway: jest.fn(), + handlePlayerLogin: jest.fn(), + handlePlayerLogout: jest.fn(), + sendChatMessage: jest.fn(), + updatePlayerPosition: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatWebSocketGateway, + { provide: ChatService, useValue: mockChatService }, + ], + }).compile(); + + gateway = module.get(ChatWebSocketGateway); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('onModuleInit', () => { + it('should initialize WebSocket server and set gateway reference', async () => { + await gateway.onModuleInit(); + + expect(mockChatService.setWebSocketGateway).toHaveBeenCalledWith(gateway); + }); + + it('should use default port 3001 when WEBSOCKET_PORT is not set', async () => { + delete process.env.WEBSOCKET_PORT; + + await gateway.onModuleInit(); + + // Verify server was created (mock was called) + const ws = require('ws'); + expect(ws.Server).toHaveBeenCalledWith( + expect.objectContaining({ + port: 3001, + path: '/game', + }) + ); + }); + + it('should use custom port from environment variable', async () => { + process.env.WEBSOCKET_PORT = '4000'; + + // Create new gateway instance to pick up env change + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatWebSocketGateway, + { provide: ChatService, useValue: mockChatService }, + ], + }).compile(); + + const newGateway = module.get(ChatWebSocketGateway); + await newGateway.onModuleInit(); + + const ws = require('ws'); + expect(ws.Server).toHaveBeenCalledWith( + expect.objectContaining({ + port: 4000, + path: '/game', + }) + ); + + delete process.env.WEBSOCKET_PORT; + }); + }); + + describe('onModuleDestroy', () => { + it('should close WebSocket server when it exists', async () => { + await gateway.onModuleInit(); + await gateway.onModuleDestroy(); + + const ws = require('ws'); + expect(ws.__mockServerInstance.close).toHaveBeenCalled(); + }); + + it('should not throw when server does not exist', async () => { + // Don't call onModuleInit, so server is undefined + await expect(gateway.onModuleDestroy()).resolves.not.toThrow(); + }); + }); + + describe('getConnectionCount', () => { + it('should return 0 when no clients connected', () => { + expect(gateway.getConnectionCount()).toBe(0); + }); + }); + + describe('getAuthenticatedConnectionCount', () => { + it('should return 0 when no authenticated clients', () => { + expect(gateway.getAuthenticatedConnectionCount()).toBe(0); + }); + }); + + describe('getMapPlayerCounts', () => { + it('should return empty object when no rooms exist', () => { + expect(gateway.getMapPlayerCounts()).toEqual({}); + }); + }); + + describe('getMapPlayers', () => { + it('should return empty array for non-existent room', () => { + expect(gateway.getMapPlayers('non_existent_map')).toEqual([]); + }); + }); + + describe('sendToPlayer', () => { + it('should not throw when client does not exist', () => { + expect(() => { + gateway.sendToPlayer('non_existent_id', { type: 'test' }); + }).not.toThrow(); + }); + }); + + describe('broadcastToMap', () => { + it('should not throw when room does not exist', () => { + expect(() => { + gateway.broadcastToMap('non_existent_map', { type: 'test' }); + }).not.toThrow(); + }); + + it('should handle excludeId parameter', () => { + expect(() => { + gateway.broadcastToMap('non_existent_map', { type: 'test' }, 'exclude_id'); + }).not.toThrow(); + }); + }); + + describe('IChatWebSocketGateway interface', () => { + it('should implement all interface methods', () => { + expect(typeof gateway.sendToPlayer).toBe('function'); + expect(typeof gateway.broadcastToMap).toBe('function'); + expect(typeof gateway.getConnectionCount).toBe('function'); + expect(typeof gateway.getAuthenticatedConnectionCount).toBe('function'); + expect(typeof gateway.getMapPlayerCounts).toBe('function'); + expect(typeof gateway.getMapPlayers).toBe('function'); + }); + }); +}); diff --git a/src/gateway/chat/chat.gateway.ts b/src/gateway/chat/chat.gateway.ts new file mode 100644 index 0000000..725ea0d --- /dev/null +++ b/src/gateway/chat/chat.gateway.ts @@ -0,0 +1,461 @@ +/** + * 聊天 WebSocket 网关 + * + * 功能描述: + * - 处理 WebSocket 协议连接和消息 + * - 只做协议转换,不包含业务逻辑 + * - 将消息路由到 Business 层处理 + * + * 架构层级:Gateway Layer(网关层) + * + * 职责: + * - WebSocket 连接管理 + * - 消息协议解析 + * - 路由到业务层 + * - 错误转换 + * + * WebSocket 事件: + * - connection: 客户端连接事件 + * - message: 消息接收事件(login/logout/chat/position) + * - close: 客户端断开事件 + * - error: 错误处理事件 + * + * 最近修改: + * - 2026-01-14: 代码规范优化 - 提取常量、替换弃用API (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.2 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import * as WebSocket from 'ws'; +import { ChatService } from '../../business/chat/chat.service'; + +/** WebSocket 服务器默认端口 */ +const DEFAULT_WEBSOCKET_PORT = 3001; + +/** 默认地图 ID */ +const DEFAULT_MAP_ID = 'whale_port'; + +/** + * 扩展的 WebSocket 接口 + */ +interface ExtendedWebSocket extends WebSocket { + id: string; + isAlive?: boolean; + authenticated?: boolean; + userId?: string; + username?: string; + sessionId?: string; + currentMap?: string; +} + +/** + * WebSocket 网关接口 - 供业务层调用 + */ +export interface IChatWebSocketGateway { + sendToPlayer(socketId: string, data: any): void; + broadcastToMap(mapId: string, data: any, excludeId?: string): void; + getConnectionCount(): number; + getAuthenticatedConnectionCount(): number; + getMapPlayerCounts(): Record; + getMapPlayers(mapId: string): string[]; +} + +@Injectable() +/** + * 聊天 WebSocket 网关类 + * + * 职责: + * - 管理 WebSocket 客户端连接 + * - 解析和路由 WebSocket 消息 + * - 管理地图房间和玩家广播 + * + * 主要方法: + * - sendToPlayer() - 向指定玩家发送消息 + * - broadcastToMap() - 向地图内所有玩家广播 + * - getConnectionCount() - 获取连接数统计 + * + * 使用场景: + * - 游戏内实时聊天通信 + * - 玩家位置同步广播 + */ +export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, IChatWebSocketGateway { + private server: WebSocket.Server; + private readonly logger = new Logger(ChatWebSocketGateway.name); + private clients = new Map(); + private mapRooms = new Map>(); + + constructor(private readonly chatService: ChatService) {} + + async onModuleInit() { + const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT; + + this.server = new WebSocket.Server({ + port, + path: '/game' + }); + + this.server.on('connection', (ws: ExtendedWebSocket) => { + ws.id = this.generateClientId(); + ws.isAlive = true; + ws.authenticated = false; + + this.clients.set(ws.id, ws); + this.logger.log(`新的WebSocket连接: ${ws.id}`); + + ws.on('message', (data) => this.handleRawMessage(ws, data)); + ws.on('close', (code, reason) => this.handleClose(ws, code, reason)); + ws.on('error', (error) => this.handleError(ws, error)); + + this.sendMessage(ws, { + type: 'connected', + message: '连接成功', + socketId: ws.id + }); + }); + + // 设置网关引用到业务层 + this.chatService.setWebSocketGateway(this); + this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`); + } + + async onModuleDestroy() { + if (this.server) { + this.server.close(); + this.logger.log('WebSocket服务器已关闭'); + } + } + + /** + * 处理原始消息 - 协议解析 + * + * @param ws WebSocket 连接实例 + * @param data 原始消息数据 + */ + private handleRawMessage(ws: ExtendedWebSocket, data: WebSocket.RawData) { + try { + const message = JSON.parse(data.toString()); + this.routeMessage(ws, message); + } catch (error) { + this.logger.error('解析消息失败', error); + this.sendError(ws, '消息格式错误'); + } + } + + /** + * 消息路由 - 根据类型分发到业务层 + * + * @param ws WebSocket 连接实例 + * @param message 解析后的消息对象 + */ + private async routeMessage(ws: ExtendedWebSocket, message: any) { + const messageType = message.type || message.t; + this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`); + + switch (messageType) { + case 'login': + await this.handleLogin(ws, message); + break; + case 'logout': + await this.handleLogout(ws); + break; + case 'chat': + await this.handleChat(ws, message); + break; + case 'position': + await this.handlePosition(ws, message); + break; + default: + this.logger.warn(`未知消息类型: ${messageType}`); + this.sendError(ws, `未知消息类型: ${messageType}`); + } + } + + /** + * 处理登录 - 协议转换后调用业务层 + * + * @param ws WebSocket 连接实例 + * @param message 登录消息(包含 token) + */ + private async handleLogin(ws: ExtendedWebSocket, message: any) { + if (!message.token) { + this.sendError(ws, 'Token不能为空'); + return; + } + + try { + const result = await this.chatService.handlePlayerLogin({ + socketId: ws.id, + token: message.token + }); + + if (result.success) { + ws.authenticated = true; + ws.userId = result.userId; + ws.username = result.username; + ws.sessionId = result.sessionId; + ws.currentMap = result.currentMap || DEFAULT_MAP_ID; + + this.joinMapRoom(ws.id, ws.currentMap); + + this.sendMessage(ws, { + t: 'login_success', + sessionId: result.sessionId, + userId: result.userId, + username: result.username, + currentMap: ws.currentMap + }); + + this.logger.log(`用户登录成功: ${result.username} (${ws.id})`); + } else { + this.sendMessage(ws, { + t: 'login_error', + message: result.error || '登录失败' + }); + } + } catch (error) { + this.logger.error('登录处理失败', error); + this.sendError(ws, '登录处理失败'); + } + } + + /** + * 处理登出 + * + * @param ws WebSocket 连接实例 + */ + private async handleLogout(ws: ExtendedWebSocket) { + if (!ws.authenticated) { + this.sendError(ws, '用户未登录'); + return; + } + + try { + await this.chatService.handlePlayerLogout(ws.id, 'manual'); + this.cleanupClient(ws); + + this.sendMessage(ws, { + t: 'logout_success', + message: '登出成功' + }); + + ws.close(1000, '用户主动登出'); + } catch (error) { + this.logger.error('登出处理失败', error); + this.sendError(ws, '登出处理失败'); + } + } + + /** + * 处理聊天消息 + * + * @param ws WebSocket 连接实例 + * @param message 聊天消息(包含 content, scope) + */ + private async handleChat(ws: ExtendedWebSocket, message: any) { + if (!ws.authenticated) { + this.sendError(ws, '请先登录'); + return; + } + + if (!message.content) { + this.sendError(ws, '消息内容不能为空'); + return; + } + + try { + const result = await this.chatService.sendChatMessage({ + socketId: ws.id, + content: message.content, + scope: message.scope || 'local' + }); + + if (result.success) { + this.sendMessage(ws, { + t: 'chat_sent', + messageId: result.messageId, + message: '消息发送成功' + }); + } else { + this.sendMessage(ws, { + t: 'chat_error', + message: result.error || '消息发送失败' + }); + } + } catch (error) { + this.logger.error('聊天处理失败', error); + this.sendError(ws, '聊天处理失败'); + } + } + + /** + * 处理位置更新 + * + * @param ws WebSocket 连接实例 + * @param message 位置消息(包含 x, y, mapId) + */ + private async handlePosition(ws: ExtendedWebSocket, message: any) { + if (!ws.authenticated) { + this.sendError(ws, '请先登录'); + return; + } + + try { + // 如果切换地图,更新房间 + if (ws.currentMap !== message.mapId) { + this.leaveMapRoom(ws.id, ws.currentMap); + this.joinMapRoom(ws.id, message.mapId); + ws.currentMap = message.mapId; + } + + await this.chatService.updatePlayerPosition({ + socketId: ws.id, + x: message.x, + y: message.y, + mapId: message.mapId + }); + + // 广播位置更新 + this.broadcastToMap(message.mapId, { + t: 'position_update', + userId: ws.userId, + username: ws.username, + x: message.x, + y: message.y, + mapId: message.mapId + }, ws.id); + + } catch (error) { + this.logger.error('位置更新处理失败', error); + this.sendError(ws, '位置更新处理失败'); + } + } + + /** + * 处理连接关闭 + * + * @param ws WebSocket 连接实例 + * @param code 关闭状态码 + * @param reason 关闭原因 + */ + private handleClose(ws: ExtendedWebSocket, code: number, reason: Buffer) { + this.logger.log(`WebSocket连接关闭: ${ws.id}`, { code, reason: reason?.toString() }); + + let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect'; + if (code === 1000) logoutReason = 'manual'; + + this.cleanupClient(ws, logoutReason); + } + + /** + * 处理错误 + * + * @param ws WebSocket 连接实例 + * @param error 错误对象 + */ + private handleError(ws: ExtendedWebSocket, error: Error) { + this.logger.error(`WebSocket错误: ${ws.id}`, error); + } + + // ========== IChatWebSocketGateway 接口实现 ========== + + public sendToPlayer(socketId: string, data: any): void { + const client = this.clients.get(socketId); + if (client && client.readyState === WebSocket.OPEN) { + this.sendMessage(client, data); + } + } + + public broadcastToMap(mapId: string, data: any, excludeId?: string): void { + const room = this.mapRooms.get(mapId); + if (!room) return; + + room.forEach(clientId => { + if (clientId !== excludeId) { + const client = this.clients.get(clientId); + if (client && client.authenticated && client.readyState === WebSocket.OPEN) { + this.sendMessage(client, data); + } + } + }); + } + + public getConnectionCount(): number { + return this.clients.size; + } + + public getAuthenticatedConnectionCount(): number { + return Array.from(this.clients.values()).filter(c => c.authenticated).length; + } + + public getMapPlayerCounts(): Record { + const counts: Record = {}; + this.mapRooms.forEach((clients, mapId) => { + counts[mapId] = clients.size; + }); + return counts; + } + + public getMapPlayers(mapId: string): string[] { + const room = this.mapRooms.get(mapId); + if (!room) return []; + + const players: string[] = []; + room.forEach(clientId => { + const client = this.clients.get(clientId); + if (client?.authenticated && client.username) { + players.push(client.username); + } + }); + return players; + } + + // ========== 私有辅助方法 ========== + + private sendMessage(ws: ExtendedWebSocket, data: any) { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } + } + + private sendError(ws: ExtendedWebSocket, message: string) { + this.sendMessage(ws, { type: 'error', message }); + } + + private joinMapRoom(clientId: string, mapId: string) { + if (!this.mapRooms.has(mapId)) { + this.mapRooms.set(mapId, new Set()); + } + this.mapRooms.get(mapId).add(clientId); + } + + private leaveMapRoom(clientId: string, mapId: string) { + const room = this.mapRooms.get(mapId); + if (room) { + room.delete(clientId); + if (room.size === 0) this.mapRooms.delete(mapId); + } + } + + private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') { + try { + if (ws.authenticated && ws.id) { + await this.chatService.handlePlayerLogout(ws.id, reason); + } + if (ws.currentMap) { + this.leaveMapRoom(ws.id, ws.currentMap); + } + this.clients.delete(ws.id); + } catch (error) { + this.logger.error(`清理客户端失败: ${ws.id}`, error); + } + } + + private generateClientId(): string { + return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + } +} diff --git a/src/gateway/chat/chat_response.dto.ts b/src/gateway/chat/chat_response.dto.ts new file mode 100644 index 0000000..f2ce502 --- /dev/null +++ b/src/gateway/chat/chat_response.dto.ts @@ -0,0 +1,135 @@ +/** + * 聊天网关层响应 DTO 定义 + * + * 功能描述: + * - 定义聊天相关的响应数据传输对象 + * - 用于 HTTP 和 WebSocket 响应的数据结构 + * - 提供 Swagger API 文档的响应类型定义 + * + * 最近修改: + * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.1 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +/** + * 聊天消息响应 DTO + */ +export class ChatMessageResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiPropertyOptional({ description: '消息ID', example: 'game_1234567890_user1' }) + messageId?: string; + + @ApiPropertyOptional({ description: '响应消息', example: '消息发送成功' }) + message?: string; + + @ApiPropertyOptional({ description: '错误信息', example: '消息内容不能为空' }) + error?: string; +} + +/** + * 聊天消息信息 DTO + */ +export class ChatMessageInfoDto { + @ApiProperty({ description: '消息ID', example: 12345 }) + id: number; + + @ApiProperty({ description: '发送者用户名', example: 'Player_123' }) + sender: string; + + @ApiProperty({ description: '消息内容', example: '大家好!' }) + content: string; + + @ApiProperty({ description: '消息范围', example: 'local' }) + scope: string; + + @ApiProperty({ description: '地图ID', example: 'whale_port' }) + mapId: string; + + @ApiProperty({ description: '发送时间', example: '2026-01-14T14:30:00.000Z' }) + timestamp: string; + + @ApiProperty({ description: 'Zulip Stream 名称', example: 'Whale Port' }) + streamName: string; + + @ApiProperty({ description: 'Zulip Topic 名称', example: 'Game Chat' }) + topicName: string; +} + +/** + * 聊天历史响应 DTO + */ +export class ChatHistoryResponseDto { + @ApiProperty({ description: '是否成功', example: true }) + success: boolean; + + @ApiProperty({ description: '消息列表', type: [ChatMessageInfoDto] }) + @ValidateNested({ each: true }) + @Type(() => ChatMessageInfoDto) + messages: ChatMessageInfoDto[]; + + @ApiProperty({ description: '总消息数', example: 150 }) + total: number; + + @ApiProperty({ description: '当前页消息数', example: 50 }) + count: number; + + @ApiPropertyOptional({ description: '错误信息', example: '获取消息历史失败' }) + error?: string; +} + +/** + * WebSocket 连接状态 DTO + */ +export class WebSocketStatusDto { + @ApiProperty({ description: '总连接数', example: 25 }) + totalConnections: number; + + @ApiProperty({ description: '已认证连接数', example: 20 }) + authenticatedConnections: number; + + @ApiProperty({ description: '活跃会话数', example: 18 }) + activeSessions: number; + + @ApiProperty({ description: '各地图在线人数' }) + mapPlayerCounts: Record; +} + +/** + * 系统状态响应 DTO + */ +export class SystemStatusResponseDto { + @ApiProperty({ description: 'WebSocket 状态', type: WebSocketStatusDto }) + @ValidateNested() + @Type(() => WebSocketStatusDto) + websocket: WebSocketStatusDto; + + @ApiProperty({ description: 'Zulip 集成状态' }) + zulip: { + serverConnected: boolean; + serverVersion: string; + botAccountActive: boolean; + availableStreams: number; + gameStreams: string[]; + recentMessageCount: number; + }; + + @ApiProperty({ description: '系统运行时间(秒)', example: 86400 }) + uptime: number; + + @ApiProperty({ description: '内存使用情况' }) + memory: { + used: string; + total: string; + percentage: number; + }; +}