From 6ad8d804497a821a0100c6e10db6c2fe7d2ece89 Mon Sep 17 00:00:00 2001 From: angjustinl <96008766+ANGJustinl@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:41:54 +0800 Subject: [PATCH 1/4] feat(zulip): Add Zulip account management and integrate with auth system - Add ZulipAccountsEntity, repository, and module for persistent Zulip account storage - Create ZulipAccountService in core layer for managing Zulip account lifecycle - Integrate Zulip account creation into login flow via LoginService - Add comprehensive test suite for Zulip account creation during user registration - Create quick test script for validating registered user Zulip integration - Update UsersEntity to support Zulip account associations - Update auth module to include Zulip and ZulipAccounts dependencies - Fix WebSocket connection protocol from ws:// to wss:// in API documentation - Enhance LoginCoreService to coordinate Zulip account provisioning during authentication --- docs/systems/zulip/api.md | 2 +- .../zulip/quick_tests/test-registered-user.js | 232 ++++++ src/business/auth/auth.module.ts | 12 +- src/business/auth/services/login.service.ts | 262 ++++++- .../login.service.zulip-account.spec.ts | 520 +++++++++++++ src/business/zulip/zulip.service.ts | 53 +- src/core/db/users/users.entity.ts | 24 +- .../zulip_accounts/zulip_accounts.entity.ts | 185 +++++ .../zulip_accounts/zulip_accounts.module.ts | 81 ++ .../zulip_accounts.repository.ts | 323 ++++++++ .../zulip_accounts_memory.repository.ts | 299 ++++++++ src/core/login_core/login_core.service.ts | 32 + .../zulip/services/zulip_account.service.ts | 708 ++++++++++++++++++ src/core/zulip/zulip-core.module.ts | 3 + 14 files changed, 2698 insertions(+), 38 deletions(-) create mode 100644 docs/systems/zulip/quick_tests/test-registered-user.js create mode 100644 src/business/auth/services/login.service.zulip-account.spec.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.entity.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.module.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.repository.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts create mode 100644 src/core/zulip/services/zulip_account.service.ts diff --git a/docs/systems/zulip/api.md b/docs/systems/zulip/api.md index 86e3548..35af5b0 100644 --- a/docs/systems/zulip/api.md +++ b/docs/systems/zulip/api.md @@ -5,7 +5,7 @@ ### 连接地址 ``` -ws://localhost:3000/game +wss://localhost:3000/game ``` ### 连接参数 diff --git a/docs/systems/zulip/quick_tests/test-registered-user.js b/docs/systems/zulip/quick_tests/test-registered-user.js new file mode 100644 index 0000000..b2e0b1c --- /dev/null +++ b/docs/systems/zulip/quick_tests/test-registered-user.js @@ -0,0 +1,232 @@ +/** + * 测试新注册用户的Zulip账号功能 + * + * 功能: + * 1. 验证新注册用户可以通过游戏服务器登录 + * 2. 验证Zulip账号已正确创建和关联 + * 3. 验证用户可以通过WebSocket发送消息到Zulip + * 4. 验证用户可以接收来自Zulip的消息 + * + * 使用方法: + * node docs/systems/zulip/quick_tests/test-registered-user.js + */ + +const io = require('socket.io-client'); +const axios = require('axios'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 步骤1: 登录游戏服务器获取token + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 昵称: ${response.data.data.user.nickname}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + +/** + * 步骤2: 通过WebSocket连接并测试Zulip集成 + */ +async function testZulipIntegration(userInfo) { + console.log('\n📡 步骤 2: 测试 Zulip 集成'); + console.log(` 连接到: ${GAME_SERVER}/game`); + + return new Promise((resolve, reject) => { + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); + + let testStep = 0; + let testResults = { + connected: false, + loggedIn: false, + messageSent: false, + messageReceived: false + }; + + // 连接成功 + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + testResults.connected = true; + testStep = 1; + + // 发送登录消息 + const loginMessage = { + type: 'login', + token: userInfo.token + }; + + console.log('📤 发送登录消息...'); + socket.emit('login', loginMessage); + }); + + // 登录成功 + socket.on('login_success', (data) => { + console.log('✅ 登录成功'); + console.log(` 会话ID: ${data.sessionId}`); + console.log(` 用户ID: ${data.userId}`); + console.log(` 用户名: ${data.username}`); + console.log(` 当前地图: ${data.currentMap}`); + testResults.loggedIn = true; + testStep = 2; + + // 等待Zulip客户端初始化 + console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...'); + setTimeout(() => { + const chatMessage = { + t: 'chat', + content: `🎮 【注册用户测试】来自 ${userInfo.username} 的消息!\n` + + `时间: ${new Date().toLocaleString()}\n` + + `这是通过新注册账号发送的测试消息。`, + scope: 'local' + }; + + console.log('📤 发送测试消息到 Zulip...'); + console.log(` 内容: ${chatMessage.content.split('\n')[0]}`); + socket.emit('chat', chatMessage); + }, 3000); + }); + + // 消息发送成功 + socket.on('chat_sent', (data) => { + console.log('✅ 消息发送成功'); + console.log(` 消息ID: ${data.id || '未知'}`); + testResults.messageSent = true; + testStep = 3; + + // 等待一段时间接收消息 + setTimeout(() => { + console.log('\n📊 测试完成,断开连接...'); + socket.disconnect(); + }, 5000); + }); + + // 接收到消息 + socket.on('chat_render', (data) => { + console.log('📨 收到来自 Zulip 的消息:'); + console.log(` 发送者: ${data.from}`); + console.log(` 内容: ${data.txt}`); + console.log(` Stream: ${data.stream || '未知'}`); + console.log(` Topic: ${data.topic || '未知'}`); + testResults.messageReceived = true; + }); + + // 错误处理 + socket.on('error', (error) => { + console.error('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + // 连接断开 + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + resolve(testResults); + }); + + // 连接错误 + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + reject(error); + }); + + // 超时保护 + setTimeout(() => { + if (socket.connected) { + socket.disconnect(); + } + }, 15000); + }); +} + +/** + * 打印测试结果 + */ +function printTestResults(results) { + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + + const checks = [ + { name: 'WebSocket 连接', passed: results.connected }, + { name: '游戏服务器登录', passed: results.loggedIn }, + { name: '发送消息到 Zulip', passed: results.messageSent }, + { name: '接收 Zulip 消息', passed: results.messageReceived } + ]; + + checks.forEach(check => { + const icon = check.passed ? '✅' : '❌'; + console.log(`${icon} ${check.name}: ${check.passed ? '通过' : '失败'}`); + }); + + const passedCount = checks.filter(c => c.passed).length; + const totalCount = checks.length; + + console.log('='.repeat(60)); + console.log(`总计: ${passedCount}/${totalCount} 项测试通过`); + + if (passedCount === totalCount) { + console.log('\n🎉 所有测试通过!Zulip账号创建和集成功能正常!'); + console.log('💡 提示: 请访问 https://zulip.xinghangee.icu/ 查看发送的消息'); + } else { + console.log('\n⚠️ 部分测试失败,请检查日志'); + } + console.log('='.repeat(60)); +} + +/** + * 主测试流程 + */ +async function runTest() { + console.log('🚀 开始测试新注册用户的 Zulip 集成功能'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录 + const userInfo = await loginToGameServer(); + + // 步骤2: 测试Zulip集成 + const results = await testZulipIntegration(userInfo); + + // 打印结果 + printTestResults(results); + + process.exit(results.connected && results.loggedIn && results.messageSent ? 0 : 1); + } catch (error) { + console.error('\n❌ 测试失败:', error.message); + process.exit(1); + } +} + +// 运行测试 +runTest(); diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 28e8065..60e8e66 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -16,11 +16,19 @@ import { Module } from '@nestjs/common'; import { LoginController } from './controllers/login.controller'; import { LoginService } from './services/login.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; +import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; @Module({ - imports: [LoginCoreModule], + imports: [ + LoginCoreModule, + ZulipCoreModule, + ZulipAccountsModule.forRoot(), + ], controllers: [LoginController], - providers: [LoginService], + providers: [ + LoginService, + ], exports: [LoginService], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index a0f6503..a9bcc0c 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -16,9 +16,12 @@ * @since 2025-12-17 */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service'; import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; /** * 登录响应数据接口 @@ -65,6 +68,10 @@ export class LoginService { constructor( private readonly loginCoreService: LoginCoreService, + private readonly zulipAccountService: ZulipAccountService, + @Inject('ZulipAccountsRepository') + private readonly zulipAccountsRepository: ZulipAccountsRepository, + private readonly apiKeySecurityService: ApiKeySecurityService, ) {} /** @@ -116,36 +123,106 @@ export class LoginService { * @returns 注册响应 */ async register(registerRequest: RegisterRequest): Promise> { + const startTime = Date.now(); + try { this.logger.log(`用户注册尝试: ${registerRequest.username}`); - // 调用核心服务进行注册 + // 1. 初始化Zulip管理员客户端 + await this.initializeZulipAdminClient(); + + // 2. 调用核心服务进行注册 const authResult = await this.loginCoreService.register(registerRequest); - // 生成访问令牌 + // 3. 创建Zulip账号(使用相同的邮箱和密码) + let zulipAccountCreated = false; + try { + if (registerRequest.email && registerRequest.password) { + await this.createZulipAccountForUser(authResult.user, registerRequest.password); + zulipAccountCreated = true; + + this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + email: registerRequest.email, + }); + } else { + this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, { + operation: 'register', + username: registerRequest.username, + hasEmail: !!registerRequest.email, + hasPassword: !!registerRequest.password, + }); + } + } catch (zulipError) { + const err = zulipError as Error; + this.logger.error(`Zulip账号创建失败,回滚用户注册`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + zulipError: err.message, + }, err.stack); + + // 回滚游戏用户注册 + try { + await this.loginCoreService.deleteUser(authResult.user.id); + this.logger.log(`用户注册回滚成功: ${registerRequest.username}`); + } catch (rollbackError) { + const rollbackErr = rollbackError as Error; + this.logger.error(`用户注册回滚失败`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + rollbackError: rollbackErr.message, + }, rollbackErr.stack); + } + + // 抛出原始错误 + throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); + } + + // 4. 生成访问令牌 const accessToken = this.generateAccessToken(authResult.user); - // 格式化响应数据 + // 5. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), access_token: accessToken, is_new_user: true, - message: '注册成功' + message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功' }; - this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + const duration = Date.now() - startTime; + + this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + username: authResult.user.username, + zulipAccountCreated, + duration, + timestamp: new Date().toISOString(), + }); return { success: true, data: response, - message: '注册成功' + message: response.message }; } catch (error) { - this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error)); + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error(`用户注册失败: ${registerRequest.username}`, { + operation: 'register', + username: registerRequest.username, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); return { success: false, - message: error instanceof Error ? error.message : '注册失败', + message: err.message || '注册失败', error_code: 'REGISTER_FAILED' }; } @@ -592,4 +669,171 @@ export class LoginService { }; } } + + /** + * 初始化Zulip管理员客户端 + * + * 功能描述: + * 使用环境变量中的管理员凭证初始化Zulip客户端 + * + * 业务逻辑: + * 1. 从环境变量获取管理员配置 + * 2. 验证配置完整性 + * 3. 初始化ZulipAccountService的管理员客户端 + * + * @throws Error 当配置缺失或初始化失败时 + * @private + */ + private async initializeZulipAdminClient(): Promise { + try { + // 从环境变量获取管理员配置 + const adminConfig = { + realm: process.env.ZULIP_SERVER_URL || '', + username: process.env.ZULIP_BOT_EMAIL || '', + apiKey: process.env.ZULIP_BOT_API_KEY || '', + }; + + // 验证配置完整性 + if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) { + throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'); + } + + // 初始化管理员客户端 + const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); + + if (!initialized) { + throw new Error('Zulip管理员客户端初始化失败'); + } + + this.logger.log('Zulip管理员客户端初始化成功', { + operation: 'initializeZulipAdminClient', + realm: adminConfig.realm, + adminEmail: adminConfig.username, + }); + + } catch (error) { + const err = error as Error; + this.logger.error('Zulip管理员客户端初始化失败', { + operation: 'initializeZulipAdminClient', + error: err.message, + }, err.stack); + throw error; + } + } + + /** + * 为用户创建Zulip账号 + * + * 功能描述: + * 为新注册的游戏用户创建对应的Zulip账号并建立关联 + * + * 业务逻辑: + * 1. 使用相同的邮箱和密码创建Zulip账号 + * 2. 加密存储API Key + * 3. 在数据库中建立关联关系 + * 4. 处理创建失败的情况 + * + * @param gameUser 游戏用户信息 + * @param password 用户密码(明文) + * @throws Error 当Zulip账号创建失败时 + * @private + */ + private async createZulipAccountForUser(gameUser: Users, password: string): Promise { + const startTime = Date.now(); + + this.logger.log('开始为用户创建Zulip账号', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + nickname: gameUser.nickname, + }); + + try { + // 1. 检查是否已存在Zulip账号关联 + const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id); + if (existingAccount) { + this.logger.warn('用户已存在Zulip账号关联,跳过创建', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + existingZulipUserId: existingAccount.zulipUserId, + }); + return; + } + + // 2. 创建Zulip账号 + const createResult = await this.zulipAccountService.createZulipAccount({ + email: gameUser.email, + fullName: gameUser.nickname, + password: password, + }); + + if (!createResult.success) { + throw new Error(createResult.error || 'Zulip账号创建失败'); + } + + // 3. 存储API Key + if (createResult.apiKey) { + await this.apiKeySecurityService.storeApiKey( + gameUser.id.toString(), + createResult.apiKey + ); + } + + // 4. 在数据库中创建关联记录 + await this.zulipAccountsRepository.create({ + gameUserId: gameUser.id, + zulipUserId: createResult.userId!, + zulipEmail: createResult.email!, + zulipFullName: gameUser.nickname, + zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中 + status: 'active', + }); + + // 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话) + if (createResult.apiKey) { + await this.zulipAccountService.linkGameAccount( + gameUser.id.toString(), + createResult.userId!, + createResult.email!, + createResult.apiKey + ); + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建和关联成功', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + zulipUserId: createResult.userId, + zulipEmail: createResult.email, + hasApiKey: !!createResult.apiKey, + duration, + }); + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('为用户创建Zulip账号失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + error: err.message, + duration, + }, err.stack); + + // 清理可能创建的部分数据 + try { + await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id); + } catch (cleanupError) { + this.logger.warn('清理Zulip账号关联数据失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + cleanupError: (cleanupError as Error).message, + }); + } + + throw error; + } + } } \ No newline at end of file diff --git a/src/business/auth/services/login.service.zulip-account.spec.ts b/src/business/auth/services/login.service.zulip-account.spec.ts new file mode 100644 index 0000000..4011b47 --- /dev/null +++ b/src/business/auth/services/login.service.zulip-account.spec.ts @@ -0,0 +1,520 @@ +/** + * LoginService Zulip账号创建属性测试 + * + * 功能描述: + * - 测试用户注册时Zulip账号创建的一致性 + * - 验证账号关联和数据完整性 + * - 测试失败回滚机制 + * + * 属性测试: + * - 属性 13: Zulip账号创建一致性 + * - 验证需求: 账号创建成功率和数据一致性 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as fc from 'fast-check'; +import { LoginService } from './login.service'; +import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccounts } from '../../../core/db/zulip_accounts/zulip_accounts.entity'; + +describe('LoginService - Zulip账号创建属性测试', () => { + let loginService: LoginService; + let loginCoreService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let zulipAccountsRepository: jest.Mocked; + let apiKeySecurityService: jest.Mocked; + + // 测试用的模拟数据生成器 + const validEmailArb = fc.string({ minLength: 5, maxLength: 50 }) + .filter(s => s.includes('@') && s.includes('.')) + .map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`); + + const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 }) + .filter(s => /^[a-zA-Z0-9_]+$/.test(s)); + + const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 }) + .filter(s => s.trim().length > 0); + + const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 }) + .filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s)); + + const registerRequestArb = fc.record({ + username: validUsernameArb, + email: validEmailArb, + nickname: validNicknameArb, + password: validPasswordArb, + }); + + beforeEach(async () => { + // 创建模拟服务 + const mockLoginCoreService = { + register: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsRepository = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginService, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsRepository', + useValue: mockZulipAccountsRepository, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, + ], + }).compile(); + + loginService = module.get(LoginService); + loginCoreService = module.get(LoginCoreService); + zulipAccountService = module.get(ZulipAccountService); + zulipAccountsRepository = module.get('ZulipAccountsRepository'); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // 设置环境变量模拟 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }); + + afterEach(() => { + jest.clearAllMocks(); + // 清理环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + }); + + /** + * 属性 13: Zulip账号创建一致性 + * + * 验证需求: 账号创建成功率和数据一致性 + * + * 测试内容: + * 1. 成功注册时,游戏账号和Zulip账号都应该被创建 + * 2. 账号关联信息应该正确存储 + * 3. Zulip账号创建失败时,游戏账号应该被回滚 + * 4. 数据一致性:邮箱、昵称等信息应该保持一致 + */ + describe('属性 13: Zulip账号创建一致性', () => { + it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + const mockZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey, + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.user.email).toBe(registerRequest.email); + expect(result.data?.user.nickname).toBe(registerRequest.nickname); + expect(result.data?.is_new_user).toBe(true); + + // 验证Zulip管理员客户端初始化 + expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({ + realm: 'https://test.zulip.com', + username: 'bot@test.zulip.com', + apiKey: 'test_api_key_123', + }); + + // 验证游戏用户注册 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证API Key存储 + expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.apiKey + ); + + // 验证账号关联创建 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }); + + // 验证内存关联 + expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.userId, + mockZulipResult.email, + mockZulipResult.apiKey + ); + }), + { numRuns: 100 } + ); + }); + + it('应该在Zulip账号创建失败时回滚游戏账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 - Zulip账号创建失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: false, + error: 'Zulip服务器连接失败', + errorCode: 'CONNECTION_FAILED', + }); + loginCoreService.deleteUser.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip账号创建失败'); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建尝试 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证游戏用户被回滚删除 + expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有创建账号关联 + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理已存在Zulip账号关联的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const existingZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: 12345, + zulipEmail: registerRequest.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'existing_encrypted_key', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 - 已存在Zulip账号关联 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该成功 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证检查了现有关联 + expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有尝试创建新的Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理缺少邮箱或密码的注册请求', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + username: validUsernameArb, + nickname: validNicknameArb, + email: fc.option(validEmailArb, { nil: undefined }), + password: fc.option(validPasswordArb, { nil: undefined }), + }), + async (registerRequest) => { + // 只测试缺少邮箱或密码的情况 + if (registerRequest.email && registerRequest.password) { + return; // 跳过完整数据的情况 + } + + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email || null, + nickname: registerRequest.nickname, + password_hash: registerRequest.password ? 'hashed_password' : null, + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + + // 执行注册 + const result = await loginService.register(registerRequest as RegisterRequest); + + // 验证结果 - 注册应该成功,但跳过Zulip账号创建 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息 + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }); + + it('应该正确处理Zulip管理员客户端初始化失败', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 设置模拟行为 - 管理员客户端初始化失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(false); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员客户端初始化失败'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + }), + { numRuns: 50 } + ); + }); + + it('应该正确处理环境变量缺失的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 清除环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员配置不完整'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 恢复环境变量 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }), + { numRuns: 30 } + ); + }); + }); + + /** + * 数据一致性验证测试 + * + * 验证游戏账号和Zulip账号之间的数据一致性 + */ + describe('数据一致性验证', () => { + it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + await loginService.register(registerRequest); + + // 验证Zulip账号创建时使用了正确的数据 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, // 相同的邮箱 + fullName: registerRequest.nickname, // 相同的昵称 + password: registerRequest.password, // 相同的密码 + }); + + // 验证账号关联存储了正确的数据 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: registerRequest.email, // 相同的邮箱 + zulipFullName: registerRequest.nickname, // 相同的昵称 + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }) + ); + }), + { numRuns: 100 } + ); + }); + }); +}); \ No newline at end of file diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 32faf11..1386948 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -31,6 +31,7 @@ import { IZulipClientPoolService, IZulipConfigService, } from '../../core/zulip/interfaces/zulip-core.interfaces'; +import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; /** * 玩家登录请求接口 @@ -114,6 +115,7 @@ export class ZulipService { private readonly eventProcessor: ZulipEventProcessorService, @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, + private readonly apiKeySecurityService: ApiKeySecurityService, ) { this.logger.log('ZulipService初始化完成'); } @@ -318,36 +320,38 @@ export class ZulipService { // 从Token中提取用户ID(模拟) const userId = `user_${token.substring(0, 8)}`; - // 为测试用户提供真实的 Zulip API Key + // 从ApiKeySecurityService获取真实的Zulip API Key let zulipApiKey = undefined; let zulipEmail = undefined; - // 检查是否是配置了真实 Zulip API Key 的测试用户 - const hasTestApiKey = token.includes('lCPWCPf'); - const hasUserApiKey = token.includes('W2KhXaQx'); - const hasOldApiKey = token.includes('MZ1jEMQo'); - const isRealUserToken = token === 'real_user_token_with_zulip_key_123'; - - this.logger.log('Token检查', { - operation: 'validateGameToken', - userId, - tokenPrefix: token.substring(0, 20), - hasUserApiKey, - hasOldApiKey, - isRealUserToken, - }); - - if (isRealUserToken || hasUserApiKey || hasTestApiKey || hasOldApiKey) { - // 使用用户的真实 API Key - // 注意:这个API Key对应的Zulip用户邮箱是 user8@zulip.xinghangee.icu - zulipApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; - zulipEmail = 'angjustinl@mail.angforever.top'; + try { + // 尝试从Redis获取存储的API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - this.logger.log('配置真实Zulip API Key', { + if (apiKeyResult.success && apiKeyResult.apiKey) { + zulipApiKey = apiKeyResult.apiKey; + // TODO: 从数据库获取用户的Zulip邮箱 + // 暂时使用模拟数据 + zulipEmail = 'angjustinl@163.com'; + + this.logger.log('从存储获取到Zulip API Key', { + operation: 'validateGameToken', + userId, + hasApiKey: true, + zulipEmail, + }); + } else { + this.logger.debug('用户没有存储的Zulip API Key', { + operation: 'validateGameToken', + userId, + }); + } + } catch (error) { + const err = error as Error; + this.logger.warn('获取Zulip API Key失败', { operation: 'validateGameToken', userId, - zulipEmail, - hasApiKey: true, + error: err.message, }); } @@ -355,7 +359,6 @@ export class ZulipService { userId, username: `Player_${userId.substring(5, 10)}`, email: `${userId}@example.com`, - // 实际项目中从数据库获取 zulipEmail, zulipApiKey, }; diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts index 1a785cc..9a4bdb2 100644 --- a/src/core/db/users/users.entity.ts +++ b/src/core/db/users/users.entity.ts @@ -19,8 +19,9 @@ * @since 2025-12-17 */ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm'; import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum'; +import { ZulipAccounts } from '../zulip_accounts/zulip_accounts.entity'; /** * 用户实体类 @@ -432,4 +433,25 @@ export class Users { comment: '更新时间' }) updated_at: Date; + + /** + * 关联的Zulip账号 + * + * 关系设计: + * - 类型:一对一关系(OneToOne) + * - 外键:在ZulipAccounts表中 + * - 级联:不设置级联删除,保证数据安全 + * + * 业务规则: + * - 每个游戏用户最多关联一个Zulip账号 + * - 支持延迟加载,提高查询性能 + * - 可选关联,不是所有用户都有Zulip账号 + * + * 使用场景: + * - 游戏内聊天功能集成 + * - 跨平台消息同步 + * - 用户身份验证和权限管理 + */ + @OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser) + zulipAccount?: ZulipAccounts; } \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.entity.ts b/src/core/db/zulip_accounts/zulip_accounts.entity.ts new file mode 100644 index 0000000..10034bf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.entity.ts @@ -0,0 +1,185 @@ +/** + * Zulip账号关联实体 + * + * 功能描述: + * - 存储游戏用户与Zulip账号的关联关系 + * - 管理Zulip账号的基本信息和状态 + * - 提供账号验证和同步功能 + * + * 关联关系: + * - 与Users表建立一对一关系 + * - 存储Zulip用户ID、邮箱、API Key等信息 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, Index } from 'typeorm'; +import { Users } from '../users/users.entity'; + +@Entity('zulip_accounts') +@Index(['zulip_user_id'], { unique: true }) +@Index(['zulip_email'], { unique: true }) +export class ZulipAccounts { + /** + * 主键ID + */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: bigint; + + /** + * 关联的游戏用户ID + */ + @Column({ type: 'bigint', name: 'game_user_id', comment: '关联的游戏用户ID' }) + gameUserId: bigint; + + /** + * Zulip用户ID + */ + @Column({ type: 'int', name: 'zulip_user_id', comment: 'Zulip服务器上的用户ID' }) + zulipUserId: number; + + /** + * Zulip用户邮箱 + */ + @Column({ type: 'varchar', length: 255, name: 'zulip_email', comment: 'Zulip账号邮箱地址' }) + zulipEmail: string; + + /** + * Zulip用户全名 + */ + @Column({ type: 'varchar', length: 100, name: 'zulip_full_name', comment: 'Zulip账号全名' }) + zulipFullName: string; + + /** + * Zulip API Key(加密存储) + */ + @Column({ type: 'text', name: 'zulip_api_key_encrypted', comment: '加密存储的Zulip API Key' }) + zulipApiKeyEncrypted: string; + + /** + * 账号状态 + * - active: 正常激活状态 + * - inactive: 未激活状态 + * - suspended: 暂停状态 + * - error: 错误状态 + */ + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'suspended', 'error'], + default: 'active', + comment: '账号状态:active-正常,inactive-未激活,suspended-暂停,error-错误' + }) + status: 'active' | 'inactive' | 'suspended' | 'error'; + + /** + * 最后验证时间 + */ + @Column({ type: 'timestamp', name: 'last_verified_at', nullable: true, comment: '最后一次验证Zulip账号的时间' }) + lastVerifiedAt: Date | null; + + /** + * 最后同步时间 + */ + @Column({ type: 'timestamp', name: 'last_synced_at', nullable: true, comment: '最后一次同步数据的时间' }) + lastSyncedAt: Date | null; + + /** + * 错误信息 + */ + @Column({ type: 'text', name: 'error_message', nullable: true, comment: '最后一次操作的错误信息' }) + errorMessage: string | null; + + /** + * 重试次数 + */ + @Column({ type: 'int', name: 'retry_count', default: 0, comment: '创建或同步失败的重试次数' }) + retryCount: number; + + /** + * 创建时间 + */ + @CreateDateColumn({ name: 'created_at', comment: '记录创建时间' }) + createdAt: Date; + + /** + * 更新时间 + */ + @UpdateDateColumn({ name: 'updated_at', comment: '记录最后更新时间' }) + updatedAt: Date; + + /** + * 关联的游戏用户 + */ + @OneToOne(() => Users, user => user.zulipAccount) + @JoinColumn({ name: 'game_user_id' }) + gameUser: Users; + + /** + * 检查账号是否处于正常状态 + * + * @returns boolean 是否为正常状态 + */ + isActive(): boolean { + return this.status === 'active'; + } + + /** + * 检查账号是否需要重新验证 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns boolean 是否需要重新验证 + */ + needsVerification(maxAge: number = 24 * 60 * 60 * 1000): boolean { + if (!this.lastVerifiedAt) { + return true; + } + + const now = new Date(); + const timeDiff = now.getTime() - this.lastVerifiedAt.getTime(); + return timeDiff > maxAge; + } + + /** + * 更新验证时间 + */ + updateVerificationTime(): void { + this.lastVerifiedAt = new Date(); + } + + /** + * 更新同步时间 + */ + updateSyncTime(): void { + this.lastSyncedAt = new Date(); + } + + /** + * 设置错误状态 + * + * @param errorMessage 错误信息 + */ + setError(errorMessage: string): void { + this.status = 'error'; + this.errorMessage = errorMessage; + this.retryCount += 1; + } + + /** + * 清除错误状态 + */ + clearError(): void { + if (this.status === 'error') { + this.status = 'active'; + this.errorMessage = null; + } + } + + /** + * 重置重试计数 + */ + resetRetryCount(): void { + this.retryCount = 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.module.ts b/src/core/db/zulip_accounts/zulip_accounts.module.ts new file mode 100644 index 0000000..6c288ef --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.module.ts @@ -0,0 +1,81 @@ +/** + * Zulip账号关联数据模块 + * + * 功能描述: + * - 提供Zulip账号关联数据的访问接口 + * - 封装TypeORM实体和Repository + * - 为业务层提供数据访问服务 + * - 支持数据库和内存模式的动态切换 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { ZulipAccountsRepository } from './zulip_accounts.repository'; +import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository'; + +/** + * 检查数据库配置是否完整 + * + * @returns 是否配置了数据库 + */ +function isDatabaseConfigured(): boolean { + const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME']; + return requiredEnvVars.every(varName => process.env[varName]); +} + +@Global() +@Module({}) +export class ZulipAccountsModule { + /** + * 创建数据库模式的Zulip账号模块 + * + * @returns 配置了TypeORM的动态模块 + */ + static forDatabase(): DynamicModule { + return { + module: ZulipAccountsModule, + imports: [TypeOrmModule.forFeature([ZulipAccounts])], + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsRepository, + }, + ], + exports: ['ZulipAccountsRepository', TypeOrmModule], + }; + } + + /** + * 创建内存模式的Zulip账号模块 + * + * @returns 配置了内存存储的动态模块 + */ + static forMemory(): DynamicModule { + return { + module: ZulipAccountsModule, + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsMemoryRepository, + }, + ], + exports: ['ZulipAccountsRepository'], + }; + } + + /** + * 根据环境自动选择模式 + * + * @returns 动态模块 + */ + static forRoot(): DynamicModule { + return isDatabaseConfigured() + ? ZulipAccountsModule.forDatabase() + : ZulipAccountsModule.forMemory(); + } +} diff --git a/src/core/db/zulip_accounts/zulip_accounts.repository.ts b/src/core/db/zulip_accounts/zulip_accounts.repository.ts new file mode 100644 index 0000000..9991d03 --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.repository.ts @@ -0,0 +1,323 @@ +/** + * Zulip账号关联数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的CRUD操作 + * - 封装复杂查询逻辑和数据库交互 + * - 实现数据访问层的业务逻辑抽象 + * + * 主要功能: + * - 账号关联的创建、查询、更新、删除 + * - 支持按游戏用户ID、Zulip用户ID、邮箱查询 + * - 提供账号状态管理和批量操作 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; + +/** + * 创建Zulip账号关联的数据传输对象 + */ +export interface CreateZulipAccountDto { + gameUserId: bigint; + zulipUserId: number; + zulipEmail: string; + zulipFullName: string; + zulipApiKeyEncrypted: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; +} + +/** + * 更新Zulip账号关联的数据传输对象 + */ +export interface UpdateZulipAccountDto { + zulipFullName?: string; + zulipApiKeyEncrypted?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + lastVerifiedAt?: Date; + lastSyncedAt?: Date; + errorMessage?: string; + retryCount?: number; +} + +/** + * Zulip账号查询条件 + */ +export interface ZulipAccountQueryOptions { + gameUserId?: bigint; + zulipUserId?: number; + zulipEmail?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + includeGameUser?: boolean; +} + +@Injectable() +export class ZulipAccountsRepository { + constructor( + @InjectRepository(ZulipAccounts) + private readonly repository: Repository, + ) {} + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const zulipAccount = this.repository.create(createDto); + return await this.repository.save(zulipAccount); + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { gameUserId }, + relations, + }); + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipUserId }, + relations, + }); + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipEmail }, + relations, + }); + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { id }, + relations, + }); + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ id }, updateDto); + return await this.findById(id); + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ gameUserId }, updateDto); + return await this.findByGameUserId(gameUserId); + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + const result = await this.repository.delete({ id }); + return result.affected > 0; + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + const result = await this.repository.delete({ gameUserId }); + return result.affected > 0; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + const { includeGameUser, ...whereOptions } = options; + const relations = includeGameUser ? ['gameUser'] : []; + + // 构建查询条件 + const where: FindOptionsWhere = {}; + if (whereOptions.gameUserId) where.gameUserId = whereOptions.gameUserId; + if (whereOptions.zulipUserId) where.zulipUserId = whereOptions.zulipUserId; + if (whereOptions.zulipEmail) where.zulipEmail = whereOptions.zulipEmail; + if (whereOptions.status) where.status = whereOptions.status; + + return await this.repository.find({ + where, + relations, + order: { createdAt: 'DESC' }, + }); + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return await this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.status = :status', { status: 'active' }) + .andWhere( + '(zulip_accounts.last_verified_at IS NULL OR zulip_accounts.last_verified_at < :cutoffTime)', + { cutoffTime } + ) + .orderBy('zulip_accounts.last_verified_at', 'ASC', 'NULLS FIRST') + .getMany(); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数,默认3次 + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return await this.repository.find({ + where: { status: 'error' }, + order: { updatedAt: 'ASC' }, + }); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + const result = await this.repository + .createQueryBuilder() + .update(ZulipAccounts) + .set({ status }) + .whereInIds(ids) + .execute(); + + return result.affected || 0; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const result = await this.repository + .createQueryBuilder('zulip_accounts') + .select('zulip_accounts.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('zulip_accounts.status') + .getRawMany(); + + const statistics: Record = {}; + result.forEach(row => { + statistics[row.status] = parseInt(row.count, 10); + }); + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_email = :zulipEmail', { zulipEmail }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_user_id = :zulipUserId', { zulipUserId }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts new file mode 100644 index 0000000..e31e6cf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts @@ -0,0 +1,299 @@ +/** + * Zulip账号关联内存数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的内存存储实现 + * - 用于开发和测试环境 + * - 实现与数据库版本相同的接口 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { + CreateZulipAccountDto, + UpdateZulipAccountDto, + ZulipAccountQueryOptions, +} from './zulip_accounts.repository'; + +@Injectable() +export class ZulipAccountsMemoryRepository { + private accounts: Map = new Map(); + private currentId: bigint = BigInt(1); + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const account = new ZulipAccounts(); + account.id = this.currentId++; + account.gameUserId = createDto.gameUserId; + account.zulipUserId = createDto.zulipUserId; + account.zulipEmail = createDto.zulipEmail; + account.zulipFullName = createDto.zulipFullName; + account.zulipApiKeyEncrypted = createDto.zulipApiKeyEncrypted; + account.status = createDto.status || 'active'; + account.createdAt = new Date(); + account.updatedAt = new Date(); + + this.accounts.set(account.id, account); + return account; + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.gameUserId === gameUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipUserId === zulipUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipEmail === zulipEmail) { + return account; + } + } + return null; + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + return this.accounts.get(id) || null; + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = this.accounts.get(id); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = await this.findByGameUserId(gameUserId); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + return this.accounts.delete(id); + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.gameUserId === gameUserId) { + return this.accounts.delete(id); + } + } + return false; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + let results = Array.from(this.accounts.values()); + + if (options.gameUserId) { + results = results.filter(a => a.gameUserId === options.gameUserId); + } + if (options.zulipUserId) { + results = results.filter(a => a.zulipUserId === options.zulipUserId); + } + if (options.zulipEmail) { + results = results.filter(a => a.zulipEmail === options.zulipEmail); + } + if (options.status) { + results = results.filter(a => a.status === options.status); + } + + // 按创建时间降序排序 + results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return results; + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return Array.from(this.accounts.values()) + .filter(account => + account.status === 'active' && + (!account.lastVerifiedAt || account.lastVerifiedAt < cutoffTime) + ) + .sort((a, b) => { + if (!a.lastVerifiedAt) return -1; + if (!b.lastVerifiedAt) return 1; + return a.lastVerifiedAt.getTime() - b.lastVerifiedAt.getTime(); + }); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数(内存模式忽略) + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return Array.from(this.accounts.values()) + .filter(account => account.status === 'error') + .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime()); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + let count = 0; + for (const id of ids) { + const account = this.accounts.get(id); + if (account) { + account.status = status; + account.updatedAt = new Date(); + count++; + } + } + return count; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const statistics: Record = {}; + + for (const account of this.accounts.values()) { + const status = account.status; + statistics[status] = (statistics[status] || 0) + 1; + } + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipEmail === zulipEmail && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipUserId === zulipUserId && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } +} diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index a53f79f..38aac56 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -826,4 +826,36 @@ export class LoginCoreService { VerificationCodeType.EMAIL_VERIFICATION ); } + + /** + * 删除用户 + * + * 功能描述: + * 删除指定的用户记录,用于注册失败时的回滚操作 + * + * 业务逻辑: + * 1. 验证用户是否存在 + * 2. 执行用户删除操作 + * 3. 返回删除结果 + * + * @param userId 用户ID + * @returns Promise 是否删除成功 + * @throws NotFoundException 用户不存在时 + */ + async deleteUser(userId: bigint): Promise { + // 1. 验证用户是否存在 + const user = await this.usersService.findOne(userId); + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 2. 执行删除操作 + try { + await this.usersService.remove(userId); + return true; + } catch (error) { + console.error(`删除用户失败: ${userId}`, error); + return false; + } + } } \ No newline at end of file diff --git a/src/core/zulip/services/zulip_account.service.ts b/src/core/zulip/services/zulip_account.service.ts new file mode 100644 index 0000000..162ea7c --- /dev/null +++ b/src/core/zulip/services/zulip_account.service.ts @@ -0,0 +1,708 @@ +/** + * Zulip账号管理核心服务 + * + * 功能描述: + * - 自动创建Zulip用户账号 + * - 生成API Key并安全存储 + * - 处理账号创建失败场景 + * - 管理用户账号与游戏账号的关联 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 关联游戏账号与Zulip账号 + * + * 使用场景: + * - 用户注册时自动创建Zulip账号 + * - API Key管理和更新 + * - 账号关联和映射存储 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ZulipClientConfig } from '../interfaces/zulip-core.interfaces'; + +/** + * Zulip账号创建请求接口 + */ +export interface CreateZulipAccountRequest { + email: string; + fullName: string; + password?: string; + shortName?: string; +} + +/** + * Zulip账号创建结果接口 + */ +export interface CreateZulipAccountResult { + success: boolean; + userId?: number; + email?: string; + apiKey?: string; + error?: string; + errorCode?: string; +} + +/** + * API Key生成结果接口 + */ +export interface GenerateApiKeyResult { + success: boolean; + apiKey?: string; + error?: string; +} + +/** + * 账号验证结果接口 + */ +export interface ValidateAccountResult { + success: boolean; + isValid?: boolean; + userInfo?: any; + error?: string; +} + +/** + * 账号关联信息接口 + */ +export interface AccountLinkInfo { + gameUserId: string; + zulipUserId: number; + zulipEmail: string; + zulipApiKey: string; + createdAt: Date; + lastVerified?: Date; + isActive: boolean; +} + +/** + * Zulip账号管理服务类 + * + * 职责: + * - 处理Zulip用户账号的创建和管理 + * - 管理API Key的生成和存储 + * - 维护游戏账号与Zulip账号的关联关系 + * - 提供账号验证和状态检查功能 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为现有用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 建立游戏账号与Zulip账号的关联 + * - unlinkGameAccount(): 解除账号关联 + * + * 使用场景: + * - 用户注册流程中自动创建Zulip账号 + * - API Key管理和更新 + * - 账号状态监控和维护 + * - 跨平台账号同步 + */ +@Injectable() +export class ZulipAccountService { + private readonly logger = new Logger(ZulipAccountService.name); + private adminClient: any = null; + private readonly accountLinks = new Map(); + + constructor() { + this.logger.log('ZulipAccountService初始化完成'); + } + + /** + * 初始化管理员客户端 + * + * 功能描述: + * 使用管理员凭证初始化Zulip客户端,用于创建用户账号 + * + * @param adminConfig 管理员配置 + * @returns Promise 是否初始化成功 + */ + async initializeAdminClient(adminConfig: ZulipClientConfig): Promise { + this.logger.log('初始化Zulip管理员客户端', { + operation: 'initializeAdminClient', + realm: adminConfig.realm, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 创建管理员客户端 + this.adminClient = await zulipInit({ + username: adminConfig.username, + apiKey: adminConfig.apiKey, + realm: adminConfig.realm, + }); + + // 验证管理员权限 + const profile = await this.adminClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`管理员客户端验证失败: ${profile.msg || '未知错误'}`); + } + + this.logger.log('管理员客户端初始化成功', { + operation: 'initializeAdminClient', + adminEmail: profile.email, + isAdmin: profile.is_admin, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + this.logger.error('管理员客户端初始化失败', { + operation: 'initializeAdminClient', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 创建Zulip用户账号 + * + * 功能描述: + * 使用管理员权限在Zulip服务器上创建新的用户账号 + * + * 业务逻辑: + * 1. 验证管理员客户端是否已初始化 + * 2. 检查邮箱是否已存在 + * 3. 生成用户密码(如果未提供) + * 4. 调用Zulip API创建用户 + * 5. 为新用户生成API Key + * 6. 返回创建结果 + * + * @param request 账号创建请求 + * @returns Promise 创建结果 + */ + async createZulipAccount(request: CreateZulipAccountRequest): Promise { + const startTime = Date.now(); + + this.logger.log('开始创建Zulip账号', { + operation: 'createZulipAccount', + email: request.email, + fullName: request.fullName, + timestamp: new Date().toISOString(), + }); + + try { + // 1. 验证管理员客户端 + if (!this.adminClient) { + throw new Error('管理员客户端未初始化'); + } + + // 2. 验证请求参数 + if (!request.email || !request.email.trim()) { + throw new Error('邮箱地址不能为空'); + } + + if (!request.fullName || !request.fullName.trim()) { + throw new Error('用户全名不能为空'); + } + + // 3. 检查邮箱格式 + if (!this.isValidEmail(request.email)) { + throw new Error('邮箱格式无效'); + } + + // 4. 检查用户是否已存在 + const existingUser = await this.checkUserExists(request.email); + if (existingUser) { + this.logger.warn('用户已存在', { + operation: 'createZulipAccount', + email: request.email, + }); + return { + success: false, + error: '用户已存在', + errorCode: 'USER_ALREADY_EXISTS', + }; + } + + // 5. 生成密码(如果未提供) + const password = request.password || this.generateRandomPassword(); + const shortName = request.shortName || this.generateShortName(request.email); + + // 6. 创建用户参数 + const createParams = { + email: request.email, + password: password, + full_name: request.fullName, + short_name: shortName, + }; + + // 7. 调用Zulip API创建用户 + const createResponse = await this.adminClient.users.create(createParams); + + if (createResponse.result !== 'success') { + this.logger.warn('Zulip用户创建失败', { + operation: 'createZulipAccount', + email: request.email, + error: createResponse.msg, + }); + return { + success: false, + error: createResponse.msg || '用户创建失败', + errorCode: 'ZULIP_CREATE_FAILED', + }; + } + + // 8. 为新用户生成API Key + const apiKeyResult = await this.generateApiKeyForUser(request.email, password); + + if (!apiKeyResult.success) { + this.logger.warn('API Key生成失败,但用户已创建', { + operation: 'createZulipAccount', + email: request.email, + error: apiKeyResult.error, + }); + // 用户已创建,但API Key生成失败 + return { + success: true, + userId: createResponse.user_id, + email: request.email, + error: `用户创建成功,但API Key生成失败: ${apiKeyResult.error}`, + errorCode: 'API_KEY_GENERATION_FAILED', + }; + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建成功', { + operation: 'createZulipAccount', + email: request.email, + userId: createResponse.user_id, + hasApiKey: !!apiKeyResult.apiKey, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + userId: createResponse.user_id, + email: request.email, + apiKey: apiKeyResult.apiKey, + }; + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('创建Zulip账号失败', { + operation: 'createZulipAccount', + email: request.email, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + errorCode: 'ACCOUNT_CREATION_FAILED', + }; + } + } + + /** + * 为用户生成API Key + * + * 功能描述: + * 使用用户凭证获取API Key + * + * @param email 用户邮箱 + * @param password 用户密码 + * @returns Promise 生成结果 + */ + async generateApiKeyForUser(email: string, password: string): Promise { + this.logger.log('为用户生成API Key', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 使用用户凭证获取API Key + const userClient = await zulipInit({ + username: email, + password: password, + realm: this.getRealmFromAdminClient(), + }); + + // 验证客户端并获取API Key + const profile = await userClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`API Key获取失败: ${profile.msg || '未知错误'}`); + } + + // 从客户端配置中提取API Key + const apiKey = userClient.config?.apiKey; + + if (!apiKey) { + throw new Error('无法从客户端配置中获取API Key'); + } + + this.logger.log('API Key生成成功', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + apiKey: apiKey, + }; + + } catch (error) { + const err = error as Error; + + this.logger.error('API Key生成失败', { + operation: 'generateApiKeyForUser', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 验证Zulip账号有效性 + * + * 功能描述: + * 验证指定的Zulip账号是否存在且有效 + * + * @param email 用户邮箱 + * @param apiKey 用户API Key(可选) + * @returns Promise 验证结果 + */ + async validateZulipAccount(email: string, apiKey?: string): Promise { + this.logger.log('验证Zulip账号', { + operation: 'validateZulipAccount', + email, + hasApiKey: !!apiKey, + timestamp: new Date().toISOString(), + }); + + try { + if (apiKey) { + // 使用API Key验证 + const zulipInit = await this.loadZulipModule(); + const userClient = await zulipInit({ + username: email, + apiKey: apiKey, + realm: this.getRealmFromAdminClient(), + }); + + const profile = await userClient.users.me.getProfile(); + + if (profile.result === 'success') { + this.logger.log('账号验证成功(API Key)', { + operation: 'validateZulipAccount', + email, + userId: profile.user_id, + }); + + return { + success: true, + isValid: true, + userInfo: profile, + }; + } else { + return { + success: true, + isValid: false, + error: profile.msg || 'API Key验证失败', + }; + } + } else { + // 仅检查用户是否存在 + const userExists = await this.checkUserExists(email); + + this.logger.log('账号存在性检查完成', { + operation: 'validateZulipAccount', + email, + exists: userExists, + }); + + return { + success: true, + isValid: userExists, + }; + } + + } catch (error) { + const err = error as Error; + + this.logger.error('账号验证失败', { + operation: 'validateZulipAccount', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 关联游戏账号与Zulip账号 + * + * 功能描述: + * 建立游戏用户ID与Zulip账号的映射关系 + * + * @param gameUserId 游戏用户ID + * @param zulipUserId Zulip用户ID + * @param zulipEmail Zulip邮箱 + * @param zulipApiKey Zulip API Key + * @returns Promise 是否关联成功 + */ + async linkGameAccount( + gameUserId: string, + zulipUserId: number, + zulipEmail: string, + zulipApiKey: string, + ): Promise { + this.logger.log('关联游戏账号与Zulip账号', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + try { + // 验证参数 + if (!gameUserId || !zulipUserId || !zulipEmail || !zulipApiKey) { + throw new Error('关联参数不完整'); + } + + // 创建关联信息 + const linkInfo: AccountLinkInfo = { + gameUserId, + zulipUserId, + zulipEmail, + zulipApiKey, + createdAt: new Date(), + isActive: true, + }; + + // 存储关联信息(实际项目中应存储到数据库) + this.accountLinks.set(gameUserId, linkInfo); + + this.logger.log('账号关联成功', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('账号关联失败', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 解除游戏账号与Zulip账号的关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否解除成功 + */ + async unlinkGameAccount(gameUserId: string): Promise { + this.logger.log('解除账号关联', { + operation: 'unlinkGameAccount', + gameUserId, + timestamp: new Date().toISOString(), + }); + + try { + const linkInfo = this.accountLinks.get(gameUserId); + + if (linkInfo) { + linkInfo.isActive = false; + this.accountLinks.delete(gameUserId); + + this.logger.log('账号关联解除成功', { + operation: 'unlinkGameAccount', + gameUserId, + zulipEmail: linkInfo.zulipEmail, + }); + } + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('解除账号关联失败', { + operation: 'unlinkGameAccount', + gameUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 获取游戏账号的Zulip关联信息 + * + * @param gameUserId 游戏用户ID + * @returns AccountLinkInfo | null 关联信息 + */ + getAccountLink(gameUserId: string): AccountLinkInfo | null { + return this.accountLinks.get(gameUserId) || null; + } + + /** + * 获取所有账号关联信息 + * + * @returns AccountLinkInfo[] 所有关联信息 + */ + getAllAccountLinks(): AccountLinkInfo[] { + return Array.from(this.accountLinks.values()).filter(link => link.isActive); + } + + /** + * 检查用户是否已存在 + * + * @param email 用户邮箱 + * @returns Promise 用户是否存在 + * @private + */ + private async checkUserExists(email: string): Promise { + try { + if (!this.adminClient) { + return false; + } + + // 获取所有用户列表 + const usersResponse = await this.adminClient.users.retrieve(); + + if (usersResponse.result === 'success') { + const users = usersResponse.members || []; + return users.some((user: any) => user.email === email); + } + + return false; + + } catch (error) { + const err = error as Error; + this.logger.warn('检查用户存在性失败', { + operation: 'checkUserExists', + email, + error: err.message, + }); + return false; + } + } + + /** + * 验证邮箱格式 + * + * @param email 邮箱地址 + * @returns boolean 是否为有效邮箱 + * @private + */ + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + /** + * 生成随机密码 + * + * @returns string 随机密码 + * @private + */ + private generateRandomPassword(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + let password = ''; + for (let i = 0; i < 12; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return password; + } + + /** + * 从邮箱生成短名称 + * + * @param email 邮箱地址 + * @returns string 短名称 + * @private + */ + private generateShortName(email: string): string { + const localPart = email.split('@')[0]; + // 移除特殊字符,只保留字母数字和下划线 + return localPart.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase(); + } + + /** + * 从管理员客户端获取Realm + * + * @returns string Realm URL + * @private + */ + private getRealmFromAdminClient(): string { + if (!this.adminClient || !this.adminClient.config) { + throw new Error('管理员客户端未初始化或配置缺失'); + } + return this.adminClient.config.realm; + } + + /** + * 动态加载zulip-js模块 + * + * @returns Promise zulip-js初始化函数 + * @private + */ + private async loadZulipModule(): Promise { + try { + // 使用动态导入加载zulip-js + const zulipModule = await import('zulip-js'); + return zulipModule.default || zulipModule; + } catch (error) { + const err = error as Error; + this.logger.error('加载zulip-js模块失败', { + operation: 'loadZulipModule', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + throw new Error(`加载zulip-js模块失败: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/src/core/zulip/zulip-core.module.ts b/src/core/zulip/zulip-core.module.ts index e30d2c1..134ee45 100644 --- a/src/core/zulip/zulip-core.module.ts +++ b/src/core/zulip/zulip-core.module.ts @@ -19,6 +19,7 @@ import { ApiKeySecurityService } from './services/api_key_security.service'; import { ErrorHandlerService } from './services/error_handler.service'; import { MonitoringService } from './services/monitoring.service'; import { StreamInitializerService } from './services/stream_initializer.service'; +import { ZulipAccountService } from './services/zulip_account.service'; import { RedisModule } from '../redis/redis.module'; @Module({ @@ -46,6 +47,7 @@ import { RedisModule } from '../redis/redis.module'; ErrorHandlerService, MonitoringService, StreamInitializerService, + ZulipAccountService, // 直接提供类(用于内部依赖) ZulipClientService, @@ -63,6 +65,7 @@ import { RedisModule } from '../redis/redis.module'; ErrorHandlerService, MonitoringService, StreamInitializerService, + ZulipAccountService, ], }) export class ZulipCoreModule {} \ No newline at end of file -- 2.25.1 From 2b87eac4956395854a9acdf5704e002125d039e3 Mon Sep 17 00:00:00 2001 From: angjustinl <96008766+ANGJustinl@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:41:54 +0800 Subject: [PATCH 2/4] feat(zulip): Add Zulip account management and integrate with auth system - Add ZulipAccountsEntity, repository, and module for persistent Zulip account storage - Create ZulipAccountService in core layer for managing Zulip account lifecycle - Integrate Zulip account creation into login flow via LoginService - Add comprehensive test suite for Zulip account creation during user registration - Create quick test script for validating registered user Zulip integration - Update UsersEntity to support Zulip account associations - Update auth module to include Zulip and ZulipAccounts dependencies - Fix WebSocket connection protocol from ws:// to wss:// in API documentation - Enhance LoginCoreService to coordinate Zulip account provisioning during authentication --- docs/systems/zulip/api.md | 2 +- .../zulip/quick_tests/test-registered-user.js | 232 ++++++ src/business/auth/auth.module.ts | 12 +- src/business/auth/services/login.service.ts | 262 ++++++- .../login.service.zulip-account.spec.ts | 520 +++++++++++++ src/business/zulip/zulip.service.ts | 53 +- src/core/db/users/users.entity.ts | 24 +- .../zulip_accounts/zulip_accounts.entity.ts | 185 +++++ .../zulip_accounts/zulip_accounts.module.ts | 81 ++ .../zulip_accounts.repository.ts | 323 ++++++++ .../zulip_accounts_memory.repository.ts | 299 ++++++++ src/core/login_core/login_core.service.ts | 32 + .../zulip/services/zulip_account.service.ts | 708 ++++++++++++++++++ src/core/zulip/zulip-core.module.ts | 3 + 14 files changed, 2698 insertions(+), 38 deletions(-) create mode 100644 docs/systems/zulip/quick_tests/test-registered-user.js create mode 100644 src/business/auth/services/login.service.zulip-account.spec.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.entity.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.module.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts.repository.ts create mode 100644 src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts create mode 100644 src/core/zulip/services/zulip_account.service.ts diff --git a/docs/systems/zulip/api.md b/docs/systems/zulip/api.md index 86e3548..35af5b0 100644 --- a/docs/systems/zulip/api.md +++ b/docs/systems/zulip/api.md @@ -5,7 +5,7 @@ ### 连接地址 ``` -ws://localhost:3000/game +wss://localhost:3000/game ``` ### 连接参数 diff --git a/docs/systems/zulip/quick_tests/test-registered-user.js b/docs/systems/zulip/quick_tests/test-registered-user.js new file mode 100644 index 0000000..b2e0b1c --- /dev/null +++ b/docs/systems/zulip/quick_tests/test-registered-user.js @@ -0,0 +1,232 @@ +/** + * 测试新注册用户的Zulip账号功能 + * + * 功能: + * 1. 验证新注册用户可以通过游戏服务器登录 + * 2. 验证Zulip账号已正确创建和关联 + * 3. 验证用户可以通过WebSocket发送消息到Zulip + * 4. 验证用户可以接收来自Zulip的消息 + * + * 使用方法: + * node docs/systems/zulip/quick_tests/test-registered-user.js + */ + +const io = require('socket.io-client'); +const axios = require('axios'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 步骤1: 登录游戏服务器获取token + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 昵称: ${response.data.data.user.nickname}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + +/** + * 步骤2: 通过WebSocket连接并测试Zulip集成 + */ +async function testZulipIntegration(userInfo) { + console.log('\n📡 步骤 2: 测试 Zulip 集成'); + console.log(` 连接到: ${GAME_SERVER}/game`); + + return new Promise((resolve, reject) => { + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); + + let testStep = 0; + let testResults = { + connected: false, + loggedIn: false, + messageSent: false, + messageReceived: false + }; + + // 连接成功 + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + testResults.connected = true; + testStep = 1; + + // 发送登录消息 + const loginMessage = { + type: 'login', + token: userInfo.token + }; + + console.log('📤 发送登录消息...'); + socket.emit('login', loginMessage); + }); + + // 登录成功 + socket.on('login_success', (data) => { + console.log('✅ 登录成功'); + console.log(` 会话ID: ${data.sessionId}`); + console.log(` 用户ID: ${data.userId}`); + console.log(` 用户名: ${data.username}`); + console.log(` 当前地图: ${data.currentMap}`); + testResults.loggedIn = true; + testStep = 2; + + // 等待Zulip客户端初始化 + console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...'); + setTimeout(() => { + const chatMessage = { + t: 'chat', + content: `🎮 【注册用户测试】来自 ${userInfo.username} 的消息!\n` + + `时间: ${new Date().toLocaleString()}\n` + + `这是通过新注册账号发送的测试消息。`, + scope: 'local' + }; + + console.log('📤 发送测试消息到 Zulip...'); + console.log(` 内容: ${chatMessage.content.split('\n')[0]}`); + socket.emit('chat', chatMessage); + }, 3000); + }); + + // 消息发送成功 + socket.on('chat_sent', (data) => { + console.log('✅ 消息发送成功'); + console.log(` 消息ID: ${data.id || '未知'}`); + testResults.messageSent = true; + testStep = 3; + + // 等待一段时间接收消息 + setTimeout(() => { + console.log('\n📊 测试完成,断开连接...'); + socket.disconnect(); + }, 5000); + }); + + // 接收到消息 + socket.on('chat_render', (data) => { + console.log('📨 收到来自 Zulip 的消息:'); + console.log(` 发送者: ${data.from}`); + console.log(` 内容: ${data.txt}`); + console.log(` Stream: ${data.stream || '未知'}`); + console.log(` Topic: ${data.topic || '未知'}`); + testResults.messageReceived = true; + }); + + // 错误处理 + socket.on('error', (error) => { + console.error('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + // 连接断开 + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + resolve(testResults); + }); + + // 连接错误 + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + reject(error); + }); + + // 超时保护 + setTimeout(() => { + if (socket.connected) { + socket.disconnect(); + } + }, 15000); + }); +} + +/** + * 打印测试结果 + */ +function printTestResults(results) { + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + + const checks = [ + { name: 'WebSocket 连接', passed: results.connected }, + { name: '游戏服务器登录', passed: results.loggedIn }, + { name: '发送消息到 Zulip', passed: results.messageSent }, + { name: '接收 Zulip 消息', passed: results.messageReceived } + ]; + + checks.forEach(check => { + const icon = check.passed ? '✅' : '❌'; + console.log(`${icon} ${check.name}: ${check.passed ? '通过' : '失败'}`); + }); + + const passedCount = checks.filter(c => c.passed).length; + const totalCount = checks.length; + + console.log('='.repeat(60)); + console.log(`总计: ${passedCount}/${totalCount} 项测试通过`); + + if (passedCount === totalCount) { + console.log('\n🎉 所有测试通过!Zulip账号创建和集成功能正常!'); + console.log('💡 提示: 请访问 https://zulip.xinghangee.icu/ 查看发送的消息'); + } else { + console.log('\n⚠️ 部分测试失败,请检查日志'); + } + console.log('='.repeat(60)); +} + +/** + * 主测试流程 + */ +async function runTest() { + console.log('🚀 开始测试新注册用户的 Zulip 集成功能'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录 + const userInfo = await loginToGameServer(); + + // 步骤2: 测试Zulip集成 + const results = await testZulipIntegration(userInfo); + + // 打印结果 + printTestResults(results); + + process.exit(results.connected && results.loggedIn && results.messageSent ? 0 : 1); + } catch (error) { + console.error('\n❌ 测试失败:', error.message); + process.exit(1); + } +} + +// 运行测试 +runTest(); diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 28e8065..60e8e66 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -16,11 +16,19 @@ import { Module } from '@nestjs/common'; import { LoginController } from './controllers/login.controller'; import { LoginService } from './services/login.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; +import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; +import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; @Module({ - imports: [LoginCoreModule], + imports: [ + LoginCoreModule, + ZulipCoreModule, + ZulipAccountsModule.forRoot(), + ], controllers: [LoginController], - providers: [LoginService], + providers: [ + LoginService, + ], exports: [LoginService], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index a0f6503..a9bcc0c 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -16,9 +16,12 @@ * @since 2025-12-17 */ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject } from '@nestjs/common'; import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service'; import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; /** * 登录响应数据接口 @@ -65,6 +68,10 @@ export class LoginService { constructor( private readonly loginCoreService: LoginCoreService, + private readonly zulipAccountService: ZulipAccountService, + @Inject('ZulipAccountsRepository') + private readonly zulipAccountsRepository: ZulipAccountsRepository, + private readonly apiKeySecurityService: ApiKeySecurityService, ) {} /** @@ -116,36 +123,106 @@ export class LoginService { * @returns 注册响应 */ async register(registerRequest: RegisterRequest): Promise> { + const startTime = Date.now(); + try { this.logger.log(`用户注册尝试: ${registerRequest.username}`); - // 调用核心服务进行注册 + // 1. 初始化Zulip管理员客户端 + await this.initializeZulipAdminClient(); + + // 2. 调用核心服务进行注册 const authResult = await this.loginCoreService.register(registerRequest); - // 生成访问令牌 + // 3. 创建Zulip账号(使用相同的邮箱和密码) + let zulipAccountCreated = false; + try { + if (registerRequest.email && registerRequest.password) { + await this.createZulipAccountForUser(authResult.user, registerRequest.password); + zulipAccountCreated = true; + + this.logger.log(`Zulip账号创建成功: ${registerRequest.username}`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + email: registerRequest.email, + }); + } else { + this.logger.warn(`跳过Zulip账号创建:缺少邮箱或密码`, { + operation: 'register', + username: registerRequest.username, + hasEmail: !!registerRequest.email, + hasPassword: !!registerRequest.password, + }); + } + } catch (zulipError) { + const err = zulipError as Error; + this.logger.error(`Zulip账号创建失败,回滚用户注册`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + zulipError: err.message, + }, err.stack); + + // 回滚游戏用户注册 + try { + await this.loginCoreService.deleteUser(authResult.user.id); + this.logger.log(`用户注册回滚成功: ${registerRequest.username}`); + } catch (rollbackError) { + const rollbackErr = rollbackError as Error; + this.logger.error(`用户注册回滚失败`, { + operation: 'register', + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + rollbackError: rollbackErr.message, + }, rollbackErr.stack); + } + + // 抛出原始错误 + throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); + } + + // 4. 生成访问令牌 const accessToken = this.generateAccessToken(authResult.user); - // 格式化响应数据 + // 5. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), access_token: accessToken, is_new_user: true, - message: '注册成功' + message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功' }; - this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + const duration = Date.now() - startTime; + + this.logger.log(`用户注册成功: ${authResult.user.username} (ID: ${authResult.user.id})`, { + operation: 'register', + gameUserId: authResult.user.id.toString(), + username: authResult.user.username, + zulipAccountCreated, + duration, + timestamp: new Date().toISOString(), + }); return { success: true, data: response, - message: '注册成功' + message: response.message }; } catch (error) { - this.logger.error(`用户注册失败: ${registerRequest.username}`, error instanceof Error ? error.stack : String(error)); + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error(`用户注册失败: ${registerRequest.username}`, { + operation: 'register', + username: registerRequest.username, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); return { success: false, - message: error instanceof Error ? error.message : '注册失败', + message: err.message || '注册失败', error_code: 'REGISTER_FAILED' }; } @@ -592,4 +669,171 @@ export class LoginService { }; } } + + /** + * 初始化Zulip管理员客户端 + * + * 功能描述: + * 使用环境变量中的管理员凭证初始化Zulip客户端 + * + * 业务逻辑: + * 1. 从环境变量获取管理员配置 + * 2. 验证配置完整性 + * 3. 初始化ZulipAccountService的管理员客户端 + * + * @throws Error 当配置缺失或初始化失败时 + * @private + */ + private async initializeZulipAdminClient(): Promise { + try { + // 从环境变量获取管理员配置 + const adminConfig = { + realm: process.env.ZULIP_SERVER_URL || '', + username: process.env.ZULIP_BOT_EMAIL || '', + apiKey: process.env.ZULIP_BOT_API_KEY || '', + }; + + // 验证配置完整性 + if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) { + throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY'); + } + + // 初始化管理员客户端 + const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); + + if (!initialized) { + throw new Error('Zulip管理员客户端初始化失败'); + } + + this.logger.log('Zulip管理员客户端初始化成功', { + operation: 'initializeZulipAdminClient', + realm: adminConfig.realm, + adminEmail: adminConfig.username, + }); + + } catch (error) { + const err = error as Error; + this.logger.error('Zulip管理员客户端初始化失败', { + operation: 'initializeZulipAdminClient', + error: err.message, + }, err.stack); + throw error; + } + } + + /** + * 为用户创建Zulip账号 + * + * 功能描述: + * 为新注册的游戏用户创建对应的Zulip账号并建立关联 + * + * 业务逻辑: + * 1. 使用相同的邮箱和密码创建Zulip账号 + * 2. 加密存储API Key + * 3. 在数据库中建立关联关系 + * 4. 处理创建失败的情况 + * + * @param gameUser 游戏用户信息 + * @param password 用户密码(明文) + * @throws Error 当Zulip账号创建失败时 + * @private + */ + private async createZulipAccountForUser(gameUser: Users, password: string): Promise { + const startTime = Date.now(); + + this.logger.log('开始为用户创建Zulip账号', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + nickname: gameUser.nickname, + }); + + try { + // 1. 检查是否已存在Zulip账号关联 + const existingAccount = await this.zulipAccountsRepository.findByGameUserId(gameUser.id); + if (existingAccount) { + this.logger.warn('用户已存在Zulip账号关联,跳过创建', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + existingZulipUserId: existingAccount.zulipUserId, + }); + return; + } + + // 2. 创建Zulip账号 + const createResult = await this.zulipAccountService.createZulipAccount({ + email: gameUser.email, + fullName: gameUser.nickname, + password: password, + }); + + if (!createResult.success) { + throw new Error(createResult.error || 'Zulip账号创建失败'); + } + + // 3. 存储API Key + if (createResult.apiKey) { + await this.apiKeySecurityService.storeApiKey( + gameUser.id.toString(), + createResult.apiKey + ); + } + + // 4. 在数据库中创建关联记录 + await this.zulipAccountsRepository.create({ + gameUserId: gameUser.id, + zulipUserId: createResult.userId!, + zulipEmail: createResult.email!, + zulipFullName: gameUser.nickname, + zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中 + status: 'active', + }); + + // 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话) + if (createResult.apiKey) { + await this.zulipAccountService.linkGameAccount( + gameUser.id.toString(), + createResult.userId!, + createResult.email!, + createResult.apiKey + ); + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建和关联成功', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + zulipUserId: createResult.userId, + zulipEmail: createResult.email, + hasApiKey: !!createResult.apiKey, + duration, + }); + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('为用户创建Zulip账号失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + email: gameUser.email, + error: err.message, + duration, + }, err.stack); + + // 清理可能创建的部分数据 + try { + await this.zulipAccountsRepository.deleteByGameUserId(gameUser.id); + } catch (cleanupError) { + this.logger.warn('清理Zulip账号关联数据失败', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + cleanupError: (cleanupError as Error).message, + }); + } + + throw error; + } + } } \ No newline at end of file diff --git a/src/business/auth/services/login.service.zulip-account.spec.ts b/src/business/auth/services/login.service.zulip-account.spec.ts new file mode 100644 index 0000000..4011b47 --- /dev/null +++ b/src/business/auth/services/login.service.zulip-account.spec.ts @@ -0,0 +1,520 @@ +/** + * LoginService Zulip账号创建属性测试 + * + * 功能描述: + * - 测试用户注册时Zulip账号创建的一致性 + * - 验证账号关联和数据完整性 + * - 测试失败回滚机制 + * + * 属性测试: + * - 属性 13: Zulip账号创建一致性 + * - 验证需求: 账号创建成功率和数据一致性 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as fc from 'fast-check'; +import { LoginService } from './login.service'; +import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +import { Users } from '../../../core/db/users/users.entity'; +import { ZulipAccounts } from '../../../core/db/zulip_accounts/zulip_accounts.entity'; + +describe('LoginService - Zulip账号创建属性测试', () => { + let loginService: LoginService; + let loginCoreService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let zulipAccountsRepository: jest.Mocked; + let apiKeySecurityService: jest.Mocked; + + // 测试用的模拟数据生成器 + const validEmailArb = fc.string({ minLength: 5, maxLength: 50 }) + .filter(s => s.includes('@') && s.includes('.')) + .map(s => `test_${s.replace(/[^a-zA-Z0-9@._-]/g, '')}@example.com`); + + const validUsernameArb = fc.string({ minLength: 3, maxLength: 20 }) + .filter(s => /^[a-zA-Z0-9_]+$/.test(s)); + + const validNicknameArb = fc.string({ minLength: 2, maxLength: 50 }) + .filter(s => s.trim().length > 0); + + const validPasswordArb = fc.string({ minLength: 8, maxLength: 20 }) + .filter(s => /[a-zA-Z]/.test(s) && /\d/.test(s)); + + const registerRequestArb = fc.record({ + username: validUsernameArb, + email: validEmailArb, + nickname: validNicknameArb, + password: validPasswordArb, + }); + + beforeEach(async () => { + // 创建模拟服务 + const mockLoginCoreService = { + register: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsRepository = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginService, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsRepository', + useValue: mockZulipAccountsRepository, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, + ], + }).compile(); + + loginService = module.get(LoginService); + loginCoreService = module.get(LoginCoreService); + zulipAccountService = module.get(ZulipAccountService); + zulipAccountsRepository = module.get('ZulipAccountsRepository'); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // 设置环境变量模拟 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }); + + afterEach(() => { + jest.clearAllMocks(); + // 清理环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + }); + + /** + * 属性 13: Zulip账号创建一致性 + * + * 验证需求: 账号创建成功率和数据一致性 + * + * 测试内容: + * 1. 成功注册时,游戏账号和Zulip账号都应该被创建 + * 2. 账号关联信息应该正确存储 + * 3. Zulip账号创建失败时,游戏账号应该被回滚 + * 4. 数据一致性:邮箱、昵称等信息应该保持一致 + */ + describe('属性 13: Zulip账号创建一致性', () => { + it('应该在成功注册时创建一致的游戏账号和Zulip账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + const mockZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey, + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue(mockZulipAccount); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.user.email).toBe(registerRequest.email); + expect(result.data?.user.nickname).toBe(registerRequest.nickname); + expect(result.data?.is_new_user).toBe(true); + + // 验证Zulip管理员客户端初始化 + expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({ + realm: 'https://test.zulip.com', + username: 'bot@test.zulip.com', + apiKey: 'test_api_key_123', + }); + + // 验证游戏用户注册 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证API Key存储 + expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.apiKey + ); + + // 验证账号关联创建 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: mockZulipResult.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }); + + // 验证内存关联 + expect(zulipAccountService.linkGameAccount).toHaveBeenCalledWith( + mockGameUser.id.toString(), + mockZulipResult.userId, + mockZulipResult.email, + mockZulipResult.apiKey + ); + }), + { numRuns: 100 } + ); + }); + + it('应该在Zulip账号创建失败时回滚游戏账号', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 - Zulip账号创建失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: false, + error: 'Zulip服务器连接失败', + errorCode: 'CONNECTION_FAILED', + }); + loginCoreService.deleteUser.mockResolvedValue(true); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip账号创建失败'); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证Zulip账号创建尝试 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, + fullName: registerRequest.nickname, + password: registerRequest.password, + }); + + // 验证游戏用户被回滚删除 + expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有创建账号关联 + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + expect(zulipAccountService.linkGameAccount).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理已存在Zulip账号关联的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const existingZulipAccount: ZulipAccounts = { + id: BigInt(Math.floor(Math.random() * 1000000)), + gameUserId: mockGameUser.id, + zulipUserId: 12345, + zulipEmail: registerRequest.email, + zulipFullName: registerRequest.nickname, + zulipApiKeyEncrypted: 'existing_encrypted_key', + status: 'active', + createdAt: new Date(), + updatedAt: new Date(), + } as ZulipAccounts; + + // 设置模拟行为 - 已存在Zulip账号关联 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(existingZulipAccount); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该成功 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证检查了现有关联 + expect(zulipAccountsRepository.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id); + + // 验证没有尝试创建新的Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + }), + { numRuns: 100 } + ); + }); + + it('应该正确处理缺少邮箱或密码的注册请求', async () => { + await fc.assert( + fc.asyncProperty( + fc.record({ + username: validUsernameArb, + nickname: validNicknameArb, + email: fc.option(validEmailArb, { nil: undefined }), + password: fc.option(validPasswordArb, { nil: undefined }), + }), + async (registerRequest) => { + // 只测试缺少邮箱或密码的情况 + if (registerRequest.email && registerRequest.password) { + return; // 跳过完整数据的情况 + } + + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email || null, + nickname: registerRequest.nickname, + password_hash: registerRequest.password ? 'hashed_password' : null, + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + + // 执行注册 + const result = await loginService.register(registerRequest as RegisterRequest); + + // 验证结果 - 注册应该成功,但跳过Zulip账号创建 + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe(registerRequest.username); + expect(result.data?.message).toBe('注册成功'); // 不包含Zulip创建信息 + + // 验证游戏用户被创建 + expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + expect(zulipAccountsRepository.create).not.toHaveBeenCalled(); + } + ), + { numRuns: 50 } + ); + }); + + it('应该正确处理Zulip管理员客户端初始化失败', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 设置模拟行为 - 管理员客户端初始化失败 + zulipAccountService.initializeAdminClient.mockResolvedValue(false); + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员客户端初始化失败'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 验证没有尝试创建Zulip账号 + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + }), + { numRuns: 50 } + ); + }); + + it('应该正确处理环境变量缺失的情况', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 清除环境变量 + delete process.env.ZULIP_SERVER_URL; + delete process.env.ZULIP_BOT_EMAIL; + delete process.env.ZULIP_BOT_API_KEY; + + // 执行注册 + const result = await loginService.register(registerRequest); + + // 验证结果 - 注册应该失败 + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip管理员配置不完整'); + + // 验证没有尝试创建游戏用户 + expect(loginCoreService.register).not.toHaveBeenCalled(); + + // 恢复环境变量 + process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; + process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; + process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + }), + { numRuns: 30 } + ); + }); + }); + + /** + * 数据一致性验证测试 + * + * 验证游戏账号和Zulip账号之间的数据一致性 + */ + describe('数据一致性验证', () => { + it('应该确保游戏账号和Zulip账号使用相同的邮箱和昵称', async () => { + await fc.assert( + fc.asyncProperty(registerRequestArb, async (registerRequest) => { + // 准备测试数据 + const mockGameUser: Users = { + id: BigInt(Math.floor(Math.random() * 1000000)), + username: registerRequest.username, + email: registerRequest.email, + nickname: registerRequest.nickname, + password_hash: 'hashed_password', + role: 1, + created_at: new Date(), + updated_at: new Date(), + } as Users; + + const mockZulipResult = { + success: true, + userId: Math.floor(Math.random() * 1000000), + email: registerRequest.email, + apiKey: 'zulip_api_key_' + Math.random().toString(36), + }; + + // 设置模拟行为 + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + loginCoreService.register.mockResolvedValue({ + user: mockGameUser, + isNewUser: true, + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + zulipAccountsRepository.create.mockResolvedValue({} as ZulipAccounts); + zulipAccountService.linkGameAccount.mockResolvedValue(true); + + // 执行注册 + await loginService.register(registerRequest); + + // 验证Zulip账号创建时使用了正确的数据 + expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ + email: registerRequest.email, // 相同的邮箱 + fullName: registerRequest.nickname, // 相同的昵称 + password: registerRequest.password, // 相同的密码 + }); + + // 验证账号关联存储了正确的数据 + expect(zulipAccountsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + gameUserId: mockGameUser.id, + zulipUserId: mockZulipResult.userId, + zulipEmail: registerRequest.email, // 相同的邮箱 + zulipFullName: registerRequest.nickname, // 相同的昵称 + zulipApiKeyEncrypted: 'stored_in_redis', + status: 'active', + }) + ); + }), + { numRuns: 100 } + ); + }); + }); +}); \ No newline at end of file diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 2c6e6f2..0e18c51 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -31,6 +31,7 @@ import { IZulipClientPoolService, IZulipConfigService, } from '../../core/zulip/interfaces/zulip-core.interfaces'; +import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; /** * 玩家登录请求接口 @@ -114,6 +115,7 @@ export class ZulipService { private readonly eventProcessor: ZulipEventProcessorService, @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, + private readonly apiKeySecurityService: ApiKeySecurityService, ) { this.logger.log('ZulipService初始化完成'); @@ -321,36 +323,38 @@ export class ZulipService { // 从Token中提取用户ID(模拟) const userId = `user_${token.substring(0, 8)}`; - // 为测试用户提供真实的 Zulip API Key + // 从ApiKeySecurityService获取真实的Zulip API Key let zulipApiKey = undefined; let zulipEmail = undefined; - // 检查是否是配置了真实 Zulip API Key 的测试用户 - const hasTestApiKey = token.includes('lCPWCPf'); - const hasUserApiKey = token.includes('W2KhXaQx'); - const hasOldApiKey = token.includes('MZ1jEMQo'); - const isRealUserToken = token === 'real_user_token_with_zulip_key_123'; - - this.logger.log('Token检查', { - operation: 'validateGameToken', - userId, - tokenPrefix: token.substring(0, 20), - hasUserApiKey, - hasOldApiKey, - isRealUserToken, - }); - - if (isRealUserToken || hasUserApiKey || hasTestApiKey || hasOldApiKey) { - // 使用用户的真实 API Key - // 注意:这个API Key对应的Zulip用户邮箱是 user8@zulip.xinghangee.icu - zulipApiKey = 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8'; - zulipEmail = 'angjustinl@mail.angforever.top'; + try { + // 尝试从Redis获取存储的API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - this.logger.log('配置真实Zulip API Key', { + if (apiKeyResult.success && apiKeyResult.apiKey) { + zulipApiKey = apiKeyResult.apiKey; + // TODO: 从数据库获取用户的Zulip邮箱 + // 暂时使用模拟数据 + zulipEmail = 'angjustinl@163.com'; + + this.logger.log('从存储获取到Zulip API Key', { + operation: 'validateGameToken', + userId, + hasApiKey: true, + zulipEmail, + }); + } else { + this.logger.debug('用户没有存储的Zulip API Key', { + operation: 'validateGameToken', + userId, + }); + } + } catch (error) { + const err = error as Error; + this.logger.warn('获取Zulip API Key失败', { operation: 'validateGameToken', userId, - zulipEmail, - hasApiKey: true, + error: err.message, }); } @@ -358,7 +362,6 @@ export class ZulipService { userId, username: `Player_${userId.substring(5, 10)}`, email: `${userId}@example.com`, - // 实际项目中从数据库获取 zulipEmail, zulipApiKey, }; diff --git a/src/core/db/users/users.entity.ts b/src/core/db/users/users.entity.ts index 1a785cc..9a4bdb2 100644 --- a/src/core/db/users/users.entity.ts +++ b/src/core/db/users/users.entity.ts @@ -19,8 +19,9 @@ * @since 2025-12-17 */ -import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, OneToOne } from 'typeorm'; import { UserStatus } from '../../../business/user-mgmt/enums/user-status.enum'; +import { ZulipAccounts } from '../zulip_accounts/zulip_accounts.entity'; /** * 用户实体类 @@ -432,4 +433,25 @@ export class Users { comment: '更新时间' }) updated_at: Date; + + /** + * 关联的Zulip账号 + * + * 关系设计: + * - 类型:一对一关系(OneToOne) + * - 外键:在ZulipAccounts表中 + * - 级联:不设置级联删除,保证数据安全 + * + * 业务规则: + * - 每个游戏用户最多关联一个Zulip账号 + * - 支持延迟加载,提高查询性能 + * - 可选关联,不是所有用户都有Zulip账号 + * + * 使用场景: + * - 游戏内聊天功能集成 + * - 跨平台消息同步 + * - 用户身份验证和权限管理 + */ + @OneToOne(() => ZulipAccounts, zulipAccount => zulipAccount.gameUser) + zulipAccount?: ZulipAccounts; } \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.entity.ts b/src/core/db/zulip_accounts/zulip_accounts.entity.ts new file mode 100644 index 0000000..10034bf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.entity.ts @@ -0,0 +1,185 @@ +/** + * Zulip账号关联实体 + * + * 功能描述: + * - 存储游戏用户与Zulip账号的关联关系 + * - 管理Zulip账号的基本信息和状态 + * - 提供账号验证和同步功能 + * + * 关联关系: + * - 与Users表建立一对一关系 + * - 存储Zulip用户ID、邮箱、API Key等信息 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToOne, JoinColumn, Index } from 'typeorm'; +import { Users } from '../users/users.entity'; + +@Entity('zulip_accounts') +@Index(['zulip_user_id'], { unique: true }) +@Index(['zulip_email'], { unique: true }) +export class ZulipAccounts { + /** + * 主键ID + */ + @PrimaryGeneratedColumn('increment', { type: 'bigint' }) + id: bigint; + + /** + * 关联的游戏用户ID + */ + @Column({ type: 'bigint', name: 'game_user_id', comment: '关联的游戏用户ID' }) + gameUserId: bigint; + + /** + * Zulip用户ID + */ + @Column({ type: 'int', name: 'zulip_user_id', comment: 'Zulip服务器上的用户ID' }) + zulipUserId: number; + + /** + * Zulip用户邮箱 + */ + @Column({ type: 'varchar', length: 255, name: 'zulip_email', comment: 'Zulip账号邮箱地址' }) + zulipEmail: string; + + /** + * Zulip用户全名 + */ + @Column({ type: 'varchar', length: 100, name: 'zulip_full_name', comment: 'Zulip账号全名' }) + zulipFullName: string; + + /** + * Zulip API Key(加密存储) + */ + @Column({ type: 'text', name: 'zulip_api_key_encrypted', comment: '加密存储的Zulip API Key' }) + zulipApiKeyEncrypted: string; + + /** + * 账号状态 + * - active: 正常激活状态 + * - inactive: 未激活状态 + * - suspended: 暂停状态 + * - error: 错误状态 + */ + @Column({ + type: 'enum', + enum: ['active', 'inactive', 'suspended', 'error'], + default: 'active', + comment: '账号状态:active-正常,inactive-未激活,suspended-暂停,error-错误' + }) + status: 'active' | 'inactive' | 'suspended' | 'error'; + + /** + * 最后验证时间 + */ + @Column({ type: 'timestamp', name: 'last_verified_at', nullable: true, comment: '最后一次验证Zulip账号的时间' }) + lastVerifiedAt: Date | null; + + /** + * 最后同步时间 + */ + @Column({ type: 'timestamp', name: 'last_synced_at', nullable: true, comment: '最后一次同步数据的时间' }) + lastSyncedAt: Date | null; + + /** + * 错误信息 + */ + @Column({ type: 'text', name: 'error_message', nullable: true, comment: '最后一次操作的错误信息' }) + errorMessage: string | null; + + /** + * 重试次数 + */ + @Column({ type: 'int', name: 'retry_count', default: 0, comment: '创建或同步失败的重试次数' }) + retryCount: number; + + /** + * 创建时间 + */ + @CreateDateColumn({ name: 'created_at', comment: '记录创建时间' }) + createdAt: Date; + + /** + * 更新时间 + */ + @UpdateDateColumn({ name: 'updated_at', comment: '记录最后更新时间' }) + updatedAt: Date; + + /** + * 关联的游戏用户 + */ + @OneToOne(() => Users, user => user.zulipAccount) + @JoinColumn({ name: 'game_user_id' }) + gameUser: Users; + + /** + * 检查账号是否处于正常状态 + * + * @returns boolean 是否为正常状态 + */ + isActive(): boolean { + return this.status === 'active'; + } + + /** + * 检查账号是否需要重新验证 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns boolean 是否需要重新验证 + */ + needsVerification(maxAge: number = 24 * 60 * 60 * 1000): boolean { + if (!this.lastVerifiedAt) { + return true; + } + + const now = new Date(); + const timeDiff = now.getTime() - this.lastVerifiedAt.getTime(); + return timeDiff > maxAge; + } + + /** + * 更新验证时间 + */ + updateVerificationTime(): void { + this.lastVerifiedAt = new Date(); + } + + /** + * 更新同步时间 + */ + updateSyncTime(): void { + this.lastSyncedAt = new Date(); + } + + /** + * 设置错误状态 + * + * @param errorMessage 错误信息 + */ + setError(errorMessage: string): void { + this.status = 'error'; + this.errorMessage = errorMessage; + this.retryCount += 1; + } + + /** + * 清除错误状态 + */ + clearError(): void { + if (this.status === 'error') { + this.status = 'active'; + this.errorMessage = null; + } + } + + /** + * 重置重试计数 + */ + resetRetryCount(): void { + this.retryCount = 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts.module.ts b/src/core/db/zulip_accounts/zulip_accounts.module.ts new file mode 100644 index 0000000..6c288ef --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.module.ts @@ -0,0 +1,81 @@ +/** + * Zulip账号关联数据模块 + * + * 功能描述: + * - 提供Zulip账号关联数据的访问接口 + * - 封装TypeORM实体和Repository + * - 为业务层提供数据访问服务 + * - 支持数据库和内存模式的动态切换 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Module, DynamicModule, Global } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { ZulipAccountsRepository } from './zulip_accounts.repository'; +import { ZulipAccountsMemoryRepository } from './zulip_accounts_memory.repository'; + +/** + * 检查数据库配置是否完整 + * + * @returns 是否配置了数据库 + */ +function isDatabaseConfigured(): boolean { + const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME']; + return requiredEnvVars.every(varName => process.env[varName]); +} + +@Global() +@Module({}) +export class ZulipAccountsModule { + /** + * 创建数据库模式的Zulip账号模块 + * + * @returns 配置了TypeORM的动态模块 + */ + static forDatabase(): DynamicModule { + return { + module: ZulipAccountsModule, + imports: [TypeOrmModule.forFeature([ZulipAccounts])], + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsRepository, + }, + ], + exports: ['ZulipAccountsRepository', TypeOrmModule], + }; + } + + /** + * 创建内存模式的Zulip账号模块 + * + * @returns 配置了内存存储的动态模块 + */ + static forMemory(): DynamicModule { + return { + module: ZulipAccountsModule, + providers: [ + { + provide: 'ZulipAccountsRepository', + useClass: ZulipAccountsMemoryRepository, + }, + ], + exports: ['ZulipAccountsRepository'], + }; + } + + /** + * 根据环境自动选择模式 + * + * @returns 动态模块 + */ + static forRoot(): DynamicModule { + return isDatabaseConfigured() + ? ZulipAccountsModule.forDatabase() + : ZulipAccountsModule.forMemory(); + } +} diff --git a/src/core/db/zulip_accounts/zulip_accounts.repository.ts b/src/core/db/zulip_accounts/zulip_accounts.repository.ts new file mode 100644 index 0000000..9991d03 --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts.repository.ts @@ -0,0 +1,323 @@ +/** + * Zulip账号关联数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的CRUD操作 + * - 封装复杂查询逻辑和数据库交互 + * - 实现数据访问层的业务逻辑抽象 + * + * 主要功能: + * - 账号关联的创建、查询、更新、删除 + * - 支持按游戏用户ID、Zulip用户ID、邮箱查询 + * - 提供账号状态管理和批量操作 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, FindOptionsWhere } from 'typeorm'; +import { ZulipAccounts } from './zulip_accounts.entity'; + +/** + * 创建Zulip账号关联的数据传输对象 + */ +export interface CreateZulipAccountDto { + gameUserId: bigint; + zulipUserId: number; + zulipEmail: string; + zulipFullName: string; + zulipApiKeyEncrypted: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; +} + +/** + * 更新Zulip账号关联的数据传输对象 + */ +export interface UpdateZulipAccountDto { + zulipFullName?: string; + zulipApiKeyEncrypted?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + lastVerifiedAt?: Date; + lastSyncedAt?: Date; + errorMessage?: string; + retryCount?: number; +} + +/** + * Zulip账号查询条件 + */ +export interface ZulipAccountQueryOptions { + gameUserId?: bigint; + zulipUserId?: number; + zulipEmail?: string; + status?: 'active' | 'inactive' | 'suspended' | 'error'; + includeGameUser?: boolean; +} + +@Injectable() +export class ZulipAccountsRepository { + constructor( + @InjectRepository(ZulipAccounts) + private readonly repository: Repository, + ) {} + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const zulipAccount = this.repository.create(createDto); + return await this.repository.save(zulipAccount); + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { gameUserId }, + relations, + }); + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipUserId }, + relations, + }); + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { zulipEmail }, + relations, + }); + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息 + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + const relations = includeGameUser ? ['gameUser'] : []; + + return await this.repository.findOne({ + where: { id }, + relations, + }); + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ id }, updateDto); + return await this.findById(id); + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + await this.repository.update({ gameUserId }, updateDto); + return await this.findByGameUserId(gameUserId); + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + const result = await this.repository.delete({ id }); + return result.affected > 0; + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + const result = await this.repository.delete({ gameUserId }); + return result.affected > 0; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + const { includeGameUser, ...whereOptions } = options; + const relations = includeGameUser ? ['gameUser'] : []; + + // 构建查询条件 + const where: FindOptionsWhere = {}; + if (whereOptions.gameUserId) where.gameUserId = whereOptions.gameUserId; + if (whereOptions.zulipUserId) where.zulipUserId = whereOptions.zulipUserId; + if (whereOptions.zulipEmail) where.zulipEmail = whereOptions.zulipEmail; + if (whereOptions.status) where.status = whereOptions.status; + + return await this.repository.find({ + where, + relations, + order: { createdAt: 'DESC' }, + }); + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return await this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.status = :status', { status: 'active' }) + .andWhere( + '(zulip_accounts.last_verified_at IS NULL OR zulip_accounts.last_verified_at < :cutoffTime)', + { cutoffTime } + ) + .orderBy('zulip_accounts.last_verified_at', 'ASC', 'NULLS FIRST') + .getMany(); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数,默认3次 + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return await this.repository.find({ + where: { status: 'error' }, + order: { updatedAt: 'ASC' }, + }); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + const result = await this.repository + .createQueryBuilder() + .update(ZulipAccounts) + .set({ status }) + .whereInIds(ids) + .execute(); + + return result.affected || 0; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const result = await this.repository + .createQueryBuilder('zulip_accounts') + .select('zulip_accounts.status', 'status') + .addSelect('COUNT(*)', 'count') + .groupBy('zulip_accounts.status') + .getRawMany(); + + const statistics: Record = {}; + result.forEach(row => { + statistics[row.status] = parseInt(row.count, 10); + }); + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_email = :zulipEmail', { zulipEmail }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + const queryBuilder = this.repository + .createQueryBuilder('zulip_accounts') + .where('zulip_accounts.zulip_user_id = :zulipUserId', { zulipUserId }); + + if (excludeId) { + queryBuilder.andWhere('zulip_accounts.id != :excludeId', { excludeId }); + } + + const count = await queryBuilder.getCount(); + return count > 0; + } +} \ No newline at end of file diff --git a/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts new file mode 100644 index 0000000..e31e6cf --- /dev/null +++ b/src/core/db/zulip_accounts/zulip_accounts_memory.repository.ts @@ -0,0 +1,299 @@ +/** + * Zulip账号关联内存数据访问层 + * + * 功能描述: + * - 提供Zulip账号关联数据的内存存储实现 + * - 用于开发和测试环境 + * - 实现与数据库版本相同的接口 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable } from '@nestjs/common'; +import { ZulipAccounts } from './zulip_accounts.entity'; +import { + CreateZulipAccountDto, + UpdateZulipAccountDto, + ZulipAccountQueryOptions, +} from './zulip_accounts.repository'; + +@Injectable() +export class ZulipAccountsMemoryRepository { + private accounts: Map = new Map(); + private currentId: bigint = BigInt(1); + + /** + * 创建新的Zulip账号关联 + * + * @param createDto 创建数据 + * @returns Promise 创建的关联记录 + */ + async create(createDto: CreateZulipAccountDto): Promise { + const account = new ZulipAccounts(); + account.id = this.currentId++; + account.gameUserId = createDto.gameUserId; + account.zulipUserId = createDto.zulipUserId; + account.zulipEmail = createDto.zulipEmail; + account.zulipFullName = createDto.zulipFullName; + account.zulipApiKeyEncrypted = createDto.zulipApiKeyEncrypted; + account.status = createDto.status || 'active'; + account.createdAt = new Date(); + account.updatedAt = new Date(); + + this.accounts.set(account.id, account); + return account; + } + + /** + * 根据游戏用户ID查找Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByGameUserId(gameUserId: bigint, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.gameUserId === gameUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip用户ID查找账号关联 + * + * @param zulipUserId Zulip用户ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipUserId(zulipUserId: number, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipUserId === zulipUserId) { + return account; + } + } + return null; + } + + /** + * 根据Zulip邮箱查找账号关联 + * + * @param zulipEmail Zulip邮箱 + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findByZulipEmail(zulipEmail: string, includeGameUser: boolean = false): Promise { + for (const account of this.accounts.values()) { + if (account.zulipEmail === zulipEmail) { + return account; + } + } + return null; + } + + /** + * 根据ID查找Zulip账号关联 + * + * @param id 关联记录ID + * @param includeGameUser 是否包含游戏用户信息(内存模式忽略) + * @returns Promise 关联记录或null + */ + async findById(id: bigint, includeGameUser: boolean = false): Promise { + return this.accounts.get(id) || null; + } + + /** + * 更新Zulip账号关联 + * + * @param id 关联记录ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async update(id: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = this.accounts.get(id); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 根据游戏用户ID更新Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @param updateDto 更新数据 + * @returns Promise 更新后的记录或null + */ + async updateByGameUserId(gameUserId: bigint, updateDto: UpdateZulipAccountDto): Promise { + const account = await this.findByGameUserId(gameUserId); + if (!account) { + return null; + } + + Object.assign(account, updateDto); + account.updatedAt = new Date(); + + return account; + } + + /** + * 删除Zulip账号关联 + * + * @param id 关联记录ID + * @returns Promise 是否删除成功 + */ + async delete(id: bigint): Promise { + return this.accounts.delete(id); + } + + /** + * 根据游戏用户ID删除Zulip账号关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否删除成功 + */ + async deleteByGameUserId(gameUserId: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.gameUserId === gameUserId) { + return this.accounts.delete(id); + } + } + return false; + } + + /** + * 查询多个Zulip账号关联 + * + * @param options 查询选项 + * @returns Promise 关联记录列表 + */ + async findMany(options: ZulipAccountQueryOptions = {}): Promise { + let results = Array.from(this.accounts.values()); + + if (options.gameUserId) { + results = results.filter(a => a.gameUserId === options.gameUserId); + } + if (options.zulipUserId) { + results = results.filter(a => a.zulipUserId === options.zulipUserId); + } + if (options.zulipEmail) { + results = results.filter(a => a.zulipEmail === options.zulipEmail); + } + if (options.status) { + results = results.filter(a => a.status === options.status); + } + + // 按创建时间降序排序 + results.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + return results; + } + + /** + * 获取需要验证的账号列表 + * + * @param maxAge 最大验证间隔(毫秒),默认24小时 + * @returns Promise 需要验证的账号列表 + */ + async findAccountsNeedingVerification(maxAge: number = 24 * 60 * 60 * 1000): Promise { + const cutoffTime = new Date(Date.now() - maxAge); + + return Array.from(this.accounts.values()) + .filter(account => + account.status === 'active' && + (!account.lastVerifiedAt || account.lastVerifiedAt < cutoffTime) + ) + .sort((a, b) => { + if (!a.lastVerifiedAt) return -1; + if (!b.lastVerifiedAt) return 1; + return a.lastVerifiedAt.getTime() - b.lastVerifiedAt.getTime(); + }); + } + + /** + * 获取错误状态的账号列表 + * + * @param maxRetryCount 最大重试次数(内存模式忽略) + * @returns Promise 错误状态的账号列表 + */ + async findErrorAccounts(maxRetryCount: number = 3): Promise { + return Array.from(this.accounts.values()) + .filter(account => account.status === 'error') + .sort((a, b) => a.updatedAt.getTime() - b.updatedAt.getTime()); + } + + /** + * 批量更新账号状态 + * + * @param ids 账号ID列表 + * @param status 新状态 + * @returns Promise 更新的记录数 + */ + async batchUpdateStatus(ids: bigint[], status: 'active' | 'inactive' | 'suspended' | 'error'): Promise { + let count = 0; + for (const id of ids) { + const account = this.accounts.get(id); + if (account) { + account.status = status; + account.updatedAt = new Date(); + count++; + } + } + return count; + } + + /** + * 统计各状态的账号数量 + * + * @returns Promise> 状态统计 + */ + async getStatusStatistics(): Promise> { + const statistics: Record = {}; + + for (const account of this.accounts.values()) { + const status = account.status; + statistics[status] = (statistics[status] || 0) + 1; + } + + return statistics; + } + + /** + * 检查邮箱是否已存在 + * + * @param zulipEmail Zulip邮箱 + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByEmail(zulipEmail: string, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipEmail === zulipEmail && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } + + /** + * 检查Zulip用户ID是否已存在 + * + * @param zulipUserId Zulip用户ID + * @param excludeId 排除的记录ID(用于更新时检查) + * @returns Promise 是否已存在 + */ + async existsByZulipUserId(zulipUserId: number, excludeId?: bigint): Promise { + for (const [id, account] of this.accounts.entries()) { + if (account.zulipUserId === zulipUserId && (!excludeId || id !== excludeId)) { + return true; + } + } + return false; + } +} diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index a53f79f..38aac56 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -826,4 +826,36 @@ export class LoginCoreService { VerificationCodeType.EMAIL_VERIFICATION ); } + + /** + * 删除用户 + * + * 功能描述: + * 删除指定的用户记录,用于注册失败时的回滚操作 + * + * 业务逻辑: + * 1. 验证用户是否存在 + * 2. 执行用户删除操作 + * 3. 返回删除结果 + * + * @param userId 用户ID + * @returns Promise 是否删除成功 + * @throws NotFoundException 用户不存在时 + */ + async deleteUser(userId: bigint): Promise { + // 1. 验证用户是否存在 + const user = await this.usersService.findOne(userId); + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 2. 执行删除操作 + try { + await this.usersService.remove(userId); + return true; + } catch (error) { + console.error(`删除用户失败: ${userId}`, error); + return false; + } + } } \ No newline at end of file diff --git a/src/core/zulip/services/zulip_account.service.ts b/src/core/zulip/services/zulip_account.service.ts new file mode 100644 index 0000000..162ea7c --- /dev/null +++ b/src/core/zulip/services/zulip_account.service.ts @@ -0,0 +1,708 @@ +/** + * Zulip账号管理核心服务 + * + * 功能描述: + * - 自动创建Zulip用户账号 + * - 生成API Key并安全存储 + * - 处理账号创建失败场景 + * - 管理用户账号与游戏账号的关联 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 关联游戏账号与Zulip账号 + * + * 使用场景: + * - 用户注册时自动创建Zulip账号 + * - API Key管理和更新 + * - 账号关联和映射存储 + * + * @author angjustinl + * @version 1.0.0 + * @since 2025-01-05 + */ + +import { Injectable, Logger } from '@nestjs/common'; +import { ZulipClientConfig } from '../interfaces/zulip-core.interfaces'; + +/** + * Zulip账号创建请求接口 + */ +export interface CreateZulipAccountRequest { + email: string; + fullName: string; + password?: string; + shortName?: string; +} + +/** + * Zulip账号创建结果接口 + */ +export interface CreateZulipAccountResult { + success: boolean; + userId?: number; + email?: string; + apiKey?: string; + error?: string; + errorCode?: string; +} + +/** + * API Key生成结果接口 + */ +export interface GenerateApiKeyResult { + success: boolean; + apiKey?: string; + error?: string; +} + +/** + * 账号验证结果接口 + */ +export interface ValidateAccountResult { + success: boolean; + isValid?: boolean; + userInfo?: any; + error?: string; +} + +/** + * 账号关联信息接口 + */ +export interface AccountLinkInfo { + gameUserId: string; + zulipUserId: number; + zulipEmail: string; + zulipApiKey: string; + createdAt: Date; + lastVerified?: Date; + isActive: boolean; +} + +/** + * Zulip账号管理服务类 + * + * 职责: + * - 处理Zulip用户账号的创建和管理 + * - 管理API Key的生成和存储 + * - 维护游戏账号与Zulip账号的关联关系 + * - 提供账号验证和状态检查功能 + * + * 主要方法: + * - createZulipAccount(): 创建新的Zulip用户账号 + * - generateApiKey(): 为现有用户生成API Key + * - validateZulipAccount(): 验证Zulip账号有效性 + * - linkGameAccount(): 建立游戏账号与Zulip账号的关联 + * - unlinkGameAccount(): 解除账号关联 + * + * 使用场景: + * - 用户注册流程中自动创建Zulip账号 + * - API Key管理和更新 + * - 账号状态监控和维护 + * - 跨平台账号同步 + */ +@Injectable() +export class ZulipAccountService { + private readonly logger = new Logger(ZulipAccountService.name); + private adminClient: any = null; + private readonly accountLinks = new Map(); + + constructor() { + this.logger.log('ZulipAccountService初始化完成'); + } + + /** + * 初始化管理员客户端 + * + * 功能描述: + * 使用管理员凭证初始化Zulip客户端,用于创建用户账号 + * + * @param adminConfig 管理员配置 + * @returns Promise 是否初始化成功 + */ + async initializeAdminClient(adminConfig: ZulipClientConfig): Promise { + this.logger.log('初始化Zulip管理员客户端', { + operation: 'initializeAdminClient', + realm: adminConfig.realm, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 创建管理员客户端 + this.adminClient = await zulipInit({ + username: adminConfig.username, + apiKey: adminConfig.apiKey, + realm: adminConfig.realm, + }); + + // 验证管理员权限 + const profile = await this.adminClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`管理员客户端验证失败: ${profile.msg || '未知错误'}`); + } + + this.logger.log('管理员客户端初始化成功', { + operation: 'initializeAdminClient', + adminEmail: profile.email, + isAdmin: profile.is_admin, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + this.logger.error('管理员客户端初始化失败', { + operation: 'initializeAdminClient', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 创建Zulip用户账号 + * + * 功能描述: + * 使用管理员权限在Zulip服务器上创建新的用户账号 + * + * 业务逻辑: + * 1. 验证管理员客户端是否已初始化 + * 2. 检查邮箱是否已存在 + * 3. 生成用户密码(如果未提供) + * 4. 调用Zulip API创建用户 + * 5. 为新用户生成API Key + * 6. 返回创建结果 + * + * @param request 账号创建请求 + * @returns Promise 创建结果 + */ + async createZulipAccount(request: CreateZulipAccountRequest): Promise { + const startTime = Date.now(); + + this.logger.log('开始创建Zulip账号', { + operation: 'createZulipAccount', + email: request.email, + fullName: request.fullName, + timestamp: new Date().toISOString(), + }); + + try { + // 1. 验证管理员客户端 + if (!this.adminClient) { + throw new Error('管理员客户端未初始化'); + } + + // 2. 验证请求参数 + if (!request.email || !request.email.trim()) { + throw new Error('邮箱地址不能为空'); + } + + if (!request.fullName || !request.fullName.trim()) { + throw new Error('用户全名不能为空'); + } + + // 3. 检查邮箱格式 + if (!this.isValidEmail(request.email)) { + throw new Error('邮箱格式无效'); + } + + // 4. 检查用户是否已存在 + const existingUser = await this.checkUserExists(request.email); + if (existingUser) { + this.logger.warn('用户已存在', { + operation: 'createZulipAccount', + email: request.email, + }); + return { + success: false, + error: '用户已存在', + errorCode: 'USER_ALREADY_EXISTS', + }; + } + + // 5. 生成密码(如果未提供) + const password = request.password || this.generateRandomPassword(); + const shortName = request.shortName || this.generateShortName(request.email); + + // 6. 创建用户参数 + const createParams = { + email: request.email, + password: password, + full_name: request.fullName, + short_name: shortName, + }; + + // 7. 调用Zulip API创建用户 + const createResponse = await this.adminClient.users.create(createParams); + + if (createResponse.result !== 'success') { + this.logger.warn('Zulip用户创建失败', { + operation: 'createZulipAccount', + email: request.email, + error: createResponse.msg, + }); + return { + success: false, + error: createResponse.msg || '用户创建失败', + errorCode: 'ZULIP_CREATE_FAILED', + }; + } + + // 8. 为新用户生成API Key + const apiKeyResult = await this.generateApiKeyForUser(request.email, password); + + if (!apiKeyResult.success) { + this.logger.warn('API Key生成失败,但用户已创建', { + operation: 'createZulipAccount', + email: request.email, + error: apiKeyResult.error, + }); + // 用户已创建,但API Key生成失败 + return { + success: true, + userId: createResponse.user_id, + email: request.email, + error: `用户创建成功,但API Key生成失败: ${apiKeyResult.error}`, + errorCode: 'API_KEY_GENERATION_FAILED', + }; + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建成功', { + operation: 'createZulipAccount', + email: request.email, + userId: createResponse.user_id, + hasApiKey: !!apiKeyResult.apiKey, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + userId: createResponse.user_id, + email: request.email, + apiKey: apiKeyResult.apiKey, + }; + + } catch (error) { + const err = error as Error; + const duration = Date.now() - startTime; + + this.logger.error('创建Zulip账号失败', { + operation: 'createZulipAccount', + email: request.email, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + errorCode: 'ACCOUNT_CREATION_FAILED', + }; + } + } + + /** + * 为用户生成API Key + * + * 功能描述: + * 使用用户凭证获取API Key + * + * @param email 用户邮箱 + * @param password 用户密码 + * @returns Promise 生成结果 + */ + async generateApiKeyForUser(email: string, password: string): Promise { + this.logger.log('为用户生成API Key', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + try { + // 动态导入zulip-js + const zulipInit = await this.loadZulipModule(); + + // 使用用户凭证获取API Key + const userClient = await zulipInit({ + username: email, + password: password, + realm: this.getRealmFromAdminClient(), + }); + + // 验证客户端并获取API Key + const profile = await userClient.users.me.getProfile(); + + if (profile.result !== 'success') { + throw new Error(`API Key获取失败: ${profile.msg || '未知错误'}`); + } + + // 从客户端配置中提取API Key + const apiKey = userClient.config?.apiKey; + + if (!apiKey) { + throw new Error('无法从客户端配置中获取API Key'); + } + + this.logger.log('API Key生成成功', { + operation: 'generateApiKeyForUser', + email, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + apiKey: apiKey, + }; + + } catch (error) { + const err = error as Error; + + this.logger.error('API Key生成失败', { + operation: 'generateApiKeyForUser', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 验证Zulip账号有效性 + * + * 功能描述: + * 验证指定的Zulip账号是否存在且有效 + * + * @param email 用户邮箱 + * @param apiKey 用户API Key(可选) + * @returns Promise 验证结果 + */ + async validateZulipAccount(email: string, apiKey?: string): Promise { + this.logger.log('验证Zulip账号', { + operation: 'validateZulipAccount', + email, + hasApiKey: !!apiKey, + timestamp: new Date().toISOString(), + }); + + try { + if (apiKey) { + // 使用API Key验证 + const zulipInit = await this.loadZulipModule(); + const userClient = await zulipInit({ + username: email, + apiKey: apiKey, + realm: this.getRealmFromAdminClient(), + }); + + const profile = await userClient.users.me.getProfile(); + + if (profile.result === 'success') { + this.logger.log('账号验证成功(API Key)', { + operation: 'validateZulipAccount', + email, + userId: profile.user_id, + }); + + return { + success: true, + isValid: true, + userInfo: profile, + }; + } else { + return { + success: true, + isValid: false, + error: profile.msg || 'API Key验证失败', + }; + } + } else { + // 仅检查用户是否存在 + const userExists = await this.checkUserExists(email); + + this.logger.log('账号存在性检查完成', { + operation: 'validateZulipAccount', + email, + exists: userExists, + }); + + return { + success: true, + isValid: userExists, + }; + } + + } catch (error) { + const err = error as Error; + + this.logger.error('账号验证失败', { + operation: 'validateZulipAccount', + email, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + error: err.message, + }; + } + } + + /** + * 关联游戏账号与Zulip账号 + * + * 功能描述: + * 建立游戏用户ID与Zulip账号的映射关系 + * + * @param gameUserId 游戏用户ID + * @param zulipUserId Zulip用户ID + * @param zulipEmail Zulip邮箱 + * @param zulipApiKey Zulip API Key + * @returns Promise 是否关联成功 + */ + async linkGameAccount( + gameUserId: string, + zulipUserId: number, + zulipEmail: string, + zulipApiKey: string, + ): Promise { + this.logger.log('关联游戏账号与Zulip账号', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + try { + // 验证参数 + if (!gameUserId || !zulipUserId || !zulipEmail || !zulipApiKey) { + throw new Error('关联参数不完整'); + } + + // 创建关联信息 + const linkInfo: AccountLinkInfo = { + gameUserId, + zulipUserId, + zulipEmail, + zulipApiKey, + createdAt: new Date(), + isActive: true, + }; + + // 存储关联信息(实际项目中应存储到数据库) + this.accountLinks.set(gameUserId, linkInfo); + + this.logger.log('账号关联成功', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + zulipEmail, + timestamp: new Date().toISOString(), + }); + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('账号关联失败', { + operation: 'linkGameAccount', + gameUserId, + zulipUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 解除游戏账号与Zulip账号的关联 + * + * @param gameUserId 游戏用户ID + * @returns Promise 是否解除成功 + */ + async unlinkGameAccount(gameUserId: string): Promise { + this.logger.log('解除账号关联', { + operation: 'unlinkGameAccount', + gameUserId, + timestamp: new Date().toISOString(), + }); + + try { + const linkInfo = this.accountLinks.get(gameUserId); + + if (linkInfo) { + linkInfo.isActive = false; + this.accountLinks.delete(gameUserId); + + this.logger.log('账号关联解除成功', { + operation: 'unlinkGameAccount', + gameUserId, + zulipEmail: linkInfo.zulipEmail, + }); + } + + return true; + + } catch (error) { + const err = error as Error; + + this.logger.error('解除账号关联失败', { + operation: 'unlinkGameAccount', + gameUserId, + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + return false; + } + } + + /** + * 获取游戏账号的Zulip关联信息 + * + * @param gameUserId 游戏用户ID + * @returns AccountLinkInfo | null 关联信息 + */ + getAccountLink(gameUserId: string): AccountLinkInfo | null { + return this.accountLinks.get(gameUserId) || null; + } + + /** + * 获取所有账号关联信息 + * + * @returns AccountLinkInfo[] 所有关联信息 + */ + getAllAccountLinks(): AccountLinkInfo[] { + return Array.from(this.accountLinks.values()).filter(link => link.isActive); + } + + /** + * 检查用户是否已存在 + * + * @param email 用户邮箱 + * @returns Promise 用户是否存在 + * @private + */ + private async checkUserExists(email: string): Promise { + try { + if (!this.adminClient) { + return false; + } + + // 获取所有用户列表 + const usersResponse = await this.adminClient.users.retrieve(); + + if (usersResponse.result === 'success') { + const users = usersResponse.members || []; + return users.some((user: any) => user.email === email); + } + + return false; + + } catch (error) { + const err = error as Error; + this.logger.warn('检查用户存在性失败', { + operation: 'checkUserExists', + email, + error: err.message, + }); + return false; + } + } + + /** + * 验证邮箱格式 + * + * @param email 邮箱地址 + * @returns boolean 是否为有效邮箱 + * @private + */ + private isValidEmail(email: string): boolean { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + /** + * 生成随机密码 + * + * @returns string 随机密码 + * @private + */ + private generateRandomPassword(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + let password = ''; + for (let i = 0; i < 12; i++) { + password += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return password; + } + + /** + * 从邮箱生成短名称 + * + * @param email 邮箱地址 + * @returns string 短名称 + * @private + */ + private generateShortName(email: string): string { + const localPart = email.split('@')[0]; + // 移除特殊字符,只保留字母数字和下划线 + return localPart.replace(/[^a-zA-Z0-9_]/g, '').toLowerCase(); + } + + /** + * 从管理员客户端获取Realm + * + * @returns string Realm URL + * @private + */ + private getRealmFromAdminClient(): string { + if (!this.adminClient || !this.adminClient.config) { + throw new Error('管理员客户端未初始化或配置缺失'); + } + return this.adminClient.config.realm; + } + + /** + * 动态加载zulip-js模块 + * + * @returns Promise zulip-js初始化函数 + * @private + */ + private async loadZulipModule(): Promise { + try { + // 使用动态导入加载zulip-js + const zulipModule = await import('zulip-js'); + return zulipModule.default || zulipModule; + } catch (error) { + const err = error as Error; + this.logger.error('加载zulip-js模块失败', { + operation: 'loadZulipModule', + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + throw new Error(`加载zulip-js模块失败: ${err.message}`); + } + } +} \ No newline at end of file diff --git a/src/core/zulip/zulip-core.module.ts b/src/core/zulip/zulip-core.module.ts index e30d2c1..134ee45 100644 --- a/src/core/zulip/zulip-core.module.ts +++ b/src/core/zulip/zulip-core.module.ts @@ -19,6 +19,7 @@ import { ApiKeySecurityService } from './services/api_key_security.service'; import { ErrorHandlerService } from './services/error_handler.service'; import { MonitoringService } from './services/monitoring.service'; import { StreamInitializerService } from './services/stream_initializer.service'; +import { ZulipAccountService } from './services/zulip_account.service'; import { RedisModule } from '../redis/redis.module'; @Module({ @@ -46,6 +47,7 @@ import { RedisModule } from '../redis/redis.module'; ErrorHandlerService, MonitoringService, StreamInitializerService, + ZulipAccountService, // 直接提供类(用于内部依赖) ZulipClientService, @@ -63,6 +65,7 @@ import { RedisModule } from '../redis/redis.module'; ErrorHandlerService, MonitoringService, StreamInitializerService, + ZulipAccountService, ], }) export class ZulipCoreModule {} \ No newline at end of file -- 2.25.1 From 3733717d1f7caa27cefa3bc04ab7ca23b3c154bc Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Tue, 6 Jan 2026 16:48:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0JWT=E4=BB=A4?= =?UTF-8?q?=E7=89=8C=E5=88=B7=E6=96=B0=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 @nestjs/jwt 和 jsonwebtoken 依赖包 - 实现 refreshAccessToken 方法支持令牌续期 - 添加 RefreshTokenDto 和 RefreshTokenResponseDto - 新增 /auth/refresh-token 接口 - 完善令牌刷新的限流和超时控制 - 增加相关单元测试覆盖 - 优化错误处理和日志记录 --- package.json | 3 + src/business/auth/auth.module.ts | 22 +- .../auth/controllers/login.controller.ts | 109 ++- src/business/auth/dto/login.dto.ts | 17 + src/business/auth/dto/login_response.dto.ts | 81 ++- .../auth/services/login.service.spec.ts | 646 +++++++++++++++++- src/business/auth/services/login.service.ts | 419 +++++++++++- .../login.service.zulip-account.spec.ts | 64 +- src/business/zulip/zulip.service.spec.ts | 14 + .../decorators/throttle.decorator.ts | 3 + 10 files changed, 1304 insertions(+), 74 deletions(-) diff --git a/package.json b/package.json index 0c1126b..f191a4a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.1.9", + "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^10.4.20", "@nestjs/platform-socket.io": "^10.4.20", "@nestjs/schedule": "^4.1.2", @@ -40,6 +41,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "ioredis": "^5.8.2", + "jsonwebtoken": "^9.0.3", "mysql2": "^3.16.0", "nestjs-pino": "^4.5.0", "nodemailer": "^6.10.1", @@ -59,6 +61,7 @@ "@nestjs/testing": "^10.4.20", "@types/express": "^5.0.6", "@types/jest": "^29.5.14", + "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.19.27", "@types/nodemailer": "^6.4.14", "@types/supertest": "^6.0.3", diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 60e8e66..a63dbef 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -6,6 +6,7 @@ * - 用户登录、注册、密码管理 * - GitHub OAuth集成 * - 邮箱验证功能 + * - JWT令牌管理和验证 * * @author kiro-ai * @version 1.0.0 @@ -13,22 +14,41 @@ */ import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoginController } from './controllers/login.controller'; import { LoginService } from './services/login.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { ZulipCoreModule } from '../../core/zulip/zulip-core.module'; import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; +import { UsersModule } from '../../core/db/users/users.module'; @Module({ imports: [ LoginCoreModule, ZulipCoreModule, ZulipAccountsModule.forRoot(), + UsersModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const expiresIn = configService.get('JWT_EXPIRES_IN', '7d'); + return { + secret: configService.get('JWT_SECRET'), + signOptions: { + expiresIn: expiresIn as any, // JWT库支持字符串格式如 '7d' + issuer: 'whale-town', + audience: 'whale-town-users', + }, + }; + }, + inject: [ConfigService], + }), ], controllers: [LoginController], providers: [ LoginService, ], - exports: [LoginService], + exports: [LoginService, JwtModule], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/controllers/login.controller.ts b/src/business/auth/controllers/login.controller.ts index c1d94af..0029901 100644 --- a/src/business/auth/controllers/login.controller.ts +++ b/src/business/auth/controllers/login.controller.ts @@ -13,6 +13,7 @@ * - POST /auth/forgot-password - 发送密码重置验证码 * - POST /auth/reset-password - 重置密码 * - PUT /auth/change-password - 修改密码 + * - POST /auth/refresh-token - 刷新访问令牌 * * @author moyin angjustinl * @version 1.0.0 @@ -23,7 +24,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; import { Response } from 'express'; import { LoginService, ApiResponse, LoginResponse } from '../services/login.service'; -import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../dto/login.dto'; +import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from '../dto/login.dto'; import { LoginResponseDto, RegisterResponseDto, @@ -31,7 +32,8 @@ import { ForgotPasswordResponseDto, CommonResponseDto, TestModeEmailVerificationResponseDto, - SuccessEmailVerificationResponseDto + SuccessEmailVerificationResponseDto, + RefreshTokenResponseDto } from '../dto/login_response.dto'; import { Throttle, ThrottlePresets } from '../../../core/security_core/decorators/throttle.decorator'; import { Timeout, TimeoutPresets } from '../../../core/security_core/decorators/timeout.decorator'; @@ -609,4 +611,107 @@ export class LoginController { message: '限流记录已清除' }); } + + /** + * 刷新访问令牌 + * + * 功能描述: + * 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期 + * + * 业务逻辑: + * 1. 验证刷新令牌的有效性和格式 + * 2. 检查用户状态是否正常 + * 3. 生成新的JWT令牌对 + * 4. 返回新的访问令牌和刷新令牌 + * + * @param refreshTokenDto 刷新令牌数据 + * @param res Express响应对象 + * @returns 新的令牌对 + */ + @ApiOperation({ + summary: '刷新访问令牌', + description: '使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期。建议在访问令牌即将过期时调用此接口。' + }) + @ApiBody({ type: RefreshTokenDto }) + @SwaggerApiResponse({ + status: 200, + description: '令牌刷新成功', + type: RefreshTokenResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '请求参数错误' + }) + @SwaggerApiResponse({ + status: 401, + description: '刷新令牌无效或已过期' + }) + @SwaggerApiResponse({ + status: 404, + description: '用户不存在或已被禁用' + }) + @SwaggerApiResponse({ + status: 429, + description: '刷新请求过于频繁' + }) + @Throttle(ThrottlePresets.REFRESH_TOKEN) + @Timeout(TimeoutPresets.NORMAL) + @Post('refresh-token') + @UsePipes(new ValidationPipe({ transform: true })) + async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise { + const startTime = Date.now(); + + try { + this.logger.log('令牌刷新请求', { + operation: 'refreshToken', + timestamp: new Date().toISOString(), + }); + + const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token); + + const duration = Date.now() - startTime; + + if (result.success) { + this.logger.log('令牌刷新成功', { + operation: 'refreshToken', + duration, + timestamp: new Date().toISOString(), + }); + res.status(HttpStatus.OK).json(result); + } else { + this.logger.warn('令牌刷新失败', { + operation: 'refreshToken', + error: result.message, + errorCode: result.error_code, + duration, + timestamp: new Date().toISOString(), + }); + + // 根据错误类型设置不同的状态码 + if (result.message?.includes('令牌验证失败') || result.message?.includes('已过期')) { + res.status(HttpStatus.UNAUTHORIZED).json(result); + } else if (result.message?.includes('用户不存在')) { + res.status(HttpStatus.NOT_FOUND).json(result); + } else { + res.status(HttpStatus.BAD_REQUEST).json(result); + } + } + } catch (error) { + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('令牌刷新异常', { + operation: 'refreshToken', + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: '服务器内部错误', + error_code: 'INTERNAL_SERVER_ERROR' + }); + } + } } \ No newline at end of file diff --git a/src/business/auth/dto/login.dto.ts b/src/business/auth/dto/login.dto.ts index 02b563d..8d66ef2 100644 --- a/src/business/auth/dto/login.dto.ts +++ b/src/business/auth/dto/login.dto.ts @@ -424,4 +424,21 @@ export class SendLoginVerificationCodeDto { @IsNotEmpty({ message: '登录标识符不能为空' }) @Length(1, 100, { message: '登录标识符长度需在1-100字符之间' }) identifier: string; +} + +/** + * 刷新令牌请求DTO + */ +export class RefreshTokenDto { + /** + * 刷新令牌 + */ + @ApiProperty({ + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + minLength: 1 + }) + @IsString({ message: '刷新令牌必须是字符串' }) + @IsNotEmpty({ message: '刷新令牌不能为空' }) + refresh_token: string; } \ No newline at end of file diff --git a/src/business/auth/dto/login_response.dto.ts b/src/business/auth/dto/login_response.dto.ts index ef853f2..9fae08a 100644 --- a/src/business/auth/dto/login_response.dto.ts +++ b/src/business/auth/dto/login_response.dto.ts @@ -80,17 +80,28 @@ export class LoginResponseDataDto { user: UserInfoDto; @ApiProperty({ - description: '访问令牌', + description: 'JWT访问令牌', example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) access_token: string; @ApiProperty({ - description: '刷新令牌', - example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', - required: false + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' }) - refresh_token?: string; + refresh_token: string; + + @ApiProperty({ + description: '访问令牌过期时间(秒)', + example: 604800 + }) + expires_in: number; + + @ApiProperty({ + description: '令牌类型', + example: 'Bearer' + }) + token_type: string; @ApiProperty({ description: '是否为新用户', @@ -392,4 +403,64 @@ export class SuccessEmailVerificationResponseDto { required: false }) error_code?: string; +} + +/** + * 令牌刷新响应数据DTO + */ +export class RefreshTokenResponseDataDto { + @ApiProperty({ + description: 'JWT访问令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' + }) + access_token: string; + + @ApiProperty({ + description: 'JWT刷新令牌', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' + }) + refresh_token: string; + + @ApiProperty({ + description: '访问令牌过期时间(秒)', + example: 604800 + }) + expires_in: number; + + @ApiProperty({ + description: '令牌类型', + example: 'Bearer' + }) + token_type: string; +} + +/** + * 令牌刷新响应DTO + */ +export class RefreshTokenResponseDto { + @ApiProperty({ + description: '请求是否成功', + example: true + }) + success: boolean; + + @ApiProperty({ + description: '响应数据', + type: RefreshTokenResponseDataDto, + required: false + }) + data?: RefreshTokenResponseDataDto; + + @ApiProperty({ + description: '响应消息', + example: '令牌刷新成功' + }) + message: string; + + @ApiProperty({ + description: '错误代码', + example: 'TOKEN_REFRESH_FAILED', + required: false + }) + error_code?: string; } \ No newline at end of file diff --git a/src/business/auth/services/login.service.spec.ts b/src/business/auth/services/login.service.spec.ts index 26076c0..87e1a2e 100644 --- a/src/business/auth/services/login.service.spec.ts +++ b/src/business/auth/services/login.service.spec.ts @@ -1,14 +1,43 @@ /** * 登录业务服务测试 + * + * 功能描述: + * - 测试登录相关的业务逻辑 + * - 测试JWT令牌生成和验证 + * - 测试令牌刷新功能 + * - 测试各种异常情况处理 + * + * @author kiro-ai + * @version 1.0.0 + * @since 2025-01-06 */ import { Test, TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import { LoginService } from './login.service'; import { LoginCoreService } from '../../../core/login_core/login_core.service'; +import { UsersService } from '../../../core/db/users/users.service'; +import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; +import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; +import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +import * as jwt from 'jsonwebtoken'; + +// Mock jwt module +jest.mock('jsonwebtoken', () => ({ + sign: jest.fn(), + verify: jest.fn(), +})); describe('LoginService', () => { let service: LoginService; let loginCoreService: jest.Mocked; + let jwtService: jest.Mocked; + let configService: jest.Mocked; + let usersService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let zulipAccountsRepository: jest.Mocked; + let apiKeySecurityService: jest.Mocked; const mockUser = { id: BigInt(1), @@ -26,7 +55,20 @@ describe('LoginService', () => { updated_at: new Date() }; + const mockJwtSecret = 'test_jwt_secret_key_for_testing_32chars'; + const mockAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJhY2Nlc3MiLCJpYXQiOjE2NDA5OTUyMDAsImlzcyI6IndoYWxlLXRvd24iLCJhdWQiOiJ3aGFsZS10b3duLXVzZXJzIn0.test'; + const mockRefreshToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwidXNlcm5hbWUiOiJ0ZXN0dXNlciIsInJvbGUiOjEsInR5cGUiOiJyZWZyZXNoIiwiaWF0IjoxNjQwOTk1MjAwLCJpc3MiOiJ3aGFsZS10b3duIiwiYXVkIjoid2hhbGUtdG93bi11c2VycyJ9.test'; + beforeEach(async () => { + // Mock environment variables for Zulip + const originalEnv = process.env; + process.env = { + ...originalEnv, + ZULIP_SERVER_URL: 'https://test.zulipchat.com', + ZULIP_BOT_EMAIL: 'test-bot@test.zulipchat.com', + ZULIP_BOT_API_KEY: 'test_api_key_12345', + }; + const mockLoginCoreService = { login: jest.fn(), register: jest.fn(), @@ -40,6 +82,36 @@ describe('LoginService', () => { verificationCodeLogin: jest.fn(), sendLoginVerificationCode: jest.fn(), debugVerificationCode: jest.fn(), + deleteUser: jest.fn(), + }; + + const mockJwtService = { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }; + + const mockConfigService = { + get: jest.fn(), + }; + + const mockUsersService = { + findOne: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsRepository = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -49,11 +121,72 @@ describe('LoginService', () => { provide: LoginCoreService, useValue: mockLoginCoreService, }, + { + provide: JwtService, + useValue: mockJwtService, + }, + { + provide: ConfigService, + useValue: mockConfigService, + }, + { + provide: 'UsersService', + useValue: mockUsersService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsRepository', + useValue: mockZulipAccountsRepository, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, ], }).compile(); service = module.get(LoginService); loginCoreService = module.get(LoginCoreService); + jwtService = module.get(JwtService); + configService = module.get(ConfigService); + usersService = module.get('UsersService'); + zulipAccountService = module.get(ZulipAccountService); + zulipAccountsRepository = module.get('ZulipAccountsRepository'); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // Setup default config service mocks + configService.get.mockImplementation((key: string, defaultValue?: any) => { + const config = { + 'JWT_SECRET': mockJwtSecret, + 'JWT_EXPIRES_IN': '7d', + }; + return config[key] || defaultValue; + }); + + // Setup default JWT service mocks + jwtService.signAsync.mockResolvedValue(mockAccessToken); + (jwt.sign as jest.Mock).mockReturnValue(mockRefreshToken); + + // Setup default Zulip mocks + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: true, + userId: 123, + email: 'test@example.com', + apiKey: 'mock_api_key' + }); + zulipAccountsRepository.findByGameUserId.mockResolvedValue(null); + zulipAccountsRepository.create.mockResolvedValue({} as any); + apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.clearAllMocks(); + // Restore original environment variables + jest.restoreAllMocks(); }); it('should be defined', () => { @@ -61,7 +194,7 @@ describe('LoginService', () => { }); describe('login', () => { - it('should login successfully', async () => { + it('should login successfully and return JWT tokens', async () => { loginCoreService.login.mockResolvedValue({ user: mockUser, isNewUser: false @@ -74,7 +207,40 @@ describe('LoginService', () => { expect(result.success).toBe(true); expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBeDefined(); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); // 7 days in seconds + expect(result.data?.token_type).toBe('Bearer'); + expect(result.data?.is_new_user).toBe(false); + expect(result.message).toBe('登录成功'); + + // Verify JWT service was called correctly + expect(jwtService.signAsync).toHaveBeenCalledWith({ + sub: '1', + username: 'testuser', + role: 1, + email: 'test@example.com', + type: 'access', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }); + + expect(jwt.sign).toHaveBeenCalledWith( + { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }, + mockJwtSecret, + { + expiresIn: '30d', + } + ); }); it('should handle login failure', async () => { @@ -87,16 +253,80 @@ describe('LoginService', () => { expect(result.success).toBe(false); expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toBe('用户名或密码错误'); + }); + + it('should handle JWT generation failure', async () => { + loginCoreService.login.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed')); + + const result = await service.login({ + identifier: 'testuser', + password: 'password123' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toContain('JWT generation failed'); + }); + + it('should handle missing JWT secret', async () => { + loginCoreService.login.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + if (key === 'JWT_EXPIRES_IN') return '7d'; + return undefined; + }); + + const result = await service.login({ + identifier: 'testuser', + password: 'password123' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('LOGIN_FAILED'); + expect(result.message).toContain('JWT_SECRET未配置'); }); }); describe('register', () => { - it('should register successfully', async () => { + it('should register successfully with JWT tokens', async () => { loginCoreService.register.mockResolvedValue({ user: mockUser, isNewUser: true }); + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); + expect(result.data?.token_type).toBe('Bearer'); + expect(result.data?.is_new_user).toBe(true); + expect(result.message).toBe('注册成功,Zulip账号已同步创建'); + }); + + it('should register successfully without email', async () => { + loginCoreService.register.mockResolvedValue({ + user: { ...mockUser, email: null }, + isNewUser: true + }); + const result = await service.register({ username: 'testuser', password: 'password123', @@ -104,13 +334,323 @@ describe('LoginService', () => { }); expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); + expect(result.data?.message).toBe('注册成功'); + // Should not try to create Zulip account without email + expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + }); + + it('should handle Zulip account creation failure and rollback', async () => { + loginCoreService.register.mockResolvedValue({ + user: mockUser, + isNewUser: true + }); + + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: false, + error: 'Zulip creation failed' + }); + + loginCoreService.deleteUser.mockResolvedValue(undefined); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Zulip账号创建失败'); + expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id); + }); + + it('should handle register failure', async () => { + loginCoreService.register.mockRejectedValue(new Error('用户名已存在')); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('REGISTER_FAILED'); + expect(result.message).toBe('用户名已存在'); }); }); - describe('verificationCodeLogin', () => { - it('should login with verification code successfully', async () => { + describe('verifyToken', () => { + const mockPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'access' as const, + iat: Math.floor(Date.now() / 1000), + iss: 'whale-town', + aud: 'whale-town-users', + }; + + it('should verify access token successfully', async () => { + (jwt.verify as jest.Mock).mockReturnValue(mockPayload); + + const result = await service.verifyToken(mockAccessToken, 'access'); + + expect(result).toEqual(mockPayload); + expect(jwt.verify).toHaveBeenCalledWith( + mockAccessToken, + mockJwtSecret, + { + issuer: 'whale-town', + audience: 'whale-town-users', + } + ); + }); + + it('should verify refresh token successfully', async () => { + const refreshPayload = { ...mockPayload, type: 'refresh' as const }; + (jwt.verify as jest.Mock).mockReturnValue(refreshPayload); + + const result = await service.verifyToken(mockRefreshToken, 'refresh'); + + expect(result).toEqual(refreshPayload); + }); + + it('should throw error for invalid token', async () => { + (jwt.verify as jest.Mock).mockImplementation(() => { + throw new Error('invalid token'); + }); + + await expect(service.verifyToken('invalid_token')).rejects.toThrow('令牌验证失败: invalid token'); + }); + + it('should throw error for token type mismatch', async () => { + const refreshPayload = { ...mockPayload, type: 'refresh' as const }; + (jwt.verify as jest.Mock).mockReturnValue(refreshPayload); + + await expect(service.verifyToken(mockRefreshToken, 'access')).rejects.toThrow('令牌类型不匹配'); + }); + + it('should throw error for incomplete payload', async () => { + const incompletePayload = { sub: '1', type: 'access' }; // missing username and role + (jwt.verify as jest.Mock).mockReturnValue(incompletePayload); + + await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('令牌载荷数据不完整'); + }); + + it('should throw error when JWT secret is missing', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + return undefined; + }); + + await expect(service.verifyToken(mockAccessToken)).rejects.toThrow('JWT_SECRET未配置'); + }); + }); + + describe('refreshAccessToken', () => { + const mockRefreshPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh' as const, + iat: Math.floor(Date.now() / 1000), + iss: 'whale-town', + aud: 'whale-town-users', + }; + + beforeEach(() => { + (jwt.verify as jest.Mock).mockReturnValue(mockRefreshPayload); + usersService.findOne.mockResolvedValue(mockUser); + }); + + it('should refresh access token successfully', async () => { + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(true); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.expires_in).toBe(604800); + expect(result.data?.token_type).toBe('Bearer'); + expect(result.message).toBe('令牌刷新成功'); + + expect(jwt.verify).toHaveBeenCalledWith( + mockRefreshToken, + mockJwtSecret, + { + issuer: 'whale-town', + audience: 'whale-town-users', + } + ); + expect(usersService.findOne).toHaveBeenCalledWith(BigInt(1)); + }); + + it('should handle invalid refresh token', async () => { + (jwt.verify as jest.Mock).mockImplementation(() => { + throw new Error('invalid token'); + }); + + const result = await service.refreshAccessToken('invalid_token'); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('invalid token'); + }); + + it('should handle user not found', async () => { + usersService.findOne.mockResolvedValue(null); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toBe('用户不存在或已被禁用'); + }); + + it('should handle user service error', async () => { + usersService.findOne.mockRejectedValue(new Error('Database error')); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('Database error'); + }); + + it('should handle JWT generation error during refresh', async () => { + jwtService.signAsync.mockRejectedValue(new Error('JWT generation failed')); + + const result = await service.refreshAccessToken(mockRefreshToken); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('TOKEN_REFRESH_FAILED'); + expect(result.message).toContain('JWT generation failed'); + }); + }); + + describe('parseExpirationTime', () => { + it('should parse seconds correctly', () => { + const result = (service as any).parseExpirationTime('30s'); + expect(result).toBe(30); + }); + + it('should parse minutes correctly', () => { + const result = (service as any).parseExpirationTime('5m'); + expect(result).toBe(300); + }); + + it('should parse hours correctly', () => { + const result = (service as any).parseExpirationTime('2h'); + expect(result).toBe(7200); + }); + + it('should parse days correctly', () => { + const result = (service as any).parseExpirationTime('7d'); + expect(result).toBe(604800); + }); + + it('should parse weeks correctly', () => { + const result = (service as any).parseExpirationTime('2w'); + expect(result).toBe(1209600); + }); + + it('should return default for invalid format', () => { + const result = (service as any).parseExpirationTime('invalid'); + expect(result).toBe(604800); // 7 days default + }); + }); + + describe('generateTokenPair', () => { + it('should generate token pair successfully', async () => { + const result = await (service as any).generateTokenPair(mockUser); + + expect(result.access_token).toBe(mockAccessToken); + expect(result.refresh_token).toBe(mockRefreshToken); + expect(result.expires_in).toBe(604800); + expect(result.token_type).toBe('Bearer'); + + expect(jwtService.signAsync).toHaveBeenCalledWith({ + sub: '1', + username: 'testuser', + role: 1, + email: 'test@example.com', + type: 'access', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }); + + expect(jwt.sign).toHaveBeenCalledWith( + { + sub: '1', + username: 'testuser', + role: 1, + type: 'refresh', + iat: expect.any(Number), + iss: 'whale-town', + aud: 'whale-town-users', + }, + mockJwtSecret, + { + expiresIn: '30d', + } + ); + }); + + it('should handle missing JWT secret', async () => { + configService.get.mockImplementation((key: string) => { + if (key === 'JWT_SECRET') return undefined; + if (key === 'JWT_EXPIRES_IN') return '7d'; + return undefined; + }); + + await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT_SECRET未配置'); + }); + + it('should handle JWT service error', async () => { + jwtService.signAsync.mockRejectedValue(new Error('JWT service error')); + + await expect((service as any).generateTokenPair(mockUser)).rejects.toThrow('令牌生成失败: JWT service error'); + }); + }); + + describe('formatUserInfo', () => { + it('should format user info correctly', () => { + const formattedUser = (service as any).formatUserInfo(mockUser); + + expect(formattedUser).toEqual({ + id: '1', + username: 'testuser', + nickname: '测试用户', + email: 'test@example.com', + phone: '+8613800138000', + avatar_url: null, + role: 1, + created_at: mockUser.created_at + }); + }); + }); + + describe('other methods', () => { + it('should handle githubOAuth successfully', async () => { + loginCoreService.githubOAuth.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + const result = await service.githubOAuth({ + github_id: '12345', + username: 'testuser', + nickname: '测试用户', + email: 'test@example.com' + }); + + expect(result.success).toBe(true); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.message).toBe('GitHub登录成功'); + }); + + it('should handle verificationCodeLogin successfully', async () => { loginCoreService.verificationCodeLogin.mockResolvedValue({ user: mockUser, isNewUser: false @@ -123,23 +663,74 @@ describe('LoginService', () => { expect(result.success).toBe(true); expect(result.data?.user.email).toBe('test@example.com'); + expect(result.data?.access_token).toBe(mockAccessToken); + expect(result.data?.refresh_token).toBe(mockRefreshToken); + expect(result.data?.message).toBe('验证码登录成功'); }); - it('should handle verification code login failure', async () => { - loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误')); - - const result = await service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '999999' + it('should handle sendPasswordResetCode in test mode', async () => { + loginCoreService.sendPasswordResetCode.mockResolvedValue({ + code: '123456', + isTestMode: true }); - expect(result.success).toBe(false); - expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED'); - }); - }); + const result = await service.sendPasswordResetCode('test@example.com'); - describe('sendLoginVerificationCode', () => { - it('should send login verification code successfully', async () => { + expect(result.success).toBe(false); // Test mode returns false + expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(true); + expect(result.error_code).toBe('TEST_MODE_ONLY'); + }); + + it('should handle resetPassword successfully', async () => { + loginCoreService.resetPassword.mockResolvedValue(undefined); + + const result = await service.resetPassword({ + identifier: 'test@example.com', + verificationCode: '123456', + newPassword: 'newpassword123' + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('密码重置成功'); + }); + + it('should handle changePassword successfully', async () => { + loginCoreService.changePassword.mockResolvedValue(undefined); + + const result = await service.changePassword( + BigInt(1), + 'oldpassword', + 'newpassword123' + ); + + expect(result.success).toBe(true); + expect(result.message).toBe('密码修改成功'); + }); + + it('should handle sendEmailVerification in test mode', async () => { + loginCoreService.sendEmailVerification.mockResolvedValue({ + code: '123456', + isTestMode: true + }); + + const result = await service.sendEmailVerification('test@example.com'); + + expect(result.success).toBe(false); + expect(result.data?.verification_code).toBe('123456'); + expect(result.error_code).toBe('TEST_MODE_ONLY'); + }); + + it('should handle verifyEmailCode successfully', async () => { + loginCoreService.verifyEmailCode.mockResolvedValue(true); + + const result = await service.verifyEmailCode('test@example.com', '123456'); + + expect(result.success).toBe(true); + expect(result.message).toBe('邮箱验证成功'); + }); + + it('should handle sendLoginVerificationCode successfully', async () => { loginCoreService.sendLoginVerificationCode.mockResolvedValue({ code: '123456', isTestMode: true @@ -151,5 +742,22 @@ describe('LoginService', () => { expect(result.data?.verification_code).toBe('123456'); expect(result.error_code).toBe('TEST_MODE_ONLY'); }); + + it('should handle debugVerificationCode successfully', async () => { + const mockDebugInfo = { + email: 'test@example.com', + code: '123456', + expiresAt: new Date(), + attempts: 0 + }; + + loginCoreService.debugVerificationCode.mockResolvedValue(mockDebugInfo); + + const result = await service.debugVerificationCode('test@example.com'); + + expect(result.success).toBe(true); + expect(result.data).toEqual(mockDebugInfo); + expect(result.message).toBe('调试信息获取成功'); + }); }); }); \ No newline at end of file diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index a9bcc0c..0fde21b 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -17,12 +17,54 @@ */ import { Injectable, Logger, Inject } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import * as jwt from 'jsonwebtoken'; import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../../core/login_core/login_core.service'; import { Users } from '../../../core/db/users/users.entity'; +import { UsersService } from '../../../core/db/users/users.service'; import { ZulipAccountService } from '../../../core/zulip/services/zulip_account.service'; import { ZulipAccountsRepository } from '../../../core/db/zulip_accounts/zulip_accounts.repository'; import { ApiKeySecurityService } from '../../../core/zulip/services/api_key_security.service'; +/** + * JWT载荷接口 + */ +export interface JwtPayload { + /** 用户ID */ + sub: string; + /** 用户名 */ + username: string; + /** 用户角色 */ + role: number; + /** 邮箱 */ + email?: string; + /** 令牌类型 */ + type: 'access' | 'refresh'; + /** 签发时间 */ + iat?: number; + /** 过期时间 */ + exp?: number; + /** 签发者 */ + iss?: string; + /** 受众 */ + aud?: string; +} + +/** + * 令牌对接口 + */ +export interface TokenPair { + /** 访问令牌 */ + access_token: string; + /** 刷新令牌 */ + refresh_token: string; + /** 访问令牌过期时间(秒) */ + expires_in: number; + /** 令牌类型 */ + token_type: string; +} + /** * 登录响应数据接口 */ @@ -38,10 +80,14 @@ export interface LoginResponse { role: number; created_at: Date; }; - /** 访问令牌(实际应用中应生成JWT) */ + /** 访问令牌 */ access_token: string; /** 刷新令牌 */ - refresh_token?: string; + refresh_token: string; + /** 访问令牌过期时间(秒) */ + expires_in: number; + /** 令牌类型 */ + token_type: string; /** 是否为新用户 */ is_new_user?: boolean; /** 消息 */ @@ -72,33 +118,68 @@ export class LoginService { @Inject('ZulipAccountsRepository') private readonly zulipAccountsRepository: ZulipAccountsRepository, private readonly apiKeySecurityService: ApiKeySecurityService, + private readonly jwtService: JwtService, + private readonly configService: ConfigService, + @Inject('UsersService') + private readonly usersService: UsersService, ) {} /** * 用户登录 * - * @param loginRequest 登录请求 - * @returns 登录响应 + * 功能描述: + * 处理用户登录请求,验证用户凭据并生成JWT令牌 + * + * 业务逻辑: + * 1. 调用核心服务进行用户认证 + * 2. 生成JWT访问令牌和刷新令牌 + * 3. 记录登录日志和安全审计 + * 4. 返回用户信息和令牌 + * + * @param loginRequest 登录请求数据 + * @returns Promise> 登录响应 + * + * @throws BadRequestException 当登录参数无效时 + * @throws UnauthorizedException 当用户凭据错误时 + * @throws InternalServerErrorException 当系统错误时 */ async login(loginRequest: LoginRequest): Promise> { + const startTime = Date.now(); + try { - this.logger.log(`用户登录尝试: ${loginRequest.identifier}`); + this.logger.log('用户登录尝试', { + operation: 'login', + identifier: loginRequest.identifier, + timestamp: new Date().toISOString(), + }); - // 调用核心服务进行认证 + // 1. 调用核心服务进行认证 const authResult = await this.loginCoreService.login(loginRequest); - // 生成访问令牌(实际应用中应使用JWT) - const accessToken = this.generateAccessToken(authResult.user); + // 2. 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); - // 格式化响应数据 + // 3. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: '登录成功' }; - this.logger.log(`用户登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + const duration = Date.now() - startTime; + + this.logger.log('用户登录成功', { + operation: 'login', + userId: authResult.user.id.toString(), + username: authResult.user.username, + isNewUser: authResult.isNewUser, + duration, + timestamp: new Date().toISOString(), + }); return { success: true, @@ -106,11 +187,20 @@ export class LoginService { message: '登录成功' }; } catch (error) { - this.logger.error(`用户登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error)); + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('用户登录失败', { + operation: 'login', + identifier: loginRequest.identifier, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); return { success: false, - message: error instanceof Error ? error.message : '登录失败', + message: err.message || '登录失败', error_code: 'LOGIN_FAILED' }; } @@ -181,13 +271,16 @@ export class LoginService { throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); } - // 4. 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 4. 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 5. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: true, message: zulipAccountCreated ? '注册成功,Zulip账号已同步创建' : '注册成功' }; @@ -241,13 +334,16 @@ export class LoginService { // 调用核心服务进行OAuth认证 const authResult = await this.loginCoreService.githubOAuth(oauthRequest); - // 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: authResult.isNewUser ? 'GitHub账户绑定成功' : 'GitHub登录成功' }; @@ -534,23 +630,273 @@ export class LoginService { } /** - * 生成访问令牌 + * 生成JWT令牌对 + * + * 功能描述: + * 为用户生成访问令牌和刷新令牌,符合JWT标准和安全最佳实践 + * + * 业务逻辑: + * 1. 创建访问令牌载荷(短期有效) + * 2. 创建刷新令牌载荷(长期有效) + * 3. 使用配置的密钥签名令牌 + * 4. 返回完整的令牌对信息 * * @param user 用户信息 - * @returns 访问令牌 + * @returns Promise JWT令牌对 + * + * @throws InternalServerErrorException 当令牌生成失败时 + * + * @example + * ```typescript + * const tokenPair = await this.generateTokenPair(user); + * console.log(tokenPair.access_token); // JWT访问令牌 + * console.log(tokenPair.refresh_token); // JWT刷新令牌 + * ``` */ - private generateAccessToken(user: Users): string { - // 实际应用中应使用JWT库生成真正的JWT令牌 - // 这里仅用于演示,生成一个简单的令牌 - const payload = { - userId: user.id.toString(), - username: user.username, - role: user.role, - timestamp: Date.now() - }; + private async generateTokenPair(user: Users): Promise { + try { + const currentTime = Math.floor(Date.now() / 1000); + const jwtSecret = this.configService.get('JWT_SECRET'); + const expiresIn = this.configService.get('JWT_EXPIRES_IN', '7d'); + + if (!jwtSecret) { + throw new Error('JWT_SECRET未配置'); + } - // 简单的Base64编码(实际应用中应使用JWT) - return Buffer.from(JSON.stringify(payload)).toString('base64'); + // 1. 创建访问令牌载荷 + const accessPayload: JwtPayload = { + sub: user.id.toString(), + username: user.username, + role: user.role, + email: user.email, + type: 'access', + iat: currentTime, + iss: 'whale-town', + aud: 'whale-town-users', + }; + + // 2. 创建刷新令牌载荷(有效期更长) + const refreshPayload: JwtPayload = { + sub: user.id.toString(), + username: user.username, + role: user.role, + type: 'refresh', + iat: currentTime, + iss: 'whale-town', + aud: 'whale-town-users', + }; + + // 3. 生成访问令牌(使用NestJS JwtService) + const accessToken = await this.jwtService.signAsync(accessPayload); + + // 4. 生成刷新令牌(有效期30天) + const refreshToken = jwt.sign(refreshPayload, jwtSecret, { + expiresIn: '30d', + }); + + // 5. 计算过期时间(秒) + const expiresInSeconds = this.parseExpirationTime(expiresIn); + + this.logger.log('JWT令牌对生成成功', { + operation: 'generateTokenPair', + userId: user.id.toString(), + username: user.username, + expiresIn: expiresInSeconds, + timestamp: new Date().toISOString(), + }); + + return { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresInSeconds, + token_type: 'Bearer', + }; + + } catch (error) { + const err = error as Error; + + this.logger.error('JWT令牌对生成失败', { + operation: 'generateTokenPair', + userId: user.id.toString(), + error: err.message, + timestamp: new Date().toISOString(), + }, err.stack); + + throw new Error(`令牌生成失败: ${err.message}`); + } + } + + /** + * 验证JWT令牌 + * + * 功能描述: + * 验证JWT令牌的有效性,包括签名、过期时间和载荷格式 + * + * 业务逻辑: + * 1. 验证令牌签名和格式 + * 2. 检查令牌是否过期 + * 3. 验证载荷数据完整性 + * 4. 返回解码后的载荷信息 + * + * @param token JWT令牌字符串 + * @param tokenType 令牌类型(access 或 refresh) + * @returns Promise 解码后的载荷 + * + * @throws UnauthorizedException 当令牌无效时 + * @throws Error 当验证过程出错时 + */ + async verifyToken(token: string, tokenType: 'access' | 'refresh' = 'access'): Promise { + try { + const jwtSecret = this.configService.get('JWT_SECRET'); + + if (!jwtSecret) { + throw new Error('JWT_SECRET未配置'); + } + + // 1. 验证令牌并解码载荷 + const payload = jwt.verify(token, jwtSecret, { + issuer: 'whale-town', + audience: 'whale-town-users', + }) as JwtPayload; + + // 2. 验证令牌类型 + if (payload.type !== tokenType) { + throw new Error(`令牌类型不匹配,期望: ${tokenType},实际: ${payload.type}`); + } + + // 3. 验证载荷完整性 + if (!payload.sub || !payload.username || payload.role === undefined) { + throw new Error('令牌载荷数据不完整'); + } + + this.logger.log('JWT令牌验证成功', { + operation: 'verifyToken', + userId: payload.sub, + username: payload.username, + tokenType: payload.type, + timestamp: new Date().toISOString(), + }); + + return payload; + + } catch (error) { + const err = error as Error; + + this.logger.warn('JWT令牌验证失败', { + operation: 'verifyToken', + tokenType, + error: err.message, + timestamp: new Date().toISOString(), + }); + + throw new Error(`令牌验证失败: ${err.message}`); + } + } + + /** + * 刷新访问令牌 + * + * 功能描述: + * 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期 + * + * 业务逻辑: + * 1. 验证刷新令牌的有效性 + * 2. 从数据库获取最新用户信息 + * 3. 生成新的访问令牌 + * 4. 可选择性地轮换刷新令牌 + * + * @param refreshToken 刷新令牌 + * @returns Promise> 新的令牌对 + * + * @throws UnauthorizedException 当刷新令牌无效时 + * @throws NotFoundException 当用户不存在时 + */ + async refreshAccessToken(refreshToken: string): Promise> { + const startTime = Date.now(); + + try { + this.logger.log('开始刷新访问令牌', { + operation: 'refreshAccessToken', + timestamp: new Date().toISOString(), + }); + + // 1. 验证刷新令牌 + const payload = await this.verifyToken(refreshToken, 'refresh'); + + // 2. 获取最新用户信息 + const user = await this.usersService.findOne(BigInt(payload.sub)); + if (!user) { + throw new Error('用户不存在或已被禁用'); + } + + // 3. 生成新的令牌对 + const newTokenPair = await this.generateTokenPair(user); + + const duration = Date.now() - startTime; + + this.logger.log('访问令牌刷新成功', { + operation: 'refreshAccessToken', + userId: user.id.toString(), + username: user.username, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + data: newTokenPair, + message: '令牌刷新成功' + }; + + } catch (error) { + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error('访问令牌刷新失败', { + operation: 'refreshAccessToken', + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + message: err.message || '令牌刷新失败', + error_code: 'TOKEN_REFRESH_FAILED' + }; + } + } + + /** + * 解析过期时间字符串 + * + * 功能描述: + * 将时间字符串(如 '7d', '24h', '60m')转换为秒数 + * + * @param expiresIn 过期时间字符串 + * @returns number 过期时间(秒) + * @private + */ + private parseExpirationTime(expiresIn: string): number { + if (!expiresIn || typeof expiresIn !== 'string') { + return 7 * 24 * 60 * 60; // 默认7天 + } + + const timeUnit = expiresIn.slice(-1); + const timeValue = parseInt(expiresIn.slice(0, -1)); + + if (isNaN(timeValue)) { + return 7 * 24 * 60 * 60; // 默认7天 + } + + switch (timeUnit) { + case 's': return timeValue; + case 'm': return timeValue * 60; + case 'h': return timeValue * 60 * 60; + case 'd': return timeValue * 24 * 60 * 60; + case 'w': return timeValue * 7 * 24 * 60 * 60; + default: return 7 * 24 * 60 * 60; // 默认7天 + } } /** * 验证码登录 @@ -565,13 +911,16 @@ export class LoginService { // 调用核心服务进行验证码认证 const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest); - // 生成访问令牌 - const accessToken = this.generateAccessToken(authResult.user); + // 生成JWT令牌对 + const tokenPair = await this.generateTokenPair(authResult.user); // 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), - access_token: accessToken, + access_token: tokenPair.access_token, + refresh_token: tokenPair.refresh_token, + expires_in: tokenPair.expires_in, + token_type: tokenPair.token_type, is_new_user: authResult.isNewUser, message: '验证码登录成功' }; diff --git a/src/business/auth/services/login.service.zulip-account.spec.ts b/src/business/auth/services/login.service.zulip-account.spec.ts index 4011b47..422ebfe 100644 --- a/src/business/auth/services/login.service.zulip-account.spec.ts +++ b/src/business/auth/services/login.service.zulip-account.spec.ts @@ -18,6 +18,8 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; import * as fc from 'fast-check'; import { LoginService } from './login.service'; import { LoginCoreService, RegisterRequest } from '../../../core/login_core/login_core.service'; @@ -97,6 +99,41 @@ describe('LoginService - Zulip账号创建属性测试', () => { provide: ApiKeySecurityService, useValue: mockApiKeySecurityService, }, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('mock_jwt_token'), + signAsync: jest.fn().mockResolvedValue('mock_jwt_token'), + verify: jest.fn(), + decode: jest.fn(), + }, + }, + { + provide: ConfigService, + useValue: { + get: jest.fn((key: string) => { + switch (key) { + case 'JWT_SECRET': + return 'test_jwt_secret_key_for_testing'; + case 'JWT_EXPIRES_IN': + return '7d'; + default: + return undefined; + } + }), + }, + }, + { + provide: 'UsersService', + useValue: { + findById: jest.fn(), + findByUsername: jest.fn(), + findByEmail: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, ], }).compile(); @@ -106,6 +143,9 @@ describe('LoginService - Zulip账号创建属性测试', () => { zulipAccountsRepository = module.get('ZulipAccountsRepository'); apiKeySecurityService = module.get(ApiKeySecurityService); + // Mock LoginService 的 initializeZulipAdminClient 方法 + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); + // 设置环境变量模拟 process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; @@ -167,7 +207,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as ZulipAccounts; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -189,11 +228,7 @@ describe('LoginService - Zulip账号创建属性测试', () => { expect(result.data?.is_new_user).toBe(true); // 验证Zulip管理员客户端初始化 - expect(zulipAccountService.initializeAdminClient).toHaveBeenCalledWith({ - realm: 'https://test.zulip.com', - username: 'bot@test.zulip.com', - apiKey: 'test_api_key_123', - }); + expect(loginService['initializeZulipAdminClient']).toHaveBeenCalled(); // 验证游戏用户注册 expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); @@ -249,7 +284,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as Users; // 设置模拟行为 - Zulip账号创建失败 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -318,7 +352,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as ZulipAccounts; // 设置模拟行为 - 已存在Zulip账号关联 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -374,7 +407,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { } as Users; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, @@ -404,7 +436,8 @@ describe('LoginService - Zulip账号创建属性测试', () => { await fc.assert( fc.asyncProperty(registerRequestArb, async (registerRequest) => { // 设置模拟行为 - 管理员客户端初始化失败 - zulipAccountService.initializeAdminClient.mockResolvedValue(false); + jest.spyOn(loginService as any, 'initializeZulipAdminClient') + .mockRejectedValue(new Error('Zulip管理员客户端初始化失败')); // 执行注册 const result = await loginService.register(registerRequest); @@ -418,6 +451,9 @@ describe('LoginService - Zulip账号创建属性测试', () => { // 验证没有尝试创建Zulip账号 expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); + + // 恢复 mock + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 50 } ); @@ -431,6 +467,10 @@ describe('LoginService - Zulip账号创建属性测试', () => { delete process.env.ZULIP_BOT_EMAIL; delete process.env.ZULIP_BOT_API_KEY; + // 重新设置 mock 以模拟环境变量缺失的错误 + jest.spyOn(loginService as any, 'initializeZulipAdminClient') + .mockRejectedValue(new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY')); + // 执行注册 const result = await loginService.register(registerRequest); @@ -441,10 +481,11 @@ describe('LoginService - Zulip账号创建属性测试', () => { // 验证没有尝试创建游戏用户 expect(loginCoreService.register).not.toHaveBeenCalled(); - // 恢复环境变量 + // 恢复环境变量和 mock process.env.ZULIP_SERVER_URL = 'https://test.zulip.com'; process.env.ZULIP_BOT_EMAIL = 'bot@test.zulip.com'; process.env.ZULIP_BOT_API_KEY = 'test_api_key_123'; + jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); }), { numRuns: 30 } ); @@ -480,7 +521,6 @@ describe('LoginService - Zulip账号创建属性测试', () => { }; // 设置模拟行为 - zulipAccountService.initializeAdminClient.mockResolvedValue(true); loginCoreService.register.mockResolvedValue({ user: mockGameUser, isNewUser: true, diff --git a/src/business/zulip/zulip.service.spec.ts b/src/business/zulip/zulip.service.spec.ts index aa021ce..5441332 100644 --- a/src/business/zulip/zulip.service.spec.ts +++ b/src/business/zulip/zulip.service.spec.ts @@ -40,6 +40,7 @@ import { ZulipClientInstance, SendMessageResult, } from '../../core/zulip/interfaces/zulip-core.interfaces'; +import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; describe('ZulipService', () => { let service: ZulipService; @@ -158,6 +159,19 @@ describe('ZulipService', () => { provide: 'ZULIP_CONFIG_SERVICE', useValue: mockConfigManager, }, + { + provide: ApiKeySecurityService, + useValue: { + extractApiKey: jest.fn(), + validateApiKey: jest.fn(), + encryptApiKey: jest.fn(), + decryptApiKey: jest.fn(), + getApiKey: jest.fn().mockResolvedValue({ + success: true, + apiKey: 'lCPWCPfGh7WUHxwN56GF8oYXOpqNfGF8', + }), + }, + }, ], }).compile(); diff --git a/src/core/security_core/decorators/throttle.decorator.ts b/src/core/security_core/decorators/throttle.decorator.ts index d872f5b..c8f2ca8 100644 --- a/src/core/security_core/decorators/throttle.decorator.ts +++ b/src/core/security_core/decorators/throttle.decorator.ts @@ -81,6 +81,9 @@ export const ThrottlePresets = { /** 密码重置:每小时3次 */ RESET_PASSWORD: { limit: 3, ttl: 3600, message: '密码重置请求过于频繁,请1小时后再试' }, + /** 令牌刷新:每分钟10次 */ + REFRESH_TOKEN: { limit: 10, ttl: 60, message: '令牌刷新请求过于频繁,请稍后再试' }, + /** 管理员操作:每分钟10次 */ ADMIN_OPERATION: { limit: 10, ttl: 60, message: '管理员操作过于频繁,请稍后再试' }, -- 2.25.1 From 8f9a6e7f9d4f65e289e047b273681ed92f132d9b Mon Sep 17 00:00:00 2001 From: angjustinl <96008766+ANGJustinl@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:51:37 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(login,=20zulip):=20=E5=BC=95=E5=85=A5?= =?UTF-8?q?=20JWT=20=E9=AA=8C=E8=AF=81=E5=B9=B6=E9=87=8D=E6=9E=84=20API=20?= =?UTF-8?q?=E5=AF=86=E9=92=A5=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 详细变更描述 * **修复 JWT 签名冲突**:重构 `LoginService.generateTokenPair()`,移除载荷(Payload)中的 `iss` (issuer) 与 `aud` (audience) 字段,解决签名校验失败的问题。 * **统一验证逻辑**:更新 `ZulipService` 以调用 `LoginService.verifyToken()`,消除重复的 JWT 校验代码,确保逻辑单一职责化(Single Responsibility)。 * **修复硬编码 API 密钥问题**:消息发送功能不再依赖静态配置,改为从 Redis 动态读取用户真实的 API 密钥。 * **解耦依赖注入**:在 `ZulipModule` 中注入 `AuthModule` 依赖,以支持标准的 Token 验证流程。 * **完善技术文档**:补充了关于 JWT 验证流程及 API 密钥管理逻辑的详细文档。 * **新增测试工具**:添加 `test-get-messages.js` 脚本,用于验证通过 WebSocket 接收消息的功能。 * **更新自动化脚本**:同步更新了 API 密钥验证及用户注册校验的快速测试脚本。 * **端到端功能验证**:确保消息发送逻辑能够正确映射并调用用户真实的 Zulip API 密钥。 --- docs/systems/zulip/README.md | 11 +- docs/systems/zulip/guide.md | 147 +++++++++- .../zulip/quick_tests/test-get-messages.js | 260 +++++++++++++++++ .../quick_tests/test-list-subscriptions.js | 113 +++++++- .../zulip/quick_tests/test-user-api-key.js | 266 +++++++++++------- src/business/auth/services/login.service.ts | 21 +- src/business/zulip/zulip.module.ts | 7 +- src/business/zulip/zulip.service.ts | 125 ++++---- 8 files changed, 763 insertions(+), 187 deletions(-) create mode 100644 docs/systems/zulip/quick_tests/test-get-messages.js diff --git a/docs/systems/zulip/README.md b/docs/systems/zulip/README.md index fafcd4f..18efb9e 100644 --- a/docs/systems/zulip/README.md +++ b/docs/systems/zulip/README.md @@ -358,7 +358,16 @@ node test-stream-initialization.js ## 更新日志 -### v2.0.0 (2025-12-25) +### v1.1.0 (2026-01-06) +- **修复 JWT Token 验证和 API Key 管理** + - 修复 `LoginService.generateTokenPair()` 的 JWT 签名冲突问题 + - `ZulipService` 现在复用 `LoginService.verifyToken()` 进行 Token 验证 + - 修复消息发送时使用错误的硬编码 API Key 问题 + - 现在正确从 Redis 读取用户注册时存储的真实 API Key + - 添加 `AuthModule` 到 `ZulipModule` 的依赖注入 + - 消息发送功能现已完全正常工作 ✅ + +### v1.0.1 (2025-12-25) - 更新地图配置为 9 区域系统 - 添加 Stream Initializer Service 自动初始化服务 - 更新默认出生点为鲸之港 (Whale Port) diff --git a/docs/systems/zulip/guide.md b/docs/systems/zulip/guide.md index d7f5734..2d7f526 100644 --- a/docs/systems/zulip/guide.md +++ b/docs/systems/zulip/guide.md @@ -68,4 +68,149 @@ C. 接收消息 (Downstream: Zulip -> Node -> Godot) - Zulip API Key 永不下发: 用户的 Zulip 凭证永远只保存在服务器端。如果客户端直接拿 Key,黑客可以通过解包 Godot 拿到 Key 然后去 Zulip 乱发消息。 - 位置欺诈完全消除: 因为 Stream 的选择权在 Node 手里,玩家无法做到“人在新手村,却往高等级区域频道发消息”。 3. 协议统一: - - 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。 \ No newline at end of file + - 不再需要处理 HTTP (Zulip) 和 WebSocket (Game) 两种并发逻辑。网络层代码减少一半。 + + +--- + +## 3. JWT Token 验证和 API Key 管理 (v1.1.0 更新) + +### 3.1 用户注册和 API Key 生成流程 + +当用户注册游戏账号时,系统会自动创建对应的 Zulip 账号并获取 API Key: + +``` +用户注册 (POST /auth/register) + ↓ +1. 创建游戏账号 (LoginService.register) + ↓ +2. 初始化 Zulip 管理员客户端 + ↓ +3. 创建 Zulip 账号 (ZulipAccountService.createZulipAccount) + - 使用相同的邮箱和密码 + - 调用 Zulip API: POST /api/v1/users + ↓ +4. 获取 API Key (ZulipAccountService.generateApiKeyForUser) + - 使用 fetch_api_key 端点(固定的、基于密码的 Key) + - 注意:不使用 regenerate_api_key(会生成新 Key) + ↓ +5. 加密存储 API Key (ApiKeySecurityService.storeApiKey) + - 使用 AES-256-GCM 加密 + - 存储到 Redis: zulip:api_key:{userId} + ↓ +6. 创建账号关联记录 (ZulipAccountsRepository) + - 存储 gameUserId ↔ zulipUserId 映射 + ↓ +7. 生成 JWT Token (LoginService.generateTokenPair) + - 包含用户信息:sub, username, email, role + - 返回 access_token 和 refresh_token +``` + +### 3.2 JWT Token 验证流程 + +当用户通过 WebSocket 登录游戏时,系统会验证 JWT Token 并获取 API Key: + +``` +WebSocket 登录 (login 消息) + ↓ +1. ZulipService.validateGameToken(token) + ↓ +2. 调用 LoginService.verifyToken(token, 'access') + - 验证签名、过期时间、载荷 + - 提取用户信息:userId, username, email + ↓ +3. 从 Redis 获取 API Key (ApiKeySecurityService.getApiKey) + - 解密存储的 API Key + - 更新访问计数和时间 + ↓ +4. 创建 Zulip 客户端 (ZulipClientPoolService.createUserClient) + - 使用真实的用户 API Key + - 注册事件队列 + ↓ +5. 创建游戏会话 (SessionManagerService.createSession) + - 绑定 socketId ↔ zulipQueueId + - 记录用户位置信息 + ↓ +6. 返回登录成功 +``` + +### 3.3 消息发送流程(使用正确的 API Key) + +``` +发送聊天消息 (chat 消息) + ↓ +1. ZulipService.sendChatMessage() + ↓ +2. 获取会话信息 (SessionManagerService.getSession) + - 获取 userId 和当前位置 + ↓ +3. 上下文注入 (SessionManagerService.injectContext) + - 根据位置确定目标 Stream/Topic + ↓ +4. 消息验证 (MessageFilterService.validateMessage) + - 内容过滤、频率限制 + ↓ +5. 发送到 Zulip (ZulipClientPoolService.sendMessage) + - 使用用户的真实 API Key + - 调用 Zulip API: POST /api/v1/messages + ↓ +6. 返回发送结果 +``` + +### 3.4 关键修复说明 + +**问题 1: JWT Token 签名冲突** +- **原因**: payload 中包含 `iss` 和 `aud`,但 `jwtService.signAsync()` 也通过 options 传递这些值 +- **修复**: 从 payload 中移除 `iss` 和 `aud`,只通过 options 传递 +- **文件**: `src/business/auth/services/login.service.ts` + +**问题 2: 使用硬编码的旧 API Key** +- **原因**: `ZulipService.validateGameToken()` 使用硬编码的测试 API Key +- **修复**: 从 Redis 读取用户注册时存储的真实 API Key +- **文件**: `src/business/zulip/zulip.service.ts` + +**问题 3: 重复实现 JWT 验证逻辑** +- **原因**: `ZulipService` 自己实现了 JWT 解析 +- **修复**: 复用 `LoginService.verifyToken()` 方法 +- **文件**: `src/business/zulip/zulip.service.ts`, `src/business/zulip/zulip.module.ts` + +### 3.5 API Key 安全机制 + +**加密存储**: +- 使用 AES-256-GCM 算法加密 +- 加密密钥从环境变量 `ZULIP_API_KEY_ENCRYPTION_KEY` 读取 +- 存储格式:`{encryptedKey, iv, authTag, createdAt, updatedAt, accessCount}` + +**访问控制**: +- 频率限制:每分钟最多 60 次访问 +- 访问日志:记录每次访问的时间和次数 +- 安全事件:记录所有关键操作(存储、访问、更新、删除) + +**环境变量配置**: +```bash +# 生成 64 字符的十六进制密钥(32 字节 = 256 位) +node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + +# 在 .env 文件中配置 +ZULIP_API_KEY_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef +``` + +### 3.6 测试验证 + +使用测试脚本验证功能: + +```bash +# 测试注册用户的 Zulip 集成 +node docs/systems/zulip/quick_tests/test-registered-user.js + +# 验证 API Key 一致性 +node docs/systems/zulip/quick_tests/verify-api-key.js +``` + +**预期结果**: +- ✅ WebSocket 连接成功 +- ✅ JWT Token 验证通过 +- ✅ 从 Redis 获取正确的 API Key +- ✅ 消息成功发送到 Zulip + +--- diff --git a/docs/systems/zulip/quick_tests/test-get-messages.js b/docs/systems/zulip/quick_tests/test-get-messages.js new file mode 100644 index 0000000..a7f14b0 --- /dev/null +++ b/docs/systems/zulip/quick_tests/test-get-messages.js @@ -0,0 +1,260 @@ +/** + * 测试通过 WebSocket 接收 Zulip 消息 + * + * 设计理念: + * - Zulip API Key 永不下发到客户端 + * - 所有 Zulip 交互通过游戏服务器的 WebSocket 进行 + * - 客户端只接收 chat_render 消息,不直接调用 Zulip API + * + * 功能: + * 1. 登录游戏服务器获取 JWT Token + * 2. 通过 WebSocket 连接游戏服务器 + * 3. 在当前地图 (Whale Port) 接收消息 + * 4. 切换到 Pumpkin Valley 接收消息 + * 5. 统计接收到的消息数量 + * + * 使用方法: + * node docs/systems/zulip/quick_tests/test-get-messages.js + */ + +const axios = require('axios'); +const io = require('socket.io-client'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +// 测试配置 +const TEST_CONFIG = { + whalePortWaitTime: 10000, // 在 Whale Port 等待 10 秒 + pumpkinValleyWaitTime: 10000, // 在 Pumpkin Valley 等待 10 秒 + totalTimeout: 30000 // 总超时时间 30 秒 +}; + +/** + * 登录游戏服务器获取用户信息 + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + email: response.data.data.user.email, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + + + +/** + * 通过 WebSocket 接收消息 + */ +async function receiveMessagesViaWebSocket(userInfo) { + console.log('\n📡 步骤 2: 通过 WebSocket 连接并接收消息'); + console.log(` 连接到: ${GAME_SERVER}/game`); + + return new Promise((resolve, reject) => { + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); + + const receivedMessages = { + whalePort: [], + pumpkinValley: [] + }; + + let currentMap = 'whale_port'; + let testPhase = 0; // 0: 连接中, 1: Whale Port, 2: Pumpkin Valley, 3: 完成 + + // 连接成功 + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + + // 发送登录消息 + const loginMessage = { + type: 'login', + token: userInfo.token + }; + + console.log('📤 发送登录消息...'); + socket.emit('login', loginMessage); + }); + + // 登录成功 + socket.on('login_success', (data) => { + console.log('✅ 登录成功'); + console.log(` 会话ID: ${data.sessionId}`); + console.log(` 用户ID: ${data.userId}`); + console.log(` 当前地图: ${data.currentMap}`); + + testPhase = 1; + currentMap = data.currentMap || 'whale_port'; + + console.log(`\n📬 步骤 3: 在 Whale Port 接收消息 (等待 ${TEST_CONFIG.whalePortWaitTime / 1000} 秒)`); + console.log(' 💡 提示: 请在 Zulip 的 "Whale Port" Stream 发送测试消息'); + + // 在 Whale Port 等待一段时间 + setTimeout(() => { + console.log(`\n📊 Whale Port 接收到 ${receivedMessages.whalePort.length} 条消息`); + + // 切换到 Pumpkin Valley + console.log(`\n📤 步骤 4: 切换到 Pumpkin Valley`); + const positionUpdate = { + t: 'position', + x: 150, + y: 400, + mapId: 'pumpkin_valley' + }; + socket.emit('position_update', positionUpdate); + + testPhase = 2; + currentMap = 'pumpkin_valley'; + + console.log(`\n📬 步骤 5: 在 Pumpkin Valley 接收消息 (等待 ${TEST_CONFIG.pumpkinValleyWaitTime / 1000} 秒)`); + console.log(' 💡 提示: 请在 Zulip 的 "Pumpkin Valley" Stream 发送测试消息'); + + // 在 Pumpkin Valley 等待一段时间 + setTimeout(() => { + console.log(`\n📊 Pumpkin Valley 接收到 ${receivedMessages.pumpkinValley.length} 条消息`); + + testPhase = 3; + console.log('\n📊 测试完成,断开连接...'); + socket.disconnect(); + }, TEST_CONFIG.pumpkinValleyWaitTime); + }, TEST_CONFIG.whalePortWaitTime); + }); + + // 接收到消息 (chat_render) + socket.on('chat_render', (data) => { + const timestamp = new Date().toLocaleTimeString('zh-CN'); + + console.log(`\n📨 [${timestamp}] 收到消息:`); + console.log(` ├─ 发送者: ${data.from}`); + console.log(` ├─ 内容: ${data.txt}`); + console.log(` ├─ Stream: ${data.stream || '未知'}`); + console.log(` ├─ Topic: ${data.topic || '未知'}`); + console.log(` └─ 当前地图: ${currentMap}`); + + // 记录消息 + const message = { + from: data.from, + content: data.txt, + stream: data.stream, + topic: data.topic, + timestamp: new Date(), + map: currentMap + }; + + if (testPhase === 1) { + receivedMessages.whalePort.push(message); + } else if (testPhase === 2) { + receivedMessages.pumpkinValley.push(message); + } + }); + + // 错误处理 + socket.on('error', (error) => { + console.error('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + // 连接断开 + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + resolve(receivedMessages); + }); + + // 连接错误 + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + reject(error); + }); + + // 总超时保护 + setTimeout(() => { + if (socket.connected) { + console.log('\n⏰ 测试超时,关闭连接'); + socket.disconnect(); + } + }, TEST_CONFIG.totalTimeout); + }); +} + +/** + * 主测试流程 + */ +async function runTest() { + console.log('🚀 开始测试通过 WebSocket 接收 Zulip 消息'); + console.log('='.repeat(60)); + console.log('📋 设计理念: Zulip API Key 永不下发到客户端'); + console.log('📋 所有消息通过游戏服务器的 WebSocket (chat_render) 接收'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录游戏服务器 + const userInfo = await loginToGameServer(); + + // 步骤2-5: 通过 WebSocket 接收消息 + const receivedMessages = await receiveMessagesViaWebSocket(userInfo); + + // 步骤6: 统计信息 + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + console.log(`✅ Whale Port: ${receivedMessages.whalePort.length} 条消息`); + console.log(`✅ Pumpkin Valley: ${receivedMessages.pumpkinValley.length} 条消息`); + console.log(`📝 总计: ${receivedMessages.whalePort.length + receivedMessages.pumpkinValley.length} 条消息`); + + // 显示详细消息列表 + if (receivedMessages.whalePort.length > 0) { + console.log('\n📬 Whale Port 消息列表:'); + receivedMessages.whalePort.forEach((msg, index) => { + console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`); + }); + } + + if (receivedMessages.pumpkinValley.length > 0) { + console.log('\n📬 Pumpkin Valley 消息列表:'); + receivedMessages.pumpkinValley.forEach((msg, index) => { + console.log(` ${index + 1}. [${msg.timestamp.toLocaleTimeString()}] ${msg.from}: ${msg.content.substring(0, 50)}${msg.content.length > 50 ? '...' : ''}`); + }); + } + + console.log('='.repeat(60)); + + console.log('\n🎉 测试完成!'); + console.log('💡 提示: 客户端通过 WebSocket 接收消息,无需直接访问 Zulip API'); + console.log('💡 提示: 访问 https://zulip.xinghangee.icu 查看完整消息历史'); + + process.exit(0); + } catch (error) { + console.error('\n❌ 测试失败:', error.message); + process.exit(1); + } +} + +// 运行测试 +runTest(); diff --git a/docs/systems/zulip/quick_tests/test-list-subscriptions.js b/docs/systems/zulip/quick_tests/test-list-subscriptions.js index 2136548..6dfb74d 100644 --- a/docs/systems/zulip/quick_tests/test-list-subscriptions.js +++ b/docs/systems/zulip/quick_tests/test-list-subscriptions.js @@ -1,15 +1,102 @@ const zulip = require('zulip-js'); +const axios = require('axios'); -async function listSubscriptions() { - console.log('🔧 检查用户订阅的 Streams...'); - - const config = { - username: 'angjustinl@mail.angforever.top', - apiKey: 'lCPWC...pqNfGF8', - realm: 'https://zulip.xinghangee.icu/' - }; +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 登录游戏服务器获取用户信息 + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + email: response.data.data.user.email, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} + +/** + * 使用密码获取 Zulip API Key + */ +async function getZulipApiKey(email, password) { + console.log('\n📝 步骤 2: 获取 Zulip API Key'); + console.log(` 邮箱: ${email}`); + + try { + // Zulip API 使用 Basic Auth 和 form data + const response = await axios.post( + 'https://zulip.xinghangee.icu/api/v1/fetch_api_key', + `username=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`, + { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + if (response.data.result === 'success') { + console.log('✅ 成功获取 API Key'); + console.log(` API Key: ${response.data.api_key.substring(0, 10)}...`); + console.log(` 用户ID: ${response.data.user_id}`); + return { + apiKey: response.data.api_key, + email: response.data.email, + userId: response.data.user_id + }; + } else { + throw new Error(response.data.msg || '获取 API Key 失败'); + } + } catch (error) { + console.error('❌ 获取 API Key 失败:', error.response?.data?.msg || error.message); + throw error; + } +} + +async function listSubscriptions() { + console.log('🚀 开始测试用户订阅的 Streams'); + console.log('='.repeat(60)); + + try { + // 步骤1: 登录游戏服务器 + const userInfo = await loginToGameServer(); + + // 步骤2: 获取 Zulip API Key + const zulipAuth = await getZulipApiKey(userInfo.email, TEST_USER.password); + + console.log('\n📝 步骤 3: 检查用户订阅的 Streams'); + + const config = { + username: zulipAuth.email, + apiKey: zulipAuth.apiKey, + realm: 'https://zulip.xinghangee.icu/' + }; + const client = await zulip(config); // 获取用户信息 @@ -29,15 +116,15 @@ async function listSubscriptions() { }); // 检查是否有 "Novice Village" - const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Novice Village'); + const noviceVillage = subscriptions.subscriptions.find(s => s.name === 'Pumpkin Valley'); if (noviceVillage) { - console.log('\n✅ "Novice Village" Stream 已存在!'); + console.log('\n✅ "Pumpkin Valley" Stream 已存在!'); // 测试发送消息 console.log('\n📤 测试发送消息...'); const result = await client.messages.send({ type: 'stream', - to: 'Novice Village', + to: 'Pumpkin Valley', subject: 'General', content: '测试消息:系统集成测试成功 🎮' }); @@ -48,7 +135,7 @@ async function listSubscriptions() { console.log('❌ 消息发送失败:', result.msg); } } else { - console.log('\n⚠️ "Novice Village" Stream 不存在'); + console.log('\n⚠️ "Pumpkin Valley" Stream 不存在'); console.log('💡 请在 Zulip 网页界面手动创建该 Stream,或使用管理员账号创建'); // 尝试发送到第一个可用的 Stream @@ -79,7 +166,9 @@ async function listSubscriptions() { if (error.response) { console.error('响应数据:', error.response.data); } + process.exit(1); } } +// 运行测试 listSubscriptions(); diff --git a/docs/systems/zulip/quick_tests/test-user-api-key.js b/docs/systems/zulip/quick_tests/test-user-api-key.js index 4591dbb..06ac28a 100644 --- a/docs/systems/zulip/quick_tests/test-user-api-key.js +++ b/docs/systems/zulip/quick_tests/test-user-api-key.js @@ -1,127 +1,183 @@ const io = require('socket.io-client'); +const axios = require('axios'); + +// 配置 +const GAME_SERVER = 'http://localhost:3000'; +const TEST_USER = { + username: 'angtest123', + password: 'angtest123', + email: 'angjustinl@163.com' +}; + +/** + * 登录游戏服务器获取token + */ +async function loginToGameServer() { + console.log('📝 步骤 1: 登录游戏服务器'); + console.log(` 用户名: ${TEST_USER.username}`); + + try { + const response = await axios.post(`${GAME_SERVER}/auth/login`, { + identifier: TEST_USER.username, + password: TEST_USER.password + }); + + if (response.data.success) { + console.log('✅ 登录成功'); + console.log(` 用户ID: ${response.data.data.user.id}`); + console.log(` 昵称: ${response.data.data.user.nickname}`); + console.log(` 邮箱: ${response.data.data.user.email}`); + console.log(` Token: ${response.data.data.access_token.substring(0, 20)}...`); + return { + userId: response.data.data.user.id, + username: response.data.data.user.username, + token: response.data.data.access_token + }; + } else { + throw new Error(response.data.message || '登录失败'); + } + } catch (error) { + console.error('❌ 登录失败:', error.response?.data?.message || error.message); + throw error; + } +} // 使用用户 API Key 测试 Zulip 集成 async function testWithUserApiKey() { - console.log('🚀 使用用户 API Key 测试 Zulip 集成...'); - console.log('📡 用户 API Key: lCPWCPfGh7WU...pqNfGF8'); - console.log('📡 Zulip 服务器: https://zulip.xinghangee.icu/'); - console.log('📡 游戏服务器: http://localhost:3000/game'); + console.log('🚀 开始测试用户 API Key 的 Zulip 集成'); + console.log('='.repeat(60)); - const socket = io('http://localhost:3000/game', { - transports: ['websocket'], - timeout: 20000 - }); - - let testStep = 0; - - socket.on('connect', () => { - console.log('✅ WebSocket 连接成功'); - testStep = 1; + try { + // 登录获取 token + const userInfo = await loginToGameServer(); - // 使用包含用户 API Key 的 token - const loginMessage = { - type: 'login', - token: 'lCPWCPfGh7...fGF8_user_token' - }; - - console.log('📤 步骤 1: 发送登录消息(使用用户 API Key)'); - socket.emit('login', loginMessage); - }); + console.log('\n📡 步骤 2: 连接 WebSocket 并测试 Zulip 集成'); + console.log(` 连接到: ${GAME_SERVER}/game`); - socket.on('login_success', (data) => { - console.log('✅ 步骤 1 完成: 登录成功'); - console.log(' 会话ID:', data.sessionId); - console.log(' 用户ID:', data.userId); - console.log(' 用户名:', data.username); - console.log(' 当前地图:', data.currentMap); - testStep = 2; + const socket = io(`${GAME_SERVER}/game`, { + transports: ['websocket'], + timeout: 20000 + }); - // 等待 Zulip 客户端初始化 - console.log('⏳ 等待 3 秒让 Zulip 客户端初始化...'); - setTimeout(() => { - const chatMessage = { - t: 'chat', - content: '🎮 【用户API Key测试】来自游戏的消息!\\n' + - '时间: ' + new Date().toLocaleString() + '\\n' + - '使用用户 API Key 发送此消息。', - scope: 'local' + let testStep = 0; + + socket.on('connect', () => { + console.log('✅ WebSocket 连接成功'); + testStep = 1; + + // 使用真实的 JWT token + const loginMessage = { + type: 'login', + token: userInfo.token }; - console.log('📤 步骤 2: 发送消息到 Zulip(使用用户 API Key)'); - console.log(' 目标 Stream: Whale Port'); - socket.emit('chat', chatMessage); - }, 3000); - }); + console.log('📤 步骤 3: 发送登录消息(使用 JWT Token)'); + socket.emit('login', loginMessage); + }); - socket.on('chat_sent', (data) => { - console.log('✅ 步骤 2 完成: 消息发送成功'); - console.log(' 响应:', JSON.stringify(data, null, 2)); - - // 只在第一次收到 chat_sent 时发送第二条消息 - if (testStep === 2) { - testStep = 3; + socket.on('login_success', (data) => { + console.log('✅ 步骤 3 完成: 登录成功'); + console.log(' 会话ID:', data.sessionId); + console.log(' 用户ID:', data.userId); + console.log(' 用户名:', data.username); + console.log(' 当前地图:', data.currentMap); + testStep = 2; + // 等待 Zulip 客户端初始化 + console.log('\n⏳ 等待 3 秒让 Zulip 客户端初始化...'); setTimeout(() => { - // 先切换到 Pumpkin Valley 地图 - console.log('📤 步骤 3: 切换到 Pumpkin Valley 地图'); - const positionUpdate = { - t: 'position', - x: 150, - y: 400, - mapId: 'pumpkin_valley' + const chatMessage = { + t: 'chat', + content: `🎮 【用户API Key测试】来自 ${userInfo.username} 的消息!\n` + + `时间: ${new Date().toLocaleString()}\n` + + `使用真实 API Key 发送此消息。`, + scope: 'local' }; - socket.emit('position_update', positionUpdate); - // 等待位置更新后发送消息 + console.log('📤 步骤 4: 发送消息到 Zulip(使用真实 API Key)'); + console.log(' 目标 Stream: Whale Port'); + socket.emit('chat', chatMessage); + }, 3000); + }); + + socket.on('chat_sent', (data) => { + console.log('✅ 步骤 4 完成: 消息发送成功'); + console.log(' 响应:', JSON.stringify(data, null, 2)); + + // 只在第一次收到 chat_sent 时发送第二条消息 + if (testStep === 2) { + testStep = 3; + setTimeout(() => { - const chatMessage2 = { - t: 'chat', - content: '🎃 在南瓜谷发送的测试消息!', - scope: 'local' + // 先切换到 Pumpkin Valley 地图 + console.log('\n📤 步骤 5: 切换到 Pumpkin Valley 地图'); + const positionUpdate = { + t: 'position', + x: 150, + y: 400, + mapId: 'pumpkin_valley' }; + socket.emit('position_update', positionUpdate); - console.log('📤 步骤 4: 在 Pumpkin Valley 发送消息'); - socket.emit('chat', chatMessage2); - }, 1000); - }, 2000); - } - }); + // 等待位置更新后发送消息 + setTimeout(() => { + const chatMessage2 = { + t: 'chat', + content: '🎃 在南瓜谷发送的测试消息!', + scope: 'local' + }; + + console.log('📤 步骤 6: 在 Pumpkin Valley 发送消息'); + socket.emit('chat', chatMessage2); + }, 1000); + }, 2000); + } + }); - socket.on('chat_render', (data) => { - console.log('📨 收到来自 Zulip 的消息:'); - console.log(' 发送者:', data.from); - console.log(' 内容:', data.txt); - console.log(' Stream:', data.stream || '未知'); - console.log(' Topic:', data.topic || '未知'); - }); - - socket.on('error', (error) => { - console.log('❌ 收到错误:', JSON.stringify(error, null, 2)); - }); - - socket.on('disconnect', () => { - console.log('🔌 WebSocket 连接已关闭'); - console.log(''); - console.log('📊 测试结果:'); - console.log(' 完成步骤:', testStep, '/ 4'); - if (testStep >= 3) { - console.log(' ✅ 核心功能正常!'); - console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息'); - } - process.exit(0); - }); - - socket.on('connect_error', (error) => { - console.error('❌ 连接错误:', error.message); + socket.on('chat_render', (data) => { + console.log('\n📨 收到来自 Zulip 的消息:'); + console.log(' 发送者:', data.from); + console.log(' 内容:', data.txt); + console.log(' Stream:', data.stream || '未知'); + console.log(' Topic:', data.topic || '未知'); + }); + + socket.on('error', (error) => { + console.log('❌ 收到错误:', JSON.stringify(error, null, 2)); + }); + + socket.on('disconnect', () => { + console.log('\n🔌 WebSocket 连接已关闭'); + console.log('\n' + '='.repeat(60)); + console.log('📊 测试结果汇总'); + console.log('='.repeat(60)); + console.log(' 完成步骤:', testStep, '/ 3'); + if (testStep >= 3) { + console.log(' ✅ 核心功能正常!'); + console.log(' 💡 请检查 Zulip 中的 "Whale Port" 和 "Pumpkin Valley" Streams 查看消息'); + } else { + console.log(' ⚠️ 部分测试未完成'); + } + console.log('='.repeat(60)); + process.exit(testStep >= 3 ? 0 : 1); + }); + + socket.on('connect_error', (error) => { + console.error('❌ 连接错误:', error.message); + process.exit(1); + }); + + // 20秒后自动关闭(给足够时间完成测试) + setTimeout(() => { + console.log('\n⏰ 测试时间到,关闭连接'); + socket.disconnect(); + }, 20000); + + } catch (error) { + console.error('\n❌ 测试失败:', error.message); process.exit(1); - }); - - // 20秒后自动关闭(给足够时间完成测试) - setTimeout(() => { - console.log('⏰ 测试时间到,关闭连接'); - socket.disconnect(); - }, 20000); + } } -console.log('🔧 准备测试环境...'); -testWithUserApiKey().catch(console.error); \ No newline at end of file +// 运行测试 +testWithUserApiKey(); \ No newline at end of file diff --git a/src/business/auth/services/login.service.ts b/src/business/auth/services/login.service.ts index 0fde21b..a5f0072 100644 --- a/src/business/auth/services/login.service.ts +++ b/src/business/auth/services/login.service.ts @@ -663,35 +663,34 @@ export class LoginService { throw new Error('JWT_SECRET未配置'); } - // 1. 创建访问令牌载荷 - const accessPayload: JwtPayload = { + // 1. 创建访问令牌载荷(不包含iss和aud,这些通过options传递) + const accessPayload: Omit = { sub: user.id.toString(), username: user.username, role: user.role, email: user.email, type: 'access', - iat: currentTime, - iss: 'whale-town', - aud: 'whale-town-users', }; // 2. 创建刷新令牌载荷(有效期更长) - const refreshPayload: JwtPayload = { + const refreshPayload: Omit = { sub: user.id.toString(), username: user.username, role: user.role, type: 'refresh', - iat: currentTime, - iss: 'whale-town', - aud: 'whale-town-users', }; - // 3. 生成访问令牌(使用NestJS JwtService) - const accessToken = await this.jwtService.signAsync(accessPayload); + // 3. 生成访问令牌(使用NestJS JwtService,通过options传递iss和aud) + const accessToken = await this.jwtService.signAsync(accessPayload, { + issuer: 'whale-town', + audience: 'whale-town-users', + }); // 4. 生成刷新令牌(有效期30天) const refreshToken = jwt.sign(refreshPayload, jwtSecret, { expiresIn: '30d', + issuer: 'whale-town', + audience: 'whale-town-users', }); // 5. 计算过期时间(秒) diff --git a/src/business/zulip/zulip.module.ts b/src/business/zulip/zulip.module.ts index 13590aa..5fcc0bc 100644 --- a/src/business/zulip/zulip.module.ts +++ b/src/business/zulip/zulip.module.ts @@ -38,8 +38,8 @@ * - 业务规则驱动的消息过滤和权限控制 * * @author angjustinl - * @version 2.0.0 - * @since 2025-12-31 + * @version 1.1.0 + * @since 2026-01-06 */ import { Module } from '@nestjs/common'; @@ -53,6 +53,7 @@ import { ZulipCoreModule } from '../../core/zulip/zulip-core.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'; @Module({ imports: [ @@ -64,6 +65,8 @@ import { LoginCoreModule } from '../../core/login_core/login_core.module'; LoggerModule, // 登录模块 - 提供用户认证和Token验证 LoginCoreModule, + // 认证模块 - 提供JWT验证和用户认证服务 + AuthModule, ], providers: [ // 主协调服务 - 整合各子服务,提供统一业务接口 diff --git a/src/business/zulip/zulip.service.ts b/src/business/zulip/zulip.service.ts index 1386948..2449f65 100644 --- a/src/business/zulip/zulip.service.ts +++ b/src/business/zulip/zulip.service.ts @@ -18,8 +18,8 @@ * - 消息格式转换和过滤 * * @author angjustinl - * @version 1.0.0 - * @since 2025-12-25 + * @version 1.1.0 + * @since 2026-01-06 */ import { Injectable, Logger, Inject } from '@nestjs/common'; @@ -32,6 +32,7 @@ import { IZulipConfigService, } from '../../core/zulip/interfaces/zulip-core.interfaces'; import { ApiKeySecurityService } from '../../core/zulip/services/api_key_security.service'; +import { LoginService } from '../auth/services/login.service'; /** * 玩家登录请求接口 @@ -116,6 +117,7 @@ export class ZulipService { @Inject('ZULIP_CONFIG_SERVICE') private readonly configManager: IZulipConfigService, private readonly apiKeySecurityService: ApiKeySecurityService, + private readonly loginService: LoginService, ) { this.logger.log('ZulipService初始化完成'); } @@ -172,9 +174,7 @@ export class ZulipService { }; } - // 2. 验证游戏Token并获取用户信息 - // TODO: 实际项目中应该调用认证服务验证Token - // 这里暂时使用模拟数据 + // 2. 验证游戏Token并获取用户信息 调用认证服务验证Token const userInfo = await this.validateGameToken(request.token); if (!userInfo) { this.logger.warn('登录失败:Token验证失败', { @@ -288,7 +288,7 @@ export class ZulipService { * 功能描述: * 验证游戏Token的有效性,返回用户信息 * - * @param token 游戏Token + * @param token 游戏Token (JWT) * @returns Promise 用户信息,验证失败返回null * @private */ @@ -299,69 +299,84 @@ export class ZulipService { zulipEmail?: string; zulipApiKey?: string; } | null> { - // TODO: 实际项目中应该调用认证服务验证Token (登录godot所获取的JWT token) - // 这里暂时使用模拟数据进行开发测试 - this.logger.debug('验证游戏Token', { operation: 'validateGameToken', tokenLength: token.length, }); - // 模拟Token验证 - // 实际实现应该: - // 1. 调用LoginService验证Token - // 2. 从数据库获取用户的Zulip API Key - // 3. 返回完整的用户信息 - - if (token.startsWith('invalid')) { - return null; - } - - // 从Token中提取用户ID(模拟) - const userId = `user_${token.substring(0, 8)}`; - - // 从ApiKeySecurityService获取真实的Zulip API Key - let zulipApiKey = undefined; - let zulipEmail = undefined; - try { - // 尝试从Redis获取存储的API Key - const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); - - if (apiKeyResult.success && apiKeyResult.apiKey) { - zulipApiKey = apiKeyResult.apiKey; - // TODO: 从数据库获取用户的Zulip邮箱 - // 暂时使用模拟数据 - zulipEmail = 'angjustinl@163.com'; - - this.logger.log('从存储获取到Zulip API Key', { + // 1. 使用LoginService验证JWT token + const payload = await this.loginService.verifyToken(token, 'access'); + + if (!payload || !payload.sub) { + this.logger.warn('Token载荷无效', { operation: 'validateGameToken', - userId, - hasApiKey: true, - zulipEmail, - }); - } else { - this.logger.debug('用户没有存储的Zulip API Key', { - operation: 'validateGameToken', - userId, }); + return null; } - } catch (error) { - const err = error as Error; - this.logger.warn('获取Zulip API Key失败', { + + const userId = payload.sub; + const username = payload.username || `user_${userId}`; + const email = payload.email || `${userId}@example.com`; + + this.logger.debug('Token解析成功', { operation: 'validateGameToken', userId, + username, + email, + }); + + // 2. 从ApiKeySecurityService获取真实的Zulip API Key + let zulipApiKey = undefined; + let zulipEmail = undefined; + + try { + // 尝试从Redis获取存储的API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(userId); + + if (apiKeyResult.success && apiKeyResult.apiKey) { + zulipApiKey = apiKeyResult.apiKey; + // 使用游戏账号的邮箱 + zulipEmail = email; + + this.logger.log('从存储获取到Zulip API Key', { + operation: 'validateGameToken', + userId, + hasApiKey: true, + apiKeyLength: zulipApiKey.length, + }); + } else { + this.logger.debug('用户没有存储的Zulip API Key', { + operation: 'validateGameToken', + userId, + reason: apiKeyResult.message, + }); + } + } catch (error) { + const err = error as Error; + this.logger.warn('获取Zulip API Key失败', { + operation: 'validateGameToken', + userId, + error: err.message, + }); + } + + return { + userId, + username, + email, + zulipEmail, + zulipApiKey, + }; + + } catch (error) { + const err = error as Error; + this.logger.warn('Token验证失败', { + operation: 'validateGameToken', error: err.message, }); + return null; } - - return { - userId, - username: `Player_${userId.substring(5, 10)}`, - email: `${userId}@example.com`, - zulipEmail, - zulipApiKey, - }; } /** -- 2.25.1