1111 lines
36 KiB
TypeScript
1111 lines
36 KiB
TypeScript
/**
|
||
* 聊天业务服务
|
||
*
|
||
* 功能描述:
|
||
* - 实现聊天相关的业务逻辑
|
||
* - 协调会话管理、消息过滤等子服务
|
||
* - 实现游戏内实时聊天 + 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 });
|
||
}
|
||
}
|
||
}
|