diff --git a/src/business/zulip/README.md b/src/business/zulip/README.md index b7def06..a5bbd8e 100644 --- a/src/business/zulip/README.md +++ b/src/business/zulip/README.md @@ -1,318 +1,211 @@ -# Zulip 游戏集成业务模块 +# Zulip 业务模块 -Zulip 是游戏与Zulip社群平台的集成业务模块,提供完整的实时聊天、会话管理、消息过滤和WebSocket通信功能,实现游戏内聊天与Zulip社群的双向同步,支持基于位置的聊天上下文管理和业务规则驱动的消息过滤控制。 +Zulip业务模块是游戏服务器与Zulip聊天系统集成的核心业务层,负责处理Zulip账号关联管理和事件处理的业务逻辑,实现游戏内聊天消息与Zulip平台的双向同步。 -## 玩家登录和会话管理 +## 对外提供的接口 -### handlePlayerLogin() -验证游戏Token,创建Zulip客户端,建立会话映射关系,支持JWT认证和API Key获取。 +### ZulipAccountsBusinessService -### handlePlayerLogout() -清理玩家会话,注销Zulip事件队列,释放相关资源,确保连接正常断开。 +#### create(createDto: CreateZulipAccountDto): Promise +创建游戏用户与Zulip账号的关联关系,支持数据验证和唯一性检查。 -### getSession() -根据socketId获取会话信息,并更新最后活动时间,支持会话状态查询。 +#### findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise +根据游戏用户ID查找对应的Zulip账号关联信息,支持缓存优化。 -### getSocketsInMap() -获取指定地图中所有在线玩家的Socket ID列表,用于消息分发和空间过滤。 +#### getStatusStatistics(): Promise +获取所有Zulip账号关联的状态统计信息,包括活跃、非活跃、暂停、错误状态的数量。 -## 消息发送和处理 +### ZulipEventProcessorService -### sendChatMessage() -处理游戏客户端发送的聊天消息,转发到对应的Zulip Stream/Topic,包含内容过滤和权限验证。 +#### startEventProcessing(): Promise +启动Zulip事件处理循环,监听所有活跃的事件队列。 -### processZulipMessage() -处理Zulip事件队列推送的消息,转换格式后发送给相关的游戏客户端,实现双向通信。 +#### stopEventProcessing(): Promise +停止事件处理循环,清理所有事件队列资源。 -### updatePlayerPosition() -更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入,支持地图切换。 +#### registerEventQueue(queueId: string, userId: string, lastEventId?: number): Promise +注册新的Zulip事件队列到处理列表中。 -## WebSocket网关功能 +#### unregisterEventQueue(queueId: string): Promise +从处理列表中注销指定的事件队列。 -### handleConnection() -处理游戏客户端WebSocket连接建立,记录连接信息并初始化连接状态。 +#### setMessageDistributor(distributor: MessageDistributor): void +设置消息分发器,用于向游戏客户端发送消息。 -### handleDisconnect() -处理游戏客户端连接断开,清理相关资源并执行登出逻辑。 +#### processMessageEvent(event: ZulipEvent, senderUserId: string): Promise +处理Zulip消息事件,转换格式后分发给相关的游戏客户端。 -### handleLogin() -处理登录消息,验证Token并建立会话,返回登录结果和用户信息。 +#### convertMessageFormat(zulipMessage: ZulipMessage, streamName?: string): Promise +将Zulip消息转换为游戏协议格式(chat_render)。 -### handleChat() -处理聊天消息,验证用户认证状态和消息格式,调用业务服务发送消息。 +#### determineTargetPlayers(message: ZulipMessage, streamName: string, senderUserId: string): Promise +根据消息的Stream确定应该接收消息的玩家(空间过滤)。 -### sendChatRender() -向指定客户端发送聊天渲染消息,用于显示气泡或聊天框。 +#### distributeMessage(gameMessage: GameMessage, targetPlayers: string[]): Promise +通过WebSocket将消息发送给目标客户端。 -### broadcastToMap() -向指定地图的所有客户端广播消息,支持区域性消息分发。 +#### broadcastToMap(mapId: string, gameMessage: GameMessage): Promise +向指定地图区域内的所有在线玩家广播消息。 -## 会话管理功能 - -### createSession() -创建会话并绑定Socket_ID与Zulip_Queue_ID,建立WebSocket连接与Zulip队列的映射关系。 - -### injectContext() -上下文注入,根据玩家位置确定消息应该发送到的Zulip Stream和Topic。 - -### destroySession() -清理玩家会话数据,从地图玩家列表中移除,释放相关资源。 - -### cleanupExpiredSessions() -定时清理超时的会话数据和相关资源,返回需要注销的Zulip队列ID列表。 - -## 消息过滤和安全 - -### validateMessage() -对消息进行综合验证,包括内容过滤、频率限制和权限验证。 - -### filterContent() -检查消息内容是否包含敏感词,进行内容过滤和替换。 - -### checkRateLimit() -检查用户是否超过消息发送频率限制,防止刷屏。 - -### validatePermission() -验证用户是否有权限向目标Stream发送消息,防止位置欺诈。 - -### logViolation() -记录用户的违规行为,用于监控和分析。 - -## WebSocket事件接口 - -### 'login' -客户端登录认证,建立游戏会话并获取Zulip访问权限。 -- 输入: `{ 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: string }` - -### 'chat' -发送聊天消息,支持本地和全局范围,自动同步到Zulip。 -- 输入: `{ type: 'chat', content: string, scope?: 'local'|'global' }` -- 输出: `{ t: 'chat_sent', messageId: string, message: string }` 或 `{ 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', userId: string, username: string, content: string, timestamp: number, mapId: string }` - -### 'connected' -连接建立确认事件,服务器主动发送连接状态。 -- 输入: 无(服务器推送) -- 输出: `{ type: 'connected', message: string, socketId: string }` - -### 'error' -错误事件通知,用于处理各种异常情况和错误信息。 -- 输入: 无(服务器推送) -- 输出: `{ type: 'error', message: string }` - -## REST API接口 - -### sendMessage() -通过REST API发送聊天消息到Zulip(推荐使用WebSocket接口)。 - -### getChatHistory() -获取指定地图或全局的聊天历史记录,支持分页查询。 - -### getSystemStatus() -获取WebSocket连接状态、Zulip集成状态等系统信息。 - -### getWebSocketInfo() -获取WebSocket连接的详细信息,包括连接地址、协议等。 +#### getProcessingStats(): EventProcessingStats +获取事件处理的统计信息,包括活跃队列数、处理事件数等。 ## 使用的项目内部依赖 -### ZulipCoreModule (来自 core/zulip_core) -提供Zulip核心技术服务,包括客户端池管理、配置管理和事件处理等底层技术实现。 +### ISessionQueryService (来自 core/session_core) +会话查询接口,用于获取地图中的在线玩家和会话信息,实现空间过滤功能。 -### LoginCoreModule (来自 core/login_core) -提供用户认证和Token验证服务,支持JWT令牌验证和用户信息获取。 +### IZulipConfigService (来自 core/zulip_core) +Zulip配置服务接口,用于获取Stream与地图的映射关系。 -### RedisModule (来自 core/redis) -提供会话状态缓存和数据存储服务,支持会话持久化和快速查询。 +### IZulipClientPoolService (来自 core/zulip_core) +Zulip客户端池服务接口,用于获取用户的Zulip客户端实例。 -### LoggerModule (来自 core/utils/logger) -提供统一的日志记录服务,支持结构化日志和性能监控。 +### ZulipAccountsRepository (来自 core/db/zulip_accounts) +Zulip账号数据仓库,提供账号关联的CRUD操作。 -### ZulipAccountsModule (来自 core/db/zulip_accounts) -提供Zulip账号关联管理功能,支持用户与Zulip账号的绑定关系。 +### AppLoggerService (来自 core/utils/logger) +日志服务,用于记录业务操作和系统事件。 -### AuthModule (来自 business/auth) -提供JWT验证和用户认证服务,支持用户身份验证和权限控制。 +### Cache (来自 @nestjs/cache-manager) +缓存管理器,用于缓存账号查询结果和统计数据,提升查询性能。 -### IZulipClientPoolService (来自 core/zulip_core/interfaces) -Zulip客户端池服务接口,用于管理用户专用的Zulip客户端实例。 - -### IZulipConfigService (来自 core/zulip_core/interfaces) -Zulip配置服务接口,用于获取地图到Stream的映射关系和配置信息。 - -### ApiKeySecurityService (来自 core/zulip_core/services) -API密钥安全服务,用于获取和管理用户的Zulip API Key。 - -### IRedisService (来自 core/redis) -Redis服务接口,用于会话数据存储、频率限制和违规记录管理。 - -### SendChatMessageDto (本模块) -发送聊天消息的数据传输对象,定义消息内容、范围和地图ID等字段。 - -### ChatMessageResponseDto (本模块) -聊天消息响应的数据传输对象,包含成功状态、消息ID和错误信息。 - -### SystemStatusResponseDto (本模块) -系统状态响应的数据传输对象,包含WebSocket状态、Zulip集成状态和系统信息。 +### CreateZulipAccountDto, ZulipAccountResponseDto (来自 core/db/zulip_accounts) +数据传输对象,定义账号创建和响应的数据结构。 ## 核心特性 -### 双向通信支持 -- WebSocket实时通信:支持游戏客户端与服务器的实时双向通信 -- Zulip集成同步:实现游戏内聊天与Zulip社群的双向消息同步 -- 事件驱动架构:基于事件队列处理Zulip消息推送和游戏事件 +### 事件队列轮询机制 +- 支持多用户并发事件队列管理 +- 2秒轮询间隔,非阻塞模式获取事件 +- 自动处理队列错误和重连机制 +- 支持队列的动态注册和注销 -### 会话状态管理 -- Redis持久化存储:会话数据存储在Redis中,支持服务重启后状态恢复 -- 自动过期清理:定时清理超时会话,释放系统资源 -- 多地图支持:支持玩家在不同地图间切换,自动更新地图玩家列表 +### 消息格式转换 +- Zulip消息到游戏协议(chat_render)的自动转换 +- Markdown格式移除,保留纯文本内容 +- HTML标签清理和实体解码 +- 消息长度限制(200字符)和截断处理 -### 消息过滤和安全 -- 敏感词过滤:支持block和replace两种级别的敏感词处理 -- 频率限制控制:防止用户发送消息过于频繁导致刷屏 -- 位置权限验证:防止用户向不匹配位置的Stream发送消息 -- 违规行为记录:记录和统计用户违规行为,支持监控和分析 +### 空间过滤机制 +- 根据Zulip Stream确定对应的游戏地图 +- 从SessionManager获取地图内的在线玩家 +- 自动排除消息发送者,避免收到自己的消息 +- 支持区域广播功能 -### 业务规则引擎 -- 上下文注入机制:根据玩家位置自动确定消息的目标Stream和Topic -- 动态配置管理:支持地图到Stream映射关系的动态配置和热重载 -- 权限分级控制:支持不同用户角色的权限控制和消息发送限制 +### 缓存优化 +- 账号查询结果缓存(5分钟TTL) +- 统计数据缓存(1分钟TTL) +- 自动缓存失效和更新机制 +- 缓存键前缀隔离 + +### 性能监控 +- 操作耗时记录和日志输出 +- 事件处理统计(处理事件数、消息数) +- 队列状态监控(活跃队列数、总队列数) +- 最后事件时间追踪 ## 潜在风险 -### 会话数据丢失 -- Redis服务故障可能导致会话数据丢失,影响用户体验 -- 建议配置Redis主从复制和持久化策略 -- 实现会话数据的定期备份和恢复机制 +### 事件队列连接风险 +- Zulip服务器不可用时事件队列无法获取 +- 队列ID过期导致BAD_EVENT_QUEUE_ID错误 +- 网络不稳定时轮询失败 +- 缓解措施:自动禁用错误队列、支持队列重新激活、错误日志记录 -### 消息同步延迟 -- Zulip服务器网络延迟可能影响消息同步实时性 -- 大量并发消息可能导致事件队列处理延迟 -- 建议监控消息处理延迟并设置合理的超时机制 +### 消息分发延迟风险 +- 大量并发消息可能导致分发延迟 +- WebSocket连接断开时消息丢失 +- 目标玩家列表过大时性能下降 +- 缓解措施:异步分发、连接状态检查、分批发送 -### 频率限制绕过 -- 恶意用户可能通过多个账号绕过频率限制 -- IP级别的频率限制可能影响正常用户 -- 建议结合用户行为分析和动态调整限制策略 +### 缓存一致性风险 +- 缓存数据与数据库不一致 +- 缓存清理失败导致脏数据 +- 高并发下缓存穿透 +- 缓解措施:写操作后主动清理缓存、缓存失败降级查询、合理设置TTL -### 敏感词过滤失效 -- 新型敏感词和变体可能绕过现有过滤规则 -- 过度严格的过滤可能影响正常交流 -- 建议定期更新敏感词库并优化过滤算法 +### 内存泄漏风险 +- 事件队列未正确注销导致内存累积 +- 长时间运行后统计数据累积 +- 缓解措施:模块销毁时清理资源、提供统计重置接口 -### WebSocket连接稳定性 -- 网络不稳定可能导致WebSocket连接频繁断开重连 -- 大量连接可能消耗过多服务器资源 -- 建议实现连接池管理和自动重连机制 +## 架构定位 -### 位置验证绕过 -- 客户端修改可能绕过位置验证机制 -- 服务端位置验证逻辑需要持续完善 -- 建议结合多种验证手段和异常行为检测 +- **层级**: Business层(业务层) +- **职责**: 业务逻辑处理、服务协调 +- **依赖**: Core层的ZulipCoreModule、ZulipAccountsModule等 -## 使用示例 +## 文件结构 -### WebSocket 客户端连接 -```typescript -// 建立WebSocket连接 -const socket = io('ws://localhost:3000/zulip'); - -// 监听连接事件 -socket.on('connect', () => { - console.log('Connected to Zulip WebSocket'); -}); - -// 发送登录消息 -socket.emit('login', { - token: 'your-jwt-token' -}); - -// 发送聊天消息 -socket.emit('chat', { - content: '大家好!', - scope: 'local', - mapId: 'whale_port' -}); - -// 监听聊天消息 -socket.on('chat_render', (data) => { - console.log('收到消息:', data); -}); +``` +src/business/zulip/ +├── services/ +│ ├── zulip_accounts_business.service.ts # Zulip账号业务服务 +│ ├── zulip_accounts_business.service.spec.ts +│ ├── zulip_event_processor.service.ts # Zulip事件处理服务 +│ └── zulip_event_processor.service.spec.ts +├── zulip.module.ts # 业务模块定义 +├── zulip.module.spec.ts # 模块测试 +└── README.md # 本文档 ``` -### REST API 调用 -```typescript -// 发送聊天消息 -const response = await fetch('/api/zulip/send-message', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer your-jwt-token' - }, - body: JSON.stringify({ - content: '测试消息', - scope: 'global', - mapId: 'whale_port' - }) -}); +## 依赖关系 -// 获取聊天历史 -const history = await fetch('/api/zulip/chat-history?mapId=whale_port&limit=50'); -const messages = await history.json(); - -// 获取系统状态 -const status = await fetch('/api/zulip/system-status'); -const systemInfo = await status.json(); +``` +ZulipModule (Business层) + ├─ imports: ZulipCoreModule (Core层) + ├─ imports: ZulipAccountsModule (Core层) + ├─ imports: RedisModule (Core层) + ├─ imports: LoggerModule (Core层) + ├─ imports: LoginCoreModule (Core层) + ├─ imports: AuthModule (Business层) + ├─ imports: ChatModule (Business层) + ├─ providers: [ZulipEventProcessorService, ZulipAccountsBusinessService] + └─ exports: [ZulipEventProcessorService, ZulipAccountsBusinessService, DynamicConfigManagerService] ``` -### 服务集成示例 -```typescript -@Injectable() -export class GameChatService { - constructor( - private readonly zulipService: ZulipService, - private readonly sessionManager: SessionManagerService - ) {} +## 架构规范 - async handlePlayerMessage(playerId: string, message: string) { - // 获取玩家会话 - const session = await this.sessionManager.getSession(playerId); - - // 发送消息到Zulip - const result = await this.zulipService.sendChatMessage({ - gameUserId: playerId, - content: message, - scope: 'local', - mapId: session.mapId - }); - - return result; - } -} -``` +### Business层职责 +- 业务逻辑实现 +- 服务协调和编排 +- 业务规则验证 +- 调用Core层服务 -## 版本信息 -- **版本**: 1.3.0 -- **作者**: angjustinl -- **创建时间**: 2025-12-20 -- **最后修改**: 2026-01-12 +### Business层禁止 +- 包含HTTP协议处理(Controller应在Gateway层) +- 直接访问数据库(应通过Core层Repository) +- 包含技术实现细节 -## 最近修改记录 -- 2026-01-12: 功能新增 - 添加完整的WebSocket事件接口文档,包含所有事件的输入输出格式说明 (修改者: moyin) -- 2026-01-07: 功能修改 - 更新业务逻辑和接口描述 (修改者: angjustinl) -- 2025-12-20: 功能新增 - 创建Zulip游戏集成业务模块文档 (修改者: angjustinl) \ No newline at end of file +## 迁移说明 + +### 2026-01-14 架构优化 + +**Controller迁移到Gateway层** + +所有Controller已从本模块迁移到 `src/gateway/zulip/`: +- `DynamicConfigController` -> `src/gateway/zulip/dynamic_config.controller.ts` +- `WebSocketDocsController` -> `src/gateway/zulip/websocket_docs.controller.ts` +- `WebSocketOpenApiController` -> `src/gateway/zulip/websocket_openapi.controller.ts` +- `WebSocketTestController` -> `src/gateway/zulip/websocket_test.controller.ts` +- `ZulipAccountsController` -> `src/gateway/zulip/zulip_accounts.controller.ts` + +**原因**:符合四层架构规范,Controller属于Gateway层(HTTP协议处理),不应在Business层。 + +## 相关文档 + +- [Gateway层Zulip模块](../../gateway/zulip/README.md) +- [架构文档](../../../docs/ARCHITECTURE.md) +- [开发指南](../../../docs/development/backend_development_guide.md) + +## 最近更新 + +- 2026-01-14: 功能文档完善 - 补充对外接口、内部依赖、核心特性、潜在风险章节 (moyin) +- 2026-01-14: 架构优化 - Controller迁移到Gateway层 (moyin) +- 2026-01-14: 聊天功能迁移到business/chat模块 (moyin) + +## 维护者 + +- angjustinl +- moyin diff --git a/src/business/zulip/chat.controller.spec.ts b/src/business/zulip/chat.controller.spec.ts deleted file mode 100644 index 87f0374..0000000 --- a/src/business/zulip/chat.controller.spec.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * 聊天控制器测试 - * - * 功能描述: - * - 测试聊天消息发送功能 - * - 验证消息过滤和验证逻辑 - * - 测试错误处理和异常情况 - * - 验证WebSocket消息广播功能 - * - * 测试范围: - * - 消息发送API测试 - * - 参数验证测试 - * - 错误处理测试 - * - 业务逻辑验证 - * - * 最近修改: - * - 2026-01-12: Bug修复 - 修复测试用例中的方法名和DTO结构 (修改者: moyin) - * - 2026-01-12: 代码规范优化 - 创建测试文件,确保控制器功能的测试覆盖 (修改者: moyin) - * - * @author moyin - * @version 1.0.1 - * @since 2026-01-12 - * @lastModified 2026-01-12 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { HttpException, HttpStatus } from '@nestjs/common'; -import { ChatController } from './chat.controller'; -import { ZulipService } from './zulip.service'; -import { MessageFilterService } from './services/message_filter.service'; -import { CleanWebSocketGateway } from './clean_websocket.gateway'; -import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; - -// Mock JwtAuthGuard -const mockJwtAuthGuard = { - canActivate: jest.fn(() => true), -}; - -describe('ChatController', () => { - let controller: ChatController; - let zulipService: jest.Mocked; - let messageFilterService: jest.Mocked; - let websocketGateway: jest.Mocked; - - beforeEach(async () => { - const mockZulipService = { - sendChatMessage: jest.fn(), - }; - - const mockMessageFilterService = { - validateMessage: jest.fn(), - }; - - const mockWebSocketGateway = { - broadcastToRoom: jest.fn(), - getActiveConnections: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [ChatController], - providers: [ - { - provide: ZulipService, - useValue: mockZulipService, - }, - { - provide: MessageFilterService, - useValue: mockMessageFilterService, - }, - { - provide: CleanWebSocketGateway, - useValue: mockWebSocketGateway, - }, - { - provide: JwtAuthGuard, - useValue: mockJwtAuthGuard, - }, - ], - }) - .overrideGuard(JwtAuthGuard) - .useValue(mockJwtAuthGuard) - .compile(); - - controller = module.get(ChatController); - zulipService = module.get(ZulipService); - messageFilterService = module.get(MessageFilterService); - websocketGateway = module.get(CleanWebSocketGateway); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); - - describe('sendMessage', () => { - const validMessageDto = { - content: 'Hello, world!', - stream: 'general', - topic: 'chat', - userId: 'user123', - scope: 'local', - }; - - it('should reject REST API message sending and suggest WebSocket', async () => { - // Act & Assert - await expect(controller.sendMessage(validMessageDto)).rejects.toThrow( - new HttpException( - '聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletownend.xinghangee.icu', - HttpStatus.BAD_REQUEST, - ) - ); - }); - - it('should log the REST API request attempt', async () => { - // Arrange - const loggerSpy = jest.spyOn(controller['logger'], 'log'); - - // Act - try { - await controller.sendMessage(validMessageDto); - } catch (error) { - // Expected to throw - } - - // Assert - expect(loggerSpy).toHaveBeenCalledWith('收到REST API聊天消息发送请求', { - operation: 'sendMessage', - content: validMessageDto.content.substring(0, 50), - scope: validMessageDto.scope, - timestamp: expect.any(String), - }); - }); - - it('should handle different message content lengths', async () => { - // Arrange - const longMessageDto = { - ...validMessageDto, - content: 'a'.repeat(100), // Long message - }; - - // Act & Assert - await expect(controller.sendMessage(longMessageDto)).rejects.toThrow(HttpException); - }); - - it('should handle empty message content', async () => { - // Arrange - const emptyMessageDto = { ...validMessageDto, content: '' }; - - // Act & Assert - await expect(controller.sendMessage(emptyMessageDto)).rejects.toThrow(HttpException); - }); - }); - - describe('Error Handling', () => { - it('should always throw HttpException for REST API requests', async () => { - // Arrange - const validMessageDto = { - content: 'Hello, world!', - stream: 'general', - topic: 'chat', - userId: 'user123', - scope: 'local', - }; - - // Act & Assert - await expect(controller.sendMessage(validMessageDto)).rejects.toThrow(HttpException); - }); - - it('should log error when REST API is used', async () => { - // Arrange - const validMessageDto = { - content: 'Hello, world!', - stream: 'general', - topic: 'chat', - userId: 'user123', - scope: 'local', - }; - - const loggerSpy = jest.spyOn(controller['logger'], 'error'); - - // Act - try { - await controller.sendMessage(validMessageDto); - } catch (error) { - // Expected to throw - } - - // Assert - expect(loggerSpy).toHaveBeenCalledWith('REST API消息发送失败', { - operation: 'sendMessage', - error: expect.any(String), - timestamp: expect.any(String), - }); - }); - }); -}); \ No newline at end of file diff --git a/src/business/zulip/chat.controller.ts b/src/business/zulip/chat.controller.ts deleted file mode 100644 index a352751..0000000 --- a/src/business/zulip/chat.controller.ts +++ /dev/null @@ -1,383 +0,0 @@ -/** - * 聊天相关的 REST API 控制器 - * - * 功能描述: - * - 提供聊天消息的 REST API 接口 - * - 获取聊天历史记录 - * - 查看系统状态和统计信息 - * - 管理 WebSocket 连接状态 - * - * 职责分离: - * - REST接口:提供HTTP方式的聊天功能访问 - * - 状态查询:提供系统运行状态和统计信息 - * - 文档支持:提供WebSocket API的使用文档 - * - 监控支持:提供连接数和性能监控接口 - * - * 最近修改: - * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) - * - * @author angjustinl - * @version 1.0.1 - * @since 2025-01-07 - * @lastModified 2026-01-07 - */ - -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 '../../gateway/auth/jwt_auth.guard'; -import { ZulipService } from './zulip.service'; -import { CleanWebSocketGateway } from './clean_websocket.gateway'; -import { - SendChatMessageDto, - ChatMessageResponseDto, - GetChatHistoryDto, - ChatHistoryResponseDto, - SystemStatusResponseDto, -} from './chat.dto'; - -@ApiTags('chat') -@Controller('chat') -export class ChatController { - private readonly logger = new Logger(ChatController.name); - - constructor( - private readonly zulipService: ZulipService, - private readonly websocketGateway: CleanWebSocketGateway, - ) {} - - /** - * 发送聊天消息(REST API 方式) - * - * 注意:这是 WebSocket 消息发送的 REST API 替代方案 - * 推荐使用 WebSocket 接口以获得更好的实时性 - */ - @Post('send') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: '发送聊天消息', - description: '通过 REST API 发送聊天消息到 Zulip。注意:推荐使用 WebSocket 接口以获得更好的实时性。' - }) - @ApiResponse({ - status: 200, - description: '消息发送成功', - type: ChatMessageResponseDto, - }) - @ApiResponse({ - status: 400, - description: '请求参数错误', - }) - @ApiResponse({ - status: 401, - description: '未授权访问', - }) - @ApiResponse({ - status: 500, - description: '服务器内部错误', - }) - async sendMessage( - @Body() sendMessageDto: SendChatMessageDto, - ): Promise { - this.logger.log('收到REST API聊天消息发送请求', { - operation: 'sendMessage', - content: sendMessageDto.content.substring(0, 50), - scope: sendMessageDto.scope, - timestamp: new Date().toISOString(), - }); - - try { - // 注意:这里需要一个有效的 socketId,但 REST API 没有 WebSocket 连接 - // 这是一个限制,实际使用中应该通过 WebSocket 发送消息 - throw new HttpException( - '聊天消息发送需要通过 WebSocket 连接。请使用 WebSocket 接口:wss://whaletownend.xinghangee.icu', - HttpStatus.BAD_REQUEST, - ); - - } catch (error) { - const err = error as Error; - this.logger.error('REST API消息发送失败', { - operation: 'sendMessage', - error: err.message, - timestamp: new Date().toISOString(), - }); - - if (error instanceof HttpException) { - throw error; - } - - throw new HttpException( - '消息发送失败,请稍后重试', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - - /** - * 获取聊天历史记录 - */ - @Get('history') - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ - summary: '获取聊天历史记录', - description: '获取指定地图或全局的聊天历史记录' - }) - @ApiQuery({ - name: 'mapId', - required: false, - description: '地图ID,不指定则获取全局消息', - example: 'whale_port' - }) - @ApiQuery({ - name: 'limit', - required: false, - description: '消息数量限制', - example: 50 - }) - @ApiQuery({ - name: 'offset', - required: false, - description: '偏移量(分页用)', - example: 0 - }) - @ApiResponse({ - status: 200, - description: '获取聊天历史成功', - type: ChatHistoryResponseDto, - }) - @ApiResponse({ - status: 401, - description: '未授权访问', - }) - @ApiResponse({ - status: 500, - description: '服务器内部错误', - }) - async getChatHistory( - @Query() query: GetChatHistoryDto, - ): Promise { - this.logger.log('获取聊天历史记录', { - operation: 'getChatHistory', - mapId: query.mapId, - limit: query.limit, - offset: query.offset, - timestamp: new Date().toISOString(), - }); - - try { - // 注意:这里需要实现从 Zulip 获取消息历史的逻辑 - // 目前返回模拟数据 - const mockMessages = [ - { - id: 1, - sender: 'Player_123', - content: '大家好!我刚进入游戏', - scope: 'local', - mapId: query.mapId || 'whale_port', - timestamp: new Date(Date.now() - 3600000).toISOString(), - streamName: 'Whale Port', - topicName: 'Game Chat', - }, - { - id: 2, - sender: 'Player_456', - content: '欢迎新玩家!', - scope: 'local', - mapId: query.mapId || 'whale_port', - timestamp: new Date(Date.now() - 1800000).toISOString(), - streamName: 'Whale Port', - topicName: 'Game Chat', - }, - ]; - - return { - success: true, - messages: mockMessages.slice(query.offset || 0, (query.offset || 0) + (query.limit || 50)), - total: mockMessages.length, - count: Math.min(mockMessages.length - (query.offset || 0), query.limit || 50), - }; - - } catch (error) { - const err = error as Error; - this.logger.error('获取聊天历史失败', { - operation: 'getChatHistory', - error: err.message, - timestamp: new Date().toISOString(), - }); - - throw new HttpException( - '获取聊天历史失败,请稍后重试', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - - /** - * 获取系统状态 - */ - @Get('status') - @ApiOperation({ - summary: '获取聊天系统状态', - description: '获取 WebSocket 连接状态、Zulip 集成状态等系统信息' - }) - @ApiResponse({ - status: 200, - description: '获取系统状态成功', - type: SystemStatusResponseDto, - }) - @ApiResponse({ - status: 500, - description: '服务器内部错误', - }) - async getSystemStatus(): Promise { - this.logger.log('获取系统状态', { - operation: 'getSystemStatus', - timestamp: new Date().toISOString(), - }); - - try { - // 获取 WebSocket 连接状态 - const totalConnections = await this.websocketGateway.getConnectionCount(); - const authenticatedConnections = await this.websocketGateway.getAuthenticatedConnectionCount(); - const mapPlayerCounts = await 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: 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) { - const err = error as Error; - this.logger.error('获取系统状态失败', { - operation: 'getSystemStatus', - error: err.message, - timestamp: new Date().toISOString(), - }); - - throw new HttpException( - '获取系统状态失败,请稍后重试', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - - /** - * 获取 WebSocket 连接信息 - */ - @Get('websocket/info') - @ApiOperation({ - summary: '获取 WebSocket 连接信息', - description: '获取 WebSocket 连接的详细信息,包括连接地址、协议等' - }) - @ApiResponse({ - status: 200, - description: '获取连接信息成功', - schema: { - type: 'object', - properties: { - websocketUrl: { - type: 'string', - example: 'wss://whaletownend.xinghangee.icu/game', - description: 'WebSocket 连接地址' - }, - namespace: { - type: 'string', - example: '/game', - description: 'WebSocket 命名空间' - }, - supportedEvents: { - type: 'array', - items: { type: 'string' }, - example: ['login', 'chat', 'position_update'], - description: '支持的事件类型' - }, - authRequired: { - type: 'boolean', - example: true, - description: '是否需要认证' - }, - documentation: { - type: 'string', - example: 'https://docs.example.com/websocket', - description: '文档链接' - } - } - } - }) - async getWebSocketInfo() { - return { - websocketUrl: 'wss://whaletownend.xinghangee.icu/game', - protocol: 'native-websocket', - path: '/game', - namespace: '/', - supportedEvents: [ - 'login', // 用户登录 - 'chat', // 发送聊天消息 - 'position', // 位置更新 - ], - supportedResponses: [ - 'connected', // 连接确认 - 'login_success', // 登录成功 - 'login_error', // 登录失败 - 'chat_sent', // 消息发送成功 - 'chat_error', // 消息发送失败 - 'chat_render', // 接收到聊天消息 - 'error', // 通用错误 - ], - quickLinks: { - testPage: '/websocket-test?from=chat-api', - apiDocs: '/api-docs', - connectionInfo: '/websocket-api/connection-info' - }, - authRequired: true, - tokenType: 'JWT', - tokenFormat: { - issuer: 'whale-town', - audience: 'whale-town-users', - type: 'access', - requiredFields: ['sub', 'username', 'email', 'role'] - }, - documentation: '/api-docs', - }; - } -} \ No newline at end of file diff --git a/src/business/zulip/chat.dto.ts b/src/business/zulip/chat.dto.ts deleted file mode 100644 index adc5eaf..0000000 --- a/src/business/zulip/chat.dto.ts +++ /dev/null @@ -1,313 +0,0 @@ -/** - * 聊天相关的 DTO 定义 - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-01-07 - */ - -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsOptional, IsNumber, IsBoolean, IsEnum, IsArray, ValidateNested } 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 ChatMessageResponseDto { - @ApiProperty({ - description: '是否成功', - example: true - }) - success: boolean; - - @ApiProperty({ - description: '消息ID', - example: 12345 - }) - messageId: number; - - @ApiProperty({ - description: '响应消息', - example: '消息发送成功' - }) - message: string; - - @ApiPropertyOptional({ - description: '错误信息(失败时)', - example: '消息内容不能为空' - }) - error?: 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; -} - -/** - * 聊天消息信息 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: '2025-01-07T14: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: '各地图在线人数', - example: { - 'whale_port': 8, - 'pumpkin_valley': 5, - 'novice_village': 7 - } - }) - mapPlayerCounts: Record; -} - -/** - * Zulip 集成状态 DTO - */ -export class ZulipIntegrationStatusDto { - @ApiProperty({ - description: 'Zulip 服务器连接状态', - example: true - }) - serverConnected: boolean; - - @ApiProperty({ - description: 'Zulip 服务器版本', - example: '11.4' - }) - serverVersion: string; - - @ApiProperty({ - description: '机器人账号状态', - example: true - }) - botAccountActive: boolean; - - @ApiProperty({ - description: '可用 Stream 数量', - example: 12 - }) - availableStreams: number; - - @ApiProperty({ - description: '游戏相关 Stream 列表', - example: ['Whale Port', 'Pumpkin Valley', 'Novice Village'] - }) - gameStreams: string[]; - - @ApiProperty({ - description: '最近24小时消息数', - example: 156 - }) - recentMessageCount: number; -} - -/** - * 系统状态响应 DTO - */ -export class SystemStatusResponseDto { - @ApiProperty({ - description: 'WebSocket 状态', - type: WebSocketStatusDto - }) - @ValidateNested() - @Type(() => WebSocketStatusDto) - websocket: WebSocketStatusDto; - - @ApiProperty({ - description: 'Zulip 集成状态', - type: ZulipIntegrationStatusDto - }) - @ValidateNested() - @Type(() => ZulipIntegrationStatusDto) - zulip: ZulipIntegrationStatusDto; - - @ApiProperty({ - description: '系统运行时间(秒)', - example: 86400 - }) - uptime: number; - - @ApiProperty({ - description: '内存使用情况', - example: { - used: '45.2 MB', - total: '64.0 MB', - percentage: 70.6 - } - }) - memory: { - used: string; - total: string; - percentage: number; - }; -} \ No newline at end of file diff --git a/src/business/zulip/clean_websocket.gateway.spec.ts b/src/business/zulip/clean_websocket.gateway.spec.ts deleted file mode 100644 index 743616e..0000000 --- a/src/business/zulip/clean_websocket.gateway.spec.ts +++ /dev/null @@ -1,491 +0,0 @@ -/** - * WebSocket网关测试 - * - * 功能描述: - * - 测试WebSocket连接管理功能 - * - 验证消息广播和路由逻辑 - * - 测试用户认证和会话管理 - * - 验证错误处理和连接清理 - * - * 测试范围: - * - 连接建立和断开测试 - * - 消息处理和广播测试 - * - 用户认证测试 - * - 错误处理测试 - * - * 最近修改: - * - 2026-01-12: 测试修复 - 修正私有方法访问和接口匹配问题,适配实际网关实现 (修改者: moyin) - * - 2026-01-12: 代码规范优化 - 创建测试文件,确保WebSocket网关功能的测试覆盖 (修改者: moyin) - * - * @author moyin - * @version 1.1.0 - * @since 2026-01-12 - * @lastModified 2026-01-12 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { WsException } from '@nestjs/websockets'; -import { CleanWebSocketGateway } from './clean_websocket.gateway'; -import { SessionManagerService } from './services/session_manager.service'; -import { MessageFilterService } from './services/message_filter.service'; -import { ZulipService } from './zulip.service'; - -describe('CleanWebSocketGateway', () => { - let gateway: CleanWebSocketGateway; - let sessionManagerService: jest.Mocked; - let messageFilterService: jest.Mocked; - let zulipService: jest.Mocked; - - const mockSocket = { - id: 'socket123', - emit: jest.fn(), - disconnect: jest.fn(), - handshake: { - auth: { token: 'valid-jwt-token' }, - headers: { authorization: 'Bearer valid-jwt-token' }, - }, - data: {}, - }; - - const mockServer = { - emit: jest.fn(), - to: jest.fn().mockReturnThis(), - in: jest.fn().mockReturnThis(), - sockets: new Map(), - }; - - beforeEach(async () => { - const mockSessionManagerService = { - createSession: jest.fn(), - destroySession: jest.fn(), - getSession: jest.fn(), - updateSession: jest.fn(), - validateSession: jest.fn(), - }; - - const mockMessageFilterService = { - filterMessage: jest.fn(), - validateMessageContent: jest.fn(), - checkRateLimit: jest.fn(), - }; - - const mockZulipService = { - handlePlayerLogin: jest.fn(), - handlePlayerLogout: jest.fn(), - sendChatMessage: jest.fn(), - setWebSocketGateway: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - CleanWebSocketGateway, - { provide: SessionManagerService, useValue: mockSessionManagerService }, - { provide: MessageFilterService, useValue: mockMessageFilterService }, - { provide: ZulipService, useValue: mockZulipService }, - ], - }).compile(); - - gateway = module.get(CleanWebSocketGateway); - sessionManagerService = module.get(SessionManagerService); - messageFilterService = module.get(MessageFilterService); - zulipService = module.get(ZulipService); - - // Reset all mocks - jest.clearAllMocks(); - }); - - describe('Gateway Initialization', () => { - it('should be defined', () => { - expect(gateway).toBeDefined(); - }); - - it('should have all required dependencies', () => { - expect(sessionManagerService).toBeDefined(); - expect(messageFilterService).toBeDefined(); - expect(zulipService).toBeDefined(); - }); - }); - - describe('handleConnection', () => { - it('should accept valid connection with JWT token', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - emit: jest.fn(), - disconnect: jest.fn(), - handshake: { - auth: { token: 'valid-jwt-token' }, - headers: { authorization: 'Bearer valid-jwt-token' }, - }, - data: {}, - readyState: 1, // WebSocket.OPEN - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - // Mock the private method calls by testing the public interface - // Since handleConnection is private, we test through the message handling - const loginMessage = { - type: 'login', - token: 'valid-jwt-token' - }; - - zulipService.handlePlayerLogin.mockResolvedValue({ - success: true, - userId: 'user123', - username: 'testuser', - sessionId: 'session123', - }); - - // Act - Test through public interface - await gateway['handleMessage'](mockSocket as any, loginMessage); - - // Assert - expect(zulipService.handlePlayerLogin).toHaveBeenCalledWith({ - socketId: mockSocket.id, - token: 'valid-jwt-token' - }); - }); - - it('should reject connection with invalid JWT token', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - emit: jest.fn(), - disconnect: jest.fn(), - handshake: { - auth: { token: 'invalid-token' }, - headers: { authorization: 'Bearer invalid-token' }, - }, - data: {}, - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - const loginMessage = { - type: 'login', - token: 'invalid-token' - }; - - zulipService.handlePlayerLogin.mockResolvedValue({ - success: false, - error: 'Invalid token', - }); - - // Act - await gateway['handleMessage'](mockSocket as any, loginMessage); - - // Assert - expect(zulipService.handlePlayerLogin).toHaveBeenCalledWith({ - socketId: mockSocket.id, - token: 'invalid-token' - }); - }); - - it('should reject connection without token', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - emit: jest.fn(), - disconnect: jest.fn(), - handshake: { - auth: {}, - headers: {}, - }, - data: {}, - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - const loginMessage = { - type: 'login', - // No token - }; - - // Act & Assert - Should send error message - await gateway['handleMessage'](mockSocket as any, loginMessage); - - expect(mockSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - type: 'error', - message: 'Token不能为空' - }) - ); - }); - }); - - describe('handleDisconnect', () => { - it('should clean up session on disconnect', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - authenticated: true, - username: 'testuser', - currentMap: 'whale_port', - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - // Act - Test through the cleanup method since handleDisconnect is private - await gateway['cleanupClient'](mockSocket as any, 'disconnect'); - - // Assert - expect(zulipService.handlePlayerLogout).toHaveBeenCalledWith(mockSocket.id, 'disconnect'); - }); - - it('should handle disconnect when session does not exist', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - authenticated: false, - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - // Act - await gateway['cleanupClient'](mockSocket as any, 'disconnect'); - - // Assert - Should not call logout for unauthenticated users - expect(zulipService.handlePlayerLogout).not.toHaveBeenCalled(); - }); - - it('should handle errors during session cleanup', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - authenticated: true, - username: 'testuser', - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - zulipService.handlePlayerLogout.mockRejectedValue(new Error('Cleanup failed')); - - // Act & Assert - Should not throw, just log error - await expect(gateway['cleanupClient'](mockSocket as any, 'disconnect')).resolves.not.toThrow(); - }); - }); - - describe('handleMessage', () => { - const validMessage = { - type: 'chat', - content: 'Hello, world!', - scope: 'local', - }; - - const mockSocket = { - id: 'socket123', - authenticated: true, - userId: 'user123', - username: 'testuser', - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - it('should process valid chat message', async () => { - // Arrange - zulipService.sendChatMessage.mockResolvedValue({ - success: true, - messageId: 'msg123', - }); - - // Act - await gateway['handleMessage'](mockSocket as any, validMessage); - - // Assert - expect(zulipService.sendChatMessage).toHaveBeenCalledWith({ - socketId: mockSocket.id, - content: validMessage.content, - scope: validMessage.scope, - }); - }); - - it('should reject message from unauthenticated user', async () => { - // Arrange - const unauthenticatedSocket = { - ...mockSocket, - authenticated: false, - }; - - // Act - await gateway['handleMessage'](unauthenticatedSocket as any, validMessage); - - // Assert - expect(unauthenticatedSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - type: 'error', - message: '请先登录' - }) - ); - }); - - it('should reject message with empty content', async () => { - // Arrange - const emptyMessage = { - type: 'chat', - content: '', - scope: 'local', - }; - - // Act - await gateway['handleMessage'](mockSocket as any, emptyMessage); - - // Assert - expect(mockSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - type: 'error', - message: '消息内容不能为空' - }) - ); - }); - - it('should handle zulip service errors during message sending', async () => { - // Arrange - zulipService.sendChatMessage.mockResolvedValue({ - success: false, - error: 'Zulip API error', - }); - - // Act - await gateway['handleMessage'](mockSocket as any, validMessage); - - // Assert - expect(mockSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - t: 'chat_error', - message: 'Zulip API error' - }) - ); - }); - }); - - describe('broadcastToMap', () => { - it('should broadcast message to specific map', () => { - // Arrange - const message = { - t: 'chat', - content: 'Hello room!', - from: 'user123', - timestamp: new Date().toISOString(), - }; - const mapId = 'whale_port'; - - // Act - gateway.broadcastToMap(mapId, message); - - // Assert - Since we can't easily test the internal map structure, - // we just verify the method doesn't throw - expect(true).toBe(true); - }); - }); - - describe('Error Handling', () => { - it('should handle authentication errors gracefully', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - const loginMessage = { - type: 'login', - token: 'valid-token' - }; - - zulipService.handlePlayerLogin.mockRejectedValue(new Error('Auth service unavailable')); - - // Act - await gateway['handleMessage'](mockSocket as any, loginMessage); - - // Assert - expect(mockSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - type: 'error', - message: '登录处理失败' - }) - ); - }); - - it('should handle message processing errors', async () => { - // Arrange - const mockSocket = { - id: 'socket123', - authenticated: true, - readyState: 1, - send: jest.fn(), - close: jest.fn(), - on: jest.fn(), - }; - - const validMessage = { - type: 'chat', - content: 'Hello, world!', - }; - - zulipService.sendChatMessage.mockRejectedValue(new Error('Service error')); - - // Act - await gateway['handleMessage'](mockSocket as any, validMessage); - - // Assert - expect(mockSocket.send).toHaveBeenCalledWith( - JSON.stringify({ - type: 'error', - message: '聊天处理失败' - }) - ); - }); - }); - - describe('Connection Management', () => { - it('should track active connections', () => { - // Act - const connectionCount = gateway.getConnectionCount(); - - // Assert - expect(typeof connectionCount).toBe('number'); - expect(connectionCount).toBeGreaterThanOrEqual(0); - }); - - it('should track authenticated connections', () => { - // Act - const authCount = gateway.getAuthenticatedConnectionCount(); - - // Assert - expect(typeof authCount).toBe('number'); - expect(authCount).toBeGreaterThanOrEqual(0); - }); - - it('should get map player counts', () => { - // Act - const mapCounts = gateway.getMapPlayerCounts(); - - // Assert - expect(typeof mapCounts).toBe('object'); - }); - - it('should get players in specific map', () => { - // Act - const players = gateway.getMapPlayers('whale_port'); - - // Assert - expect(Array.isArray(players)).toBe(true); - }); - }); -}); \ No newline at end of file diff --git a/src/business/zulip/clean_websocket.gateway.ts b/src/business/zulip/clean_websocket.gateway.ts deleted file mode 100644 index 5516993..0000000 --- a/src/business/zulip/clean_websocket.gateway.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * 清洁的WebSocket网关 - 优化版本 - * - * 功能描述: - * - 使用原生WebSocket,不依赖NestJS的WebSocket装饰器 - * - 支持游戏内实时聊天广播 - * - 与优化后的ZulipService集成 - * - * 核心优化: - * - 🚀 实时消息广播:直接广播给同区域玩家 - * - 🔄 与ZulipService的异步同步集成 - * - ⚡ 低延迟聊天体验 - * - * 最近修改: - * - 2026-01-10: 重构优化 - 适配优化后的ZulipService,支持实时广播 (修改者: moyin) - */ - -import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; -import * as WebSocket from 'ws'; -import { ZulipService } from './zulip.service'; -import { SessionManagerService } from './services/session_manager.service'; - -interface ExtendedWebSocket extends WebSocket { - id: string; - isAlive?: boolean; - authenticated?: boolean; - userId?: string; - username?: string; - sessionId?: string; - currentMap?: string; -} - -@Injectable() -export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy { - private server: WebSocket.Server; - private readonly logger = new Logger(CleanWebSocketGateway.name); - private clients = new Map(); - private mapRooms = new Map>(); // mapId -> Set - - constructor( - private readonly zulipService: ZulipService, - private readonly sessionManager: SessionManagerService, - ) {} - - async onModuleInit() { - const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : 3001; - - this.server = new WebSocket.Server({ - port, - path: '/game' // 统一使用 /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) => { - try { - const message = JSON.parse(data.toString()); - this.handleMessage(ws, message); - } catch (error) { - this.logger.error('解析消息失败', error); - this.sendError(ws, '消息格式错误'); - } - }); - - ws.on('close', (code, reason) => { - this.logger.log(`WebSocket连接关闭: ${ws.id}`, { - code, - reason: reason?.toString(), - authenticated: ws.authenticated, - username: ws.username - }); - - // 根据关闭原因确定登出类型 - let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect'; - - if (code === 1000) { - logoutReason = 'manual'; // 正常关闭,通常是主动登出 - } else if (code === 1001 || code === 1006) { - logoutReason = 'disconnect'; // 异常断开 - } - - this.cleanupClient(ws, logoutReason); - }); - - ws.on('error', (error) => { - this.logger.error(`WebSocket错误: ${ws.id}`, error); - }); - - // 发送连接确认 - this.sendMessage(ws, { - type: 'connected', - message: '连接成功', - socketId: ws.id - }); - }); - - // 🔄 设置WebSocket网关引用到ZulipService - this.zulipService.setWebSocketGateway(this); - - this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`); - } - - async onModuleDestroy() { - if (this.server) { - this.server.close(); - this.logger.log('WebSocket服务器已关闭'); - } - } - - private async handleMessage(ws: ExtendedWebSocket, message: any) { - this.logger.log(`收到消息: ${ws.id}`, message); - - const messageType = message.type || message.t; - - this.logger.log(`消息类型: ${messageType}`, { type: message.type, t: message.t }); - - switch (messageType) { - case 'login': - await this.handleLogin(ws, message); - break; - case 'logout': - await this.handleLogout(ws, message); - break; - case 'chat': - await this.handleChat(ws, message); - break; - case 'position': - await this.handlePositionUpdate(ws, message); - break; - default: - this.logger.warn(`未知消息类型: ${messageType}`, message); - this.sendError(ws, `未知消息类型: ${messageType}`); - } - } - - private async handleLogin(ws: ExtendedWebSocket, message: any) { - try { - if (!message.token) { - this.sendError(ws, 'Token不能为空'); - return; - } - - // 调用ZulipService进行登录 - const result = await this.zulipService.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 = 'whale_port'; // 默认地图 - - // 加入默认地图房间 - 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}) 进入地图: ${ws.currentMap}`); - } else { - this.sendMessage(ws, { - t: 'login_error', - message: result.error || '登录失败' - }); - } - } catch (error) { - this.logger.error('登录处理失败', error); - this.sendError(ws, '登录处理失败'); - } - } - - /** - * 处理主动登出请求 - */ - private async handleLogout(ws: ExtendedWebSocket, message: any) { - try { - if (!ws.authenticated) { - this.sendError(ws, '用户未登录'); - return; - } - - this.logger.log(`用户主动登出: ${ws.username} (${ws.id})`); - - // 调用ZulipService处理登出,标记为主动登出 - await this.zulipService.handlePlayerLogout(ws.id, 'manual'); - - // 清理WebSocket状态 - this.cleanupClient(ws); - - this.sendMessage(ws, { - t: 'logout_success', - message: '登出成功' - }); - - // 关闭WebSocket连接 - ws.close(1000, '用户主动登出'); - - } catch (error) { - this.logger.error('登出处理失败', error); - this.sendError(ws, '登出处理失败'); - } - } - - private async handleChat(ws: ExtendedWebSocket, message: any) { - try { - if (!ws.authenticated) { - this.sendError(ws, '请先登录'); - return; - } - - if (!message.content) { - this.sendError(ws, '消息内容不能为空'); - return; - } - - // 🚀 调用优化后的ZulipService发送消息(实时广播+异步同步) - const result = await this.zulipService.sendChatMessage({ - socketId: ws.id, - content: message.content, - scope: message.scope || 'local' - }); - - if (result.success) { - this.sendMessage(ws, { - t: 'chat_sent', - messageId: result.messageId, - message: '消息发送成功' - }); - - this.logger.log(`消息发送成功: ${ws.username} -> ${message.content}`); - } else { - this.sendMessage(ws, { - t: 'chat_error', - message: result.error || '消息发送失败' - }); - } - } catch (error) { - this.logger.error('聊天处理失败', error); - this.sendError(ws, '聊天处理失败'); - } - } - - private async handlePositionUpdate(ws: ExtendedWebSocket, message: any) { - try { - if (!ws.authenticated) { - this.sendError(ws, '请先登录'); - return; - } - - // 简单的位置更新处理,这里可以添加更多逻辑 - this.logger.log(`位置更新: ${ws.username} -> (${message.x}, ${message.y}) 在 ${message.mapId}`); - - // 如果用户切换了地图,更新房间 - if (ws.currentMap !== message.mapId) { - this.leaveMapRoom(ws.id, ws.currentMap); - this.joinMapRoom(ws.id, message.mapId); - ws.currentMap = message.mapId; - - this.logger.log(`用户 ${ws.username} 切换到地图: ${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, '位置更新处理失败'); - } - } - - // 🚀 实现IWebSocketGateway接口方法,供ZulipService调用 - - /** - * 向指定玩家发送消息 - * - * @param socketId 目标Socket ID - * @param data 消息数据 - */ - public sendToPlayer(socketId: string, data: any): void { - const client = this.clients.get(socketId); - if (client && client.readyState === WebSocket.OPEN) { - this.sendMessage(client, data); - } - } - - /** - * 向指定地图广播消息 - * - * @param mapId 地图ID - * @param data 消息数据 - * @param excludeId 排除的Socket ID - */ - 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); - } - } - }); - } - - // 原有的私有方法保持不变 - 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: message - }); - } - - private broadcastMessage(data: any, excludeId?: string) { - this.clients.forEach((client, id) => { - if (id !== excludeId && client.authenticated) { - this.sendMessage(client, data); - } - }); - } - - private joinMapRoom(clientId: string, mapId: string) { - if (!this.mapRooms.has(mapId)) { - this.mapRooms.set(mapId, new Set()); - } - this.mapRooms.get(mapId).add(clientId); - - this.logger.log(`客户端 ${clientId} 加入地图房间: ${mapId}`); - } - - 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); - } - this.logger.log(`客户端 ${clientId} 离开地图房间: ${mapId}`); - } - } - - private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') { - try { - // 如果用户已认证,调用ZulipService处理登出 - if (ws.authenticated && ws.id) { - this.logger.log(`清理已认证用户: ${ws.username} (${ws.id})`, { reason }); - await this.zulipService.handlePlayerLogout(ws.id, reason); - } - - // 从地图房间中移除 - if (ws.currentMap) { - this.leaveMapRoom(ws.id, ws.currentMap); - } - - // 从客户端列表中移除 - this.clients.delete(ws.id); - - this.logger.log(`客户端清理完成: ${ws.id}`, { - reason, - wasAuthenticated: ws.authenticated, - username: ws.username - }); - } catch (error) { - this.logger.error(`清理客户端失败: ${ws.id}`, { - error: (error as Error).message, - reason, - username: ws.username - }); - } - } - - private generateClientId(): string { - return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - } - - // 公共方法供其他服务调用 - public getConnectionCount(): number { - return this.clients.size; - } - - public getAuthenticatedConnectionCount(): number { - return Array.from(this.clients.values()).filter(client => client.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 && client.authenticated && client.username) { - players.push(client.username); - } - }); - return players; - } -} \ No newline at end of file diff --git a/src/business/zulip/services/message_filter.service.spec.ts b/src/business/zulip/services/message_filter.service.spec.ts deleted file mode 100644 index 08823c2..0000000 --- a/src/business/zulip/services/message_filter.service.spec.ts +++ /dev/null @@ -1,530 +0,0 @@ -/** - * 消息过滤服务测试 - * - * 功能描述: - * - 测试MessageFilterService的核心功能 - * - 包含属性测试验证内容安全和频率控制 - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import * as fc from 'fast-check'; -import { MessageFilterService, ViolationType } from './message_filter.service'; -import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; -import { AppLoggerService } from '../../../core/utils/logger/logger.service'; -import { IRedisService } from '../../../core/redis/redis.interface'; - -describe('MessageFilterService', () => { - let service: MessageFilterService; - let mockLogger: jest.Mocked; - let mockRedisService: jest.Mocked; - let mockConfigManager: jest.Mocked; - - // 内存存储模拟Redis - let memoryStore: Map; - - beforeEach(async () => { - jest.clearAllMocks(); - - // 初始化内存存储 - memoryStore = new Map(); - - mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - } as any; - - // 创建模拟Redis服务 - mockRedisService = { - set: jest.fn().mockImplementation(async (key: string, value: string, ttl?: number) => { - memoryStore.set(key, { - value, - expireAt: ttl ? Date.now() + ttl * 1000 : undefined - }); - }), - setex: jest.fn().mockImplementation(async (key: string, ttl: number, value: string) => { - memoryStore.set(key, { - value, - expireAt: Date.now() + ttl * 1000 - }); - }), - get: jest.fn().mockImplementation(async (key: string) => { - const item = memoryStore.get(key); - if (!item) return null; - if (item.expireAt && item.expireAt <= Date.now()) { - memoryStore.delete(key); - return null; - } - return item.value; - }), - del: jest.fn().mockImplementation(async (key: string) => { - const existed = memoryStore.has(key); - memoryStore.delete(key); - return existed; - }), - exists: jest.fn().mockImplementation(async (key: string) => { - return memoryStore.has(key); - }), - ttl: jest.fn().mockImplementation(async (key: string) => { - const item = memoryStore.get(key); - if (!item || !item.expireAt) return -1; - return Math.max(0, Math.floor((item.expireAt - Date.now()) / 1000)); - }), - incr: jest.fn().mockImplementation(async (key: string) => { - const item = memoryStore.get(key); - if (!item) { - memoryStore.set(key, { value: '1' }); - return 1; - } - const newValue = parseInt(item.value, 10) + 1; - item.value = newValue.toString(); - return newValue; - }), - } as any; - - // 创建模拟ConfigManager服务 - mockConfigManager = { - getStreamByMap: jest.fn().mockImplementation((mapId: string) => { - const mapping: Record = { - 'novice_village': 'Novice Village', - 'tavern': 'Tavern', - 'market': 'Market', - }; - return mapping[mapId] || null; - }), - hasMap: jest.fn().mockImplementation((mapId: string) => { - return ['novice_village', 'tavern', 'market'].includes(mapId); - }), - getMapIdByStream: jest.fn(), - getTopicByObject: jest.fn(), - getZulipConfig: jest.fn(), - hasStream: jest.fn(), - getAllMapIds: jest.fn(), - getAllStreams: jest.fn(), - reloadConfig: jest.fn(), - validateConfig: jest.fn(), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MessageFilterService, - { - provide: AppLoggerService, - useValue: mockLogger, - }, - { - provide: 'REDIS_SERVICE', - useValue: mockRedisService, - }, - { - provide: 'ZULIP_CONFIG_SERVICE', - useValue: mockConfigManager, - }, - ], - }).compile(); - - service = module.get(MessageFilterService); - }); - - afterEach(async () => { - memoryStore.clear(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('filterContent - 内容过滤', () => { - it('应该允许正常消息通过', async () => { - const result = await service.filterContent('Hello, world!'); - expect(result.allowed).toBe(true); - expect(result.filtered).toBeUndefined(); - }); - - it('应该拒绝空消息', async () => { - const result = await service.filterContent(''); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('不能为空'); - }); - - it('应该拒绝只包含空白字符的消息', async () => { - const result = await service.filterContent(' \t\n '); - expect(result.allowed).toBe(false); - expect(result.reason).toBeDefined(); - }); - - it('应该拒绝过长的消息', async () => { - const longMessage = 'a'.repeat(1001); - const result = await service.filterContent(longMessage); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('过长'); - }); - - it('应该替换敏感词', async () => { - const result = await service.filterContent('这是垃圾消息'); - expect(result.allowed).toBe(true); - expect(result.filtered).toBe('这是**消息'); - }); - - it('应该拒绝包含重复字符的消息', async () => { - const result = await service.filterContent('aaaaaaaaa'); - expect(result.allowed).toBe(false); - expect(result.reason).toContain('重复字符'); - }); - }); - - describe('checkRateLimit - 频率限制', () => { - it('应该允许首次发送', async () => { - const result = await service.checkRateLimit('user-123'); - expect(result).toBe(true); - }); - - it('应该在达到限制后拒绝', async () => { - // 发送10条消息(达到限制) - for (let i = 0; i < 10; i++) { - await service.checkRateLimit('user-123'); - } - - // 第11条应该被拒绝 - const result = await service.checkRateLimit('user-123'); - expect(result).toBe(false); - }); - }); - - describe('validatePermission - 权限验证', () => { - it('应该允许匹配的地图和Stream', async () => { - const result = await service.validatePermission( - 'user-123', - 'Novice Village', - 'novice_village' - ); - expect(result).toBe(true); - }); - - it('应该拒绝不匹配的地图和Stream', async () => { - const result = await service.validatePermission( - 'user-123', - 'Tavern', - 'novice_village' - ); - expect(result).toBe(false); - }); - - it('应该拒绝未知地图', async () => { - const result = await service.validatePermission( - 'user-123', - 'Some Stream', - 'unknown_map' - ); - expect(result).toBe(false); - }); - }); - - - /** - * 属性测试: 内容安全和频率控制 - * - * **Feature: zulip-integration, Property 7: 内容安全和频率控制** - * **Validates: Requirements 4.3, 4.4** - * - * 对于任何包含敏感词或高频发送的消息,系统应该正确过滤敏感内容, - * 实施频率限制,并返回适当的提示信息 - */ - describe('Property 7: 内容安全和频率控制', () => { - /** - * 属性: 对于任何有效的非敏感消息,内容过滤应该允许通过 - * 验证需求 4.3: 消息内容包含敏感词时系统应过滤敏感内容或拒绝发送 - */ - it('对于任何有效的非敏感消息,内容过滤应该允许通过', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的非敏感消息(字母、数字、空格组成) - fc.string({ minLength: 1, maxLength: 500 }) - .filter(s => s.trim().length > 0) - .filter(s => !/(.)\1{4,}/.test(s)) // 排除重复字符 - .filter(s => !['垃圾', '广告', '刷屏', '傻逼', '操你'].some(w => s.includes(w))) // 排除敏感词 - .map(s => s.replace(/(.{2,})\1{2,}/g, '$1')), // 移除重复短语 - async (content) => { - const result = await service.filterContent(content); - - // 有效的非敏感消息应该被允许 - if (content.trim().length > 0 && content.length <= 1000) { - expect(result.allowed).toBe(true); - } - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何包含敏感词的消息,应该被过滤或拒绝 - * 验证需求 4.3: 消息内容包含敏感词时系统应过滤敏感内容或拒绝发送 - */ - it('对于任何包含敏感词的消息,应该被过滤或拒绝', async () => { - const sensitiveWords = ['垃圾', '广告', '刷屏']; - - await fc.assert( - fc.asyncProperty( - // 生成包含敏感词的消息 - fc.constantFrom(...sensitiveWords), - fc.string({ minLength: 0, maxLength: 50 }), - fc.string({ minLength: 0, maxLength: 50 }), - async (sensitiveWord, prefix, suffix) => { - const content = `${prefix}${sensitiveWord}${suffix}`; - const result = await service.filterContent(content); - - // 包含敏感词的消息应该被过滤(替换为星号)或拒绝 - if (result.allowed) { - // 如果允许,敏感词应该被替换 - expect(result.filtered).toBeDefined(); - expect(result.filtered).not.toContain(sensitiveWord); - expect(result.filtered).toContain('*'.repeat(sensitiveWord.length)); - } - // 如果不允许,reason应该有值 - if (!result.allowed) { - expect(result.reason).toBeDefined(); - } - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何空或只包含空白字符的消息,应该被拒绝 - * 验证需求 4.3: 消息内容验证 - */ - it('对于任何空或只包含空白字符的消息,应该被拒绝', async () => { - await fc.assert( - fc.asyncProperty( - // 生成空白字符串 - fc.constantFrom('', ' ', ' ', '\t', '\n', ' \t\n '), - async (content) => { - const result = await service.filterContent(content); - - // 空或空白消息应该被拒绝 - expect(result.allowed).toBe(false); - expect(result.reason).toBeDefined(); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 对于任何超过长度限制的消息,应该被拒绝 - * 验证需求 4.3: 消息长度验证 - */ - it('对于任何超过长度限制的消息,应该被拒绝', async () => { - await fc.assert( - fc.asyncProperty( - // 生成超长消息 - fc.integer({ min: 1001, max: 2000 }), - async (length) => { - const content = 'a'.repeat(length); - const result = await service.filterContent(content); - - // 超长消息应该被拒绝 - expect(result.allowed).toBe(false); - expect(result.reason).toContain('过长'); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 对于任何用户,在频率限制窗口内发送超过限制数量的消息应该被拒绝 - * 验证需求 4.4: 玩家发送频率过高时系统应实施频率限制并返回限制提示 - */ - it('对于任何用户,在频率限制窗口内发送超过限制数量的消息应该被拒绝', async () => { - await fc.assert( - fc.asyncProperty( - // 生成用户ID - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成发送次数(超过限制) - fc.integer({ min: 11, max: 20 }), - async (userId, sendCount) => { - // 清理之前的数据 - memoryStore.clear(); - - const results: boolean[] = []; - - // 发送多条消息 - for (let i = 0; i < sendCount; i++) { - const result = await service.checkRateLimit(userId.trim()); - results.push(result); - } - - // 前10条应该被允许 - const allowedCount = results.filter(r => r).length; - expect(allowedCount).toBe(10); - - // 超过10条的应该被拒绝 - const rejectedCount = results.filter(r => !r).length; - expect(rejectedCount).toBe(sendCount - 10); - } - ), - { numRuns: 50 } - ); - }, 60000); - - /** - * 属性: 对于任何用户,在频率限制内的消息应该被允许 - * 验证需求 4.4: 正常频率的消息应该被允许 - */ - it('对于任何用户,在频率限制内的消息应该被允许', async () => { - await fc.assert( - fc.asyncProperty( - // 生成用户ID - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成发送次数(在限制内) - fc.integer({ min: 1, max: 10 }), - async (userId, sendCount) => { - // 清理之前的数据 - memoryStore.clear(); - - // 发送消息 - for (let i = 0; i < sendCount; i++) { - const result = await service.checkRateLimit(userId.trim()); - expect(result).toBe(true); - } - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何包含过多重复字符的消息,应该被拒绝 - * 验证需求 4.3: 防刷屏检测 - */ - it('对于任何包含过多重复字符的消息,应该被拒绝', async () => { - await fc.assert( - fc.asyncProperty( - // 生成单个字符 - fc.constantFrom('a', 'b', 'c', 'd', 'e', '1', '2', '3'), - // 生成重复次数(超过5次) - fc.integer({ min: 5, max: 20 }), - async (char: string, repeatCount: number) => { - const content = char.repeat(repeatCount); - const result = await service.filterContent(content); - - // 包含过多重复字符的消息应该被拒绝 - expect(result.allowed).toBe(false); - expect(result.reason).toContain('重复'); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 综合验证 - 对于任何消息,过滤结果应该是确定性的 - * 验证需求 4.3, 4.4: 过滤行为的一致性 - */ - it('对于任何消息,过滤结果应该是确定性的', async () => { - await fc.assert( - fc.asyncProperty( - // 生成任意消息 - fc.string({ minLength: 0, maxLength: 500 }), - async (content) => { - // 对同一消息进行两次过滤 - const result1 = await service.filterContent(content); - const result2 = await service.filterContent(content); - - // 结果应该一致 - expect(result1.allowed).toBe(result2.allowed); - expect(result1.reason).toBe(result2.reason); - expect(result1.filtered).toBe(result2.filtered); - } - ), - { numRuns: 100 } - ); - }, 60000); - }); - - describe('validateMessage - 综合消息验证', () => { - it('应该对有效消息返回允许', async () => { - const result = await service.validateMessage( - 'user-123', - 'Hello, world!', - 'Novice Village', - 'novice_village' - ); - expect(result.allowed).toBe(true); - }); - - it('应该对无效内容返回拒绝', async () => { - const result = await service.validateMessage( - 'user-123', - '', - 'Novice Village', - 'novice_village' - ); - expect(result.allowed).toBe(false); - }); - - it('应该对位置不匹配返回拒绝', async () => { - const result = await service.validateMessage( - 'user-123', - 'Hello', - 'Tavern', - 'novice_village' - ); - expect(result.allowed).toBe(false); - }); - }); - - describe('logViolation - 违规记录', () => { - it('应该成功记录违规行为', async () => { - await service.logViolation('user-123', ViolationType.CONTENT, { - reason: 'test violation', - }); - - // 验证Redis被调用 - expect(mockRedisService.setex).toHaveBeenCalled(); - }); - }); - - describe('resetUserRateLimit - 重置频率限制', () => { - it('应该成功重置用户频率限制', async () => { - // 先发送一些消息 - await service.checkRateLimit('user-123'); - await service.checkRateLimit('user-123'); - - // 重置 - await service.resetUserRateLimit('user-123'); - - // 验证Redis del被调用 - expect(mockRedisService.del).toHaveBeenCalled(); - }); - }); - - describe('敏感词管理', () => { - it('应该能够添加敏感词', () => { - const initialCount = service.getSensitiveWords().length; - service.addSensitiveWord('测试词', 'replace', 'test'); - expect(service.getSensitiveWords().length).toBe(initialCount + 1); - }); - - it('应该能够移除敏感词', () => { - service.addSensitiveWord('临时词', 'replace'); - const result = service.removeSensitiveWord('临时词'); - expect(result).toBe(true); - }); - - it('应该返回过滤服务统计信息', () => { - const stats = service.getFilterStats(); - expect(stats.sensitiveWordsCount).toBeGreaterThan(0); - expect(stats.rateLimit).toBe(10); - expect(stats.maxMessageLength).toBe(1000); - }); - }); -}); diff --git a/src/business/zulip/services/message_filter.service.ts b/src/business/zulip/services/message_filter.service.ts deleted file mode 100644 index fd0af63..0000000 --- a/src/business/zulip/services/message_filter.service.ts +++ /dev/null @@ -1,996 +0,0 @@ -/** - * 消息过滤服务 - * - * 功能描述: - * - 实施内容审核和频率控制 - * - 敏感词过滤和权限验证 - * - 防止恶意操作和滥用 - * - 与ConfigManager集成实现位置权限验证 - * - * 职责分离: - * - 内容审核:检查消息内容是否包含敏感词和恶意链接 - * - 频率控制:防止用户发送消息过于频繁导致刷屏 - * - 权限验证:验证用户是否有权限向目标Stream发送消息 - * - 违规记录:记录和统计用户的违规行为 - * - 规则管理:动态管理敏感词列表和过滤规则 - * - * 主要方法: - * - filterContent(): 内容过滤,敏感词检查 - * - checkRateLimit(): 频率限制检查 - * - validatePermission(): 权限验证,防止位置欺诈 - * - logViolation(): 记录违规行为 - * - * 使用场景: - * - 消息发送前的内容审核 - * - 频率限制和防刷屏 - * - 权限验证和安全控制 - * - * 依赖模块: - * - AppLoggerService: 日志记录服务 - * - IRedisService: Redis缓存服务 - * - ConfigManagerService: 配置管理服务 - * - * 最近修改: - * - 2026-01-12: 代码规范优化 - 处理TODO项,移除告警通知相关的TODO注释 (修改者: moyin) - * - 2026-01-07: 代码规范优化 - 清理未使用的导入(forwardRef) (修改者: moyin) - * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) - * - * @author angjustinl - * @version 1.1.3 - * @since 2025-12-25 - * @lastModified 2026-01-12 - */ - -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; -} - -/** - * 权限验证结果接口 - */ -export interface PermissionValidationResult { - allowed: boolean; - reason?: string; - expectedStream?: string; - actualStream?: string; -} - -/** - * 频率限制结果接口 - */ -export interface RateLimitResult { - allowed: boolean; - currentCount: number; - limit: number; - remainingTime?: number; - reason?: string; -} - -/** - * 违规类型枚举 - */ -export enum ViolationType { - CONTENT = 'content', - RATE = 'rate', - PERMISSION = 'permission', -} - -/** - * 违规记录接口 - */ -export interface ViolationRecord { - userId: string; - type: ViolationType; - details: any; - timestamp: Date; -} - -/** - * 敏感词配置接口 - */ -export interface SensitiveWordConfig { - word: string; - level: 'block' | 'replace'; // block: 直接拒绝, replace: 替换为星号 - category?: string; -} - -/** - * 消息过滤服务类 - * - * 职责: - * - 实施内容审核和频率控制 - * - 敏感词过滤和权限验证 - * - 防止恶意操作和滥用 - * - 与ConfigManager集成实现位置权限验证 - * - * 主要方法: - * - filterContent(): 内容过滤,敏感词检查 - * - checkRateLimit(): 频率限制检查 - * - validatePermission(): 权限验证,防止位置欺诈 - * - validateMessage(): 综合消息验证 - * - logViolation(): 记录违规行为 - * - * 使用场景: - * - 消息发送前的内容审核 - * - 频率限制和防刷屏 - * - 权限验证和安全控制 - * - 违规行为监控和记录 - */ -@Injectable() -export class MessageFilterService { - private readonly RATE_LIMIT_PREFIX = 'zulip:rate_limit:'; - private readonly VIOLATION_PREFIX = 'zulip:violation:'; - private readonly VIOLATION_COUNT_PREFIX = 'zulip:violation_count:'; - private readonly DEFAULT_RATE_LIMIT = 10; // 每分钟最多10条消息 - private readonly RATE_LIMIT_WINDOW = 60; // 60秒窗口 - private readonly MAX_MESSAGE_LENGTH = 1000; // 最大消息长度 - private readonly MIN_MESSAGE_LENGTH = 1; // 最小消息长度 - private readonly logger = new Logger(MessageFilterService.name); - - // 敏感词列表(可从配置文件或数据库加载) - private sensitiveWords: SensitiveWordConfig[] = [ - { word: '垃圾', level: 'replace', category: 'offensive' }, - { word: '广告', level: 'replace', category: 'spam' }, - { word: '刷屏', level: 'replace', category: 'spam' }, - { word: '傻逼', level: 'block', category: 'offensive' }, - { word: '操你', level: 'block', category: 'offensive' }, - ]; - - // 恶意链接黑名单域名 - private readonly BLACKLISTED_DOMAINS = [ - 'malware.com', - 'phishing.net', - 'spam-site.org', - ]; - - // 允许的链接白名单域名 - private readonly WHITELISTED_DOMAINS = [ - 'github.com', - 'datawhale.club', - 'zulip.com', - ]; - - constructor( - @Inject('REDIS_SERVICE') - private readonly redisService: IRedisService, - @Inject('ZULIP_CONFIG_SERVICE') - private readonly configManager: IZulipConfigService, - ) { - this.logger.log('MessageFilterService初始化完成'); - } - - /** - * 内容过滤 - 敏感词检查 - * - * 功能描述: - * 检查消息内容是否包含敏感词,进行内容过滤和替换 - * - * 业务逻辑: - * 1. 检查消息长度限制 - * 2. 检查是否全为空白字符 - * 3. 扫描敏感词列表(区分block和replace级别) - * 4. 检查重复字符和刷屏行为 - * 5. 检查恶意链接 - * 6. 返回过滤结果 - * - * @param content 消息内容 - * @returns Promise 过滤结果 - */ - async filterContent(content: string): Promise { - this.logger.debug('开始内容过滤', { - operation: 'filterContent', - contentLength: content?.length || 0, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 检查消息是否为空 - if (!content || content.trim().length === 0) { - return { - allowed: false, - reason: '消息内容不能为空', - }; - } - - // 2. 检查消息长度 - if (content.length > this.MAX_MESSAGE_LENGTH) { - return { - allowed: false, - reason: `消息内容过长,最多${this.MAX_MESSAGE_LENGTH}字符`, - }; - } - - if (content.trim().length < this.MIN_MESSAGE_LENGTH) { - return { - allowed: false, - reason: '消息内容过短', - }; - } - - // 3. 检查是否全为空白字符 - if (/^\s+$/.test(content)) { - return { - allowed: false, - reason: '消息不能只包含空白字符', - }; - } - - // 4. 敏感词检查 - let filteredContent = content; - let hasBlockedWord = false; - let hasReplacedWord = false; - let blockedWord = ''; - - for (const wordConfig of this.sensitiveWords) { - if (content.toLowerCase().includes(wordConfig.word.toLowerCase())) { - if (wordConfig.level === 'block') { - hasBlockedWord = true; - blockedWord = wordConfig.word; - break; - } else { - hasReplacedWord = true; - // 替换敏感词为星号 - const replacement = '*'.repeat(wordConfig.word.length); - filteredContent = filteredContent.replace( - new RegExp(this.escapeRegExp(wordConfig.word), 'gi'), - replacement - ); - } - } - } - - // 如果包含需要阻止的敏感词,直接拒绝 - if (hasBlockedWord) { - this.logger.warn('消息包含禁止的敏感词', { - operation: 'filterContent', - blockedWord, - contentLength: content.length, - }); - return { - allowed: false, - reason: '消息包含不允许的内容', - }; - } - - // 5. 检查是否包含过多重复字符(防刷屏) - if (this.hasExcessiveRepetition(content)) { - return { - allowed: false, - reason: '消息包含过多重复字符', - }; - } - - // 6. 检查是否包含恶意链接 - const linkCheckResult = this.checkLinks(content); - if (!linkCheckResult.allowed) { - return { - allowed: false, - reason: linkCheckResult.reason, - }; - } - - const result: ContentFilterResult = { - allowed: true, - filtered: hasReplacedWord ? filteredContent : undefined, - }; - - this.logger.debug('内容过滤完成', { - operation: 'filterContent', - allowed: result.allowed, - hasReplacedWord, - originalLength: content.length, - filteredLength: filteredContent.length, - }); - - return result; - - } catch (error) { - const err = error as Error; - this.logger.error('内容过滤失败', { - operation: 'filterContent', - contentLength: content?.length || 0, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - // 过滤失败时默认拒绝 - return { - allowed: false, - reason: '内容过滤失败,请稍后重试', - }; - } - } - - /** - * 频率限制检查 - * - * 功能描述: - * 检查用户是否超过消息发送频率限制,防止刷屏 - * - * 业务逻辑: - * 1. 获取用户当前发送计数 - * 2. 检查是否超过限制 - * 3. 更新发送计数 - * 4. 返回检查结果 - * - * @param userId 用户ID - * @returns Promise 是否允许发送(true表示允许) - */ - async checkRateLimit(userId: string): Promise { - const result = await this.checkRateLimitDetailed(userId); - return result.allowed; - } - - /** - * 频率限制检查(详细版本) - * - * 功能描述: - * 检查用户是否超过消息发送频率限制,返回详细信息 - * - * @param userId 用户ID - * @param customLimit 自定义限制(可选) - * @returns Promise 频率限制检查结果 - */ - async checkRateLimitDetailed(userId: string, customLimit?: number): Promise { - this.logger.debug('开始频率限制检查', { - operation: 'checkRateLimitDetailed', - userId, - timestamp: new Date().toISOString(), - }); - - const limit = customLimit || this.DEFAULT_RATE_LIMIT; - - try { - const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`; - - // 获取当前计数 - const currentCount = await this.redisService.get(rateLimitKey); - const count = currentCount ? parseInt(currentCount, 10) : 0; - - // 检查是否超过限制 - if (count >= limit) { - this.logger.warn('用户超过频率限制', { - operation: 'checkRateLimitDetailed', - userId, - currentCount: count, - limit, - }); - - // 获取剩余时间 - const ttl = await this.redisService.ttl(rateLimitKey); - - // 记录违规行为 - await this.logViolation(userId, ViolationType.RATE, { - currentCount: count, - limit, - remainingTime: ttl, - }); - - return { - allowed: false, - currentCount: count, - limit, - remainingTime: ttl > 0 ? ttl : undefined, - reason: `发送频率过高,请${ttl > 0 ? `${ttl}秒后` : '稍后'}重试`, - }; - } - - // 增加计数 - if (count === 0) { - // 首次发送,设置计数和过期时间 - await this.redisService.setex(rateLimitKey, this.RATE_LIMIT_WINDOW, '1'); - } else { - // 增加计数 - await this.redisService.incr(rateLimitKey); - } - - this.logger.debug('频率限制检查通过', { - operation: 'checkRateLimitDetailed', - userId, - newCount: count + 1, - limit, - }); - - return { - allowed: true, - currentCount: count + 1, - limit, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('频率限制检查失败', { - operation: 'checkRateLimitDetailed', - userId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - // 检查失败时默认允许,避免影响正常用户 - return { - allowed: true, - currentCount: 0, - limit, - reason: '频率检查服务暂时不可用', - }; - } - } - - /** - * 权限验证 - 防止位置欺诈 - * - * 功能描述: - * 验证用户是否有权限向目标Stream发送消息,防止位置欺诈 - * 使用ConfigManager获取地图到Stream的映射关系 - * - * 业务逻辑: - * 1. 从ConfigManager获取地图到Stream的映射 - * 2. 检查目标Stream是否匹配当前地图 - * 3. 检查用户是否有特殊权限(如管理员) - * 4. 返回验证结果 - * - * @param userId 用户ID - * @param targetStream 目标Stream名称 - * @param currentMap 当前地图ID - * @returns Promise 是否有权限(true表示有权限) - */ - async validatePermission(userId: string, targetStream: string, currentMap: string): Promise { - const result = await this.validatePermissionDetailed(userId, targetStream, currentMap); - return result.allowed; - } - - /** - * 权限验证(详细版本) - * - * 功能描述: - * 验证用户是否有权限向目标Stream发送消息,返回详细信息 - * - * @param userId 用户ID - * @param targetStream 目标Stream名称 - * @param currentMap 当前地图ID - * @returns Promise 权限验证结果 - */ - async validatePermissionDetailed( - userId: string, - targetStream: string, - currentMap: string - ): Promise { - this.logger.debug('开始权限验证', { - operation: 'validatePermissionDetailed', - userId, - targetStream, - currentMap, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 参数验证 - if (!userId || !userId.trim()) { - return { - allowed: false, - reason: '用户ID无效', - }; - } - - if (!targetStream || !targetStream.trim()) { - return { - allowed: false, - reason: '目标Stream无效', - }; - } - - if (!currentMap || !currentMap.trim()) { - return { - allowed: false, - reason: '当前地图无效', - }; - } - - // 2. 从ConfigManager获取地图对应的Stream - const allowedStream = this.configManager.getStreamByMap(currentMap); - - if (!allowedStream) { - this.logger.warn('未知地图,拒绝发送', { - operation: 'validatePermissionDetailed', - userId, - currentMap, - targetStream, - }); - - await this.logViolation(userId, ViolationType.PERMISSION, { - reason: 'unknown_map', - currentMap, - targetStream, - }); - - return { - allowed: false, - reason: '当前地图未配置对应的聊天频道', - }; - } - - // 3. 检查目标Stream是否匹配(不区分大小写) - if (targetStream.toLowerCase() !== allowedStream.toLowerCase()) { - this.logger.warn('位置与目标Stream不匹配', { - operation: 'validatePermissionDetailed', - userId, - currentMap, - targetStream, - allowedStream, - }); - - await this.logViolation(userId, ViolationType.PERMISSION, { - reason: 'location_mismatch', - currentMap, - targetStream, - allowedStream, - }); - - return { - allowed: false, - reason: '您当前位置无法向该频道发送消息', - expectedStream: allowedStream, - actualStream: targetStream, - }; - } - - this.logger.debug('权限验证通过', { - operation: 'validatePermissionDetailed', - userId, - targetStream, - currentMap, - }); - - return { - allowed: true, - expectedStream: allowedStream, - actualStream: targetStream, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('权限验证失败', { - operation: 'validatePermissionDetailed', - userId, - targetStream, - currentMap, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - // 验证失败时默认拒绝 - return { - allowed: false, - reason: '权限验证服务暂时不可用', - }; - } - } - - /** - * 综合消息验证 - * - * 功能描述: - * 对消息进行综合验证,包括内容过滤、频率限制和权限验证 - * - * @param userId 用户ID - * @param content 消息内容 - * @param targetStream 目标Stream - * @param currentMap 当前地图 - * @returns Promise<{allowed: boolean, reason?: string, filteredContent?: string}> - */ - async validateMessage( - userId: string, - content: string, - targetStream: string, - currentMap: string - ): Promise<{ - allowed: boolean; - reason?: string; - filteredContent?: string; - }> { - this.logger.debug('开始综合消息验证', { - operation: 'validateMessage', - userId, - contentLength: content?.length || 0, - targetStream, - currentMap, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 频率限制检查 - const rateLimitResult = await this.checkRateLimitDetailed(userId); - if (!rateLimitResult.allowed) { - return { - allowed: false, - reason: rateLimitResult.reason, - }; - } - - // 2. 内容过滤 - const contentResult = await this.filterContent(content); - if (!contentResult.allowed) { - return { - allowed: false, - reason: contentResult.reason, - }; - } - - // 3. 权限验证 - const permissionResult = await this.validatePermissionDetailed(userId, targetStream, currentMap); - if (!permissionResult.allowed) { - return { - allowed: false, - reason: permissionResult.reason, - }; - } - - this.logger.log('消息验证通过', { - operation: 'validateMessage', - userId, - targetStream, - currentMap, - hasFilteredContent: !!contentResult.filtered, - }); - - return { - allowed: true, - filteredContent: contentResult.filtered, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('综合消息验证失败', { - operation: 'validateMessage', - userId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return { - allowed: false, - reason: '消息验证失败,请稍后重试', - }; - } - } - - /** - * 记录违规行为 - * - * 功能描述: - * 记录用户的违规行为,用于监控和分析 - * - * @param userId 用户ID - * @param type 违规类型 - * @param details 违规详情 - * @returns Promise - */ - async logViolation(userId: string, type: ViolationType, details: any): Promise { - this.logger.warn('记录违规行为', { - operation: 'logViolation', - userId, - type, - details, - timestamp: new Date().toISOString(), - }); - - try { - const violation: ViolationRecord = { - userId, - type, - details, - timestamp: new Date(), - }; - - // 存储违规记录到Redis(保留7天) - const violationKey = `${this.VIOLATION_PREFIX}${userId}:${Date.now()}`; - await this.redisService.setex(violationKey, 7 * 24 * 3600, JSON.stringify(violation)); - - // 后续版本可以考虑发送告警通知或更新用户信誉度 - - } catch (error) { - const err = error as Error; - this.logger.error('记录违规行为失败', { - operation: 'logViolation', - userId, - type, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - } - } - - /** - * 检查是否包含过多重复字符 - * - * @param content 消息内容 - * @returns boolean 是否包含过多重复字符 - * @private - */ - private hasExcessiveRepetition(content: string): boolean { - // 检查连续重复字符(超过5个相同字符) - const repetitionPattern = /(.)\1{4,}/; - if (repetitionPattern.test(content)) { - return true; - } - - // 检查重复短语(同一个词重复超过3次) - const words = content.split(/\s+/); - const wordCount = new Map(); - - for (const word of words) { - if (word.length > 1) { - const normalizedWord = word.toLowerCase(); - const count = (wordCount.get(normalizedWord) || 0) + 1; - wordCount.set(normalizedWord, count); - - if (count > 3) { - return true; - } - } - } - - // 检查连续重复的短语模式(如 "哈哈哈哈哈") - const phrasePattern = /(.{2,})\1{2,}/; - if (phrasePattern.test(content)) { - return true; - } - - return false; - } - - /** - * 检查链接安全性 - * - * @param content 消息内容 - * @returns {allowed: boolean, reason?: string} 检查结果 - * @private - */ - private checkLinks(content: string): { allowed: boolean; reason?: string } { - // 提取所有URL - const urlPattern = /(https?:\/\/[^\s]+)/gi; - const urls = content.match(urlPattern); - - if (!urls || urls.length === 0) { - return { allowed: 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 { - allowed: false, - reason: '消息包含不允许的链接', - }; - } - } - - // 可选:只允许白名单域名 - // const isWhitelisted = this.WHITELISTED_DOMAINS.some( - // whitelisted => domain.includes(whitelisted) - // ); - // if (!isWhitelisted) { - // return { - // allowed: false, - // reason: '消息包含未授权的链接', - // }; - // } - - } catch { - // URL解析失败,可能是格式不正确的链接 - // 暂时允许,避免误判 - } - } - - return { allowed: true }; - } - - /** - * 转义正则表达式特殊字符 - * - * @param string 要转义的字符串 - * @returns string 转义后的字符串 - * @private - */ - private escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - /** - * 获取用户违规统计 - * - * @param userId 用户ID - * @returns Promise<{totalViolations: number, recentViolations: number, violationsByType: Record}> - */ - async getUserViolationStats(userId: string): Promise<{ - totalViolations: number; - recentViolations: number; - violationsByType: Record; - }> { - try { - // 获取违规计数 - const countKey = `${this.VIOLATION_COUNT_PREFIX}${userId}`; - const totalCount = await this.redisService.get(countKey); - - // 获取最近24小时的违规记录 - const now = Date.now(); - const oneDayAgo = now - 24 * 60 * 60 * 1000; - - // 统计各类型违规 - const violationsByType: Record = { - [ViolationType.CONTENT]: 0, - [ViolationType.RATE]: 0, - [ViolationType.PERMISSION]: 0, - }; - - // 注意:这里简化了实现,实际应该使用Redis的有序集合来存储和查询违规记录 - - return { - totalViolations: totalCount ? parseInt(totalCount, 10) : 0, - recentViolations: 0, // 需要更复杂的实现 - violationsByType, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('获取用户违规统计失败', { - operation: 'getUserViolationStats', - userId, - error: err.message, - }); - - return { - totalViolations: 0, - recentViolations: 0, - violationsByType: {}, - }; - } - } - - /** - * 重置用户频率限制 - * - * @param userId 用户ID - * @returns Promise - */ - async resetUserRateLimit(userId: string): Promise { - try { - const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`; - await this.redisService.del(rateLimitKey); - - this.logger.log('重置用户频率限制', { - operation: 'resetUserRateLimit', - userId, - timestamp: new Date().toISOString(), - }); - - } catch (error) { - const err = error as Error; - this.logger.error('重置用户频率限制失败', { - operation: 'resetUserRateLimit', - userId, - error: err.message, - }); - } - } - - /** - * 添加敏感词 - * - * 功能描述: - * 动态添加敏感词到过滤列表 - * - * @param word 敏感词 - * @param level 过滤级别 - * @param category 分类(可选) - * @returns void - */ - addSensitiveWord(word: string, level: 'block' | 'replace', category?: string): void { - if (!word || !word.trim()) { - this.logger.warn('添加敏感词失败:词为空', { - operation: 'addSensitiveWord', - }); - return; - } - - // 检查是否已存在 - const exists = this.sensitiveWords.some( - w => w.word.toLowerCase() === word.toLowerCase() - ); - - if (exists) { - this.logger.debug('敏感词已存在', { - operation: 'addSensitiveWord', - word, - }); - return; - } - - this.sensitiveWords.push({ - word: word.trim(), - level, - category, - }); - - this.logger.log('添加敏感词成功', { - operation: 'addSensitiveWord', - word, - level, - category, - totalCount: this.sensitiveWords.length, - }); - } - - /** - * 移除敏感词 - * - * @param word 敏感词 - * @returns boolean 是否成功移除 - */ - removeSensitiveWord(word: string): boolean { - const index = this.sensitiveWords.findIndex( - w => w.word.toLowerCase() === word.toLowerCase() - ); - - if (index === -1) { - return false; - } - - this.sensitiveWords.splice(index, 1); - - this.logger.log('移除敏感词成功', { - operation: 'removeSensitiveWord', - word, - totalCount: this.sensitiveWords.length, - }); - - return true; - } - - /** - * 获取敏感词列表 - * - * @returns SensitiveWordConfig[] 敏感词配置列表 - */ - getSensitiveWords(): SensitiveWordConfig[] { - return [...this.sensitiveWords]; - } - - /** - * 获取过滤服务统计信息 - * - * @returns 统计信息 - */ - getFilterStats(): { - sensitiveWordsCount: number; - blacklistedDomainsCount: number; - whitelistedDomainsCount: number; - rateLimit: number; - rateLimitWindow: number; - maxMessageLength: number; - } { - return { - sensitiveWordsCount: this.sensitiveWords.length, - blacklistedDomainsCount: this.BLACKLISTED_DOMAINS.length, - whitelistedDomainsCount: this.WHITELISTED_DOMAINS.length, - rateLimit: this.DEFAULT_RATE_LIMIT, - rateLimitWindow: this.RATE_LIMIT_WINDOW, - maxMessageLength: this.MAX_MESSAGE_LENGTH, - }; - } -} - diff --git a/src/business/zulip/services/session_cleanup.service.spec.ts b/src/business/zulip/services/session_cleanup.service.spec.ts deleted file mode 100644 index 70ec022..0000000 --- a/src/business/zulip/services/session_cleanup.service.spec.ts +++ /dev/null @@ -1,665 +0,0 @@ -/** - * 会话清理定时任务服务测试 - * - * 功能描述: - * - 测试SessionCleanupService的核心功能 - * - 包含属性测试验证定时清理机制 - * - 包含属性测试验证资源释放完整性 - * - * **Feature: zulip-integration, Property 13: 定时清理机制** - * **Validates: Requirements 6.1, 6.2, 6.3** - * - * **Feature: zulip-integration, Property 14: 资源释放完整性** - * **Validates: Requirements 6.4, 6.5** - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-12-31 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import * as fc from 'fast-check'; -import { - SessionCleanupService, - CleanupConfig, - CleanupResult -} from './session_cleanup.service'; -import { SessionManagerService } from './session_manager.service'; -import { IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces'; - -describe('SessionCleanupService', () => { - let service: SessionCleanupService; - let mockSessionManager: jest.Mocked; - let mockZulipClientPool: jest.Mocked; - - // 模拟清理结果 - const createMockCleanupResult = (overrides: Partial = {}): any => ({ - cleanedCount: 3, - zulipQueueIds: ['queue-1', 'queue-2', 'queue-3'], - duration: 150, - timestamp: new Date(), - ...overrides, - }); - - beforeEach(async () => { - jest.clearAllMocks(); - jest.clearAllTimers(); - // 确保每个测试开始时都使用真实定时器 - jest.useRealTimers(); - - mockSessionManager = { - cleanupExpiredSessions: jest.fn(), - getSession: jest.fn(), - destroySession: jest.fn(), - createSession: jest.fn(), - updatePlayerPosition: jest.fn(), - getSocketsInMap: jest.fn(), - injectContext: jest.fn(), - } as any; - - mockZulipClientPool = { - createUserClient: jest.fn(), - getUserClient: jest.fn(), - hasUserClient: jest.fn(), - sendMessage: jest.fn(), - registerEventQueue: jest.fn(), - deregisterEventQueue: jest.fn(), - destroyUserClient: jest.fn(), - getPoolStats: jest.fn(), - cleanupIdleClients: jest.fn(), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SessionCleanupService, - { - provide: SessionManagerService, - useValue: mockSessionManager, - }, - { - provide: 'ZULIP_CLIENT_POOL_SERVICE', - useValue: mockZulipClientPool, - }, - ], - }).compile(); - - service = module.get(SessionCleanupService); - }); - - afterEach(async () => { - // 确保停止所有清理任务 - service.stopCleanupTask(); - - // 等待任何正在进行的异步操作完成 - await new Promise(resolve => setImmediate(resolve)); - - // 清理定时器 - jest.clearAllTimers(); - - // 恢复真实定时器 - jest.useRealTimers(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('startCleanupTask - 启动清理任务', () => { - it('应该启动定时清理任务', () => { - service.startCleanupTask(); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(true); - }); - - it('应该在已启动时不重复启动', () => { - service.startCleanupTask(); - service.startCleanupTask(); // 第二次调用 - - const status = service.getStatus(); - expect(status.isEnabled).toBe(true); - }); - - it('应该立即执行一次清理', async () => { - jest.useFakeTimers(); - - mockSessionManager.cleanupExpiredSessions.mockResolvedValue( - createMockCleanupResult({ cleanedCount: 2 }) - ); - - service.startCleanupTask(); - - // 等待立即执行的清理完成 - await jest.runOnlyPendingTimersAsync(); - - expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); - - // 确保清理任务被停止 - service.stopCleanupTask(); - jest.useRealTimers(); - }); - }); - - describe('stopCleanupTask - 停止清理任务', () => { - it('应该停止定时清理任务', () => { - service.startCleanupTask(); - service.stopCleanupTask(); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(false); - }); - - it('应该在未启动时安全停止', () => { - service.stopCleanupTask(); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(false); - }); - }); - - describe('runCleanup - 执行清理', () => { - it('应该成功执行清理并返回结果', async () => { - const mockResult = createMockCleanupResult({ - cleanedCount: 5, - zulipQueueIds: ['queue-1', 'queue-2', 'queue-3', 'queue-4', 'queue-5'], - }); - - mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); - - const result = await service.runCleanup(); - - expect(result.success).toBe(true); - expect(result.cleanedSessions).toBe(5); - expect(result.deregisteredQueues).toBe(5); - expect(result.duration).toBeGreaterThanOrEqual(0); // 修改为 >= 0,因为测试环境可能很快 - expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); - }); - - it('应该处理清理过程中的错误', async () => { - const error = new Error('清理失败'); - mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); - - const result = await service.runCleanup(); - - expect(result.success).toBe(false); - expect(result.error).toBe('清理失败'); - expect(result.cleanedSessions).toBe(0); - expect(result.deregisteredQueues).toBe(0); - }); - - it('应该防止并发执行', async () => { - let resolveFirst: () => void; - const firstPromise = new Promise(resolve => { - resolveFirst = () => resolve(createMockCleanupResult()); - }); - - mockSessionManager.cleanupExpiredSessions.mockReturnValueOnce(firstPromise); - - // 同时启动两个清理任务 - const promise1 = service.runCleanup(); - const promise2 = service.runCleanup(); - - // 第二个应该立即返回失败 - const result2 = await promise2; - expect(result2.success).toBe(false); - expect(result2.error).toContain('正在执行中'); - - // 完成第一个任务 - resolveFirst!(); - const result1 = await promise1; - expect(result1.success).toBe(true); - }, 15000); - - it('应该记录最后一次清理结果', async () => { - const mockResult = createMockCleanupResult({ cleanedCount: 3 }); - mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); - - await service.runCleanup(); - - const lastResult = service.getLastCleanupResult(); - expect(lastResult).not.toBeNull(); - expect(lastResult!.cleanedSessions).toBe(3); - expect(lastResult!.success).toBe(true); - }); - }); - - describe('getStatus - 获取状态', () => { - it('应该返回正确的状态信息', () => { - const status = service.getStatus(); - - expect(status).toHaveProperty('isRunning'); - expect(status).toHaveProperty('isEnabled'); - expect(status).toHaveProperty('config'); - expect(status).toHaveProperty('lastResult'); - expect(typeof status.isRunning).toBe('boolean'); - expect(typeof status.isEnabled).toBe('boolean'); - }); - - it('应该反映任务启动状态', () => { - let status = service.getStatus(); - expect(status.isEnabled).toBe(false); - - service.startCleanupTask(); - status = service.getStatus(); - expect(status.isEnabled).toBe(true); - - service.stopCleanupTask(); - status = service.getStatus(); - expect(status.isEnabled).toBe(false); - }); - }); - - describe('updateConfig - 更新配置', () => { - it('应该更新清理配置', () => { - const newConfig: Partial = { - intervalMs: 10 * 60 * 1000, // 10分钟 - sessionTimeoutMinutes: 60, // 60分钟 - }; - - service.updateConfig(newConfig); - - const status = service.getStatus(); - expect(status.config.intervalMs).toBe(10 * 60 * 1000); - expect(status.config.sessionTimeoutMinutes).toBe(60); - }); - - it('应该在配置更改后重启任务', () => { - service.startCleanupTask(); - - const newConfig: Partial = { - intervalMs: 2 * 60 * 1000, // 2分钟 - }; - - service.updateConfig(newConfig); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(true); - expect(status.config.intervalMs).toBe(2 * 60 * 1000); - }); - - it('应该支持禁用清理任务', () => { - service.startCleanupTask(); - - service.updateConfig({ enabled: false }); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(false); - }); - }); - /** - * 属性测试: 定时清理机制 - * - * **Feature: zulip-integration, Property 13: 定时清理机制** - * **Validates: Requirements 6.1, 6.2, 6.3** - * - * 系统应该定期清理过期的游戏会话,释放相关资源, - * 并确保清理过程不影响正常的游戏服务 - */ - describe('Property 13: 定时清理机制', () => { - /** - * 属性: 对于任何有效的清理配置,系统应该按配置间隔执行清理 - * 验证需求 6.1: 系统应定期检查并清理过期的游戏会话 - */ - it('对于任何有效的清理配置,系统应该按配置间隔执行清理', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的清理间隔(1-5分钟,减少范围) - fc.integer({ min: 1, max: 5 }).map(minutes => minutes * 60 * 1000), - // 生成有效的会话超时时间(10-60分钟,减少范围) - fc.integer({ min: 10, max: 60 }), - async (intervalMs, sessionTimeoutMinutes) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - jest.useFakeTimers(); - - try { - const config: Partial = { - intervalMs, - sessionTimeoutMinutes, - enabled: true, - }; - - // 模拟清理结果 - mockSessionManager.cleanupExpiredSessions.mockResolvedValue( - createMockCleanupResult({ cleanedCount: 2 }) - ); - - service.updateConfig(config); - service.startCleanupTask(); - - // 验证配置被正确设置 - const status = service.getStatus(); - expect(status.config.intervalMs).toBe(intervalMs); - expect(status.config.sessionTimeoutMinutes).toBe(sessionTimeoutMinutes); - expect(status.isEnabled).toBe(true); - - // 验证立即执行了一次清理 - await jest.runOnlyPendingTimersAsync(); - expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(sessionTimeoutMinutes); - - } finally { - service.stopCleanupTask(); - jest.useRealTimers(); - } - } - ), - { numRuns: 20, timeout: 5000 } // 减少运行次数并添加超时 - ); - }, 15000); - - /** - * 属性: 对于任何清理操作,都应该记录清理结果和统计信息 - * 验证需求 6.2: 清理过程中系统应记录清理的会话数量和释放的资源 - */ - it('对于任何清理操作,都应该记录清理结果和统计信息', async () => { - await fc.assert( - fc.asyncProperty( - // 生成清理的会话数量 - fc.integer({ min: 0, max: 10 }), - // 生成Zulip队列ID列表 - fc.array( - fc.string({ minLength: 5, maxLength: 15 }).filter(s => s.trim().length > 0), - { minLength: 0, maxLength: 10 } - ), - async (cleanedCount, queueIds) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - - const mockResult = createMockCleanupResult({ - cleanedCount, - zulipQueueIds: queueIds.slice(0, cleanedCount), // 确保队列数量不超过清理数量 - }); - - mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); - - const result = await service.runCleanup(); - - // 验证清理结果被正确记录 - expect(result.success).toBe(true); - expect(result.cleanedSessions).toBe(cleanedCount); - expect(result.deregisteredQueues).toBe(Math.min(queueIds.length, cleanedCount)); - expect(result.duration).toBeGreaterThanOrEqual(0); - expect(result.timestamp).toBeInstanceOf(Date); - - // 验证最后一次清理结果被保存 - const lastResult = service.getLastCleanupResult(); - expect(lastResult).not.toBeNull(); - expect(lastResult!.cleanedSessions).toBe(cleanedCount); - } - ), - { numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时 - ); - }, 10000); - - /** - * 属性: 清理过程中发生错误时,系统应该正确处理并记录错误信息 - * 验证需求 6.3: 清理过程中出现错误时系统应记录错误信息并继续正常服务 - */ - it('清理过程中发生错误时,系统应该正确处理并记录错误信息', async () => { - await fc.assert( - fc.asyncProperty( - // 生成各种错误消息 - fc.string({ minLength: 5, maxLength: 50 }).filter(s => s.trim().length > 0), - async (errorMessage) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - - const error = new Error(errorMessage.trim()); - mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); - - const result = await service.runCleanup(); - - // 验证错误被正确处理 - expect(result.success).toBe(false); - expect(result.error).toBe(errorMessage.trim()); - expect(result.cleanedSessions).toBe(0); - expect(result.deregisteredQueues).toBe(0); - expect(result.duration).toBeGreaterThanOrEqual(0); - - // 验证错误结果被保存 - const lastResult = service.getLastCleanupResult(); - expect(lastResult).not.toBeNull(); - expect(lastResult!.success).toBe(false); - expect(lastResult!.error).toBe(errorMessage.trim()); - } - ), - { numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时 - ); - }, 10000); - - /** - * 属性: 并发清理请求应该被正确处理,避免重复执行 - * 验证需求 6.1: 系统应避免同时执行多个清理任务 - */ - it('并发清理请求应该被正确处理,避免重复执行', async () => { - // 重置mock - jest.clearAllMocks(); - - // 创建一个可控的Promise,使用实际的异步行为 - let resolveCleanup: (value: any) => void; - const cleanupPromise = new Promise(resolve => { - resolveCleanup = resolve; - }); - - mockSessionManager.cleanupExpiredSessions.mockReturnValue(cleanupPromise); - - // 启动第一个清理请求(应该成功) - const promise1 = service.runCleanup(); - - // 等待一个微任务周期,确保第一个请求开始执行 - await Promise.resolve(); - - // 启动第二个和第三个清理请求(应该被拒绝) - const promise2 = service.runCleanup(); - const promise3 = service.runCleanup(); - - // 第二个和第三个请求应该立即返回失败 - const result2 = await promise2; - const result3 = await promise3; - - expect(result2.success).toBe(false); - expect(result2.error).toContain('正在执行中'); - expect(result3.success).toBe(false); - expect(result3.error).toContain('正在执行中'); - - // 完成第一个清理操作 - resolveCleanup!(createMockCleanupResult({ cleanedCount: 1 })); - const result1 = await promise1; - - expect(result1.success).toBe(true); - }, 10000); - }); - /** - * 属性测试: 资源释放完整性 - * - * **Feature: zulip-integration, Property 14: 资源释放完整性** - * **Validates: Requirements 6.4, 6.5** - * - * 清理过期会话时,系统应该完整释放所有相关资源, - * 包括Zulip事件队列、内存缓存等,确保不会造成资源泄漏 - */ - describe('Property 14: 资源释放完整性', () => { - /** - * 属性: 对于任何过期会话,清理时应该释放所有相关的Zulip资源 - * 验证需求 6.4: 清理会话时系统应注销对应的Zulip事件队列 - */ - it('对于任何过期会话,清理时应该释放所有相关的Zulip资源', async () => { - await fc.assert( - fc.asyncProperty( - // 生成过期会话数量 - fc.integer({ min: 1, max: 5 }), - // 生成每个会话对应的Zulip队列ID - fc.array( - fc.string({ minLength: 8, maxLength: 15 }).filter(s => s.trim().length > 0), - { minLength: 1, maxLength: 5 } - ), - async (sessionCount, queueIds) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - - const actualQueueIds = queueIds.slice(0, sessionCount); - const mockResult = createMockCleanupResult({ - cleanedCount: sessionCount, - zulipQueueIds: actualQueueIds, - }); - - mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); - - const result = await service.runCleanup(); - - // 验证清理成功 - expect(result.success).toBe(true); - expect(result.cleanedSessions).toBe(sessionCount); - - // 验证Zulip队列被处理(这里简化为计数验证) - expect(result.deregisteredQueues).toBe(actualQueueIds.length); - - // 验证SessionManager被调用清理过期会话 - expect(mockSessionManager.cleanupExpiredSessions).toHaveBeenCalledWith(30); - } - ), - { numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时 - ); - }, 10000); - - /** - * 属性: 清理操作应该是原子性的,要么全部成功要么全部回滚 - * 验证需求 6.5: 清理过程应确保数据一致性,避免部分清理导致的不一致状态 - */ - it('清理操作应该是原子性的,要么全部成功要么全部回滚', async () => { - await fc.assert( - fc.asyncProperty( - // 生成是否模拟清理失败 - fc.boolean(), - // 生成会话数量 - fc.integer({ min: 1, max: 3 }), - async (shouldFail, sessionCount) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - - if (shouldFail) { - // 模拟清理失败 - const error = new Error('清理操作失败'); - mockSessionManager.cleanupExpiredSessions.mockRejectedValue(error); - } else { - // 模拟清理成功 - const mockResult = createMockCleanupResult({ - cleanedCount: sessionCount, - zulipQueueIds: Array.from({ length: sessionCount }, (_, i) => `queue-${i}`), - }); - mockSessionManager.cleanupExpiredSessions.mockResolvedValue(mockResult); - } - - const result = await service.runCleanup(); - - if (shouldFail) { - // 失败时应该没有任何资源被释放 - expect(result.success).toBe(false); - expect(result.cleanedSessions).toBe(0); - expect(result.deregisteredQueues).toBe(0); - expect(result.error).toBeDefined(); - } else { - // 成功时所有资源都应该被正确处理 - expect(result.success).toBe(true); - expect(result.cleanedSessions).toBe(sessionCount); - expect(result.deregisteredQueues).toBe(sessionCount); - expect(result.error).toBeUndefined(); - } - - // 验证结果的一致性 - expect(result.timestamp).toBeInstanceOf(Date); - expect(result.duration).toBeGreaterThanOrEqual(0); - } - ), - { numRuns: 20, timeout: 3000 } // 减少运行次数并添加超时 - ); - }, 10000); - - /** - * 属性: 清理配置更新应该正确重启清理任务而不丢失状态 - * 验证需求 6.5: 配置更新时系统应保持服务连续性 - */ - it('清理配置更新应该正确重启清理任务而不丢失状态', async () => { - await fc.assert( - fc.asyncProperty( - // 生成初始配置 - fc.record({ - intervalMs: fc.integer({ min: 1, max: 3 }).map(m => m * 60 * 1000), - sessionTimeoutMinutes: fc.integer({ min: 10, max: 30 }), - }), - // 生成新配置 - fc.record({ - intervalMs: fc.integer({ min: 1, max: 3 }).map(m => m * 60 * 1000), - sessionTimeoutMinutes: fc.integer({ min: 10, max: 30 }), - }), - async (initialConfig, newConfig) => { - // 重置mock以确保每次测试都是干净的状态 - jest.clearAllMocks(); - - try { - // 设置初始配置并启动任务 - service.updateConfig(initialConfig); - service.startCleanupTask(); - - let status = service.getStatus(); - expect(status.isEnabled).toBe(true); - expect(status.config.intervalMs).toBe(initialConfig.intervalMs); - - // 更新配置 - service.updateConfig(newConfig); - - // 验证配置更新后任务仍在运行 - status = service.getStatus(); - expect(status.isEnabled).toBe(true); - expect(status.config.intervalMs).toBe(newConfig.intervalMs); - expect(status.config.sessionTimeoutMinutes).toBe(newConfig.sessionTimeoutMinutes); - - } finally { - service.stopCleanupTask(); - } - } - ), - { numRuns: 15, timeout: 3000 } // 减少运行次数并添加超时 - ); - }, 10000); - }); - - describe('模块生命周期', () => { - it('应该在模块初始化时启动清理任务', async () => { - // 重新创建服务实例来测试模块初始化 - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SessionCleanupService, - { - provide: SessionManagerService, - useValue: mockSessionManager, - }, - { - provide: 'ZULIP_CLIENT_POOL_SERVICE', - useValue: mockZulipClientPool, - }, - ], - }).compile(); - - const newService = module.get(SessionCleanupService); - - // 模拟模块初始化 - await newService.onModuleInit(); - - const status = newService.getStatus(); - expect(status.isEnabled).toBe(true); - - // 清理 - await newService.onModuleDestroy(); - }); - - it('应该在模块销毁时停止清理任务', async () => { - service.startCleanupTask(); - - await service.onModuleDestroy(); - - const status = service.getStatus(); - expect(status.isEnabled).toBe(false); - }); - }); -}); \ No newline at end of file diff --git a/src/business/zulip/services/session_cleanup.service.ts b/src/business/zulip/services/session_cleanup.service.ts deleted file mode 100644 index 041349a..0000000 --- a/src/business/zulip/services/session_cleanup.service.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * 会话清理定时任务服务 - * - * 功能描述: - * - 定时清理过期的游戏会话 - * - 自动注销对应的Zulip事件队列 - * - 释放系统资源 - * - * 主要方法: - * - startCleanupTask(): 启动清理定时任务 - * - stopCleanupTask(): 停止清理定时任务 - * - runCleanup(): 执行一次清理 - * - * 使用场景: - * - 系统启动时自动启动清理任务 - * - 定期清理超时的会话数据 - * - 释放Zulip事件队列资源 - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 - */ - -import { Injectable, Logger, OnModuleInit, OnModuleDestroy, Inject } from '@nestjs/common'; -import { SessionManagerService } from './session_manager.service'; -import { IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces'; - -/** - * 清理任务配置接口 - */ -export interface CleanupConfig { - /** 清理间隔(毫秒),默认5分钟 */ - intervalMs: number; - /** 会话超时时间(分钟),默认30分钟 */ - sessionTimeoutMinutes: number; - /** 是否启用自动清理,默认true */ - enabled: boolean; -} - -/** - * 清理结果接口 - */ -export interface CleanupResult { - /** 清理的会话数量 */ - cleanedSessions: number; - /** 注销的Zulip队列数量 */ - deregisteredQueues: number; - /** 清理耗时(毫秒) */ - duration: number; - /** 清理时间 */ - timestamp: Date; - /** 是否成功 */ - success: boolean; - /** 错误信息(如果有) */ - error?: string; -} - -/** - * 会话清理服务类 - * - * 职责: - * - 定时清理过期的游戏会话 - * - 释放无效的Zulip客户端资源 - * - 维护会话数据的一致性 - * - 提供会话清理统计和监控 - * - * 主要方法: - * - startCleanup(): 启动定时清理任务 - * - stopCleanup(): 停止清理任务 - * - performCleanup(): 执行一次清理操作 - * - getCleanupStats(): 获取清理统计信息 - * - updateConfig(): 更新清理配置 - * - * 使用场景: - * - 系统启动时自动开始清理任务 - * - 定期清理过期会话和资源 - * - 系统关闭时停止清理任务 - * - 监控清理效果和系统健康 - */ -@Injectable() -export class SessionCleanupService implements OnModuleInit, OnModuleDestroy { - private cleanupInterval: NodeJS.Timeout | null = null; - private isRunning = false; - private lastCleanupResult: CleanupResult | null = null; - private readonly logger = new Logger(SessionCleanupService.name); - - private readonly config: CleanupConfig = { - intervalMs: 5 * 60 * 1000, // 5分钟 - sessionTimeoutMinutes: 30, // 30分钟 - enabled: true, - }; - - constructor( - private readonly sessionManager: SessionManagerService, - @Inject('ZULIP_CLIENT_POOL_SERVICE') - private readonly zulipClientPool: IZulipClientPoolService, - ) { - this.logger.log('SessionCleanupService初始化完成'); - } - - /** - * 模块初始化时启动清理任务 - */ - async onModuleInit(): Promise { - if (this.config.enabled) { - this.startCleanupTask(); - } - } - - /** - * 模块销毁时停止清理任务 - */ - async onModuleDestroy(): Promise { - this.stopCleanupTask(); - } - - /** - * 启动清理定时任务 - * - * 功能描述: - * 启动定时任务,按配置的间隔定期清理过期会话 - */ - startCleanupTask(): void { - if (this.cleanupInterval) { - this.logger.warn('清理任务已在运行中', { - operation: 'startCleanupTask', - }); - return; - } - - this.logger.log('启动会话清理定时任务', { - operation: 'startCleanupTask', - intervalMs: this.config.intervalMs, - sessionTimeoutMinutes: this.config.sessionTimeoutMinutes, - timestamp: new Date().toISOString(), - }); - - this.cleanupInterval = setInterval(async () => { - await this.runCleanup(); - }, this.config.intervalMs); - - // 立即执行一次清理 - this.runCleanup(); - } - - /** - * 停止清理定时任务 - */ - stopCleanupTask(): void { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - - this.logger.log('停止会话清理定时任务', { - operation: 'stopCleanupTask', - timestamp: new Date().toISOString(), - }); - } - } - - /** - * 获取当前定时器引用(用于测试) - */ - getCleanupInterval(): NodeJS.Timeout | null { - return this.cleanupInterval; - } - - /** - * 执行一次清理 - * - * 功能描述: - * 执行一次完整的清理流程: - * 1. 清理过期会话 - * 2. 注销对应的Zulip事件队列 - * - * @returns Promise 清理结果 - */ - async runCleanup(): Promise { - if (this.isRunning) { - this.logger.warn('清理任务正在执行中,跳过本次执行', { - operation: 'runCleanup', - }); - return { - cleanedSessions: 0, - deregisteredQueues: 0, - duration: 0, - timestamp: new Date(), - success: false, - error: '清理任务正在执行中', - }; - } - - this.isRunning = true; - const startTime = Date.now(); - - this.logger.log('开始执行会话清理', { - operation: 'runCleanup', - timestamp: new Date().toISOString(), - }); - - try { - // 1. 清理过期会话 - const cleanupResult = await this.sessionManager.cleanupExpiredSessions( - this.config.sessionTimeoutMinutes - ); - - // 2. 注销对应的Zulip事件队列 - let deregisteredQueues = 0; - const queueIds = cleanupResult?.zulipQueueIds || []; - for (const queueId of queueIds) { - try { - // 根据queueId找到对应的用户并注销队列 - // 注意:这里需要通过某种方式找到queueId对应的userId - // 由于会话已被清理,我们需要在清理前记录userId - // 这里简化处理,直接尝试注销 - this.logger.debug('尝试注销Zulip队列', { - operation: 'runCleanup', - queueId, - }); - deregisteredQueues++; - } catch (deregisterError) { - const err = deregisterError as Error; - this.logger.warn('注销Zulip队列失败', { - operation: 'runCleanup', - queueId, - error: err.message, - }); - } - } - - const duration = Date.now() - startTime; - - const result: CleanupResult = { - cleanedSessions: cleanupResult?.cleanedCount || 0, - deregisteredQueues, - duration, - timestamp: new Date(), - success: true, - }; - - this.lastCleanupResult = result; - - this.logger.log('会话清理完成', { - operation: 'runCleanup', - cleanedSessions: result.cleanedSessions, - deregisteredQueues: result.deregisteredQueues, - duration, - timestamp: new Date().toISOString(), - }); - - return result; - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - const result: CleanupResult = { - cleanedSessions: 0, - deregisteredQueues: 0, - duration, - timestamp: new Date(), - success: false, - error: err.message, - }; - - this.lastCleanupResult = result; - - this.logger.error('会话清理失败', { - operation: 'runCleanup', - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - return result; - - } finally { - this.isRunning = false; - } - } - - /** - * 获取最后一次清理结果 - * - * @returns CleanupResult | null 最后一次清理结果 - */ - getLastCleanupResult(): CleanupResult | null { - return this.lastCleanupResult; - } - - /** - * 获取清理任务状态 - * - * @returns 清理任务状态信息 - */ - getStatus(): { - isRunning: boolean; - isEnabled: boolean; - config: CleanupConfig; - lastResult: CleanupResult | null; - } { - return { - isRunning: this.isRunning, - isEnabled: this.cleanupInterval !== null, - config: this.config, - lastResult: this.lastCleanupResult, - }; - } - - /** - * 更新清理配置 - * - * @param config 新的配置 - */ - updateConfig(config: Partial): void { - const wasEnabled = this.cleanupInterval !== null; - - if (config.intervalMs !== undefined) { - this.config.intervalMs = config.intervalMs; - } - if (config.sessionTimeoutMinutes !== undefined) { - this.config.sessionTimeoutMinutes = config.sessionTimeoutMinutes; - } - if (config.enabled !== undefined) { - this.config.enabled = config.enabled; - } - - this.logger.log('更新清理配置', { - operation: 'updateConfig', - config: this.config, - timestamp: new Date().toISOString(), - }); - - // 如果配置改变,重启任务 - if (wasEnabled) { - this.stopCleanupTask(); - if (this.config.enabled) { - this.startCleanupTask(); - } - } - } -} - - diff --git a/src/business/zulip/services/session_manager.service.spec.ts b/src/business/zulip/services/session_manager.service.spec.ts deleted file mode 100644 index 9a5e91c..0000000 --- a/src/business/zulip/services/session_manager.service.spec.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * 会话管理服务测试 - * - * 功能描述: - * - 测试SessionManagerService的核心功能 - * - 包含属性测试验证会话状态一致性 - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import * as fc from 'fast-check'; -import { SessionManagerService, GameSession, Position } from './session_manager.service'; -import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; -import { AppLoggerService } from '../../../core/utils/logger/logger.service'; -import { IRedisService } from '../../../core/redis/redis.interface'; - -describe('SessionManagerService', () => { - let service: SessionManagerService; - let mockLogger: jest.Mocked; - let mockRedisService: jest.Mocked; - let mockConfigManager: jest.Mocked; - - // 内存存储模拟Redis - let memoryStore: Map; - let memorySets: Map>; - - beforeEach(async () => { - jest.clearAllMocks(); - - // 初始化内存存储 - memoryStore = new Map(); - memorySets = new Map(); - - mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - } as any; - - mockConfigManager = { - getStreamByMap: jest.fn().mockImplementation((mapId: string) => { - const streamMap: Record = { - 'whale_port': 'Whale Port', - 'pumpkin_valley': 'Pumpkin Valley', - 'offer_city': 'Offer City', - 'model_factory': 'Model Factory', - 'kernel_island': 'Kernel Island', - 'moyu_beach': 'Moyu Beach', - 'ladder_peak': 'Ladder Peak', - 'galaxy_bay': 'Galaxy Bay', - 'data_ruins': 'Data Ruins', - 'novice_village': 'Novice Village', - }; - return streamMap[mapId] || 'General'; - }), - getMapIdByStream: jest.fn(), - getTopicByObject: jest.fn().mockReturnValue('General'), - findNearbyObject: jest.fn().mockReturnValue(null), - getZulipConfig: jest.fn(), - hasMap: jest.fn(), - hasStream: jest.fn(), - getAllMapIds: jest.fn(), - getAllStreams: jest.fn(), - reloadConfig: jest.fn(), - validateConfig: jest.fn(), - } as any; - - // 创建模拟Redis服务,使用内存存储 - mockRedisService = { - set: jest.fn().mockImplementation(async (key: string, value: string, ttl?: number) => { - memoryStore.set(key, { - value, - expireAt: ttl ? Date.now() + ttl * 1000 : undefined - }); - }), - setex: jest.fn().mockImplementation(async (key: string, ttl: number, value: string) => { - memoryStore.set(key, { - value, - expireAt: Date.now() + ttl * 1000 - }); - }), - get: jest.fn().mockImplementation(async (key: string) => { - const item = memoryStore.get(key); - if (!item) return null; - if (item.expireAt && item.expireAt <= Date.now()) { - memoryStore.delete(key); - return null; - } - return item.value; - }), - del: jest.fn().mockImplementation(async (key: string) => { - const existed = memoryStore.has(key); - memoryStore.delete(key); - return existed; - }), - exists: jest.fn().mockImplementation(async (key: string) => { - return memoryStore.has(key); - }), - expire: jest.fn().mockImplementation(async (key: string, ttl: number) => { - const item = memoryStore.get(key); - if (item) { - item.expireAt = Date.now() + ttl * 1000; - } - }), - ttl: jest.fn().mockResolvedValue(3600), - incr: jest.fn().mockResolvedValue(1), - sadd: jest.fn().mockImplementation(async (key: string, member: string) => { - if (!memorySets.has(key)) { - memorySets.set(key, new Set()); - } - memorySets.get(key)!.add(member); - }), - srem: jest.fn().mockImplementation(async (key: string, member: string) => { - const set = memorySets.get(key); - if (set) { - set.delete(member); - } - }), - smembers: jest.fn().mockImplementation(async (key: string) => { - const set = memorySets.get(key); - return set ? Array.from(set) : []; - }), - flushall: jest.fn().mockImplementation(async () => { - memoryStore.clear(); - memorySets.clear(); - }), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - SessionManagerService, - { - provide: AppLoggerService, - useValue: mockLogger, - }, - { - provide: 'REDIS_SERVICE', - useValue: mockRedisService, - }, - { - provide: 'ZULIP_CONFIG_SERVICE', - useValue: mockConfigManager, - }, - ], - }).compile(); - - service = module.get(SessionManagerService); - }); - - afterEach(async () => { - // 清理内存存储 - memoryStore.clear(); - memorySets.clear(); - - // 等待任何正在进行的异步操作完成 - await new Promise(resolve => setImmediate(resolve)); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('createSession - 创建会话', () => { - it('应该成功创建新会话', async () => { - const session = await service.createSession( - 'socket-123', - 'user-456', - 'queue-789', - 'TestUser', - ); - - expect(session).toBeDefined(); - expect(session.socketId).toBe('socket-123'); - expect(session.userId).toBe('user-456'); - expect(session.zulipQueueId).toBe('queue-789'); - expect(session.username).toBe('TestUser'); - expect(session.currentMap).toBe('novice_village'); - }); - - it('应该在socketId为空时抛出错误', async () => { - await expect(service.createSession('', 'user-456', 'queue-789')) - .rejects.toThrow('socketId不能为空'); - }); - - it('应该在userId为空时抛出错误', async () => { - await expect(service.createSession('socket-123', '', 'queue-789')) - .rejects.toThrow('userId不能为空'); - }); - - it('应该在zulipQueueId为空时抛出错误', async () => { - await expect(service.createSession('socket-123', 'user-456', '')) - .rejects.toThrow('zulipQueueId不能为空'); - }); - - it('应该清理用户已有的旧会话', async () => { - // 创建第一个会话 - await service.createSession('socket-old', 'user-456', 'queue-old'); - - // 创建第二个会话(同一用户) - const newSession = await service.createSession('socket-new', 'user-456', 'queue-new'); - - expect(newSession.socketId).toBe('socket-new'); - - // 旧会话应该被清理 - const oldSession = await service.getSession('socket-old'); - expect(oldSession).toBeNull(); - }); - }); - - describe('getSession - 获取会话', () => { - it('应该返回已存在的会话', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const session = await service.getSession('socket-123'); - - expect(session).toBeDefined(); - expect(session?.socketId).toBe('socket-123'); - }); - - it('应该在会话不存在时返回null', async () => { - const session = await service.getSession('nonexistent'); - - expect(session).toBeNull(); - }); - - it('应该在socketId为空时返回null', async () => { - const session = await service.getSession(''); - - expect(session).toBeNull(); - }); - }); - - describe('getSessionByUserId - 根据用户ID获取会话', () => { - it('应该返回用户的会话', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const session = await service.getSessionByUserId('user-456'); - - expect(session).toBeDefined(); - expect(session?.userId).toBe('user-456'); - }); - - it('应该在用户没有会话时返回null', async () => { - const session = await service.getSessionByUserId('nonexistent'); - - expect(session).toBeNull(); - }); - }); - - describe('updatePlayerPosition - 更新玩家位置', () => { - it('应该成功更新位置', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const result = await service.updatePlayerPosition('socket-123', 'novice_village', 100, 200); - - expect(result).toBe(true); - - const session = await service.getSession('socket-123'); - expect(session?.position).toEqual({ x: 100, y: 200 }); - }); - - it('应该在切换地图时更新地图玩家列表', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const result = await service.updatePlayerPosition('socket-123', 'tavern', 150, 250); - - expect(result).toBe(true); - - const session = await service.getSession('socket-123'); - expect(session?.currentMap).toBe('tavern'); - - // 验证地图玩家列表更新 - const tavernPlayers = await service.getSocketsInMap('tavern'); - expect(tavernPlayers).toContain('socket-123'); - - const villagePlayers = await service.getSocketsInMap('novice_village'); - expect(villagePlayers).not.toContain('socket-123'); - }); - - it('应该在会话不存在时返回false', async () => { - const result = await service.updatePlayerPosition('nonexistent', 'tavern', 100, 200); - - expect(result).toBe(false); - }); - }); - - describe('destroySession - 销毁会话', () => { - it('应该成功销毁会话', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const result = await service.destroySession('socket-123'); - - expect(result).toBe(true); - - const session = await service.getSession('socket-123'); - expect(session).toBeNull(); - }); - - it('应该在会话不存在时返回true', async () => { - const result = await service.destroySession('nonexistent'); - - expect(result).toBe(true); - }); - - it('应该清理用户会话映射', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - await service.destroySession('socket-123'); - - const session = await service.getSessionByUserId('user-456'); - expect(session).toBeNull(); - }); - }); - - describe('getSocketsInMap - 获取地图玩家列表', () => { - it('应该返回地图中的所有玩家', async () => { - await service.createSession('socket-1', 'user-1', 'queue-1'); - await service.createSession('socket-2', 'user-2', 'queue-2'); - - const sockets = await service.getSocketsInMap('novice_village'); - - expect(sockets).toHaveLength(2); - expect(sockets).toContain('socket-1'); - expect(sockets).toContain('socket-2'); - }); - - it('应该在地图为空时返回空数组', async () => { - const sockets = await service.getSocketsInMap('empty_map'); - - expect(sockets).toHaveLength(0); - }); - }); - - describe('injectContext - 上下文注入', () => { - it('应该返回正确的Stream', async () => { - await service.createSession('socket-123', 'user-456', 'queue-789'); - - const context = await service.injectContext('socket-123'); - - expect(context.stream).toBe('Novice Village'); - }); - - it('应该在会话不存在时返回默认上下文', async () => { - const context = await service.injectContext('nonexistent'); - - expect(context.stream).toBe('General'); - }); - }); - - - /** - * 属性测试: 会话状态一致性 - * - * **Feature: zulip-integration, Property 6: 会话状态一致性** - * **Validates: Requirements 6.1, 6.2, 6.3, 6.5** - * - * 对于任何玩家会话,系统应该在Redis中正确维护WebSocket ID与Zulip队列ID的映射关系, - * 及时更新位置信息,并支持服务重启后的状态恢复 - */ - describe('Property 6: 会话状态一致性', () => { - /** - * 属性: 对于任何有效的会话参数,创建会话后应该能够正确获取 - * 验证需求 6.1: 玩家登录成功后系统应在Redis中存储WebSocket ID与Zulip队列ID的映射关系 - */ - it('对于任何有效的会话参数,创建会话后应该能够正确获取', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的userId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的zulipQueueId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的username - fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), - async (socketId, userId, zulipQueueId, username) => { - // 清理之前的数据 - memoryStore.clear(); - memorySets.clear(); - - // 创建会话 - const createdSession = await service.createSession( - socketId.trim(), - userId.trim(), - zulipQueueId.trim(), - username.trim(), - ); - - // 验证创建的会话 - expect(createdSession.socketId).toBe(socketId.trim()); - expect(createdSession.userId).toBe(userId.trim()); - expect(createdSession.zulipQueueId).toBe(zulipQueueId.trim()); - expect(createdSession.username).toBe(username.trim()); - - // 获取会话并验证一致性 - const retrievedSession = await service.getSession(socketId.trim()); - expect(retrievedSession).not.toBeNull(); - expect(retrievedSession?.socketId).toBe(createdSession.socketId); - expect(retrievedSession?.userId).toBe(createdSession.userId); - expect(retrievedSession?.zulipQueueId).toBe(createdSession.zulipQueueId); - } - ), - { numRuns: 50, timeout: 5000 } // 添加超时控制 - ); - }, 30000); - - /** - * 属性: 对于任何位置更新,会话应该正确反映新位置 - * 验证需求 6.2: 玩家切换地图时系统应更新玩家的当前位置信息 - */ - it('对于任何位置更新,会话应该正确反映新位置', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的userId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的地图ID - fc.constantFrom('novice_village', 'tavern', 'market'), - // 生成有效的坐标 - fc.integer({ min: 0, max: 1000 }), - fc.integer({ min: 0, max: 1000 }), - async (socketId, userId, mapId, x, y) => { - // 清理之前的数据 - memoryStore.clear(); - memorySets.clear(); - - // 创建会话 - await service.createSession( - socketId.trim(), - userId.trim(), - 'queue-test', - ); - - // 更新位置 - const updateResult = await service.updatePlayerPosition( - socketId.trim(), - mapId, - x, - y, - ); - - expect(updateResult).toBe(true); - - // 验证位置更新 - const session = await service.getSession(socketId.trim()); - expect(session).not.toBeNull(); - expect(session?.currentMap).toBe(mapId); - expect(session?.position.x).toBe(x); - expect(session?.position.y).toBe(y); - } - ), - { numRuns: 50, timeout: 5000 } // 添加超时控制 - ); - }, 30000); - - /** - * 属性: 对于任何地图切换,玩家应该从旧地图移除并添加到新地图 - * 验证需求 6.3: 查询在线玩家时系统应从Redis中获取当前活跃的会话列表 - */ - it('对于任何地图切换,玩家应该从旧地图移除并添加到新地图', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的userId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成初始地图和目标地图(确保不同) - fc.constantFrom('novice_village', 'tavern', 'market'), - fc.constantFrom('novice_village', 'tavern', 'market'), - async (socketId, userId, initialMap, targetMap) => { - // 清理之前的数据 - memoryStore.clear(); - memorySets.clear(); - - // 创建会话(使用初始地图) - await service.createSession( - socketId.trim(), - userId.trim(), - 'queue-test', - 'TestUser', - initialMap, - ); - - // 验证初始地图包含玩家 - const initialPlayers = await service.getSocketsInMap(initialMap); - expect(initialPlayers).toContain(socketId.trim()); - - // 如果目标地图不同,切换地图 - if (initialMap !== targetMap) { - await service.updatePlayerPosition(socketId.trim(), targetMap, 100, 100); - - // 验证旧地图不再包含玩家 - const oldMapPlayers = await service.getSocketsInMap(initialMap); - expect(oldMapPlayers).not.toContain(socketId.trim()); - - // 验证新地图包含玩家 - const newMapPlayers = await service.getSocketsInMap(targetMap); - expect(newMapPlayers).toContain(socketId.trim()); - } - } - ), - { numRuns: 50, timeout: 5000 } // 添加超时控制 - ); - }, 30000); - - /** - * 属性: 对于任何会话销毁,所有相关数据应该被清理 - * 验证需求 6.5: 服务器重启时系统应能够从Redis中恢复会话状态(通过验证销毁后数据被正确清理) - */ - it('对于任何会话销毁,所有相关数据应该被清理', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的userId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的地图ID - fc.constantFrom('novice_village', 'tavern', 'market'), - async (socketId, userId, mapId) => { - // 清理之前的数据 - memoryStore.clear(); - memorySets.clear(); - - // 创建会话 - await service.createSession( - socketId.trim(), - userId.trim(), - 'queue-test', - 'TestUser', - mapId, - ); - - // 验证会话存在 - const sessionBefore = await service.getSession(socketId.trim()); - expect(sessionBefore).not.toBeNull(); - - // 销毁会话 - const destroyResult = await service.destroySession(socketId.trim()); - expect(destroyResult).toBe(true); - - // 验证会话被清理 - const sessionAfter = await service.getSession(socketId.trim()); - expect(sessionAfter).toBeNull(); - - // 验证用户会话映射被清理 - const userSession = await service.getSessionByUserId(userId.trim()); - expect(userSession).toBeNull(); - - // 验证地图玩家列表被清理 - const mapPlayers = await service.getSocketsInMap(mapId); - expect(mapPlayers).not.toContain(socketId.trim()); - } - ), - { numRuns: 50, timeout: 5000 } // 添加超时控制 - ); - }, 30000); - - /** - * 属性: 创建-更新-销毁的完整生命周期应该正确管理会话状态 - */ - it('创建-更新-销毁的完整生命周期应该正确管理会话状态', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的userId - fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成位置更新序列 - fc.array( - fc.record({ - mapId: fc.constantFrom('novice_village', 'tavern', 'market'), - x: fc.integer({ min: 0, max: 1000 }), - y: fc.integer({ min: 0, max: 1000 }), - }), - { minLength: 1, maxLength: 5 } - ), - async (socketId, userId, positionUpdates) => { - // 清理之前的数据 - memoryStore.clear(); - memorySets.clear(); - - // 1. 创建会话 - const session = await service.createSession( - socketId.trim(), - userId.trim(), - 'queue-test', - ); - expect(session).toBeDefined(); - - // 2. 执行位置更新序列 - for (const update of positionUpdates) { - const result = await service.updatePlayerPosition( - socketId.trim(), - update.mapId, - update.x, - update.y, - ); - expect(result).toBe(true); - - // 验证每次更新后的状态 - const currentSession = await service.getSession(socketId.trim()); - expect(currentSession?.currentMap).toBe(update.mapId); - expect(currentSession?.position.x).toBe(update.x); - expect(currentSession?.position.y).toBe(update.y); - } - - // 3. 销毁会话 - const destroyResult = await service.destroySession(socketId.trim()); - expect(destroyResult).toBe(true); - - // 4. 验证所有数据被清理 - const finalSession = await service.getSession(socketId.trim()); - expect(finalSession).toBeNull(); - } - ), - { numRuns: 50, timeout: 5000 } // 添加超时控制 - ); - }, 30000); - }); -}); diff --git a/src/business/zulip/services/session_manager.service.ts b/src/business/zulip/services/session_manager.service.ts deleted file mode 100644 index 7aab09b..0000000 --- a/src/business/zulip/services/session_manager.service.ts +++ /dev/null @@ -1,1028 +0,0 @@ -/** - * 会话管理服务 - * - * 功能描述: - * - 维护WebSocket连接ID与Zulip队列ID的映射关系 - * - 管理玩家位置跟踪和上下文注入 - * - 提供空间过滤和会话查询功能 - * - 支持会话状态的序列化和反序列化 - * - 支持服务重启后的状态恢复 - * - * 职责分离: - * - 会话存储:管理会话数据在Redis中的存储和检索 - * - 位置跟踪:维护玩家在游戏世界中的位置信息 - * - 上下文注入:根据玩家位置确定消息的目标Stream和Topic - * - 空间过滤:根据地图ID筛选相关的玩家会话 - * - 资源清理:定期清理过期会话和释放相关资源 - * - * 主要方法: - * - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID - * - getSession(): 获取会话信息 - * - injectContext(): 上下文注入,根据位置确定Stream/Topic - * - getSocketsInMap(): 空间过滤,获取指定地图的所有Socket - * - updatePlayerPosition(): 更新玩家位置 - * - destroySession(): 销毁会话 - * - cleanupExpiredSessions(): 清理过期会话 - * - * Redis存储结构: - * - 会话数据: zulip:session:{socketId} -> JSON(GameSession) - * - 地图玩家列表: zulip:map_players:{mapId} -> Set - * - 用户会话映射: zulip:user_session:{userId} -> socketId - * - * 使用场景: - * - 玩家登录时创建会话映射 - * - 消息路由时进行上下文注入 - * - 消息分发时进行空间过滤 - * - 玩家登出时清理会话数据 - * - * 最近修改: - * - 2026-01-12: 代码规范优化 - 处理TODO项,实现玩家位置确定Topic逻辑,从配置获取地图ID列表 (修改者: moyin) - * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) - * - * @author angjustinl - * @version 1.1.0 - * @since 2025-12-25 - * @lastModified 2026-01-12 - */ - -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { IRedisService } from '../../../core/redis/redis.interface'; -import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; -import { Internal, Constants } from '../../../core/zulip_core/zulip.interfaces'; - -// 常量定义 -const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const; -const SESSION_TIMEOUT_MINUTES = 30; -const CLEANUP_INTERVAL_MINUTES = 5; - -/** - * 游戏会话接口 - 重新导出以保持向后兼容 - */ -export type GameSession = Internal.GameSession; - -/** - * 位置信息接口 - 重新导出以保持向后兼容 - */ -export type Position = Internal.Position; - -/** - * 上下文信息接口 - */ -export interface ContextInfo { - stream: string; - topic?: string; -} - -/** - * 创建会话请求接口 - */ -export interface CreateSessionRequest { - socketId: string; - userId: string; - username?: string; - zulipQueueId: string; - initialMap?: string; - initialPosition?: Position; -} - -/** - * 会话统计信息接口 - */ -export interface SessionStats { - totalSessions: number; - mapDistribution: Record; - oldestSession?: Date; - newestSession?: Date; -} - -/** - * 会话管理服务类 - * - * 职责: - * - 维护WebSocket连接ID与Zulip队列ID的映射关系 - * - 管理玩家位置跟踪和上下文注入 - * - 提供空间过滤和会话查询功能 - * - 支持会话状态的序列化和反序列化 - * - * 主要方法: - * - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID - * - getSession(): 获取会话信息 - * - injectContext(): 上下文注入,根据位置确定Stream/Topic - * - getSocketsInMap(): 空间过滤,获取指定地图的所有Socket - * - updatePlayerPosition(): 更新玩家位置 - * - destroySession(): 销毁会话 - * - * 使用场景: - * - 玩家登录时创建会话映射 - * - 消息路由时进行上下文注入 - * - 消息分发时进行空间过滤 - * - 玩家登出时清理会话数据 - */ -@Injectable() -export class SessionManagerService { - private readonly SESSION_PREFIX = 'zulip:session:'; - private readonly MAP_PLAYERS_PREFIX = 'zulip:map_players:'; - private readonly USER_SESSION_PREFIX = 'zulip:user_session:'; - private readonly SESSION_TIMEOUT = 3600; // 1小时 - private readonly DEFAULT_MAP = 'novice_village'; - private readonly DEFAULT_POSITION: Position = { x: 400, y: 300 }; - private readonly logger = new Logger(SessionManagerService.name); - - constructor( - @Inject('REDIS_SERVICE') - private readonly redisService: IRedisService, - @Inject('ZULIP_CONFIG_SERVICE') - private readonly configManager: IZulipConfigService, - ) { - this.logger.log('SessionManagerService初始化完成'); - } - - /** - * 序列化会话对象为JSON字符串 - * - * 功能描述: - * 将GameSession对象转换为可存储在Redis中的JSON字符串 - * - * @param session 会话对象 - * @returns string JSON字符串 - * @private - */ - private serializeSession(session: GameSession): string { - const serialized: Internal.GameSessionSerialized = { - socketId: session.socketId, - userId: session.userId, - username: session.username, - zulipQueueId: session.zulipQueueId, - currentMap: session.currentMap, - position: session.position, - lastActivity: session.lastActivity instanceof Date - ? session.lastActivity.toISOString() - : session.lastActivity, - createdAt: session.createdAt instanceof Date - ? session.createdAt.toISOString() - : session.createdAt, - }; - return JSON.stringify(serialized); - } - - /** - * 反序列化JSON字符串为会话对象 - * - * 功能描述: - * 将Redis中存储的JSON字符串转换回GameSession对象 - * - * @param data JSON字符串 - * @returns GameSession 会话对象 - * @private - */ - private deserializeSession(data: string): GameSession { - const parsed: Internal.GameSessionSerialized = JSON.parse(data); - return { - socketId: parsed.socketId, - userId: parsed.userId, - username: parsed.username, - zulipQueueId: parsed.zulipQueueId, - currentMap: parsed.currentMap, - position: parsed.position, - lastActivity: new Date(parsed.lastActivity), - createdAt: new Date(parsed.createdAt), - }; - } - - /** - * 创建会话并绑定Socket_ID与Zulip_Queue_ID - * - * 功能描述: - * 创建新的游戏会话,建立WebSocket连接与Zulip队列的映射关系 - * - * 业务逻辑: - * 1. 验证输入参数 - * 2. 检查用户是否已有会话(如有则先清理) - * 3. 创建会话对象 - * 4. 存储到Redis缓存 - * 5. 添加到地图玩家列表 - * 6. 建立用户到会话的映射 - * 7. 设置过期时间 - * - * @param socketId WebSocket连接ID - * @param userId 用户ID - * @param zulipQueueId Zulip事件队列ID - * @param username 用户名(可选) - * @param initialMap 初始地图(可选) - * @param initialPosition 初始位置(可选) - * @returns Promise 创建的会话对象 - * - * @throws Error 当参数验证失败时 - * @throws Error 当Redis操作失败时 - */ - async createSession( - socketId: string, - userId: string, - zulipQueueId: string, - username?: string, - initialMap?: string, - initialPosition?: Position, - ): Promise { - const startTime = Date.now(); - - this.logger.log('开始创建游戏会话', { - operation: 'createSession', - socketId, - userId, - zulipQueueId, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 参数验证 - if (!socketId || !socketId.trim()) { - throw new Error('socketId不能为空'); - } - if (!userId || !userId.trim()) { - throw new Error('userId不能为空'); - } - if (!zulipQueueId || !zulipQueueId.trim()) { - throw new Error('zulipQueueId不能为空'); - } - - // 2. 检查用户是否已有会话,如有则先清理 - const existingSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${userId}`); - if (existingSocketId) { - this.logger.log('用户已有会话,先清理旧会话', { - operation: 'createSession', - userId, - existingSocketId, - }); - await this.destroySession(existingSocketId); - } - - // 3. 创建会话对象 - const now = new Date(); - const session: GameSession = { - socketId, - userId, - username: username || `user_${userId}`, - zulipQueueId, - currentMap: initialMap || this.DEFAULT_MAP, - position: initialPosition || { ...this.DEFAULT_POSITION }, - lastActivity: now, - createdAt: now, - }; - - // 4. 存储会话到Redis - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); - - // 5. 添加到地图玩家列表 - const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`; - await this.redisService.sadd(mapKey, socketId); - await this.redisService.expire(mapKey, this.SESSION_TIMEOUT); - - // 6. 建立用户到会话的映射 - const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`; - await this.redisService.setex(userSessionKey, this.SESSION_TIMEOUT, socketId); - - const duration = Date.now() - startTime; - - this.logger.log('游戏会话创建成功', { - operation: 'createSession', - socketId, - userId, - zulipQueueId, - currentMap: session.currentMap, - duration, - timestamp: new Date().toISOString(), - }); - - return session; - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('创建游戏会话失败', { - operation: 'createSession', - socketId, - userId, - zulipQueueId, - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - throw error; - } - } - - /** - * 获取会话信息 - * - * 功能描述: - * 根据socketId获取会话信息,并更新最后活动时间 - * - * @param socketId WebSocket连接ID - * @returns Promise 会话信息,不存在时返回null - */ - async getSession(socketId: string): Promise { - try { - if (!socketId || !socketId.trim()) { - this.logger.warn('获取会话失败:socketId为空', { - operation: 'getSession', - }); - return null; - } - - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - const sessionData = await this.redisService.get(sessionKey); - - if (!sessionData) { - this.logger.debug('会话不存在', { - operation: 'getSession', - socketId, - }); - return null; - } - - const session = this.deserializeSession(sessionData); - - // 更新最后活动时间 - session.lastActivity = new Date(); - await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); - - this.logger.debug('获取会话信息成功', { - operation: 'getSession', - socketId, - userId: session.userId, - currentMap: session.currentMap, - }); - - return session; - - } catch (error) { - const err = error as Error; - this.logger.error('获取会话信息失败', { - operation: 'getSession', - socketId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return null; - } - } - - /** - * 根据用户ID获取会话信息 - * - * 功能描述: - * 根据userId查找对应的会话信息 - * - * @param userId 用户ID - * @returns Promise 会话信息,不存在时返回null - */ - async getSessionByUserId(userId: string): Promise { - try { - if (!userId || !userId.trim()) { - return null; - } - - const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`; - const socketId = await this.redisService.get(userSessionKey); - - if (!socketId) { - return null; - } - - return this.getSession(socketId); - - } catch (error) { - const err = error as Error; - this.logger.error('根据用户ID获取会话失败', { - operation: 'getSessionByUserId', - userId, - error: err.message, - }, err.stack); - - return null; - } - } - - /** - * 上下文注入:根据位置确定Stream/Topic - * - * 功能描述: - * 根据玩家当前位置和地图信息,确定消息应该发送到的Zulip Stream和Topic - * - * 业务逻辑: - * 1. 获取玩家会话信息 - * 2. 根据地图ID查找对应的Stream - * 3. 根据玩家位置确定Topic(如果有交互对象) - * 4. 返回上下文信息 - * - * @param socketId WebSocket连接ID - * @param mapId 地图ID(可选,用于覆盖当前地图) - * @returns Promise 上下文信息 - * - * @throws Error 当会话不存在时 - */ - async injectContext(socketId: string, mapId?: string): Promise { - this.logger.debug('开始上下文注入', { - operation: 'injectContext', - socketId, - mapId, - timestamp: new Date().toISOString(), - }); - - try { - const session = await this.getSession(socketId); - if (!session) { - throw new Error('会话不存在'); - } - - const targetMapId = mapId || session.currentMap; - - // 从ConfigManager获取地图对应的Stream - const stream = this.configManager.getStreamByMap(targetMapId) || 'General'; - - // 根据玩家位置确定Topic(基础实现) - // 检查是否靠近交互对象,如果没有则使用默认Topic - let topic = 'General'; - - // 尝试根据位置查找附近的交互对象 - if (session.position) { - const nearbyObject = this.configManager.findNearbyObject( - targetMapId, - session.position.x, - session.position.y, - 50 // 50像素范围内 - ); - - if (nearbyObject) { - topic = nearbyObject.zulipTopic; - } - } - - const context: ContextInfo = { - stream, - topic, - }; - - this.logger.debug('上下文注入完成', { - operation: 'injectContext', - socketId, - targetMapId, - stream: context.stream, - topic: context.topic, - }); - - return context; - - } catch (error) { - const err = error as Error; - this.logger.error('上下文注入失败', { - operation: 'injectContext', - socketId, - mapId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - // 返回默认上下文 - return { - stream: 'General', - }; - } - } - - /** - * 空间过滤:获取指定地图的所有Socket - * - * 功能描述: - * 获取指定地图中所有在线玩家的Socket ID列表,用于消息分发 - * - * @param mapId 地图ID - * @returns Promise Socket ID列表 - */ - async getSocketsInMap(mapId: string): Promise { - try { - const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; - const socketIds = await this.redisService.smembers(mapKey); - - this.logger.debug('获取地图玩家列表', { - operation: 'getSocketsInMap', - mapId, - playerCount: socketIds.length, - }); - - return socketIds; - - } catch (error) { - const err = error as Error; - this.logger.error('获取地图玩家列表失败', { - operation: 'getSocketsInMap', - mapId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return []; - } - } - - /** - * 更新玩家位置 - * - * 功能描述: - * 更新玩家在游戏世界中的位置信息,如果切换地图则更新地图玩家列表 - * - * 业务逻辑: - * 1. 获取当前会话 - * 2. 检查是否切换地图 - * 3. 更新会话位置信息 - * 4. 如果切换地图,更新地图玩家列表 - * 5. 保存更新后的会话 - * - * @param socketId WebSocket连接ID - * @param mapId 地图ID - * @param x X坐标 - * @param y Y坐标 - * @returns Promise 是否更新成功 - */ - async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): Promise { - this.logger.debug('开始更新玩家位置', { - operation: 'updatePlayerPosition', - socketId, - mapId, - position: { x, y }, - timestamp: new Date().toISOString(), - }); - - try { - // 参数验证 - if (!socketId || !socketId.trim()) { - this.logger.warn('更新位置失败:socketId为空', { - operation: 'updatePlayerPosition', - }); - return false; - } - - if (!mapId || !mapId.trim()) { - this.logger.warn('更新位置失败:mapId为空', { - operation: 'updatePlayerPosition', - socketId, - }); - return false; - } - - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - const sessionData = await this.redisService.get(sessionKey); - - if (!sessionData) { - this.logger.warn('更新位置失败:会话不存在', { - operation: 'updatePlayerPosition', - socketId, - }); - return false; - } - - const session = this.deserializeSession(sessionData); - const oldMapId = session.currentMap; - const mapChanged = oldMapId !== mapId; - - // 更新会话信息 - session.currentMap = mapId; - session.position = { x, y }; - session.lastActivity = new Date(); - - await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); - - // 如果切换了地图,更新地图玩家列表 - if (mapChanged) { - // 从旧地图移除 - const oldMapKey = `${this.MAP_PLAYERS_PREFIX}${oldMapId}`; - await this.redisService.srem(oldMapKey, socketId); - - // 添加到新地图 - const newMapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; - await this.redisService.sadd(newMapKey, socketId); - await this.redisService.expire(newMapKey, this.SESSION_TIMEOUT); - - this.logger.log('玩家切换地图', { - operation: 'updatePlayerPosition', - socketId, - userId: session.userId, - oldMapId, - newMapId: mapId, - position: { x, y }, - }); - } - - this.logger.debug('玩家位置更新成功', { - operation: 'updatePlayerPosition', - socketId, - mapId, - position: { x, y }, - mapChanged, - }); - - return true; - - } catch (error) { - const err = error as Error; - this.logger.error('更新玩家位置失败', { - operation: 'updatePlayerPosition', - socketId, - mapId, - position: { x, y }, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return false; - } - } - - /** - * 销毁会话 - * - * 功能描述: - * 清理玩家会话数据,从地图玩家列表中移除,释放相关资源 - * - * 业务逻辑: - * 1. 获取会话信息 - * 2. 从地图玩家列表中移除 - * 3. 删除用户会话映射 - * 4. 删除会话数据 - * - * @param socketId WebSocket连接ID - * @returns Promise 是否销毁成功 - */ - async destroySession(socketId: string): Promise { - this.logger.log('开始销毁游戏会话', { - operation: 'destroySession', - socketId, - timestamp: new Date().toISOString(), - }); - - try { - if (!socketId || !socketId.trim()) { - this.logger.warn('销毁会话失败:socketId为空', { - operation: 'destroySession', - }); - return false; - } - - // 获取会话信息 - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - const sessionData = await this.redisService.get(sessionKey); - - if (!sessionData) { - this.logger.log('会话不存在,跳过销毁', { - operation: 'destroySession', - socketId, - }); - return true; - } - - const session = this.deserializeSession(sessionData); - - // 从地图玩家列表中移除 - const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`; - await this.redisService.srem(mapKey, socketId); - - // 删除用户会话映射 - const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`; - await this.redisService.del(userSessionKey); - - // 删除会话数据 - await this.redisService.del(sessionKey); - - this.logger.log('游戏会话销毁成功', { - operation: 'destroySession', - socketId, - userId: session.userId, - currentMap: session.currentMap, - timestamp: new Date().toISOString(), - }); - - return true; - - } catch (error) { - const err = error as Error; - this.logger.error('销毁游戏会话失败', { - operation: 'destroySession', - socketId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - // 即使失败也要尝试清理会话数据 - try { - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - await this.redisService.del(sessionKey); - } catch (cleanupError) { - const cleanupErr = cleanupError as Error; - this.logger.error('会话清理失败', { - operation: 'destroySession', - socketId, - error: cleanupErr.message, - }); - } - - return false; - } - } - - /** - * 清理过期会话 - * - * 功能描述: - * 定时任务,清理超时的会话数据和相关资源 - * - * 业务逻辑: - * 1. 获取所有地图的玩家列表 - * 2. 检查每个会话的最后活动时间 - * 3. 清理超过30分钟未活动的会话 - * 4. 返回需要注销的Zulip队列ID列表 - * - * @param timeoutMinutes 超时时间(分钟),默认30分钟 - * @returns Promise<{cleanedCount: number, zulipQueueIds: string[]}> 清理结果 - */ - async cleanupExpiredSessions(timeoutMinutes: number = 30): Promise<{ - cleanedCount: number; - zulipQueueIds: string[]; - }> { - const startTime = Date.now(); - - this.logger.log('开始清理过期会话', { - operation: 'cleanupExpiredSessions', - timeoutMinutes, - timestamp: new Date().toISOString(), - }); - - 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) { - try { - 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 instanceof Date - ? session.lastActivity.getTime() - : new Date(session.lastActivity).getTime(); - - // 检查是否超时 - if (now - lastActivityTime > timeoutMs) { - expiredSessions.push(session); - zulipQueueIds.push(session.zulipQueueId); - - this.logger.log('发现过期会话', { - operation: 'cleanupExpiredSessions', - socketId: session.socketId, - userId: session.userId, - lastActivity: session.lastActivity, - idleMinutes: Math.round((now - lastActivityTime) / 60000), - }); - } - } catch (sessionError) { - const err = sessionError as Error; - this.logger.warn('检查会话时出错', { - operation: 'cleanupExpiredSessions', - socketId, - error: err.message, - }); - } - } - } - - // 清理过期会话 - for (const session of expiredSessions) { - try { - await this.destroySession(session.socketId); - } catch (destroyError) { - const err = destroyError as Error; - this.logger.error('清理过期会话失败', { - operation: 'cleanupExpiredSessions', - socketId: session.socketId, - error: err.message, - }); - } - } - - const duration = Date.now() - startTime; - - this.logger.log('过期会话清理完成', { - operation: 'cleanupExpiredSessions', - cleanedCount: expiredSessions.length, - zulipQueueCount: zulipQueueIds.length, - duration, - timestamp: new Date().toISOString(), - }); - - return { - cleanedCount: expiredSessions.length, - zulipQueueIds, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('清理过期会话失败', { - operation: 'cleanupExpiredSessions', - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return { - cleanedCount: 0, - zulipQueueIds: [], - }; - } - } - - /** - * 检查会话是否过期 - * - * @param socketId WebSocket连接ID - * @param timeoutMinutes 超时时间(分钟) - * @returns Promise 是否过期 - */ - async isSessionExpired(socketId: string, timeoutMinutes: number = 30): Promise { - try { - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - const sessionData = await this.redisService.get(sessionKey); - - if (!sessionData) { - return true; // 会话不存在视为过期 - } - - const session = this.deserializeSession(sessionData); - const lastActivityTime = session.lastActivity instanceof Date - ? session.lastActivity.getTime() - : new Date(session.lastActivity).getTime(); - - const timeoutMs = timeoutMinutes * 60 * 1000; - return Date.now() - lastActivityTime > timeoutMs; - - } catch (error) { - return true; // 出错时视为过期 - } - } - - /** - * 刷新会话活动时间 - * - * @param socketId WebSocket连接ID - * @returns Promise 是否刷新成功 - */ - async refreshSession(socketId: string): Promise { - try { - const sessionKey = `${this.SESSION_PREFIX}${socketId}`; - const sessionData = await this.redisService.get(sessionKey); - - if (!sessionData) { - return false; - } - - const session = this.deserializeSession(sessionData); - session.lastActivity = new Date(); - - await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); - - // 同时刷新用户会话映射的过期时间 - const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`; - await this.redisService.expire(userSessionKey, this.SESSION_TIMEOUT); - - return true; - - } catch (error) { - const err = error as Error; - this.logger.error('刷新会话失败', { - operation: 'refreshSession', - socketId, - error: err.message, - }); - return false; - } - } - - /** - * 获取会话统计信息 - * - * 功能描述: - * 获取当前系统中的会话统计信息,包括总会话数和地图分布 - * - * @returns Promise 会话统计信息 - */ - async getSessionStats(): Promise { - try { - // 获取所有地图的玩家列表 - const mapIds = this.configManager.getAllMapIds().length > 0 - ? this.configManager.getAllMapIds() - : DEFAULT_MAP_IDS; - const mapDistribution: Record = {}; - let totalSessions = 0; - - for (const mapId of mapIds) { - const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; - const players = await this.redisService.smembers(mapKey); - mapDistribution[mapId] = players.length; - totalSessions += players.length; - } - - this.logger.debug('获取会话统计信息', { - operation: 'getSessionStats', - totalSessions, - mapDistribution, - }); - - return { - totalSessions, - mapDistribution, - }; - - } catch (error) { - const err = error as Error; - this.logger.error('获取会话统计失败', { - operation: 'getSessionStats', - error: err.message, - }); - - return { - totalSessions: 0, - mapDistribution: {}, - }; - } - } - - /** - * 获取所有活跃会话 - * - * 功能描述: - * 获取指定地图中所有活跃会话的详细信息 - * - * @param mapId 地图ID(可选,不传则获取所有地图) - * @returns Promise 会话列表 - */ - async getAllSessions(mapId?: string): Promise { - try { - const sessions: GameSession[] = []; - - if (mapId) { - // 获取指定地图的会话 - const socketIds = await this.getSocketsInMap(mapId); - for (const socketId of socketIds) { - const session = await this.getSession(socketId); - if (session) { - sessions.push(session); - } - } - } else { - // 获取所有地图的会话 - const mapIds = this.configManager.getAllMapIds().length > 0 - ? this.configManager.getAllMapIds() - : DEFAULT_MAP_IDS; - for (const map of mapIds) { - const socketIds = await this.getSocketsInMap(map); - for (const socketId of socketIds) { - const session = await this.getSession(socketId); - if (session) { - sessions.push(session); - } - } - } - } - - return sessions; - - } catch (error) { - const err = error as Error; - this.logger.error('获取所有会话失败', { - operation: 'getAllSessions', - mapId, - error: err.message, - }); - - return []; - } - } -} - diff --git a/src/business/zulip/services/zulip_event_processor.service.spec.ts b/src/business/zulip/services/zulip_event_processor.service.spec.ts index 9be788c..ede8273 100644 --- a/src/business/zulip/services/zulip_event_processor.service.spec.ts +++ b/src/business/zulip/services/zulip_event_processor.service.spec.ts @@ -12,9 +12,13 @@ * **Feature: zulip-integration, Property 5: 消息接收和分发** * **Validates: Requirements 5.1, 5.2, 5.5** * + * 更新记录: + * - 2026-01-14: 重构后更新 - 使用 ISessionQueryService 接口替代具体实现 + * * @author angjustinl - * @version 1.0.0 + * @version 2.0.0 * @since 2025-12-25 + * @lastModified 2026-01-14 */ import { Test, TestingModule } from '@nestjs/testing'; @@ -25,14 +29,19 @@ import { GameMessage, MessageDistributor, } from './zulip_event_processor.service'; -import { SessionManagerService, GameSession } from './session_manager.service'; +import { + ISessionQueryService, + IGameSession, + SESSION_QUERY_SERVICE, +} from '../../../core/session_core/session_core.interfaces'; import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces'; -import { AppLoggerService } from '../../../core/utils/logger/logger.service'; + +// 为测试定义 GameSession 类型别名 +type GameSession = IGameSession; describe('ZulipEventProcessorService', () => { let service: ZulipEventProcessorService; - let mockLogger: jest.Mocked; - let mockSessionManager: jest.Mocked; + let mockSessionManager: jest.Mocked; let mockConfigManager: jest.Mocked; let mockClientPool: jest.Mocked; let mockDistributor: jest.Mocked; @@ -67,20 +76,9 @@ describe('ZulipEventProcessorService', () => { beforeEach(async () => { jest.clearAllMocks(); - mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - debug: jest.fn(), - } as any; - mockSessionManager = { getSession: jest.fn(), getSocketsInMap: jest.fn(), - createSession: jest.fn(), - destroySession: jest.fn(), - updatePlayerPosition: jest.fn(), - injectContext: jest.fn(), } as any; mockConfigManager = { @@ -117,11 +115,7 @@ describe('ZulipEventProcessorService', () => { providers: [ ZulipEventProcessorService, { - provide: AppLoggerService, - useValue: mockLogger, - }, - { - provide: SessionManagerService, + provide: SESSION_QUERY_SERVICE, useValue: mockSessionManager, }, { @@ -197,30 +191,18 @@ describe('ZulipEventProcessorService', () => { }); }); - /** * 属性测试: 消息格式转换完整性 * * **Feature: zulip-integration, Property 4: 消息格式转换完整性** * **Validates: Requirements 5.3, 5.4** - * - * 对于任何在Zulip和游戏之间转发的消息,转换后的消息应该包含所有必需的信息 - * (发送者、内容、时间戳),并符合目标协议格式 */ describe('Property 4: 消息格式转换完整性', () => { - /** - * 属性: 对于任何有效的Zulip消息,转换后应该包含发送者信息 - * 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式 - * 验证需求 5.4: 转换消息格式时系统应包含发送者信息、消息内容和时间戳 - */ it('对于任何有效的Zulip消息,转换后应该包含发送者信息', async () => { await fc.assert( fc.asyncProperty( - // 生成有效的发送者全名 fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), - // 生成有效的发送者邮箱 fc.emailAddress(), - // 生成有效的消息内容 fc.string({ minLength: 1, maxLength: 500 }).filter(s => s.trim().length > 0), async (senderName, senderEmail, content) => { const zulipMessage = createMockZulipMessage({ @@ -231,14 +213,9 @@ describe('ZulipEventProcessorService', () => { const result = await service.convertMessageFormat(zulipMessage); - // 验证消息类型正确 expect(result.t).toBe('chat_render'); - - // 验证发送者信息存在且非空 expect(result.from).toBeDefined(); expect(result.from.length).toBeGreaterThan(0); - - // 验证发送者名称正确(应该是senderName或从邮箱提取) expect(result.from).toBe(senderName.trim()); } ), @@ -246,31 +223,23 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何sender_full_name为空的消息,应该从邮箱提取用户名 - * 验证需求 5.4: 转换消息格式时系统应包含发送者信息 - */ it('对于任何sender_full_name为空的消息,应该从邮箱提取用户名', async () => { await fc.assert( fc.asyncProperty( - // 生成有效的邮箱用户名部分 fc.string({ minLength: 1, maxLength: 30 }) .filter(s => s.trim().length > 0 && /^[a-zA-Z0-9._-]+$/.test(s)), - // 生成有效的域名 fc.constantFrom('example.com', 'test.org', 'mail.net'), - // 生成有效的消息内容 fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0), async (username, domain, content) => { const email = `${username}@${domain}`; const zulipMessage = createMockZulipMessage({ - sender_full_name: '', // 空的全名 + sender_full_name: '', sender_email: email, content: content.trim(), }); const result = await service.convertMessageFormat(zulipMessage); - // 验证从邮箱提取了用户名 expect(result.from).toBe(username); } ), @@ -278,18 +247,12 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何消息内容,转换后应该保留核心文本信息 - * 验证需求 5.4: 转换消息格式时系统应包含消息内容 - */ it('对于任何消息内容,转换后应该保留核心文本信息', async () => { await fc.assert( fc.asyncProperty( - // 生成纯文本消息内容(不含Markdown和HTML标记) fc.string({ minLength: 1, maxLength: 150 }) .filter(s => { const trimmed = s.trim(); - // 排除Markdown标记和HTML标记 return trimmed.length > 0 && !/[*_`#\[\]<>]/.test(trimmed) && !trimmed.startsWith('>') && @@ -304,11 +267,9 @@ describe('ZulipEventProcessorService', () => { const result = await service.convertMessageFormat(zulipMessage); - // 验证消息内容存在 expect(result.txt).toBeDefined(); expect(result.txt.length).toBeGreaterThan(0); - // 验证核心内容被保留(对于短消息应该完全匹配) if (content.trim().length <= 200) { expect(result.txt).toBe(content.trim()); } @@ -318,14 +279,9 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何超过200字符的消息,应该被截断并添加省略号 - * 验证需求 5.4: 转换消息格式时系统应正确处理消息内容 - */ it('对于任何超过200字符的消息,应该被截断并添加省略号', async () => { await fc.assert( fc.asyncProperty( - // 生成超过200字符的纯字母数字消息内容(避免Markdown/HTML标记影响长度) fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 '.split('')), { minLength: 250, maxLength: 500 }) .map(arr => arr.join('')), async (content: string) => { @@ -335,10 +291,7 @@ describe('ZulipEventProcessorService', () => { const result = await service.convertMessageFormat(zulipMessage); - // 验证消息被截断 expect(result.txt.length).toBeLessThanOrEqual(200); - - // 验证添加了省略号 expect(result.txt.endsWith('...')).toBe(true); } ), @@ -346,21 +299,14 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何包含Markdown的消息,应该正确移除格式标记 - * 验证需求 5.4: 转换消息格式时系统应正确处理消息内容 - * 注意: 列表标记(- + *)会被转换为bullet point(•),这是预期行为,不在此测试范围 - */ it('对于任何包含Markdown的消息,应该正确移除格式标记', async () => { await fc.assert( fc.asyncProperty( - // 生成纯字母数字基础文本(避免特殊字符干扰) fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('')), { minLength: 1, maxLength: 50 }) .map(arr => arr.join('')), - // 选择Markdown格式类型(仅测试inline格式,不测试列表) fc.constantFrom('bold', 'italic', 'code', 'link'), async (text: string, formatType: string) => { - if (text.length === 0) return; // 跳过空字符串 + if (text.length === 0) return; let markdownContent: string; @@ -369,7 +315,6 @@ describe('ZulipEventProcessorService', () => { markdownContent = `**${text}**`; break; case 'italic': - // 使用下划线斜体避免与列表标记冲突 markdownContent = `_${text}_`; break; case 'code': @@ -388,7 +333,6 @@ describe('ZulipEventProcessorService', () => { const result = await service.convertMessageFormat(zulipMessage); - // 验证Markdown标记被移除,只保留文本 expect(result.txt).toBe(text); } ), @@ -396,14 +340,9 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何消息,转换结果应该符合游戏协议格式 - * 验证需求 5.3: 确定目标玩家时系统应将消息转换为游戏协议格式 - */ it('对于任何消息,转换结果应该符合游戏协议格式', async () => { await fc.assert( fc.asyncProperty( - // 生成随机的Zulip消息属性 fc.record({ sender_full_name: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0), sender_email: fc.emailAddress(), @@ -422,19 +361,15 @@ describe('ZulipEventProcessorService', () => { const result = await service.convertMessageFormat(zulipMessage); - // 验证游戏协议格式 expect(result).toHaveProperty('t', 'chat_render'); expect(result).toHaveProperty('from'); expect(result).toHaveProperty('txt'); expect(result).toHaveProperty('bubble'); - // 验证类型正确 expect(typeof result.t).toBe('string'); expect(typeof result.from).toBe('string'); expect(typeof result.txt).toBe('string'); expect(typeof result.bubble).toBe('boolean'); - - // 验证bubble默认为true expect(result.bubble).toBe(true); } ), @@ -443,7 +378,6 @@ describe('ZulipEventProcessorService', () => { }, 60000); }); - describe('determineTargetPlayers - 确定目标玩家', () => { it('应该根据Stream名称确定目标地图并获取玩家列表', async () => { const zulipMessage = createMockZulipMessage({ @@ -476,14 +410,13 @@ describe('ZulipEventProcessorService', () => { mockSessionManager.getSocketsInMap.mockResolvedValue(['socket-1', 'socket-2']); mockSessionManager.getSession.mockImplementation(async (socketId) => { if (socketId === 'socket-1') { - return createMockSession({ socketId: 'socket-1', userId: 'sender-user' }); // 发送者 + return createMockSession({ socketId: 'socket-1', userId: 'sender-user' }); } return createMockSession({ socketId: 'socket-2', userId: 'other-user' }); }); const result = await service.determineTargetPlayers(zulipMessage, 'Tavern', 'sender-user'); - // 发送者应该被排除 expect(result).not.toContain('socket-1'); expect(result).toContain('socket-2'); }); @@ -538,24 +471,13 @@ describe('ZulipEventProcessorService', () => { * * **Feature: zulip-integration, Property 5: 消息接收和分发** * **Validates: Requirements 5.1, 5.2, 5.5** - * - * 对于任何从Zulip接收的消息,系统应该正确确定目标玩家,转换消息格式, - * 并通过WebSocket发送给所有相关的游戏客户端 */ describe('Property 5: 消息接收和分发', () => { - /** - * 属性: 对于任何有效的Stream消息,应该正确确定目标地图 - * 验证需求 5.1: Zulip中有新消息时系统应通过事件队列接收消息通知 - * 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息 - */ it('对于任何有效的Stream消息,应该正确确定目标地图', async () => { await fc.assert( fc.asyncProperty( - // 生成有效的Stream名称 fc.constantFrom('Tavern', 'Novice Village', 'Market', 'General'), - // 生成对应的地图ID fc.constantFrom('tavern', 'novice_village', 'market', 'general'), - // 生成玩家Socket ID列表 fc.array( fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), { minLength: 0, maxLength: 10 } @@ -565,7 +487,6 @@ describe('ZulipEventProcessorService', () => { display_recipient: streamName, }); - // 设置模拟返回值 mockConfigManager.getMapIdByStream.mockReturnValue(mapId); mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds); mockSessionManager.getSession.mockImplementation(async (socketId) => { @@ -582,14 +503,12 @@ describe('ZulipEventProcessorService', () => { 'different-sender' ); - // 验证调用了正确的方法 expect(mockConfigManager.getMapIdByStream).toHaveBeenCalledWith(streamName); if (socketIds.length > 0) { expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId); } - // 验证返回的Socket ID数量正确(所有玩家都不是发送者) expect(result.length).toBe(socketIds.length); } ), @@ -597,16 +516,10 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何消息分发,发送者应该被排除在接收者之外 - * 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息 - */ it('对于任何消息分发,发送者应该被排除在接收者之外', async () => { await fc.assert( fc.asyncProperty( - // 生成发送者用户ID fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), - // 生成其他玩家用户ID列表 fc.array( fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), { minLength: 1, maxLength: 5 } @@ -616,7 +529,6 @@ describe('ZulipEventProcessorService', () => { display_recipient: 'Tavern', }); - // 创建包含发送者的Socket列表 const allSocketIds = [`socket_${senderUserId}`, ...otherUserIds.map(id => `socket_${id}`)]; mockConfigManager.getMapIdByStream.mockReturnValue('tavern'); @@ -635,10 +547,8 @@ describe('ZulipEventProcessorService', () => { senderUserId ); - // 验证发送者被排除 expect(result).not.toContain(`socket_${senderUserId}`); - // 验证其他玩家都在结果中 for (const userId of otherUserIds) { expect(result).toContain(`socket_${userId}`); } @@ -648,18 +558,11 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何消息分发,所有目标玩家都应该收到消息 - * 验证需求 5.5: 推送消息到游戏客户端时系统应通过WebSocket发送消息 - */ it('对于任何消息分发,所有目标玩家都应该收到消息', async () => { await fc.assert( fc.asyncProperty( - // 生成发送者名称 fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), - // 生成消息内容 fc.string({ minLength: 1, maxLength: 200 }).filter(s => s.trim().length > 0), - // 生成目标玩家Socket ID列表 fc.array( fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), { minLength: 1, maxLength: 10 } @@ -672,12 +575,10 @@ describe('ZulipEventProcessorService', () => { bubble: true, }; - // 重置mock mockDistributor.sendChatRender.mockClear(); await service.distributeMessage(gameMessage, targetPlayers); - // 验证每个目标玩家都收到了消息 expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(targetPlayers.length); for (const socketId of targetPlayers) { @@ -694,14 +595,9 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 对于任何未知Stream的消息,应该返回空的目标玩家列表 - * 验证需求 5.2: 接收到消息通知时系统应确定哪些在线玩家应该接收该消息 - */ it('对于任何未知Stream的消息,应该返回空的目标玩家列表', async () => { await fc.assert( fc.asyncProperty( - // 生成未知的Stream名称 fc.string({ minLength: 5, maxLength: 50 }) .filter(s => s.trim().length > 0) .map(s => `Unknown_${s}`), @@ -710,7 +606,6 @@ describe('ZulipEventProcessorService', () => { display_recipient: unknownStream, }); - // 模拟未找到对应地图 mockConfigManager.getMapIdByStream.mockReturnValue(null); const result = await service.determineTargetPlayers( @@ -719,10 +614,7 @@ describe('ZulipEventProcessorService', () => { 'sender-user' ); - // 验证返回空列表 expect(result).toEqual([]); - - // 验证没有尝试获取玩家列表 expect(mockSessionManager.getSocketsInMap).not.toHaveBeenCalled(); } ), @@ -730,24 +622,16 @@ describe('ZulipEventProcessorService', () => { ); }, 60000); - /** - * 属性: 完整的消息处理流程应该正确执行 - * 验证需求 5.1, 5.2, 5.5: 完整的消息接收和分发流程 - */ it('完整的消息处理流程应该正确执行', async () => { await fc.assert( fc.asyncProperty( - // 生成发送者信息 fc.record({ senderName: fc.string({ minLength: 1, maxLength: 30 }).filter(s => s.trim().length > 0), senderEmail: fc.emailAddress(), senderUserId: fc.string({ minLength: 5, maxLength: 20 }).filter(s => s.trim().length > 0), }), - // 生成消息内容 fc.string({ minLength: 1, maxLength: 150 }).filter(s => s.trim().length > 0), - // 生成Stream名称 fc.constantFrom('Tavern', 'Novice Village'), - // 生成目标玩家数量 fc.integer({ min: 1, max: 5 }), async (sender, content, streamName, playerCount) => { const zulipMessage = createMockZulipMessage({ @@ -757,7 +641,6 @@ describe('ZulipEventProcessorService', () => { display_recipient: streamName, }); - // 生成目标玩家 const targetSocketIds = Array.from( { length: playerCount }, (_, i) => `socket_player_${i}` @@ -774,17 +657,12 @@ describe('ZulipEventProcessorService', () => { }); }); - // 重置mock mockDistributor.sendChatRender.mockClear(); - // 执行完整的消息处理 const result = await service.processMessageManually(zulipMessage, sender.senderUserId); - // 验证处理成功 expect(result.success).toBe(true); expect(result.targetCount).toBe(playerCount); - - // 验证消息被分发给所有目标玩家 expect(mockDistributor.sendChatRender).toHaveBeenCalledTimes(playerCount); } ), @@ -811,14 +689,12 @@ describe('ZulipEventProcessorService', () => { const queueId = 'test-queue-123'; const userId = 'user-456'; - // 注册队列 await service.registerEventQueue(queueId, userId, 0); let stats = service.getProcessingStats(); expect(stats.queueIds).toContain(queueId); expect(stats.totalQueues).toBe(1); - // 注销队列 await service.unregisterEventQueue(queueId); stats = service.getProcessingStats(); diff --git a/src/business/zulip/services/zulip_event_processor.service.ts b/src/business/zulip/services/zulip_event_processor.service.ts index 66f7235..90d3bc3 100644 --- a/src/business/zulip/services/zulip_event_processor.service.ts +++ b/src/business/zulip/services/zulip_event_processor.service.ts @@ -7,6 +7,12 @@ * - 实现空间过滤和消息分发 * - 支持区域广播功能 * + * 职责分离: + * - 事件轮询:管理Zulip事件队列的轮询和处理 + * - 消息转换:将Zulip消息转换为游戏协议格式 + * - 空间过滤:根据地图确定消息接收者 + * - 消息分发:通过WebSocket向目标玩家发送消息 + * * 主要方法: * - startEventProcessing(): 启动事件处理循环 * - processMessageEvent(): 处理Zulip消息事件 @@ -20,18 +26,27 @@ * - 向游戏客户端分发消息 * * 依赖模块: - * - SessionManagerService: 会话管理服务 + * - ISessionQueryService: 会话查询接口(通过 Core 层接口解耦) * - ConfigManagerService: 配置管理服务 * - ZulipClientPoolService: Zulip客户端池服务 * - AppLoggerService: 日志记录服务 * + * 最近修改: + * - 2026-01-14: 代码质量优化 - 移除未使用的IGameSession导入 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin) + * - 2025-12-25: 功能新增 - 初始创建Zulip事件处理服务 (修改者: angjustinl) + * * @author angjustinl - * @version 1.0.0 + * @version 1.1.2 * @since 2025-12-25 + * @lastModified 2026-01-14 */ -import { Injectable, OnModuleDestroy, Inject, forwardRef, Logger } from '@nestjs/common'; -import { SessionManagerService } from './session_manager.service'; +import { Injectable, OnModuleDestroy, Inject, Logger } from '@nestjs/common'; +import { + ISessionQueryService, + SESSION_QUERY_SERVICE, +} from '../../../core/session_core/session_core.interfaces'; import { IZulipConfigService, IZulipClientPoolService } from '../../../core/zulip_core/zulip_core.interfaces'; /** @@ -129,7 +144,8 @@ export class ZulipEventProcessorService implements OnModuleDestroy { private readonly MAX_EVENTS_PER_POLL = 100; constructor( - private readonly sessionManager: SessionManagerService, + @Inject(SESSION_QUERY_SERVICE) + private readonly sessionManager: ISessionQueryService, @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, @Inject('ZULIP_CLIENT_POOL_SERVICE') diff --git a/src/business/zulip/zulip.module.spec.ts b/src/business/zulip/zulip.module.spec.ts index 9e95f64..5dad528 100644 --- a/src/business/zulip/zulip.module.spec.ts +++ b/src/business/zulip/zulip.module.spec.ts @@ -4,37 +4,32 @@ * 功能描述: * - 测试模块配置的正确性 * - 验证依赖注入配置的完整性 - * - 测试服务和控制器的注册 + * - 测试服务的注册 * - 验证模块导出的正确性 * * 测试范围: * - 模块导入配置验证 * - 服务提供者注册验证 - * - 控制器注册验证 * - 模块导出验证 * - * 最近修改: - * - 2026-01-12: 代码规范优化 - 创建测试文件,确保模块配置逻辑的测试覆盖 (修改者: moyin) + * 架构说明: + * - Business层:仅包含业务逻辑服务 + * - Controller已迁移到Gateway层(src/gateway/zulip/) + * + * 更新记录: + * - 2026-01-14: 架构优化 - Controller迁移到Gateway层,更新测试用例 (修改者: moyin) + * - 2026-01-14: 重构后更新 - 聊天功能已迁移到 gateway/chat 和 business/chat 模块 + * 本模块仅保留 Zulip 账号管理和事件处理功能 * * @author moyin - * @version 1.0.0 + * @version 3.0.0 * @since 2026-01-12 - * @lastModified 2026-01-12 + * @lastModified 2026-01-14 */ import { ZulipModule } from './zulip.module'; -import { ZulipService } from './zulip.service'; -import { SessionManagerService } from './services/session_manager.service'; -import { MessageFilterService } from './services/message_filter.service'; import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; -import { SessionCleanupService } from './services/session_cleanup.service'; -import { CleanWebSocketGateway } from './clean_websocket.gateway'; -import { ChatController } from './chat.controller'; -import { WebSocketDocsController } from './websocket_docs.controller'; -import { WebSocketOpenApiController } from './websocket_openapi.controller'; -import { ZulipAccountsController } from './zulip_accounts.controller'; -import { WebSocketTestController } from './websocket_test.controller'; -import { DynamicConfigController } from './dynamic_config.controller'; +import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service'; import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service'; describe('ZulipModule', () => { @@ -50,85 +45,42 @@ describe('ZulipModule', () => { const exportsMetadata = Reflect.getMetadata('exports', ZulipModule) || []; // 验证导入的模块数量 - expect(moduleMetadata).toHaveLength(6); + expect(moduleMetadata.length).toBeGreaterThanOrEqual(6); - // 验证提供者数量 - expect(providersMetadata).toHaveLength(7); + // 验证提供者数量(2个业务服务) + expect(providersMetadata).toHaveLength(2); - // 验证控制器数量 - expect(controllersMetadata).toHaveLength(6); + // 验证控制器数量(Controller已迁移到Gateway层,应为0) + expect(controllersMetadata).toHaveLength(0); - // 验证导出数量 - expect(exportsMetadata).toHaveLength(7); + // 验证导出数量(3个服务:2个业务服务 + 1个重新导出的Core服务) + expect(exportsMetadata).toHaveLength(3); }); }); describe('Service Providers', () => { - it('should include ZulipService in providers', () => { - const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(ZulipService); - }); - - it('should include SessionManagerService in providers', () => { - const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(SessionManagerService); - }); - - it('should include MessageFilterService in providers', () => { - const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(MessageFilterService); - }); - it('should include ZulipEventProcessorService in providers', () => { const providers = Reflect.getMetadata('providers', ZulipModule) || []; expect(providers).toContain(ZulipEventProcessorService); }); - it('should include SessionCleanupService in providers', () => { + it('should include ZulipAccountsBusinessService in providers', () => { const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(SessionCleanupService); + expect(providers).toContain(ZulipAccountsBusinessService); }); - it('should include CleanWebSocketGateway in providers', () => { + it('should NOT include DynamicConfigManagerService in providers (provided by ZulipCoreModule)', () => { const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(CleanWebSocketGateway); - }); - - it('should include DynamicConfigManagerService in providers', () => { - const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(DynamicConfigManagerService); + // DynamicConfigManagerService 由 ZulipCoreModule 提供,不在本模块的 providers 中 + expect(providers).not.toContain(DynamicConfigManagerService); }); }); - describe('Controllers', () => { - it('should include ChatController in controllers', () => { + describe('Controllers (Migrated to Gateway Layer)', () => { + it('should NOT have any controllers (migrated to src/gateway/zulip/)', () => { const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(ChatController); - }); - - it('should include WebSocketDocsController in controllers', () => { - const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(WebSocketDocsController); - }); - - it('should include WebSocketOpenApiController in controllers', () => { - const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(WebSocketOpenApiController); - }); - - it('should include ZulipAccountsController in controllers', () => { - const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(ZulipAccountsController); - }); - - it('should include WebSocketTestController in controllers', () => { - const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(WebSocketTestController); - }); - - it('should include DynamicConfigController in controllers', () => { - const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - expect(controllers).toContain(DynamicConfigController); + // 所有Controller已迁移到Gateway层 + expect(controllers).toHaveLength(0); }); }); @@ -160,20 +112,15 @@ describe('ZulipModule', () => { it('should have proper service dependencies', () => { // 验证服务依赖关系 const providers = Reflect.getMetadata('providers', ZulipModule) || []; - expect(providers).toContain(ZulipService); - expect(providers).toContain(SessionManagerService); - expect(providers).toContain(MessageFilterService); + expect(providers).toContain(ZulipEventProcessorService); + expect(providers).toContain(ZulipAccountsBusinessService); }); it('should export essential services', () => { // 验证导出的服务 const exports = Reflect.getMetadata('exports', ZulipModule) || []; - expect(exports).toContain(ZulipService); - expect(exports).toContain(SessionManagerService); - expect(exports).toContain(MessageFilterService); expect(exports).toContain(ZulipEventProcessorService); - expect(exports).toContain(SessionCleanupService); - expect(exports).toContain(CleanWebSocketGateway); + expect(exports).toContain(ZulipAccountsBusinessService); expect(exports).toContain(DynamicConfigManagerService); }); }); @@ -193,8 +140,8 @@ describe('ZulipModule', () => { it('should have all required imports', () => { const imports = Reflect.getMetadata('imports', ZulipModule) || []; - // 验证必需的模块导入 - expect(imports.length).toBe(6); + // 验证必需的模块导入(至少6个) + expect(imports.length).toBeGreaterThanOrEqual(6); }); it('should have all required providers', () => { @@ -202,13 +149,8 @@ describe('ZulipModule', () => { // 验证所有必需的服务提供者 const requiredProviders = [ - ZulipService, - SessionManagerService, - MessageFilterService, ZulipEventProcessorService, - SessionCleanupService, - CleanWebSocketGateway, - DynamicConfigManagerService, + ZulipAccountsBusinessService, ]; requiredProviders.forEach(provider => { @@ -216,22 +158,11 @@ describe('ZulipModule', () => { }); }); - it('should have all required controllers', () => { + it('should have no controllers (Business layer)', () => { const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - // 验证所有必需的控制器 - const requiredControllers = [ - ChatController, - WebSocketDocsController, - WebSocketOpenApiController, - ZulipAccountsController, - WebSocketTestController, - DynamicConfigController, - ]; - - requiredControllers.forEach(controller => { - expect(controllers).toContain(controller); - }); + // Business层不应该包含Controller + expect(controllers).toHaveLength(0); }); }); @@ -241,31 +172,67 @@ describe('ZulipModule', () => { // 验证导入模块的数量和类型 expect(Array.isArray(imports)).toBe(true); - expect(imports.length).toBe(6); + expect(imports.length).toBeGreaterThanOrEqual(6); }); it('should have correct providers configuration', () => { const providers = Reflect.getMetadata('providers', ZulipModule) || []; - // 验证提供者的数量和类型 + // 验证提供者的数量和类型(2个业务服务) expect(Array.isArray(providers)).toBe(true); - expect(providers.length).toBe(7); + expect(providers).toHaveLength(2); }); - it('should have correct controllers configuration', () => { + it('should have correct controllers configuration (empty for Business layer)', () => { const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; - // 验证控制器的数量和类型 + // Business层不包含Controller expect(Array.isArray(controllers)).toBe(true); - expect(controllers.length).toBe(6); + expect(controllers).toHaveLength(0); }); it('should have correct exports configuration', () => { const exports = Reflect.getMetadata('exports', ZulipModule) || []; - // 验证导出的数量和类型 + // 验证导出的数量和类型(3个服务) expect(Array.isArray(exports)).toBe(true); - expect(exports.length).toBe(7); + expect(exports).toHaveLength(3); }); }); -}); \ No newline at end of file + + describe('Architecture Compliance', () => { + it('should not include chat-related services (migrated to business/chat)', () => { + const providers = Reflect.getMetadata('providers', ZulipModule) || []; + const providerNames = providers.map((p: any) => p.name || p.toString()); + + // 验证聊天相关服务已迁移 + expect(providerNames).not.toContain('ZulipService'); + expect(providerNames).not.toContain('SessionManagerService'); + expect(providerNames).not.toContain('MessageFilterService'); + expect(providerNames).not.toContain('SessionCleanupService'); + expect(providerNames).not.toContain('CleanWebSocketGateway'); + }); + + it('should not include any controllers (migrated to gateway/zulip)', () => { + const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; + + // 验证所有Controller已迁移到Gateway层 + expect(controllers).toHaveLength(0); + }); + + it('should follow four-layer architecture (Business layer has no controllers)', () => { + const controllers = Reflect.getMetadata('controllers', ZulipModule) || []; + const providers = Reflect.getMetadata('providers', ZulipModule) || []; + + // Business层规范:只有Service,没有Controller + expect(controllers).toHaveLength(0); + expect(providers.length).toBeGreaterThan(0); + + // 验证所有provider都是Service类型 + const providerNames = providers.map((p: any) => p.name || ''); + providerNames.forEach((name: string) => { + expect(name).toMatch(/Service$/); + }); + }); + }); +}); diff --git a/src/business/zulip/zulip.module.ts b/src/business/zulip/zulip.module.ts index 8c5d44c..362b3fd 100644 --- a/src/business/zulip/zulip.module.ts +++ b/src/business/zulip/zulip.module.ts @@ -2,128 +2,79 @@ * Zulip集成业务模块 * * 功能描述: - * - 整合Zulip集成相关的业务逻辑和控制器 - * - 提供完整的Zulip集成业务功能模块 - * - 实现游戏与Zulip的业务逻辑协调 - * - 支持WebSocket网关、会话管理、消息过滤等业务功能 + * - 提供Zulip账号关联管理业务逻辑 + * - 提供Zulip事件处理业务逻辑 + * - 通过 SESSION_QUERY_SERVICE 接口与 ChatModule 解耦 * - * 架构设计: - * - 业务逻辑层:处理游戏相关的业务规则和流程 - * - 核心服务层:封装技术实现细节和第三方API调用 - * - 通过依赖注入实现业务层与技术层的解耦 + * 架构说明: + * - Business层:专注业务逻辑处理,不包含HTTP协议处理 + * - Controller已迁移到Gateway层(src/gateway/zulip/) + * - 通过 Core 层接口解耦,不直接依赖其他模块的具体实现 * - * 业务服务: - * - ZulipService: 主协调服务,处理登录、消息发送等核心业务流程 - * - CleanWebSocketGateway: WebSocket统一网关,处理客户端连接 - * - SessionManagerService: 会话状态管理和业务逻辑 - * - MessageFilterService: 消息过滤和业务规则控制 - * - * 核心服务(通过ZulipCoreModule提供): - * - ZulipClientService: Zulip REST API封装 - * - ZulipClientPoolService: 客户端池管理 - * - ConfigManagerService: 配置管理和热重载 - * - ZulipEventProcessorService: 事件处理和消息转换 - * - 其他技术支持服务 - * - * 依赖模块: - * - ZulipCoreModule: Zulip核心技术服务 - * - LoginCoreModule: 用户认证和会话管理 - * - RedisModule: 会话状态缓存 - * - LoggerModule: 日志记录服务 - * - * 使用场景: - * - 游戏客户端通过WebSocket连接进行实时聊天 - * - 游戏内消息与Zulip社群的双向同步 - * - 基于位置的聊天上下文管理 - * - 业务规则驱动的消息过滤和权限控制 + * 迁移记录: + * - 2026-01-14: 架构优化 - 将所有Controller迁移到Gateway层,符合四层架构规范 (修改者: moyin) + * - 2026-01-14: 架构优化 - 移除冗余的DynamicConfigManagerService声明,该服务已由ZulipCoreModule提供 (修改者: moyin) + * - 2026-01-14: 聊天功能迁移到新的四层架构模块 + * - CleanWebSocketGateway -> gateway/chat/chat.gateway.ts + * - ZulipService(聊天部分) -> business/chat/chat.service.ts + * - SessionManagerService -> business/chat/services/chat_session.service.ts + * - MessageFilterService -> business/chat/services/chat_filter.service.ts + * - SessionCleanupService -> business/chat/services/chat_cleanup.service.ts + * - ChatController -> gateway/chat/chat.controller.ts + * - 2026-01-14: 通过 Core 层接口解耦,不再直接依赖 ChatModule 的具体实现 * * @author angjustinl - * @version 1.1.0 + * @version 3.0.0 * @since 2026-01-06 + * @lastModified 2026-01-14 */ import { Module } from '@nestjs/common'; -import { CleanWebSocketGateway } from './clean_websocket.gateway'; -import { ZulipService } from './zulip.service'; -import { SessionManagerService } from './services/session_manager.service'; -import { MessageFilterService } from './services/message_filter.service'; +// 业务服务 import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; -import { SessionCleanupService } from './services/session_cleanup.service'; import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service'; -import { ChatController } from './chat.controller'; -import { WebSocketDocsController } from './websocket_docs.controller'; -import { WebSocketOpenApiController } from './websocket_openapi.controller'; -import { ZulipAccountsController } from './zulip_accounts.controller'; -import { WebSocketTestController } from './websocket_test.controller'; -import { DynamicConfigController } from './dynamic_config.controller'; +// 依赖模块 import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module'; import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; import { RedisModule } from '../../core/redis/redis.module'; import { LoggerModule } from '../../core/utils/logger/logger.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { AuthModule } from '../auth/auth.module'; +// 通过接口依赖 ChatModule(解耦) +import { ChatModule } from '../chat/chat.module'; import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service'; @Module({ imports: [ - // Zulip核心服务模块 - 提供技术实现相关的核心服务 + // Zulip核心服务模块 ZulipCoreModule, - // Zulip账号关联模块 - 提供账号关联管理功能 + // Zulip账号关联模块 ZulipAccountsModule.forRoot(), - // Redis模块 - 提供会话状态缓存和数据存储 + // Redis模块 RedisModule, - // 日志模块 - 提供统一的日志记录服务 + // 日志模块 LoggerModule, - // 登录模块 - 提供用户认证和Token验证 + // 登录模块 LoginCoreModule, - // 认证模块 - 提供JWT验证和用户认证服务 + // 认证模块 AuthModule, + // 聊天模块 - 通过 SESSION_QUERY_SERVICE 接口提供会话查询能力 + // ZulipEventProcessorService 依赖接口而非具体实现,实现解耦 + ChatModule, ], providers: [ - // 主协调服务 - 整合各子服务,提供统一业务接口 - ZulipService, - // 会话管理服务 - 维护Socket_ID与Zulip_Queue_ID的映射关系 - SessionManagerService, - // 消息过滤服务 - 敏感词过滤、频率限制、权限验证 - MessageFilterService, // Zulip事件处理服务 - 处理Zulip事件队列消息 ZulipEventProcessorService, - // 会话清理服务 - 定时清理过期会话 - SessionCleanupService, - // WebSocket网关 - 处理游戏客户端WebSocket连接 - CleanWebSocketGateway, - // 动态配置管理服务 - 从Zulip服务器动态获取配置 - DynamicConfigManagerService, - ], - controllers: [ - // 聊天相关的REST API控制器 - ChatController, - // WebSocket API文档控制器 - WebSocketDocsController, - // WebSocket OpenAPI规范控制器 - WebSocketOpenApiController, - // Zulip账号关联管理控制器 - ZulipAccountsController, - // WebSocket测试工具控制器 - 提供测试页面和API监控 - WebSocketTestController, - // 动态配置管理控制器 - 提供配置管理API - DynamicConfigController, + // Zulip账号业务服务 - 账号关联管理 + ZulipAccountsBusinessService, ], exports: [ - // 导出主服务供其他模块使用 - ZulipService, - // 导出会话管理服务 - SessionManagerService, - // 导出消息过滤服务 - MessageFilterService, // 导出事件处理服务 ZulipEventProcessorService, - // 导出会话清理服务 - SessionCleanupService, - // 导出WebSocket网关 - CleanWebSocketGateway, - // 导出动态配置管理服务 + // 导出账号业务服务 + ZulipAccountsBusinessService, + // 重新导出动态配置管理服务(来自ZulipCoreModule) DynamicConfigManagerService, ], }) -export class ZulipModule {} \ No newline at end of file +export class ZulipModule {} diff --git a/src/business/zulip/zulip.service.spec.ts b/src/business/zulip/zulip.service.spec.ts deleted file mode 100644 index 0aa350a..0000000 --- a/src/business/zulip/zulip.service.spec.ts +++ /dev/null @@ -1,1159 +0,0 @@ -/** - * Zulip集成主服务测试 - * - * 功能描述: - * - 测试ZulipService的核心功能 - * - 包含属性测试验证玩家登录流程完整性 - * - 包含属性测试验证消息发送流程完整性 - * - 包含属性测试验证位置更新和上下文注入 - * - * **Feature: zulip-integration, Property 1: 玩家登录流程完整性** - * **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5** - * - * **Feature: zulip-integration, Property 3: 消息发送流程完整性** - * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** - * - * **Feature: zulip-integration, Property 6: 位置更新和上下文注入** - * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** - * - * 最近修改: - * - 2026-01-12: 测试修复 - 修复消息内容断言,使用stringContaining匹配包含游戏消息ID的内容 (修改者: moyin) - * - * @author angjustinl - * @version 1.0.0 - * @since 2025-12-31 - * @lastModified 2026-01-12 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import * as fc from 'fast-check'; -import { - ZulipService, - PlayerLoginRequest, - ChatMessageRequest, - PositionUpdateRequest, - LoginResponse, - ChatMessageResponse, -} from './zulip.service'; -import { SessionManagerService, GameSession } from './services/session_manager.service'; -import { MessageFilterService } from './services/message_filter.service'; -import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; -import { - IZulipClientPoolService, - IZulipConfigService, - ZulipClientInstance, - SendMessageResult, -} from '../../core/zulip_core/zulip_core.interfaces'; -import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; -import { LoginCoreService } from '../../core/login_core/login_core.service'; - -describe('ZulipService', () => { - let service: ZulipService; - let mockZulipClientPool: jest.Mocked; - let mockSessionManager: jest.Mocked; - let mockMessageFilter: jest.Mocked; - let mockEventProcessor: jest.Mocked; - let mockConfigManager: jest.Mocked; - let mockLoginCoreService: jest.Mocked; - - // 创建模拟的Zulip客户端实例 - const createMockClientInstance = (overrides: Partial = {}): ZulipClientInstance => ({ - userId: 'test-user-123', - config: { - username: 'test@example.com', - apiKey: 'test-api-key', - realm: 'https://zulip.example.com', - }, - client: {}, - queueId: 'queue-123', - lastEventId: 0, - createdAt: new Date(), - lastActivity: new Date(), - isValid: true, - ...overrides, - }); - - // 创建模拟的游戏会话 - const createMockSession = (overrides: Partial = {}): GameSession => ({ - socketId: 'socket-123', - userId: 'user-123', - username: 'TestPlayer', - zulipQueueId: 'queue-123', - currentMap: 'whale_port', - position: { x: 400, y: 300 }, - lastActivity: new Date(), - createdAt: new Date(), - ...overrides, - }); - - beforeEach(async () => { - jest.clearAllMocks(); - - mockZulipClientPool = { - createUserClient: jest.fn(), - getUserClient: jest.fn(), - hasUserClient: jest.fn(), - sendMessage: jest.fn(), - registerEventQueue: jest.fn(), - deregisterEventQueue: jest.fn(), - destroyUserClient: jest.fn(), - getPoolStats: jest.fn(), - cleanupIdleClients: jest.fn(), - } as any; - - mockSessionManager = { - createSession: jest.fn(), - getSession: jest.fn(), - destroySession: jest.fn(), - updatePlayerPosition: jest.fn(), - getSocketsInMap: jest.fn(), - injectContext: jest.fn(), - cleanupExpiredSessions: jest.fn(), - } as any; - - mockMessageFilter = { - validateMessage: jest.fn(), - filterContent: jest.fn(), - checkRateLimit: jest.fn(), - validatePermission: jest.fn(), - logViolation: jest.fn(), - } as any; - - mockEventProcessor = { - startEventProcessing: jest.fn(), - stopEventProcessing: jest.fn(), - registerEventQueue: jest.fn(), - unregisterEventQueue: jest.fn(), - processMessageEvent: jest.fn(), - setMessageDistributor: jest.fn(), - getProcessingStats: jest.fn(), - } as any; - - mockConfigManager = { - getStreamByMap: jest.fn(), - getMapIdByStream: jest.fn(), - getTopicByObject: jest.fn(), - getZulipConfig: jest.fn(), - hasMap: jest.fn(), - hasStream: jest.fn(), - getAllMapIds: jest.fn(), - getAllStreams: jest.fn(), - reloadConfig: jest.fn(), - validateConfig: jest.fn(), - } as any; - - mockLoginCoreService = { - verifyToken: jest.fn(), - generateTokens: jest.fn(), - refreshTokens: jest.fn(), - revokeToken: jest.fn(), - validateTokenPayload: jest.fn(), - } as any; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ZulipService, - { - provide: 'ZULIP_CLIENT_POOL_SERVICE', - useValue: mockZulipClientPool, - }, - { - provide: SessionManagerService, - useValue: mockSessionManager, - }, - { - provide: MessageFilterService, - useValue: mockMessageFilter, - }, - { - provide: ZulipEventProcessorService, - useValue: mockEventProcessor, - }, - { - provide: 'ZULIP_CONFIG_SERVICE', - useValue: mockConfigManager, - }, - { - provide: 'API_KEY_SECURITY_SERVICE', - useValue: { - extractApiKey: jest.fn(), - validateApiKey: jest.fn(), - encryptApiKey: jest.fn(), - decryptApiKey: jest.fn(), - getApiKey: jest.fn().mockResolvedValue({ - success: true, - apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8', - }), - }, - }, - { - provide: LoginCoreService, - useValue: mockLoginCoreService, - }, - ], - }).compile(); - - service = module.get(ZulipService); - - // 配置LoginCoreService的默认mock行为 - mockLoginCoreService.verifyToken.mockImplementation(async (token: string) => { - // 模拟token验证逻辑 - if (token.startsWith('invalid')) { - throw new Error('Invalid token'); - } - - // 从token中提取用户信息(模拟JWT解析) - const userId = `user_${token.substring(0, 8)}`; - const username = `Player_${userId.substring(5, 10)}`; - const email = `${userId}@example.com`; - - return { - sub: userId, - username, - email, - role: 1, // 数字类型的角色 - type: 'access' as const, - iat: Math.floor(Date.now() / 1000), - exp: Math.floor(Date.now() / 1000) + 3600, - iss: 'whale-town', - aud: 'whale-town-users', - }; - }); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('handlePlayerLogin - 处理玩家登录', () => { - it('应该成功处理有效Token的登录请求', async () => { - const loginRequest: PlayerLoginRequest = { - token: 'valid_token_123', - socketId: 'socket-456', - }; - - const mockSession = createMockSession({ - socketId: 'socket-456', - userId: 'user_valid_to', - username: 'Player_lid_to', - }); - - mockConfigManager.getZulipConfig.mockReturnValue({ - zulipServerUrl: 'https://zulip.example.com', - }); - - mockZulipClientPool.createUserClient.mockResolvedValue( - createMockClientInstance({ - userId: 'user_valid_to', - queueId: 'queue-789', - }) - ); - - mockSessionManager.createSession.mockResolvedValue(mockSession); - - const result = await service.handlePlayerLogin(loginRequest); - - expect(result.success).toBe(true); - expect(result.userId).toBe('user_valid_to'); - expect(result.username).toBe('Player_valid'); - expect(result.currentMap).toBe('whale_port'); - expect(mockSessionManager.createSession).toHaveBeenCalled(); - }); - - it('应该拒绝无效Token的登录请求', async () => { - const loginRequest: PlayerLoginRequest = { - token: 'invalid_token', - socketId: 'socket-456', - }; - - const result = await service.handlePlayerLogin(loginRequest); - - expect(result.success).toBe(false); - expect(result.error).toBe('Token验证失败'); - expect(mockSessionManager.createSession).not.toHaveBeenCalled(); - }); - - it('应该处理空Token的情况', async () => { - const loginRequest: PlayerLoginRequest = { - token: '', - socketId: 'socket-456', - }; - - const result = await service.handlePlayerLogin(loginRequest); - - expect(result.success).toBe(false); - expect(result.error).toBe('Token不能为空'); - }); - - it('应该处理空socketId的情况', async () => { - const loginRequest: PlayerLoginRequest = { - token: 'valid_token', - socketId: '', - }; - - const result = await service.handlePlayerLogin(loginRequest); - - expect(result.success).toBe(false); - expect(result.error).toBe('socketId不能为空'); - }); - it('应该在Zulip客户端创建失败时使用本地模式', async () => { - const loginRequest: PlayerLoginRequest = { - token: 'real_user_token_with_zulip_key_123', // 有API Key的Token - socketId: 'socket-456', - }; - - const mockSession = createMockSession({ - socketId: 'socket-456', - userId: 'user_real_user_', - }); - - mockConfigManager.getZulipConfig.mockReturnValue({ - zulipServerUrl: 'https://zulip.example.com', - }); - - // 模拟Zulip客户端创建失败 - mockZulipClientPool.createUserClient.mockRejectedValue(new Error('Zulip连接失败')); - mockSessionManager.createSession.mockResolvedValue(mockSession); - - const result = await service.handlePlayerLogin(loginRequest); - - // 应该成功登录(本地模式) - expect(result.success).toBe(true); - expect(mockSessionManager.createSession).toHaveBeenCalled(); - }); - }); - - describe('handlePlayerLogout - 处理玩家登出', () => { - it('应该成功处理玩家登出', async () => { - const socketId = 'socket-123'; - const mockSession = createMockSession({ socketId, userId: 'user-123' }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockZulipClientPool.destroyUserClient.mockResolvedValue(); - mockSessionManager.destroySession.mockResolvedValue(undefined); - - await service.handlePlayerLogout(socketId); - - expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); - expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123'); - expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId); - }); - - it('应该处理会话不存在的情况', async () => { - const socketId = 'non-existent-socket'; - - mockSessionManager.getSession.mockResolvedValue(null); - - await service.handlePlayerLogout(socketId); - - expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); - expect(mockZulipClientPool.destroyUserClient).not.toHaveBeenCalled(); - expect(mockSessionManager.destroySession).not.toHaveBeenCalled(); - }); - - it('应该在Zulip客户端清理失败时继续执行会话清理', async () => { - const socketId = 'socket-123'; - const mockSession = createMockSession({ socketId, userId: 'user-123' }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockZulipClientPool.destroyUserClient.mockRejectedValue(new Error('清理失败')); - mockSessionManager.destroySession.mockResolvedValue(undefined); - - await service.handlePlayerLogout(socketId); - - expect(mockZulipClientPool.destroyUserClient).toHaveBeenCalledWith('user-123'); - expect(mockSessionManager.destroySession).toHaveBeenCalledWith(socketId); - }); - }); - - describe('sendChatMessage - 发送聊天消息', () => { - it('应该成功发送聊天消息', async () => { - const chatRequest: ChatMessageRequest = { - socketId: 'socket-123', - content: 'Hello, world!', - scope: 'local', - }; - - const mockSession = createMockSession({ - socketId: 'socket-123', - userId: 'user-123', - currentMap: 'tavern', - }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: 'Tavern', - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: true, - filteredContent: 'Hello, world!', - }); - - mockZulipClientPool.sendMessage.mockResolvedValue({ - success: true, - messageId: 12345, - }); - - const result = await service.sendChatMessage(chatRequest); - - expect(result.success).toBe(true); - expect(result.messageId).toMatch(/^game_\d+_user-123$/); - expect(mockZulipClientPool.sendMessage).toHaveBeenCalledWith( - 'user-123', - 'Tavern', - 'General', - expect.stringContaining('Hello, world!') - ); - }); - - it('应该拒绝会话不存在的消息发送', async () => { - const chatRequest: ChatMessageRequest = { - socketId: 'non-existent-socket', - content: 'Hello, world!', - scope: 'local', - }; - - mockSessionManager.getSession.mockResolvedValue(null); - - const result = await service.sendChatMessage(chatRequest); - - expect(result.success).toBe(false); - expect(result.error).toBe('会话不存在,请重新登录'); - expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); - }); - - it('应该拒绝未通过验证的消息', async () => { - const chatRequest: ChatMessageRequest = { - socketId: 'socket-123', - content: '敏感词内容', - scope: 'local', - }; - - const mockSession = createMockSession({ socketId: 'socket-123' }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: 'Tavern', - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: false, - reason: '消息包含敏感词', - }); - - const result = await service.sendChatMessage(chatRequest); - - expect(result.success).toBe(false); - expect(result.error).toBe('消息包含敏感词'); - expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); - }); - - it('应该在Zulip发送失败时仍返回成功(本地模式)', async () => { - const chatRequest: ChatMessageRequest = { - socketId: 'socket-123', - content: 'Hello, world!', - scope: 'local', - }; - - const mockSession = createMockSession({ socketId: 'socket-123' }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: 'Tavern', - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: true, - filteredContent: 'Hello, world!', - }); - - mockZulipClientPool.sendMessage.mockResolvedValue({ - success: false, - error: 'Zulip服务不可用', - }); - - const result = await service.sendChatMessage(chatRequest); - - expect(result.success).toBe(true); // 本地模式下仍返回成功 - }); - }); - - describe('updatePlayerPosition - 更新玩家位置', () => { - it('应该成功更新玩家位置', async () => { - const positionRequest: PositionUpdateRequest = { - socketId: 'socket-123', - x: 500, - y: 400, - mapId: 'tavern', - }; - - mockSessionManager.updatePlayerPosition.mockResolvedValue(true); - - const result = await service.updatePlayerPosition(positionRequest); - - expect(result).toBe(true); - expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith( - 'socket-123', - 'tavern', - 500, - 400 - ); - }); - - it('应该拒绝空socketId的位置更新', async () => { - const positionRequest: PositionUpdateRequest = { - socketId: '', - x: 500, - y: 400, - mapId: 'tavern', - }; - - const result = await service.updatePlayerPosition(positionRequest); - - expect(result).toBe(false); - expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); - }); - - it('应该拒绝空mapId的位置更新', async () => { - const positionRequest: PositionUpdateRequest = { - socketId: 'socket-123', - x: 500, - y: 400, - mapId: '', - }; - - const result = await service.updatePlayerPosition(positionRequest); - - expect(result).toBe(false); - expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); - }); - }); - /** - * 属性测试: 玩家登录流程完整性 - * - * **Feature: zulip-integration, Property 1: 玩家登录流程完整性** - * **Validates: Requirements 1.1, 1.2, 1.3, 1.4, 1.5** - * - * 对于任何有效的游戏Token,系统应该能够验证Token,创建Zulip客户端, - * 建立会话映射,并返回成功的登录响应 - */ - describe('Property 1: 玩家登录流程完整性', () => { - /** - * 属性: 对于任何有效的Token和socketId,登录应该成功并创建会话 - * 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性 - * 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key - * 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例 - */ - it('对于任何有效的Token和socketId,登录应该成功并创建会话', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的Token(不以'invalid'开头) - fc.string({ minLength: 8, maxLength: 50 }) - .filter(s => !s.startsWith('invalid') && s.trim().length > 0), - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - async (token, socketId) => { - const trimmedToken = token.trim(); - const trimmedSocketId = socketId.trim(); - - const loginRequest: PlayerLoginRequest = { - token: trimmedToken, - socketId: trimmedSocketId, - }; - - const expectedUserId = `user_${trimmedToken.substring(0, 8)}`; - const expectedUsername = `Player_${expectedUserId.substring(5, 10)}`; - - const mockSession = createMockSession({ - socketId: trimmedSocketId, - userId: expectedUserId, - username: expectedUsername, - }); - - mockConfigManager.getZulipConfig.mockReturnValue({ - zulipServerUrl: 'https://zulip.example.com', - }); - - mockSessionManager.createSession.mockResolvedValue(mockSession); - - const result = await service.handlePlayerLogin(loginRequest); - - // 验证登录成功 - expect(result.success).toBe(true); - expect(result.userId).toBe(expectedUserId); - expect(result.username).toBe(expectedUsername); - expect(result.currentMap).toBe('whale_port'); - expect(result.sessionId).toBeDefined(); - - // 验证会话创建被调用 - expect(mockSessionManager.createSession).toHaveBeenCalledWith( - trimmedSocketId, - expectedUserId, - expect.any(String), // zulipQueueId - expectedUsername, - 'whale_port', - { x: 400, y: 300 } - ); - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何无效的Token,登录应该失败 - * 验证需求 1.1: 游戏客户端连接时系统应验证游戏Token的有效性 - */ - it('对于任何无效的Token,登录应该失败', async () => { - await fc.assert( - fc.asyncProperty( - // 生成无效的Token(以'invalid'开头) - fc.string({ minLength: 1, maxLength: 30 }) - .map(s => `invalid${s}`), - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - async (invalidToken, socketId) => { - const loginRequest: PlayerLoginRequest = { - token: invalidToken, - socketId: socketId.trim(), - }; - - const result = await service.handlePlayerLogin(loginRequest); - - // 验证登录失败 - expect(result.success).toBe(false); - expect(result.error).toBe('Token验证失败'); - expect(result.userId).toBeUndefined(); - expect(result.sessionId).toBeUndefined(); - - // 验证没有创建会话 - expect(mockSessionManager.createSession).not.toHaveBeenCalled(); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 对于空或无效的参数,登录应该返回相应的错误信息 - * 验证需求 1.1: 系统应正确处理无效的登录请求 - */ - it('对于空或无效的参数,登录应该返回相应的错误信息', async () => { - await fc.assert( - fc.asyncProperty( - // 生成可能为空或以'invalid'开头的Token - fc.oneof( - fc.constant(''), // 空字符串 - fc.constant(' '), // 只有空格 - fc.string({ minLength: 1, maxLength: 50 }).map(s => 'invalid' + s), // 以invalid开头 - ), - // 生成可能为空的socketId - fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }), - async (token, socketId) => { - // 重置mock调用历史 - jest.clearAllMocks(); - - const loginRequest: PlayerLoginRequest = { - token: token || '', - socketId: socketId || '', - }; - - const result = await service.handlePlayerLogin(loginRequest); - - // 验证登录失败 - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - - if (!token || token.trim().length === 0) { - expect(result.error).toBe('Token不能为空'); - } else if (!socketId || socketId.trim().length === 0) { - expect(result.error).toBe('socketId不能为空'); - } else if (token.startsWith('invalid')) { - expect(result.error).toBe('Token验证失败'); - } - - // 验证没有创建会话 - expect(mockSessionManager.createSession).not.toHaveBeenCalled(); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 对于有Zulip API Key的用户,应该尝试创建Zulip客户端 - * 验证需求 1.2: Token验证成功后系统应获取用户的Zulip API Key - * 验证需求 1.3: 获取API Key后系统应创建用户专用的Zulip客户端实例 - */ - it('对于有Zulip API Key的用户,应该尝试创建Zulip客户端', async () => { - await fc.assert( - fc.asyncProperty( - // 生成包含特定标识的Token(表示有API Key) - fc.constantFrom( - 'real_user_token_with_zulip_key_123', - 'token_with_lCPWCPf_key', - 'token_with_W2KhXaQx_key' - ), - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - async (tokenWithApiKey, socketId) => { - const loginRequest: PlayerLoginRequest = { - token: tokenWithApiKey, - socketId: socketId.trim(), - }; - - const mockClientInstance = createMockClientInstance({ - userId: `user_${tokenWithApiKey.substring(0, 8)}`, - queueId: 'test-queue-123', - }); - - const mockSession = createMockSession({ - socketId: socketId.trim(), - zulipQueueId: 'test-queue-123', - }); - - // Mock validateGameToken to return user with API key - const mockUserInfo = { - userId: `user_${tokenWithApiKey.substring(0, 8)}`, - username: 'TestUser', - email: 'test@example.com', - zulipEmail: 'test@example.com', - zulipApiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8', - }; - - // Spy on the private method - jest.spyOn(service as any, 'validateGameToken').mockResolvedValue(mockUserInfo); - - mockConfigManager.getZulipConfig.mockReturnValue({ - zulipServerUrl: 'https://zulip.example.com', - }); - - mockZulipClientPool.createUserClient.mockResolvedValue(mockClientInstance); - mockSessionManager.createSession.mockResolvedValue(mockSession); - - const result = await service.handlePlayerLogin(loginRequest); - - // 验证登录成功 - expect(result.success).toBe(true); - - // 验证尝试创建了Zulip客户端 - expect(mockZulipClientPool.createUserClient).toHaveBeenCalledWith( - mockUserInfo.userId, - expect.objectContaining({ - username: mockUserInfo.zulipEmail, - apiKey: mockUserInfo.zulipApiKey, - realm: expect.any(String), - }) - ); - } - ), - { numRuns: 30 } - ); - }, 30000); - }); - /** - * 属性测试: 消息发送流程完整性 - * - * **Feature: zulip-integration, Property 3: 消息发送流程完整性** - * **Validates: Requirements 3.1, 3.2, 3.3, 3.4, 3.5** - * - * 对于任何有效的聊天消息请求,系统应该进行内容过滤、权限验证、 - * 上下文注入,并成功发送到对应的Zulip Stream/Topic - */ - describe('Property 3: 消息发送流程完整性', () => { - /** - * 属性: 对于任何有效会话的消息发送请求,应该成功处理并发送 - * 验证需求 3.1: 游戏客户端发送聊天消息时系统应获取玩家当前位置 - * 验证需求 3.2: 获取位置后系统应根据位置确定目标Stream和Topic - * 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查 - */ - it('对于任何有效会话的消息发送请求,应该成功处理并发送', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - // 生成有效的消息内容 - fc.string({ minLength: 1, maxLength: 200 }) - .filter(s => s.trim().length > 0 && !/[敏感词|违禁词]/.test(s)), - // 生成地图和Stream映射 - fc.record({ - mapId: fc.constantFrom('tavern', 'novice_village', 'market'), - streamName: fc.constantFrom('Tavern', 'Novice Village', 'Market'), - }), - async (socketId, content, mapping) => { - const chatRequest: ChatMessageRequest = { - socketId: socketId.trim(), - content: content.trim(), - scope: 'local', - }; - - const mockSession = createMockSession({ - socketId: socketId.trim(), - userId: `user_${socketId.substring(0, 8)}`, - currentMap: mapping.mapId, - }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: mapping.streamName, - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: true, - filteredContent: content.trim(), - }); - - mockZulipClientPool.sendMessage.mockResolvedValue({ - success: true, - messageId: Math.floor(Math.random() * 1000000), - }); - - const result = await service.sendChatMessage(chatRequest); - - // 验证消息发送成功 - expect(result.success).toBe(true); - expect(result.messageId).toBeDefined(); - - // 验证调用了正确的方法 - expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId.trim()); - expect(mockSessionManager.injectContext).toHaveBeenCalledWith(socketId.trim()); - expect(mockMessageFilter.validateMessage).toHaveBeenCalledWith( - mockSession.userId, - content.trim(), - mapping.streamName, - mapping.mapId - ); - // 注意:sendMessage是异步调用的,不在主流程中验证 - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何不存在的会话,消息发送应该失败 - * 验证需求 3.1: 系统应验证会话的有效性 - */ - it('对于任何不存在的会话,消息发送应该失败', async () => { - await fc.assert( - fc.asyncProperty( - // 生成不存在的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0) - .map(s => `nonexistent_${s}`), - // 生成任意消息内容 - fc.string({ minLength: 1, maxLength: 200 }) - .filter(s => s.trim().length > 0), - async (nonExistentSocketId, content) => { - const chatRequest: ChatMessageRequest = { - socketId: nonExistentSocketId, - content: content.trim(), - scope: 'local', - }; - - mockSessionManager.getSession.mockResolvedValue(null); - - const result = await service.sendChatMessage(chatRequest); - - // 验证消息发送失败 - expect(result.success).toBe(false); - expect(result.error).toBe('会话不存在,请重新登录'); - - // 验证没有进行后续处理 - expect(mockMessageFilter.validateMessage).not.toHaveBeenCalled(); - expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 对于任何未通过验证的消息,发送应该失败 - * 验证需求 3.3: 确定目标后系统应进行消息内容过滤和频率检查 - */ - it('对于任何未通过验证的消息,发送应该失败', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - // 生成可能包含敏感词的消息内容 - fc.string({ minLength: 1, maxLength: 200 }) - .filter(s => s.trim().length > 0), - // 生成验证失败的原因 - fc.constantFrom( - '消息包含敏感词', - '发送频率过快', - '权限不足', - '消息长度超限' - ), - async (socketId, content, failureReason) => { - const chatRequest: ChatMessageRequest = { - socketId: socketId.trim(), - content: content.trim(), - scope: 'local', - }; - - const mockSession = createMockSession({ - socketId: socketId.trim(), - }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: 'Tavern', - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: false, - reason: failureReason, - }); - - const result = await service.sendChatMessage(chatRequest); - - // 验证消息发送失败 - expect(result.success).toBe(false); - expect(result.error).toBe(failureReason); - - // 验证没有发送到Zulip - expect(mockZulipClientPool.sendMessage).not.toHaveBeenCalled(); - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 即使Zulip发送失败,系统也应该返回成功(本地模式) - * 验证需求 3.5: 发送消息到Zulip时系统应处理发送失败的情况 - */ - it('即使Zulip发送失败,系统也应该返回成功(本地模式)', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - // 生成有效的消息内容 - fc.string({ minLength: 1, maxLength: 200 }) - .filter(s => s.trim().length > 0), - // 生成Zulip错误信息 - fc.constantFrom( - 'Zulip服务不可用', - '网络连接超时', - 'API Key无效', - 'Stream不存在' - ), - async (socketId, content, zulipError) => { - const chatRequest: ChatMessageRequest = { - socketId: socketId.trim(), - content: content.trim(), - scope: 'local', - }; - - const mockSession = createMockSession({ - socketId: socketId.trim(), - }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - mockSessionManager.injectContext.mockResolvedValue({ - stream: 'Tavern', - topic: 'General', - }); - - mockMessageFilter.validateMessage.mockResolvedValue({ - allowed: true, - filteredContent: content.trim(), - }); - - mockZulipClientPool.sendMessage.mockResolvedValue({ - success: false, - error: zulipError, - }); - - const result = await service.sendChatMessage(chatRequest); - - // 验证本地模式下仍返回成功 - expect(result.success).toBe(true); - expect(result.messageId).toBeDefined(); // 游戏内消息ID总是存在 - } - ), - { numRuns: 50 } - ); - }, 30000); - }); - /** - * 属性测试: 位置更新和上下文注入 - * - * **Feature: zulip-integration, Property 6: 位置更新和上下文注入** - * **Validates: Requirements 4.1, 4.2, 4.3, 4.4** - * - * 对于任何位置更新请求,系统应该正确更新玩家位置信息, - * 并在消息发送时根据位置进行上下文注入 - */ - describe('Property 6: 位置更新和上下文注入', () => { - /** - * 属性: 对于任何有效的位置更新请求,应该成功更新位置 - * 验证需求 4.1: 玩家移动时系统应更新玩家在游戏世界中的位置信息 - * 验证需求 4.2: 更新位置时系统应验证位置的有效性 - */ - it('对于任何有效的位置更新请求,应该成功更新位置', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - // 生成有效的坐标 - fc.record({ - x: fc.integer({ min: 0, max: 2000 }), - y: fc.integer({ min: 0, max: 2000 }), - }), - // 生成有效的地图ID - fc.constantFrom('tavern', 'novice_village', 'market', 'whale_port'), - async (socketId, position, mapId) => { - const positionRequest: PositionUpdateRequest = { - socketId: socketId.trim(), - x: position.x, - y: position.y, - mapId, - }; - - mockSessionManager.updatePlayerPosition.mockResolvedValue(true); - - const result = await service.updatePlayerPosition(positionRequest); - - // 验证位置更新成功 - expect(result).toBe(true); - - // 验证调用了正确的方法 - expect(mockSessionManager.updatePlayerPosition).toHaveBeenCalledWith( - socketId.trim(), - mapId, - position.x, - position.y - ); - } - ), - { numRuns: 100 } - ); - }, 60000); - - /** - * 属性: 对于任何无效的参数,位置更新应该失败 - * 验证需求 4.2: 更新位置时系统应验证位置的有效性 - */ - it('对于任何无效的参数,位置更新应该失败', async () => { - await fc.assert( - fc.asyncProperty( - // 生成可能为空的socketId - fc.option(fc.string({ minLength: 1, maxLength: 30 }), { nil: '' }), - // 生成可能为空的mapId - fc.option(fc.constantFrom('tavern', 'market'), { nil: '' }), - // 生成坐标 - fc.record({ - x: fc.integer({ min: 0, max: 2000 }), - y: fc.integer({ min: 0, max: 2000 }), - }), - async (socketId, mapId, position) => { - // 重置mock调用历史 - jest.clearAllMocks(); - - const positionRequest: PositionUpdateRequest = { - socketId: socketId || '', - x: position.x, - y: position.y, - mapId: mapId || '', - }; - - const result = await service.updatePlayerPosition(positionRequest); - - if (!socketId || socketId.trim().length === 0 || - !mapId || mapId.trim().length === 0) { - // 验证位置更新失败 - expect(result).toBe(false); - - // 验证没有调用SessionManager - expect(mockSessionManager.updatePlayerPosition).not.toHaveBeenCalled(); - } - } - ), - { numRuns: 50 } - ); - }, 30000); - - /** - * 属性: 位置更新失败时应该正确处理错误 - * 验证需求 4.1: 系统应正确处理位置更新过程中的错误 - */ - it('位置更新失败时应该正确处理错误', async () => { - await fc.assert( - fc.asyncProperty( - // 生成有效的socketId - fc.string({ minLength: 5, maxLength: 30 }) - .filter(s => s.trim().length > 0), - // 生成有效的坐标 - fc.record({ - x: fc.integer({ min: 0, max: 2000 }), - y: fc.integer({ min: 0, max: 2000 }), - }), - // 生成有效的地图ID - fc.constantFrom('tavern', 'novice_village', 'market'), - async (socketId, position, mapId) => { - const positionRequest: PositionUpdateRequest = { - socketId: socketId.trim(), - x: position.x, - y: position.y, - mapId, - }; - - // 模拟SessionManager抛出错误 - mockSessionManager.updatePlayerPosition.mockRejectedValue( - new Error('位置更新失败') - ); - - const result = await service.updatePlayerPosition(positionRequest); - - // 验证位置更新失败 - expect(result).toBe(false); - } - ), - { numRuns: 50 } - ); - }, 30000); - }); - - describe('辅助方法', () => { - it('getSession - 应该返回会话信息', async () => { - const socketId = 'socket-123'; - const mockSession = createMockSession({ socketId }); - - mockSessionManager.getSession.mockResolvedValue(mockSession); - - const result = await service.getSession(socketId); - - expect(result).toBe(mockSession); - expect(mockSessionManager.getSession).toHaveBeenCalledWith(socketId); - }); - - it('getSocketsInMap - 应该返回地图中的Socket列表', async () => { - const mapId = 'tavern'; - const socketIds = ['socket-1', 'socket-2', 'socket-3']; - - mockSessionManager.getSocketsInMap.mockResolvedValue(socketIds); - - const result = await service.getSocketsInMap(mapId); - - expect(result).toBe(socketIds); - expect(mockSessionManager.getSocketsInMap).toHaveBeenCalledWith(mapId); - }); - }); -}); \ No newline at end of file diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts deleted file mode 100644 index 894324b..0000000 --- a/src/business/zulip/zulip.service.ts +++ /dev/null @@ -1,1043 +0,0 @@ -/** - * 优化后的Zulip服务 - 实现游戏内实时聊天 + Zulip异步同步 - * - * 核心优化: - * 1. 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip - * 2. 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储 - * 3. ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms - * 4. 🛡️ 容错性强:Zulip异常不影响游戏聊天体验 - * - * 职责分离: - * - 业务协调:整合会话管理、消息过滤等子服务 - * - 流程控制:管理玩家登录登出的完整业务流程 - * - 实时广播:游戏内消息的即时分发 - * - 异步同步:Zulip消息的后台存储 - * - * 主要方法: - * - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化 - * - handlePlayerLogout(): 处理玩家登出和资源清理 - * - sendChatMessage(): 优化的聊天消息发送(实时+异步) - * - updatePlayerPosition(): 更新玩家位置信息 - * - * 使用场景: - * - WebSocket网关调用处理消息路由 - * - 会话管理和状态维护 - * - 游戏内实时聊天广播 - * - Zulip消息异步存储 - * - * 最近修改: - * - 2026-01-10: 重构优化 - 实现游戏内实时聊天+Zulip异步同步架构 (修改者: moyin) - * - * @author angjustinl - * @version 2.0.0 - * @since 2026-01-06 - * @lastModified 2026-01-10 - */ - -import { Injectable, Logger, Inject } from '@nestjs/common'; -import { randomUUID } from 'crypto'; -import { SessionManagerService } from './services/session_manager.service'; -import { MessageFilterService } from './services/message_filter.service'; -import { - IZulipClientPoolService, - IApiKeySecurityService, -} from '../../core/zulip_core/zulip_core.interfaces'; -import { LoginCoreService } from '../../core/login_core/login_core.service'; - -/** - * 聊天消息请求接口 - */ -export interface ChatMessageRequest { - socketId: string; - content: string; - scope: string; -} - -/** - * 聊天消息响应接口 - */ -export interface ChatMessageResponse { - success: boolean; - messageId?: string; - error?: string; -} - -/** - * 玩家登录请求接口 - */ -export interface PlayerLoginRequest { - token: string; - socketId: string; -} - -/** - * 登录响应接口 - */ -export interface LoginResponse { - success: boolean; - sessionId?: string; - userId?: string; - username?: string; - currentMap?: string; - error?: string; -} - -/** - * 位置更新请求接口 - */ -export interface PositionUpdateRequest { - socketId: string; - x: number; - y: number; - mapId: string; -} - -/** - * 游戏消息接口 - */ -interface GameChatMessage { - t: 'chat_render'; - from: string; - txt: string; - bubble: boolean; - timestamp: string; - messageId: string; - mapId: string; - scope: string; -} - -/** - * WebSocket网关接口(用于依赖注入) - */ -interface IWebSocketGateway { - broadcastToMap(mapId: string, data: any, excludeId?: string): void; - sendToPlayer(socketId: string, data: any): void; -} - -/** - * Zulip集成主服务类 - * - * 职责: - * - 作为Zulip集成系统的主要协调服务 - * - 整合各个子服务,提供统一的业务接口 - * - 实现游戏内实时聊天 + Zulip异步同步 - * - 管理玩家会话和消息路由 - * - * 核心优化: - * - 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip - * - 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储 - * - ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms - * - 🛡️ 容错性强:Zulip异常不影响游戏聊天体验 - * - * 主要方法: - * - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化 - * - handlePlayerLogout(): 处理玩家登出和资源清理 - * - sendChatMessage(): 优化的聊天消息发送(实时+异步) - * - updatePlayerPosition(): 更新玩家位置信息 - * - * 使用场景: - * - WebSocket网关调用处理消息路由 - * - 会话管理和状态维护 - * - 游戏内实时聊天广播 - * - Zulip消息异步存储 - */ -@Injectable() -export class ZulipService { - private readonly logger = new Logger(ZulipService.name); - private readonly DEFAULT_MAP = 'whale_port'; - - constructor( - @Inject('ZULIP_CLIENT_POOL_SERVICE') - private readonly zulipClientPool: IZulipClientPoolService, - private readonly sessionManager: SessionManagerService, - private readonly messageFilter: MessageFilterService, - @Inject('API_KEY_SECURITY_SERVICE') - private readonly apiKeySecurityService: IApiKeySecurityService, - private readonly loginCoreService: LoginCoreService, - ) { - this.logger.log('ZulipService初始化完成 - 游戏内实时聊天模式'); - } - - // WebSocket网关引用(通过setter注入,避免循环依赖) - private websocketGateway: IWebSocketGateway; - - /** - * 设置WebSocket网关引用 - */ - setWebSocketGateway(gateway: IWebSocketGateway): void { - this.websocketGateway = gateway; - this.logger.log('WebSocket网关引用设置完成'); - } - - /** - * 处理玩家登录 - * - * 功能描述: - * 验证游戏Token,创建Zulip客户端,建立会话映射关系 - * - * 业务逻辑: - * 1. 验证游戏Token的有效性 - * 2. 获取用户的Zulip API Key - * 3. 创建用户专用的Zulip客户端实例 - * 4. 注册Zulip事件队列 - * 5. 建立Socket_ID与Zulip_Queue_ID的映射关系 - * 6. 返回登录成功确认 - * - * @param request 玩家登录请求数据 - * @returns Promise - * - * @throws UnauthorizedException 当Token验证失败时 - * @throws InternalServerErrorException 当系统操作失败时 - * - * @example - * ```typescript - * const loginRequest: PlayerLoginRequest = { - * token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - * socketId: 'socket_12345' - * }; - * const result = await zulipService.handlePlayerLogin(loginRequest); - * if (result.success) { - * console.log(`用户 ${result.username} 登录成功`); - * } - * ``` - */ - async handlePlayerLogin(request: PlayerLoginRequest): Promise { - const startTime = Date.now(); - - this.logger.log('开始处理玩家登录', { - operation: 'handlePlayerLogin', - socketId: request.socketId, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 验证请求参数 - const paramValidation = this.validateLoginParams(request); - if (!paramValidation.isValid) { - return { - success: false, - error: paramValidation.error, - }; - } - - // 2. 验证游戏Token并获取用户信息 - const userInfo = await this.validateGameToken(request.token); - if (!userInfo) { - this.logger.warn('登录失败:Token验证失败', { - operation: 'handlePlayerLogin', - socketId: request.socketId, - }); - return { - success: false, - error: 'Token验证失败', - }; - } - - // 3. 创建Zulip客户端和会话 - const sessionResult = await this.createUserSession(request.socketId, userInfo); - - const duration = Date.now() - startTime; - - this.logger.log('玩家登录处理完成', { - operation: 'handlePlayerLogin', - socketId: request.socketId, - sessionId: sessionResult.sessionId, - userId: userInfo.userId, - username: userInfo.username, - currentMap: sessionResult.currentMap, - duration, - timestamp: new Date().toISOString(), - }); - - return { - success: true, - sessionId: sessionResult.sessionId, - userId: userInfo.userId, - username: userInfo.username, - currentMap: sessionResult.currentMap, - }; - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('玩家登录处理失败', { - operation: 'handlePlayerLogin', - socketId: request.socketId, - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - return { - success: false, - error: '登录失败,请稍后重试', - }; - } - } - - /** - * 验证登录请求参数 - * - * @param request 登录请求 - * @returns 验证结果 - * @private - */ - private validateLoginParams(request: PlayerLoginRequest): { isValid: boolean; error?: string } { - if (!request.token || !request.token.trim()) { - this.logger.warn('登录失败:Token为空', { - operation: 'validateLoginParams', - socketId: request.socketId, - }); - return { - isValid: false, - error: 'Token不能为空', - }; - } - - if (!request.socketId || !request.socketId.trim()) { - this.logger.warn('登录失败:socketId为空', { - operation: 'validateLoginParams', - }); - return { - isValid: false, - error: 'socketId不能为空', - }; - } - - return { isValid: true }; - } - - /** - * 创建用户会话和Zulip客户端 - * - * @param socketId Socket连接ID - * @param userInfo 用户信息 - * @returns 会话创建结果 - * @private - */ - private async createUserSession(socketId: string, userInfo: any): Promise<{ sessionId: string; currentMap: string }> { - // 生成会话ID - const sessionId = randomUUID(); - - // 调试日志:检查用户信息 - this.logger.log('用户信息检查', { - operation: 'createUserSession', - userId: userInfo.userId, - hasZulipApiKey: !!userInfo.zulipApiKey, - zulipApiKeyLength: userInfo.zulipApiKey?.length || 0, - zulipEmail: userInfo.zulipEmail, - email: userInfo.email, - }); - - // 创建Zulip客户端(如果有API Key) - let zulipQueueId = `queue_${sessionId}`; - - if (userInfo.zulipApiKey) { - try { - const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, { - username: userInfo.zulipEmail || userInfo.email, - apiKey: userInfo.zulipApiKey, - realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/', - }); - - if (clientInstance.queueId) { - zulipQueueId = clientInstance.queueId; - } - - this.logger.log('Zulip客户端创建成功', { - operation: 'createUserSession', - userId: userInfo.userId, - queueId: zulipQueueId, - }); - } catch (zulipError) { - const err = zulipError as Error; - this.logger.warn('Zulip客户端创建失败,使用本地模式', { - operation: 'createUserSession', - userId: userInfo.userId, - error: err.message, - }); - // Zulip客户端创建失败不影响登录,使用本地模式 - } - } - - // 创建游戏会话 - const session = await this.sessionManager.createSession( - socketId, - userInfo.userId, - zulipQueueId, - userInfo.username, - this.DEFAULT_MAP, - { x: 400, y: 300 }, - ); - - return { - sessionId, - currentMap: session.currentMap, - }; - } - - /** - * 验证游戏Token - * - * 功能描述: - * 验证游戏Token的有效性,返回用户信息 - * - * @param token 游戏Token (JWT) - * @returns Promise 用户信息,验证失败返回null - * @private - */ - private async validateGameToken(token: string): Promise<{ - userId: string; - username: string; - email: string; - zulipEmail?: string; - zulipApiKey?: string; - } | null> { - this.logger.debug('验证游戏Token', { - operation: 'validateGameToken', - tokenLength: token.length, - }); - - try { - // 1. 使用LoginCoreService验证JWT token - const payload = await this.loginCoreService.verifyToken(token, 'access'); - - if (!payload || !payload.sub) { - this.logger.warn('Token载荷无效', { - operation: 'validateGameToken', - }); - return null; - } - - const userId = payload.sub; - const username = payload.username || `user_${userId}`; - const email = payload.email || `${userId}@example.com`; - - this.logger.debug('Token解析成功', { - operation: 'validateGameToken', - userId, - username, - email, - }); - - // 2. 登录时直接从数据库获取Zulip信息(不使用Redis缓存) - let zulipApiKey = undefined; - let zulipEmail = undefined; - - try { - // 从数据库查找Zulip账号关联 - const zulipAccount = await this.getZulipAccountByGameUserId(userId); - - if (zulipAccount) { - zulipEmail = zulipAccount.zulipEmail; - - // 登录时直接从数据库获取加密的API Key并解密 - if (zulipAccount.zulipApiKeyEncrypted) { - // 这里需要解密API Key,暂时使用加密的值 - // 在实际实现中,应该调用解密服务 - zulipApiKey = await this.decryptApiKey(zulipAccount.zulipApiKeyEncrypted); - - // 登录成功后,将API Key缓存到Redis供后续聊天使用 - if (zulipApiKey) { - await this.apiKeySecurityService.storeApiKey(userId, zulipApiKey); - } - - this.logger.log('从数据库获取到Zulip信息并缓存到Redis', { - operation: 'validateGameToken', - userId, - zulipEmail, - hasApiKey: true, - apiKeyLength: zulipApiKey?.length || 0, - }); - } else { - this.logger.debug('用户有Zulip账号关联但没有API Key', { - operation: 'validateGameToken', - userId, - zulipEmail, - }); - } - } else { - this.logger.debug('用户没有Zulip账号关联', { - operation: 'validateGameToken', - userId, - }); - } - } catch (error) { - const err = error as Error; - this.logger.warn('获取Zulip信息失败', { - operation: 'validateGameToken', - userId, - error: err.message, - }); - } - - return { - userId, - username, - email, - zulipEmail, - zulipApiKey, - }; - - } catch (error) { - const err = error as Error; - this.logger.warn('Token验证失败', { - operation: 'validateGameToken', - error: err.message, - }); - return null; - } - } - - /** - * 处理玩家登出 - * - * 功能描述: - * 清理玩家会话,注销Zulip事件队列,释放相关资源,清除Redis缓存 - * - * 业务逻辑: - * 1. 获取会话信息 - * 2. 注销Zulip事件队列 - * 3. 清理Zulip客户端实例 - * 4. 清除Redis中的API Key缓存 - * 5. 删除会话映射关系 - * 6. 记录登出日志 - * - * @param socketId WebSocket连接ID - * @param reason 登出原因('manual' | 'timeout' | 'disconnect') - * @returns Promise - */ - async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise { - const startTime = Date.now(); - - this.logger.log('开始处理玩家登出', { - operation: 'handlePlayerLogout', - socketId, - reason, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 获取会话信息 - const session = await this.sessionManager.getSession(socketId); - - if (!session) { - this.logger.log('会话不存在,跳过登出处理', { - operation: 'handlePlayerLogout', - socketId, - reason, - }); - return; - } - - const userId = session.userId; - - // 2. 清理Zulip客户端资源 - if (userId) { - try { - await this.zulipClientPool.destroyUserClient(userId); - this.logger.log('Zulip客户端清理完成', { - operation: 'handlePlayerLogout', - userId, - reason, - }); - } catch (zulipError) { - const err = zulipError as Error; - this.logger.warn('Zulip客户端清理失败', { - operation: 'handlePlayerLogout', - userId, - error: err.message, - reason, - }); - // 继续执行其他清理操作 - } - - // 3. 清除Redis中的API Key缓存(确保内存足够) - try { - const apiKeyDeleted = await this.apiKeySecurityService.deleteApiKey(userId); - this.logger.log('Redis API Key缓存清理完成', { - operation: 'handlePlayerLogout', - userId, - apiKeyDeleted, - reason, - }); - } catch (apiKeyError) { - const err = apiKeyError as Error; - this.logger.warn('Redis API Key缓存清理失败', { - operation: 'handlePlayerLogout', - userId, - error: err.message, - reason, - }); - // 继续执行其他清理操作 - } - } - - // 4. 删除会话映射 - await this.sessionManager.destroySession(socketId); - - const duration = Date.now() - startTime; - - this.logger.log('玩家登出处理完成', { - operation: 'handlePlayerLogout', - socketId, - userId: session.userId, - reason, - duration, - timestamp: new Date().toISOString(), - }); - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('玩家登出处理失败', { - operation: 'handlePlayerLogout', - socketId, - reason, - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - // 登出失败不抛出异常,确保连接能够正常断开 - } - } - - /** - * 优化后的聊天消息发送逻辑 - * - * 核心改进: - * 1. 立即广播给游戏内同区域玩家 - * 2. 异步同步到Zulip,不阻塞游戏聊天 - * 3. 提升用户体验和系统性能 - */ - async sendChatMessage(request: ChatMessageRequest): Promise { - const startTime = Date.now(); - - this.logger.log('开始处理聊天消息发送(优化模式)', { - operation: 'sendChatMessage', - socketId: request.socketId, - contentLength: request.content.length, - scope: request.scope, - timestamp: new Date().toISOString(), - }); - - try { - // 1. 获取会话信息 - const session = await this.sessionManager.getSession(request.socketId); - if (!session) { - return { - success: false, - error: '会话不存在,请重新登录', - }; - } - - // 2. 上下文注入:根据位置确定目标区域 - const context = await this.sessionManager.injectContext(request.socketId); - const targetStream = context.stream; - const targetTopic = context.topic || 'General'; - - // 3. 消息验证(内容过滤、频率限制、权限验证) - const validationResult = await this.messageFilter.validateMessage( - session.userId, - request.content, - targetStream, - session.currentMap, - ); - - if (!validationResult.allowed) { - this.logger.warn('消息验证失败', { - operation: 'sendChatMessage', - socketId: request.socketId, - userId: session.userId, - reason: validationResult.reason, - }); - return { - success: false, - error: validationResult.reason || '消息发送失败', - }; - } - - const messageContent = validationResult.filteredContent || request.content; - const messageId = `game_${Date.now()}_${session.userId}`; - - // 4. 🚀 立即广播给游戏内同区域玩家(核心优化) - const gameMessage: GameChatMessage = { - t: 'chat_render', - from: session.username, - txt: messageContent, - bubble: true, - timestamp: new Date().toISOString(), - messageId, - mapId: session.currentMap, - scope: request.scope, - }; - - // 立即广播,不等待结果 - this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId) - .catch(error => { - this.logger.warn('游戏内广播失败', { - operation: 'broadcastToGamePlayers', - mapId: session.currentMap, - error: error.message, - }); - }); - - // 5. 🔄 异步同步到Zulip(不阻塞游戏聊天) - this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId) - .catch(error => { - // Zulip同步失败不影响游戏聊天,只记录日志 - this.logger.warn('Zulip异步同步失败', { - operation: 'syncToZulipAsync', - userId: session.userId, - targetStream, - messageId, - error: error.message, - }); - }); - - const duration = Date.now() - startTime; - - this.logger.log('聊天消息发送完成(游戏内实时模式)', { - operation: 'sendChatMessage', - socketId: request.socketId, - userId: session.userId, - messageId, - targetStream, - targetTopic, - duration, - timestamp: new Date().toISOString(), - }); - - return { - success: true, - messageId, - }; - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('聊天消息发送失败', { - operation: 'sendChatMessage', - socketId: request.socketId, - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - return { - success: false, - error: '消息发送失败,请稍后重试', - }; - } - } - - /** - * 更新玩家位置 - * - * 功能描述: - * 更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入 - * - * @param request 位置更新请求数据 - * @returns Promise 是否更新成功 - */ - async updatePlayerPosition(request: PositionUpdateRequest): Promise { - this.logger.debug('更新玩家位置', { - operation: 'updatePlayerPosition', - socketId: request.socketId, - mapId: request.mapId, - position: { x: request.x, y: request.y }, - timestamp: new Date().toISOString(), - }); - - try { - // 验证参数 - if (!request.socketId || !request.socketId.trim()) { - this.logger.warn('更新位置失败:socketId为空', { - operation: 'updatePlayerPosition', - }); - return false; - } - - if (!request.mapId || !request.mapId.trim()) { - this.logger.warn('更新位置失败:mapId为空', { - operation: 'updatePlayerPosition', - socketId: request.socketId, - }); - return false; - } - - // 调用SessionManager更新位置信息 - const result = await this.sessionManager.updatePlayerPosition( - request.socketId, - request.mapId, - request.x, - request.y, - ); - - if (result) { - this.logger.debug('玩家位置更新成功', { - operation: 'updatePlayerPosition', - socketId: request.socketId, - mapId: request.mapId, - }); - } - - return result; - - } catch (error) { - const err = error as Error; - this.logger.error('更新玩家位置失败', { - operation: 'updatePlayerPosition', - socketId: request.socketId, - error: err.message, - timestamp: new Date().toISOString(), - }, err.stack); - - return false; - } - } - - /** - * 广播消息给游戏内同区域玩家 - * - * @param mapId 地图ID - * @param message 游戏消息 - * @param excludeSocketId 排除的Socket ID(发送者自己) - */ - private async broadcastToGamePlayers( - mapId: string, - message: GameChatMessage, - excludeSocketId?: string, - ): Promise { - const startTime = Date.now(); - - try { - if (!this.websocketGateway) { - throw new Error('WebSocket网关未设置'); - } - - // 获取地图内所有玩家的Socket连接 - const sockets = await this.sessionManager.getSocketsInMap(mapId); - - if (sockets.length === 0) { - this.logger.debug('地图中没有在线玩家', { - operation: 'broadcastToGamePlayers', - mapId, - }); - return; - } - - // 过滤掉发送者自己 - const targetSockets = sockets.filter(socketId => socketId !== excludeSocketId); - - if (targetSockets.length === 0) { - this.logger.debug('地图中没有其他玩家需要接收消息', { - operation: 'broadcastToGamePlayers', - mapId, - }); - return; - } - - // 并行发送给所有目标玩家 - const broadcastPromises = targetSockets.map(async (socketId) => { - try { - this.websocketGateway.sendToPlayer(socketId, message); - } catch (error) { - this.logger.warn('发送消息给玩家失败', { - operation: 'broadcastToGamePlayers', - socketId, - error: (error as Error).message, - }); - } - }); - - await Promise.allSettled(broadcastPromises); - - const duration = Date.now() - startTime; - - this.logger.debug('游戏内广播完成', { - operation: 'broadcastToGamePlayers', - mapId, - targetCount: targetSockets.length, - duration, - }); - - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('游戏内广播失败', { - operation: 'broadcastToGamePlayers', - mapId, - error: err.message, - duration, - }, err.stack); - - throw error; - } - } - - /** - * 异步同步消息到Zulip - * - * @param userId 用户ID - * @param stream Zulip Stream - * @param topic Zulip Topic - * @param content 消息内容 - * @param gameMessageId 游戏消息ID - */ - private async syncToZulipAsync( - userId: string, - stream: string, - topic: string, - content: string, - gameMessageId: string, - ): Promise { - const startTime = Date.now(); - - try { - // 聊天过程中从Redis缓存获取API Key - const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - - if (!apiKeyResult.success || !apiKeyResult.apiKey) { - this.logger.warn('聊天时无法获取API Key,跳过Zulip同步', { - operation: 'syncToZulipAsync', - userId, - gameMessageId, - reason: apiKeyResult.message || 'API Key不存在', - }); - return; - } - - // 添加游戏消息ID到Zulip消息中,便于追踪 - const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`; - - const sendResult = await this.zulipClientPool.sendMessage( - userId, - stream, - topic, - zulipContent, - ); - - const duration = Date.now() - startTime; - - if (sendResult.success) { - this.logger.debug('Zulip同步成功', { - operation: 'syncToZulipAsync', - userId, - stream, - topic, - gameMessageId, - zulipMessageId: sendResult.messageId, - duration, - }); - } else { - this.logger.warn('Zulip同步失败', { - operation: 'syncToZulipAsync', - userId, - stream, - topic, - gameMessageId, - error: sendResult.error, - duration, - }); - } - } catch (error) { - const err = error as Error; - const duration = Date.now() - startTime; - - this.logger.error('Zulip异步同步异常', { - operation: 'syncToZulipAsync', - userId, - stream, - topic, - gameMessageId, - error: err.message, - duration, - }, err.stack); - } - } - - /** - * 获取会话信息 - * - * 功能描述: - * 根据socketId获取会话信息 - * - * @param socketId WebSocket连接ID - * @returns Promise - */ - async getSession(socketId: string) { - return this.sessionManager.getSession(socketId); - } - - /** - * 获取地图中的所有Socket - * - * 功能描述: - * 获取指定地图中所有在线玩家的Socket ID列表 - * - * @param mapId 地图ID - * @returns Promise - */ - async getSocketsInMap(mapId: string): Promise { - return this.sessionManager.getSocketsInMap(mapId); - } - - /** - * 根据游戏用户ID获取Zulip账号信息 - * - * @param gameUserId 游戏用户ID - * @returns Promise Zulip账号信息 - * @private - */ - private async getZulipAccountByGameUserId(gameUserId: string): Promise { - try { - // 注入ZulipAccountsService,从数据库获取Zulip账号信息 - // 这里需要通过依赖注入获取ZulipAccountsService - // const zulipAccount = await this.zulipAccountsService.findByGameUserId(gameUserId); - // return zulipAccount; - - // 临时实现:直接返回null,表示没有找到Zulip账号关联 - // 在实际实现中,应该通过依赖注入获取ZulipAccountsService - return null; - } catch (error) { - this.logger.warn('获取Zulip账号信息失败', { - operation: 'getZulipAccountByGameUserId', - gameUserId, - error: (error as Error).message, - }); - return null; - } - } - - /** - * 解密API Key - * - * @param encryptedApiKey 加密的API Key - * @returns Promise 解密后的API Key - * @private - */ - private async decryptApiKey(encryptedApiKey: string): Promise { - try { - // 这里需要实现API Key的解密逻辑 - // 在实际实现中,应该调用加密服务进行解密 - // const decryptedKey = await this.encryptionService.decrypt(encryptedApiKey); - // return decryptedKey; - - // 临时实现:直接返回null - return null; - } catch (error) { - this.logger.warn('解密API Key失败', { - operation: 'decryptApiKey', - error: (error as Error).message, - }); - return null; - } - } -} - diff --git a/src/gateway/zulip/README.md b/src/gateway/zulip/README.md new file mode 100644 index 0000000..ef9e8f9 --- /dev/null +++ b/src/gateway/zulip/README.md @@ -0,0 +1,106 @@ +# Zulip Gateway Module + +## 📋 模块概述 + +Zulip网关模块,负责提供Zulip相关功能的HTTP API接口。 + +## 🏗️ 架构定位 + +- **层级**: Gateway层(网关层) +- **职责**: HTTP协议处理、API接口暴露、请求验证 +- **依赖**: Business层的ZulipModule + +## 📁 文件结构 + +``` +src/gateway/zulip/ +├── dynamic_config.controller.ts # 动态配置管理API +├── websocket_docs.controller.ts # WebSocket文档API +├── websocket_openapi.controller.ts # WebSocket OpenAPI规范 +├── websocket_test.controller.ts # WebSocket测试工具 +├── zulip_accounts.controller.ts # Zulip账号管理API +├── zulip.gateway.module.ts # 网关模块定义 +└── README.md # 本文档 +``` + +## 🎯 主要功能 + +### 1. 动态配置管理 (DynamicConfigController) +- 获取当前配置 +- 同步远程配置 +- 配置状态查询 +- 备份管理 + +### 2. WebSocket文档 (WebSocketDocsController) +- 提供WebSocket API使用文档 +- 消息格式示例 +- 连接示例代码 + +### 3. WebSocket OpenAPI (WebSocketOpenApiController) +- 在Swagger中展示WebSocket接口 +- 提供测试工具推荐 +- 架构信息展示 + +### 4. WebSocket测试工具 (WebSocketTestController) +- 交互式WebSocket测试页面 +- 支持连接、认证、消息发送测试 +- API调用监控功能 + +### 5. Zulip账号管理 (ZulipAccountsController) +- Zulip账号关联CRUD操作 +- 账号验证和统计 +- 批量管理功能 + +## 🔗 依赖关系 + +``` +ZulipGatewayModule + ├─ imports: ZulipModule (Business层) + ├─ imports: AuthModule (Business层) + └─ controllers: [所有Controller] +``` + +## 📝 使用示例 + +### 在AppModule中导入 + +```typescript +import { ZulipGatewayModule } from './gateway/zulip/zulip.gateway.module'; + +@Module({ + imports: [ + // ... 其他模块 + ZulipGatewayModule, + ], +}) +export class AppModule {} +``` + +## 🚨 架构规范 + +### Gateway层职责 +- ✅ HTTP协议处理 +- ✅ 请求参数验证(DTO) +- ✅ 调用Business层服务 +- ✅ 响应格式转换 +- ✅ 错误处理和转换 + +### Gateway层禁止 +- ❌ 包含业务逻辑 +- ❌ 直接访问数据库 +- ❌ 直接调用Core层(应通过Business层) +- ❌ 包含复杂的业务规则 + +## 📚 相关文档 + +- [架构文档](../../docs/ARCHITECTURE.md) +- [Zulip Business模块](../../business/zulip/README.md) +- [开发指南](../../docs/development/backend_development_guide.md) + +## 🔄 最近更新 + +- 2026-01-14: 架构优化 - 从Business层分离Controller到Gateway层 (moyin) + +## 👥 维护者 + +- moyin diff --git a/src/business/zulip/dynamic_config.controller.spec.ts b/src/gateway/zulip/dynamic_config.controller.spec.ts similarity index 100% rename from src/business/zulip/dynamic_config.controller.spec.ts rename to src/gateway/zulip/dynamic_config.controller.spec.ts diff --git a/src/business/zulip/dynamic_config.controller.ts b/src/gateway/zulip/dynamic_config.controller.ts similarity index 94% rename from src/business/zulip/dynamic_config.controller.ts rename to src/gateway/zulip/dynamic_config.controller.ts index bee23d0..ecfb97b 100644 --- a/src/business/zulip/dynamic_config.controller.ts +++ b/src/gateway/zulip/dynamic_config.controller.ts @@ -6,9 +6,26 @@ * - 支持配置查询、同步、状态检查 * - 提供备份管理功能 * - * @author assistant - * @version 2.0.0 + * 架构定位: + * - 层级:Gateway层(网关层) + * - 职责:HTTP协议处理、API接口暴露 + * - 依赖:调用Business层的ZulipModule服务 + * + * 职责分离: + * - API接口:提供RESTful风格的配置管理接口 + * - 协议处理:处理HTTP请求和响应 + * - 参数验证:验证请求参数格式 + * - 错误转换:将业务异常转换为HTTP响应 + * + * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin) + * - 2026-01-12: 功能新增 - 初始创建统一配置管理控制器 (修改者: moyin) + * + * @author moyin + * @version 3.0.0 * @since 2026-01-12 + * @lastModified 2026-01-14 */ import { diff --git a/src/business/zulip/websocket_docs.controller.spec.ts b/src/gateway/zulip/websocket_docs.controller.spec.ts similarity index 100% rename from src/business/zulip/websocket_docs.controller.spec.ts rename to src/gateway/zulip/websocket_docs.controller.spec.ts diff --git a/src/business/zulip/websocket_docs.controller.ts b/src/gateway/zulip/websocket_docs.controller.ts similarity index 97% rename from src/business/zulip/websocket_docs.controller.ts rename to src/gateway/zulip/websocket_docs.controller.ts index c59adfa..33ff85d 100644 --- a/src/business/zulip/websocket_docs.controller.ts +++ b/src/gateway/zulip/websocket_docs.controller.ts @@ -6,6 +6,11 @@ * - 展示消息格式和事件类型 * - 提供连接示例和测试工具 * + * 架构定位: + * - 层级:Gateway层(网关层) + * - 职责:HTTP协议处理、文档接口暴露 + * - 依赖:无业务逻辑依赖,纯文档展示 + * * 职责分离: * - API文档:提供完整的WebSocket API使用说明 * - 示例代码:提供各种编程语言的连接示例 @@ -13,12 +18,13 @@ * - 开发指导:提供最佳实践和故障排除指南 * * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin) * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) * * @author angjustinl - * @version 1.0.1 + * @version 2.0.0 * @since 2025-01-07 - * @lastModified 2026-01-07 + * @lastModified 2026-01-14 */ import { Controller, Get } from '@nestjs/common'; diff --git a/src/business/zulip/websocket_openapi.controller.spec.ts b/src/gateway/zulip/websocket_openapi.controller.spec.ts similarity index 100% rename from src/business/zulip/websocket_openapi.controller.spec.ts rename to src/gateway/zulip/websocket_openapi.controller.spec.ts diff --git a/src/business/zulip/websocket_openapi.controller.ts b/src/gateway/zulip/websocket_openapi.controller.ts similarity index 95% rename from src/business/zulip/websocket_openapi.controller.ts rename to src/gateway/zulip/websocket_openapi.controller.ts index 3712856..addb740 100644 --- a/src/business/zulip/websocket_openapi.controller.ts +++ b/src/gateway/zulip/websocket_openapi.controller.ts @@ -1,12 +1,31 @@ /** * WebSocket OpenAPI 文档控制器 * - * 专门用于在OpenAPI/Swagger中展示WebSocket接口 - * 通过REST API的方式描述WebSocket的消息格式和交互流程 + * 功能描述: + * - 专门用于在OpenAPI/Swagger中展示WebSocket接口 + * - 通过REST API的方式描述WebSocket的消息格式和交互流程 + * - 提供WebSocket连接信息和测试工具推荐 + * + * 架构定位: + * - 层级:Gateway层(网关层) + * - 职责:HTTP协议处理、OpenAPI文档暴露 + * - 依赖:无业务逻辑依赖,纯文档展示 + * + * 职责分离: + * - 文档展示:在Swagger中展示WebSocket消息格式 + * - 连接信息:提供WebSocket连接配置和认证信息 + * - 消息流程:展示WebSocket消息交互流程 + * - 测试工具:提供测试工具推荐和示例代码 + * + * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin) + * - 2026-01-09: 功能新增 - 初始创建WebSocket OpenAPI文档控制器 (修改者: moyin) * * @author moyin - * @version 1.0.0 + * @version 2.0.0 * @since 2026-01-09 + * @lastModified 2026-01-14 */ import { Controller, Get, Post, Body } from '@nestjs/common'; diff --git a/src/business/zulip/websocket_test.controller.spec.ts b/src/gateway/zulip/websocket_test.controller.spec.ts similarity index 100% rename from src/business/zulip/websocket_test.controller.spec.ts rename to src/gateway/zulip/websocket_test.controller.spec.ts diff --git a/src/business/zulip/websocket_test.controller.ts b/src/gateway/zulip/websocket_test.controller.ts similarity index 99% rename from src/business/zulip/websocket_test.controller.ts rename to src/gateway/zulip/websocket_test.controller.ts index 86d900a..c3fb0f2 100644 --- a/src/business/zulip/websocket_test.controller.ts +++ b/src/gateway/zulip/websocket_test.controller.ts @@ -1,12 +1,31 @@ /** * WebSocket 测试页面控制器 * - * 提供一个简单的WebSocket测试界面,可以直接在浏览器中测试WebSocket连接 - * 包含API调用监控功能,帮助前端开发者了解接口调用情况 + * 功能描述: + * - 提供一个简单的WebSocket测试界面,可以直接在浏览器中测试WebSocket连接 + * - 包含API调用监控功能,帮助前端开发者了解接口调用情况 + * - 支持聊天测试和通知系统测试两种模式 + * + * 架构定位: + * - 层级:Gateway层(网关层) + * - 职责:HTTP协议处理、测试页面暴露 + * - 依赖:无业务逻辑依赖,纯测试工具 + * + * 职责分离: + * - 测试界面:提供交互式WebSocket测试页面 + * - 连接测试:支持WebSocket连接、认证、消息发送测试 + * - API监控:实时显示HTTP请求和响应信息 + * - 通知测试:提供通知系统功能测试 + * + * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin) + * - 2026-01-14: 代码规范优化 - 完善文件头注释和职责分离描述 (修改者: moyin) + * - 2026-01-09: 功能新增 - 初始创建WebSocket测试页面控制器 (修改者: moyin) * * @author moyin - * @version 1.1.0 + * @version 2.0.0 * @since 2026-01-09 + * @lastModified 2026-01-14 */ import { Controller, Get, Res } from '@nestjs/common'; diff --git a/src/gateway/zulip/zulip.gateway.module.ts b/src/gateway/zulip/zulip.gateway.module.ts new file mode 100644 index 0000000..8e2198e --- /dev/null +++ b/src/gateway/zulip/zulip.gateway.module.ts @@ -0,0 +1,55 @@ +/** + * Zulip网关模块 + * + * 功能描述: + * - 提供Zulip相关的HTTP API接口 + * - 提供WebSocket测试和文档功能 + * - 提供动态配置管理接口 + * - 提供Zulip账号管理接口 + * + * 架构说明: + * - Gateway层:负责HTTP协议处理和API接口暴露 + * - 依赖Business层:调用ZulipModule提供的业务服务 + * - 职责分离:只做协议转换,不包含业务逻辑 + * + * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层分离Controller到Gateway层,符合四层架构规范 (修改者: moyin) + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-14 + * @lastModified 2026-01-14 + */ + +import { Module } from '@nestjs/common'; +// Gateway层控制器 +import { DynamicConfigController } from './dynamic_config.controller'; +import { WebSocketDocsController } from './websocket_docs.controller'; +import { WebSocketOpenApiController } from './websocket_openapi.controller'; +import { WebSocketTestController } from './websocket_test.controller'; +import { ZulipAccountsController } from './zulip_accounts.controller'; +// 依赖Business层模块 +import { ZulipModule } from '../../business/zulip/zulip.module'; +import { AuthModule } from '../../business/auth/auth.module'; + +@Module({ + imports: [ + // 导入Business层的Zulip模块 + ZulipModule, + // 导入认证模块(用于JwtAuthGuard) + AuthModule, + ], + controllers: [ + // 动态配置管理控制器 + DynamicConfigController, + // WebSocket API文档控制器 + WebSocketDocsController, + // WebSocket OpenAPI规范控制器 + WebSocketOpenApiController, + // WebSocket测试工具控制器 + WebSocketTestController, + // Zulip账号关联管理控制器 + ZulipAccountsController, + ], +}) +export class ZulipGatewayModule {} diff --git a/src/business/zulip/zulip_accounts.controller.spec.ts b/src/gateway/zulip/zulip_accounts.controller.spec.ts similarity index 98% rename from src/business/zulip/zulip_accounts.controller.spec.ts rename to src/gateway/zulip/zulip_accounts.controller.spec.ts index 8b04671..22fabc9 100644 --- a/src/business/zulip/zulip_accounts.controller.spec.ts +++ b/src/gateway/zulip/zulip_accounts.controller.spec.ts @@ -26,9 +26,9 @@ import { Test, TestingModule } from '@nestjs/testing'; import { HttpException, HttpStatus } from '@nestjs/common'; import { ZulipAccountsController } from './zulip_accounts.controller'; -import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; +import { JwtAuthGuard } from '../auth/jwt_auth.guard'; import { AppLoggerService } from '../../core/utils/logger/logger.service'; -import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service'; +import { ZulipAccountsBusinessService } from '../../business/zulip/services/zulip_accounts_business.service'; describe('ZulipAccountsController', () => { let controller: ZulipAccountsController; diff --git a/src/business/zulip/zulip_accounts.controller.ts b/src/gateway/zulip/zulip_accounts.controller.ts similarity index 96% rename from src/business/zulip/zulip_accounts.controller.ts rename to src/gateway/zulip/zulip_accounts.controller.ts index 021065c..da09163 100644 --- a/src/business/zulip/zulip_accounts.controller.ts +++ b/src/gateway/zulip/zulip_accounts.controller.ts @@ -8,6 +8,11 @@ * - 集成性能监控和结构化日志记录 * - 实现统一的错误处理和响应格式 * + * 架构定位: + * - 层级:Gateway层(网关层) + * - 职责:HTTP协议处理、API接口暴露 + * - 依赖:调用Business层的ZulipAccountsBusinessService + * * 职责分离: * - API接口:提供RESTful风格的HTTP接口 * - 参数验证:使用DTO进行请求参数验证 @@ -17,13 +22,16 @@ * - 日志记录:使用AppLoggerService记录结构化日志 * * 最近修改: + * - 2026-01-14: 架构优化 - 从Business层迁移到Gateway层,符合四层架构规范 (修改者: moyin) + * - 2026-01-14: 代码质量优化 - 移除未使用的requestLogger属性 (修改者: moyin) + * - 2026-01-14: 代码质量优化 - 移除未使用的导入 (修改者: moyin) * - 2026-01-12: 性能优化 - 集成AppLoggerService和性能监控,优化错误处理 * - 2025-01-07: 初始创建 - 实现基础的CRUD和管理接口 * * @author angjustinl - * @version 1.1.0 + * @version 2.0.0 * @since 2025-01-07 - * @lastModified 2026-01-12 + * @lastModified 2026-01-14 */ import { @@ -50,9 +58,7 @@ import { ApiQuery, } from '@nestjs/swagger'; import { Request } from 'express'; -import { JwtAuthGuard } from '../../gateway/auth/jwt_auth.guard'; -import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; -import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service'; +import { JwtAuthGuard } from '../auth/jwt_auth.guard'; import { AppLoggerService } from '../../core/utils/logger/logger.service'; import { CreateZulipAccountDto, @@ -72,8 +78,6 @@ import { @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') export class ZulipAccountsController { - private readonly requestLogger: any; - constructor( @Inject('ZulipAccountsService') private readonly zulipAccountsService: any, @Inject(AppLoggerService) private readonly logger: AppLoggerService, diff --git a/test/zulip_integration/chat_message_e2e.spec.ts b/test/zulip_integration/chat_message_e2e.spec.ts index 9c02ac7..076bfd1 100644 --- a/test/zulip_integration/chat_message_e2e.spec.ts +++ b/test/zulip_integration/chat_message_e2e.spec.ts @@ -7,27 +7,33 @@ * - 测试真实的网络请求和响应处理 * * 测试范围: - * - WebSocket → ZulipService → ZulipClientPool → ZulipClient → Zulip API + * - WebSocket → ChatService → ZulipClientPool → ZulipClient → Zulip API + * + * 更新记录: + * - 2026-01-14: 重构后更新 - 使用新的四层架构模块 + * - ChatService 替代 ZulipService + * - ChatSessionService 替代 SessionManagerService * * @author moyin - * @version 1.0.0 + * @version 2.0.0 * @since 2026-01-10 + * @lastModified 2026-01-14 */ import { Test, TestingModule } from '@nestjs/testing'; import { INestApplication } from '@nestjs/common'; -import { ZulipService } from '../../src/business/zulip/zulip.service'; +import { ChatService } from '../../src/business/chat/chat.service'; +import { ChatSessionService } from '../../src/business/chat/services/chat_session.service'; import { ZulipClientPoolService } from '../../src/core/zulip_core/services/zulip_client_pool.service'; import { ZulipClientService, ZulipClientInstance } from '../../src/core/zulip_core/services/zulip_client.service'; -import { SessionManagerService } from '../../src/business/zulip/services/session_manager.service'; import { AppModule } from '../../src/app.module'; describe('ChatMessage E2E Integration', () => { let app: INestApplication; - let zulipService: ZulipService; + let chatService: ChatService; let zulipClientPool: ZulipClientPoolService; let zulipClient: ZulipClientService; - let sessionManager: SessionManagerService; + let sessionManager: ChatSessionService; // 模拟的Zulip客户端 let mockZulipSdkClient: any; @@ -48,11 +54,11 @@ describe('ChatMessage E2E Integration', () => { app = moduleFixture.createNestApplication(); - // 获取服务实例 - zulipService = moduleFixture.get(ZulipService); + // 获取服务实例(使用新的四层架构模块) + chatService = moduleFixture.get(ChatService); zulipClientPool = moduleFixture.get(ZulipClientPoolService); zulipClient = moduleFixture.get(ZulipClientService); - sessionManager = moduleFixture.get(SessionManagerService); + sessionManager = moduleFixture.get(ChatSessionService); await app.init(); }); @@ -110,7 +116,7 @@ describe('ChatMessage E2E Integration', () => { describe('完整的聊天消息流程', () => { it('应该成功处理从登录到消息发送的完整流程', async () => { // 1. 模拟用户登录 - const loginResult = await zulipService.handlePlayerLogin({ + const loginResult = await chatService.handlePlayerLogin({ socketId: testSocketId, token: 'valid-jwt-token', // 这里需要有效的JWT token }); @@ -121,7 +127,7 @@ describe('ChatMessage E2E Integration', () => { expect(loginResult.sessionId).toBeDefined(); // 2. 发送聊天消息 - const chatResult = await zulipService.sendChatMessage({ + const chatResult = await chatService.sendChatMessage({ socketId: testSocketId, content: 'Hello from E2E test!', scope: 'local', @@ -153,7 +159,7 @@ describe('ChatMessage E2E Integration', () => { ); // 发送消息 - const chatResult = await zulipService.sendChatMessage({ + const chatResult = await chatService.sendChatMessage({ socketId: testSocketId, content: 'Hello from E2E test with mock session!', scope: 'local', @@ -175,7 +181,7 @@ describe('ChatMessage E2E Integration', () => { ); // 测试本地消息 - const localResult = await zulipService.sendChatMessage({ + const localResult = await chatService.sendChatMessage({ socketId: testSocketId, content: 'Local message test', scope: 'local', @@ -191,7 +197,7 @@ describe('ChatMessage E2E Integration', () => { expect(localCall[0].to).toBe('Whale Port'); // 应该路由到地图对应的Stream // 测试全局消息 - const globalResult = await zulipService.sendChatMessage({ + const globalResult = await chatService.sendChatMessage({ socketId: testSocketId, content: 'Global message test', scope: 'global', @@ -218,7 +224,7 @@ describe('ChatMessage E2E Integration', () => { ); // 测试正常消息 - const normalResult = await zulipService.sendChatMessage({ + const normalResult = await chatService.sendChatMessage({ socketId: testSocketId, content: 'This is a normal message', scope: 'local', @@ -226,7 +232,7 @@ describe('ChatMessage E2E Integration', () => { expect(normalResult.success).toBe(true); // 测试空消息 - const emptyResult = await zulipService.sendChatMessage({ + const emptyResult = await chatService.sendChatMessage({ socketId: testSocketId, content: '', scope: 'local', @@ -235,7 +241,7 @@ describe('ChatMessage E2E Integration', () => { // 测试过长消息 const longMessage = 'A'.repeat(2000); // 假设限制是1000字符 - const longResult = await zulipService.sendChatMessage({ + const longResult = await chatService.sendChatMessage({ socketId: testSocketId, content: longMessage, scope: 'local', @@ -262,7 +268,7 @@ describe('ChatMessage E2E Integration', () => { code: 'STREAM_NOT_FOUND', }); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: testSocketId, content: 'This message will fail', scope: 'local', @@ -286,7 +292,7 @@ describe('ChatMessage E2E Integration', () => { // 模拟网络异常 mockZulipSdkClient.messages.send.mockRejectedValueOnce(new Error('Network timeout')); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: testSocketId, content: 'This will timeout', scope: 'local', @@ -423,7 +429,7 @@ describe('ChatMessage E2E Integration', () => { // 发送大量消息 const promises = Array.from({ length: messageCount }, (_, i) => - zulipService.sendChatMessage({ + chatService.sendChatMessage({ socketId: testSocketId, content: `Performance test message ${i}`, scope: 'local', @@ -445,4 +451,4 @@ describe('ChatMessage E2E Integration', () => { expect(avgTimePerMessage).toBeLessThan(100); }, 30000); }); -}); \ No newline at end of file +}); diff --git a/test/zulip_integration/performance/chat_performance.spec.ts b/test/zulip_integration/performance/chat_performance.spec.ts index 627d250..69c5e39 100644 --- a/test/zulip_integration/performance/chat_performance.spec.ts +++ b/test/zulip_integration/performance/chat_performance.spec.ts @@ -12,16 +12,23 @@ * - 大量消息批量处理性能 * - 内存使用和资源清理 * + * 更新记录: + * - 2026-01-14: 重构后更新 - 使用新的四层架构模块 + * - ChatService 替代 ZulipService + * - ChatSessionService 替代 SessionManagerService + * - ChatFilterService 替代 MessageFilterService + * * @author moyin - * @version 1.0.0 + * @version 2.0.0 * @since 2026-01-10 + * @lastModified 2026-01-14 */ import { Test, TestingModule } from '@nestjs/testing'; -import { ZulipService } from '../../../src/business/zulip/zulip.service'; +import { ChatService } from '../../../src/business/chat/chat.service'; +import { ChatSessionService } from '../../../src/business/chat/services/chat_session.service'; +import { ChatFilterService } from '../../../src/business/chat/services/chat_filter.service'; import { ZulipClientPoolService } from '../../../src/core/zulip_core/services/zulip_client_pool.service'; -import { SessionManagerService } from '../../../src/business/zulip/services/session_manager.service'; -import { MessageFilterService } from '../../../src/business/zulip/services/message_filter.service'; // 模拟WebSocket网关 class MockWebSocketGateway { @@ -45,8 +52,8 @@ class MockWebSocketGateway { } describe('Zulip聊天性能测试', () => { - let zulipService: ZulipService; - let sessionManager: SessionManagerService; + let chatService: ChatService; + let sessionManager: ChatSessionService; let mockWebSocketGateway: MockWebSocketGateway; let mockZulipClientPool: any; @@ -88,17 +95,17 @@ describe('Zulip聊天性能测试', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - ZulipService, + ChatService, { provide: 'ZULIP_CLIENT_POOL_SERVICE', useValue: mockZulipClientPool, }, { - provide: SessionManagerService, + provide: ChatSessionService, useValue: mockSessionManager, }, { - provide: MessageFilterService, + provide: ChatFilterService, useValue: mockMessageFilter, }, { @@ -123,12 +130,12 @@ describe('Zulip聊天性能测试', () => { ], }).compile(); - zulipService = module.get(ZulipService); - sessionManager = module.get(SessionManagerService); + chatService = module.get(ChatService); + sessionManager = module.get(ChatSessionService); // 设置WebSocket网关 mockWebSocketGateway = new MockWebSocketGateway(); - zulipService.setWebSocketGateway(mockWebSocketGateway as any); + chatService.setWebSocketGateway(mockWebSocketGateway as any); }); beforeEach(() => { @@ -140,7 +147,7 @@ describe('Zulip聊天性能测试', () => { it('应该在50ms内完成游戏内广播', async () => { const startTime = Date.now(); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: 'test-socket', content: 'Performance test message', scope: 'local', @@ -165,7 +172,7 @@ describe('Zulip聊天性能测试', () => { const startTime = Date.now(); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: 'test-socket', content: 'Async test message', scope: 'local', @@ -186,7 +193,7 @@ describe('Zulip聊天性能测试', () => { const startTime = Date.now(); const promises = Array.from({ length: messageCount }, (_, i) => - zulipService.sendChatMessage({ + chatService.sendChatMessage({ socketId: `socket-${i}`, content: `Concurrent message ${i}`, scope: 'local', @@ -210,7 +217,7 @@ describe('Zulip聊天性能测试', () => { }, 10000); it('应该正确广播给地图内的所有玩家', async () => { - await zulipService.sendChatMessage({ + await chatService.sendChatMessage({ socketId: 'sender-socket', content: 'Broadcast test message', scope: 'local', @@ -234,7 +241,7 @@ describe('Zulip聊天性能测试', () => { // 创建批量消息 const batchPromises = Array.from({ length: batchSize }, (_, i) => - zulipService.sendChatMessage({ + chatService.sendChatMessage({ socketId: 'batch-socket', content: `Batch message ${i}`, scope: 'local', @@ -267,7 +274,7 @@ describe('Zulip聊天性能测试', () => { // 模拟会话创建 for (const sessionId of sessionIds) { - await zulipService.handlePlayerLogin({ + await chatService.handlePlayerLogin({ socketId: sessionId, token: 'valid-jwt-token', }); @@ -275,7 +282,7 @@ describe('Zulip聊天性能测试', () => { // 清理所有会话 for (const sessionId of sessionIds) { - await zulipService.handlePlayerLogout(sessionId); + await chatService.handlePlayerLogout(sessionId); } // 验证资源清理 @@ -294,7 +301,7 @@ describe('Zulip聊天性能测试', () => { // 处理大量消息 const promises = largeDataSet.map((item, i) => - zulipService.sendChatMessage({ + chatService.sendChatMessage({ socketId: `memory-test-${i}`, content: `Memory test ${item.id}: ${item.data.substring(0, 50)}...`, scope: 'local', @@ -322,7 +329,7 @@ describe('Zulip聊天性能测试', () => { it('应该快速处理无效会话', async () => { const startTime = Date.now(); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: 'invalid-socket', content: 'This should fail quickly', scope: 'local', @@ -341,7 +348,7 @@ describe('Zulip聊天性能测试', () => { // 模拟Zulip服务异常 mockZulipClientPool.sendMessage.mockRejectedValue(new Error('Zulip service unavailable')); - const result = await zulipService.sendChatMessage({ + const result = await chatService.sendChatMessage({ socketId: 'test-socket', content: 'Message during Zulip outage', scope: 'local', @@ -355,4 +362,4 @@ describe('Zulip聊天性能测试', () => { expect(broadcastMessages).toHaveLength(1); }); }); -}); \ No newline at end of file +});