范围:src/business/zulip/ - 统一命名规范和注释格式 - 完善JSDoc注释和参数说明 - 优化代码结构和缩进 - 清理未使用的导入和变量 - 更新修改记录和版本信息
435 lines
12 KiB
TypeScript
435 lines
12 KiB
TypeScript
/**
|
||
* 清洁的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<string, ExtendedWebSocket>();
|
||
private mapRooms = new Map<string, Set<string>>(); // mapId -> Set<clientId>
|
||
|
||
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<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 && client.authenticated && client.username) {
|
||
players.push(client.username);
|
||
}
|
||
});
|
||
return players;
|
||
}
|
||
} |