Files
whale-town-end-v2/src/business/chat/services/chat_session.service.ts

916 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天会话管理服务
*
* 功能描述:
* - 维护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,
IPlayerAppearance,
ICafeCompanionPresence,
} 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;
/**
* 聊天好友信息
*/
export interface ChatFriendInfo {
/** 好友用户ID */
userId: string;
/** 好友用户名 */
username: string;
/** 当前是否在线 */
online: boolean;
}
/**
* 聊天好友请求信息
*/
export interface ChatFriendRequestInfo {
/** 发起请求的用户ID */
userId: string;
/** 发起请求的用户名 */
username: string;
/** 请求创建时间 */
createdAt: string;
}
/**
* 地图在线玩家信息
*/
export interface MapPlayerPresence {
/** WebSocket连接ID */
socketId: string;
/** 用户ID */
userId: string;
/** 用户名 */
username: string;
/** 当前地图ID */
mapId: string;
/** X坐标 */
x: number;
/** Y坐标 */
y: number;
/** 外观同步信息 */
appearance?: IPlayerAppearance;
/** 咖啡店陪伴服务状态 */
cafeCompanion?: ICafeCompanionPresence | null;
/** 是否锁定移动 */
movementLocked?: boolean;
/** 面向方向 */
direction?: 'down' | 'up' | 'right' | 'left';
/** 移动动画状态 */
movementState?: 'idle' | 'walk';
/** 当前连接内的移动消息序号 */
sequence?: number;
}
export interface PlayerPresenceMetadata {
appearance?: IPlayerAppearance;
direction?: 'down' | 'up' | 'right' | 'left';
movementState?: 'idle' | 'walk';
sequence?: number;
}
export interface BusinessPresenceUpdate {
mapId?: string;
position?: Position;
cafeCompanion?: ICafeCompanionPresence | null;
movementLocked?: boolean;
}
/**
* 聊天会话管理服务类
*
* 职责:
* - 管理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 FRIENDS_PREFIX = 'chat:friends:';
private readonly FRIEND_DATA_PREFIX = 'chat:friend_data:';
private readonly FRIEND_REQUESTS_PREFIX = 'chat:friend_requests:';
private readonly FRIEND_REQUEST_DATA_PREFIX = 'chat:friend_request_data:';
private readonly FRIEND_REQUEST_TIMEOUT = 7 * 24 * 60 * 60; // 7天
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,
initialAppearance?: IPlayerAppearance,
): Promise<GameSession> {
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 },
appearance: this.mergeAppearance(undefined, initialAppearance),
direction: 'down',
movementState: 'idle',
movementSequence: 0,
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<GameSession | null> {
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;
}
}
/**
* 根据用户ID获取当前在线会话的Socket ID
* @param userId 用户ID
* @returns 在线Socket ID或null
*/
async getSocketIdByUserId(userId: string): Promise<string | null> {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) return null;
try {
const userSessionKey = `${this.USER_SESSION_PREFIX}${normalizedUserId}`;
const socketId = await this.redisService.get(userSessionKey);
if (!socketId) return null;
const session = await this.getSession(socketId);
if (!session || session.userId !== normalizedUserId) {
await this.redisService.del(userSessionKey);
return null;
}
return socketId;
} catch (error) {
this.logger.error('获取用户Socket失败', { userId: normalizedUserId, error: (error as Error).message });
return null;
}
}
/**
* 添加好友
* @param userId 当前用户ID
* @param friendUserId 好友用户ID
* @param friendUsername 好友用户名
* @returns 好友信息
*/
async addFriend(userId: string, friendUserId: string, friendUsername?: string): Promise<ChatFriendInfo> {
const normalizedUserId = userId?.trim();
const normalizedFriendUserId = friendUserId?.trim();
if (!normalizedUserId || !normalizedFriendUserId) {
throw new Error('用户ID不能为空');
}
if (normalizedUserId === normalizedFriendUserId) {
throw new Error('不能添加自己为好友');
}
const friend: ChatFriendInfo = {
userId: normalizedFriendUserId,
username: friendUsername?.trim() || `user_${normalizedFriendUserId}`,
online: (await this.getSocketIdByUserId(normalizedFriendUserId)) != null,
};
await this.redisService.sadd(this.getFriendsKey(normalizedUserId), normalizedFriendUserId);
await this.redisService.set(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId), JSON.stringify({
userId: friend.userId,
username: friend.username,
}));
return friend;
}
/**
* 创建好友请求
* @param requesterUserId 请求发起人用户ID
* @param requesterUsername 请求发起人用户名
* @param targetUserId 目标用户ID
* @returns 好友请求信息
*/
async createFriendRequest(
requesterUserId: string,
requesterUsername: string,
targetUserId: string,
): Promise<ChatFriendRequestInfo> {
const normalizedRequesterUserId = requesterUserId?.trim();
const normalizedTargetUserId = targetUserId?.trim();
if (!normalizedRequesterUserId || !normalizedTargetUserId) {
throw new Error('用户ID不能为空');
}
if (normalizedRequesterUserId === normalizedTargetUserId) {
throw new Error('不能添加自己为好友');
}
if (await this.isFriend(normalizedRequesterUserId, normalizedTargetUserId)) {
throw new Error('已经是好友');
}
const request: ChatFriendRequestInfo = {
userId: normalizedRequesterUserId,
username: requesterUsername?.trim() || `user_${normalizedRequesterUserId}`,
createdAt: new Date().toISOString(),
};
const requestsKey = this.getFriendRequestsKey(normalizedTargetUserId);
const requestDataKey = this.getFriendRequestDataKey(normalizedTargetUserId, normalizedRequesterUserId);
await this.redisService.sadd(requestsKey, normalizedRequesterUserId);
await this.redisService.expire(requestsKey, this.FRIEND_REQUEST_TIMEOUT);
await this.redisService.setex(requestDataKey, this.FRIEND_REQUEST_TIMEOUT, JSON.stringify(request));
return request;
}
/**
* 接受好友请求,并建立双向好友关系
* @param userId 当前用户ID
* @param requesterUserId 请求发起人用户ID
* @param currentUsername 当前用户名
* @returns 双方好友信息
*/
async acceptFriendRequest(
userId: string,
requesterUserId: string,
currentUsername?: string,
): Promise<{ friend: ChatFriendInfo; reciprocalFriend: ChatFriendInfo }> {
const normalizedUserId = userId?.trim();
const normalizedRequesterUserId = requesterUserId?.trim();
if (!normalizedUserId || !normalizedRequesterUserId) {
throw new Error('用户ID不能为空');
}
const request = await this.getFriendRequestData(normalizedUserId, normalizedRequesterUserId);
if (!request) {
throw new Error('好友请求不存在或已过期');
}
const friend = await this.addFriend(normalizedUserId, normalizedRequesterUserId, request.username);
const reciprocalFriend = await this.addFriend(
normalizedRequesterUserId,
normalizedUserId,
currentUsername?.trim() || `user_${normalizedUserId}`,
);
await this.clearFriendRequest(normalizedUserId, normalizedRequesterUserId);
return { friend, reciprocalFriend };
}
/**
* 拒绝好友请求
* @param userId 当前用户ID
* @param requesterUserId 请求发起人用户ID
*/
async rejectFriendRequest(userId: string, requesterUserId: string): Promise<void> {
const normalizedUserId = userId?.trim();
const normalizedRequesterUserId = requesterUserId?.trim();
if (!normalizedUserId || !normalizedRequesterUserId) {
throw new Error('用户ID不能为空');
}
await this.clearFriendRequest(normalizedUserId, normalizedRequesterUserId);
}
/**
* 获取收到的好友请求
* @param userId 当前用户ID
* @returns 收到的好友请求列表
*/
async getFriendRequests(userId: string): Promise<ChatFriendRequestInfo[]> {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) return [];
const requesterIds = await this.redisService.smembers(this.getFriendRequestsKey(normalizedUserId));
const requests: ChatFriendRequestInfo[] = [];
for (const requesterUserId of requesterIds) {
const normalizedRequesterUserId = requesterUserId?.trim();
if (!normalizedRequesterUserId) continue;
const request = await this.getFriendRequestData(normalizedUserId, normalizedRequesterUserId);
if (request) {
requests.push(request);
}
}
return requests.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
/**
* 移除好友
* @param userId 当前用户ID
* @param friendUserId 好友用户ID
*/
async removeFriend(userId: string, friendUserId: string): Promise<void> {
const normalizedUserId = userId?.trim();
const normalizedFriendUserId = friendUserId?.trim();
if (!normalizedUserId || !normalizedFriendUserId) {
throw new Error('用户ID不能为空');
}
await this.redisService.srem(this.getFriendsKey(normalizedUserId), normalizedFriendUserId);
await this.redisService.del(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId));
}
/**
* 获取好友列表
* @param userId 当前用户ID
* @returns 好友列表,包含在线状态
*/
async getFriends(userId: string): Promise<ChatFriendInfo[]> {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) return [];
const friendIds = await this.redisService.smembers(this.getFriendsKey(normalizedUserId));
const friends: ChatFriendInfo[] = [];
for (const friendUserId of friendIds) {
const normalizedFriendUserId = friendUserId?.trim();
if (!normalizedFriendUserId) continue;
const data = await this.redisService.get(this.getFriendDataKey(normalizedUserId, normalizedFriendUserId));
let username = `user_${normalizedFriendUserId}`;
if (data) {
try {
const parsed = JSON.parse(data);
username = parsed.username || username;
} catch (error) {
this.logger.warn('好友数据解析失败', { userId: normalizedUserId, friendUserId: normalizedFriendUserId });
}
}
friends.push({
userId: normalizedFriendUserId,
username,
online: (await this.getSocketIdByUserId(normalizedFriendUserId)) != null,
});
}
return friends.sort((a, b) => {
if (a.online !== b.online) return a.online ? -1 : 1;
return a.username.localeCompare(b.username);
});
}
/**
* 上下文注入根据位置确定Stream/Topic
* @param socketId WebSocket连接ID
* @param mapId 地图ID可选默认使用会话当前地图
* @returns 上下文信息包含stream和topic
*/
async injectContext(socketId: string, mapId?: string): Promise<ContextInfo> {
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<string[]> {
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 mapId 地图ID
* @returns 在线玩家列表
*/
async getPlayersInMap(mapId: string): Promise<MapPlayerPresence[]> {
const normalizedMapId = mapId?.trim();
if (!normalizedMapId) return [];
const socketIds = await this.getSocketsInMap(normalizedMapId);
const players: MapPlayerPresence[] = [];
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}${normalizedMapId}`, socketId);
continue;
}
const session = this.deserializeSession(sessionData);
if (session.currentMap !== normalizedMapId) {
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${normalizedMapId}`, socketId);
continue;
}
const activeSocketId = await this.redisService.get(`${this.USER_SESSION_PREFIX}${session.userId}`);
if (activeSocketId !== socketId) {
await this.redisService.srem(`${this.MAP_PLAYERS_PREFIX}${normalizedMapId}`, socketId);
await this.redisService.del(sessionKey);
continue;
}
players.push({
socketId: session.socketId,
userId: session.userId,
username: session.username,
mapId: session.currentMap,
x: Number(session.position?.x ?? 0),
y: Number(session.position?.y ?? 0),
appearance: session.appearance,
cafeCompanion: session.cafeCompanion ?? null,
movementLocked: Boolean(session.movementLocked),
direction: session.direction || 'down',
movementState: session.movementState || 'idle',
sequence: Number(session.movementSequence ?? 0),
});
}
return players;
}
async updateBusinessPresenceByUserId(
userId: string,
update: BusinessPresenceUpdate,
): Promise<MapPlayerPresence | null> {
const normalizedUserId = userId?.trim();
if (!normalizedUserId) return null;
const socketId = await this.getSocketIdByUserId(normalizedUserId);
if (!socketId) return null;
return this.updateBusinessPresence(socketId, update);
}
async updateBusinessPresence(
socketId: string,
update: BusinessPresenceUpdate,
): Promise<MapPlayerPresence | null> {
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);
const oldMapId = session.currentMap;
const nextMapId = update.mapId?.trim() || session.currentMap;
const mapChanged = oldMapId !== nextMapId;
session.currentMap = nextMapId;
if (update.position) {
session.position = { x: update.position.x, y: update.position.y };
}
if (update.cafeCompanion !== undefined) {
session.cafeCompanion = update.cafeCompanion;
}
if (update.movementLocked !== undefined) {
session.movementLocked = update.movementLocked;
}
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}${nextMapId}`;
await this.redisService.sadd(newMapKey, socketId);
await this.redisService.expire(newMapKey, SESSION_TIMEOUT);
}
return {
socketId: session.socketId,
userId: session.userId,
username: session.username,
mapId: session.currentMap,
x: Number(session.position?.x ?? 0),
y: Number(session.position?.y ?? 0),
appearance: session.appearance,
cafeCompanion: session.cafeCompanion ?? null,
movementLocked: Boolean(session.movementLocked),
direction: session.direction || 'down',
movementState: session.movementState || 'idle',
sequence: Number(session.movementSequence ?? 0),
};
} catch (error) {
this.logger.error('更新玩家业务状态失败', { socketId, error: (error as Error).message });
return null;
}
}
/**
* 更新玩家位置
* @param socketId WebSocket连接ID
* @param mapId 地图ID
* @param x X坐标
* @param y Y坐标
* @returns 更新是否成功
*/
async updatePlayerPosition(
socketId: string,
mapId: string,
x: number,
y: number,
metadata: PlayerPresenceMetadata = {},
): Promise<boolean> {
return (await this.updatePlayerPositionWithPresence(socketId, mapId, x, y, metadata)) !== null;
}
async updatePlayerPositionWithPresence(
socketId: string,
mapId: string,
x: number,
y: number,
metadata: PlayerPresenceMetadata = {},
): Promise<MapPlayerPresence | null> {
if (!socketId?.trim() || !mapId?.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);
const oldMapId = session.currentMap;
const mapChanged = oldMapId !== mapId;
// 更新会话
session.currentMap = mapId;
if (!session.movementLocked || mapChanged) {
session.position = { x, y };
}
session.appearance = this.mergeAppearance(session.appearance, metadata.appearance);
if (metadata.direction !== undefined) {
session.direction = metadata.direction;
}
if (metadata.movementState !== undefined) {
session.movementState = metadata.movementState;
}
if (metadata.sequence !== undefined) {
session.movementSequence = metadata.sequence;
}
if (mapId !== 'whale_cafe') {
session.cafeCompanion = null;
session.movementLocked = false;
}
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 {
socketId: session.socketId,
userId: session.userId,
username: session.username,
mapId: session.currentMap,
x: Number(session.position?.x ?? 0),
y: Number(session.position?.y ?? 0),
appearance: session.appearance,
cafeCompanion: session.cafeCompanion ?? null,
movementLocked: Boolean(session.movementLocked),
direction: session.direction || 'down',
movementState: session.movementState || 'idle',
sequence: Number(session.movementSequence ?? 0),
};
} catch (error) {
this.logger.error('更新位置失败', { socketId, error: (error as Error).message });
return null;
}
}
/**
* 销毁会话
* @param socketId WebSocket连接ID
* @returns 销毁是否成功
*/
async destroySession(socketId: string): Promise<boolean> {
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);
// 旧连接的延迟清理不能删除同账号的新会话映射。
const userSessionKey = `${this.USER_SESSION_PREFIX}${session.userId}`;
const activeSocketId = await this.redisService.get(userSessionKey);
if (activeSocketId === socketId) {
await this.redisService.del(userSessionKey);
}
// 删除会话数据
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),
};
}
private mergeAppearance(
current: IPlayerAppearance | undefined,
next: IPlayerAppearance | undefined,
): IPlayerAppearance | undefined {
if (!next) return current;
const appearance: IPlayerAppearance = { ...(current ?? {}) };
if (next.skinId !== undefined) {
appearance.skinId = next.skinId;
if (next.skinAsset === undefined && current?.skinId !== next.skinId) {
delete appearance.skinAsset;
}
}
if (next.avatarId !== undefined) {
appearance.avatarId = next.avatarId;
}
if (next.skinAsset !== undefined) {
appearance.skinAsset = next.skinAsset;
}
return appearance;
}
private getFriendsKey(userId: string): string {
return `${this.FRIENDS_PREFIX}${userId}`;
}
private getFriendDataKey(userId: string, friendUserId: string): string {
return `${this.FRIEND_DATA_PREFIX}${userId}:${friendUserId}`;
}
private getFriendRequestsKey(userId: string): string {
return `${this.FRIEND_REQUESTS_PREFIX}${userId}`;
}
private getFriendRequestDataKey(userId: string, requesterUserId: string): string {
return `${this.FRIEND_REQUEST_DATA_PREFIX}${userId}:${requesterUserId}`;
}
private async isFriend(userId: string, friendUserId: string): Promise<boolean> {
const friendIds = await this.redisService.smembers(this.getFriendsKey(userId));
return friendIds.includes(friendUserId);
}
private async getFriendRequestData(userId: string, requesterUserId: string): Promise<ChatFriendRequestInfo | null> {
const data = await this.redisService.get(this.getFriendRequestDataKey(userId, requesterUserId));
if (!data) return null;
try {
const parsed = JSON.parse(data);
return {
userId: parsed.userId || requesterUserId,
username: parsed.username || `user_${requesterUserId}`,
createdAt: parsed.createdAt || new Date().toISOString(),
};
} catch (error) {
this.logger.warn('好友请求数据解析失败', { userId, requesterUserId });
return {
userId: requesterUserId,
username: `user_${requesterUserId}`,
createdAt: new Date().toISOString(),
};
}
}
private async clearFriendRequest(userId: string, requesterUserId: string): Promise<void> {
await this.redisService.srem(this.getFriendRequestsKey(userId), requesterUserId);
await this.redisService.del(this.getFriendRequestDataKey(userId, requesterUserId));
}
}