范围:src/business/zulip/ - 统一命名规范和注释格式 - 完善JSDoc注释和参数说明 - 优化代码结构和缩进 - 清理未使用的导入和变量 - 更新修改记录和版本信息
1044 lines
29 KiB
TypeScript
1044 lines
29 KiB
TypeScript
/**
|
||
* 优化后的Zulip服务 - 实现游戏内实时聊天 + Zulip异步同步
|
||
*
|
||
* 核心优化:
|
||
* 1. 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip
|
||
* 2. 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储
|
||
* 3. ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms
|
||
* 4. 🛡️ 容错性强:Zulip异常不影响游戏聊天体验
|
||
*
|
||
* 职责分离:
|
||
* - 业务协调:整合会话管理、消息过滤等子服务
|
||
* - 流程控制:管理玩家登录登出的完整业务流程
|
||
* - 实时广播:游戏内消息的即时分发
|
||
* - 异步同步:Zulip消息的后台存储
|
||
*
|
||
* 主要方法:
|
||
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
|
||
* - handlePlayerLogout(): 处理玩家登出和资源清理
|
||
* - sendChatMessage(): 优化的聊天消息发送(实时+异步)
|
||
* - updatePlayerPosition(): 更新玩家位置信息
|
||
*
|
||
* 使用场景:
|
||
* - WebSocket网关调用处理消息路由
|
||
* - 会话管理和状态维护
|
||
* - 游戏内实时聊天广播
|
||
* - Zulip消息异步存储
|
||
*
|
||
* 最近修改:
|
||
* - 2026-01-10: 重构优化 - 实现游戏内实时聊天+Zulip异步同步架构 (修改者: moyin)
|
||
*
|
||
* @author angjustinl
|
||
* @version 2.0.0
|
||
* @since 2026-01-06
|
||
* @lastModified 2026-01-10
|
||
*/
|
||
|
||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||
import { randomUUID } from 'crypto';
|
||
import { SessionManagerService } from './services/session_manager.service';
|
||
import { MessageFilterService } from './services/message_filter.service';
|
||
import {
|
||
IZulipClientPoolService,
|
||
IApiKeySecurityService,
|
||
} from '../../core/zulip_core/zulip_core.interfaces';
|
||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||
|
||
/**
|
||
* 聊天消息请求接口
|
||
*/
|
||
export interface ChatMessageRequest {
|
||
socketId: string;
|
||
content: string;
|
||
scope: string;
|
||
}
|
||
|
||
/**
|
||
* 聊天消息响应接口
|
||
*/
|
||
export interface ChatMessageResponse {
|
||
success: boolean;
|
||
messageId?: string;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 玩家登录请求接口
|
||
*/
|
||
export interface PlayerLoginRequest {
|
||
token: string;
|
||
socketId: string;
|
||
}
|
||
|
||
/**
|
||
* 登录响应接口
|
||
*/
|
||
export interface LoginResponse {
|
||
success: boolean;
|
||
sessionId?: string;
|
||
userId?: string;
|
||
username?: string;
|
||
currentMap?: string;
|
||
error?: string;
|
||
}
|
||
|
||
/**
|
||
* 位置更新请求接口
|
||
*/
|
||
export interface PositionUpdateRequest {
|
||
socketId: string;
|
||
x: number;
|
||
y: number;
|
||
mapId: string;
|
||
}
|
||
|
||
/**
|
||
* 游戏消息接口
|
||
*/
|
||
interface GameChatMessage {
|
||
t: 'chat_render';
|
||
from: string;
|
||
txt: string;
|
||
bubble: boolean;
|
||
timestamp: string;
|
||
messageId: string;
|
||
mapId: string;
|
||
scope: string;
|
||
}
|
||
|
||
/**
|
||
* WebSocket网关接口(用于依赖注入)
|
||
*/
|
||
interface IWebSocketGateway {
|
||
broadcastToMap(mapId: string, data: any, excludeId?: string): void;
|
||
sendToPlayer(socketId: string, data: any): void;
|
||
}
|
||
|
||
/**
|
||
* Zulip集成主服务类
|
||
*
|
||
* 职责:
|
||
* - 作为Zulip集成系统的主要协调服务
|
||
* - 整合各个子服务,提供统一的业务接口
|
||
* - 实现游戏内实时聊天 + Zulip异步同步
|
||
* - 管理玩家会话和消息路由
|
||
*
|
||
* 核心优化:
|
||
* - 🚀 游戏内实时广播:后端直接广播给同区域用户,无需等待Zulip
|
||
* - 🔄 Zulip异步同步:使用HTTPS将消息同步到Zulip作为存储
|
||
* - ⚡ 性能提升:聊天延迟从 ~200ms 降低到 ~20ms
|
||
* - 🛡️ 容错性强:Zulip异常不影响游戏聊天体验
|
||
*
|
||
* 主要方法:
|
||
* - handlePlayerLogin(): 处理玩家登录和Zulip客户端初始化
|
||
* - handlePlayerLogout(): 处理玩家登出和资源清理
|
||
* - sendChatMessage(): 优化的聊天消息发送(实时+异步)
|
||
* - updatePlayerPosition(): 更新玩家位置信息
|
||
*
|
||
* 使用场景:
|
||
* - WebSocket网关调用处理消息路由
|
||
* - 会话管理和状态维护
|
||
* - 游戏内实时聊天广播
|
||
* - Zulip消息异步存储
|
||
*/
|
||
@Injectable()
|
||
export class ZulipService {
|
||
private readonly logger = new Logger(ZulipService.name);
|
||
private readonly DEFAULT_MAP = 'whale_port';
|
||
|
||
constructor(
|
||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||
private readonly zulipClientPool: IZulipClientPoolService,
|
||
private readonly sessionManager: SessionManagerService,
|
||
private readonly messageFilter: MessageFilterService,
|
||
@Inject('API_KEY_SECURITY_SERVICE')
|
||
private readonly apiKeySecurityService: IApiKeySecurityService,
|
||
private readonly loginCoreService: LoginCoreService,
|
||
) {
|
||
this.logger.log('ZulipService初始化完成 - 游戏内实时聊天模式');
|
||
}
|
||
|
||
// WebSocket网关引用(通过setter注入,避免循环依赖)
|
||
private websocketGateway: IWebSocketGateway;
|
||
|
||
/**
|
||
* 设置WebSocket网关引用
|
||
*/
|
||
setWebSocketGateway(gateway: IWebSocketGateway): void {
|
||
this.websocketGateway = gateway;
|
||
this.logger.log('WebSocket网关引用设置完成');
|
||
}
|
||
|
||
/**
|
||
* 处理玩家登录
|
||
*
|
||
* 功能描述:
|
||
* 验证游戏Token,创建Zulip客户端,建立会话映射关系
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 验证游戏Token的有效性
|
||
* 2. 获取用户的Zulip API Key
|
||
* 3. 创建用户专用的Zulip客户端实例
|
||
* 4. 注册Zulip事件队列
|
||
* 5. 建立Socket_ID与Zulip_Queue_ID的映射关系
|
||
* 6. 返回登录成功确认
|
||
*
|
||
* @param request 玩家登录请求数据
|
||
* @returns Promise<LoginResponse>
|
||
*
|
||
* @throws UnauthorizedException 当Token验证失败时
|
||
* @throws InternalServerErrorException 当系统操作失败时
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const loginRequest: PlayerLoginRequest = {
|
||
* token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||
* socketId: 'socket_12345'
|
||
* };
|
||
* const result = await zulipService.handlePlayerLogin(loginRequest);
|
||
* if (result.success) {
|
||
* console.log(`用户 ${result.username} 登录成功`);
|
||
* }
|
||
* ```
|
||
*/
|
||
async handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('开始处理玩家登录', {
|
||
operation: 'handlePlayerLogin',
|
||
socketId: request.socketId,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 验证请求参数
|
||
const paramValidation = this.validateLoginParams(request);
|
||
if (!paramValidation.isValid) {
|
||
return {
|
||
success: false,
|
||
error: paramValidation.error,
|
||
};
|
||
}
|
||
|
||
// 2. 验证游戏Token并获取用户信息
|
||
const userInfo = await this.validateGameToken(request.token);
|
||
if (!userInfo) {
|
||
this.logger.warn('登录失败:Token验证失败', {
|
||
operation: 'handlePlayerLogin',
|
||
socketId: request.socketId,
|
||
});
|
||
return {
|
||
success: false,
|
||
error: 'Token验证失败',
|
||
};
|
||
}
|
||
|
||
// 3. 创建Zulip客户端和会话
|
||
const sessionResult = await this.createUserSession(request.socketId, userInfo);
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.log('玩家登录处理完成', {
|
||
operation: 'handlePlayerLogin',
|
||
socketId: request.socketId,
|
||
sessionId: sessionResult.sessionId,
|
||
userId: userInfo.userId,
|
||
username: userInfo.username,
|
||
currentMap: sessionResult.currentMap,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
sessionId: sessionResult.sessionId,
|
||
userId: userInfo.userId,
|
||
username: userInfo.username,
|
||
currentMap: sessionResult.currentMap,
|
||
};
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('玩家登录处理失败', {
|
||
operation: 'handlePlayerLogin',
|
||
socketId: request.socketId,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: '登录失败,请稍后重试',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 验证登录请求参数
|
||
*
|
||
* @param request 登录请求
|
||
* @returns 验证结果
|
||
* @private
|
||
*/
|
||
private validateLoginParams(request: PlayerLoginRequest): { isValid: boolean; error?: string } {
|
||
if (!request.token || !request.token.trim()) {
|
||
this.logger.warn('登录失败:Token为空', {
|
||
operation: 'validateLoginParams',
|
||
socketId: request.socketId,
|
||
});
|
||
return {
|
||
isValid: false,
|
||
error: 'Token不能为空',
|
||
};
|
||
}
|
||
|
||
if (!request.socketId || !request.socketId.trim()) {
|
||
this.logger.warn('登录失败:socketId为空', {
|
||
operation: 'validateLoginParams',
|
||
});
|
||
return {
|
||
isValid: false,
|
||
error: 'socketId不能为空',
|
||
};
|
||
}
|
||
|
||
return { isValid: true };
|
||
}
|
||
|
||
/**
|
||
* 创建用户会话和Zulip客户端
|
||
*
|
||
* @param socketId Socket连接ID
|
||
* @param userInfo 用户信息
|
||
* @returns 会话创建结果
|
||
* @private
|
||
*/
|
||
private async createUserSession(socketId: string, userInfo: any): Promise<{ sessionId: string; currentMap: string }> {
|
||
// 生成会话ID
|
||
const sessionId = randomUUID();
|
||
|
||
// 调试日志:检查用户信息
|
||
this.logger.log('用户信息检查', {
|
||
operation: 'createUserSession',
|
||
userId: userInfo.userId,
|
||
hasZulipApiKey: !!userInfo.zulipApiKey,
|
||
zulipApiKeyLength: userInfo.zulipApiKey?.length || 0,
|
||
zulipEmail: userInfo.zulipEmail,
|
||
email: userInfo.email,
|
||
});
|
||
|
||
// 创建Zulip客户端(如果有API Key)
|
||
let zulipQueueId = `queue_${sessionId}`;
|
||
|
||
if (userInfo.zulipApiKey) {
|
||
try {
|
||
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
|
||
username: userInfo.zulipEmail || userInfo.email,
|
||
apiKey: userInfo.zulipApiKey,
|
||
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
|
||
});
|
||
|
||
if (clientInstance.queueId) {
|
||
zulipQueueId = clientInstance.queueId;
|
||
}
|
||
|
||
this.logger.log('Zulip客户端创建成功', {
|
||
operation: 'createUserSession',
|
||
userId: userInfo.userId,
|
||
queueId: zulipQueueId,
|
||
});
|
||
} catch (zulipError) {
|
||
const err = zulipError as Error;
|
||
this.logger.warn('Zulip客户端创建失败,使用本地模式', {
|
||
operation: 'createUserSession',
|
||
userId: userInfo.userId,
|
||
error: err.message,
|
||
});
|
||
// Zulip客户端创建失败不影响登录,使用本地模式
|
||
}
|
||
}
|
||
|
||
// 创建游戏会话
|
||
const session = await this.sessionManager.createSession(
|
||
socketId,
|
||
userInfo.userId,
|
||
zulipQueueId,
|
||
userInfo.username,
|
||
this.DEFAULT_MAP,
|
||
{ x: 400, y: 300 },
|
||
);
|
||
|
||
return {
|
||
sessionId,
|
||
currentMap: session.currentMap,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 验证游戏Token
|
||
*
|
||
* 功能描述:
|
||
* 验证游戏Token的有效性,返回用户信息
|
||
*
|
||
* @param token 游戏Token (JWT)
|
||
* @returns Promise<UserInfo | null> 用户信息,验证失败返回null
|
||
* @private
|
||
*/
|
||
private async validateGameToken(token: string): Promise<{
|
||
userId: string;
|
||
username: string;
|
||
email: string;
|
||
zulipEmail?: string;
|
||
zulipApiKey?: string;
|
||
} | null> {
|
||
this.logger.debug('验证游戏Token', {
|
||
operation: 'validateGameToken',
|
||
tokenLength: token.length,
|
||
});
|
||
|
||
try {
|
||
// 1. 使用LoginCoreService验证JWT token
|
||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||
|
||
if (!payload || !payload.sub) {
|
||
this.logger.warn('Token载荷无效', {
|
||
operation: 'validateGameToken',
|
||
});
|
||
return null;
|
||
}
|
||
|
||
const userId = payload.sub;
|
||
const username = payload.username || `user_${userId}`;
|
||
const email = payload.email || `${userId}@example.com`;
|
||
|
||
this.logger.debug('Token解析成功', {
|
||
operation: 'validateGameToken',
|
||
userId,
|
||
username,
|
||
email,
|
||
});
|
||
|
||
// 2. 登录时直接从数据库获取Zulip信息(不使用Redis缓存)
|
||
let zulipApiKey = undefined;
|
||
let zulipEmail = undefined;
|
||
|
||
try {
|
||
// 从数据库查找Zulip账号关联
|
||
const zulipAccount = await this.getZulipAccountByGameUserId(userId);
|
||
|
||
if (zulipAccount) {
|
||
zulipEmail = zulipAccount.zulipEmail;
|
||
|
||
// 登录时直接从数据库获取加密的API Key并解密
|
||
if (zulipAccount.zulipApiKeyEncrypted) {
|
||
// 这里需要解密API Key,暂时使用加密的值
|
||
// 在实际实现中,应该调用解密服务
|
||
zulipApiKey = await this.decryptApiKey(zulipAccount.zulipApiKeyEncrypted);
|
||
|
||
// 登录成功后,将API Key缓存到Redis供后续聊天使用
|
||
if (zulipApiKey) {
|
||
await this.apiKeySecurityService.storeApiKey(userId, zulipApiKey);
|
||
}
|
||
|
||
this.logger.log('从数据库获取到Zulip信息并缓存到Redis', {
|
||
operation: 'validateGameToken',
|
||
userId,
|
||
zulipEmail,
|
||
hasApiKey: true,
|
||
apiKeyLength: zulipApiKey?.length || 0,
|
||
});
|
||
} else {
|
||
this.logger.debug('用户有Zulip账号关联但没有API Key', {
|
||
operation: 'validateGameToken',
|
||
userId,
|
||
zulipEmail,
|
||
});
|
||
}
|
||
} else {
|
||
this.logger.debug('用户没有Zulip账号关联', {
|
||
operation: 'validateGameToken',
|
||
userId,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.warn('获取Zulip信息失败', {
|
||
operation: 'validateGameToken',
|
||
userId,
|
||
error: err.message,
|
||
});
|
||
}
|
||
|
||
return {
|
||
userId,
|
||
username,
|
||
email,
|
||
zulipEmail,
|
||
zulipApiKey,
|
||
};
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.warn('Token验证失败', {
|
||
operation: 'validateGameToken',
|
||
error: err.message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 处理玩家登出
|
||
*
|
||
* 功能描述:
|
||
* 清理玩家会话,注销Zulip事件队列,释放相关资源,清除Redis缓存
|
||
*
|
||
* 业务逻辑:
|
||
* 1. 获取会话信息
|
||
* 2. 注销Zulip事件队列
|
||
* 3. 清理Zulip客户端实例
|
||
* 4. 清除Redis中的API Key缓存
|
||
* 5. 删除会话映射关系
|
||
* 6. 记录登出日志
|
||
*
|
||
* @param socketId WebSocket连接ID
|
||
* @param reason 登出原因('manual' | 'timeout' | 'disconnect')
|
||
* @returns Promise<void>
|
||
*/
|
||
async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise<void> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('开始处理玩家登出', {
|
||
operation: 'handlePlayerLogout',
|
||
socketId,
|
||
reason,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 获取会话信息
|
||
const session = await this.sessionManager.getSession(socketId);
|
||
|
||
if (!session) {
|
||
this.logger.log('会话不存在,跳过登出处理', {
|
||
operation: 'handlePlayerLogout',
|
||
socketId,
|
||
reason,
|
||
});
|
||
return;
|
||
}
|
||
|
||
const userId = session.userId;
|
||
|
||
// 2. 清理Zulip客户端资源
|
||
if (userId) {
|
||
try {
|
||
await this.zulipClientPool.destroyUserClient(userId);
|
||
this.logger.log('Zulip客户端清理完成', {
|
||
operation: 'handlePlayerLogout',
|
||
userId,
|
||
reason,
|
||
});
|
||
} catch (zulipError) {
|
||
const err = zulipError as Error;
|
||
this.logger.warn('Zulip客户端清理失败', {
|
||
operation: 'handlePlayerLogout',
|
||
userId,
|
||
error: err.message,
|
||
reason,
|
||
});
|
||
// 继续执行其他清理操作
|
||
}
|
||
|
||
// 3. 清除Redis中的API Key缓存(确保内存足够)
|
||
try {
|
||
const apiKeyDeleted = await this.apiKeySecurityService.deleteApiKey(userId);
|
||
this.logger.log('Redis API Key缓存清理完成', {
|
||
operation: 'handlePlayerLogout',
|
||
userId,
|
||
apiKeyDeleted,
|
||
reason,
|
||
});
|
||
} catch (apiKeyError) {
|
||
const err = apiKeyError as Error;
|
||
this.logger.warn('Redis API Key缓存清理失败', {
|
||
operation: 'handlePlayerLogout',
|
||
userId,
|
||
error: err.message,
|
||
reason,
|
||
});
|
||
// 继续执行其他清理操作
|
||
}
|
||
}
|
||
|
||
// 4. 删除会话映射
|
||
await this.sessionManager.destroySession(socketId);
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.log('玩家登出处理完成', {
|
||
operation: 'handlePlayerLogout',
|
||
socketId,
|
||
userId: session.userId,
|
||
reason,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('玩家登出处理失败', {
|
||
operation: 'handlePlayerLogout',
|
||
socketId,
|
||
reason,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
// 登出失败不抛出异常,确保连接能够正常断开
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 优化后的聊天消息发送逻辑
|
||
*
|
||
* 核心改进:
|
||
* 1. 立即广播给游戏内同区域玩家
|
||
* 2. 异步同步到Zulip,不阻塞游戏聊天
|
||
* 3. 提升用户体验和系统性能
|
||
*/
|
||
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
|
||
const startTime = Date.now();
|
||
|
||
this.logger.log('开始处理聊天消息发送(优化模式)', {
|
||
operation: 'sendChatMessage',
|
||
socketId: request.socketId,
|
||
contentLength: request.content.length,
|
||
scope: request.scope,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 1. 获取会话信息
|
||
const session = await this.sessionManager.getSession(request.socketId);
|
||
if (!session) {
|
||
return {
|
||
success: false,
|
||
error: '会话不存在,请重新登录',
|
||
};
|
||
}
|
||
|
||
// 2. 上下文注入:根据位置确定目标区域
|
||
const context = await this.sessionManager.injectContext(request.socketId);
|
||
const targetStream = context.stream;
|
||
const targetTopic = context.topic || 'General';
|
||
|
||
// 3. 消息验证(内容过滤、频率限制、权限验证)
|
||
const validationResult = await this.messageFilter.validateMessage(
|
||
session.userId,
|
||
request.content,
|
||
targetStream,
|
||
session.currentMap,
|
||
);
|
||
|
||
if (!validationResult.allowed) {
|
||
this.logger.warn('消息验证失败', {
|
||
operation: 'sendChatMessage',
|
||
socketId: request.socketId,
|
||
userId: session.userId,
|
||
reason: validationResult.reason,
|
||
});
|
||
return {
|
||
success: false,
|
||
error: validationResult.reason || '消息发送失败',
|
||
};
|
||
}
|
||
|
||
const messageContent = validationResult.filteredContent || request.content;
|
||
const messageId = `game_${Date.now()}_${session.userId}`;
|
||
|
||
// 4. 🚀 立即广播给游戏内同区域玩家(核心优化)
|
||
const gameMessage: GameChatMessage = {
|
||
t: 'chat_render',
|
||
from: session.username,
|
||
txt: messageContent,
|
||
bubble: true,
|
||
timestamp: new Date().toISOString(),
|
||
messageId,
|
||
mapId: session.currentMap,
|
||
scope: request.scope,
|
||
};
|
||
|
||
// 立即广播,不等待结果
|
||
this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId)
|
||
.catch(error => {
|
||
this.logger.warn('游戏内广播失败', {
|
||
operation: 'broadcastToGamePlayers',
|
||
mapId: session.currentMap,
|
||
error: error.message,
|
||
});
|
||
});
|
||
|
||
// 5. 🔄 异步同步到Zulip(不阻塞游戏聊天)
|
||
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
||
.catch(error => {
|
||
// Zulip同步失败不影响游戏聊天,只记录日志
|
||
this.logger.warn('Zulip异步同步失败', {
|
||
operation: 'syncToZulipAsync',
|
||
userId: session.userId,
|
||
targetStream,
|
||
messageId,
|
||
error: error.message,
|
||
});
|
||
});
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.log('聊天消息发送完成(游戏内实时模式)', {
|
||
operation: 'sendChatMessage',
|
||
socketId: request.socketId,
|
||
userId: session.userId,
|
||
messageId,
|
||
targetStream,
|
||
targetTopic,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
return {
|
||
success: true,
|
||
messageId,
|
||
};
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('聊天消息发送失败', {
|
||
operation: 'sendChatMessage',
|
||
socketId: request.socketId,
|
||
error: err.message,
|
||
duration,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return {
|
||
success: false,
|
||
error: '消息发送失败,请稍后重试',
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 更新玩家位置
|
||
*
|
||
* 功能描述:
|
||
* 更新玩家在游戏世界中的位置信息,用于消息路由和上下文注入
|
||
*
|
||
* @param request 位置更新请求数据
|
||
* @returns Promise<boolean> 是否更新成功
|
||
*/
|
||
async updatePlayerPosition(request: PositionUpdateRequest): Promise<boolean> {
|
||
this.logger.debug('更新玩家位置', {
|
||
operation: 'updatePlayerPosition',
|
||
socketId: request.socketId,
|
||
mapId: request.mapId,
|
||
position: { x: request.x, y: request.y },
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
|
||
try {
|
||
// 验证参数
|
||
if (!request.socketId || !request.socketId.trim()) {
|
||
this.logger.warn('更新位置失败:socketId为空', {
|
||
operation: 'updatePlayerPosition',
|
||
});
|
||
return false;
|
||
}
|
||
|
||
if (!request.mapId || !request.mapId.trim()) {
|
||
this.logger.warn('更新位置失败:mapId为空', {
|
||
operation: 'updatePlayerPosition',
|
||
socketId: request.socketId,
|
||
});
|
||
return false;
|
||
}
|
||
|
||
// 调用SessionManager更新位置信息
|
||
const result = await this.sessionManager.updatePlayerPosition(
|
||
request.socketId,
|
||
request.mapId,
|
||
request.x,
|
||
request.y,
|
||
);
|
||
|
||
if (result) {
|
||
this.logger.debug('玩家位置更新成功', {
|
||
operation: 'updatePlayerPosition',
|
||
socketId: request.socketId,
|
||
mapId: request.mapId,
|
||
});
|
||
}
|
||
|
||
return result;
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
this.logger.error('更新玩家位置失败', {
|
||
operation: 'updatePlayerPosition',
|
||
socketId: request.socketId,
|
||
error: err.message,
|
||
timestamp: new Date().toISOString(),
|
||
}, err.stack);
|
||
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 广播消息给游戏内同区域玩家
|
||
*
|
||
* @param mapId 地图ID
|
||
* @param message 游戏消息
|
||
* @param excludeSocketId 排除的Socket ID(发送者自己)
|
||
*/
|
||
private async broadcastToGamePlayers(
|
||
mapId: string,
|
||
message: GameChatMessage,
|
||
excludeSocketId?: string,
|
||
): Promise<void> {
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
if (!this.websocketGateway) {
|
||
throw new Error('WebSocket网关未设置');
|
||
}
|
||
|
||
// 获取地图内所有玩家的Socket连接
|
||
const sockets = await this.sessionManager.getSocketsInMap(mapId);
|
||
|
||
if (sockets.length === 0) {
|
||
this.logger.debug('地图中没有在线玩家', {
|
||
operation: 'broadcastToGamePlayers',
|
||
mapId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 过滤掉发送者自己
|
||
const targetSockets = sockets.filter(socketId => socketId !== excludeSocketId);
|
||
|
||
if (targetSockets.length === 0) {
|
||
this.logger.debug('地图中没有其他玩家需要接收消息', {
|
||
operation: 'broadcastToGamePlayers',
|
||
mapId,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 并行发送给所有目标玩家
|
||
const broadcastPromises = targetSockets.map(async (socketId) => {
|
||
try {
|
||
this.websocketGateway.sendToPlayer(socketId, message);
|
||
} catch (error) {
|
||
this.logger.warn('发送消息给玩家失败', {
|
||
operation: 'broadcastToGamePlayers',
|
||
socketId,
|
||
error: (error as Error).message,
|
||
});
|
||
}
|
||
});
|
||
|
||
await Promise.allSettled(broadcastPromises);
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.debug('游戏内广播完成', {
|
||
operation: 'broadcastToGamePlayers',
|
||
mapId,
|
||
targetCount: targetSockets.length,
|
||
duration,
|
||
});
|
||
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('游戏内广播失败', {
|
||
operation: 'broadcastToGamePlayers',
|
||
mapId,
|
||
error: err.message,
|
||
duration,
|
||
}, err.stack);
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 异步同步消息到Zulip
|
||
*
|
||
* @param userId 用户ID
|
||
* @param stream Zulip Stream
|
||
* @param topic Zulip Topic
|
||
* @param content 消息内容
|
||
* @param gameMessageId 游戏消息ID
|
||
*/
|
||
private async syncToZulipAsync(
|
||
userId: string,
|
||
stream: string,
|
||
topic: string,
|
||
content: string,
|
||
gameMessageId: string,
|
||
): Promise<void> {
|
||
const startTime = Date.now();
|
||
|
||
try {
|
||
// 聊天过程中从Redis缓存获取API Key
|
||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||
|
||
if (!apiKeyResult.success || !apiKeyResult.apiKey) {
|
||
this.logger.warn('聊天时无法获取API Key,跳过Zulip同步', {
|
||
operation: 'syncToZulipAsync',
|
||
userId,
|
||
gameMessageId,
|
||
reason: apiKeyResult.message || 'API Key不存在',
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 添加游戏消息ID到Zulip消息中,便于追踪
|
||
const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`;
|
||
|
||
const sendResult = await this.zulipClientPool.sendMessage(
|
||
userId,
|
||
stream,
|
||
topic,
|
||
zulipContent,
|
||
);
|
||
|
||
const duration = Date.now() - startTime;
|
||
|
||
if (sendResult.success) {
|
||
this.logger.debug('Zulip同步成功', {
|
||
operation: 'syncToZulipAsync',
|
||
userId,
|
||
stream,
|
||
topic,
|
||
gameMessageId,
|
||
zulipMessageId: sendResult.messageId,
|
||
duration,
|
||
});
|
||
} else {
|
||
this.logger.warn('Zulip同步失败', {
|
||
operation: 'syncToZulipAsync',
|
||
userId,
|
||
stream,
|
||
topic,
|
||
gameMessageId,
|
||
error: sendResult.error,
|
||
duration,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
const err = error as Error;
|
||
const duration = Date.now() - startTime;
|
||
|
||
this.logger.error('Zulip异步同步异常', {
|
||
operation: 'syncToZulipAsync',
|
||
userId,
|
||
stream,
|
||
topic,
|
||
gameMessageId,
|
||
error: err.message,
|
||
duration,
|
||
}, err.stack);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取会话信息
|
||
*
|
||
* 功能描述:
|
||
* 根据socketId获取会话信息
|
||
*
|
||
* @param socketId WebSocket连接ID
|
||
* @returns Promise<GameSession | null>
|
||
*/
|
||
async getSession(socketId: string) {
|
||
return this.sessionManager.getSession(socketId);
|
||
}
|
||
|
||
/**
|
||
* 获取地图中的所有Socket
|
||
*
|
||
* 功能描述:
|
||
* 获取指定地图中所有在线玩家的Socket ID列表
|
||
*
|
||
* @param mapId 地图ID
|
||
* @returns Promise<string[]>
|
||
*/
|
||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||
return this.sessionManager.getSocketsInMap(mapId);
|
||
}
|
||
|
||
/**
|
||
* 根据游戏用户ID获取Zulip账号信息
|
||
*
|
||
* @param gameUserId 游戏用户ID
|
||
* @returns Promise<any | null> Zulip账号信息
|
||
* @private
|
||
*/
|
||
private async getZulipAccountByGameUserId(gameUserId: string): Promise<any> {
|
||
try {
|
||
// 注入ZulipAccountsService,从数据库获取Zulip账号信息
|
||
// 这里需要通过依赖注入获取ZulipAccountsService
|
||
// const zulipAccount = await this.zulipAccountsService.findByGameUserId(gameUserId);
|
||
// return zulipAccount;
|
||
|
||
// 临时实现:直接返回null,表示没有找到Zulip账号关联
|
||
// 在实际实现中,应该通过依赖注入获取ZulipAccountsService
|
||
return null;
|
||
} catch (error) {
|
||
this.logger.warn('获取Zulip账号信息失败', {
|
||
operation: 'getZulipAccountByGameUserId',
|
||
gameUserId,
|
||
error: (error as Error).message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解密API Key
|
||
*
|
||
* @param encryptedApiKey 加密的API Key
|
||
* @returns Promise<string | null> 解密后的API Key
|
||
* @private
|
||
*/
|
||
private async decryptApiKey(encryptedApiKey: string): Promise<string | null> {
|
||
try {
|
||
// 这里需要实现API Key的解密逻辑
|
||
// 在实际实现中,应该调用加密服务进行解密
|
||
// const decryptedKey = await this.encryptionService.decrypt(encryptedApiKey);
|
||
// return decryptedKey;
|
||
|
||
// 临时实现:直接返回null
|
||
return null;
|
||
} catch (error) {
|
||
this.logger.warn('解密API Key失败', {
|
||
operation: 'decryptApiKey',
|
||
error: (error as Error).message,
|
||
});
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|