/** * 聊天会话管理服务 * * 功能描述: * - 维护WebSocket连接ID与Zulip队列ID的映射关系 * - 管理玩家位置跟踪和上下文注入 * - 提供空间过滤和会话查询功能 * - 实现 ISessionManagerService 接口,供其他模块依赖 * * 架构层级:Business Layer(业务层) * * 最近修改: * - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin) * * @author moyin * @version 1.1.3 * @since 2026-01-14 * @lastModified 2026-01-14 */ import { Injectable, Logger, Inject } from '@nestjs/common'; import { IRedisService } from '../../../core/redis/redis.interface'; import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; import { ISessionManagerService, IPosition, IGameSession, IContextInfo, } from '../../../core/session_core/session_core.interfaces'; // 常量定义 const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const; const SESSION_TIMEOUT = 3600; // 1小时 const NEARBY_OBJECT_RADIUS = 50; // 附近对象搜索半径 /** * 位置信息接口(兼容旧代码) */ export type Position = IPosition; /** * 游戏会话接口(兼容旧代码) */ export type GameSession = IGameSession; /** * 上下文信息接口(兼容旧代码) */ export type ContextInfo = IContextInfo; /** * 聊天会话管理服务类 * * 职责: * - 管理WebSocket连接与用户会话的映射 * - 跟踪玩家在游戏地图中的位置 * - 根据位置注入聊天上下文(Stream/Topic) * * 主要方法: * - createSession() - 创建新的游戏会话 * - getSession() - 获取会话信息 * - updatePlayerPosition() - 更新玩家位置 * - destroySession() - 销毁会话 * - injectContext() - 注入聊天上下文 * * 使用场景: * - 玩家登录游戏后的会话管理 * - 基于位置的聊天频道自动切换 */ @Injectable() export class ChatSessionService implements ISessionManagerService { private readonly SESSION_PREFIX = 'chat:session:'; private readonly MAP_PLAYERS_PREFIX = 'chat:map_players:'; private readonly USER_SESSION_PREFIX = 'chat:user_session:'; private readonly DEFAULT_MAP = 'novice_village'; private readonly DEFAULT_POSITION: Position = { x: 400, y: 300 }; private readonly logger = new Logger(ChatSessionService.name); constructor( @Inject('REDIS_SERVICE') private readonly redisService: IRedisService, @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, ) { this.logger.log('ChatSessionService初始化完成'); } /** * 创建会话 * @param socketId WebSocket连接ID * @param userId 用户ID * @param zulipQueueId Zulip队列ID * @param username 用户名(可选) * @param initialMap 初始地图ID(可选) * @param initialPosition 初始位置(可选) * @returns 创建的游戏会话 * @throws Error 参数为空时抛出异常 */ async createSession( socketId: string, userId: string, zulipQueueId: string, username?: string, initialMap?: string, initialPosition?: Position, ): Promise { this.logger.log('创建游戏会话', { socketId, userId }); // 参数验证 if (!socketId?.trim() || !userId?.trim() || !zulipQueueId?.trim()) { throw new Error('参数不能为空'); } // 检查并清理旧会话 const existingSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${userId}`); if (existingSocketId) { await this.destroySession(existingSocketId); } // 创建会话对象 const now = new Date(); const session: GameSession = { socketId, userId, username: username || `user_${userId}`, zulipQueueId, currentMap: initialMap || this.DEFAULT_MAP, position: initialPosition || { ...this.DEFAULT_POSITION }, lastActivity: now, createdAt: now, }; // 存储到Redis const sessionKey = `${this.SESSION_PREFIX}${socketId}`; await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session)); // 添加到地图玩家列表 const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`; await this.redisService.sadd(mapKey, socketId); await this.redisService.expire(mapKey, SESSION_TIMEOUT); // 建立用户到会话的映射 const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`; await this.redisService.setex(userSessionKey, SESSION_TIMEOUT, socketId); this.logger.log('会话创建成功', { socketId, userId, currentMap: session.currentMap }); return session; } /** * 获取会话信息 * @param socketId WebSocket连接ID * @returns 会话信息或null */ async getSession(socketId: string): Promise { if (!socketId?.trim()) return null; try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) return null; const session = this.deserializeSession(sessionData); // 更新最后活动时间 session.lastActivity = new Date(); await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session)); return session; } catch (error) { this.logger.error('获取会话失败', { socketId, error: (error as Error).message }); return null; } } /** * 上下文注入:根据位置确定Stream/Topic * @param socketId WebSocket连接ID * @param mapId 地图ID(可选,默认使用会话当前地图) * @returns 上下文信息,包含stream和topic */ async injectContext(socketId: string, mapId?: string): Promise { try { const session = await this.getSession(socketId); if (!session) throw new Error('会话不存在'); const targetMapId = mapId || session.currentMap; const stream = this.configManager.getStreamByMap(targetMapId) || 'General'; let topic = 'General'; if (session.position) { const nearbyObject = this.configManager.findNearbyObject( targetMapId, session.position.x, session.position.y, NEARBY_OBJECT_RADIUS ); if (nearbyObject) topic = nearbyObject.zulipTopic; } return { stream, topic }; } catch (error) { this.logger.error('上下文注入失败', { socketId, error: (error as Error).message }); return { stream: 'General' }; } } /** * 获取指定地图的所有Socket * @param mapId 地图ID * @returns Socket ID列表 */ async getSocketsInMap(mapId: string): Promise { try { const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; return await this.redisService.smembers(mapKey); } catch (error) { this.logger.error('获取地图玩家失败', { mapId, error: (error as Error).message }); return []; } } /** * 更新玩家位置 * @param socketId WebSocket连接ID * @param mapId 地图ID * @param x X坐标 * @param y Y坐标 * @returns 更新是否成功 */ async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): Promise { if (!socketId?.trim() || !mapId?.trim()) return false; try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) return false; const session = this.deserializeSession(sessionData); const oldMapId = session.currentMap; const mapChanged = oldMapId !== mapId; // 更新会话 session.currentMap = mapId; session.position = { x, y }; session.lastActivity = new Date(); await this.redisService.setex(sessionKey, SESSION_TIMEOUT, this.serializeSession(session)); // 如果切换地图,更新地图玩家列表 if (mapChanged) { await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${oldMapId}`, socketId); const newMapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; await this.redisService.sadd(newMapKey, socketId); await this.redisService.expire(newMapKey, SESSION_TIMEOUT); } return true; } catch (error) { this.logger.error('更新位置失败', { socketId, error: (error as Error).message }); return false; } } /** * 销毁会话 * @param socketId WebSocket连接ID * @returns 销毁是否成功 */ async destroySession(socketId: string): Promise { if (!socketId?.trim()) return false; try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) return true; const session = this.deserializeSession(sessionData); // 从地图玩家列表移除 await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${session.currentMap}`, socketId); // 删除用户会话映射 await this.redisService.del(`${this.USER_SESSION_PREFIX}${session.userId}`); // 删除会话数据 await this.redisService.del(sessionKey); this.logger.log('会话销毁成功', { socketId, userId: session.userId }); return true; } catch (error) { this.logger.error('销毁会话失败', { socketId, error: (error as Error).message }); return false; } } /** * 清理过期会话 * @param timeoutMinutes 超时时间(分钟),默认30分钟 * @returns 清理结果,包含清理数量和Zulip队列ID列表 */ async cleanupExpiredSessions(timeoutMinutes: number = 30): Promise<{ cleanedCount: number; zulipQueueIds: string[] }> { const expiredSessions: GameSession[] = []; const zulipQueueIds: string[] = []; const timeoutMs = timeoutMinutes * 60 * 1000; const now = Date.now(); try { const mapIds = this.configManager.getAllMapIds().length > 0 ? this.configManager.getAllMapIds() : DEFAULT_MAP_IDS; for (const mapId of mapIds) { const socketIds = await this.getSocketsInMap(mapId); for (const socketId of socketIds) { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${mapId}`, socketId); continue; } const session = this.deserializeSession(sessionData); const lastActivityTime = session.lastActivity.getTime(); if (now - lastActivityTime > timeoutMs) { expiredSessions.push(session); zulipQueueIds.push(session.zulipQueueId); } } } for (const session of expiredSessions) { await this.destroySession(session.socketId); } return { cleanedCount: expiredSessions.length, zulipQueueIds }; } catch (error) { this.logger.error('清理过期会话失败', { error: (error as Error).message }); return { cleanedCount: 0, zulipQueueIds: [] }; } } // ========== 私有方法 ========== private serializeSession(session: GameSession): string { return JSON.stringify({ ...session, lastActivity: session.lastActivity.toISOString(), createdAt: session.createdAt.toISOString(), }); } private deserializeSession(data: string): GameSession { const parsed = JSON.parse(data); return { ...parsed, lastActivity: new Date(parsed.lastActivity), createdAt: new Date(parsed.createdAt), }; } }