/** * 聊天 WebSocket 网关 * * 功能描述: * - 处理 WebSocket 协议连接和消息 * - 只做协议转换,不包含业务逻辑 * - 将消息路由到 Business 层处理 * * 架构层级:Gateway Layer(网关层) * * 职责: * - WebSocket 连接管理 * - 消息协议解析 * - 路由到业务层 * - 错误转换 * * WebSocket 事件: * - connection: 客户端连接事件 * - message: 消息接收事件(login/logout/chat/position) * - close: 客户端断开事件 * - error: 错误处理事件 * * 最近修改: * - 2026-01-14: 代码规范优化 - 提取常量、替换弃用API (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善注释规范 (修改者: moyin) * * @author moyin * @version 1.0.2 * @since 2026-01-14 * @lastModified 2026-01-14 */ import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import * as WebSocket from 'ws'; import { ChatService } from '../../business/chat/chat.service'; import { SocialService } from '../../business/social/social.service'; import { getTestLabPresence, getTestLabPresences, removeTestLabPresence, TestLabPresence, upsertTestLabPresence, } from '../../business/admin/test_lab_presence.registry'; /** WebSocket 服务器默认端口 */ const DEFAULT_WEBSOCKET_PORT = 3001; /** 默认地图 ID */ const DEFAULT_MAP_ID = 'whale_port'; /** * 扩展的 WebSocket 接口 */ interface ExtendedWebSocket extends WebSocket { id: string; isAlive?: boolean; authenticated?: boolean; userId?: string; username?: string; sessionId?: string; currentMap?: string; worldReady?: boolean; welcomed?: boolean; messageQueue?: Promise; } interface MapPositionMessage { mapId: string; x: number; y: number; } const WELCOME_RECONNECT_GRACE_MS = 15_000; /** * WebSocket 网关接口 - 供业务层调用 */ export interface IChatWebSocketGateway { sendToPlayer(socketId: string, data: any): void; broadcastToMap(mapId: string, data: any, excludeId?: string): void; broadcastToAll(data: any, excludeId?: string): void; getConnectionCount(): number; getAuthenticatedConnectionCount(): number; getMapPlayerCounts(): Record; getMapPlayers(mapId: string): string[]; } @Injectable() /** * 聊天 WebSocket 网关类 * * 职责: * - 管理 WebSocket 客户端连接 * - 解析和路由 WebSocket 消息 * - 管理地图房间和玩家广播 * * 主要方法: * - sendToPlayer() - 向指定玩家发送消息 * - broadcastToMap() - 向地图内所有玩家广播 * - getConnectionCount() - 获取连接数统计 * * 使用场景: * - 游戏内实时聊天通信 * - 玩家位置同步广播 */ export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, IChatWebSocketGateway { private server: WebSocket.Server; private readonly logger = new Logger(ChatWebSocketGateway.name); private clients = new Map(); private mapRooms = new Map>(); private lastWelcomeAtByUserId = new Map(); constructor( private readonly chatService: ChatService, private readonly socialService: SocialService, ) {} async onModuleInit() { const port = process.env.WEBSOCKET_PORT ? parseInt(process.env.WEBSOCKET_PORT) : DEFAULT_WEBSOCKET_PORT; this.server = new WebSocket.Server({ port, path: '/game' }); this.server.on('connection', (ws: ExtendedWebSocket) => { ws.id = this.generateClientId(); ws.isAlive = true; ws.authenticated = false; ws.worldReady = false; ws.welcomed = false; ws.messageQueue = Promise.resolve(); this.clients.set(ws.id, ws); this.logger.log(`新的WebSocket连接: ${ws.id}`); ws.on('message', (data) => { ws.messageQueue = (ws.messageQueue || Promise.resolve()) .then(() => this.handleRawMessage(ws, data)) .catch((error) => this.logger.error(`消息处理失败: ${ws.id}`, error)); }); ws.on('close', (code, reason) => this.handleClose(ws, code, reason)); ws.on('error', (error) => this.handleError(ws, error)); this.sendMessage(ws, { type: 'connected', message: '连接成功', socketId: ws.id }); }); // 设置网关引用到业务层 this.chatService.setWebSocketGateway(this); this.socialService.setRealtimeGateway(this); this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`); } async onModuleDestroy() { if (this.server) { this.server.close(); this.logger.log('WebSocket服务器已关闭'); } } /** * 处理原始消息 - 协议解析 * * @param ws WebSocket 连接实例 * @param data 原始消息数据 */ private async handleRawMessage(ws: ExtendedWebSocket, data: WebSocket.RawData): Promise { try { const message = JSON.parse(data.toString()); await this.routeMessage(ws, message); } catch (error) { this.logger.error('解析消息失败', error); this.sendError(ws, '消息格式错误'); } } /** * 消息路由 - 根据类型分发到业务层 * * @param ws WebSocket 连接实例 * @param message 解析后的消息对象 */ private async routeMessage(ws: ExtendedWebSocket, message: any) { const messageType = message.type || message.t; this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`); switch (messageType) { case 'login': await this.handleLogin(ws, message); break; case 'logout': await this.handleLogout(ws); break; case 'chat': await this.handleChat(ws, message); break; case 'dm_send': await this.handleDirectMessage(ws, message); break; case 'dm_read': await this.handleDirectMessageRead(ws, message); break; case 'position': await this.handlePosition(ws, message); break; case 'change_map': await this.handleChangeMap(ws, message); break; case 'world_ready': await this.handleWorldReady(ws, message); break; case 'leave_world': await this.handleLeaveWorld(ws, message); break; case 'appearance_changed': await this.handleAppearanceChanged(ws); break; case 'friend_add': await this.handleFriendAdd(ws, message); break; case 'friend_request': await this.handleFriendRequest(ws, message); break; case 'friend_accept': await this.handleFriendAccept(ws, message); break; case 'friend_reject': await this.handleFriendReject(ws, message); break; case 'friend_remove': await this.handleFriendRemove(ws, message); break; case 'friend_list': await this.handleFriendList(ws); break; default: this.logger.warn(`未知消息类型: ${messageType}`); this.sendError(ws, `未知消息类型: ${messageType}`); } } /** * 处理登录 - 协议转换后调用业务层 * * @param ws WebSocket 连接实例 * @param message 登录消息(包含 token) */ private async handleLogin(ws: ExtendedWebSocket, message: any) { if (!message.token) { this.sendError(ws, 'Token不能为空'); return; } try { const result = await this.chatService.handlePlayerLogin({ socketId: ws.id, token: message.token }); if (result.success) { this.disconnectOtherUserConnections(ws.id, String(result.userId || '')); ws.authenticated = true; ws.userId = result.userId; ws.username = result.username; ws.sessionId = result.sessionId; ws.currentMap = result.currentMap || DEFAULT_MAP_ID; 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})`); await this.socialService.notifyPresenceChanged(String(result.userId), true); } else { this.sendMessage(ws, { t: 'login_error', message: result.error || '登录失败' }); } } catch (error) { this.logger.error('登录处理失败', error); this.sendError(ws, '登录处理失败'); } } /** * 处理登出 * * @param ws WebSocket 连接实例 */ private async handleLogout(ws: ExtendedWebSocket) { if (!ws.authenticated) { this.sendError(ws, '用户未登录'); return; } try { await this.chatService.handlePlayerLogout(ws.id, 'manual'); this.cleanupClient(ws); this.sendMessage(ws, { t: 'logout_success', message: '登出成功' }); ws.close(1000, '用户主动登出'); } catch (error) { this.logger.error('登出处理失败', error); this.sendError(ws, '登出处理失败'); } } /** * 处理聊天消息 * * @param ws WebSocket 连接实例 * @param message 聊天消息(包含 content, scope, mapId) */ private async handleChat(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!message.content) { this.sendError(ws, '消息内容不能为空'); return; } const scope = String(message.scope || 'local').trim().toLowerCase(); if (scope === 'private' || scope === 'whisper' || scope === 'dm') { await this.handleDirectMessage(ws, message); return; } try { const result = await this.chatService.sendChatMessage({ socketId: ws.id, content: message.content, scope: message.scope || 'local', mapId: message.mapId || ws.currentMap, // 支持指定目标地图 targetUserId: message.targetUserId || message.target_user_id, targetUsername: message.targetUsername || message.target_username, privateContext: message.privateContext || message.private_context, bubble: Boolean(message.bubble ?? message.showBubble ?? message.show_bubble), }); if (result.success) { this.sendMessage(ws, { t: 'chat_sent', messageId: result.messageId, message: '消息发送成功' }); } else { this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode(result.error), message: result.error || '消息发送失败' }); } } catch (error) { this.logger.error('聊天处理失败', error); this.sendError(ws, '聊天处理失败'); } } private async handleDirectMessage(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated || !ws.userId) { this.sendError(ws, '请先登录'); return; } const targetUserId = String(message.targetUserId || message.target_user_id || message.userId || message.user_id || '').trim(); const content = String(message.content || message.txt || '').trim(); if (!/^\d+$/.test(targetUserId) || !content) { this.sendMessage(ws, { t: 'chat_error', code: 'CHAT_ERROR', message: '私聊目标或内容无效' }); return; } try { const result = await this.socialService.sendDirectMessage(BigInt(ws.userId), BigInt(targetUserId), content); this.sendMessage(ws, { t: 'chat_sent', messageId: result.id, message: '消息发送成功' }); } catch (error) { this.sendMessage(ws, { t: 'chat_error', code: this.toClientErrorCode((error as Error).message), message: (error as Error).message || '私聊发送失败' }); } } private async handleDirectMessageRead(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated || !ws.userId) { this.sendError(ws, '请先登录'); return; } const targetUserId = String(message.userId || message.user_id || message.targetUserId || '').trim(); if (!/^\d+$/.test(targetUserId)) { this.sendError(ws, '私聊对象无效'); return; } try { const result = await this.socialService.markConversationRead(BigInt(ws.userId), BigInt(targetUserId)); this.sendMessage(ws, { t: 'dm_read_success', ...result, userId: targetUserId }); } catch (error) { this.sendError(ws, (error as Error).message || '标记私聊已读失败'); } } private async handleFriendAdd(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } try { const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId))); this.sendMessage(ws, { t: 'friend_request_sent', request }); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' }); } } private async handleFriendRequest(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } try { const request = await this.socialService.createFriendRequest(BigInt(String(ws.userId)), BigInt(String(friendUserId))); this.sendMessage(ws, { t: 'friend_request_sent', request }); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '好友请求发送失败' }); } } private async handleFriendAccept(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } try { const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId))); const request = requests.find((item) => item.requester.id === String(friendUserId)); if (!request) throw new Error('好友请求不存在或已过期'); const result = await this.socialService.acceptFriendRequest(BigInt(String(ws.userId)), BigInt(request.id)); this.sendMessage(ws, { t: 'friend_added', friend: this.legacyFriend(result.friend) }); await this.sendFriendList(ws); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '接受好友请求失败' }); } } private async handleFriendReject(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } try { const requests = await this.socialService.getFriendRequests(BigInt(String(ws.userId))); const request = requests.find((item) => item.requester.id === String(friendUserId)); if (!request) throw new Error('好友请求不存在或已过期'); await this.socialService.rejectFriendRequest(BigInt(String(ws.userId)), BigInt(request.id)); this.sendMessage(ws, { t: 'friend_request_rejected', userId: String(friendUserId) }); await this.sendFriendList(ws); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '拒绝好友请求失败' }); } } private async handleFriendRemove(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const friendUserId = message.friendUserId || message.friend_user_id || message.userId || message.user_id; if (!friendUserId) { this.sendMessage(ws, { t: 'friend_error', message: '好友用户ID不能为空' }); return; } try { await this.socialService.removeFriend(BigInt(String(ws.userId)), BigInt(String(friendUserId))); this.sendMessage(ws, { t: 'friend_removed', friendUserId: String(friendUserId) }); await this.sendFriendList(ws); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '移除好友失败' }); } } private async handleFriendList(ws: ExtendedWebSocket) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } await this.sendFriendList(ws); } private async sendFriendList(ws: ExtendedWebSocket) { try { const userId = BigInt(String(ws.userId)); const [friends, requests] = await Promise.all([this.socialService.getFriends(userId), this.socialService.getFriendRequests(userId)]); this.sendMessage(ws, { t: 'friend_list', friends: friends.map((friend) => this.legacyFriend(friend)), requests: requests.map((request) => ({ userId: request.requester.id, username: request.requester.nickname, createdAt: request.createdAt, requestId: request.id })) }); } catch (error) { this.sendMessage(ws, { t: 'friend_error', code: 'CHAT_ERROR', message: (error as Error).message || '获取好友列表失败' }); } } /** * 处理位置更新 * * @param ws WebSocket 连接实例 * @param message 位置消息(包含 x, y, mapId) */ private async handlePosition(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!ws.worldReady) { this.sendError(ws, '请先进入世界'); return; } const positionMessage = this.normalizePositionMessage(message, ws.currentMap); if (!positionMessage) { this.sendError(ws, '位置消息无效'); return; } try { const oldMapId = ws.currentMap || DEFAULT_MAP_ID; const mapChanged = oldMapId !== positionMessage.mapId; // 如果切换地图,更新房间 if (mapChanged) { this.leaveMapRoom(ws.id, oldMapId); this.joinMapRoom(ws.id, positionMessage.mapId); ws.currentMap = positionMessage.mapId; } await this.chatService.updatePlayerPosition({ socketId: ws.id, x: positionMessage.x, y: positionMessage.y, mapId: positionMessage.mapId, }); const updatedSession = await this.chatService.getSession(ws.id); const broadcastX = Number(updatedSession?.position?.x ?? positionMessage.x); const broadcastY = Number(updatedSession?.position?.y ?? positionMessage.y); const broadcastAppearance = updatedSession?.appearance; if (mapChanged) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId }, ws.id); await this.sendMapPlayersSnapshot(ws, positionMessage.mapId); } const presencePayload = { t: 'position_update', userId: ws.userId, username: ws.username, x: broadcastX, y: broadcastY, mapId: positionMessage.mapId, skinId: broadcastAppearance?.skinId, avatarId: broadcastAppearance?.avatarId, skinAsset: broadcastAppearance?.skinAsset, cafeCompanion: updatedSession?.cafeCompanion ?? null, movementLocked: Boolean(updatedSession?.movementLocked), }; this.broadcastToMap(positionMessage.mapId, mapChanged ? { ...presencePayload, t: 'player_joined' } : presencePayload, ws.id); } catch (error) { this.logger.error('位置更新处理失败', error); this.sendError(ws, '位置更新处理失败'); } } private async handleWorldReady(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const mapId = String(message.mapId || message.map_id || ws.currentMap || DEFAULT_MAP_ID).trim(); const x = Number(message.x ?? 400); const y = Number(message.y ?? 300); if (!mapId || !Number.isFinite(x) || !Number.isFinite(y)) { this.sendError(ws, '世界就绪消息无效'); return; } const wasWorldReady = Boolean(ws.worldReady); const oldMapId = ws.currentMap || DEFAULT_MAP_ID; const mapChanged = wasWorldReady && oldMapId !== mapId; if (mapChanged) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId, }, ws.id); this.leaveMapRoom(ws.id, oldMapId); } await this.chatService.updatePlayerPosition({ socketId: ws.id, mapId, x, y }); const refreshedPresence = await this.chatService.refreshPlayerAppearance(ws.id); if (!refreshedPresence) { this.sendError(ws, '外观刷新失败'); return; } ws.currentMap = mapId; ws.worldReady = true; this.joinMapRoom(ws.id, mapId); this.sendMessage(ws, { t: 'world_ready_success', mapId }); await this.sendMapPlayersSnapshot(ws, mapId); const appearance = refreshedPresence.appearance; this.broadcastToMap(mapId, { t: 'player_joined', userId: ws.userId, username: ws.username, x: Number(refreshedPresence.x ?? x), y: Number(refreshedPresence.y ?? y), mapId, skinId: appearance?.skinId, avatarId: appearance?.avatarId, skinAsset: appearance?.skinAsset, cafeCompanion: refreshedPresence.cafeCompanion ?? null, movementLocked: Boolean(refreshedPresence.movementLocked), }, ws.id); if (!wasWorldReady && !ws.welcomed) { if (this.shouldBroadcastWelcome(String(ws.userId || ''))) { this.broadcastToWorldReady({ t: 'system_presence', scope: 'global', event: 'player_online', userId: ws.userId, username: ws.username, content: `欢迎 ${ws.username || '新朋友'} 来到鲸鱼镇!`, timestamp: new Date().toISOString(), }); } ws.welcomed = true; } } private async handleLeaveWorld(ws: ExtendedWebSocket, message: any): Promise { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } const oldMapId = ws.currentMap || DEFAULT_MAP_ID; if (ws.worldReady) { this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId, }, ws.id); this.leaveMapRoom(ws.id, oldMapId); } const sceneId = String(message.sceneId || message.scene_id || 'private_space') .trim() .toLowerCase() .replace(/[^a-z0-9_-]/g, '_'); const privateMapId = `private:${ws.userId || 'unknown'}:${sceneId || 'private_space'}`; const session = await this.chatService.getSession(ws.id); await this.chatService.updatePlayerPosition({ socketId: ws.id, mapId: privateMapId, x: Number(session?.position?.x ?? 0), y: Number(session?.position?.y ?? 0), }); ws.currentMap = privateMapId; ws.worldReady = false; this.sendMessage(ws, { t: 'world_left', mapId: oldMapId, sceneId }); } private async handleAppearanceChanged(ws: ExtendedWebSocket): Promise { if (!ws.authenticated || !ws.worldReady) { this.sendError(ws, '请先进入世界'); return; } const presence = await this.chatService.refreshPlayerAppearance(ws.id); if (!presence) { this.sendError(ws, '外观刷新失败'); return; } this.broadcastToMap(presence.mapId, { t: 'appearance_changed', ...presence, }, ws.id); } /** * 处理切换地图 * * @param ws WebSocket 连接实例 * @param message 切换地图消息(包含 mapId) */ private async handleChangeMap(ws: ExtendedWebSocket, message: any) { if (!ws.authenticated) { this.sendError(ws, '请先登录'); return; } if (!message.mapId) { this.sendError(ws, '地图ID不能为空'); return; } try { const oldMapId = ws.currentMap; const newMapId = message.mapId; const x = Number(message.x ?? 400); const y = Number(message.y ?? 300); // 如果地图相同,直接返回成功 if (oldMapId === newMapId) { this.sendMessage(ws, { t: 'map_changed', mapId: newMapId, message: '已在当前地图' }); return; } // 更新房间 this.leaveMapRoom(ws.id, oldMapId); this.joinMapRoom(ws.id, newMapId); ws.currentMap = newMapId; // 更新会话中的地图信息(使用默认位置) await this.chatService.updatePlayerPosition({ socketId: ws.id, x: Number.isFinite(x) ? x : 400, y: Number.isFinite(y) ? y : 300, mapId: newMapId, }); const updatedSession = await this.chatService.getSession(ws.id); // 通知客户端切换成功 this.sendMessage(ws, { t: 'map_changed', mapId: newMapId, oldMapId: oldMapId, message: '地图切换成功' }); // 向旧地图广播玩家离开 this.broadcastToMap(oldMapId, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: oldMapId }); // 向新地图广播玩家加入 this.broadcastToMap(newMapId, { t: 'player_joined', userId: ws.userId, username: ws.username, x: Number.isFinite(x) ? x : 400, y: Number.isFinite(y) ? y : 300, mapId: newMapId, skinId: updatedSession?.appearance?.skinId, avatarId: updatedSession?.appearance?.avatarId, skinAsset: updatedSession?.appearance?.skinAsset, }, ws.id); await this.sendMapPlayersSnapshot(ws, newMapId); this.logger.log(`用户切换地图: ${ws.username} (${oldMapId} -> ${newMapId})`); } catch (error) { this.logger.error('切换地图处理失败', error); this.sendError(ws, '切换地图处理失败'); } } /** * 处理连接关闭 * * @param ws WebSocket 连接实例 * @param code 关闭状态码 * @param reason 关闭原因 */ private handleClose(ws: ExtendedWebSocket, code: number, reason: Buffer) { this.logger.log(`WebSocket连接关闭: ${ws.id}`, { code, reason: reason?.toString() }); let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect'; if (code === 1000) logoutReason = 'manual'; this.cleanupClient(ws, logoutReason); } /** * 处理错误 * * @param ws WebSocket 连接实例 * @param error 错误对象 */ private handleError(ws: ExtendedWebSocket, error: Error) { this.logger.error(`WebSocket错误: ${ws.id}`, error); } // ========== IChatWebSocketGateway 接口实现 ========== public sendToPlayer(socketId: string, data: any): void { const client = this.clients.get(socketId); if (client && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } } public async broadcastToMap(mapId: string, data: any, excludeId?: string): Promise { const room = this.mapRooms.get(mapId); if (!room) return; for (const clientId of room) { if (clientId !== excludeId) { const client = this.clients.get(clientId); if (client && client.authenticated && client.readyState === WebSocket.OPEN) { if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue; this.sendMessage(client, data); } } } } public async broadcastToAll(data: any, excludeId?: string): Promise { for (const [clientId, client] of this.clients) { if (clientId === excludeId) continue; if (client.authenticated && client.readyState === WebSocket.OPEN) { if (data?.t === 'chat_render' && data?.fromUserId && client.userId && !(await this.socialService.canSeeChat(String(data.fromUserId), String(client.userId)))) continue; this.sendMessage(client, data); } } } public getConnectionCount(): number { return this.clients.size; } public getAuthenticatedConnectionCount(): number { return Array.from(this.clients.values()).filter(c => c.authenticated).length; } public getMapPlayerCounts(): Record { const counts: Record = {}; this.mapRooms.forEach((clients, mapId) => { counts[mapId] = clients.size; }); return counts; } public getMapPlayers(mapId: string): string[] { const room = this.mapRooms.get(mapId); if (!room) return []; const players: string[] = []; room.forEach(clientId => { const client = this.clients.get(clientId); if (client?.authenticated && client.username) { players.push(client.username); } }); return players; } /** 管理端测试实验室使用:返回真实在线玩家,绝不包含合成假人。 */ public getOnlineWorldPlayers(): Array<{ userId: string; username: string; mapId: string }> { return [...this.clients.values()] .filter((client) => client.authenticated && client.worldReady && client.userId && client.currentMap) .map((client) => ({ userId: String(client.userId), username: String(client.username || ''), mapId: String(client.currentMap) })); } public setTestLabPresence(presence: TestLabPresence): void { const previous = getTestLabPresence(presence.userId); const current = upsertTestLabPresence(presence); if (previous?.online && (!current.online || previous.mapId !== current.mapId)) { this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId }); } if (!current.online) return; const payload = { t: previous?.online && previous.mapId === current.mapId ? 'position_update' : 'player_joined', userId: current.userId, username: current.nickname, mapId: current.mapId, x: current.x, y: current.y, skinId: current.skinId, avatarId: current.avatarId, appearance: { skinId: current.skinId, avatarId: current.avatarId }, }; this.broadcastToMap(current.mapId, payload); } public removeTestLabActor(userId: string): void { const previous = removeTestLabPresence(userId); if (previous?.online) { this.broadcastToMap(previous.mapId, { t: 'player_left', userId: previous.userId, username: previous.nickname, mapId: previous.mapId }); } } public broadcastTestLabChat(actor: TestLabPresence, content: string, scope: 'local' | 'global' = 'local'): void { const payload = { t: 'chat_render', from: actor.nickname, fromUserId: actor.userId, txt: content, bubble: true, timestamp: new Date().toISOString(), messageId: `test_${Date.now()}_${actor.userId}`, mapId: actor.mapId, scope, testLab: true, }; if (scope === 'global') this.broadcastToAll(payload); else this.broadcastToMap(actor.mapId, payload); } // ========== 私有辅助方法 ========== 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', code: this.toClientErrorCode(message), message }); } private toClientErrorCode(message?: string): string { const normalizedMessage = String(message || ''); if (normalizedMessage.includes('会话不存在') || normalizedMessage.includes('重新登录')) { return 'SESSION_EXPIRED'; } if (normalizedMessage.includes('请先登录') || normalizedMessage.includes('Token')) { return 'AUTH_FAILED'; } return 'CHAT_ERROR'; } private joinMapRoom(clientId: string, mapId: string) { if (!this.mapRooms.has(mapId)) { this.mapRooms.set(mapId, new Set()); } this.mapRooms.get(mapId).add(clientId); } private leaveMapRoom(clientId: string, mapId: string) { const room = this.mapRooms.get(mapId); if (room) { room.delete(clientId); if (room.size === 0) this.mapRooms.delete(mapId); } } private normalizePositionMessage(message: any, currentMap?: string): MapPositionMessage | null { const mapId = String(message.mapId || message.map_id || currentMap || DEFAULT_MAP_ID).trim(); const x = Number(message.x); const y = Number(message.y); if (!mapId || !Number.isFinite(x) || !Number.isFinite(y)) { return null; } return { mapId, x, y, }; } private async sendMapPlayersSnapshot(ws: ExtendedWebSocket, mapId?: string): Promise { const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim(); if (!normalizedMapId) return; const activeUserIds = new Set(); this.mapRooms.get(normalizedMapId)?.forEach((socketId) => { const client = this.clients.get(socketId); if (client?.worldReady && client.userId) activeUserIds.add(String(client.userId)); }); const players = (await this.chatService.getMapPlayerSnapshot(normalizedMapId, ws.id)) .filter((player) => activeUserIds.has(String(player.userId))); const testPlayers = getTestLabPresences(normalizedMapId).map((player) => ({ userId: player.userId, username: player.nickname, mapId: player.mapId, x: player.x, y: player.y, skinId: player.skinId, avatarId: player.avatarId, appearance: { skinId: player.skinId, avatarId: player.avatarId }, cafeCompanion: null, movementLocked: false, })); this.sendMessage(ws, { t: 'map_players_snapshot', mapId: normalizedMapId, players: [...players, ...testPlayers].filter((player) => String(player.userId) !== String(ws.userId)), }); } private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') { try { if (ws.authenticated && ws.worldReady && ws.currentMap) { this.broadcastToMap(ws.currentMap, { t: 'player_left', userId: ws.userId, username: ws.username, mapId: ws.currentMap, }, ws.id); } if (ws.authenticated && ws.id) { await this.chatService.handlePlayerLogout(ws.id, reason); if (ws.userId) await this.socialService.notifyPresenceChanged(String(ws.userId), false); } if (ws.currentMap) { this.leaveMapRoom(ws.id, ws.currentMap); } this.clients.delete(ws.id); } catch (error) { this.logger.error(`清理客户端失败: ${ws.id}`, error); } } private broadcastToWorldReady(data: any): void { this.clients.forEach((client) => { if (client.authenticated && client.worldReady && client.readyState === WebSocket.OPEN) { this.sendMessage(client, data); } }); } private shouldBroadcastWelcome(userId: string): boolean { if (!userId) return false; const now = Date.now(); const lastWelcomeAt = this.lastWelcomeAtByUserId.get(userId) || 0; if (now - lastWelcomeAt < WELCOME_RECONNECT_GRACE_MS) return false; this.lastWelcomeAtByUserId.set(userId, now); return true; } private disconnectOtherUserConnections(currentSocketId: string, userId: string): void { if (!userId) return; this.clients.forEach((client, socketId) => { if (socketId !== currentSocketId && String(client.userId || '') === userId) { client.close(4001, '账号已在新连接中登录'); } }); } private generateClientId(): string { return `ws_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; } private legacyFriend(profile: any) { return { userId: String(profile.id), username: String(profile.nickname || profile.username || '玩家'), online: Boolean(profile.online), room_visitable: Boolean(profile.room_visitable), }; } }