/** * 聊天业务服务 * * 功能描述: * - 实现聊天相关的业务逻辑 * - 协调会话管理、消息过滤等子服务 * - 实现游戏内实时聊天 + Zulip 异步同步 * * 架构层级:Business Layer(业务层) * * 核心优化: * - 🚀 游戏内实时广播:后端直接广播给同区域用户 * - 🔄 Zulip异步同步:消息异步存储到Zulip * - ⚡ 低延迟聊天体验 * * 最近修改: * - 2026-01-15: 功能完善 - WebSocket登录时自动初始化用户Zulip客户端 (修改者: AI) * - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin) * * @author moyin * @version 1.1.0 * @since 2026-01-14 * @lastModified 2026-01-15 */ import { Injectable, Logger, Inject } from '@nestjs/common'; import { randomUUID } from 'crypto'; import { ChatSessionService } from './services/chat_session.service'; import type { ChatFriendInfo, ChatFriendRequestInfo, MapPlayerPresence } from './services/chat_session.service'; import { ChatFilterService } from './services/chat_filter.service'; import { IZulipClientPoolService, IApiKeySecurityService, } from '../../core/zulip_core/zulip_core.interfaces'; import type { IPlayerAppearance, ICafeCompanionPresence } from '../../core/session_core/session_core.interfaces'; import { LoginCoreService } from '../../core/login_core/login_core.service'; import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service'; import { AccountProfileService } from '../auth/account_profile.service'; import { TaskService } from '../tasks/task.service'; // ========== 接口定义 ========== /** * 聊天消息请求接口 */ export interface ChatMessageRequest { /** WebSocket连接ID */ socketId: string; /** 消息内容 */ content: string; /** 消息范围:local(本地)、global(全局) */ scope: string; /** 目标地图ID(可选,不传则使用会话当前地图) */ mapId?: string; /** 私聊目标用户ID */ targetUserId?: string; /** 私聊目标用户名(客户端展示用) */ targetUsername?: string; /** 私聊来源上下文:whisper / friends */ privateContext?: string; /** 是否同步显示角色气泡 */ bubble?: boolean; } /** * 聊天消息响应接口 */ 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; /** 外观同步信息 */ appearance?: IPlayerAppearance; } export interface PlayerPresenceStateUpdateRequest { userId: string; mapId?: string; x?: number; y?: number; cafeCompanion?: ICafeCompanionPresence | null; movementLocked?: boolean; } /** * 地图在线玩家快照项 */ export interface MapPlayerSnapshotItem { /** 用户ID */ userId: string; /** 用户名 */ username: string; /** 地图ID */ mapId: string; /** X坐标 */ x: number; /** Y坐标 */ y: number; /** 外观同步信息 */ appearance?: IPlayerAppearance; /** 皮肤ID(兼容前端实时位置协议) */ skinId?: string; /** 头像ID(兼容前端实时位置协议) */ avatarId?: string; /** 咖啡店陪伴服务状态 */ cafeCompanion?: ICafeCompanionPresence | null; /** 是否锁定移动 */ movementLocked?: boolean; } /** * 好友操作请求接口 */ export interface FriendActionRequest { /** WebSocket连接ID */ socketId: string; /** 好友用户ID */ friendUserId: string; /** 好友用户名 */ friendUsername?: string; } /** * 游戏聊天消息格式(用于WebSocket广播) */ interface GameChatMessage { /** 消息类型标识 */ t: 'chat_render'; /** 发送者用户名 */ from: string; /** 发送者用户ID */ fromUserId: string; /** 消息文本内容 */ txt: string; /** 是否显示气泡 */ bubble: boolean; /** 时间戳(ISO格式) */ timestamp: string; /** 消息ID */ messageId: string; /** 地图ID */ mapId: string; /** 消息范围 */ scope: string; /** 私聊目标用户ID */ toUserId?: string; /** 私聊目标用户名 */ toUsername?: string; /** 私聊来源上下文:whisper / friends */ privateContext?: string; } /** * 聊天WebSocket网关接口 */ interface IChatWebSocketGateway { /** * 向指定地图广播消息 * @param mapId 地图ID * @param data 广播数据 * @param excludeId 排除的socketId(可选) */ broadcastToMap(mapId: string, data: any, excludeId?: string): void; /** * 向所有已认证玩家广播消息 * @param data 广播数据 * @param excludeId 排除的socketId(可选) */ broadcastToAll(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 MAX_HISTORY_MESSAGES = 200; private readonly inMemoryHistory = new Map(); 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, @Inject('ZulipAccountsService') private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService, private readonly accountProfileService: AccountProfileService, private readonly taskService: TaskService, ) { 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. 初始化用户的Zulip客户端(从数据库获取Zulip账号信息) await this.initializeZulipClientForUser(userInfo.userId); // 4. 创建会话 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客户端(注意:不删除Redis中的API Key,保持持久化) if (userId) { try { await this.zulipClientPool.destroyUserClient(userId); } catch (e) { this.logger.warn('Zulip客户端清理失败', { 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: '会话不存在,请重新登录' }; } const normalizedScope = this.normalizeChatScope(request.scope); if (normalizedScope === 'private' && !request.targetUserId?.trim()) { return { success: false, error: '请选择悄悄话对象' }; } // 2. 确定目标地图(优先使用请求中的mapId,否则使用会话当前地图) const targetMapId = request.mapId || session.currentMap; // 3. 获取上下文 const context = await this.sessionService.injectContext(request.socketId, targetMapId); const targetStream = context.stream; const targetTopic = context.topic || 'General'; // 4. 消息验证 const validationResult = await this.filterService.validateMessage( session.userId, request.content, targetStream, targetMapId, ); if (!validationResult.allowed) { return { success: false, error: validationResult.reason || '消息发送失败' }; } const messageContent = validationResult.filteredContent || request.content; const messageId = `game_${Date.now()}_${session.userId}`; // 5. 🚀 立即广播给游戏内玩家(根据scope决定广播范围) const gameMessage: GameChatMessage = { t: 'chat_render', from: session.username, fromUserId: session.userId, txt: messageContent, bubble: Boolean(request.bubble), timestamp: new Date().toISOString(), messageId, mapId: targetMapId, scope: normalizedScope, }; if (normalizedScope === 'private') { gameMessage.toUserId = request.targetUserId?.trim(); gameMessage.toUsername = request.targetUsername?.trim() || undefined; gameMessage.privateContext = request.privateContext?.trim() || undefined; } // local: 当前地图;global: 所有在线玩家;private: 仅发送者与目标玩家。 try { await this.dispatchGameChatMessage(gameMessage, request.socketId); this.recordChatHistory(gameMessage); } catch (dispatchError) { const message = (dispatchError as Error).message || '消息发送失败'; return { success: false, error: message }; } // 6. 🔄 异步同步到Zulip if (normalizedScope !== 'private') { this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId) .catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message })); } if (normalizedScope === 'global') { await this.taskService.recordActivity(BigInt(session.userId), 'public_message_sent') .catch((error: unknown) => this.logger.warn('记录公共聊天任务失败', { error: error instanceof Error ? error.message : String(error) })); } 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, { appearance: request.appearance, }, ); } catch (error) { this.logger.error('更新位置失败', { error: (error as Error).message }); return false; } } async updatePlayerPresenceState( request: PlayerPresenceStateUpdateRequest, ): Promise<{ success: boolean; presence?: MapPlayerSnapshotItem; socketId?: string; error?: string }> { try { const normalizedUserId = request.userId?.trim(); if (!normalizedUserId) { return { success: false, error: '用户ID不能为空' }; } const position = Number.isFinite(request.x) && Number.isFinite(request.y) ? { x: Number(request.x), y: Number(request.y) } : undefined; const presence = await this.sessionService.updateBusinessPresenceByUserId(normalizedUserId, { mapId: request.mapId, position, cafeCompanion: request.cafeCompanion, movementLocked: request.movementLocked, }); if (!presence) { return { success: false, error: '玩家当前不在线' }; } const payload = this.toMapPlayerSnapshotItem(presence); this.broadcastPlayerPresence(payload); return { success: true, presence: payload, socketId: presence.socketId }; } catch (error) { this.logger.error('更新玩家业务状态失败', { userId: request.userId, error: (error as Error).message }); return { success: false, error: '更新玩家业务状态失败' }; } } /** * 获取指定地图的在线玩家快照 * @param mapId 地图ID * @param excludeSocketId 排除的WebSocket连接ID * @returns 在线玩家列表 */ async getMapPlayerSnapshot(mapId: string, excludeSocketId?: string): Promise { const players = await this.sessionService.getPlayersInMap(mapId); return players .filter((player: MapPlayerPresence) => player.socketId !== excludeSocketId) .map((player: MapPlayerPresence) => this.toMapPlayerSnapshotItem(player)); } async refreshPlayerAppearance(socketId: string): Promise { const session = await this.sessionService.getSession(socketId); if (!session) return null; const appearance = await this.resolveAccountAppearance(session.userId); await this.sessionService.updatePlayerPosition( socketId, session.currentMap, Number(session.position?.x ?? 0), Number(session.position?.y ?? 0), { appearance }, ); const updatedSession = await this.sessionService.getSession(socketId); if (!updatedSession) return null; return this.toMapPlayerSnapshotItem({ socketId: updatedSession.socketId, userId: updatedSession.userId, username: updatedSession.username, mapId: updatedSession.currentMap, x: Number(updatedSession.position?.x ?? 0), y: Number(updatedSession.position?.y ?? 0), appearance: updatedSession.appearance, cafeCompanion: updatedSession.cafeCompanion ?? null, movementLocked: Boolean(updatedSession.movementLocked), }); } /** * 添加好友 * @param request 好友操作请求 * @returns 好友信息 */ async addFriend(request: FriendActionRequest): Promise<{ success: boolean; friend?: ChatFriendInfo; error?: string }> { try { const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } const friend = await this.sessionService.addFriend( session.userId, request.friendUserId, request.friendUsername, ); return { success: true, friend }; } catch (error) { return { success: false, error: (error as Error).message || '添加好友失败' }; } } /** * 发送好友请求 * @param request 好友操作请求 * @returns 好友请求信息 */ async requestFriend( request: FriendActionRequest, ): Promise<{ success: boolean; friendRequest?: ChatFriendRequestInfo; targetSocketId?: string | null; error?: string }> { try { const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } const friendRequest = await this.sessionService.createFriendRequest( session.userId, session.username, request.friendUserId, ); const targetSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId); if (targetSocketId && this.websocketGateway) { this.websocketGateway.sendToPlayer(targetSocketId, { t: 'friend_request_received', request: friendRequest, }); } return { success: true, friendRequest, targetSocketId }; } catch (error) { return { success: false, error: (error as Error).message || '好友请求发送失败' }; } } /** * 接受好友请求 * @param request 好友操作请求 * @returns 添加后的好友信息 */ async acceptFriendRequest( request: FriendActionRequest, ): Promise<{ success: boolean; friend?: ChatFriendInfo; reciprocalFriend?: ChatFriendInfo; error?: string }> { try { const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } const result = await this.sessionService.acceptFriendRequest( session.userId, request.friendUserId, session.username, ); const requesterSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId); if (requesterSocketId && this.websocketGateway) { this.websocketGateway.sendToPlayer(requesterSocketId, { t: 'friend_request_accepted', friend: result.reciprocalFriend, }); } return { success: true, friend: result.friend, reciprocalFriend: result.reciprocalFriend }; } catch (error) { return { success: false, error: (error as Error).message || '接受好友请求失败' }; } } /** * 拒绝好友请求 * @param request 好友操作请求 * @returns 操作结果 */ async rejectFriendRequest(request: FriendActionRequest): Promise<{ success: boolean; error?: string }> { try { const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } await this.sessionService.rejectFriendRequest(session.userId, request.friendUserId); const requesterSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId); if (requesterSocketId && this.websocketGateway) { this.websocketGateway.sendToPlayer(requesterSocketId, { t: 'friend_request_rejected', userId: session.userId, username: session.username, }); } return { success: true }; } catch (error) { return { success: false, error: (error as Error).message || '拒绝好友请求失败' }; } } /** * 移除好友 * @param request 好友操作请求 * @returns 操作结果 */ async removeFriend(request: FriendActionRequest): Promise<{ success: boolean; error?: string }> { try { const session = await this.sessionService.getSession(request.socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } await this.sessionService.removeFriend(session.userId, request.friendUserId); return { success: true }; } catch (error) { return { success: false, error: (error as Error).message || '移除好友失败' }; } } /** * 获取好友列表 * @param socketId WebSocket连接ID * @returns 好友列表 */ async getFriends(socketId: string): Promise<{ success: boolean; friends?: ChatFriendInfo[]; requests?: ChatFriendRequestInfo[]; error?: string }> { try { const session = await this.sessionService.getSession(socketId); if (!session) { return { success: false, error: '会话不存在,请重新登录' }; } const friends = await this.sessionService.getFriends(session.userId); const requests = await this.sessionService.getFriendRequests(session.userId); return { success: true, friends, requests }; } catch (error) { return { success: false, error: (error as Error).message || '获取好友列表失败' }; } } /** * 获取聊天历史 * @param query 查询参数,包含mapId、limit和offset * @returns 聊天历史记录列表 */ async getChatHistory(query: { mapId?: string; limit?: number; offset?: number }) { const mapId = (query.mapId || this.DEFAULT_MAP).trim(); const limit = Math.max(1, Math.min(Number(query.limit || this.DEFAULT_PAGE_SIZE), this.DEFAULT_PAGE_SIZE)); const offset = Math.max(0, Number(query.offset || 0)); const messages = (this.inMemoryHistory.get(mapId) || []).slice().reverse().map((message, index) => ({ id: offset + index + 1, messageId: message.messageId, sender: message.from, fromUserId: message.fromUserId, content: message.txt, scope: message.scope, mapId: message.mapId, timestamp: message.timestamp, streamName: message.mapId, topicName: 'Game Chat', bubble: message.bubble, toUserId: message.toUserId, toUsername: message.toUsername, privateContext: message.privateContext, })); return { success: true, messages: messages.slice(offset, offset + limit), total: messages.length, count: Math.min(Math.max(messages.length - offset, 0), limit), }; } /** * 获取会话信息 * @param socketId WebSocket连接ID * @returns 会话信息或null */ async getSession(socketId: string) { return this.sessionService.getSession(socketId); } private recordChatHistory(message: GameChatMessage): void { if (message.scope === 'private') { return; } const mapId = message.mapId || this.DEFAULT_MAP; const history = this.inMemoryHistory.get(mapId) || []; history.push(message); if (history.length > this.MAX_HISTORY_MESSAGES) { history.splice(0, history.length - this.MAX_HISTORY_MESSAGES); } this.inMemoryHistory.set(mapId, history); } // ========== 私有方法 ========== /** * 初始化用户的Zulip客户端 * * 功能描述: * 1. 从数据库获取用户的Zulip账号信息 * 2. 检查Redis中是否已有API Key缓存 * 3. 如果Redis中没有,从数据库标记判断是否需要重新获取 * 4. 创建Zulip客户端实例 * * @param userId 用户ID */ private async initializeZulipClientForUser(userId: string): Promise { this.logger.log('开始初始化用户Zulip客户端', { operation: 'initializeZulipClientForUser', userId, }); try { // 1. 从数据库获取用户的Zulip账号信息 const zulipAccount = await this.zulipAccountsService.findByGameUserId(userId); if (!zulipAccount) { this.logger.debug('用户没有关联的Zulip账号,跳过Zulip客户端初始化', { operation: 'initializeZulipClientForUser', userId, }); return; } if (zulipAccount.status !== 'active') { this.logger.warn('用户Zulip账号状态异常,跳过初始化', { operation: 'initializeZulipClientForUser', userId, status: zulipAccount.status, }); return; } // 2. 检查Redis中是否已有API Key const existingApiKey = await this.apiKeySecurityService.getApiKey(userId); if (existingApiKey.success && existingApiKey.apiKey) { this.logger.log('Redis中已有API Key缓存,直接创建Zulip客户端', { operation: 'initializeZulipClientForUser', userId, zulipEmail: zulipAccount.zulipEmail, }); // 创建Zulip客户端 await this.createZulipClientWithApiKey( userId, zulipAccount.zulipEmail, existingApiKey.apiKey ); return; } // 3. Redis中没有API Key,记录警告 // 注意:由于登录时没有用户密码,无法重新生成API Key // API Key应该在用户注册时存储到Redis,如果丢失需要用户重新绑定Zulip账号 this.logger.warn('Redis中没有用户的Zulip API Key缓存,无法创建Zulip客户端', { operation: 'initializeZulipClientForUser', userId, zulipEmail: zulipAccount.zulipEmail, hint: '用户可能需要重新绑定Zulip账号', }); } catch (error) { const err = error as Error; this.logger.error('初始化用户Zulip客户端失败', { operation: 'initializeZulipClientForUser', userId, error: err.message, }); // 不抛出异常,允许用户继续登录(只是没有Zulip功能) } } /** * 使用API Key创建Zulip客户端 * * @param userId 用户ID * @param zulipEmail Zulip邮箱 * @param apiKey API Key */ private async createZulipClientWithApiKey( userId: string, zulipEmail: string, apiKey: string ): Promise { try { const clientInstance = await this.zulipClientPool.createUserClient(userId, { username: zulipEmail, apiKey: apiKey, realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/', }); this.logger.log('Zulip客户端创建成功', { operation: 'createZulipClientWithApiKey', userId, zulipEmail, queueId: clientInstance.queueId, }); } catch (error) { const err = error as Error; this.logger.error('创建Zulip客户端失败', { operation: 'createZulipClientWithApiKey', userId, zulipEmail, error: err.message, }); throw error; } } 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(); const appearance = await this.resolveAccountAppearance(userInfo.userId); // 尝试获取已创建的Zulip客户端的队列ID let zulipQueueId = `queue_${sessionId}`; try { const existingClient = await this.zulipClientPool.getUserClient(userInfo.userId); if (existingClient?.queueId) { zulipQueueId = existingClient.queueId; } } catch (e) { this.logger.debug('获取Zulip客户端队列ID失败,使用默认值', { error: (e as Error).message }); } const session = await this.sessionService.createSession( socketId, userInfo.userId, zulipQueueId, userInfo.username, this.DEFAULT_MAP, this.DEFAULT_POSITION, appearance, ); return { sessionId, currentMap: session.currentMap }; } private async resolveAccountAppearance(userId: string): Promise { const normalizedUserId = String(userId || '').trim(); if (!/^\d+$/.test(normalizedUserId)) { return undefined; } try { const accountProfile = await this.accountProfileService.getAccountProfile(BigInt(normalizedUserId)); const profile = accountProfile.profile; const skinId = String(profile.skin_id || '').trim(); const avatarId = String(profile.avatar_id || '').trim(); const appearance: IPlayerAppearance = {}; if (skinId) { appearance.skinId = skinId; const skinAsset = profile.owned_skins.find((skin: any) => { const assetSkinId = String(skin?.id ?? skin?.skin_id ?? '').trim(); return assetSkinId === skinId; }); if (skinAsset) { appearance.skinAsset = this.toRealtimeSkinAsset(skinAsset as Record); } } if (avatarId) { appearance.avatarId = avatarId; } return Object.keys(appearance).length > 0 ? appearance : undefined; } catch (error) { this.logger.warn('读取账号外观失败,在线会话将等待客户端位置包补充', { userId: normalizedUserId, error: (error as Error).message, }); return undefined; } } private toRealtimeSkinAsset(skinAsset: Record): Record { return { id: skinAsset.id ?? skinAsset.skin_id, name: skinAsset.name, texture_url: skinAsset.texture_url, mime_type: skinAsset.mime_type, hframes: skinAsset.hframes, vframes: skinAsset.vframes, source: skinAsset.source, }; } private normalizeChatScope(scope: string): 'local' | 'global' | 'private' { const normalizedScope = scope?.trim().toLowerCase(); if (normalizedScope === 'global' || normalizedScope === 'world') { return 'global'; } if (normalizedScope === 'private' || normalizedScope === 'whisper' || normalizedScope === 'dm') { return 'private'; } return 'local'; } private async dispatchGameChatMessage(message: GameChatMessage, senderSocketId: string): Promise { if (message.scope === 'global') { this.broadcastToAllGamePlayers(message, senderSocketId); return; } if (message.scope === 'private') { await this.sendPrivateGameMessage(message, senderSocketId); return; } await this.broadcastToGamePlayers(message.mapId, message, senderSocketId); } private broadcastToAllGamePlayers(message: GameChatMessage, excludeSocketId?: string): void { if (!this.websocketGateway) { throw new Error('WebSocket网关未设置'); } this.websocketGateway.broadcastToAll(message, excludeSocketId); } private async sendPrivateGameMessage(message: GameChatMessage, senderSocketId: string): Promise { if (!this.websocketGateway) { throw new Error('WebSocket网关未设置'); } const targetUserId = message.toUserId?.trim(); if (!targetUserId) { throw new Error('私聊目标用户ID不能为空'); } const targetSocketId = await this.sessionService.getSocketIdByUserId(targetUserId); if (!targetSocketId) { throw new Error('悄悄话对象不在线'); } this.websocketGateway.sendToPlayer(senderSocketId, message); if (targetSocketId !== senderSocketId) { this.websocketGateway.sendToPlayer(targetSocketId, message); } } 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 toMapPlayerSnapshotItem(player: MapPlayerPresence): MapPlayerSnapshotItem { return { userId: player.userId, username: player.username, mapId: player.mapId, x: player.x, y: player.y, appearance: player.appearance, skinId: player.appearance?.skinId, avatarId: player.appearance?.avatarId, cafeCompanion: player.cafeCompanion ?? null, movementLocked: Boolean(player.movementLocked), }; } private broadcastPlayerPresence(presence: MapPlayerSnapshotItem): void { if (!this.websocketGateway) { this.logger.warn('WebSocket网关未设置,跳过玩家业务状态广播', { userId: presence.userId }); return; } this.websocketGateway.broadcastToMap(presence.mapId, { t: 'position_update', userId: presence.userId, username: presence.username, x: presence.x, y: presence.y, mapId: presence.mapId, appearance: presence.appearance, skinId: presence.skinId, avatarId: presence.avatarId, cafeCompanion: presence.cafeCompanion ?? null, movementLocked: Boolean(presence.movementLocked), }); } 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 }); } } }