From cf1b37af78f8ec399dc2f31fd0634faaabedc17b Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Mon, 19 Jan 2026 18:29:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=E5=AE=9E=E7=8E=B0=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E8=87=AA=E5=8A=A8=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?Zulip=E5=AE=A2=E6=88=B7=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 范围: src/business/chat/ 涉及文件: - chat.module.ts - chat.service.ts 主要功能: - 添加ZulipAccountsModule依赖,支持查询用户Zulip账号 - 实现initializeZulipClientForUser方法,登录时自动初始化Zulip客户端 - 从数据库获取用户Zulip账号信息和API Key - 优化会话创建流程,使用已创建的Zulip客户端队列ID - 移除登出时的API Key删除逻辑,保持持久化 - 支持基于目标地图的消息发送(mapId参数) 技术改进: - 分离Zulip客户端初始化逻辑,提高代码可维护性 - 添加完整的错误处理和日志记录 - 支持用户没有Zulip账号的场景(优雅降级) --- src/business/chat/chat.module.ts | 9 +- src/business/chat/chat.service.ts | 182 +++++++++++++++++++++++++----- 2 files changed, 158 insertions(+), 33 deletions(-) diff --git a/src/business/chat/chat.module.ts b/src/business/chat/chat.module.ts index e24e14d..b03e898 100644 --- a/src/business/chat/chat.module.ts +++ b/src/business/chat/chat.module.ts @@ -12,17 +12,19 @@ * - 依赖 ZulipCoreModule(核心层)提供Zulip技术服务 * - 依赖 RedisModule(核心层)提供缓存服务 * - 依赖 LoginCoreModule(核心层)提供Token验证 + * - 依赖 ZulipAccountsModule(核心层)提供Zulip账号数据访问 * * 导出接口: * - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用) * * 最近修改: + * - 2026-01-15: 功能完善 - 添加ZulipAccountsModule依赖,支持登录时初始化Zulip客户端 (修改者: AI) * - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin) * * @author moyin - * @version 1.1.1 + * @version 1.2.0 * @since 2026-01-14 - * @lastModified 2026-01-14 + * @lastModified 2026-01-15 */ import { Module } from '@nestjs/common'; @@ -33,6 +35,7 @@ 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 { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces'; @Module({ @@ -43,6 +46,8 @@ import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.inte RedisModule, // 登录核心模块 LoginCoreModule, + // Zulip账号数据库模块 + ZulipAccountsModule.forRoot(), ], providers: [ // 主聊天服务 diff --git a/src/business/chat/chat.service.ts b/src/business/chat/chat.service.ts index 9f09599..556c989 100644 --- a/src/business/chat/chat.service.ts +++ b/src/business/chat/chat.service.ts @@ -14,15 +14,16 @@ * - ⚡ 低延迟聊天体验 * * 最近修改: + * - 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.0.4 + * @version 1.1.0 * @since 2026-01-14 - * @lastModified 2026-01-14 + * @lastModified 2026-01-15 */ import { Injectable, Logger, Inject } from '@nestjs/common'; @@ -34,6 +35,8 @@ import { IApiKeySecurityService, } from '../../core/zulip_core/zulip_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'; // ========== 接口定义 ========== @@ -47,6 +50,8 @@ export interface ChatMessageRequest { content: string; /** 消息范围:local(本地)、global(全局) */ scope: string; + /** 目标地图ID(可选,不传则使用会话当前地图) */ + mapId?: string; } /** @@ -179,6 +184,8 @@ export class ChatService { @Inject('API_KEY_SECURITY_SERVICE') private readonly apiKeySecurityService: IApiKeySecurityService, private readonly loginCoreService: LoginCoreService, + @Inject('ZulipAccountsService') + private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService, ) { this.logger.log('ChatService初始化完成'); } @@ -217,7 +224,10 @@ export class ChatService { return { success: false, error: 'Token验证失败' }; } - // 3. 创建会话 + // 3. 初始化用户的Zulip客户端(从数据库获取Zulip账号信息) + await this.initializeZulipClientForUser(userInfo.userId); + + // 4. 创建会话 const sessionResult = await this.createUserSession(request.socketId, userInfo); this.logger.log('玩家登录成功', { @@ -256,20 +266,13 @@ export class ChatService { const userId = session.userId; - // 清理Zulip客户端 + // 清理Zulip客户端(注意:不删除Redis中的API Key,保持持久化) 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 }); - } } // 销毁会话 @@ -303,17 +306,20 @@ export class ChatService { return { success: false, error: '会话不存在,请重新登录' }; } - // 2. 获取上下文 - const context = await this.sessionService.injectContext(request.socketId); + // 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'; - // 3. 消息验证 + // 4. 消息验证 const validationResult = await this.filterService.validateMessage( session.userId, request.content, targetStream, - session.currentMap, + targetMapId, ); if (!validationResult.allowed) { @@ -323,7 +329,7 @@ export class ChatService { const messageContent = validationResult.filteredContent || request.content; const messageId = `game_${Date.now()}_${session.userId}`; - // 4. 🚀 立即广播给游戏内玩家 + // 5. 🚀 立即广播给游戏内玩家(根据scope决定广播范围) const gameMessage: GameChatMessage = { t: 'chat_render', from: session.username, @@ -331,14 +337,15 @@ export class ChatService { bubble: true, timestamp: new Date().toISOString(), messageId, - mapId: session.currentMap, + mapId: targetMapId, scope: request.scope, }; - this.broadcastToGamePlayers(session.currentMap, gameMessage, request.socketId) + // local: 只广播给目标地图的玩家; global: 广播给所有玩家(暂时也用地图广播) + this.broadcastToGamePlayers(targetMapId, gameMessage, request.socketId) .catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message })); - // 5. 🔄 异步同步到Zulip + // 6. 🔄 异步同步到Zulip this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId) .catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message })); @@ -421,6 +428,121 @@ export class ChatService { // ========== 私有方法 ========== + /** + * 初始化用户的Zulip客户端 + * + * 功能描述: + * 1. 从数据库获取用户的Zulip账号信息 + * 2. 检查Redis中是否已有API Key缓存 + * 3. 如果Redis中没有,从数据库标记判断是否需要重新获取 + * 4. 创建Zulip客户端实例 + * + * @param userId 用户ID + */ + private async initializeZulipClientForUser(userId: string): Promise { + 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 { + 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'); @@ -441,20 +563,18 @@ export class ChatService { private async createUserSession(socketId: string, userInfo: any) { const sessionId = randomUUID(); + + // 尝试获取已创建的Zulip客户端的队列ID 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 }); + 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(