/** * Zulip WebSocket网关 * * 功能描述: * - 处理所有Godot游戏客户端的WebSocket连接 * - 实现游戏协议到Zulip协议的转换 * - 提供统一的消息路由和权限控制 * * 职责分离: * - 连接管理:处理WebSocket连接的建立、维护和断开 * - 协议转换:在游戏客户端协议和内部业务协议之间转换 * - 权限控制:验证用户身份和消息发送权限 * - 消息路由:将消息分发到正确的业务处理服务 * * 主要方法: * - handleConnection(): 处理客户端连接建立 * - handleDisconnect(): 处理客户端连接断开 * - handleLogin(): 处理登录消息 * - handleChat(): 处理聊天消息 * - handlePositionUpdate(): 处理位置更新 * * 使用场景: * - 游戏客户端WebSocket通信的统一入口 * - 消息协议转换和路由分发 * - 连接状态管理和权限验证 * * 最近修改: * - 2026-01-09: 重构为原生WebSocket - 移除Socket.IO依赖,使用原生WebSocket (修改者: moyin) * * @author angjustinl * @version 2.0.0 * @since 2025-12-25 * @lastModified 2026-01-09 */ 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'; /** * 扩展的WebSocket接口,包含客户端数据 */ interface ExtendedWebSocket extends WebSocket { id: string; data?: ClientData; isAlive?: boolean; } /** * 登录消息接口 - 按guide.md格式 */ interface LoginMessage { type: 'login'; token: string; } /** * 聊天消息接口 - 按guide.md格式 */ interface ChatMessage { t: 'chat'; content: string; scope: string; // "local" 或 topic名称 } /** * 位置更新消息接口 */ interface PositionMessage { t: 'position'; x: number; y: number; mapId: string; } /** * 聊天渲染消息接口 - 发送给客户端 */ interface ChatRenderMessage { t: 'chat_render'; from: string; txt: string; bubble: boolean; } /** * 登录成功消息接口 - 发送给客户端 */ interface LoginSuccessMessage { t: 'login_success'; sessionId: string; userId: string; username: string; currentMap: string; } /** * 客户端数据接口 */ interface ClientData { authenticated: boolean; userId: string | null; sessionId: string | null; username: string | null; connectedAt: Date; } /** * Zulip WebSocket网关类 * * 职责: * - 处理所有Godot游戏客户端的WebSocket连接 * - 实现游戏协议到Zulip协议的转换 * - 提供统一的消息路由和权限控制 * - 管理客户端连接状态和会话 * * 主要方法: * - handleConnection(): 处理客户端连接建立 * - handleDisconnect(): 处理客户端连接断开 * - handleLogin(): 处理登录消息 * - handleChat(): 处理聊天消息 * - handlePositionUpdate(): 处理位置更新 * - sendChatRender(): 向客户端发送聊天渲染消息 * * 使用场景: * - 游戏客户端WebSocket通信的统一入口 * - 消息协议转换和路由分发 * - 连接状态管理和权限验证 * - 实时消息推送和广播 */ @Injectable() export class ZulipWebSocketGateway implements OnModuleInit, OnModuleDestroy { private server: WebSocket.Server; private readonly logger = new Logger(ZulipWebSocketGateway.name); private clients = new Map(); private mapRooms = new Map>(); // mapId -> Set /** 心跳间隔(毫秒) */ private static readonly HEARTBEAT_INTERVAL = 30000; constructor( private readonly zulipService: ZulipService, private readonly sessionManager: SessionManagerService, ) { this.logger.log('ZulipWebSocketGateway初始化完成', { gateway: 'ZulipWebSocketGateway', path: '/game', timestamp: new Date().toISOString(), }); } /** * 模块初始化 - 启动WebSocket服务器 */ async onModuleInit() { const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : 3001; this.server = new WebSocket.Server({ port: port, path: '/game' }); this.server.on('connection', (client: ExtendedWebSocket) => { this.handleConnection(client); }); this.logger.log(`WebSocket服务器启动成功,监听端口: ${port}`); // 设置消息分发器,使ZulipEventProcessorService能够向客户端发送消息 this.setupMessageDistributor(); // 设置心跳检测 this.setupHeartbeat(); } /** * 模块销毁 - 关闭WebSocket服务器 */ async onModuleDestroy() { if (this.server) { this.server.close(); this.logger.log('WebSocket服务器已关闭'); } } /** * 处理客户端连接建立 * * 功能描述: * 当游戏客户端建立WebSocket连接时调用,记录连接信息 * * 业务逻辑: * 1. 记录新连接的建立 * 2. 为连接分配唯一标识 * 3. 初始化连接状态 * * @param client WebSocket客户端连接对象 */ async handleConnection(client: ExtendedWebSocket): Promise { // 生成唯一ID client.id = this.generateClientId(); client.isAlive = true; this.clients.set(client.id, client); this.logger.log('新的WebSocket连接建立', { operation: 'handleConnection', socketId: client.id, timestamp: new Date().toISOString(), }); // 设置连接的初始状态 const clientData: ClientData = { authenticated: false, userId: null, sessionId: null, username: null, connectedAt: new Date(), }; client.data = clientData; // 设置消息处理 client.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleMessage(client, message); } catch (error) { this.logger.error('解析消息失败', { socketId: client.id, error: error instanceof Error ? error.message : String(error), }); } }); // 设置pong响应 client.on('pong', () => { client.isAlive = true; }); } /** * 处理客户端连接断开 * * 功能描述: * 当游戏客户端断开WebSocket连接时调用,清理相关资源 * * 业务逻辑: * 1. 记录连接断开信息 * 2. 清理会话数据 * 3. 注销Zulip事件队列 * 4. 释放相关资源 * * @param client WebSocket客户端连接对象 */ async handleDisconnect(client: ExtendedWebSocket): Promise { const clientData = client.data; const connectionDuration = clientData?.connectedAt ? Date.now() - clientData.connectedAt.getTime() : 0; this.logger.log('WebSocket连接断开', { operation: 'handleDisconnect', socketId: client.id, userId: clientData?.userId, authenticated: clientData?.authenticated, connectionDuration, timestamp: new Date().toISOString(), }); // 如果用户已认证,处理登出逻辑 if (clientData?.authenticated) { try { await this.zulipService.handlePlayerLogout(client.id); this.logger.log('玩家登出处理完成', { operation: 'handleDisconnect', socketId: client.id, userId: clientData.userId, }); } catch (error) { const err = error as Error; this.logger.error('处理玩家登出时发生错误', { operation: 'handleDisconnect', socketId: client.id, error: err.message, timestamp: new Date().toISOString(), }, err.stack); } } // 从客户端列表中移除 this.clients.delete(client.id); // 从地图房间中移除 for (const [mapId, room] of this.mapRooms.entries()) { if (room.has(client.id)) { room.delete(client.id); if (room.size === 0) { this.mapRooms.delete(mapId); } } } } /** * 处理消息路由 */ private async handleMessage(client: ExtendedWebSocket, message: any) { // 直接处理消息类型,不需要event包装 const messageType = message.type || message.t; switch (messageType) { case 'login': await this.handleLogin(client, message); break; case 'chat': await this.handleChat(client, message); break; case 'position': await this.handlePositionUpdate(client, message); break; default: this.logger.warn('未知消息类型', { socketId: client.id, messageType, message, }); } } /** * 处理登录消息 - 按guide.md格式 * * 功能描述: * 处理游戏客户端发送的登录请求,验证Token并建立会话 * * 业务逻辑: * 1. 验证消息格式 * 2. 调用ZulipService处理登录逻辑 * 3. 更新连接状态 * 4. 返回登录结果 * * @param client WebSocket客户端连接对象 * @param data 登录消息数据 */ private async handleLogin(client: ExtendedWebSocket, data: LoginMessage): Promise { this.logger.log('收到登录请求', { operation: 'handleLogin', socketId: client.id, messageType: data?.type, timestamp: new Date().toISOString(), }); try { // 验证消息格式 if (!data || data.type !== 'login' || !data.token) { this.logger.warn('登录请求格式无效', { operation: 'handleLogin', socketId: client.id, data, }); this.sendMessage(client, 'login_error', { t: 'login_error', message: '登录请求格式无效', }); return; } // 检查是否已经登录 const clientData = client.data; if (clientData?.authenticated) { this.logger.warn('用户已登录,拒绝重复登录', { operation: 'handleLogin', socketId: client.id, userId: clientData.userId, }); this.sendMessage(client, 'login_error', { t: 'login_error', message: '您已经登录', }); return; } // 调用ZulipService处理登录 const result = await this.zulipService.handlePlayerLogin({ token: data.token, socketId: client.id, }); if (result.success && result.sessionId) { // 更新连接状态 const updatedClientData: ClientData = { authenticated: true, sessionId: result.sessionId, userId: result.userId || null, username: result.username || null, connectedAt: clientData?.connectedAt || new Date(), }; client.data = updatedClientData; // 发送登录成功消息 const loginSuccess: LoginSuccessMessage = { t: 'login_success', sessionId: result.sessionId, userId: result.userId || '', username: result.username || '', currentMap: result.currentMap || 'novice_village', }; this.sendMessage(client, 'login_success', loginSuccess); this.logger.log('登录处理成功', { operation: 'handleLogin', socketId: client.id, sessionId: result.sessionId, userId: result.userId, username: result.username, currentMap: result.currentMap, timestamp: new Date().toISOString(), }); } else { // 发送登录失败消息 this.sendMessage(client, 'login_error', { t: 'login_error', message: result.error || '登录失败', }); this.logger.warn('登录处理失败', { operation: 'handleLogin', socketId: client.id, error: result.error, timestamp: new Date().toISOString(), }); } } catch (error) { const err = error as Error; this.logger.error('登录处理异常', { operation: 'handleLogin', socketId: client.id, error: err.message, timestamp: new Date().toISOString(), }, err.stack); this.sendMessage(client, 'login_error', { t: 'login_error', message: '系统错误,请稍后重试', }); } } /** * 处理聊天消息 - 按guide.md格式 * * 功能描述: * 处理游戏客户端发送的聊天消息,转发到Zulip对应的Stream/Topic * * 业务逻辑: * 1. 验证用户认证状态 * 2. 验证消息格式 * 3. 调用ZulipService处理消息发送 * 4. 返回发送结果确认 * * @param client WebSocket客户端连接对象 * @param data 聊天消息数据 */ private async handleChat(client: ExtendedWebSocket, data: ChatMessage): Promise { const clientData = client.data; console.log('🔍 DEBUG: handleChat 被调用了!', { socketId: client.id, data: data, clientData: clientData, timestamp: new Date().toISOString(), }); this.logger.log('收到聊天消息', { operation: 'handleChat', socketId: client.id, messageType: data?.t, contentLength: data?.content?.length, scope: data?.scope, timestamp: new Date().toISOString(), }); try { // 验证用户认证状态 if (!clientData?.authenticated) { this.logger.warn('未认证用户尝试发送聊天消息', { operation: 'handleChat', socketId: client.id, }); this.sendMessage(client, 'chat_error', { t: 'chat_error', message: '请先登录', }); return; } // 验证消息格式 if (!data || data.t !== 'chat' || !data.content || !data.scope) { this.logger.warn('聊天消息格式无效', { operation: 'handleChat', socketId: client.id, data, }); this.sendMessage(client, 'chat_error', { t: 'chat_error', message: '消息格式无效', }); return; } // 验证消息内容不为空 if (!data.content.trim()) { this.logger.warn('聊天消息内容为空', { operation: 'handleChat', socketId: client.id, }); this.sendMessage(client, 'chat_error', { t: 'chat_error', message: '消息内容不能为空', }); return; } // 调用ZulipService处理消息发送 const result = await this.zulipService.sendChatMessage({ socketId: client.id, content: data.content, scope: data.scope, }); if (result.success) { // 发送成功确认 this.sendMessage(client, 'chat_sent', { t: 'chat_sent', messageId: result.messageId, message: '消息发送成功', }); this.logger.log('聊天消息发送成功', { operation: 'handleChat', socketId: client.id, userId: clientData.userId, messageId: result.messageId, timestamp: new Date().toISOString(), }); } else { // 发送失败通知 this.sendMessage(client, 'chat_error', { t: 'chat_error', message: result.error || '消息发送失败', }); this.logger.warn('聊天消息发送失败', { operation: 'handleChat', socketId: client.id, userId: clientData.userId, error: result.error, timestamp: new Date().toISOString(), }); } } catch (error) { const err = error as Error; this.logger.error('聊天消息处理异常', { operation: 'handleChat', socketId: client.id, error: err.message, timestamp: new Date().toISOString(), }, err.stack); this.sendMessage(client, 'chat_error', { t: 'chat_error', message: '系统错误,请稍后重试', }); } } /** * 处理位置更新消息 * * 功能描述: * 处理游戏客户端发送的位置更新,用于消息路由和上下文注入 * * @param client WebSocket客户端连接对象 * @param data 位置更新数据 */ private async handlePositionUpdate(client: ExtendedWebSocket, data: PositionMessage): Promise { const clientData = client.data; this.logger.debug('收到位置更新', { operation: 'handlePositionUpdate', socketId: client.id, mapId: data?.mapId, position: data ? { x: data.x, y: data.y } : null, timestamp: new Date().toISOString(), }); try { // 验证用户认证状态 if (!clientData?.authenticated) { this.logger.debug('未认证用户发送位置更新,忽略', { operation: 'handlePositionUpdate', socketId: client.id, }); return; } // 验证消息格式 if (!data || data.t !== 'position' || !data.mapId || typeof data.x !== 'number' || typeof data.y !== 'number') { this.logger.warn('位置更新消息格式无效', { operation: 'handlePositionUpdate', socketId: client.id, data, }); return; } // 验证坐标有效性 if (!Number.isFinite(data.x) || !Number.isFinite(data.y)) { this.logger.warn('位置坐标无效', { operation: 'handlePositionUpdate', socketId: client.id, x: data.x, y: data.y, }); return; } // 调用ZulipService更新位置 const success = await this.zulipService.updatePlayerPosition({ socketId: client.id, x: data.x, y: data.y, mapId: data.mapId, }); if (success) { this.logger.debug('位置更新成功', { operation: 'handlePositionUpdate', socketId: client.id, mapId: data.mapId, }); } } catch (error) { const err = error as Error; this.logger.error('位置更新处理异常', { operation: 'handlePositionUpdate', socketId: client.id, error: err.message, timestamp: new Date().toISOString(), }, err.stack); } } /** * 向指定客户端发送聊天渲染消息 * * 功能描述: * 向游戏客户端发送格式化的聊天消息,用于显示气泡或聊天框 * * @param socketId 目标客户端Socket ID * @param from 发送者名称 * @param txt 消息文本 * @param bubble 是否显示气泡 */ sendChatRender(socketId: string, from: string, txt: string, bubble: boolean): void { const message: ChatRenderMessage = { t: 'chat_render', from, txt, bubble, }; const client = this.clients.get(socketId); if (client) { this.sendMessage(client, 'chat_render', message); } this.logger.debug('发送聊天渲染消息', { operation: 'sendChatRender', socketId, from, textLength: txt.length, bubble, timestamp: new Date().toISOString(), }); } /** * 向指定地图的所有客户端广播消息 * * 功能描述: * 向指定地图区域内的所有在线玩家广播消息 * * @param mapId 地图ID * @param event 事件名称 * @param data 消息数据 */ async broadcastToMap(mapId: string, event: string, data: any): Promise { this.logger.debug('向地图广播消息', { operation: 'broadcastToMap', mapId, event, timestamp: new Date().toISOString(), }); try { // 从SessionManager获取指定地图的所有Socket ID const socketIds = await this.sessionManager.getSocketsInMap(mapId); if (socketIds.length === 0) { this.logger.debug('地图中没有在线玩家', { operation: 'broadcastToMap', mapId, }); return; } // 向每个Socket发送消息 for (const socketId of socketIds) { const client = this.clients.get(socketId); if (client) { this.sendMessage(client, event, data); } } this.logger.log('地图广播完成', { operation: 'broadcastToMap', mapId, event, recipientCount: socketIds.length, }); } catch (error) { const err = error as Error; this.logger.error('地图广播失败', { operation: 'broadcastToMap', mapId, event, error: err.message, }, err.stack); } } /** * 向指定客户端发送消息 * * 功能描述: * 向指定的WebSocket客户端发送消息 * * @param socketId 目标客户端Socket ID * @param event 事件名称 * @param data 消息数据 */ sendToPlayer(socketId: string, event: string, data: any): void { const client = this.clients.get(socketId); if (client) { this.sendMessage(client, event, data); } this.logger.debug('发送消息给玩家', { operation: 'sendToPlayer', socketId, event, timestamp: new Date().toISOString(), }); } /** * 获取当前连接数 * * 功能描述: * 获取当前WebSocket网关的连接数量 * * @returns Promise 连接数 */ async getConnectionCount(): Promise { return this.clients.size; } /** * 获取已认证的连接数 * * 功能描述: * 获取当前已认证的WebSocket连接数量 * * @returns Promise 已认证连接数 */ async getAuthenticatedConnectionCount(): Promise { let count = 0; for (const client of this.clients.values()) { if (client.data?.authenticated === true) { count++; } } return count; } /** * 断开指定客户端连接 * * 功能描述: * 强制断开指定的WebSocket客户端连接 * * @param socketId 目标客户端Socket ID * @param reason 断开原因 */ async disconnectClient(socketId: string, reason?: string): Promise { const client = this.clients.get(socketId); if (client) { client.close(); this.logger.log('客户端连接已断开', { operation: 'disconnectClient', socketId, reason, }); } else { this.logger.warn('未找到目标客户端', { operation: 'disconnectClient', socketId, }); } } /** * 发送消息给客户端 */ private sendMessage(client: ExtendedWebSocket, event: string, data: any) { if (client.readyState === WebSocket.OPEN) { // 直接发送数据,不包装在event中 client.send(JSON.stringify(data)); } } /** * 生成客户端ID */ private generateClientId(): string { return `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * 设置心跳检测 */ private setupHeartbeat() { setInterval(() => { this.clients.forEach((client) => { if (!client.isAlive) { this.logger.warn('客户端心跳超时,断开连接', { socketId: client.id, }); client.close(); return; } client.isAlive = false; if (client.readyState === WebSocket.OPEN) { client.ping(); } }); }, ZulipWebSocketGateway.HEARTBEAT_INTERVAL); } /** * 设置消息分发器 * * 功能描述: * 将当前WebSocket网关设置为ZulipEventProcessorService的消息分发器, * 使其能够接收从Zulip返回的消息并转发给游戏客户端 * * @private */ private setupMessageDistributor(): void { try { // 获取ZulipEventProcessorService实例 const eventProcessor = this.zulipService.getEventProcessor(); if (eventProcessor) { // 设置消息分发器 eventProcessor.setMessageDistributor(this); this.logger.log('消息分发器设置完成', { operation: 'setupMessageDistributor', timestamp: new Date().toISOString(), }); } else { this.logger.warn('无法获取ZulipEventProcessorService实例', { operation: 'setupMessageDistributor', }); } } catch (error) { const err = error as Error; this.logger.error('设置消息分发器失败', { operation: 'setupMessageDistributor', error: err.message, }, err.stack); } } }