forked from xiangwang25/whale-town-end-v2
1295 lines
40 KiB
TypeScript
1295 lines
40 KiB
TypeScript
/**
|
||
* 聊天 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 { WorldNpcService } from '../../business/world_npc/world_npc.service';
|
||
|
||
/** WebSocket 服务器默认端口 */
|
||
const DEFAULT_WEBSOCKET_PORT = 3001;
|
||
const WEBSOCKET_HEARTBEAT_INTERVAL_MS = 30_000;
|
||
|
||
/** 默认地图 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<void>;
|
||
movementSequence?: number;
|
||
guest?: boolean;
|
||
lastNpcInteractionAt?: number;
|
||
}
|
||
|
||
interface MapPositionMessage {
|
||
mapId: string;
|
||
x: number;
|
||
y: number;
|
||
direction: 'down' | 'up' | 'right' | 'left';
|
||
movementState: 'idle' | 'walk';
|
||
sequence?: 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<string, number>;
|
||
getMapPlayers(mapId: string): string[];
|
||
}
|
||
|
||
@Injectable()
|
||
/**
|
||
* 聊天 WebSocket 网关类
|
||
*
|
||
* 职责:
|
||
* - 管理 WebSocket 客户端连接
|
||
* - 解析和路由 WebSocket 消息
|
||
* - 管理地图房间和玩家广播
|
||
*
|
||
* 主要方法:
|
||
* - sendToPlayer() - 向指定玩家发送消息
|
||
* - broadcastToMap() - 向地图内所有玩家广播
|
||
* - getConnectionCount() - 获取连接数统计
|
||
*
|
||
* 使用场景:
|
||
* - 游戏内实时聊天通信
|
||
* - 玩家位置同步广播
|
||
*/
|
||
export class ChatWebSocketGateway implements OnModuleInit, OnModuleDestroy, IChatWebSocketGateway {
|
||
private server: WebSocket.Server;
|
||
private readonly logger = new Logger(ChatWebSocketGateway.name);
|
||
private clients = new Map<string, ExtendedWebSocket>();
|
||
private mapRooms = new Map<string, Set<string>>();
|
||
private lastWelcomeAtByUserId = new Map<string, number>();
|
||
private heartbeatTimer?: NodeJS.Timeout;
|
||
private npcActionTimer?: NodeJS.Timeout;
|
||
private npcTickRunning = false;
|
||
|
||
constructor(
|
||
private readonly chatService: ChatService,
|
||
private readonly worldNpcService: WorldNpcService,
|
||
) {}
|
||
|
||
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('pong', () => {
|
||
ws.isAlive = true;
|
||
});
|
||
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.heartbeatTimer = setInterval(() => this.checkClientHeartbeats(), WEBSOCKET_HEARTBEAT_INTERVAL_MS);
|
||
this.npcActionTimer = setInterval(() => void this.tickNpcActions(), 1_000);
|
||
this.heartbeatTimer.unref();
|
||
this.npcActionTimer.unref();
|
||
this.logger.log(`WebSocket服务器启动成功,端口: ${port},路径: /game`);
|
||
}
|
||
|
||
async onModuleDestroy() {
|
||
if (this.heartbeatTimer) {
|
||
clearInterval(this.heartbeatTimer);
|
||
this.heartbeatTimer = undefined;
|
||
}
|
||
if (this.npcActionTimer) {
|
||
clearInterval(this.npcActionTimer);
|
||
this.npcActionTimer = undefined;
|
||
}
|
||
if (this.server) {
|
||
this.server.close();
|
||
this.logger.log('WebSocket服务器已关闭');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理原始消息 - 协议解析
|
||
*
|
||
* @param ws WebSocket 连接实例
|
||
* @param data 原始消息数据
|
||
*/
|
||
private async handleRawMessage(ws: ExtendedWebSocket, data: WebSocket.RawData): Promise<void> {
|
||
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;
|
||
if (messageType !== 'position' && messageType !== 'ping') {
|
||
this.logger.log(`收到消息: ${ws.id}, 类型: ${messageType}`);
|
||
}
|
||
|
||
if (ws.guest && !['ping', 'world_ready', 'logout', 'npc_session_end'].includes(messageType)) {
|
||
this.sendMessage(ws, { t: 'error', code: 'GUEST_READ_ONLY', message: '游客模式只能参观' });
|
||
return;
|
||
}
|
||
|
||
switch (messageType) {
|
||
case 'ping':
|
||
ws.isAlive = true;
|
||
this.sendMessage(ws, { t: 'pong', timestamp: Date.now() });
|
||
break;
|
||
case 'login':
|
||
await this.handleLogin(ws, message);
|
||
break;
|
||
case 'guest_login':
|
||
await this.handleGuestLogin(ws);
|
||
break;
|
||
case 'logout':
|
||
await this.handleLogout(ws);
|
||
break;
|
||
case 'chat':
|
||
await this.handleChat(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 'npc_interact':
|
||
await this.handleNpcInteract(ws, message);
|
||
break;
|
||
case 'npc_session_end':
|
||
await this.handleNpcSessionEnd(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}`);
|
||
}
|
||
}
|
||
|
||
private async handleGuestLogin(ws: ExtendedWebSocket): Promise<void> {
|
||
ws.authenticated = true;
|
||
ws.guest = true;
|
||
ws.username = '游客';
|
||
ws.currentMap = DEFAULT_MAP_ID;
|
||
ws.worldReady = false;
|
||
this.sendMessage(ws, { t: 'guest_login_success', currentMap: DEFAULT_MAP_ID, readOnly: true });
|
||
}
|
||
|
||
private async handleNpcInteract(ws: ExtendedWebSocket, message: any): Promise<void> {
|
||
if (!ws.authenticated || ws.guest || !ws.userId || !ws.worldReady) {
|
||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'AUTH_REQUIRED', message: '请登录后再与NPC交流' });
|
||
return;
|
||
}
|
||
const now = Date.now();
|
||
if (ws.lastNpcInteractionAt && now - ws.lastNpcInteractionAt < 1_000) {
|
||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'RATE_LIMITED', message: '请稍后再交流' });
|
||
return;
|
||
}
|
||
const npcId = String(message.npcId || message.npc_id || '').trim();
|
||
if (!npcId) {
|
||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'NPC_REQUIRED', message: 'NPC不能为空' });
|
||
return;
|
||
}
|
||
const session = await this.chatService.getSession(ws.id);
|
||
if (!session) {
|
||
this.sendMessage(ws, { t: 'npc_interaction_error', code: 'SESSION_EXPIRED', message: '会话已失效,请重新登录' });
|
||
return;
|
||
}
|
||
ws.lastNpcInteractionAt = now;
|
||
try {
|
||
const result = await this.worldNpcService.interact({
|
||
npcId,
|
||
userId: String(ws.userId),
|
||
username: String(ws.username || session.username || '居民'),
|
||
mapId: String(session.currentMap || ws.currentMap || DEFAULT_MAP_ID),
|
||
x: Number(session.position?.x),
|
||
y: Number(session.position?.y),
|
||
message: String(message.message || '').trim(),
|
||
sessionId: String(message.sessionId || message.session_id || ''),
|
||
});
|
||
this.sendMessage(ws, { t: 'npc_interaction_success', ...result });
|
||
this.broadcastToMap(session.currentMap, {
|
||
t: 'npc_spoke',
|
||
...result,
|
||
targetUserId: ws.userId,
|
||
targetUsername: ws.username,
|
||
});
|
||
} catch (error) {
|
||
this.sendMessage(ws, {
|
||
t: 'npc_interaction_error',
|
||
code: 'INTERACTION_REJECTED',
|
||
message: error instanceof Error ? error.message : 'NPC暂时无法回应',
|
||
npcId,
|
||
});
|
||
}
|
||
}
|
||
|
||
private async handleNpcSessionEnd(ws: ExtendedWebSocket, message: any): Promise<void> {
|
||
if (!ws.authenticated || ws.guest || !ws.userId) return;
|
||
await this.worldNpcService.endResidentSession(
|
||
String(message.npcId || message.npc_id || ''), String(ws.userId), String(ws.username || ''),
|
||
String(message.sessionId || message.session_id || ''),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 处理登录 - 协议转换后调用业务层
|
||
*
|
||
* @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})`);
|
||
} 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 {
|
||
if (!ws.guest) 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;
|
||
}
|
||
|
||
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),
|
||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'chat_sent',
|
||
messageId: result.messageId,
|
||
charged: result.charged,
|
||
balance: result.balance,
|
||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||
message: '消息发送成功'
|
||
});
|
||
} else {
|
||
this.sendMessage(ws, {
|
||
t: 'chat_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
worldBulletin: message.worldBulletin === true || message.world_bulletin === true,
|
||
message: result.error || '消息发送失败'
|
||
});
|
||
}
|
||
} catch (error) {
|
||
this.logger.error('聊天处理失败', error);
|
||
this.sendError(ws, '聊天处理失败');
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const result = await this.chatService.addFriend({
|
||
socketId: ws.id,
|
||
friendUserId,
|
||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_added',
|
||
friend: result.friend,
|
||
});
|
||
await this.sendFriendList(ws);
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '添加好友失败',
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const result = await this.chatService.requestFriend({
|
||
socketId: ws.id,
|
||
friendUserId,
|
||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_request_sent',
|
||
request: result.friendRequest,
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '好友请求发送失败',
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const result = await this.chatService.acceptFriendRequest({
|
||
socketId: ws.id,
|
||
friendUserId,
|
||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_added',
|
||
friend: result.friend,
|
||
});
|
||
await this.sendFriendList(ws);
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '接受好友请求失败',
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const result = await this.chatService.rejectFriendRequest({
|
||
socketId: ws.id,
|
||
friendUserId,
|
||
friendUsername: message.friendUsername || message.friend_username || message.username,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_request_rejected',
|
||
userId: friendUserId,
|
||
});
|
||
await this.sendFriendList(ws);
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '拒绝好友请求失败',
|
||
});
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const result = await this.chatService.removeFriend({
|
||
socketId: ws.id,
|
||
friendUserId,
|
||
});
|
||
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_removed',
|
||
friendUserId,
|
||
});
|
||
await this.sendFriendList(ws);
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '移除好友失败',
|
||
});
|
||
}
|
||
|
||
private async handleFriendList(ws: ExtendedWebSocket) {
|
||
if (!ws.authenticated) {
|
||
this.sendError(ws, '请先登录');
|
||
return;
|
||
}
|
||
|
||
await this.sendFriendList(ws);
|
||
}
|
||
|
||
private async sendFriendList(ws: ExtendedWebSocket) {
|
||
const result = await this.chatService.getFriends(ws.id);
|
||
if (result.success) {
|
||
this.sendMessage(ws, {
|
||
t: 'friend_list',
|
||
friends: result.friends || [],
|
||
requests: result.requests || [],
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.sendMessage(ws, {
|
||
t: 'friend_error',
|
||
code: this.toClientErrorCode(result.error),
|
||
message: result.error || '获取好友列表失败',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 处理位置更新
|
||
*
|
||
* @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;
|
||
}
|
||
const nextSequence = positionMessage.sequence ?? Number(ws.movementSequence ?? 0) + 1;
|
||
if (ws.movementSequence !== undefined && nextSequence <= ws.movementSequence) {
|
||
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;
|
||
}
|
||
|
||
const updatedPresence = await this.chatService.updatePlayerPositionAndGetPresence({
|
||
socketId: ws.id,
|
||
x: positionMessage.x,
|
||
y: positionMessage.y,
|
||
mapId: positionMessage.mapId,
|
||
direction: positionMessage.direction,
|
||
movementState: positionMessage.movementState,
|
||
sequence: nextSequence,
|
||
});
|
||
if (!updatedPresence) {
|
||
this.sendMessage(ws, { type: 'error', code: 'SESSION_EXPIRED', message: '会话不存在,请重新登录' });
|
||
return;
|
||
}
|
||
ws.movementSequence = nextSequence;
|
||
|
||
if (mapChanged) {
|
||
this.broadcastToMap(oldMapId, {
|
||
t: 'player_left',
|
||
userId: ws.userId,
|
||
username: ws.username,
|
||
mapId: oldMapId
|
||
}, ws.id);
|
||
|
||
await this.sendMapPlayersSnapshot(ws, positionMessage.mapId);
|
||
this.sendMapNpcSnapshot(ws, positionMessage.mapId);
|
||
}
|
||
|
||
const presencePayload = {
|
||
t: 'position_update',
|
||
userId: ws.userId,
|
||
username: ws.username,
|
||
x: updatedPresence.x,
|
||
y: updatedPresence.y,
|
||
mapId: positionMessage.mapId,
|
||
skinId: updatedPresence.skinId,
|
||
avatarId: updatedPresence.avatarId,
|
||
skinAsset: updatedPresence.skinAsset,
|
||
cafeCompanion: updatedPresence.cafeCompanion ?? null,
|
||
movementLocked: Boolean(updatedPresence.movementLocked),
|
||
direction: updatedPresence.direction || positionMessage.direction,
|
||
movementState: updatedPresence.movementState || positionMessage.movementState,
|
||
sequence: Number(updatedPresence.sequence ?? nextSequence),
|
||
};
|
||
|
||
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<void> {
|
||
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;
|
||
}
|
||
if (ws.guest) {
|
||
const guestMapId = DEFAULT_MAP_ID;
|
||
if (ws.currentMap) this.leaveMapRoom(ws.id, ws.currentMap);
|
||
ws.currentMap = guestMapId;
|
||
ws.worldReady = true;
|
||
this.joinMapRoom(ws.id, guestMapId);
|
||
this.sendMessage(ws, { t: 'world_ready_success', mapId: guestMapId, readOnly: true });
|
||
await this.sendMapPlayersSnapshot(ws, guestMapId);
|
||
this.sendMapNpcSnapshot(ws, guestMapId);
|
||
return;
|
||
}
|
||
const direction = this.normalizeDirection(message.direction);
|
||
const movementState = this.normalizeMovementState(message.movementState ?? message.movement_state, 'idle');
|
||
const sequence = this.normalizeSequence(message.sequence) ?? 0;
|
||
|
||
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,
|
||
direction,
|
||
movementState,
|
||
sequence,
|
||
});
|
||
const refreshedPresence = await this.chatService.refreshPlayerAppearance(ws.id);
|
||
if (!refreshedPresence) {
|
||
this.sendError(ws, '外观刷新失败');
|
||
return;
|
||
}
|
||
|
||
ws.currentMap = mapId;
|
||
ws.worldReady = true;
|
||
ws.movementSequence = sequence;
|
||
this.joinMapRoom(ws.id, mapId);
|
||
|
||
this.sendMessage(ws, { t: 'world_ready_success', mapId });
|
||
await this.sendMapPlayersSnapshot(ws, mapId);
|
||
this.sendMapNpcSnapshot(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),
|
||
direction: refreshedPresence.direction || direction,
|
||
movementState: refreshedPresence.movementState || movementState,
|
||
sequence: Number(refreshedPresence.sequence ?? sequence),
|
||
}, 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<void> {
|
||
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<void> {
|
||
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);
|
||
this.sendMessage(ws, {
|
||
t: 'appearance_changed_success',
|
||
mapId: presence.mapId,
|
||
skinId: presence.skinId ?? presence.appearance?.skinId ?? '',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 处理切换地图
|
||
*
|
||
* @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.sendMapNpcSnapshot(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 broadcastToMap(mapId: string, data: any, excludeId?: string): void {
|
||
const room = this.mapRooms.get(mapId);
|
||
if (!room) return;
|
||
|
||
room.forEach(clientId => {
|
||
if (clientId !== excludeId) {
|
||
const client = this.clients.get(clientId);
|
||
if (client && client.authenticated && client.readyState === WebSocket.OPEN) {
|
||
this.sendMessage(client, data);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
public broadcastToAll(data: any, excludeId?: string): void {
|
||
this.clients.forEach((client, clientId) => {
|
||
if (clientId === excludeId) return;
|
||
if (client.authenticated && client.readyState === WebSocket.OPEN) {
|
||
this.sendMessage(client, data);
|
||
}
|
||
});
|
||
}
|
||
|
||
public getConnectionCount(): number {
|
||
return this.clients.size;
|
||
}
|
||
|
||
public getAuthenticatedConnectionCount(): number {
|
||
return Array.from(this.clients.values()).filter(c => c.authenticated).length;
|
||
}
|
||
|
||
public getMapPlayerCounts(): Record<string, number> {
|
||
const counts: Record<string, number> = {};
|
||
this.mapRooms.forEach((clients, mapId) => {
|
||
counts[mapId] = clients.size;
|
||
});
|
||
return counts;
|
||
}
|
||
|
||
public getMapPlayers(mapId: string): string[] {
|
||
const room = this.mapRooms.get(mapId);
|
||
if (!room) return [];
|
||
|
||
const players: string[] = [];
|
||
room.forEach(clientId => {
|
||
const client = this.clients.get(clientId);
|
||
if (client?.authenticated && client.username) {
|
||
players.push(client.username);
|
||
}
|
||
});
|
||
return players;
|
||
}
|
||
|
||
// ========== 私有辅助方法 ==========
|
||
|
||
private sendMessage(ws: ExtendedWebSocket, data: any) {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
ws.send(JSON.stringify(data));
|
||
}
|
||
}
|
||
|
||
private sendError(ws: ExtendedWebSocket, message: string) {
|
||
this.sendMessage(ws, { type: 'error', code: this.toClientErrorCode(message), message });
|
||
}
|
||
|
||
private checkClientHeartbeats(): void {
|
||
this.clients.forEach((client) => {
|
||
if (client.isAlive === false) {
|
||
this.logger.warn(`WebSocket心跳超时: ${client.id}`);
|
||
client.terminate();
|
||
return;
|
||
}
|
||
client.isAlive = false;
|
||
if (client.readyState === WebSocket.OPEN) {
|
||
client.ping();
|
||
}
|
||
});
|
||
}
|
||
|
||
private async tickNpcActions(): Promise<void> {
|
||
if (this.npcTickRunning) return;
|
||
this.npcTickRunning = true;
|
||
try {
|
||
const result = await this.worldNpcService.tick();
|
||
result.completed.forEach((completed) => this.broadcastToMap(completed.mapId, {
|
||
t: 'npc_action_completed',
|
||
...completed,
|
||
x: completed.action.toX,
|
||
y: completed.action.toY,
|
||
}));
|
||
result.started.forEach((started) => this.broadcastToMap(started.mapId, {
|
||
t: 'npc_action_started',
|
||
...started,
|
||
}));
|
||
result.conversations.forEach((conversation) => this.broadcastToMap(conversation.mapId, {
|
||
t: 'npc_conversation',
|
||
...conversation,
|
||
}));
|
||
result.changedMaps.forEach((mapId) => this.broadcastMapNpcSnapshot(mapId));
|
||
} catch (error) {
|
||
this.logger.error(`NPC 运行时 tick 失败: ${error instanceof Error ? error.message : error}`);
|
||
} finally {
|
||
this.npcTickRunning = false;
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
if (normalizedMessage.includes('余额不足')) {
|
||
return 'INSUFFICIENT_BALANCE';
|
||
}
|
||
if (normalizedMessage.includes('钱包服务')) {
|
||
return 'WALLET_UNAVAILABLE';
|
||
}
|
||
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,
|
||
direction: this.normalizeDirection(message.direction),
|
||
movementState: this.normalizeMovementState(message.movementState ?? message.movement_state, 'walk'),
|
||
sequence: this.normalizeSequence(message.sequence),
|
||
};
|
||
}
|
||
|
||
private normalizeDirection(value: unknown): 'down' | 'up' | 'right' | 'left' {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
return normalized === 'up' || normalized === 'right' || normalized === 'left' ? normalized : 'down';
|
||
}
|
||
|
||
private normalizeMovementState(value: unknown, fallback: 'idle' | 'walk'): 'idle' | 'walk' {
|
||
const normalized = String(value || '').trim().toLowerCase();
|
||
return normalized === 'idle' || normalized === 'walk' ? normalized : fallback;
|
||
}
|
||
|
||
private normalizeSequence(value: unknown): number | undefined {
|
||
if (value === undefined || value === null || value === '') return undefined;
|
||
const sequence = Number(value);
|
||
return Number.isSafeInteger(sequence) && sequence >= 0 ? sequence : undefined;
|
||
}
|
||
|
||
private async sendMapPlayersSnapshot(ws: ExtendedWebSocket, mapId?: string): Promise<void> {
|
||
const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim();
|
||
if (!normalizedMapId) return;
|
||
|
||
const activeUserIds = new Set<string>();
|
||
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)));
|
||
this.sendMessage(ws, {
|
||
t: 'map_players_snapshot',
|
||
mapId: normalizedMapId,
|
||
players,
|
||
});
|
||
}
|
||
|
||
private sendMapNpcSnapshot(ws: ExtendedWebSocket, mapId?: string): void {
|
||
const normalizedMapId = String(mapId || DEFAULT_MAP_ID).trim();
|
||
if (!normalizedMapId) return;
|
||
|
||
const snapshot = this.worldNpcService.getMapSnapshot(normalizedMapId);
|
||
this.sendMessage(ws, {
|
||
t: 'npc_snapshot',
|
||
...snapshot,
|
||
});
|
||
}
|
||
|
||
private broadcastMapNpcSnapshot(mapId: string): void {
|
||
const snapshot = this.worldNpcService.getMapSnapshot(mapId);
|
||
this.broadcastToMap(mapId, { t: 'npc_snapshot', ...snapshot });
|
||
}
|
||
|
||
private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') {
|
||
try {
|
||
if (ws.authenticated && !ws.guest && 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.guest && ws.id) {
|
||
await this.chatService.handlePlayerLogout(ws.id, reason);
|
||
}
|
||
if (ws.currentMap) {
|
||
this.leaveMapRoom(ws.id, ws.currentMap);
|
||
}
|
||
this.clients.delete(ws.id);
|
||
} catch (error) {
|
||
this.logger.error(`清理客户端失败: ${ws.id}`, error);
|
||
}
|
||
}
|
||
|
||
private 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)}`;
|
||
}
|
||
}
|