Files
whale-town-end-v2/src/business/chat/chat.service.ts
2026-07-22 00:45:59 +08:00

1111 lines
36 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-15: 功能完善 - WebSocket登录时自动初始化用户Zulip客户端 (修改者: AI)
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
*
* @author moyin
* @version 1.1.0
* @since 2026-01-14
* @lastModified 2026-01-15
*/
import { Injectable, Logger, Inject } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { ChatSessionService } from './services/chat_session.service';
import type { ChatFriendInfo, ChatFriendRequestInfo, MapPlayerPresence } from './services/chat_session.service';
import { ChatFilterService } from './services/chat_filter.service';
import {
IZulipClientPoolService,
IApiKeySecurityService,
} from '../../core/zulip_core/zulip_core.interfaces';
import type { IPlayerAppearance, ICafeCompanionPresence } from '../../core/session_core/session_core.interfaces';
import { LoginCoreService } from '../../core/login_core/login_core.service';
import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service';
import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service';
import { AccountProfileService } from '../auth/account_profile.service';
import { TaskService } from '../tasks/task.service';
// ========== 接口定义 ==========
/**
* 聊天消息请求接口
*/
export interface ChatMessageRequest {
/** WebSocket连接ID */
socketId: string;
/** 消息内容 */
content: string;
/** 消息范围local(本地)、global(全局) */
scope: string;
/** 目标地图ID可选不传则使用会话当前地图 */
mapId?: string;
/** 私聊目标用户ID */
targetUserId?: string;
/** 私聊目标用户名(客户端展示用) */
targetUsername?: string;
/** 私聊来源上下文whisper / friends */
privateContext?: string;
/** 是否同步显示角色气泡 */
bubble?: boolean;
}
/**
* 聊天消息响应接口
*/
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;
/** 外观同步信息 */
appearance?: IPlayerAppearance;
}
export interface PlayerPresenceStateUpdateRequest {
userId: string;
mapId?: string;
x?: number;
y?: number;
cafeCompanion?: ICafeCompanionPresence | null;
movementLocked?: boolean;
}
/**
* 地图在线玩家快照项
*/
export interface MapPlayerSnapshotItem {
/** 用户ID */
userId: string;
/** 用户名 */
username: string;
/** 地图ID */
mapId: string;
/** X坐标 */
x: number;
/** Y坐标 */
y: number;
/** 外观同步信息 */
appearance?: IPlayerAppearance;
/** 皮肤ID兼容前端实时位置协议 */
skinId?: string;
/** 头像ID兼容前端实时位置协议 */
avatarId?: string;
/** 咖啡店陪伴服务状态 */
cafeCompanion?: ICafeCompanionPresence | null;
/** 是否锁定移动 */
movementLocked?: boolean;
}
/**
* 好友操作请求接口
*/
export interface FriendActionRequest {
/** WebSocket连接ID */
socketId: string;
/** 好友用户ID */
friendUserId: string;
/** 好友用户名 */
friendUsername?: string;
}
/**
* 游戏聊天消息格式用于WebSocket广播
*/
interface GameChatMessage {
/** 消息类型标识 */
t: 'chat_render';
/** 发送者用户名 */
from: string;
/** 发送者用户ID */
fromUserId: string;
/** 消息文本内容 */
txt: string;
/** 是否显示气泡 */
bubble: boolean;
/** 时间戳ISO格式 */
timestamp: string;
/** 消息ID */
messageId: string;
/** 地图ID */
mapId: string;
/** 消息范围 */
scope: string;
/** 私聊目标用户ID */
toUserId?: string;
/** 私聊目标用户名 */
toUsername?: string;
/** 私聊来源上下文whisper / friends */
privateContext?: string;
}
/**
* 聊天WebSocket网关接口
*/
interface IChatWebSocketGateway {
/**
* 向指定地图广播消息
* @param mapId 地图ID
* @param data 广播数据
* @param excludeId 排除的socketId可选
*/
broadcastToMap(mapId: string, data: any, excludeId?: string): void;
/**
* 向所有已认证玩家广播消息
* @param data 广播数据
* @param excludeId 排除的socketId可选
*/
broadcastToAll(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 MAX_HISTORY_MESSAGES = 200;
private readonly inMemoryHistory = new Map<string, GameChatMessage[]>();
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,
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
private readonly accountProfileService: AccountProfileService,
private readonly taskService: TaskService,
) {
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. 初始化用户的Zulip客户端从数据库获取Zulip账号信息
await this.initializeZulipClientForUser(userInfo.userId);
// 4. 创建会话
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客户端注意不删除Redis中的API Key保持持久化
if (userId) {
try {
await this.zulipClientPool.destroyUserClient(userId);
} catch (e) {
this.logger.warn('Zulip客户端清理失败', { 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: '会话不存在,请重新登录' };
}
const normalizedScope = this.normalizeChatScope(request.scope);
if (normalizedScope === 'private' && !request.targetUserId?.trim()) {
return { success: false, error: '请选择悄悄话对象' };
}
// 2. 确定目标地图优先使用请求中的mapId否则使用会话当前地图
const targetMapId = request.mapId || session.currentMap;
// 3. 获取上下文
const context = await this.sessionService.injectContext(request.socketId, targetMapId);
const targetStream = context.stream;
const targetTopic = context.topic || 'General';
// 4. 消息验证
const validationResult = await this.filterService.validateMessage(
session.userId,
request.content,
targetStream,
targetMapId,
);
if (!validationResult.allowed) {
return { success: false, error: validationResult.reason || '消息发送失败' };
}
const messageContent = validationResult.filteredContent || request.content;
const messageId = `game_${Date.now()}_${session.userId}`;
// 5. 🚀 立即广播给游戏内玩家根据scope决定广播范围
const gameMessage: GameChatMessage = {
t: 'chat_render',
from: session.username,
fromUserId: session.userId,
txt: messageContent,
bubble: Boolean(request.bubble),
timestamp: new Date().toISOString(),
messageId,
mapId: targetMapId,
scope: normalizedScope,
};
if (normalizedScope === 'private') {
gameMessage.toUserId = request.targetUserId?.trim();
gameMessage.toUsername = request.targetUsername?.trim() || undefined;
gameMessage.privateContext = request.privateContext?.trim() || undefined;
}
// local: 当前地图global: 所有在线玩家private: 仅发送者与目标玩家。
try {
await this.dispatchGameChatMessage(gameMessage, request.socketId);
this.recordChatHistory(gameMessage);
} catch (dispatchError) {
const message = (dispatchError as Error).message || '消息发送失败';
return { success: false, error: message };
}
// 6. 🔄 异步同步到Zulip
if (normalizedScope !== 'private') {
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message }));
}
if (normalizedScope === 'global') {
await this.taskService.recordActivity(BigInt(session.userId), 'public_message_sent')
.catch((error: unknown) => this.logger.warn('记录公共聊天任务失败', { error: error instanceof Error ? error.message : String(error) }));
}
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,
{
appearance: request.appearance,
},
);
} catch (error) {
this.logger.error('更新位置失败', { error: (error as Error).message });
return false;
}
}
async updatePlayerPresenceState(
request: PlayerPresenceStateUpdateRequest,
): Promise<{ success: boolean; presence?: MapPlayerSnapshotItem; socketId?: string; error?: string }> {
try {
const normalizedUserId = request.userId?.trim();
if (!normalizedUserId) {
return { success: false, error: '用户ID不能为空' };
}
const position = Number.isFinite(request.x) && Number.isFinite(request.y)
? { x: Number(request.x), y: Number(request.y) }
: undefined;
const presence = await this.sessionService.updateBusinessPresenceByUserId(normalizedUserId, {
mapId: request.mapId,
position,
cafeCompanion: request.cafeCompanion,
movementLocked: request.movementLocked,
});
if (!presence) {
return { success: false, error: '玩家当前不在线' };
}
const payload = this.toMapPlayerSnapshotItem(presence);
this.broadcastPlayerPresence(payload);
return { success: true, presence: payload, socketId: presence.socketId };
} catch (error) {
this.logger.error('更新玩家业务状态失败', { userId: request.userId, error: (error as Error).message });
return { success: false, error: '更新玩家业务状态失败' };
}
}
/**
* 获取指定地图的在线玩家快照
* @param mapId 地图ID
* @param excludeSocketId 排除的WebSocket连接ID
* @returns 在线玩家列表
*/
async getMapPlayerSnapshot(mapId: string, excludeSocketId?: string): Promise<MapPlayerSnapshotItem[]> {
const players = await this.sessionService.getPlayersInMap(mapId);
return players
.filter((player: MapPlayerPresence) => player.socketId !== excludeSocketId)
.map((player: MapPlayerPresence) => this.toMapPlayerSnapshotItem(player));
}
async refreshPlayerAppearance(socketId: string): Promise<MapPlayerSnapshotItem | null> {
const session = await this.sessionService.getSession(socketId);
if (!session) return null;
const appearance = await this.resolveAccountAppearance(session.userId);
await this.sessionService.updatePlayerPosition(
socketId,
session.currentMap,
Number(session.position?.x ?? 0),
Number(session.position?.y ?? 0),
{ appearance },
);
const updatedSession = await this.sessionService.getSession(socketId);
if (!updatedSession) return null;
return this.toMapPlayerSnapshotItem({
socketId: updatedSession.socketId,
userId: updatedSession.userId,
username: updatedSession.username,
mapId: updatedSession.currentMap,
x: Number(updatedSession.position?.x ?? 0),
y: Number(updatedSession.position?.y ?? 0),
appearance: updatedSession.appearance,
cafeCompanion: updatedSession.cafeCompanion ?? null,
movementLocked: Boolean(updatedSession.movementLocked),
});
}
/**
* 添加好友
* @param request 好友操作请求
* @returns 好友信息
*/
async addFriend(request: FriendActionRequest): Promise<{ success: boolean; friend?: ChatFriendInfo; error?: string }> {
try {
const session = await this.sessionService.getSession(request.socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
const friend = await this.sessionService.addFriend(
session.userId,
request.friendUserId,
request.friendUsername,
);
return { success: true, friend };
} catch (error) {
return { success: false, error: (error as Error).message || '添加好友失败' };
}
}
/**
* 发送好友请求
* @param request 好友操作请求
* @returns 好友请求信息
*/
async requestFriend(
request: FriendActionRequest,
): Promise<{ success: boolean; friendRequest?: ChatFriendRequestInfo; targetSocketId?: string | null; error?: string }> {
try {
const session = await this.sessionService.getSession(request.socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
const friendRequest = await this.sessionService.createFriendRequest(
session.userId,
session.username,
request.friendUserId,
);
const targetSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId);
if (targetSocketId && this.websocketGateway) {
this.websocketGateway.sendToPlayer(targetSocketId, {
t: 'friend_request_received',
request: friendRequest,
});
}
return { success: true, friendRequest, targetSocketId };
} catch (error) {
return { success: false, error: (error as Error).message || '好友请求发送失败' };
}
}
/**
* 接受好友请求
* @param request 好友操作请求
* @returns 添加后的好友信息
*/
async acceptFriendRequest(
request: FriendActionRequest,
): Promise<{ success: boolean; friend?: ChatFriendInfo; reciprocalFriend?: ChatFriendInfo; error?: string }> {
try {
const session = await this.sessionService.getSession(request.socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
const result = await this.sessionService.acceptFriendRequest(
session.userId,
request.friendUserId,
session.username,
);
const requesterSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId);
if (requesterSocketId && this.websocketGateway) {
this.websocketGateway.sendToPlayer(requesterSocketId, {
t: 'friend_request_accepted',
friend: result.reciprocalFriend,
});
}
return { success: true, friend: result.friend, reciprocalFriend: result.reciprocalFriend };
} catch (error) {
return { success: false, error: (error as Error).message || '接受好友请求失败' };
}
}
/**
* 拒绝好友请求
* @param request 好友操作请求
* @returns 操作结果
*/
async rejectFriendRequest(request: FriendActionRequest): Promise<{ success: boolean; error?: string }> {
try {
const session = await this.sessionService.getSession(request.socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
await this.sessionService.rejectFriendRequest(session.userId, request.friendUserId);
const requesterSocketId = await this.sessionService.getSocketIdByUserId(request.friendUserId);
if (requesterSocketId && this.websocketGateway) {
this.websocketGateway.sendToPlayer(requesterSocketId, {
t: 'friend_request_rejected',
userId: session.userId,
username: session.username,
});
}
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message || '拒绝好友请求失败' };
}
}
/**
* 移除好友
* @param request 好友操作请求
* @returns 操作结果
*/
async removeFriend(request: FriendActionRequest): Promise<{ success: boolean; error?: string }> {
try {
const session = await this.sessionService.getSession(request.socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
await this.sessionService.removeFriend(session.userId, request.friendUserId);
return { success: true };
} catch (error) {
return { success: false, error: (error as Error).message || '移除好友失败' };
}
}
/**
* 获取好友列表
* @param socketId WebSocket连接ID
* @returns 好友列表
*/
async getFriends(socketId: string): Promise<{ success: boolean; friends?: ChatFriendInfo[]; requests?: ChatFriendRequestInfo[]; error?: string }> {
try {
const session = await this.sessionService.getSession(socketId);
if (!session) {
return { success: false, error: '会话不存在,请重新登录' };
}
const friends = await this.sessionService.getFriends(session.userId);
const requests = await this.sessionService.getFriendRequests(session.userId);
return { success: true, friends, requests };
} catch (error) {
return { success: false, error: (error as Error).message || '获取好友列表失败' };
}
}
/**
* 获取聊天历史
* @param query 查询参数包含mapId、limit和offset
* @returns 聊天历史记录列表
*/
async getChatHistory(query: { mapId?: string; limit?: number; offset?: number }) {
const mapId = (query.mapId || this.DEFAULT_MAP).trim();
const limit = Math.max(1, Math.min(Number(query.limit || this.DEFAULT_PAGE_SIZE), this.DEFAULT_PAGE_SIZE));
const offset = Math.max(0, Number(query.offset || 0));
const messages = (this.inMemoryHistory.get(mapId) || []).slice().reverse().map((message, index) => ({
id: offset + index + 1,
messageId: message.messageId,
sender: message.from,
fromUserId: message.fromUserId,
content: message.txt,
scope: message.scope,
mapId: message.mapId,
timestamp: message.timestamp,
streamName: message.mapId,
topicName: 'Game Chat',
bubble: message.bubble,
toUserId: message.toUserId,
toUsername: message.toUsername,
privateContext: message.privateContext,
}));
return {
success: true,
messages: messages.slice(offset, offset + limit),
total: messages.length,
count: Math.min(Math.max(messages.length - offset, 0), limit),
};
}
/**
* 获取会话信息
* @param socketId WebSocket连接ID
* @returns 会话信息或null
*/
async getSession(socketId: string) {
return this.sessionService.getSession(socketId);
}
private recordChatHistory(message: GameChatMessage): void {
if (message.scope === 'private') {
return;
}
const mapId = message.mapId || this.DEFAULT_MAP;
const history = this.inMemoryHistory.get(mapId) || [];
history.push(message);
if (history.length > this.MAX_HISTORY_MESSAGES) {
history.splice(0, history.length - this.MAX_HISTORY_MESSAGES);
}
this.inMemoryHistory.set(mapId, history);
}
// ========== 私有方法 ==========
/**
* 初始化用户的Zulip客户端
*
* 功能描述:
* 1. 从数据库获取用户的Zulip账号信息
* 2. 检查Redis中是否已有API Key缓存
* 3. 如果Redis中没有从数据库标记判断是否需要重新获取
* 4. 创建Zulip客户端实例
*
* @param userId 用户ID
*/
private async initializeZulipClientForUser(userId: string): Promise<void> {
this.logger.log('开始初始化用户Zulip客户端', {
operation: 'initializeZulipClientForUser',
userId,
});
try {
// 1. 从数据库获取用户的Zulip账号信息
const zulipAccount = await this.zulipAccountsService.findByGameUserId(userId);
if (!zulipAccount) {
this.logger.debug('用户没有关联的Zulip账号跳过Zulip客户端初始化', {
operation: 'initializeZulipClientForUser',
userId,
});
return;
}
if (zulipAccount.status !== 'active') {
this.logger.warn('用户Zulip账号状态异常跳过初始化', {
operation: 'initializeZulipClientForUser',
userId,
status: zulipAccount.status,
});
return;
}
// 2. 检查Redis中是否已有API Key
const existingApiKey = await this.apiKeySecurityService.getApiKey(userId);
if (existingApiKey.success && existingApiKey.apiKey) {
this.logger.log('Redis中已有API Key缓存直接创建Zulip客户端', {
operation: 'initializeZulipClientForUser',
userId,
zulipEmail: zulipAccount.zulipEmail,
});
// 创建Zulip客户端
await this.createZulipClientWithApiKey(
userId,
zulipAccount.zulipEmail,
existingApiKey.apiKey
);
return;
}
// 3. Redis中没有API Key记录警告
// 注意由于登录时没有用户密码无法重新生成API Key
// API Key应该在用户注册时存储到Redis如果丢失需要用户重新绑定Zulip账号
this.logger.warn('Redis中没有用户的Zulip API Key缓存无法创建Zulip客户端', {
operation: 'initializeZulipClientForUser',
userId,
zulipEmail: zulipAccount.zulipEmail,
hint: '用户可能需要重新绑定Zulip账号',
});
} catch (error) {
const err = error as Error;
this.logger.error('初始化用户Zulip客户端失败', {
operation: 'initializeZulipClientForUser',
userId,
error: err.message,
});
// 不抛出异常允许用户继续登录只是没有Zulip功能
}
}
/**
* 使用API Key创建Zulip客户端
*
* @param userId 用户ID
* @param zulipEmail Zulip邮箱
* @param apiKey API Key
*/
private async createZulipClientWithApiKey(
userId: string,
zulipEmail: string,
apiKey: string
): Promise<void> {
try {
const clientInstance = await this.zulipClientPool.createUserClient(userId, {
username: zulipEmail,
apiKey: apiKey,
realm: process.env.ZULIP_SERVER_URL || 'https://zulip.xinghangee.icu/',
});
this.logger.log('Zulip客户端创建成功', {
operation: 'createZulipClientWithApiKey',
userId,
zulipEmail,
queueId: clientInstance.queueId,
});
} catch (error) {
const err = error as Error;
this.logger.error('创建Zulip客户端失败', {
operation: 'createZulipClientWithApiKey',
userId,
zulipEmail,
error: err.message,
});
throw error;
}
}
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();
const appearance = await this.resolveAccountAppearance(userInfo.userId);
// 尝试获取已创建的Zulip客户端的队列ID
let zulipQueueId = `queue_${sessionId}`;
try {
const existingClient = await this.zulipClientPool.getUserClient(userInfo.userId);
if (existingClient?.queueId) {
zulipQueueId = existingClient.queueId;
}
} catch (e) {
this.logger.debug('获取Zulip客户端队列ID失败使用默认值', {
error: (e as Error).message
});
}
const session = await this.sessionService.createSession(
socketId,
userInfo.userId,
zulipQueueId,
userInfo.username,
this.DEFAULT_MAP,
this.DEFAULT_POSITION,
appearance,
);
return { sessionId, currentMap: session.currentMap };
}
private async resolveAccountAppearance(userId: string): Promise<IPlayerAppearance | undefined> {
const normalizedUserId = String(userId || '').trim();
if (!/^\d+$/.test(normalizedUserId)) {
return undefined;
}
try {
const accountProfile = await this.accountProfileService.getAccountProfile(BigInt(normalizedUserId));
const profile = accountProfile.profile;
const skinId = String(profile.skin_id || '').trim();
const avatarId = String(profile.avatar_id || '').trim();
const appearance: IPlayerAppearance = {};
if (skinId) {
appearance.skinId = skinId;
const skinAsset = profile.owned_skins.find((skin: any) => {
const assetSkinId = String(skin?.id ?? skin?.skin_id ?? '').trim();
return assetSkinId === skinId;
});
if (skinAsset) {
appearance.skinAsset = this.toRealtimeSkinAsset(skinAsset as Record<string, any>);
}
}
if (avatarId) {
appearance.avatarId = avatarId;
}
return Object.keys(appearance).length > 0 ? appearance : undefined;
} catch (error) {
this.logger.warn('读取账号外观失败,在线会话将等待客户端位置包补充', {
userId: normalizedUserId,
error: (error as Error).message,
});
return undefined;
}
}
private toRealtimeSkinAsset(skinAsset: Record<string, any>): Record<string, any> {
return {
id: skinAsset.id ?? skinAsset.skin_id,
name: skinAsset.name,
texture_url: skinAsset.texture_url,
mime_type: skinAsset.mime_type,
hframes: skinAsset.hframes,
vframes: skinAsset.vframes,
source: skinAsset.source,
};
}
private normalizeChatScope(scope: string): 'local' | 'global' | 'private' {
const normalizedScope = scope?.trim().toLowerCase();
if (normalizedScope === 'global' || normalizedScope === 'world') {
return 'global';
}
if (normalizedScope === 'private' || normalizedScope === 'whisper' || normalizedScope === 'dm') {
return 'private';
}
return 'local';
}
private async dispatchGameChatMessage(message: GameChatMessage, senderSocketId: string): Promise<void> {
if (message.scope === 'global') {
this.broadcastToAllGamePlayers(message, senderSocketId);
return;
}
if (message.scope === 'private') {
await this.sendPrivateGameMessage(message, senderSocketId);
return;
}
await this.broadcastToGamePlayers(message.mapId, message, senderSocketId);
}
private broadcastToAllGamePlayers(message: GameChatMessage, excludeSocketId?: string): void {
if (!this.websocketGateway) {
throw new Error('WebSocket网关未设置');
}
this.websocketGateway.broadcastToAll(message, excludeSocketId);
}
private async sendPrivateGameMessage(message: GameChatMessage, senderSocketId: string): Promise<void> {
if (!this.websocketGateway) {
throw new Error('WebSocket网关未设置');
}
const targetUserId = message.toUserId?.trim();
if (!targetUserId) {
throw new Error('私聊目标用户ID不能为空');
}
const targetSocketId = await this.sessionService.getSocketIdByUserId(targetUserId);
if (!targetSocketId) {
throw new Error('悄悄话对象不在线');
}
this.websocketGateway.sendToPlayer(senderSocketId, message);
if (targetSocketId !== senderSocketId) {
this.websocketGateway.sendToPlayer(targetSocketId, message);
}
}
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 toMapPlayerSnapshotItem(player: MapPlayerPresence): MapPlayerSnapshotItem {
return {
userId: player.userId,
username: player.username,
mapId: player.mapId,
x: player.x,
y: player.y,
appearance: player.appearance,
skinId: player.appearance?.skinId,
avatarId: player.appearance?.avatarId,
cafeCompanion: player.cafeCompanion ?? null,
movementLocked: Boolean(player.movementLocked),
};
}
private broadcastPlayerPresence(presence: MapPlayerSnapshotItem): void {
if (!this.websocketGateway) {
this.logger.warn('WebSocket网关未设置跳过玩家业务状态广播', { userId: presence.userId });
return;
}
this.websocketGateway.broadcastToMap(presence.mapId, {
t: 'position_update',
userId: presence.userId,
username: presence.username,
x: presence.x,
y: presence.y,
mapId: presence.mapId,
appearance: presence.appearance,
skinId: presence.skinId,
avatarId: presence.avatarId,
cafeCompanion: presence.cafeCompanion ?? null,
movementLocked: Boolean(presence.movementLocked),
});
}
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 });
}
}
}