3 Commits

Author SHA1 Message Date
moyin
cf1b37af78 feat(chat): 实现登录时自动初始化Zulip客户端
范围: src/business/chat/
涉及文件:
- chat.module.ts
- chat.service.ts

主要功能:
- 添加ZulipAccountsModule依赖,支持查询用户Zulip账号
- 实现initializeZulipClientForUser方法,登录时自动初始化Zulip客户端
- 从数据库获取用户Zulip账号信息和API Key
- 优化会话创建流程,使用已创建的Zulip客户端队列ID
- 移除登出时的API Key删除逻辑,保持持久化
- 支持基于目标地图的消息发送(mapId参数)

技术改进:
- 分离Zulip客户端初始化逻辑,提高代码可维护性
- 添加完整的错误处理和日志记录
- 支持用户没有Zulip账号的场景(优雅降级)
2026-01-19 18:29:53 +08:00
moyin
1849415b11 test(chat): 修复测试文件Mock配置
范围: src/business/chat/
涉及文件:
- chat.module.spec.ts
- chat.service.spec.ts

主要改进:
- 添加缺失的ZulipAccountsService Mock配置
- 修复handlePlayerLogout测试,删除过时的deleteApiKey断言
- 删除不再需要的API Key清理失败测试用例
- 添加getUserClient Mock方法
- 设置默认Mock行为,提高测试稳定性
2026-01-19 18:29:27 +08:00
moyin
963e6ca90f refactor(auth): 移除登录注册时的Zulip内存关联逻辑
范围: src/business/auth/
涉及文件:
- src/business/auth/login.service.ts
- src/business/auth/register.service.ts

主要改进:
- 移除登录时建立Zulip内存关联的代码
- 移除注册时建立Zulip内存关联的代码
- 改为在WebSocket连接时由Zulip客户端创建内存关联
- 优化了内存关联的时机,避免不必要的提前创建

技术说明:
- 原逻辑在登录/注册时就建立内存关联,但用户可能不会立即使用Zulip
- 新逻辑延迟到WebSocket连接时创建,更加合理和高效
- 减少了登录/注册流程的复杂度和耦合度
2026-01-19 17:59:58 +08:00
6 changed files with 196 additions and 74 deletions

View File

@@ -714,13 +714,7 @@ export class LoginService {
apiKeyResult.apiKey! apiKeyResult.apiKey!
); );
// 4. 更新内存关联 // 注意不在登录时建立内存关联Zulip客户端将在WebSocket连接时创建
await this.zulipAccountService.linkGameAccount(
user.id.toString(),
zulipAccount.zulipUserId,
zulipAccount.zulipEmail,
apiKeyResult.apiKey!
);
const duration = Date.now() - startTime; const duration = Date.now() - startTime;

View File

@@ -533,15 +533,7 @@ export class RegisterService {
status: 'active', status: 'active',
}); });
// 6. 建立游戏账号与Zulip账号的内存关联用于当前会话 // 注意不在注册时建立内存关联Zulip客户端将在WebSocket连接时创建
if (finalApiKey) {
await this.zulipAccountService.linkGameAccount(
gameUser.id.toString(),
createResult.userId, // 已在上面验证不为 undefined
createResult.email!,
finalApiKey
);
}
const duration = Date.now() - startTime; const duration = Date.now() - startTime;

View File

@@ -7,9 +7,12 @@
* - 接口导出验证 * - 接口导出验证
* *
* @author moyin * @author moyin
* @version 1.0.0 * @version 1.0.1
* @since 2026-01-14 * @since 2026-01-14
* @lastModified 2026-01-14 * @lastModified 2026-01-19
*
* 修改记录:
* - 2026-01-19 moyin: Bug修复 - 添加缺失的ZulipAccountsService Mock配置
*/ */
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
@@ -33,6 +36,7 @@ describe('ChatModule', () => {
createUserClient: jest.fn(), createUserClient: jest.fn(),
destroyUserClient: jest.fn(), destroyUserClient: jest.fn(),
sendMessage: jest.fn(), sendMessage: jest.fn(),
getUserClient: jest.fn(),
}; };
const mockZulipConfigService = { const mockZulipConfigService = {
@@ -61,6 +65,10 @@ describe('ChatModule', () => {
verifyToken: jest.fn(), verifyToken: jest.fn(),
}; };
const mockZulipAccountsService = {
findByGameUserId: jest.fn(),
};
beforeEach(async () => { beforeEach(async () => {
// 禁用日志输出 // 禁用日志输出
jest.spyOn(Logger.prototype, 'log').mockImplementation(); jest.spyOn(Logger.prototype, 'log').mockImplementation();
@@ -97,6 +105,10 @@ describe('ChatModule', () => {
provide: LoginCoreService, provide: LoginCoreService,
useValue: mockLoginCoreService, useValue: mockLoginCoreService,
}, },
{
provide: 'ZulipAccountsService',
useValue: mockZulipAccountsService,
},
], ],
}).compile(); }).compile();

View File

@@ -12,17 +12,19 @@
* - 依赖 ZulipCoreModule核心层提供Zulip技术服务 * - 依赖 ZulipCoreModule核心层提供Zulip技术服务
* - 依赖 RedisModule核心层提供缓存服务 * - 依赖 RedisModule核心层提供缓存服务
* - 依赖 LoginCoreModule核心层提供Token验证 * - 依赖 LoginCoreModule核心层提供Token验证
* - 依赖 ZulipAccountsModule核心层提供Zulip账号数据访问
* *
* 导出接口: * 导出接口:
* - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用) * - SESSION_QUERY_SERVICE: 会话查询接口(供其他 Business 模块使用)
* *
* 最近修改: * 最近修改:
* - 2026-01-15: 功能完善 - 添加ZulipAccountsModule依赖支持登录时初始化Zulip客户端 (修改者: AI)
* - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善文件头注释规范 (修改者: moyin)
* *
* @author moyin * @author moyin
* @version 1.1.1 * @version 1.2.0
* @since 2026-01-14 * @since 2026-01-14
* @lastModified 2026-01-14 * @lastModified 2026-01-15
*/ */
import { Module } from '@nestjs/common'; 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 { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { RedisModule } from '../../core/redis/redis.module'; import { RedisModule } from '../../core/redis/redis.module';
import { LoginCoreModule } from '../../core/login_core/login_core.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'; import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.interfaces';
@Module({ @Module({
@@ -43,6 +46,8 @@ import { SESSION_QUERY_SERVICE } from '../../core/session_core/session_core.inte
RedisModule, RedisModule,
// 登录核心模块 // 登录核心模块
LoginCoreModule, LoginCoreModule,
// Zulip账号数据库模块
ZulipAccountsModule.forRoot(),
], ],
providers: [ providers: [
// 主聊天服务 // 主聊天服务

View File

@@ -8,9 +8,12 @@
* - Token验证和错误处理 * - Token验证和错误处理
* *
* @author moyin * @author moyin
* @version 1.0.0 * @version 1.0.1
* @since 2026-01-14 * @since 2026-01-14
* @lastModified 2026-01-14 * @lastModified 2026-01-19
*
* 修改记录:
* - 2026-01-19 moyin: 修复handlePlayerLogout测试删除不再调用的deleteApiKey断言和过时测试用例
*/ */
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
@@ -51,6 +54,7 @@ describe('ChatService', () => {
createUserClient: jest.fn(), createUserClient: jest.fn(),
destroyUserClient: jest.fn(), destroyUserClient: jest.fn(),
sendMessage: jest.fn(), sendMessage: jest.fn(),
getUserClient: jest.fn(),
}; };
const mockApiKeySecurityService = { const mockApiKeySecurityService = {
@@ -62,6 +66,10 @@ describe('ChatService', () => {
verifyToken: jest.fn(), verifyToken: jest.fn(),
}; };
const mockZulipAccountsService = {
findByGameUserId: jest.fn(),
};
mockWebSocketGateway = { mockWebSocketGateway = {
broadcastToMap: jest.fn(), broadcastToMap: jest.fn(),
sendToPlayer: jest.fn(), sendToPlayer: jest.fn(),
@@ -90,6 +98,10 @@ describe('ChatService', () => {
provide: LoginCoreService, provide: LoginCoreService,
useValue: mockLoginCoreService, useValue: mockLoginCoreService,
}, },
{
provide: 'ZulipAccountsService',
useValue: mockZulipAccountsService,
},
], ],
}).compile(); }).compile();
@@ -100,6 +112,14 @@ describe('ChatService', () => {
apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE'); apiKeySecurityService = module.get('API_KEY_SECURITY_SERVICE');
loginCoreService = module.get(LoginCoreService); loginCoreService = module.get(LoginCoreService);
// 设置默认的mock行为
// ZulipAccountsService默认返回null用户没有Zulip账号
const zulipAccountsService = module.get('ZulipAccountsService');
zulipAccountsService.findByGameUserId.mockResolvedValue(null);
// ZulipClientPool的getUserClient默认返回null
zulipClientPool.getUserClient.mockResolvedValue(null);
// 设置WebSocket网关 // 设置WebSocket网关
service.setWebSocketGateway(mockWebSocketGateway); service.setWebSocketGateway(mockWebSocketGateway);
@@ -220,14 +240,12 @@ describe('ChatService', () => {
createdAt: new Date(), createdAt: new Date(),
}); });
zulipClientPool.destroyUserClient.mockResolvedValue(undefined); zulipClientPool.destroyUserClient.mockResolvedValue(undefined);
apiKeySecurityService.deleteApiKey.mockResolvedValue(undefined);
sessionService.destroySession.mockResolvedValue(true); sessionService.destroySession.mockResolvedValue(true);
await service.handlePlayerLogout(socketId, 'manual'); await service.handlePlayerLogout(socketId, 'manual');
expect(sessionService.getSession).toHaveBeenCalledWith(socketId); expect(sessionService.getSession).toHaveBeenCalledWith(socketId);
expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId); expect(zulipClientPool.destroyUserClient).toHaveBeenCalledWith(userId);
expect(apiKeySecurityService.deleteApiKey).toHaveBeenCalledWith(userId);
expect(sessionService.destroySession).toHaveBeenCalledWith(socketId); expect(sessionService.destroySession).toHaveBeenCalledWith(socketId);
}); });
@@ -257,25 +275,6 @@ describe('ChatService', () => {
expect(sessionService.destroySession).toHaveBeenCalled(); expect(sessionService.destroySession).toHaveBeenCalled();
}); });
it('应该处理API Key清理失败', async () => {
sessionService.getSession.mockResolvedValue({
socketId,
userId,
username: 'testuser',
zulipQueueId: 'queue_123',
currentMap: 'whale_port',
position: { x: 400, y: 300 },
lastActivity: new Date(),
createdAt: new Date(),
});
apiKeySecurityService.deleteApiKey.mockRejectedValue(new Error('Redis error'));
sessionService.destroySession.mockResolvedValue(true);
await service.handlePlayerLogout(socketId);
expect(sessionService.destroySession).toHaveBeenCalled();
});
}); });
describe('sendChatMessage', () => { describe('sendChatMessage', () => {

View File

@@ -14,15 +14,16 @@
* - ⚡ 低延迟聊天体验 * - ⚡ 低延迟聊天体验
* *
* 最近修改: * 最近修改:
* - 2026-01-15: 功能完善 - WebSocket登录时自动初始化用户Zulip客户端 (修改者: AI)
* - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 提取魔法数字为常量 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充类级别JSDoc注释 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 补充接口定义的JSDoc注释 (修改者: moyin)
* - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin) * - 2026-01-14: 代码规范优化 - 完善文件头注释和方法注释规范 (修改者: moyin)
* *
* @author moyin * @author moyin
* @version 1.0.4 * @version 1.1.0
* @since 2026-01-14 * @since 2026-01-14
* @lastModified 2026-01-14 * @lastModified 2026-01-15
*/ */
import { Injectable, Logger, Inject } from '@nestjs/common'; import { Injectable, Logger, Inject } from '@nestjs/common';
@@ -34,6 +35,8 @@ import {
IApiKeySecurityService, IApiKeySecurityService,
} from '../../core/zulip_core/zulip_core.interfaces'; } from '../../core/zulip_core/zulip_core.interfaces';
import { LoginCoreService } from '../../core/login_core/login_core.service'; 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; content: string;
/** 消息范围local(本地)、global(全局) */ /** 消息范围local(本地)、global(全局) */
scope: string; scope: string;
/** 目标地图ID可选不传则使用会话当前地图 */
mapId?: string;
} }
/** /**
@@ -179,6 +184,8 @@ export class ChatService {
@Inject('API_KEY_SECURITY_SERVICE') @Inject('API_KEY_SECURITY_SERVICE')
private readonly apiKeySecurityService: IApiKeySecurityService, private readonly apiKeySecurityService: IApiKeySecurityService,
private readonly loginCoreService: LoginCoreService, private readonly loginCoreService: LoginCoreService,
@Inject('ZulipAccountsService')
private readonly zulipAccountsService: ZulipAccountsService | ZulipAccountsMemoryService,
) { ) {
this.logger.log('ChatService初始化完成'); this.logger.log('ChatService初始化完成');
} }
@@ -217,7 +224,10 @@ export class ChatService {
return { success: false, error: 'Token验证失败' }; return { success: false, error: 'Token验证失败' };
} }
// 3. 创建会话 // 3. 初始化用户的Zulip客户端从数据库获取Zulip账号信息
await this.initializeZulipClientForUser(userInfo.userId);
// 4. 创建会话
const sessionResult = await this.createUserSession(request.socketId, userInfo); const sessionResult = await this.createUserSession(request.socketId, userInfo);
this.logger.log('玩家登录成功', { this.logger.log('玩家登录成功', {
@@ -256,20 +266,13 @@ export class ChatService {
const userId = session.userId; const userId = session.userId;
// 清理Zulip客户端 // 清理Zulip客户端注意不删除Redis中的API Key保持持久化
if (userId) { if (userId) {
try { try {
await this.zulipClientPool.destroyUserClient(userId); await this.zulipClientPool.destroyUserClient(userId);
} catch (e) { } catch (e) {
this.logger.warn('Zulip客户端清理失败', { error: (e as Error).message }); 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: '会话不存在,请重新登录' }; return { success: false, error: '会话不存在,请重新登录' };
} }
// 2. 获取上下文 // 2. 确定目标地图优先使用请求中的mapId否则使用会话当前地图
const context = await this.sessionService.injectContext(request.socketId); const targetMapId = request.mapId || session.currentMap;
// 3. 获取上下文
const context = await this.sessionService.injectContext(request.socketId, targetMapId);
const targetStream = context.stream; const targetStream = context.stream;
const targetTopic = context.topic || 'General'; const targetTopic = context.topic || 'General';
// 3. 消息验证 // 4. 消息验证
const validationResult = await this.filterService.validateMessage( const validationResult = await this.filterService.validateMessage(
session.userId, session.userId,
request.content, request.content,
targetStream, targetStream,
session.currentMap, targetMapId,
); );
if (!validationResult.allowed) { if (!validationResult.allowed) {
@@ -323,7 +329,7 @@ export class ChatService {
const messageContent = validationResult.filteredContent || request.content; const messageContent = validationResult.filteredContent || request.content;
const messageId = `game_${Date.now()}_${session.userId}`; const messageId = `game_${Date.now()}_${session.userId}`;
// 4. 🚀 立即广播给游戏内玩家 // 5. 🚀 立即广播给游戏内玩家根据scope决定广播范围
const gameMessage: GameChatMessage = { const gameMessage: GameChatMessage = {
t: 'chat_render', t: 'chat_render',
from: session.username, from: session.username,
@@ -331,14 +337,15 @@ export class ChatService {
bubble: true, bubble: true,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
messageId, messageId,
mapId: session.currentMap, mapId: targetMapId,
scope: request.scope, 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 })); .catch(e => this.logger.warn('游戏内广播失败', { error: (e as Error).message }));
// 5. 🔄 异步同步到Zulip // 6. 🔄 异步同步到Zulip
this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId) this.syncToZulipAsync(session.userId, targetStream, targetTopic, messageContent, messageId)
.catch(e => this.logger.warn('Zulip同步失败', { error: (e as Error).message })); .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<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) { private async validateGameToken(token: string) {
try { try {
const payload = await this.loginCoreService.verifyToken(token, 'access'); const payload = await this.loginCoreService.verifyToken(token, 'access');
@@ -441,20 +563,18 @@ export class ChatService {
private async createUserSession(socketId: string, userInfo: any) { private async createUserSession(socketId: string, userInfo: any) {
const sessionId = randomUUID(); const sessionId = randomUUID();
let zulipQueueId = `queue_${sessionId}`;
// 尝试创建Zulip客户端 // 尝试获取已创建Zulip客户端的队列ID
if (userInfo.zulipApiKey) { let zulipQueueId = `queue_${sessionId}`;
try { try {
const clientInstance = await this.zulipClientPool.createUserClient(userInfo.userId, { const existingClient = await this.zulipClientPool.getUserClient(userInfo.userId);
username: userInfo.zulipEmail || userInfo.email, if (existingClient?.queueId) {
apiKey: userInfo.zulipApiKey, zulipQueueId = existingClient.queueId;
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 });
} }
} catch (e) {
this.logger.debug('获取Zulip客户端队列ID失败使用默认值', {
error: (e as Error).message
});
} }
const session = await this.sessionService.createSession( const session = await this.sessionService.createSession(