/** * 聊天会话管理服务 * * 功能描述: * - 维护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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { return (await this.updatePlayerPositionWithPresence(socketId, mapId, x, y, metadata)) !== null; } async updatePlayerPositionWithPresence( socketId: string, mapId: string, x: number, y: number, metadata: PlayerPresenceMetadata = {}, ): Promise { 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 { 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 { const friendIds = await this.redisService.smembers(this.getFriendsKey(userId)); return friendIds.includes(friendUserId); } private async getFriendRequestData(userId: string, requesterUserId: string): Promise { 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 { await this.redisService.srem(this.getFriendRequestsKey(userId), requesterUserId); await this.redisService.del(this.getFriendRequestDataKey(userId, requesterUserId)); } }