Files
whale-town-end/src/business/chat/services/chat_session.service.ts
moyin 30a4a2813d feat(chat): 新增聊天业务模块
范围:src/business/chat/
- 实现 ChatService 聊天业务服务(登录/登出/消息发送/位置更新)
- 实现 ChatSessionService 会话管理服务(会话创建/销毁/上下文注入)
- 实现 ChatFilterService 消息过滤服务(频率限制/敏感词/权限验证)
- 实现 ChatCleanupService 会话清理服务(定时清理过期会话)
- 添加完整的单元测试覆盖
- 添加模块 README 文档
2026-01-14 19:17:32 +08:00

367 lines
12 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,
} 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),
};
}
}