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] 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