/** * 会话管理服务 * * 功能描述: * - 维护WebSocket连接ID与Zulip队列ID的映射关系 * - 管理玩家位置跟踪和上下文注入 * - 提供空间过滤和会话查询功能 * - 支持会话状态的序列化和反序列化 * - 支持服务重启后的状态恢复 * * 主要方法: * - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID * - getSession(): 获取会话信息 * - injectContext(): 上下文注入,根据位置确定Stream/Topic * - getSocketsInMap(): 空间过滤,获取指定地图的所有Socket * - updatePlayerPosition(): 更新玩家位置 * - destroySession(): 销毁会话 * - cleanupExpiredSessions(): 清理过期会话 * * Redis存储结构: * - 会话数据: zulip:session:{socketId} -> JSON(GameSession) * - 地图玩家列表: zulip:map_players:{mapId} -> Set * - 用户会话映射: zulip:user_session:{userId} -> socketId * * 使用场景: * - 玩家登录时创建会话映射 * - 消息路由时进行上下文注入 * - 消息分发时进行空间过滤 * - 玩家登出时清理会话数据 * * @author angjustinl * @version 1.0.0 * @since 2025-12-25 */ import { Injectable, Logger, Inject } from '@nestjs/common'; import { IRedisService } from '../../../core/redis/redis.interface'; import { IZulipConfigService } from '../../../core/zulip/interfaces/zulip-core.interfaces'; import { Internal, Constants } from '../../../core/zulip/interfaces/zulip.interfaces'; /** * 游戏会话接口 - 重新导出以保持向后兼容 */ export type GameSession = Internal.GameSession; /** * 位置信息接口 - 重新导出以保持向后兼容 */ export type Position = Internal.Position; /** * 上下文信息接口 */ export interface ContextInfo { stream: string; topic?: string; } /** * 创建会话请求接口 */ export interface CreateSessionRequest { socketId: string; userId: string; username?: string; zulipQueueId: string; initialMap?: string; initialPosition?: Position; } /** * 会话统计信息接口 */ export interface SessionStats { totalSessions: number; mapDistribution: Record; oldestSession?: Date; newestSession?: Date; } /** * 会话管理服务类 * * 职责: * - 维护WebSocket连接ID与Zulip队列ID的映射关系 * - 管理玩家位置跟踪和上下文注入 * - 提供空间过滤和会话查询功能 * - 支持会话状态的序列化和反序列化 * * 主要方法: * - createSession(): 创建会话并绑定Socket_ID与Zulip_Queue_ID * - getSession(): 获取会话信息 * - injectContext(): 上下文注入,根据位置确定Stream/Topic * - getSocketsInMap(): 空间过滤,获取指定地图的所有Socket * - updatePlayerPosition(): 更新玩家位置 * - destroySession(): 销毁会话 * * 使用场景: * - 玩家登录时创建会话映射 * - 消息路由时进行上下文注入 * - 消息分发时进行空间过滤 * - 玩家登出时清理会话数据 */ @Injectable() export class SessionManagerService { private readonly SESSION_PREFIX = 'zulip:session:'; private readonly MAP_PLAYERS_PREFIX = 'zulip:map_players:'; private readonly USER_SESSION_PREFIX = 'zulip:user_session:'; private readonly SESSION_TIMEOUT = 3600; // 1小时 private readonly DEFAULT_MAP = 'novice_village'; private readonly DEFAULT_POSITION: Position = { x: 400, y: 300 }; private readonly logger = new Logger(SessionManagerService.name); constructor( @Inject('REDIS_SERVICE') private readonly redisService: IRedisService, @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, ) { this.logger.log('SessionManagerService初始化完成'); } /** * 序列化会话对象为JSON字符串 * * 功能描述: * 将GameSession对象转换为可存储在Redis中的JSON字符串 * * @param session 会话对象 * @returns string JSON字符串 * @private */ private serializeSession(session: GameSession): string { const serialized: Internal.GameSessionSerialized = { socketId: session.socketId, userId: session.userId, username: session.username, zulipQueueId: session.zulipQueueId, currentMap: session.currentMap, position: session.position, lastActivity: session.lastActivity instanceof Date ? session.lastActivity.toISOString() : session.lastActivity, createdAt: session.createdAt instanceof Date ? session.createdAt.toISOString() : session.createdAt, }; return JSON.stringify(serialized); } /** * 反序列化JSON字符串为会话对象 * * 功能描述: * 将Redis中存储的JSON字符串转换回GameSession对象 * * @param data JSON字符串 * @returns GameSession 会话对象 * @private */ private deserializeSession(data: string): GameSession { const parsed: Internal.GameSessionSerialized = JSON.parse(data); return { socketId: parsed.socketId, userId: parsed.userId, username: parsed.username, zulipQueueId: parsed.zulipQueueId, currentMap: parsed.currentMap, position: parsed.position, lastActivity: new Date(parsed.lastActivity), createdAt: new Date(parsed.createdAt), }; } /** * 创建会话并绑定Socket_ID与Zulip_Queue_ID * * 功能描述: * 创建新的游戏会话,建立WebSocket连接与Zulip队列的映射关系 * * 业务逻辑: * 1. 验证输入参数 * 2. 检查用户是否已有会话(如有则先清理) * 3. 创建会话对象 * 4. 存储到Redis缓存 * 5. 添加到地图玩家列表 * 6. 建立用户到会话的映射 * 7. 设置过期时间 * * @param socketId WebSocket连接ID * @param userId 用户ID * @param zulipQueueId Zulip事件队列ID * @param username 用户名(可选) * @param initialMap 初始地图(可选) * @param initialPosition 初始位置(可选) * @returns Promise 创建的会话对象 * * @throws Error 当参数验证失败时 * @throws Error 当Redis操作失败时 */ async createSession( socketId: string, userId: string, zulipQueueId: string, username?: string, initialMap?: string, initialPosition?: Position, ): Promise { const startTime = Date.now(); this.logger.log('开始创建游戏会话', { operation: 'createSession', socketId, userId, zulipQueueId, timestamp: new Date().toISOString(), }); try { // 1. 参数验证 if (!socketId || !socketId.trim()) { throw new Error('socketId不能为空'); } if (!userId || !userId.trim()) { throw new Error('userId不能为空'); } if (!zulipQueueId || !zulipQueueId.trim()) { throw new Error('zulipQueueId不能为空'); } // 2. 检查用户是否已有会话,如有则先清理 const existingSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${userId}`); if (existingSocketId) { this.logger.log('用户已有会话,先清理旧会话', { operation: 'createSession', userId, existingSocketId, }); await this.destroySession(existingSocketId); } // 3. 创建会话对象 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, }; // 4. 存储会话到Redis const sessionKey = `${this.SESSION_PREFIX}${socketId}`; await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); // 5. 添加到地图玩家列表 const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`; await this.redisService.sadd(mapKey, socketId); await this.redisService.expire(mapKey, this.SESSION_TIMEOUT); // 6. 建立用户到会话的映射 const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`; await this.redisService.setex(userSessionKey, this.SESSION_TIMEOUT, socketId); const duration = Date.now() - startTime; this.logger.log('游戏会话创建成功', { operation: 'createSession', socketId, userId, zulipQueueId, currentMap: session.currentMap, duration, timestamp: new Date().toISOString(), }); return session; } catch (error) { const err = error as Error; const duration = Date.now() - startTime; this.logger.error('创建游戏会话失败', { operation: 'createSession', socketId, userId, zulipQueueId, error: err.message, duration, timestamp: new Date().toISOString(), }, err.stack); throw error; } } /** * 获取会话信息 * * 功能描述: * 根据socketId获取会话信息,并更新最后活动时间 * * @param socketId WebSocket连接ID * @returns Promise 会话信息,不存在时返回null */ async getSession(socketId: string): Promise { try { if (!socketId || !socketId.trim()) { this.logger.warn('获取会话失败:socketId为空', { operation: 'getSession', }); return null; } const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { this.logger.debug('会话不存在', { operation: 'getSession', socketId, }); return null; } const session = this.deserializeSession(sessionData); // 更新最后活动时间 session.lastActivity = new Date(); await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); this.logger.debug('获取会话信息成功', { operation: 'getSession', socketId, userId: session.userId, currentMap: session.currentMap, }); return session; } catch (error) { const err = error as Error; this.logger.error('获取会话信息失败', { operation: 'getSession', socketId, error: err.message, timestamp: new Date().toISOString(), }, err.stack); return null; } } /** * 根据用户ID获取会话信息 * * 功能描述: * 根据userId查找对应的会话信息 * * @param userId 用户ID * @returns Promise 会话信息,不存在时返回null */ async getSessionByUserId(userId: string): Promise { try { if (!userId || !userId.trim()) { return null; } const userSessionKey = `${this.USER_SESSION_PREFIX}${userId}`; const socketId = await this.redisService.get(userSessionKey); if (!socketId) { return null; } return this.getSession(socketId); } catch (error) { const err = error as Error; this.logger.error('根据用户ID获取会话失败', { operation: 'getSessionByUserId', userId, error: err.message, }, err.stack); return null; } } /** * 上下文注入:根据位置确定Stream/Topic * * 功能描述: * 根据玩家当前位置和地图信息,确定消息应该发送到的Zulip Stream和Topic * * 业务逻辑: * 1. 获取玩家会话信息 * 2. 根据地图ID查找对应的Stream * 3. 根据玩家位置确定Topic(如果有交互对象) * 4. 返回上下文信息 * * @param socketId WebSocket连接ID * @param mapId 地图ID(可选,用于覆盖当前地图) * @returns Promise 上下文信息 * * @throws Error 当会话不存在时 */ async injectContext(socketId: string, mapId?: string): Promise { this.logger.debug('开始上下文注入', { operation: 'injectContext', socketId, mapId, timestamp: new Date().toISOString(), }); try { const session = await this.getSession(socketId); if (!session) { throw new Error('会话不存在'); } const targetMapId = mapId || session.currentMap; // 从ConfigManager获取地图对应的Stream const stream = this.configManager.getStreamByMap(targetMapId) || 'General'; // TODO: 根据玩家位置确定Topic // 检查是否靠近交互对象 const context: ContextInfo = { stream, topic: undefined, // 暂时不设置Topic,使用默认的General }; this.logger.debug('上下文注入完成', { operation: 'injectContext', socketId, targetMapId, stream: context.stream, topic: context.topic, }); return context; } catch (error) { const err = error as Error; this.logger.error('上下文注入失败', { operation: 'injectContext', socketId, mapId, error: err.message, timestamp: new Date().toISOString(), }, err.stack); // 返回默认上下文 return { stream: 'General', }; } } /** * 空间过滤:获取指定地图的所有Socket * * 功能描述: * 获取指定地图中所有在线玩家的Socket ID列表,用于消息分发 * * @param mapId 地图ID * @returns Promise Socket ID列表 */ async getSocketsInMap(mapId: string): Promise { try { const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; const socketIds = await this.redisService.smembers(mapKey); this.logger.debug('获取地图玩家列表', { operation: 'getSocketsInMap', mapId, playerCount: socketIds.length, }); return socketIds; } catch (error) { const err = error as Error; this.logger.error('获取地图玩家列表失败', { operation: 'getSocketsInMap', mapId, error: err.message, timestamp: new Date().toISOString(), }, err.stack); return []; } } /** * 更新玩家位置 * * 功能描述: * 更新玩家在游戏世界中的位置信息,如果切换地图则更新地图玩家列表 * * 业务逻辑: * 1. 获取当前会话 * 2. 检查是否切换地图 * 3. 更新会话位置信息 * 4. 如果切换地图,更新地图玩家列表 * 5. 保存更新后的会话 * * @param socketId WebSocket连接ID * @param mapId 地图ID * @param x X坐标 * @param y Y坐标 * @returns Promise 是否更新成功 */ async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): Promise { this.logger.debug('开始更新玩家位置', { operation: 'updatePlayerPosition', socketId, mapId, position: { x, y }, timestamp: new Date().toISOString(), }); try { // 参数验证 if (!socketId || !socketId.trim()) { this.logger.warn('更新位置失败:socketId为空', { operation: 'updatePlayerPosition', }); return false; } if (!mapId || !mapId.trim()) { this.logger.warn('更新位置失败:mapId为空', { operation: 'updatePlayerPosition', socketId, }); return false; } const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { this.logger.warn('更新位置失败:会话不存在', { operation: 'updatePlayerPosition', socketId, }); 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, this.SESSION_TIMEOUT, this.serializeSession(session)); // 如果切换了地图,更新地图玩家列表 if (mapChanged) { // 从旧地图移除 const oldMapKey = `${this.MAP_PLAYERS_PREFIX}${oldMapId}`; await this.redisService.srem(oldMapKey, socketId); // 添加到新地图 const newMapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; await this.redisService.sadd(newMapKey, socketId); await this.redisService.expire(newMapKey, this.SESSION_TIMEOUT); this.logger.log('玩家切换地图', { operation: 'updatePlayerPosition', socketId, userId: session.userId, oldMapId, newMapId: mapId, position: { x, y }, }); } this.logger.debug('玩家位置更新成功', { operation: 'updatePlayerPosition', socketId, mapId, position: { x, y }, mapChanged, }); return true; } catch (error) { const err = error as Error; this.logger.error('更新玩家位置失败', { operation: 'updatePlayerPosition', socketId, mapId, position: { x, y }, error: err.message, timestamp: new Date().toISOString(), }, err.stack); return false; } } /** * 销毁会话 * * 功能描述: * 清理玩家会话数据,从地图玩家列表中移除,释放相关资源 * * 业务逻辑: * 1. 获取会话信息 * 2. 从地图玩家列表中移除 * 3. 删除用户会话映射 * 4. 删除会话数据 * * @param socketId WebSocket连接ID * @returns Promise 是否销毁成功 */ async destroySession(socketId: string): Promise { this.logger.log('开始销毁游戏会话', { operation: 'destroySession', socketId, timestamp: new Date().toISOString(), }); try { if (!socketId || !socketId.trim()) { this.logger.warn('销毁会话失败:socketId为空', { operation: 'destroySession', }); return false; } // 获取会话信息 const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { this.logger.log('会话不存在,跳过销毁', { operation: 'destroySession', socketId, }); return true; } const session = this.deserializeSession(sessionData); // 从地图玩家列表中移除 const mapKey = `${this.MAP_PLAYERS_PREFIX}${session.currentMap}`; await this.redisService.srem(mapKey, socketId); // 删除用户会话映射 const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`; await this.redisService.del(userSessionKey); // 删除会话数据 await this.redisService.del(sessionKey); this.logger.log('游戏会话销毁成功', { operation: 'destroySession', socketId, userId: session.userId, currentMap: session.currentMap, timestamp: new Date().toISOString(), }); return true; } catch (error) { const err = error as Error; this.logger.error('销毁游戏会话失败', { operation: 'destroySession', socketId, error: err.message, timestamp: new Date().toISOString(), }, err.stack); // 即使失败也要尝试清理会话数据 try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; await this.redisService.del(sessionKey); } catch (cleanupError) { const cleanupErr = cleanupError as Error; this.logger.error('会话清理失败', { operation: 'destroySession', socketId, error: cleanupErr.message, }); } return false; } } /** * 清理过期会话 * * 功能描述: * 定时任务,清理超时的会话数据和相关资源 * * 业务逻辑: * 1. 获取所有地图的玩家列表 * 2. 检查每个会话的最后活动时间 * 3. 清理超过30分钟未活动的会话 * 4. 返回需要注销的Zulip队列ID列表 * * @param timeoutMinutes 超时时间(分钟),默认30分钟 * @returns Promise<{cleanedCount: number, zulipQueueIds: string[]}> 清理结果 */ async cleanupExpiredSessions(timeoutMinutes: number = 30): Promise<{ cleanedCount: number; zulipQueueIds: string[]; }> { const startTime = Date.now(); this.logger.log('开始清理过期会话', { operation: 'cleanupExpiredSessions', timeoutMinutes, timestamp: new Date().toISOString(), }); const expiredSessions: GameSession[] = []; const zulipQueueIds: string[] = []; const timeoutMs = timeoutMinutes * 60 * 1000; const now = Date.now(); try { // 获取所有地图的玩家列表 const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 for (const mapId of mapIds) { const socketIds = await this.getSocketsInMap(mapId); for (const socketId of socketIds) { try { 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 instanceof Date ? session.lastActivity.getTime() : new Date(session.lastActivity).getTime(); // 检查是否超时 if (now - lastActivityTime > timeoutMs) { expiredSessions.push(session); zulipQueueIds.push(session.zulipQueueId); this.logger.log('发现过期会话', { operation: 'cleanupExpiredSessions', socketId: session.socketId, userId: session.userId, lastActivity: session.lastActivity, idleMinutes: Math.round((now - lastActivityTime) / 60000), }); } } catch (sessionError) { const err = sessionError as Error; this.logger.warn('检查会话时出错', { operation: 'cleanupExpiredSessions', socketId, error: err.message, }); } } } // 清理过期会话 for (const session of expiredSessions) { try { await this.destroySession(session.socketId); } catch (destroyError) { const err = destroyError as Error; this.logger.error('清理过期会话失败', { operation: 'cleanupExpiredSessions', socketId: session.socketId, error: err.message, }); } } const duration = Date.now() - startTime; this.logger.log('过期会话清理完成', { operation: 'cleanupExpiredSessions', cleanedCount: expiredSessions.length, zulipQueueCount: zulipQueueIds.length, duration, timestamp: new Date().toISOString(), }); return { cleanedCount: expiredSessions.length, zulipQueueIds, }; } catch (error) { const err = error as Error; this.logger.error('清理过期会话失败', { operation: 'cleanupExpiredSessions', error: err.message, timestamp: new Date().toISOString(), }, err.stack); return { cleanedCount: 0, zulipQueueIds: [], }; } } /** * 检查会话是否过期 * * @param socketId WebSocket连接ID * @param timeoutMinutes 超时时间(分钟) * @returns Promise 是否过期 */ async isSessionExpired(socketId: string, timeoutMinutes: number = 30): Promise { try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { return true; // 会话不存在视为过期 } const session = this.deserializeSession(sessionData); const lastActivityTime = session.lastActivity instanceof Date ? session.lastActivity.getTime() : new Date(session.lastActivity).getTime(); const timeoutMs = timeoutMinutes * 60 * 1000; return Date.now() - lastActivityTime > timeoutMs; } catch (error) { return true; // 出错时视为过期 } } /** * 刷新会话活动时间 * * @param socketId WebSocket连接ID * @returns Promise 是否刷新成功 */ async refreshSession(socketId: string): Promise { try { const sessionKey = `${this.SESSION_PREFIX}${socketId}`; const sessionData = await this.redisService.get(sessionKey); if (!sessionData) { return false; } const session = this.deserializeSession(sessionData); session.lastActivity = new Date(); await this.redisService.setex(sessionKey, this.SESSION_TIMEOUT, this.serializeSession(session)); // 同时刷新用户会话映射的过期时间 const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`; await this.redisService.expire(userSessionKey, this.SESSION_TIMEOUT); return true; } catch (error) { const err = error as Error; this.logger.error('刷新会话失败', { operation: 'refreshSession', socketId, error: err.message, }); return false; } } /** * 获取会话统计信息 * * 功能描述: * 获取当前系统中的会话统计信息,包括总会话数和地图分布 * * @returns Promise 会话统计信息 */ async getSessionStats(): Promise { try { // 获取所有地图的玩家列表 const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 const mapDistribution: Record = {}; let totalSessions = 0; for (const mapId of mapIds) { const mapKey = `${this.MAP_PLAYERS_PREFIX}${mapId}`; const players = await this.redisService.smembers(mapKey); mapDistribution[mapId] = players.length; totalSessions += players.length; } this.logger.debug('获取会话统计信息', { operation: 'getSessionStats', totalSessions, mapDistribution, }); return { totalSessions, mapDistribution, }; } catch (error) { const err = error as Error; this.logger.error('获取会话统计失败', { operation: 'getSessionStats', error: err.message, }); return { totalSessions: 0, mapDistribution: {}, }; } } /** * 获取所有活跃会话 * * 功能描述: * 获取指定地图中所有活跃会话的详细信息 * * @param mapId 地图ID(可选,不传则获取所有地图) * @returns Promise 会话列表 */ async getAllSessions(mapId?: string): Promise { try { const sessions: GameSession[] = []; if (mapId) { // 获取指定地图的会话 const socketIds = await this.getSocketsInMap(mapId); for (const socketId of socketIds) { const session = await this.getSession(socketId); if (session) { sessions.push(session); } } } else { // 获取所有地图的会话 const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 for (const map of mapIds) { const socketIds = await this.getSocketsInMap(map); for (const socketId of socketIds) { const session = await this.getSession(socketId); if (session) { sessions.push(session); } } } } return sessions; } catch (error) { const err = error as Error; this.logger.error('获取所有会话失败', { operation: 'getAllSessions', mapId, error: err.message, }); return []; } } }