forked from datawhale/whale-town-end
范围:src/business/zulip/ - 统一命名规范和注释格式 - 完善JSDoc注释和参数说明 - 优化代码结构和缩进 - 清理未使用的导入和变量 - 更新修改记录和版本信息
1029 lines
30 KiB
TypeScript
1029 lines
30 KiB
TypeScript
/**
|
||
* 会话管理服务
|
||
*
|
||
* 功能描述:
|
||
* - 维护WebSocket连接ID与Zulip队列ID的映射关系
|
||
* - 管理玩家位置跟踪和上下文注入
|
||
* - 提供空间过滤和会话查询功能
|
||
* - 支持会话状态的序列化和反序列化
|
||
* - 支持服务重启后的状态恢复
|
||
*
|
||
* 职责分离:
|
||
* - 会话存储:管理会话数据在Redis中的存储和检索
|
||
* - 位置跟踪:维护玩家在游戏世界中的位置信息
|
||
* - 上下文注入:根据玩家位置确定消息的目标Stream和Topic
|
||
* - 空间过滤:根据地图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
|
||
*
|
||
* 使用场景:
|
||
* - 玩家登录时创建会话映射
|
||
* - 消息路由时进行上下文注入
|
||
* - 消息分发时进行空间过滤
|
||
* - 玩家登出时清理会话数据
|
||
*
|
||
* 最近修改:
|
||
* - 2026-01-12: 代码规范优化 - 处理TODO项,实现玩家位置确定Topic逻辑,从配置获取地图ID列表 (修改者: moyin)
|
||
* - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin)
|
||
*
|
||
* @author angjustinl
|
||
* @version 1.1.0
|
||
* @since 2025-12-25
|
||
* @lastModified 2026-01-12
|
||
*/
|
||
|
||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||
import { IRedisService } from '../../../core/redis/redis.interface';
|
||
import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces';
|
||
import { Internal, Constants } from '../../../core/zulip_core/zulip.interfaces';
|
||
|
||
// 常量定义
|
||
const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const;
|
||
const SESSION_TIMEOUT_MINUTES = 30;
|
||
const CLEANUP_INTERVAL_MINUTES = 5;
|
||
|
||
/**
|
||
* 游戏会话接口 - 重新导出以保持向后兼容
|
||
*/
|
||
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';
|
||
|
||
// 根据玩家位置确定Topic(基础实现)
|
||
// 检查是否靠近交互对象,如果没有则使用默认Topic
|
||
let topic = 'General';
|
||
|
||
// 尝试根据位置查找附近的交互对象
|
||
if (session.position) {
|
||
const nearbyObject = this.configManager.findNearbyObject(
|
||
targetMapId,
|
||
session.position.x,
|
||
session.position.y,
|
||
50 // 50像素范围内
|
||
);
|
||
|
||
if (nearbyObject) {
|
||
topic = nearbyObject.zulipTopic;
|
||
}
|
||
}
|
||
|
||
const context: ContextInfo = {
|
||
stream,
|
||
topic,
|
||
};
|
||
|
||
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 = 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) {
|
||
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 = this.configManager.getAllMapIds().length > 0
|
||
? this.configManager.getAllMapIds()
|
||
: DEFAULT_MAP_IDS;
|
||
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 = this.configManager.getAllMapIds().length > 0
|
||
? this.configManager.getAllMapIds()
|
||
: DEFAULT_MAP_IDS;
|
||
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 [];
|
||
}
|
||
}
|
||
}
|
||
|