feat(gateway/chat): 新增聊天网关模块

范围:src/gateway/chat/
- 新增 ChatWebSocketGateway WebSocket 网关,处理实时聊天通信
- 新增 ChatController HTTP 控制器,提供聊天历史和系统状态接口
- 新增 ChatGatewayModule 模块配置,整合网关层组件
- 新增请求/响应 DTO 定义,提供数据验证和类型约束
- 新增完整的单元测试覆盖
- 新增模块 README 文档,包含接口说明、核心特性和风险评估
This commit is contained in:
moyin
2026-01-14 19:11:25 +08:00
parent 3f3c29354e
commit 5bcf3cb678
8 changed files with 1702 additions and 0 deletions

333
src/gateway/chat/README.md Normal file
View File

@@ -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<string, number>
获取各地图的在线玩家数量统计,用于负载监控。
#### getMapPlayers(mapId: string): string[]
获取指定地图内的所有玩家用户名列表,用于房间成员查询。
### ChatController 类
#### getChatHistory(query: GetChatHistoryDto): Promise<ChatHistoryResponseDto>
获取聊天历史记录,支持按地图筛选和分页查询。
#### getSystemStatus(): Promise<SystemStatusResponseDto>
获取聊天系统状态,包括 WebSocket 连接数、Zulip 状态、内存使用等。
#### getWebSocketInfo(): Promise<object>
获取 WebSocket 连接配置信息,包括连接地址、支持的事件类型等。
#### sendMessage(dto: SendChatMessageDto): Promise<ChatMessageResponseDto>
通过 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 连接需要先登录才能发送聊天消息

View File

@@ -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<Partial<ChatService>>;
let mockWebSocketGateway: jest.Mocked<Partial<ChatWebSocketGateway>>;
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>(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');
});
});
});

View File

@@ -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<ChatMessageResponseDto> {
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<ChatHistoryResponseDto> {
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<SystemStatusResponseDto> {
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',
};
}
}

View File

@@ -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;
}

View File

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

View File

@@ -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<Partial<ChatService>>;
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>(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>(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');
});
});
});

View File

@@ -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<string, number>;
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<string, ExtendedWebSocket>();
private mapRooms = new Map<string, Set<string>>();
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<string, number> {
const counts: Record<string, number> = {};
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)}`;
}
}

View File

@@ -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<string, number>;
}
/**
* 系统状态响应 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;
};
}