/** * 聊天业务服务 * * 功能描述: * - 实现聊天相关的业务逻辑 * - 协调会话管理、消息过滤等子服务 * - 实现游戏内实时聊天 + Zulip 异步同步 * * 架构层级:Business Layer(业务层) * * 核心优化: * - 🚀 游戏内实时广播:后端直接广播给同区域用户 * - 🔄 Zulip异步同步:消息异步存储到Zulip * - ⚡ 低延迟聊天体验 * * 最近修改: * - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin) * * @author moyin * @version 1.0.4 * @since 2026-01-14 * @lastModified 2026-01-14 */ import { Injectable, Logger, Inject } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { ChatSessionService } from './services/chat_session.service'; import { ChatFilterService } from './services/chat_filter.service'; import { IZulipClientPoolService, IApiKeySecurityService, } from '../../core/zulip_core/zulip_core.interfaces'; import { LoginCoreService } from '../../core/login_core/login_core.service'; // ========== 接口定义 ========== /** * 聊天消息请求接口 */ export interface ChatMessageRequest { /** WebSocket连接ID */ socketId: string; /** 消息内容 */ content: string; /** 消息范围:local(本地)、global(全局) */ scope: string; } /** * 聊天消息响应接口 */ export interface ChatMessageResponse { /** 是否成功 */ success: boolean; /** 消息ID(成功时返回) */ messageId?: string; /** 错误信息(失败时返回) */ error?: string; } /** * 玩家登录请求接口 */ export interface PlayerLoginRequest { /** 认证Token */ token: string; /** WebSocket连接ID */ socketId: string; } /** * 登录响应接口 */ export interface LoginResponse { /** 是否成功 */ success: boolean; /** 会话ID(成功时返回) */ sessionId?: string; /** 用户ID(成功时返回) */ userId?: string; /** 用户名(成功时返回) */ username?: string; /** 当前地图ID(成功时返回) */ currentMap?: string; /** 错误信息(失败时返回) */ error?: string; } /** * 位置更新请求接口 */ export interface PositionUpdateRequest { /** WebSocket连接ID */ socketId: string; /** X坐标 */ x: number; /** Y坐标 */ y: number; /** 地图ID */ mapId: string; } /** * 游戏聊天消息格式(用于WebSocket广播) */ interface GameChatMessage { /** 消息类型标识 */ t: 'chat_render'; /** 发送者用户名 */ from: string; /** 消息文本内容 */ txt: string; /** 是否显示气泡 */ bubble: boolean; /** 时间戳(ISO格式) */ timestamp: string; /** 消息ID */ messageId: string; /** 地图ID */ mapId: string; /** 消息范围 */ scope: string; } /** * 聊天WebSocket网关接口 */ interface IChatWebSocketGateway { /** * 向指定地图广播消息 * @param mapId 地图ID * @param data 广播数据 * @param excludeId 排除的socketId(可选) */ broadcastToMap(mapId: string, data: any, excludeId?: string): void; /** * 向指定玩家发送消息 * @param socketId WebSocket连接ID * @param data 发送数据 */ sendToPlayer(socketId: string, data: any): void; } /** * 聊天业务服务类 * * 职责: * - 处理玩家登录/登出的会话管理 * - 协调消息过滤和验证流程 * - 实现游戏内实时广播和Zulip异步同步 * * 主要方法: * - handlePlayerLogin() - 处理玩家登录认证和会话创建 * - handlePlayerLogout() - 处理玩家登出和资源清理 * - sendChatMessage() - 发送聊天消息并广播 * - updatePlayerPosition() - 更新玩家位置信息 * * 使用场景: * - 游戏客户端通过WebSocket连接后的聊天功能 * - 需要实时广播和持久化存储的聊天场景 */ @Injectable() export class ChatService { private readonly logger = new Logger(ChatService.name); private readonly DEFAULT_MAP = 'whale_port'; private readonly DEFAULT_POSITION = { x: 400, y: 300 }; private readonly DEFAULT_PAGE_SIZE = 50; private readonly HISTORY_TIME_OFFSET_MS = 3600000; // 1小时 private websocketGateway: IChatWebSocketGateway; constructor( @Inject('ZULIP_CLIENT_POOL_SERVICE') private readonly zulipClientPool: IZulipClientPoolService, private readonly sessionService: ChatSessionService, private readonly filterService: ChatFilterService, @Inject('API_KEY_SECURITY_SERVICE') private readonly apiKeySecurityService: IApiKeySecurityService, private readonly loginCoreService: LoginCoreService, ) { this.logger.log('ChatService初始化完成'); } /** * 设置WebSocket网关引用 * @param gateway WebSocket网关实例 */ setWebSocketGateway(gateway: IChatWebSocketGateway): void { this.websocketGateway = gateway; this.logger.log('WebSocket网关引用设置完成'); } /** * 处理玩家登录 * @param request 登录请求,包含token和socketId * @returns 登录响应,包含会话信息或错误信息 */ async handlePlayerLogin(request: PlayerLoginRequest): Promise { const startTime = Date.now(); this.logger.log('开始处理玩家登录', { operation: 'handlePlayerLogin', socketId: request.socketId, }); try { // 1. 验证参数 if (!request.token?.trim() || !request.socketId?.trim()) { return { success: false, error: 'Token或socketId不能为空' }; } // 2. 验证Token const userInfo = await this.validateGameToken(request.token); if (!userInfo) { return { success: false, error: 'Token验证失败' }; } // 3. 创建会话 const sessionResult = await this.createUserSession(request.socketId, userInfo); this.logger.log('玩家登录成功', { operation: 'handlePlayerLogin', socketId: request.socketId, userId: userInfo.userId, duration: Date.now() - startTime, }); return { success: true, sessionId: sessionResult.sessionId, userId: userInfo.userId, username: userInfo.username, currentMap: sessionResult.currentMap, }; } catch (error) { const err = error as Error; this.logger.error('玩家登录失败', { error: err.message }); return { success: false, error: '登录失败,请稍后重试' }; } } /** * 处理玩家登出 * @param socketId WebSocket连接ID * @param reason 登出原因:manual(手动)、timeout(超时)、disconnect(断开) */ async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise { this.logger.log('开始处理玩家登出', { socketId, reason }); try { const session = await this.sessionService.getSession(socketId); if (!session) return; const userId = session.userId; // 清理Zulip客户端 if (userId) { try { await this.zulipClientPool.destroyUserClient(userId); } catch (e) { this.logger.warn('Zulip客户端清理失败', { error: (e as Error).message }); } // 清理API Key缓存 try { await this.apiKeySecurityService.deleteApiKey(userId); } catch (e) { this.logger.warn('API Key缓存清理失败', { error: (e as Error).message }); } } // 销毁会话 await this.sessionService.destroySession(socketId); this.logger.log('玩家登出完成', { socketId, userId, reason }); } catch (error) { this.logger.error('玩家登出失败', { error: (error as Error).message }); } } /** * 发送聊天消息 * @param request 聊天消息请求,包含socketId、content和scope * @returns 发送结果,包含messageId或错误信息 */ async sendChatMessage(request: ChatMessageRequest): Promise { const startTime = Date.now(); this.logger.log('开始处理聊天消息', { operation: 'sendChatMessage', socketId: request.socketId, contentLength: request.content.length, }); try { // 1. 获取会话 const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } // 2. 获取上下文 const context = await this.sessionService.injectContext(request.socketId); const targetStream = context.stream; const targetTopic = context.topic || 'General'; // 3. 消息验证 const validationResult = await this.filterService.validateMessage( session.userId, request.content, targetStream, session.currentMap, ); if (!validationResult.allowed) { 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(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message })); // 5. 🔄 异步同步到Zulip this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId) .catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message })); this.logger.log('聊天消息发送完成', { operation: 'sendChatMessage', messageId, duration: Date.now() - startTime, }); return { success: true, messageId }; } catch (error) { this.logger.error('聊天消息发送失败', { error: (error as Error).message }); return { success: false, error: '消息发送失败,请稍后重试' }; } } /** * 更新玩家位置 * @param request 位置更新请求,包含socketId、坐标和mapId * @returns 更新是否成功 */ async updatePlayerPosition(request: PositionUpdateRequest): Promise { try { if (!request.socketId?.trim() || !request.mapId?.trim()) { return false; } return await this.sessionService.updatePlayerPosition( request.socketId, request.mapId, request.x, request.y, ); } catch (error) { this.logger.error('更新位置失败', { error: (error as Error).message }); return false; } } /** * 获取聊天历史 * @param query 查询参数,包含mapId、limit和offset * @returns 聊天历史记录列表 */ async getChatHistory(query: { mapId?: string; limit?: number; offset?: number }) { // 模拟数据,实际应从Zulip获取 const mockMessages = [ { id: 1, sender: 'Player_123', content: '大家好!', scope: 'local', mapId: query.mapId || 'whale_port', timestamp: new Date(Date.now() - this.HISTORY_TIME_OFFSET_MS).toISOString(), streamName: 'Whale Port', topicName: 'Game Chat', }, ]; const limit = query.limit || this.DEFAULT_PAGE_SIZE; const offset = query.offset || 0; return { success: true, messages: mockMessages.slice(offset, offset + limit), total: mockMessages.length, count: Math.min(mockMessages.length, limit), }; } /** * 获取会话信息 * @param socketId WebSocket连接ID * @returns 会话信息或null */ async getSession(socketId: string) { return this.sessionService.getSession(socketId); } // ========== 私有方法 ========== private async validateGameToken(token: string) { try { const payload = await this.loginCoreService.verifyToken(token, 'access'); if (!payload?.sub) return null; return { userId: payload.sub, username: payload.username || `user_${payload.sub}`, email: payload.email || `${payload.sub}@example.com`, zulipEmail: undefined, zulipApiKey: undefined, }; } catch (error) { this.logger.warn('Token验证失败', { error: (error as Error).message }); return null; } } private async createUserSession(socketId: string, userInfo: any) { const sessionId = randomUUID(); let zulipQueueId = `queue_${sessionId}`; // 尝试创建Zulip客户端 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; } catch (e) { this.logger.warn('Zulip客户端创建失败', { error: (e as Error).message }); } } const session = await this.sessionService.createSession( socketId, userInfo.userId, zulipQueueId, userInfo.username, this.DEFAULT_MAP, this.DEFAULT_POSITION, ); return { sessionId, currentMap: session.currentMap }; } private async broadcastToGamePlayers(mapId: string, message: GameChatMessage, excludeSocketId?: string) { if (!this.websocketGateway) { throw new Error('WebSocket网关未设置'); } const sockets = await this.sessionService.getSocketsInMap(mapId); const targetSockets = sockets.filter(id => id !== excludeSocketId); for (const socketId of targetSockets) { try { this.websocketGateway.sendToPlayer(socketId, message); } catch (e) { this.logger.warn('发送消息失败', { socketId, error: (e as Error).message }); } } } private async syncToZulipAsync(userId: string, stream: string, topic: string, content: string, gameMessageId: string) { try { const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); if (!apiKeyResult.success || !apiKeyResult.apiKey) return; const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`; await this.zulipClientPool.sendMessage(userId, stream, topic, zulipContent); } catch (error) { this.logger.warn('Zulip同步异常', { error: (error as Error).message }); } } }