feature/gateway-module-integration-20260115 #48
128
src/business/chat/README.md
Normal file
128
src/business/chat/README.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Chat 聊天业务模块
|
||||
|
||||
Chat 模块是游戏服务器的核心聊天业务层,负责实现游戏内实时聊天功能,包括玩家会话管理、消息过滤、位置追踪和 Zulip 异步同步。该模块通过 SESSION_QUERY_SERVICE 接口向其他业务模块提供会话查询能力。
|
||||
|
||||
## 对外提供的接口
|
||||
|
||||
### ChatService
|
||||
|
||||
#### handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse>
|
||||
处理玩家登录,验证 Token 并创建游戏会话。
|
||||
|
||||
#### handlePlayerLogout(socketId: string, reason?: string): Promise<void>
|
||||
处理玩家登出,清理会话和相关资源。
|
||||
|
||||
#### sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse>
|
||||
发送聊天消息,包含内容过滤、实时广播和 Zulip 异步同步。
|
||||
|
||||
#### updatePlayerPosition(request: PositionUpdateRequest): Promise<boolean>
|
||||
更新玩家在游戏地图中的位置。
|
||||
|
||||
#### getChatHistory(query: object): Promise<object>
|
||||
获取聊天历史记录。
|
||||
|
||||
#### getSession(socketId: string): Promise<GameSession | null>
|
||||
获取指定 WebSocket 连接的会话信息。
|
||||
|
||||
### ChatSessionService (实现 ISessionManagerService)
|
||||
|
||||
#### createSession(socketId, userId, zulipQueueId, username?, initialMap?, initialPosition?): Promise<GameSession>
|
||||
创建新的游戏会话,建立 WebSocket 与用户的映射关系。
|
||||
|
||||
#### getSession(socketId: string): Promise<GameSession | null>
|
||||
获取会话信息并更新最后活动时间。
|
||||
|
||||
#### destroySession(socketId: string): Promise<boolean>
|
||||
销毁会话并清理相关资源。
|
||||
|
||||
#### injectContext(socketId: string, mapId?: string): Promise<ContextInfo>
|
||||
根据玩家位置注入聊天上下文(Stream/Topic)。
|
||||
|
||||
#### updatePlayerPosition(socketId, mapId, x, y): Promise<boolean>
|
||||
更新玩家位置,支持跨地图切换。
|
||||
|
||||
#### getSocketsInMap(mapId: string): Promise<string[]>
|
||||
获取指定地图中的所有在线玩家 Socket。
|
||||
|
||||
#### cleanupExpiredSessions(timeoutMinutes?: number): Promise<object>
|
||||
清理过期会话,返回清理数量和 Zulip 队列 ID 列表。
|
||||
|
||||
### ChatFilterService
|
||||
|
||||
#### validateMessage(userId, content, targetStream, currentMap): Promise<object>
|
||||
综合验证消息,包含频率限制、内容过滤和权限验证。
|
||||
|
||||
#### filterContent(content: string): Promise<ContentFilterResult>
|
||||
过滤消息内容,检测敏感词、重复字符和恶意链接。
|
||||
|
||||
#### checkRateLimit(userId: string): Promise<boolean>
|
||||
检查用户发送消息的频率是否超限。
|
||||
|
||||
#### validatePermission(userId, targetStream, currentMap): Promise<boolean>
|
||||
验证用户是否有权限向目标频道发送消息。
|
||||
|
||||
### ChatCleanupService
|
||||
|
||||
#### triggerCleanup(): Promise<{ cleanedCount: number }>
|
||||
手动触发会话清理,返回清理的会话数量。
|
||||
|
||||
## 使用的项目内部依赖
|
||||
|
||||
### IZulipClientPoolService (来自 core/zulip_core)
|
||||
Zulip 客户端连接池服务,用于创建/销毁用户客户端和发送消息。
|
||||
|
||||
### IApiKeySecurityService (来自 core/zulip_core)
|
||||
API Key 安全服务,用于获取和删除用户的 Zulip API Key。
|
||||
|
||||
### IZulipConfigService (来自 core/zulip_core)
|
||||
Zulip 配置服务,提供地图与 Stream 的映射关系和附近对象查询。
|
||||
|
||||
### IRedisService (来自 core/redis)
|
||||
Redis 缓存服务,用于存储会话数据、地图玩家列表和频率限制计数。
|
||||
|
||||
### LoginCoreService (来自 core/login_core)
|
||||
登录核心服务,用于验证 JWT Token。
|
||||
|
||||
### ISessionManagerService (来自 core/session_core)
|
||||
会话管理接口定义,ChatSessionService 实现此接口供其他模块依赖。
|
||||
|
||||
## 核心特性
|
||||
|
||||
### 实时聊天 + 异步同步架构
|
||||
- 🚀 游戏内实时广播:消息直接广播给同地图玩家,延迟极低
|
||||
- 🔄 Zulip 异步同步:消息异步存储到 Zulip,保证持久化
|
||||
- ⚡ 低延迟体验:先广播后同步,不阻塞用户操作
|
||||
|
||||
### 基于位置的聊天上下文
|
||||
- 根据玩家当前地图自动确定 Zulip Stream
|
||||
- 根据玩家位置附近的对象自动确定 Topic
|
||||
- 支持跨地图切换时自动更新聊天频道
|
||||
|
||||
### 会话生命周期管理
|
||||
- 自动清理旧会话,防止重复登录
|
||||
- 定时清理过期会话(默认 30 分钟无活动)
|
||||
- 支持手动触发清理操作
|
||||
|
||||
### 内容安全和频率控制
|
||||
- 敏感词过滤(支持替换和阻止两种模式)
|
||||
- 频率限制(默认 60 秒内最多 10 条消息)
|
||||
- 恶意链接检测和黑名单域名过滤
|
||||
- 重复字符和刷屏检测
|
||||
|
||||
## 潜在风险
|
||||
|
||||
### Redis 连接故障风险
|
||||
- 会话数据存储在 Redis,连接故障会导致会话丢失
|
||||
- 缓解措施:Redis 集群部署、连接重试机制
|
||||
|
||||
### Zulip 同步延迟风险
|
||||
- 异步同步可能导致消息在 Zulip 中延迟出现
|
||||
- 缓解措施:消息队列、重试机制、失败告警
|
||||
|
||||
### 高并发广播性能风险
|
||||
- 同一地图玩家过多时广播性能下降
|
||||
- 缓解措施:分片广播、消息合并、限制单地图人数
|
||||
|
||||
### 会话清理遗漏风险
|
||||
- 定时清理可能遗漏部分过期会话
|
||||
- 缓解措施:多次清理、Redis 过期策略配合
|
||||
216
src/business/chat/chat.module.spec.ts
Normal file
216
src/business/chat/chat.module.spec.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 聊天业务模块测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 模块配置验证
|
||||
* - 服务提供者注册
|
||||
* - 接口导出验证
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { ChatCleanupService } from './services/chat_cleanup.service';
|
||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
describe('ChatModule', () => {
|
||||
let module: TestingModule;
|
||||
let chatService: ChatService;
|
||||
let sessionService: ChatSessionService;
|
||||
let filterService: ChatFilterService;
|
||||
let cleanupService: ChatCleanupService;
|
||||
|
||||
// Mock依赖
|
||||
const mockZulipClientPool = {
|
||||
createUserClient: jest.fn(),
|
||||
destroyUserClient: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipConfigService = {
|
||||
getStreamByMap: jest.fn().mockReturnValue('Test Stream'),
|
||||
findNearbyObject: jest.fn().mockReturnValue(null),
|
||||
getAllMapIds: jest.fn().mockReturnValue(['novice_village', 'whale_port']),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
getApiKey: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
srem: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
incr: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLoginCoreService = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatService,
|
||||
ChatSessionService,
|
||||
ChatFilterService,
|
||||
ChatCleanupService,
|
||||
{
|
||||
provide: SESSION_QUERY_SERVICE,
|
||||
useExisting: ChatSessionService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||
useValue: mockZulipClientPool,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockZulipConfigService,
|
||||
},
|
||||
{
|
||||
provide: 'API_KEY_SECURITY_SERVICE',
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
chatService = module.get<ChatService>(ChatService);
|
||||
sessionService = module.get<ChatSessionService>(ChatSessionService);
|
||||
filterService = module.get<ChatFilterService>(ChatFilterService);
|
||||
cleanupService = module.get<ChatCleanupService>(ChatCleanupService);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('模块配置', () => {
|
||||
it('应该成功编译模块', () => {
|
||||
expect(module).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该提供 ChatService', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
expect(chatService).toBeInstanceOf(ChatService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatSessionService', () => {
|
||||
expect(sessionService).toBeDefined();
|
||||
expect(sessionService).toBeInstanceOf(ChatSessionService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatFilterService', () => {
|
||||
expect(filterService).toBeDefined();
|
||||
expect(filterService).toBeInstanceOf(ChatFilterService);
|
||||
});
|
||||
|
||||
it('应该提供 ChatCleanupService', () => {
|
||||
expect(cleanupService).toBeDefined();
|
||||
expect(cleanupService).toBeInstanceOf(ChatCleanupService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('接口导出', () => {
|
||||
it('应该导出 SESSION_QUERY_SERVICE 接口', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBeDefined();
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该指向 ChatSessionService', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBe(sessionService);
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该实现 ISessionManagerService 接口', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(typeof queryService.createSession).toBe('function');
|
||||
expect(typeof queryService.getSession).toBe('function');
|
||||
expect(typeof queryService.destroySession).toBe('function');
|
||||
expect(typeof queryService.injectContext).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务依赖注入', () => {
|
||||
it('ChatService 应该能够获取所有依赖', () => {
|
||||
expect(chatService).toBeDefined();
|
||||
// 验证私有依赖通过检查服务是否正常工作
|
||||
expect(chatService['sessionService']).toBeDefined();
|
||||
expect(chatService['filterService']).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatSessionService 应该能够获取所有依赖', () => {
|
||||
expect(sessionService).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatFilterService 应该能够获取所有依赖', () => {
|
||||
expect(filterService).toBeDefined();
|
||||
});
|
||||
|
||||
it('ChatCleanupService 应该能够获取所有依赖', () => {
|
||||
expect(cleanupService).toBeDefined();
|
||||
expect(cleanupService['sessionService']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('服务协作', () => {
|
||||
it('ChatService 应该能够调用 ChatSessionService', async () => {
|
||||
mockRedisService.get.mockResolvedValue(null);
|
||||
const session = await chatService.getSession('test_socket');
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('ChatCleanupService 应该能够调用 ChatSessionService', async () => {
|
||||
mockRedisService.smembers.mockResolvedValue([]);
|
||||
const result = await cleanupService.triggerCleanup();
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('模块导出验证', () => {
|
||||
it('所有导出的服务应该可用', () => {
|
||||
// ChatModule 导出的服务
|
||||
expect(chatService).toBeDefined();
|
||||
expect(sessionService).toBeDefined();
|
||||
expect(filterService).toBeDefined();
|
||||
expect(cleanupService).toBeDefined();
|
||||
});
|
||||
|
||||
it('SESSION_QUERY_SERVICE 应该可供其他模块使用', () => {
|
||||
const queryService = module.get(SESSION_QUERY_SERVICE);
|
||||
expect(queryService).toBeDefined();
|
||||
// 验证接口方法存在
|
||||
expect(queryService.createSession).toBeDefined();
|
||||
expect(queryService.getSession).toBeDefined();
|
||||
expect(queryService.destroySession).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
71
src/business/chat/chat.module.ts
Normal file
71
src/business/chat/chat.module.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 聊天业务模块
|
||||
*
|
||||
* 功能描述:
|
||||
* - 整合聊天相关的业务逻辑服务
|
||||
* - 提供会话管理、消息过滤、清理等功能
|
||||
* - 通过 SESSION_QUERY_SERVICE 接口向其他模块提供会话查询能力
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 依赖关系:
|
||||
* - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务
|
||||
* - 依赖 RedisModule(核心层)提供缓存服务
|
||||
* - 依赖 LoginCoreModule(核心层)提供Token验证
|
||||
*
|
||||
* 导出接口:
|
||||
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.1.1
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { ChatCleanupService } from './services/chat_cleanup.service';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { RedisModule } from '../../core/redis/redis.module';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Zulip核心服务模块
|
||||
ZulipCoreModule,
|
||||
// Redis缓存模块
|
||||
RedisModule,
|
||||
// 登录核心模块
|
||||
LoginCoreModule,
|
||||
],
|
||||
providers: [
|
||||
// 主聊天服务
|
||||
ChatService,
|
||||
// 会话管理服务
|
||||
ChatSessionService,
|
||||
// 消息过滤服务
|
||||
ChatFilterService,
|
||||
// 会话清理服务
|
||||
ChatCleanupService,
|
||||
// 会话查询接口(供其他模块依赖)
|
||||
{
|
||||
provide: SESSION_QUERY_SERVICE,
|
||||
useExisting: ChatSessionService,
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
ChatService,
|
||||
ChatSessionService,
|
||||
ChatFilterService,
|
||||
ChatCleanupService,
|
||||
// 导出会话查询接口
|
||||
SESSION_QUERY_SERVICE,
|
||||
],
|
||||
})
|
||||
export class ChatModule {}
|
||||
437
src/business/chat/chat.service.spec.ts
Normal file
437
src/business/chat/chat.service.spec.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
/**
|
||||
* 聊天业务服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 玩家登录/登出流程
|
||||
* - 聊天消息发送和广播
|
||||
* - 位置更新和会话管理
|
||||
* - Token验证和错误处理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatService } from './chat.service';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
describe('ChatService', () => {
|
||||
let service: ChatService;
|
||||
let sessionService: jest.Mocked<ChatSessionService>;
|
||||
let filterService: jest.Mocked<ChatFilterService>;
|
||||
let zulipClientPool: any;
|
||||
let apiKeySecurityService: any;
|
||||
let loginCoreService: jest.Mocked<LoginCoreService>;
|
||||
let mockWebSocketGateway: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock依赖
|
||||
const mockSessionService = {
|
||||
createSession: jest.fn(),
|
||||
getSession: jest.fn(),
|
||||
destroySession: jest.fn(),
|
||||
updatePlayerPosition: jest.fn(),
|
||||
injectContext: jest.fn(),
|
||||
getSocketsInMap: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFilterService = {
|
||||
validateMessage: jest.fn(),
|
||||
filterContent: jest.fn(),
|
||||
checkRateLimit: jest.fn(),
|
||||
validatePermission: jest.fn(),
|
||||
};
|
||||
|
||||
const mockZulipClientPool = {
|
||||
createUserClient: jest.fn(),
|
||||
destroyUserClient: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
};
|
||||
|
||||
const mockApiKeySecurityService = {
|
||||
getApiKey: jest.fn(),
|
||||
deleteApiKey: jest.fn(),
|
||||
};
|
||||
|
||||
const mockLoginCoreService = {
|
||||
verifyToken: jest.fn(),
|
||||
};
|
||||
|
||||
mockWebSocketGateway = {
|
||||
broadcastToMap: jest.fn(),
|
||||
sendToPlayer: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatService,
|
||||
{
|
||||
provide: ChatSessionService,
|
||||
useValue: mockSessionService,
|
||||
},
|
||||
{
|
||||
provide: ChatFilterService,
|
||||
useValue: mockFilterService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CLIENT_POOL_SERVICE',
|
||||
useValue: mockZulipClientPool,
|
||||
},
|
||||
{
|
||||
provide: 'API_KEY_SECURITY_SERVICE',
|
||||
useValue: mockApiKeySecurityService,
|
||||
},
|
||||
{
|
||||
provide: LoginCoreService,
|
||||
useValue: mockLoginCoreService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatService>(ChatService);
|
||||
sessionService = module.get(ChatSessionService);
|
||||
filterService = module.get(ChatFilterService);
|
||||
zulipClientPool = module.get('ZULIP_CLIENT_POOL_SERVICE');
|
||||
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
|
||||
loginCoreService = module.get(LoginCoreService);
|
||||
|
||||
// 设置WebSocket网关
|
||||
service.setWebSocketGateway(mockWebSocketGateway);
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该成功设置WebSocket网关', () => {
|
||||
const newGateway = { broadcastToMap: jest.fn(), sendToPlayer: jest.fn() };
|
||||
service.setWebSocketGateway(newGateway);
|
||||
expect(service['websocketGateway']).toBe(newGateway);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePlayerLogin', () => {
|
||||
const validToken = 'valid.jwt.token';
|
||||
const socketId = 'socket_123';
|
||||
|
||||
it('应该成功处理玩家登录', async () => {
|
||||
const userInfo = {
|
||||
sub: 'user_123',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
role: 1,
|
||||
type: 'access' as 'access' | 'refresh',
|
||||
};
|
||||
|
||||
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
||||
sessionService.createSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId: userInfo.sub,
|
||||
username: userInfo.username,
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.userId).toBe(userInfo.sub);
|
||||
expect(result.username).toBe(userInfo.username);
|
||||
expect(loginCoreService.verifyToken).toHaveBeenCalledWith(validToken, 'access');
|
||||
expect(sessionService.createSession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空Token', async () => {
|
||||
const result = await service.handlePlayerLogin({ token: '', socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token或socketId不能为空');
|
||||
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId: '' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token或socketId不能为空');
|
||||
});
|
||||
|
||||
it('应该处理Token验证失败', async () => {
|
||||
loginCoreService.verifyToken.mockResolvedValue(null);
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token验证失败');
|
||||
});
|
||||
|
||||
it('应该处理Token验证异常', async () => {
|
||||
loginCoreService.verifyToken.mockRejectedValue(new Error('Token expired'));
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('Token验证失败');
|
||||
});
|
||||
|
||||
it('应该处理会话创建失败', async () => {
|
||||
const userInfo = { sub: 'user_123', username: 'testuser', email: 'test@example.com', role: 1, type: 'access' as 'access' | 'refresh' };
|
||||
loginCoreService.verifyToken.mockResolvedValue(userInfo);
|
||||
sessionService.createSession.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.handlePlayerLogin({ token: validToken, socketId });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('登录失败,请稍后重试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePlayerLogout', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
|
||||
it('应该成功处理玩家登出', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
|
||||
apiKeySecurityService.deleteApiKey.mockResolvedValue(undefined);
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId, 'manual');
|
||||
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
|
||||
expect(apiKeySecurityService.deleteApiKey).toHaveBeenCalledWith(userId);
|
||||
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在的情况', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理Zulip客户端清理失败', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
zulipClientPool.destroyUserClient.mockRejectedValue(new Error('Zulip error'));
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理API Key清理失败', async () => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
apiKeySecurityService.deleteApiKey.mockRejectedValue(new Error('Redis error'));
|
||||
sessionService.destroySession.mockResolvedValue(true);
|
||||
|
||||
await service.handlePlayerLogout(socketId);
|
||||
|
||||
expect(sessionService.destroySession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendChatMessage', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const content = 'Hello, world!';
|
||||
|
||||
beforeEach(() => {
|
||||
sessionService.getSession.mockResolvedValue({
|
||||
socketId,
|
||||
userId,
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
});
|
||||
sessionService.injectContext.mockResolvedValue({
|
||||
stream: 'Whale Port',
|
||||
topic: 'General',
|
||||
});
|
||||
filterService.validateMessage.mockResolvedValue({
|
||||
allowed: true,
|
||||
filteredContent: content,
|
||||
});
|
||||
sessionService.getSocketsInMap.mockResolvedValue([socketId, 'socket_456']);
|
||||
apiKeySecurityService.getApiKey.mockResolvedValue({
|
||||
success: true,
|
||||
apiKey: 'test_api_key',
|
||||
});
|
||||
});
|
||||
|
||||
it('应该成功发送聊天消息', async () => {
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messageId).toBeDefined();
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
expect(filterService.validateMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝不存在的会话', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('会话不存在,请重新登录');
|
||||
});
|
||||
|
||||
it('应该拒绝被过滤的消息', async () => {
|
||||
filterService.validateMessage.mockResolvedValue({
|
||||
allowed: false,
|
||||
reason: '消息包含敏感词',
|
||||
});
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('消息包含敏感词');
|
||||
});
|
||||
|
||||
it('应该处理消息发送异常', async () => {
|
||||
sessionService.getSession.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.sendChatMessage({ socketId, content, scope: 'local' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('消息发送失败,请稍后重试');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlayerPosition', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mapId = 'whale_port';
|
||||
const x = 500;
|
||||
const y = 400;
|
||||
|
||||
it('应该成功更新玩家位置', async () => {
|
||||
sessionService.updatePlayerPosition.mockResolvedValue(true);
|
||||
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(sessionService.updatePlayerPosition).toHaveBeenCalledWith(socketId, mapId, x, y);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.updatePlayerPosition({ socketId: '', mapId, x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空mapId', async () => {
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId: '', x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(sessionService.updatePlayerPosition).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理更新失败', async () => {
|
||||
sessionService.updatePlayerPosition.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.updatePlayerPosition({ socketId, mapId, x, y });
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChatHistory', () => {
|
||||
it('应该返回聊天历史', async () => {
|
||||
const result = await service.getChatHistory({ mapId: 'whale_port' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.messages).toBeDefined();
|
||||
expect(Array.isArray(result.messages)).toBe(true);
|
||||
});
|
||||
|
||||
it('应该支持分页查询', async () => {
|
||||
const result = await service.getChatHistory({ mapId: 'whale_port', limit: 10, offset: 0 });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.count).toBeLessThanOrEqual(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
|
||||
it('应该返回会话信息', async () => {
|
||||
const mockSession = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
sessionService.getSession.mockResolvedValue(mockSession);
|
||||
|
||||
const result = await service.getSession(socketId);
|
||||
|
||||
expect(result).toEqual(mockSession);
|
||||
expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
sessionService.getSession.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getSession(socketId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
500
src/business/chat/chat.service.ts
Normal file
500
src/business/chat/chat.service.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* 聊天业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 实现聊天相关的业务逻辑
|
||||
* - 协调会话管理、消息过滤等子服务
|
||||
* - 实现游戏内实时聊天 + Zulip 异步同步
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 核心优化:
|
||||
* - 🚀 游戏内实时广播:后端直接广播给同区域用户
|
||||
* - 🔄 Zulip异步同步:消息异步存储到Zulip
|
||||
* - ⚡ 低延迟聊天体验
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.4
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { ChatSessionService } from './services/chat_session.service';
|
||||
import { ChatFilterService } from './services/chat_filter.service';
|
||||
import {
|
||||
IZulipClientPoolService,
|
||||
IApiKeySecurityService,
|
||||
} from '../../core/zulip_core/zulip_core.interfaces';
|
||||
import { LoginCoreService } from '../../core/login_core/login_core.service';
|
||||
|
||||
// ========== 接口定义 ==========
|
||||
|
||||
/**
|
||||
* 聊天消息请求接口
|
||||
*/
|
||||
export interface ChatMessageRequest {
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
/** 消息内容 */
|
||||
content: string;
|
||||
/** 消息范围:local(本地)、global(全局) */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息响应接口
|
||||
*/
|
||||
export interface ChatMessageResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 消息ID(成功时返回) */
|
||||
messageId?: string;
|
||||
/** 错误信息(失败时返回) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 玩家登录请求接口
|
||||
*/
|
||||
export interface PlayerLoginRequest {
|
||||
/** 认证Token */
|
||||
token: string;
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录响应接口
|
||||
*/
|
||||
export interface LoginResponse {
|
||||
/** 是否成功 */
|
||||
success: boolean;
|
||||
/** 会话ID(成功时返回) */
|
||||
sessionId?: string;
|
||||
/** 用户ID(成功时返回) */
|
||||
userId?: string;
|
||||
/** 用户名(成功时返回) */
|
||||
username?: string;
|
||||
/** 当前地图ID(成功时返回) */
|
||||
currentMap?: string;
|
||||
/** 错误信息(失败时返回) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 位置更新请求接口
|
||||
*/
|
||||
export interface PositionUpdateRequest {
|
||||
/** WebSocket连接ID */
|
||||
socketId: string;
|
||||
/** X坐标 */
|
||||
x: number;
|
||||
/** Y坐标 */
|
||||
y: number;
|
||||
/** 地图ID */
|
||||
mapId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 游戏聊天消息格式(用于WebSocket广播)
|
||||
*/
|
||||
interface GameChatMessage {
|
||||
/** 消息类型标识 */
|
||||
t: 'chat_render';
|
||||
/** 发送者用户名 */
|
||||
from: string;
|
||||
/** 消息文本内容 */
|
||||
txt: string;
|
||||
/** 是否显示气泡 */
|
||||
bubble: boolean;
|
||||
/** 时间戳(ISO格式) */
|
||||
timestamp: string;
|
||||
/** 消息ID */
|
||||
messageId: string;
|
||||
/** 地图ID */
|
||||
mapId: string;
|
||||
/** 消息范围 */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天WebSocket网关接口
|
||||
*/
|
||||
interface IChatWebSocketGateway {
|
||||
/**
|
||||
* 向指定地图广播消息
|
||||
* @param mapId 地图ID
|
||||
* @param data 广播数据
|
||||
* @param excludeId 排除的socketId(可选)
|
||||
*/
|
||||
broadcastToMap(mapId: string, data: any, excludeId?: string): void;
|
||||
/**
|
||||
* 向指定玩家发送消息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param data 发送数据
|
||||
*/
|
||||
sendToPlayer(socketId: string, data: any): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天业务服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 处理玩家登录/登出的会话管理
|
||||
* - 协调消息过滤和验证流程
|
||||
* - 实现游戏内实时广播和Zulip异步同步
|
||||
*
|
||||
* 主要方法:
|
||||
* - handlePlayerLogin() - 处理玩家登录认证和会话创建
|
||||
* - handlePlayerLogout() - 处理玩家登出和资源清理
|
||||
* - sendChatMessage() - 发送聊天消息并广播
|
||||
* - updatePlayerPosition() - 更新玩家位置信息
|
||||
*
|
||||
* 使用场景:
|
||||
* - 游戏客户端通过WebSocket连接后的聊天功能
|
||||
* - 需要实时广播和持久化存储的聊天场景
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatService {
|
||||
private readonly logger = new Logger(ChatService.name);
|
||||
private readonly DEFAULT_MAP = 'whale_port';
|
||||
private readonly DEFAULT_POSITION = { x: 400, y: 300 };
|
||||
private readonly DEFAULT_PAGE_SIZE = 50;
|
||||
private readonly HISTORY_TIME_OFFSET_MS = 3600000; // 1小时
|
||||
private websocketGateway: IChatWebSocketGateway;
|
||||
|
||||
constructor(
|
||||
@Inject('ZULIP_CLIENT_POOL_SERVICE')
|
||||
private readonly zulipClientPool: IZulipClientPoolService,
|
||||
private readonly sessionService: ChatSessionService,
|
||||
private readonly filterService: ChatFilterService,
|
||||
@Inject('API_KEY_SECURITY_SERVICE')
|
||||
private readonly apiKeySecurityService: IApiKeySecurityService,
|
||||
private readonly loginCoreService: LoginCoreService,
|
||||
) {
|
||||
this.logger.log('ChatService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置WebSocket网关引用
|
||||
* @param gateway WebSocket网关实例
|
||||
*/
|
||||
setWebSocketGateway(gateway: IChatWebSocketGateway): void {
|
||||
this.websocketGateway = gateway;
|
||||
this.logger.log('WebSocket网关引用设置完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家登录
|
||||
* @param request 登录请求,包含token和socketId
|
||||
* @returns 登录响应,包含会话信息或错误信息
|
||||
*/
|
||||
async handlePlayerLogin(request: PlayerLoginRequest): Promise<LoginResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始处理玩家登录', {
|
||||
operation: 'handlePlayerLogin',
|
||||
socketId: request.socketId,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 验证参数
|
||||
if (!request.token?.trim() || !request.socketId?.trim()) {
|
||||
return { success: false, error: 'Token或socketId不能为空' };
|
||||
}
|
||||
|
||||
// 2. 验证Token
|
||||
const userInfo = await this.validateGameToken(request.token);
|
||||
if (!userInfo) {
|
||||
return { success: false, error: 'Token验证失败' };
|
||||
}
|
||||
|
||||
// 3. 创建会话
|
||||
const sessionResult = await this.createUserSession(request.socketId, userInfo);
|
||||
|
||||
this.logger.log('玩家登录成功', {
|
||||
operation: 'handlePlayerLogin',
|
||||
socketId: request.socketId,
|
||||
userId: userInfo.userId,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: sessionResult.sessionId,
|
||||
userId: userInfo.userId,
|
||||
username: userInfo.username,
|
||||
currentMap: sessionResult.currentMap,
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('玩家登录失败', { error: err.message });
|
||||
return { success: false, error: '登录失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理玩家登出
|
||||
* @param socketId WebSocket连接ID
|
||||
* @param reason 登出原因:manual(手动)、timeout(超时)、disconnect(断开)
|
||||
*/
|
||||
async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise<void> {
|
||||
this.logger.log('开始处理玩家登出', { socketId, reason });
|
||||
|
||||
try {
|
||||
const session = await this.sessionService.getSession(socketId);
|
||||
if (!session) return;
|
||||
|
||||
const userId = session.userId;
|
||||
|
||||
// 清理Zulip客户端
|
||||
if (userId) {
|
||||
try {
|
||||
await this.zulipClientPool.destroyUserClient(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn('Zulip客户端清理失败', { error: (e as Error).message });
|
||||
}
|
||||
|
||||
// 清理API Key缓存
|
||||
try {
|
||||
await this.apiKeySecurityService.deleteApiKey(userId);
|
||||
} catch (e) {
|
||||
this.logger.warn('API Key缓存清理失败', { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
// 销毁会话
|
||||
await this.sessionService.destroySession(socketId);
|
||||
|
||||
this.logger.log('玩家登出完成', { socketId, userId, reason });
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('玩家登出失败', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送聊天消息
|
||||
* @param request 聊天消息请求,包含socketId、content和scope
|
||||
* @returns 发送结果,包含messageId或错误信息
|
||||
*/
|
||||
async sendChatMessage(request: ChatMessageRequest): Promise<ChatMessageResponse> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始处理聊天消息', {
|
||||
operation: 'sendChatMessage',
|
||||
socketId: request.socketId,
|
||||
contentLength: request.content.length,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 获取会话
|
||||
const session = await this.sessionService.getSession(request.socketId);
|
||||
if (!session) {
|
||||
return { success: false, error: '会话不存在,请重新登录' };
|
||||
}
|
||||
|
||||
// 2. 获取上下文
|
||||
const context = await this.sessionService.injectContext(request.socketId);
|
||||
const targetStream = context.stream;
|
||||
const targetTopic = context.topic || 'General';
|
||||
|
||||
// 3. 消息验证
|
||||
const validationResult = await this.filterService.validateMessage(
|
||||
session.userId,
|
||||
request.content,
|
||||
targetStream,
|
||||
session.currentMap,
|
||||
);
|
||||
|
||||
if (!validationResult.allowed) {
|
||||
return { success: false, error: validationResult.reason || '消息发送失败' };
|
||||
}
|
||||
|
||||
const messageContent = validationResult.filteredContent || request.content;
|
||||
const messageId = `game_${Date.now()}_${session.userId}`;
|
||||
|
||||
// 4. 🚀 立即广播给游戏内玩家
|
||||
const gameMessage: GameChatMessage = {
|
||||
t: 'chat_render',
|
||||
from: session.username,
|
||||
txt: messageContent,
|
||||
bubble: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
messageId,
|
||||
mapId: session.currentMap,
|
||||
scope: request.scope,
|
||||
};
|
||||
|
||||
this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId)
|
||||
.catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message }));
|
||||
|
||||
// 5. 🔄 异步同步到Zulip
|
||||
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
|
||||
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
|
||||
|
||||
this.logger.log('聊天消息发送完成', {
|
||||
operation: 'sendChatMessage',
|
||||
messageId,
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
|
||||
return { success: true, messageId };
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('聊天消息发送失败', { error: (error as Error).message });
|
||||
return { success: false, error: '消息发送失败,请稍后重试' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新玩家位置
|
||||
* @param request 位置更新请求,包含socketId、坐标和mapId
|
||||
* @returns 更新是否成功
|
||||
*/
|
||||
async updatePlayerPosition(request: PositionUpdateRequest): Promise<boolean> {
|
||||
try {
|
||||
if (!request.socketId?.trim() || !request.mapId?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.sessionService.updatePlayerPosition(
|
||||
request.socketId,
|
||||
request.mapId,
|
||||
request.x,
|
||||
request.y,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error('更新位置失败', { error: (error as Error).message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取聊天历史
|
||||
* @param query 查询参数,包含mapId、limit和offset
|
||||
* @returns 聊天历史记录列表
|
||||
*/
|
||||
async getChatHistory(query: { mapId?: string; limit?: number; offset?: number }) {
|
||||
// 模拟数据,实际应从Zulip获取
|
||||
const mockMessages = [
|
||||
{
|
||||
id: 1,
|
||||
sender: 'Player_123',
|
||||
content: '大家好!',
|
||||
scope: 'local',
|
||||
mapId: query.mapId || 'whale_port',
|
||||
timestamp: new Date(Date.now() - this.HISTORY_TIME_OFFSET_MS).toISOString(),
|
||||
streamName: 'Whale Port',
|
||||
topicName: 'Game Chat',
|
||||
},
|
||||
];
|
||||
|
||||
const limit = query.limit || this.DEFAULT_PAGE_SIZE;
|
||||
const offset = query.offset || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messages: mockMessages.slice(offset, offset + limit),
|
||||
total: mockMessages.length,
|
||||
count: Math.min(mockMessages.length, limit),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话信息
|
||||
* @param socketId WebSocket连接ID
|
||||
* @returns 会话信息或null
|
||||
*/
|
||||
async getSession(socketId: string) {
|
||||
return this.sessionService.getSession(socketId);
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private async validateGameToken(token: string) {
|
||||
try {
|
||||
const payload = await this.loginCoreService.verifyToken(token, 'access');
|
||||
if (!payload?.sub) return null;
|
||||
|
||||
return {
|
||||
userId: payload.sub,
|
||||
username: payload.username || `user_${payload.sub}`,
|
||||
email: payload.email || `${payload.sub}@example.com`,
|
||||
zulipEmail: undefined,
|
||||
zulipApiKey: undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.warn('Token验证失败', { error: (error as Error).message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async createUserSession(socketId: string, userInfo: any) {
|
||||
const sessionId = randomUUID();
|
||||
let zulipQueueId = `queue_${sessionId}`;
|
||||
|
||||
// 尝试创建Zulip客户端
|
||||
if (userInfo.zulipApiKey) {
|
||||
try {
|
||||
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, {
|
||||
username: userInfo.zulipEmail || userInfo.email,
|
||||
apiKey: userInfo.zulipApiKey,
|
||||
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
|
||||
});
|
||||
if (clientInstance.queueId) zulipQueueId = clientInstance.queueId;
|
||||
} catch (e) {
|
||||
this.logger.warn('Zulip客户端创建失败', { error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const session = await this.sessionService.createSession(
|
||||
socketId,
|
||||
userInfo.userId,
|
||||
zulipQueueId,
|
||||
userInfo.username,
|
||||
this.DEFAULT_MAP,
|
||||
this.DEFAULT_POSITION,
|
||||
);
|
||||
|
||||
return { sessionId, currentMap: session.currentMap };
|
||||
}
|
||||
|
||||
private async broadcastToGamePlayers(mapId: string, message: GameChatMessage, excludeSocketId?: string) {
|
||||
if (!this.websocketGateway) {
|
||||
throw new Error('WebSocket网关未设置');
|
||||
}
|
||||
|
||||
const sockets = await this.sessionService.getSocketsInMap(mapId);
|
||||
const targetSockets = sockets.filter(id => id !== excludeSocketId);
|
||||
|
||||
for (const socketId of targetSockets) {
|
||||
try {
|
||||
this.websocketGateway.sendToPlayer(socketId, message);
|
||||
} catch (e) {
|
||||
this.logger.warn('发送消息失败', { socketId, error: (e as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async syncToZulipAsync(userId: string, stream: string, topic: string, content: string, gameMessageId: string) {
|
||||
try {
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId);
|
||||
if (!apiKeyResult.success || !apiKeyResult.apiKey) return;
|
||||
|
||||
const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`;
|
||||
await this.zulipClientPool.sendMessage(userId, stream, topic, zulipContent);
|
||||
} catch (error) {
|
||||
this.logger.warn('Zulip同步异常', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
246
src/business/chat/services/chat_cleanup.service.spec.ts
Normal file
246
src/business/chat/services/chat_cleanup.service.spec.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 聊天会话清理服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 定时清理任务启动和停止
|
||||
* - 过期会话清理逻辑
|
||||
* - 手动触发清理功能
|
||||
* - 资源释放和错误处理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatCleanupService } from './chat_cleanup.service';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
describe('ChatCleanupService', () => {
|
||||
let service: ChatCleanupService;
|
||||
let sessionService: jest.Mocked<ChatSessionService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSessionService = {
|
||||
cleanupExpiredSessions: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatCleanupService,
|
||||
{
|
||||
provide: ChatSessionService,
|
||||
useValue: mockSessionService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatCleanupService>(ChatCleanupService);
|
||||
sessionService = module.get(ChatSessionService);
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'debug').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.clearAllTimers();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该在模块初始化时启动清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
const startCleanupTaskSpy = jest.spyOn(service as any, 'startCleanupTask');
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
expect(startCleanupTaskSpy).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在模块销毁时停止清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
const stopCleanupTaskSpy = jest.spyOn(service as any, 'stopCleanupTask');
|
||||
|
||||
await service.onModuleDestroy();
|
||||
|
||||
expect(stopCleanupTaskSpy).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('定时清理任务', () => {
|
||||
it('应该定时执行清理操作', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 5,
|
||||
zulipQueueIds: ['queue_1', 'queue_2'],
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
|
||||
// 快进5分钟
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该在停止任务后不再执行清理', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleDestroy();
|
||||
|
||||
sessionService.cleanupExpiredSessions.mockClear();
|
||||
|
||||
// 快进5分钟
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).not.toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('triggerCleanup', () => {
|
||||
it('应该成功执行手动清理', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 3,
|
||||
zulipQueueIds: ['queue_1', 'queue_2', 'queue_3'],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(3);
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
||||
});
|
||||
|
||||
it('应该处理清理失败', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
await expect(service.triggerCleanup()).rejects.toThrow('Redis error');
|
||||
});
|
||||
|
||||
it('应该返回清理数量为0当没有过期会话', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('清理逻辑', () => {
|
||||
it('应该清理多个过期会话', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 10,
|
||||
zulipQueueIds: Array.from({ length: 10 }, (_, i) => `queue_${i}`),
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalledWith(30);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理清理过程中的异常', async () => {
|
||||
jest.useFakeTimers();
|
||||
sessionService.cleanupExpiredSessions.mockRejectedValue(new Error('Cleanup failed'));
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
// 应该记录错误但不抛出异常
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理Zulip队列清理', async () => {
|
||||
jest.useFakeTimers();
|
||||
const zulipQueueIds = ['queue_1', 'queue_2', 'queue_3'];
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 3,
|
||||
zulipQueueIds,
|
||||
});
|
||||
|
||||
await service.onModuleInit();
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(sessionService.cleanupExpiredSessions).toHaveBeenCalled();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理空的清理结果', async () => {
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: 0,
|
||||
zulipQueueIds: [],
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理大量过期会话', async () => {
|
||||
const largeCount = 1000;
|
||||
sessionService.cleanupExpiredSessions.mockResolvedValue({
|
||||
cleanedCount: largeCount,
|
||||
zulipQueueIds: Array.from({ length: largeCount }, (_, i) => `queue_${i}`),
|
||||
});
|
||||
|
||||
const result = await service.triggerCleanup();
|
||||
|
||||
expect(result.cleanedCount).toBe(largeCount);
|
||||
});
|
||||
|
||||
it('应该处理重复启动清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleInit();
|
||||
|
||||
// 应该只有一个定时器在运行
|
||||
jest.advanceTimersByTime(5 * 60 * 1000);
|
||||
await Promise.resolve();
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('应该处理重复停止清理任务', async () => {
|
||||
jest.useFakeTimers();
|
||||
|
||||
await service.onModuleInit();
|
||||
await service.onModuleDestroy();
|
||||
await service.onModuleDestroy();
|
||||
|
||||
// 不应该抛出异常
|
||||
expect(service['cleanupInterval']).toBeNull();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/business/chat/services/chat_cleanup.service.ts
Normal file
113
src/business/chat/services/chat_cleanup.service.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 聊天会话清理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 定时清理过期会话
|
||||
* - 释放相关资源
|
||||
* - 管理Zulip队列清理
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 移除未使用的依赖 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
/**
|
||||
* 聊天会话清理服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 定时检测和清理过期会话
|
||||
* - 释放Zulip队列等相关资源
|
||||
* - 维护系统资源的健康状态
|
||||
*
|
||||
* 主要方法:
|
||||
* - triggerCleanup() - 手动触发会话清理
|
||||
*
|
||||
* 使用场景:
|
||||
* - 系统启动时自动开始定时清理任务
|
||||
* - 管理员手动触发清理操作
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatCleanupService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(ChatCleanupService.name);
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
private readonly CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5分钟
|
||||
private readonly SESSION_TIMEOUT_MINUTES = 30;
|
||||
|
||||
constructor(
|
||||
private readonly sessionService: ChatSessionService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.logger.log('启动会话清理定时任务');
|
||||
this.startCleanupTask();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
this.logger.log('停止会话清理定时任务');
|
||||
this.stopCleanupTask();
|
||||
}
|
||||
|
||||
private startCleanupTask() {
|
||||
this.cleanupInterval = setInterval(async () => {
|
||||
await this.performCleanup();
|
||||
}, this.CLEANUP_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopCleanupTask() {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async performCleanup() {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始执行会话清理');
|
||||
|
||||
try {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
|
||||
// 清理Zulip队列
|
||||
for (const queueId of result.zulipQueueIds) {
|
||||
try {
|
||||
// 这里可以添加Zulip队列清理逻辑
|
||||
this.logger.debug('清理Zulip队列', { queueId });
|
||||
} catch (error) {
|
||||
this.logger.warn('清理Zulip队列失败', { queueId, error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('会话清理完成', {
|
||||
cleanedCount: result.cleanedCount,
|
||||
zulipQueueCount: result.zulipQueueIds.length,
|
||||
duration,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.logger.error('会话清理失败', { error: (error as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发清理
|
||||
* @returns 清理结果,包含清理的会话数量
|
||||
*/
|
||||
async triggerCleanup(): Promise<{ cleanedCount: number }> {
|
||||
const result = await this.sessionService.cleanupExpiredSessions(this.SESSION_TIMEOUT_MINUTES);
|
||||
return { cleanedCount: result.cleanedCount };
|
||||
}
|
||||
}
|
||||
348
src/business/chat/services/chat_filter.service.spec.ts
Normal file
348
src/business/chat/services/chat_filter.service.spec.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* 聊天消息过滤服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 消息内容过滤和敏感词检测
|
||||
* - 频率限制检查
|
||||
* - 权限验证
|
||||
* - 综合消息验证流程
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatFilterService } from './chat_filter.service';
|
||||
|
||||
describe('ChatFilterService', () => {
|
||||
let service: ChatFilterService;
|
||||
let redisService: any;
|
||||
let configManager: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
incr: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigManager = {
|
||||
getStreamByMap: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatFilterService,
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatFilterService>(ChatFilterService);
|
||||
redisService = module.get('REDIS_SERVICE');
|
||||
configManager = module.get('ZULIP_CONFIG_SERVICE');
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterContent', () => {
|
||||
it('应该通过正常消息', async () => {
|
||||
const result = await service.filterContent('Hello, world!');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toBeUndefined();
|
||||
});
|
||||
|
||||
it('应该拒绝空消息', async () => {
|
||||
const result = await service.filterContent('');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息内容不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝只包含空白字符的消息', async () => {
|
||||
const result = await service.filterContent(' \n\t ');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息内容不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝超长消息', async () => {
|
||||
const longMessage = 'a'.repeat(1001);
|
||||
const result = await service.filterContent(longMessage);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('消息内容过长');
|
||||
});
|
||||
|
||||
it('应该替换敏感词', async () => {
|
||||
const result = await service.filterContent('这是垃圾消息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toContain('**');
|
||||
});
|
||||
|
||||
it('应该拒绝包含过多重复字符的消息', async () => {
|
||||
const result = await service.filterContent('aaaaa');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
|
||||
it('应该拒绝包含重复短语的消息', async () => {
|
||||
const result = await service.filterContent('哈哈哈哈哈');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
|
||||
it('应该拒绝包含黑名单链接的消息', async () => {
|
||||
const result = await service.filterContent('访问 https://malware.com 获取更多信息');
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含不允许的链接');
|
||||
});
|
||||
|
||||
it('应该允许包含正常链接的消息', async () => {
|
||||
const result = await service.filterContent('访问 https://example.com 获取更多信息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理多个敏感词', async () => {
|
||||
const result = await service.filterContent('这是垃圾广告');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filtered).toContain('**');
|
||||
});
|
||||
|
||||
it('应该处理大小写不敏感的敏感词', async () => {
|
||||
const result = await service.filterContent('这是GARBAGE消息');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkRateLimit', () => {
|
||||
const userId = 'user_123';
|
||||
|
||||
it('应该允许首次发送消息', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该允许在限制内发送消息', async () => {
|
||||
redisService.get.mockResolvedValue('5');
|
||||
redisService.incr.mockResolvedValue(6);
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.incr).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝超过频率限制的消息', async () => {
|
||||
redisService.get.mockResolvedValue('10');
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(redisService.incr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.checkRateLimit(userId);
|
||||
|
||||
// 失败时应该允许,避免影响正常用户
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该正确递增计数器', async () => {
|
||||
redisService.get.mockResolvedValue('1');
|
||||
redisService.incr.mockResolvedValue(2);
|
||||
|
||||
await service.checkRateLimit(userId);
|
||||
|
||||
expect(redisService.incr).toHaveBeenCalledWith(`chat:rate_limit:${userId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePermission', () => {
|
||||
const userId = 'user_123';
|
||||
const targetStream = 'Whale Port';
|
||||
const currentMap = 'whale_port';
|
||||
|
||||
it('应该允许有权限的用户发送消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝无权限的用户发送消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Other Stream');
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空userId', async () => {
|
||||
const result = await service.validatePermission('', targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空targetStream', async () => {
|
||||
const result = await service.validatePermission(userId, '', currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空currentMap', async () => {
|
||||
const result = await service.validatePermission(userId, targetStream, '');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理地图没有对应Stream的情况', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue(null);
|
||||
|
||||
const result = await service.validatePermission(userId, targetStream, currentMap);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该忽略大小写进行匹配', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('whale port');
|
||||
|
||||
const result = await service.validatePermission(userId, 'WHALE PORT', currentMap);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMessage', () => {
|
||||
const userId = 'user_123';
|
||||
const content = 'Hello, world!';
|
||||
const targetStream = 'Whale Port';
|
||||
const currentMap = 'whale_port';
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
});
|
||||
|
||||
it('应该通过所有验证的消息', async () => {
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
// filteredContent可能是undefined(如果没有过滤)或者是过滤后的内容
|
||||
if (result.filteredContent) {
|
||||
expect(result.filteredContent).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('应该拒绝超过频率限制的消息', async () => {
|
||||
redisService.get.mockResolvedValue('10');
|
||||
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('发送频率过高');
|
||||
});
|
||||
|
||||
it('应该拒绝包含敏感词的消息', async () => {
|
||||
const result = await service.validateMessage(userId, 'aaaaa', targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBeDefined();
|
||||
});
|
||||
|
||||
it('应该拒绝无权限发送的消息', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Other Stream');
|
||||
|
||||
const result = await service.validateMessage(userId, content, targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toContain('无法向该频道发送消息');
|
||||
});
|
||||
|
||||
it('应该返回过滤后的内容', async () => {
|
||||
const result = await service.validateMessage(userId, '这是垃圾消息', targetStream, currentMap);
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
expect(result.filteredContent).toContain('**');
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理null内容', async () => {
|
||||
const result = await service.filterContent(null as any);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理undefined内容', async () => {
|
||||
const result = await service.filterContent(undefined as any);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理特殊字符', async () => {
|
||||
const result = await service.filterContent('!@#$%^&*()');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理Unicode字符', async () => {
|
||||
const result = await service.filterContent('你好世界 🌍');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理混合语言内容', async () => {
|
||||
const result = await service.filterContent('Hello 世界 مرحبا');
|
||||
|
||||
expect(result.allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('应该处理恰好1000字符的消息', async () => {
|
||||
const message = 'a'.repeat(1000);
|
||||
const result = await service.filterContent(message);
|
||||
|
||||
expect(result.allowed).toBe(false);
|
||||
expect(result.reason).toBe('消息包含过多重复字符');
|
||||
});
|
||||
});
|
||||
});
|
||||
264
src/business/chat/services/chat_filter.service.ts
Normal file
264
src/business/chat/services/chat_filter.service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 聊天消息过滤服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 实施内容审核和频率控制
|
||||
* - 敏感词过滤和权限验证
|
||||
* - 防止恶意操作和滥用
|
||||
*
|
||||
* 架构层级:Business Layer(业务层)
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
|
||||
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.2
|
||||
* @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';
|
||||
|
||||
/**
|
||||
* 内容过滤结果接口
|
||||
*/
|
||||
export interface ContentFilterResult {
|
||||
allowed: boolean;
|
||||
filtered?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感词配置接口
|
||||
*/
|
||||
interface SensitiveWordConfig {
|
||||
word: string;
|
||||
level: 'block' | 'replace';
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天消息过滤服务类
|
||||
*
|
||||
* 职责:
|
||||
* - 实施消息内容审核和敏感词过滤
|
||||
* - 控制用户发送消息的频率
|
||||
* - 验证用户发送消息的权限
|
||||
*
|
||||
* 主要方法:
|
||||
* - validateMessage() - 综合验证消息(频率+内容+权限)
|
||||
* - filterContent() - 过滤消息内容中的敏感词
|
||||
* - checkRateLimit() - 检查用户发送频率
|
||||
* - validatePermission() - 验证用户发送权限
|
||||
*
|
||||
* 使用场景:
|
||||
* - 用户发送聊天消息前的预处理
|
||||
* - 防止恶意刷屏和不当内容传播
|
||||
*/
|
||||
@Injectable()
|
||||
export class ChatFilterService {
|
||||
private readonly RATE_LIMIT_PREFIX = 'chat:rate_limit:';
|
||||
private readonly DEFAULT_RATE_LIMIT = 10;
|
||||
private readonly RATE_LIMIT_WINDOW = 60;
|
||||
private readonly MAX_MESSAGE_LENGTH = 1000;
|
||||
private readonly logger = new Logger(ChatFilterService.name);
|
||||
|
||||
private sensitiveWords: SensitiveWordConfig[] = [
|
||||
{ word: '垃圾', level: 'replace', category: 'offensive' },
|
||||
{ word: '广告', level: 'replace', category: 'spam' },
|
||||
{ word: '刷屏', level: 'replace', category: 'spam' },
|
||||
];
|
||||
|
||||
private readonly BLACKLISTED_DOMAINS = ['malware.com', 'phishing.net'];
|
||||
|
||||
constructor(
|
||||
@Inject('REDIS_SERVICE')
|
||||
private readonly redisService: IRedisService,
|
||||
@Inject('ZULIP_CONFIG_SERVICE')
|
||||
private readonly configManager: IZulipConfigService,
|
||||
) {
|
||||
this.logger.log('ChatFilterService初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 综合消息验证
|
||||
* @param userId 用户ID
|
||||
* @param content 消息内容
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 验证结果,包含是否允许、原因和过滤后的内容
|
||||
*/
|
||||
async validateMessage(
|
||||
userId: string,
|
||||
content: string,
|
||||
targetStream: string,
|
||||
currentMap: string
|
||||
): Promise<{ allowed: boolean; reason?: string; filteredContent?: string }> {
|
||||
// 1. 频率限制检查
|
||||
const rateLimitOk = await this.checkRateLimit(userId);
|
||||
if (!rateLimitOk) {
|
||||
return { allowed: false, reason: '发送频率过高,请稍后重试' };
|
||||
}
|
||||
|
||||
// 2. 内容过滤
|
||||
const contentResult = await this.filterContent(content);
|
||||
if (!contentResult.allowed) {
|
||||
return { allowed: false, reason: contentResult.reason };
|
||||
}
|
||||
|
||||
// 3. 权限验证
|
||||
const permissionOk = await this.validatePermission(userId, targetStream, currentMap);
|
||||
if (!permissionOk) {
|
||||
return { allowed: false, reason: '您当前位置无法向该频道发送消息' };
|
||||
}
|
||||
|
||||
return { allowed: true, filteredContent: contentResult.filtered };
|
||||
}
|
||||
|
||||
/**
|
||||
* 内容过滤
|
||||
* @param content 待过滤的消息内容
|
||||
* @returns 过滤结果,包含是否允许、过滤后内容和原因
|
||||
*/
|
||||
async filterContent(content: string): Promise<ContentFilterResult> {
|
||||
// 空内容检查
|
||||
if (!content?.trim()) {
|
||||
return { allowed: false, reason: '消息内容不能为空' };
|
||||
}
|
||||
|
||||
// 长度检查
|
||||
if (content.length > this.MAX_MESSAGE_LENGTH) {
|
||||
return { allowed: false, reason: `消息内容过长,最多${this.MAX_MESSAGE_LENGTH}字符` };
|
||||
}
|
||||
|
||||
// 空白字符检查
|
||||
if (/^\s+$/.test(content)) {
|
||||
return { allowed: false, reason: '消息不能只包含空白字符' };
|
||||
}
|
||||
|
||||
// 敏感词检查
|
||||
let filteredContent = content;
|
||||
let hasBlockedWord = false;
|
||||
|
||||
for (const wordConfig of this.sensitiveWords) {
|
||||
if (content.toLowerCase().includes(wordConfig.word.toLowerCase())) {
|
||||
if (wordConfig.level === 'block') {
|
||||
hasBlockedWord = true;
|
||||
break;
|
||||
} else {
|
||||
const replacement = '*'.repeat(wordConfig.word.length);
|
||||
filteredContent = filteredContent.replace(
|
||||
new RegExp(this.escapeRegExp(wordConfig.word), 'gi'),
|
||||
replacement
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasBlockedWord) {
|
||||
return { allowed: false, reason: '消息包含不允许的内容' };
|
||||
}
|
||||
|
||||
// 重复字符检查
|
||||
if (this.hasExcessiveRepetition(content)) {
|
||||
return { allowed: false, reason: '消息包含过多重复字符' };
|
||||
}
|
||||
|
||||
// 恶意链接检查
|
||||
if (!this.checkLinks(content)) {
|
||||
return { allowed: false, reason: '消息包含不允许的链接' };
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
filtered: filteredContent !== content ? filteredContent : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 频率限制检查
|
||||
* @param userId 用户ID
|
||||
* @returns 是否通过频率限制检查
|
||||
*/
|
||||
async checkRateLimit(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const rateLimitKey = `${this.RATE_LIMIT_PREFIX}${userId}`;
|
||||
const currentCount = await this.redisService.get(rateLimitKey);
|
||||
const count = currentCount ? parseInt(currentCount, 10) : 0;
|
||||
|
||||
if (count >= this.DEFAULT_RATE_LIMIT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
await this.redisService.setex(rateLimitKey, this.RATE_LIMIT_WINDOW, '1');
|
||||
} else {
|
||||
await this.redisService.incr(rateLimitKey);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('频率检查失败', { error: (error as Error).message });
|
||||
return true; // 失败时允许,避免影响正常用户
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限验证
|
||||
* @param userId 用户ID
|
||||
* @param targetStream 目标Stream
|
||||
* @param currentMap 当前地图ID
|
||||
* @returns 是否有权限发送消息
|
||||
*/
|
||||
async validatePermission(userId: string, targetStream: string, currentMap: string): Promise<boolean> {
|
||||
if (!userId?.trim() || !targetStream?.trim() || !currentMap?.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const allowedStream = this.configManager.getStreamByMap(currentMap);
|
||||
if (!allowedStream) return false;
|
||||
|
||||
return targetStream.toLowerCase() === allowedStream.toLowerCase();
|
||||
}
|
||||
|
||||
// ========== 私有方法 ==========
|
||||
|
||||
private hasExcessiveRepetition(content: string): boolean {
|
||||
// 连续重复字符检查
|
||||
if (/(.)\1{4,}/.test(content)) return true;
|
||||
|
||||
// 重复短语检查
|
||||
if (/(.{2,})\1{2,}/.test(content)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private checkLinks(content: string): boolean {
|
||||
const urlPattern = /(https?:\/\/[^\s]+)/gi;
|
||||
const urls = content.match(urlPattern);
|
||||
|
||||
if (!urls) return true;
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const domain = urlObj.hostname.toLowerCase();
|
||||
|
||||
for (const blacklisted of this.BLACKLISTED_DOMAINS) {
|
||||
if (domain.includes(blacklisted)) return false;
|
||||
}
|
||||
} catch {
|
||||
// URL解析失败,允许通过
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
609
src/business/chat/services/chat_session.service.spec.ts
Normal file
609
src/business/chat/services/chat_session.service.spec.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* 聊天会话管理服务测试
|
||||
*
|
||||
* 测试范围:
|
||||
* - 会话创建和销毁
|
||||
* - 位置更新和地图切换
|
||||
* - 上下文注入和Stream/Topic映射
|
||||
* - 过期会话清理
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.0
|
||||
* @since 2026-01-14
|
||||
* @lastModified 2026-01-14
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { ChatSessionService } from './chat_session.service';
|
||||
|
||||
describe('ChatSessionService', () => {
|
||||
let service: ChatSessionService;
|
||||
let redisService: any;
|
||||
let configManager: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockRedisService = {
|
||||
get: jest.fn(),
|
||||
setex: jest.fn(),
|
||||
del: jest.fn(),
|
||||
sadd: jest.fn(),
|
||||
srem: jest.fn(),
|
||||
smembers: jest.fn(),
|
||||
expire: jest.fn(),
|
||||
};
|
||||
|
||||
const mockConfigManager = {
|
||||
getStreamByMap: jest.fn(),
|
||||
findNearbyObject: jest.fn(),
|
||||
getAllMapIds: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ChatSessionService,
|
||||
{
|
||||
provide: 'REDIS_SERVICE',
|
||||
useValue: mockRedisService,
|
||||
},
|
||||
{
|
||||
provide: 'ZULIP_CONFIG_SERVICE',
|
||||
useValue: mockConfigManager,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ChatSessionService>(ChatSessionService);
|
||||
redisService = module.get('REDIS_SERVICE');
|
||||
configManager = module.get('ZULIP_CONFIG_SERVICE');
|
||||
|
||||
// 禁用日志输出
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'error').mockImplementation();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('初始化', () => {
|
||||
it('应该成功创建服务实例', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
const username = 'testuser';
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功创建会话', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.socketId).toBe(socketId);
|
||||
expect(session.userId).toBe(userId);
|
||||
expect(session.username).toBe(username);
|
||||
expect(session.zulipQueueId).toBe(zulipQueueId);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该使用默认地图和位置', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId);
|
||||
|
||||
expect(session.currentMap).toBe('novice_village');
|
||||
expect(session.position).toEqual({ x: 400, y: 300 });
|
||||
});
|
||||
|
||||
it('应该使用提供的初始地图和位置', async () => {
|
||||
const initialMap = 'whale_port';
|
||||
const initialPosition = { x: 500, y: 400 };
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
username,
|
||||
initialMap,
|
||||
initialPosition
|
||||
);
|
||||
|
||||
expect(session.currentMap).toBe(initialMap);
|
||||
expect(session.position).toEqual(initialPosition);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
await expect(service.createSession('', userId, zulipQueueId)).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空userId', async () => {
|
||||
await expect(service.createSession(socketId, '', zulipQueueId)).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该拒绝空zulipQueueId', async () => {
|
||||
await expect(service.createSession(socketId, userId, '')).rejects.toThrow('参数不能为空');
|
||||
});
|
||||
|
||||
it('应该清理旧会话', async () => {
|
||||
const oldSocketId = 'old_socket_123';
|
||||
redisService.get.mockResolvedValueOnce(oldSocketId);
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify({
|
||||
socketId: oldSocketId,
|
||||
userId,
|
||||
username,
|
||||
zulipQueueId,
|
||||
currentMap: 'novice_village',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(redisService.del).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该添加到地图玩家列表', async () => {
|
||||
await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(redisService.sadd).toHaveBeenCalledWith(
|
||||
expect.stringContaining('chat:map_players:'),
|
||||
socketId
|
||||
);
|
||||
});
|
||||
|
||||
it('应该生成默认用户名', async () => {
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId);
|
||||
|
||||
expect(session.username).toBe(`user_${userId}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
it('应该返回会话信息', async () => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session?.socketId).toBe(socketId);
|
||||
expect(session?.userId).toBe(mockSessionData.userId);
|
||||
});
|
||||
|
||||
it('应该更新最后活动时间', async () => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
|
||||
await service.getSession(socketId);
|
||||
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const session = await service.getSession('');
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const session = await service.getSession(socketId);
|
||||
|
||||
expect(session).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectContext', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
configManager.getStreamByMap.mockReturnValue('Whale Port');
|
||||
configManager.findNearbyObject.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('应该返回正确的Stream', async () => {
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('Whale Port');
|
||||
});
|
||||
|
||||
it('应该使用默认Topic', async () => {
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.topic).toBe('General');
|
||||
});
|
||||
|
||||
it('应该根据附近对象设置Topic', async () => {
|
||||
configManager.findNearbyObject.mockReturnValue({
|
||||
zulipTopic: 'Tavern',
|
||||
});
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.topic).toBe('Tavern');
|
||||
});
|
||||
|
||||
it('应该支持指定地图ID', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue('Market');
|
||||
|
||||
const context = await service.injectContext(socketId, 'market');
|
||||
|
||||
expect(configManager.getStreamByMap).toHaveBeenCalledWith('market');
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('General');
|
||||
});
|
||||
|
||||
it('应该处理地图没有对应Stream', async () => {
|
||||
configManager.getStreamByMap.mockReturnValue(null);
|
||||
|
||||
const context = await service.injectContext(socketId);
|
||||
|
||||
expect(context.stream).toBe('General');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSocketsInMap', () => {
|
||||
const mapId = 'whale_port';
|
||||
|
||||
it('应该返回地图中的所有Socket', async () => {
|
||||
const sockets = ['socket_1', 'socket_2', 'socket_3'];
|
||||
redisService.smembers.mockResolvedValue(sockets);
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual(sockets);
|
||||
});
|
||||
|
||||
it('应该处理空地图', async () => {
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.getSocketsInMap(mapId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePlayerPosition', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mapId = 'whale_port';
|
||||
const x = 500;
|
||||
const y = 400;
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'novice_village',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功更新位置', async () => {
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.setex).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该更新地图玩家列表当切换地图', async () => {
|
||||
await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
expect(redisService.sadd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该不更新地图玩家列表当在同一地图', async () => {
|
||||
const sameMapData = { ...mockSessionData, currentMap: mapId };
|
||||
redisService.get.mockResolvedValue(JSON.stringify(sameMapData));
|
||||
|
||||
await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(redisService.srem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.updatePlayerPosition('', mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该拒绝空mapId', async () => {
|
||||
const result = await service.updatePlayerPosition(socketId, '', x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.updatePlayerPosition(socketId, mapId, x, y);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroySession', () => {
|
||||
const socketId = 'socket_123';
|
||||
const mockSessionData = {
|
||||
socketId,
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
redisService.get.mockResolvedValue(JSON.stringify(mockSessionData));
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.del.mockResolvedValue(1);
|
||||
});
|
||||
|
||||
it('应该成功销毁会话', async () => {
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(redisService.del).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('应该从地图玩家列表移除', async () => {
|
||||
await service.destroySession(socketId);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('应该删除用户会话映射', async () => {
|
||||
await service.destroySession(socketId);
|
||||
|
||||
expect(redisService.del).toHaveBeenCalledWith(
|
||||
expect.stringContaining('chat:user_session:')
|
||||
);
|
||||
});
|
||||
|
||||
it('应该处理会话不存在', async () => {
|
||||
redisService.get.mockResolvedValue(null);
|
||||
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('应该拒绝空socketId', async () => {
|
||||
const result = await service.destroySession('');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('应该处理Redis错误', async () => {
|
||||
redisService.get.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.destroySession(socketId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredSessions', () => {
|
||||
beforeEach(() => {
|
||||
configManager.getAllMapIds.mockReturnValue(['novice_village', 'whale_port']);
|
||||
});
|
||||
|
||||
it('应该清理过期会话', async () => {
|
||||
const expiredSession = {
|
||||
socketId: 'socket_123',
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
||||
redisService.get.mockResolvedValueOnce(JSON.stringify(expiredSession));
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
redisService.del.mockResolvedValue(1);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBeGreaterThanOrEqual(1);
|
||||
expect(result.zulipQueueIds).toContain('queue_123');
|
||||
});
|
||||
|
||||
it('应该不清理未过期会话', async () => {
|
||||
const activeSession = {
|
||||
socketId: 'socket_123',
|
||||
userId: 'user_123',
|
||||
username: 'testuser',
|
||||
zulipQueueId: 'queue_123',
|
||||
currentMap: 'whale_port',
|
||||
position: { x: 400, y: 300 },
|
||||
lastActivity: new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValue(JSON.stringify(activeSession));
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理多个地图', async () => {
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(redisService.smembers).toHaveBeenCalledTimes(2);
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该使用默认地图当配置为空', async () => {
|
||||
configManager.getAllMapIds.mockReturnValue([]);
|
||||
redisService.smembers.mockResolvedValue([]);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('应该处理清理过程中的错误', async () => {
|
||||
redisService.smembers.mockRejectedValue(new Error('Redis error'));
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(result.cleanedCount).toBe(0);
|
||||
expect(result.zulipQueueIds).toEqual([]);
|
||||
});
|
||||
|
||||
it('应该清理不存在的会话数据', async () => {
|
||||
redisService.smembers.mockResolvedValue(['socket_123']);
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.srem.mockResolvedValue(1);
|
||||
|
||||
const result = await service.cleanupExpiredSessions(30);
|
||||
|
||||
expect(redisService.srem).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('边界情况', () => {
|
||||
it('应该处理极大的坐标值', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
'testuser',
|
||||
'whale_port',
|
||||
{ x: 999999, y: 999999 }
|
||||
);
|
||||
|
||||
expect(session.position).toEqual({ x: 999999, y: 999999 });
|
||||
});
|
||||
|
||||
it('应该处理负坐标值', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(
|
||||
socketId,
|
||||
userId,
|
||||
zulipQueueId,
|
||||
'testuser',
|
||||
'whale_port',
|
||||
{ x: -100, y: -100 }
|
||||
);
|
||||
|
||||
expect(session.position).toEqual({ x: -100, y: -100 });
|
||||
});
|
||||
|
||||
it('应该处理特殊字符的用户名', async () => {
|
||||
const socketId = 'socket_123';
|
||||
const userId = 'user_123';
|
||||
const zulipQueueId = 'queue_123';
|
||||
const username = 'test@user#123';
|
||||
|
||||
redisService.get.mockResolvedValue(null);
|
||||
redisService.setex.mockResolvedValue('OK');
|
||||
redisService.sadd.mockResolvedValue(1);
|
||||
redisService.expire.mockResolvedValue(1);
|
||||
|
||||
const session = await service.createSession(socketId, userId, zulipQueueId, username);
|
||||
|
||||
expect(session.username).toBe(username);
|
||||
});
|
||||
});
|
||||
});
|
||||
366
src/business/chat/services/chat_session.service.ts
Normal file
366
src/business/chat/services/chat_session.service.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* 聊天会话管理服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 维护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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user