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

501 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 聊天业务服务
*
* 功能描述:
* - 实现聊天相关的业务逻辑
* - 协调会话管理、消息过滤等子服务
* - 实现游戏内实时聊天 + 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 });
}
}
}