forked from datawhale/whale-town-end
refactor:重构Zulip模块按业务功能模块化架构
- 将技术实现服务从business层迁移到core层 - 创建src/core/zulip/核心服务模块,包含API客户端、连接池等技术服务 - 保留src/business/zulip/业务逻辑,专注游戏相关的业务规则 - 通过依赖注入实现业务层与核心层的解耦 - 更新模块导入关系,确保架构分层清晰 重构后的架构符合单一职责原则,提高了代码的可维护性和可测试性
This commit is contained in:
990
src/business/zulip/services/session_manager.service.ts
Normal file
990
src/business/zulip/services/session_manager.service.ts
Normal file
@@ -0,0 +1,990 @@
|
||||
/**
|
||||
* 会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 维护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<socketId>
|
||||
* - 用户会话映射: 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<string, number>;
|
||||
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<GameSession> 创建的会话对象
|
||||
*
|
||||
* @throws Error 当参数验证失败时
|
||||
* @throws Error 当Redis操作失败时
|
||||
*/
|
||||
async createSession(
|
||||
socketId: string,
|
||||
userId: string,
|
||||
zulipQueueId: string,
|
||||
username?: string,
|
||||
initialMap?: string,
|
||||
initialPosition?: Position,
|
||||
): Promise<GameSession> {
|
||||
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<GameSession | null> 会话信息,不存在时返回null
|
||||
*/
|
||||
async getSession(socketId: string): Promise<GameSession | null> {
|
||||
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<GameSession | null> 会话信息,不存在时返回null
|
||||
*/
|
||||
async getSessionByUserId(userId: string): Promise<GameSession | null> {
|
||||
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<ContextInfo> 上下文信息
|
||||
*
|
||||
* @throws Error 当会话不存在时
|
||||
*/
|
||||
async injectContext(socketId: string, mapId?: string): Promise<ContextInfo> {
|
||||
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<string[]> Socket ID列表
|
||||
*/
|
||||
async getSocketsInMap(mapId: string): Promise<string[]> {
|
||||
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<boolean> 是否更新成功
|
||||
*/
|
||||
async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): Promise<boolean> {
|
||||
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<boolean> 是否销毁成功
|
||||
*/
|
||||
async destroySession(socketId: string): Promise<boolean> {
|
||||
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<boolean> 是否过期
|
||||
*/
|
||||
async isSessionExpired(socketId: string, timeoutMinutes: number = 30): Promise<boolean> {
|
||||
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<boolean> 是否刷新成功
|
||||
*/
|
||||
async refreshSession(socketId: string): Promise<boolean> {
|
||||
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<SessionStats> 会话统计信息
|
||||
*/
|
||||
async getSessionStats(): Promise<SessionStats> {
|
||||
try {
|
||||
// 获取所有地图的玩家列表
|
||||
const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取
|
||||
const mapDistribution: Record<string, number> = {};
|
||||
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<GameSession[]> 会话列表
|
||||
*/
|
||||
async getAllSessions(mapId?: string): Promise<GameSession[]> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user