范围:src/business/chat/ - 实现 ChatService 聊天业务服务(登录/登出/消息发送/位置更新) - 实现 ChatSessionService 会话管理服务(会话创建/销毁/上下文注入) - 实现 ChatFilterService 消息过滤服务(频率限制/敏感词/权限验证) - 实现 ChatCleanupService 会话清理服务(定时清理过期会话) - 添加完整的单元测试覆盖 - 添加模块 README 文档
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
/**
|
||
* 聊天会话管理服务
|
||
*
|
||
* 功能描述:
|
||
* - 维护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,
|
||
} 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;
|
||
|
||
/**
|
||
* 聊天会话管理服务类
|
||
*
|
||
* 职责:
|
||
* - 管理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 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,
|
||
): 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 },
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上下文注入:根据位置确定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 socketId WebSocket连接ID
|
||
* @param mapId 地图ID
|
||
* @param x X坐标
|
||
* @param y Y坐标
|
||
* @returns 更新是否成功
|
||
*/
|
||
async updatePlayerPosition(socketId: string, mapId: string, x: number, y: number): 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;
|
||
session.position = { x, y };
|
||
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);
|
||
|
||
// 删除用户会话映射
|
||
await this.redisService.del(`${this.USER_SESSION_PREFIX}${session.userId}`);
|
||
|
||
// 删除会话数据
|
||
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),
|
||
};
|
||
}
|
||
}
|