forked from xiangwang25/whale-town-end-v2
Initial WhaleTown V2 backend
This commit is contained in:
865
src/business/chat/services/chat_session.service.ts
Normal file
865
src/business/chat/services/chat_session.service.ts
Normal file
@@ -0,0 +1,865 @@
|
||||
/**
|
||||
* 聊天会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 维护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;
|
||||
}
|
||||
|
||||
export interface PlayerPresenceMetadata {
|
||||
appearance?: IPlayerAppearance;
|
||||
}
|
||||
|
||||
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),
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
} 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> {
|
||||
if (!socketId?.trim() || !mapId?.trim()) return false;
|
||||
|
||||
try {
|
||||
const sessionKey = `${this.SESSION_PREFIX}${socketId}`;
|
||||
const sessionData = await this.redisService.get(sessionKey);
|
||||
if (!sessionData) return false;
|
||||
|
||||
const session = this.deserializeSession(sessionData);
|
||||
const oldMapId = session.currentMap;
|
||||
const mapChanged = oldMapId !== mapId;
|
||||
|
||||
// 更新会话
|
||||
session.currentMap = mapId;
|
||||
if (!session.movementLocked || mapChanged) {
|
||||
session.position = { x, y };
|
||||
}
|
||||
session.appearance = this.mergeAppearance(session.appearance, metadata.appearance);
|
||||
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 true;
|
||||
} catch (error) {
|
||||
this.logger.error('更新位置失败', { socketId, error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁会话
|
||||
* @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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user