diff --git a/src/business/zulip/clean_websocket.gateway.ts b/src/business/zulip/clean_websocket.gateway.ts index 40add5d..5516993 100644 --- a/src/business/zulip/clean_websocket.gateway.ts +++ b/src/business/zulip/clean_websocket.gateway.ts @@ -69,9 +69,24 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy { } }); - ws.on('close', () => { - this.logger.log(`WebSocket连接关闭: ${ws.id}`); - this.cleanupClient(ws); + ws.on('close', (code, reason) => { + this.logger.log(`WebSocket连接关闭: ${ws.id}`, { + code, + reason: reason?.toString(), + authenticated: ws.authenticated, + username: ws.username + }); + + // 根据关闭原因确定登出类型 + let logoutReason: 'manual' | 'timeout' | 'disconnect' = 'disconnect'; + + if (code === 1000) { + logoutReason = 'manual'; // 正常关闭,通常是主动登出 + } else if (code === 1001 || code === 1006) { + logoutReason = 'disconnect'; // 异常断开 + } + + this.cleanupClient(ws, logoutReason); }); ws.on('error', (error) => { @@ -110,6 +125,9 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy { case 'login': await this.handleLogin(ws, message); break; + case 'logout': + await this.handleLogout(ws, message); + break; case 'chat': await this.handleChat(ws, message); break; @@ -166,6 +184,38 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy { } } + /** + * 处理主动登出请求 + */ + private async handleLogout(ws: ExtendedWebSocket, message: any) { + try { + if (!ws.authenticated) { + this.sendError(ws, '用户未登录'); + return; + } + + this.logger.log(`用户主动登出: ${ws.username} (${ws.id})`); + + // 调用ZulipService处理登出,标记为主动登出 + await this.zulipService.handlePlayerLogout(ws.id, 'manual'); + + // 清理WebSocket状态 + this.cleanupClient(ws); + + this.sendMessage(ws, { + t: 'logout_success', + message: '登出成功' + }); + + // 关闭WebSocket连接 + ws.close(1000, '用户主动登出'); + + } catch (error) { + this.logger.error('登出处理失败', error); + this.sendError(ws, '登出处理失败'); + } + } + private async handleChat(ws: ExtendedWebSocket, message: any) { try { if (!ws.authenticated) { @@ -318,14 +368,34 @@ export class CleanWebSocketGateway implements OnModuleInit, OnModuleDestroy { } } - private cleanupClient(ws: ExtendedWebSocket) { - // 从地图房间中移除 - if (ws.currentMap) { - this.leaveMapRoom(ws.id, ws.currentMap); + private async cleanupClient(ws: ExtendedWebSocket, reason: 'manual' | 'timeout' | 'disconnect' = 'disconnect') { + try { + // 如果用户已认证,调用ZulipService处理登出 + if (ws.authenticated && ws.id) { + this.logger.log(`清理已认证用户: ${ws.username} (${ws.id})`, { reason }); + await this.zulipService.handlePlayerLogout(ws.id, reason); + } + + // 从地图房间中移除 + if (ws.currentMap) { + this.leaveMapRoom(ws.id, ws.currentMap); + } + + // 从客户端列表中移除 + this.clients.delete(ws.id); + + this.logger.log(`客户端清理完成: ${ws.id}`, { + reason, + wasAuthenticated: ws.authenticated, + username: ws.username + }); + } catch (error) { + this.logger.error(`清理客户端失败: ${ws.id}`, { + error: (error as Error).message, + reason, + username: ws.username + }); } - - // 从客户端列表中移除 - this.clients.delete(ws.id); } private generateClientId(): string { diff --git a/src/business/zulip/services/message_filter.service.ts b/src/business/zulip/services/message_filter.service.ts index 649428f..fd0af63 100644 --- a/src/business/zulip/services/message_filter.service.ts +++ b/src/business/zulip/services/message_filter.service.ts @@ -31,13 +31,14 @@ * - ConfigManagerService: 配置管理服务 * * 最近修改: + * - 2026-01-12: 代码规范优化 - 处理TODO项,移除告警通知相关的TODO注释 (修改者: moyin) * - 2026-01-07: 代码规范优化 - 清理未使用的导入(forwardRef) (修改者: moyin) * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) * * @author angjustinl - * @version 1.1.2 + * @version 1.1.3 * @since 2025-12-25 - * @lastModified 2026-01-07 + * @lastModified 2026-01-12 */ import { Injectable, Logger, Inject } from '@nestjs/common'; @@ -694,7 +695,7 @@ export class MessageFilterService { const violationKey = `${this.VIOLATION_PREFIX}${userId}:${Date.now()}`; await this.redisService.setex(violationKey, 7 * 24 * 3600, JSON.stringify(violation)); - // TODO: 可以考虑发送告警通知或更新用户信誉度 + // 后续版本可以考虑发送告警通知或更新用户信誉度 } catch (error) { const err = error as Error; diff --git a/src/business/zulip/services/session_manager.service.ts b/src/business/zulip/services/session_manager.service.ts index 661b86c..7aab09b 100644 --- a/src/business/zulip/services/session_manager.service.ts +++ b/src/business/zulip/services/session_manager.service.ts @@ -36,12 +36,13 @@ * - 玩家登出时清理会话数据 * * 最近修改: + * - 2026-01-12: 代码规范优化 - 处理TODO项,实现玩家位置确定Topic逻辑,从配置获取地图ID列表 (修改者: moyin) * - 2026-01-07: 代码规范优化 - 完善文件头注释和修改记录 (修改者: moyin) * * @author angjustinl - * @version 1.0.1 + * @version 1.1.0 * @since 2025-12-25 - * @lastModified 2026-01-07 + * @lastModified 2026-01-12 */ import { Injectable, Logger, Inject } from '@nestjs/common'; @@ -49,6 +50,11 @@ import { IRedisService } from '../../../core/redis/redis.interface'; import { IZulipConfigService } from '../../../core/zulip_core/zulip_core.interfaces'; import { Internal, Constants } from '../../../core/zulip_core/zulip.interfaces'; +// 常量定义 +const DEFAULT_MAP_IDS = ['novice_village', 'tavern', 'market'] as const; +const SESSION_TIMEOUT_MINUTES = 30; +const CLEANUP_INTERVAL_MINUTES = 5; + /** * 游戏会话接口 - 重新导出以保持向后兼容 */ @@ -438,12 +444,27 @@ export class SessionManagerService { // 从ConfigManager获取地图对应的Stream const stream = this.configManager.getStreamByMap(targetMapId) || 'General'; - // TODO: 根据玩家位置确定Topic - // 检查是否靠近交互对象 + // 根据玩家位置确定Topic(基础实现) + // 检查是否靠近交互对象,如果没有则使用默认Topic + let topic = 'General'; + + // 尝试根据位置查找附近的交互对象 + if (session.position) { + const nearbyObject = this.configManager.findNearbyObject( + targetMapId, + session.position.x, + session.position.y, + 50 // 50像素范围内 + ); + + if (nearbyObject) { + topic = nearbyObject.zulipTopic; + } + } const context: ContextInfo = { stream, - topic: undefined, // 暂时不设置Topic,使用默认的General + topic, }; this.logger.debug('上下文注入完成', { @@ -746,7 +767,9 @@ export class SessionManagerService { try { // 获取所有地图的玩家列表 - const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 + const mapIds = this.configManager.getAllMapIds().length > 0 + ? this.configManager.getAllMapIds() + : DEFAULT_MAP_IDS; for (const mapId of mapIds) { const socketIds = await this.getSocketsInMap(mapId); @@ -912,7 +935,9 @@ export class SessionManagerService { async getSessionStats(): Promise { try { // 获取所有地图的玩家列表 - const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 + const mapIds = this.configManager.getAllMapIds().length > 0 + ? this.configManager.getAllMapIds() + : DEFAULT_MAP_IDS; const mapDistribution: Record = {}; let totalSessions = 0; @@ -972,7 +997,9 @@ export class SessionManagerService { } } else { // 获取所有地图的会话 - const mapIds = ['novice_village', 'tavern', 'market']; // TODO: 从配置获取 + const mapIds = this.configManager.getAllMapIds().length > 0 + ? this.configManager.getAllMapIds() + : DEFAULT_MAP_IDS; for (const map of mapIds) { const socketIds = await this.getSocketsInMap(map); for (const socketId of socketIds) { diff --git a/src/business/zulip/zulip.module.ts b/src/business/zulip/zulip.module.ts index 4c2105e..8c5d44c 100644 --- a/src/business/zulip/zulip.module.ts +++ b/src/business/zulip/zulip.module.ts @@ -49,17 +49,20 @@ import { SessionManagerService } from './services/session_manager.service'; import { MessageFilterService } from './services/message_filter.service'; import { ZulipEventProcessorService } from './services/zulip_event_processor.service'; import { SessionCleanupService } from './services/session_cleanup.service'; +import { ZulipAccountsBusinessService } from './services/zulip_accounts_business.service'; import { ChatController } from './chat.controller'; import { WebSocketDocsController } from './websocket_docs.controller'; import { WebSocketOpenApiController } from './websocket_openapi.controller'; import { ZulipAccountsController } from './zulip_accounts.controller'; import { WebSocketTestController } from './websocket_test.controller'; +import { DynamicConfigController } from './dynamic_config.controller'; import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module'; import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; import { RedisModule } from '../../core/redis/redis.module'; import { LoggerModule } from '../../core/utils/logger/logger.module'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { AuthModule } from '../auth/auth.module'; +import { DynamicConfigManagerService } from '../../core/zulip_core/services/dynamic_config_manager.service'; @Module({ imports: [ @@ -89,6 +92,8 @@ import { AuthModule } from '../auth/auth.module'; SessionCleanupService, // WebSocket网关 - 处理游戏客户端WebSocket连接 CleanWebSocketGateway, + // 动态配置管理服务 - 从Zulip服务器动态获取配置 + DynamicConfigManagerService, ], controllers: [ // 聊天相关的REST API控制器 @@ -101,6 +106,8 @@ import { AuthModule } from '../auth/auth.module'; ZulipAccountsController, // WebSocket测试工具控制器 - 提供测试页面和API监控 WebSocketTestController, + // 动态配置管理控制器 - 提供配置管理API + DynamicConfigController, ], exports: [ // 导出主服务供其他模块使用 @@ -115,6 +122,8 @@ import { AuthModule } from '../auth/auth.module'; SessionCleanupService, // 导出WebSocket网关 CleanWebSocketGateway, + // 导出动态配置管理服务 + DynamicConfigManagerService, ], }) export class ZulipModule {} \ No newline at end of file diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 5cc97f1..894324b 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -421,36 +421,40 @@ export class ZulipService { email, }); - // 2. 从数据库和Redis获取Zulip信息 + // 2. 登录时直接从数据库获取Zulip信息(不使用Redis缓存) let zulipApiKey = undefined; let zulipEmail = undefined; try { - // 首先从数据库查找Zulip账号关联 + // 从数据库查找Zulip账号关联 const zulipAccount = await this.getZulipAccountByGameUserId(userId); if (zulipAccount) { zulipEmail = zulipAccount.zulipEmail; - // 然后从Redis获取API Key - const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - - if (apiKeyResult.success && apiKeyResult.apiKey) { - zulipApiKey = apiKeyResult.apiKey; + // 登录时直接从数据库获取加密的API Key并解密 + if (zulipAccount.zulipApiKeyEncrypted) { + // 这里需要解密API Key,暂时使用加密的值 + // 在实际实现中,应该调用解密服务 + zulipApiKey = await this.decryptApiKey(zulipAccount.zulipApiKeyEncrypted); - this.logger.log('从存储获取到Zulip信息', { + // 登录成功后,将API Key缓存到Redis供后续聊天使用 + if (zulipApiKey) { + await this.apiKeySecurityService.storeApiKey(userId, zulipApiKey); + } + + this.logger.log('从数据库获取到Zulip信息并缓存到Redis', { operation: 'validateGameToken', userId, zulipEmail, hasApiKey: true, - apiKeyLength: zulipApiKey.length, + apiKeyLength: zulipApiKey?.length || 0, }); } else { this.logger.debug('用户有Zulip账号关联但没有API Key', { operation: 'validateGameToken', userId, zulipEmail, - reason: apiKeyResult.message, }); } } else { @@ -461,7 +465,7 @@ export class ZulipService { } } catch (error) { const err = error as Error; - this.logger.warn('获取Zulip API Key失败', { + this.logger.warn('获取Zulip信息失败', { operation: 'validateGameToken', userId, error: err.message, @@ -490,24 +494,27 @@ export class ZulipService { * 处理玩家登出 * * 功能描述: - * 清理玩家会话,注销Zulip事件队列,释放相关资源 + * 清理玩家会话,注销Zulip事件队列,释放相关资源,清除Redis缓存 * * 业务逻辑: * 1. 获取会话信息 * 2. 注销Zulip事件队列 * 3. 清理Zulip客户端实例 - * 4. 删除会话映射关系 - * 5. 记录登出日志 + * 4. 清除Redis中的API Key缓存 + * 5. 删除会话映射关系 + * 6. 记录登出日志 * * @param socketId WebSocket连接ID + * @param reason 登出原因('manual' | 'timeout' | 'disconnect') * @returns Promise */ - async handlePlayerLogout(socketId: string): Promise { + async handlePlayerLogout(socketId: string, reason: 'manual' | 'timeout' | 'disconnect' = 'manual'): Promise { const startTime = Date.now(); this.logger.log('开始处理玩家登出', { operation: 'handlePlayerLogout', socketId, + reason, timestamp: new Date().toISOString(), }); @@ -519,30 +526,55 @@ export class ZulipService { this.logger.log('会话不存在,跳过登出处理', { operation: 'handlePlayerLogout', socketId, + reason, }); return; } + const userId = session.userId; + // 2. 清理Zulip客户端资源 - if (session.userId) { + if (userId) { try { - await this.zulipClientPool.destroyUserClient(session.userId); + await this.zulipClientPool.destroyUserClient(userId); this.logger.log('Zulip客户端清理完成', { operation: 'handlePlayerLogout', - userId: session.userId, + userId, + reason, }); } catch (zulipError) { const err = zulipError as Error; this.logger.warn('Zulip客户端清理失败', { operation: 'handlePlayerLogout', - userId: session.userId, + userId, error: err.message, + reason, }); - // 继续执行会话清理 + // 继续执行其他清理操作 + } + + // 3. 清除Redis中的API Key缓存(确保内存足够) + try { + const apiKeyDeleted = await this.apiKeySecurityService.deleteApiKey(userId); + this.logger.log('Redis API Key缓存清理完成', { + operation: 'handlePlayerLogout', + userId, + apiKeyDeleted, + reason, + }); + } catch (apiKeyError) { + const err = apiKeyError as Error; + this.logger.warn('Redis API Key缓存清理失败', { + operation: 'handlePlayerLogout', + userId, + error: err.message, + reason, + }); + // 继续执行其他清理操作 } } - // 3. 删除会话映射 + // 4. 删除会话映射 await this.sessionManager.destroySession(socketId); const duration = Date.now() - startTime; @@ -551,6 +583,7 @@ export class ZulipService { operation: 'handlePlayerLogout', socketId, userId: session.userId, + reason, duration, timestamp: new Date().toISOString(), }); @@ -562,6 +595,7 @@ export class ZulipService { this.logger.error('玩家登出处理失败', { operation: 'handlePlayerLogout', socketId, + reason, error: err.message, duration, timestamp: new Date().toISOString(), @@ -866,6 +900,19 @@ export class ZulipService { const startTime = Date.now(); try { + // 聊天过程中从Redis缓存获取API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); + + if (!apiKeyResult.success || !apiKeyResult.apiKey) { + this.logger.warn('聊天时无法获取API Key,跳过Zulip同步', { + operation: 'syncToZulipAsync', + userId, + gameMessageId, + reason: apiKeyResult.message || 'API Key不存在', + }); + return; + } + // 添加游戏消息ID到Zulip消息中,便于追踪 const zulipContent = `${content}\n\n*[游戏消息ID: ${gameMessageId}]*`; @@ -950,12 +997,13 @@ export class ZulipService { */ private async getZulipAccountByGameUserId(gameUserId: string): Promise { try { - // 这里需要注入ZulipAccountsService,暂时返回null - // 在实际实现中,应该通过依赖注入获取ZulipAccountsService + // 注入ZulipAccountsService,从数据库获取Zulip账号信息 + // 这里需要通过依赖注入获取ZulipAccountsService // const zulipAccount = await this.zulipAccountsService.findByGameUserId(gameUserId); // return zulipAccount; // 临时实现:直接返回null,表示没有找到Zulip账号关联 + // 在实际实现中,应该通过依赖注入获取ZulipAccountsService return null; } catch (error) { this.logger.warn('获取Zulip账号信息失败', { @@ -966,5 +1014,30 @@ export class ZulipService { return null; } } + + /** + * 解密API Key + * + * @param encryptedApiKey 加密的API Key + * @returns Promise 解密后的API Key + * @private + */ + private async decryptApiKey(encryptedApiKey: string): Promise { + try { + // 这里需要实现API Key的解密逻辑 + // 在实际实现中,应该调用加密服务进行解密 + // const decryptedKey = await this.encryptionService.decrypt(encryptedApiKey); + // return decryptedKey; + + // 临时实现:直接返回null + return null; + } catch (error) { + this.logger.warn('解密API Key失败', { + operation: 'decryptApiKey', + error: (error as Error).message, + }); + return null; + } + } } diff --git a/src/business/zulip/zulip_accounts.controller.ts b/src/business/zulip/zulip_accounts.controller.ts index 70d039b..9cc6c13 100644 --- a/src/business/zulip/zulip_accounts.controller.ts +++ b/src/business/zulip/zulip_accounts.controller.ts @@ -5,10 +5,25 @@ * - 提供Zulip账号关联管理的REST API接口 * - 支持CRUD操作和批量管理 * - 提供账号验证和统计功能 + * - 集成性能监控和结构化日志记录 + * - 实现统一的错误处理和响应格式 + * + * 职责分离: + * - API接口:提供RESTful风格的HTTP接口 + * - 参数验证:使用DTO进行请求参数验证 + * - 业务调用:调用Service层处理业务逻辑 + * - 响应格式:统一API响应格式和错误处理 + * - 性能监控:记录接口调用耗时和性能指标 + * - 日志记录:使用AppLoggerService记录结构化日志 + * + * 最近修改: + * - 2026-01-12: 性能优化 - 集成AppLoggerService和性能监控,优化错误处理 + * - 2025-01-07: 初始创建 - 实现基础的CRUD和管理接口 * * @author angjustinl - * @version 1.0.0 + * @version 1.1.0 * @since 2025-01-07 + * @lastModified 2026-01-12 */ import { @@ -24,6 +39,7 @@ import { HttpStatus, HttpCode, Inject, + Req, } from '@nestjs/common'; import { ApiTags, @@ -33,9 +49,11 @@ import { ApiParam, ApiQuery, } from '@nestjs/swagger'; +import { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt_auth.guard'; import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; import { ZulipAccountsMemoryService } from '../../core/db/zulip_accounts/zulip_accounts_memory.service'; +import { AppLoggerService } from '../../core/utils/logger/logger.service'; import { CreateZulipAccountDto, UpdateZulipAccountDto, @@ -54,9 +72,58 @@ import { @UseGuards(JwtAuthGuard) @ApiBearerAuth('JWT-auth') export class ZulipAccountsController { + private readonly requestLogger: any; + constructor( @Inject('ZulipAccountsService') private readonly zulipAccountsService: any, - ) {} + @Inject(AppLoggerService) private readonly logger: AppLoggerService, + ) { + this.logger.info('ZulipAccountsController初始化完成', { + module: 'ZulipAccountsController', + operation: 'constructor' + }); + } + + /** + * 创建性能监控器 + * + * @param req HTTP请求对象 + * @param operation 操作名称 + * @param context 上下文信息 + * @returns 性能监控器 + * @private + */ + private createPerformanceMonitor(req: Request, operation: string, context?: Record) { + const startTime = Date.now(); + const requestLogger = this.logger.bindRequest(req, 'ZulipAccountsController'); + + requestLogger.info(`开始${operation}`, context); + + return { + success: (additionalContext?: Record) => { + const duration = Date.now() - startTime; + requestLogger.info(`${operation}成功`, { + ...context, + ...additionalContext, + duration + }); + }, + error: (error: unknown, additionalContext?: Record) => { + const duration = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + requestLogger.error( + `${operation}失败`, + error instanceof Error ? error.stack : undefined, + { + ...context, + ...additionalContext, + error: errorMessage, + duration + } + ); + } + }; + } /** * 创建Zulip账号关联 @@ -80,8 +147,27 @@ export class ZulipAccountsController { description: '关联已存在', }) @HttpCode(HttpStatus.CREATED) - async create(@Body() createDto: CreateZulipAccountDto): Promise { - return this.zulipAccountsService.create(createDto); + async create( + @Req() req: Request, + @Body() createDto: CreateZulipAccountDto + ): Promise { + const monitor = this.createPerformanceMonitor(req, '创建Zulip账号关联', { + gameUserId: createDto.gameUserId, + zulipUserId: createDto.zulipUserId, + zulipEmail: createDto.zulipEmail + }); + + try { + const result = await this.zulipAccountsService.create(createDto); + monitor.success({ + accountId: result.id, + status: result.status + }); + return result; + } catch (error) { + monitor.error(error); + throw error; + } } /** @@ -480,8 +566,21 @@ export class ZulipAccountsController { description: '获取成功', type: ZulipAccountStatsResponseDto, }) - async getStatusStatistics(): Promise { - return this.zulipAccountsService.getStatusStatistics(); + async getStatusStatistics(@Req() req: Request): Promise { + const monitor = this.createPerformanceMonitor(req, '获取账号状态统计'); + + try { + const result = await this.zulipAccountsService.getStatusStatistics(); + monitor.success({ + total: result.total, + active: result.active, + error: result.error + }); + return result; + } catch (error) { + monitor.error(error); + throw error; + } } /**