diff --git a/src/business/auth/README.md b/src/business/auth/README.md index 5f7abcf..04b63c0 100644 --- a/src/business/auth/README.md +++ b/src/business/auth/README.md @@ -1,3 +1,26 @@ + + # Auth 用户认证业务模块 Auth 是应用的核心用户认证业务模块,提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能,支持邮箱验证、验证码登录、安全防护和Zulip账号同步,具备完善的业务流程控制、错误处理和安全审计能力。 diff --git a/src/business/auth/auth.module.ts b/src/business/auth/auth.module.ts index 1e5f0fb..0f0ebe5 100644 --- a/src/business/auth/auth.module.ts +++ b/src/business/auth/auth.module.ts @@ -26,6 +26,8 @@ import { Module } from '@nestjs/common'; import { LoginController } from './login.controller'; import { LoginService } from './login.service'; +import { RegisterController } from './register.controller'; +import { RegisterService } from './register.service'; import { LoginCoreModule } from '../../core/login_core/login_core.module'; import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module'; import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module'; @@ -38,10 +40,11 @@ import { UsersModule } from '../../core/db/users/users.module'; ZulipAccountsModule.forRoot(), UsersModule, ], - controllers: [LoginController], + controllers: [LoginController, RegisterController], providers: [ LoginService, + RegisterService, ], - exports: [LoginService], + exports: [LoginService, RegisterService], }) export class AuthModule {} \ No newline at end of file diff --git a/src/business/auth/index.ts b/src/business/auth/index.ts index c556a09..ed69771 100644 --- a/src/business/auth/index.ts +++ b/src/business/auth/index.ts @@ -28,9 +28,11 @@ export * from './auth.module'; // 控制器 export * from './login.controller'; +export * from './register.controller'; // 服务 export * from './login.service'; +export * from './register.service'; // DTO export * from './login.dto'; diff --git a/src/business/auth/jwt_auth.guard.spec.ts b/src/business/auth/jwt_auth.guard.spec.ts new file mode 100644 index 0000000..bd49168 --- /dev/null +++ b/src/business/auth/jwt_auth.guard.spec.ts @@ -0,0 +1,163 @@ +/** + * JwtAuthGuard 单元测试 + * + * 功能描述: + * - 测试JWT认证守卫的令牌验证功能 + * - 验证用户信息提取和注入 + * - 测试认证失败的异常处理 + * + * 最近修改: + * - 2026-01-12: 代码规范优化 - 创建缺失的守卫测试文件 (修改者: moyin) + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext, UnauthorizedException } from '@nestjs/common'; +import { JwtAuthGuard } from './jwt_auth.guard'; +import { LoginCoreService } from '../../core/login_core/login_core.service'; + +describe('JwtAuthGuard', () => { + let guard: JwtAuthGuard; + let loginCoreService: jest.Mocked; + let mockExecutionContext: jest.Mocked; + let mockRequest: any; + + beforeEach(async () => { + const mockLoginCoreService = { + verifyToken: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtAuthGuard, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + ], + }).compile(); + + guard = module.get(JwtAuthGuard); + loginCoreService = module.get(LoginCoreService); + + // Mock request object + mockRequest = { + headers: {}, + user: undefined, + }; + + // Mock execution context + mockExecutionContext = { + switchToHttp: jest.fn().mockReturnValue({ + getRequest: jest.fn().mockReturnValue(mockRequest), + }), + } as any; + }); + + it('should be defined', () => { + expect(guard).toBeDefined(); + }); + + describe('canActivate', () => { + it('should allow access with valid JWT token', async () => { + const mockPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'access' as const, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + mockRequest.headers.authorization = 'Bearer valid_jwt_token'; + loginCoreService.verifyToken.mockResolvedValue(mockPayload); + + const result = await guard.canActivate(mockExecutionContext); + + expect(result).toBe(true); + expect(mockRequest.user).toEqual(mockPayload); + expect(loginCoreService.verifyToken).toHaveBeenCalledWith('valid_jwt_token', 'access'); + }); + + it('should deny access when authorization header is missing', async () => { + mockRequest.headers.authorization = undefined; + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).not.toHaveBeenCalled(); + }); + + it('should deny access when token format is invalid', async () => { + mockRequest.headers.authorization = 'InvalidFormat token'; + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).not.toHaveBeenCalled(); + }); + + it('should deny access when token is not Bearer type', async () => { + mockRequest.headers.authorization = 'Basic dXNlcjpwYXNz'; + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).not.toHaveBeenCalled(); + }); + + it('should deny access when JWT token verification fails', async () => { + mockRequest.headers.authorization = 'Bearer invalid_jwt_token'; + loginCoreService.verifyToken.mockRejectedValue(new Error('Token expired')); + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).toHaveBeenCalledWith('invalid_jwt_token', 'access'); + }); + + it('should extract token correctly from Authorization header', async () => { + const mockPayload = { + sub: '1', + username: 'testuser', + role: 1, + type: 'access' as const, + iat: Math.floor(Date.now() / 1000), + exp: Math.floor(Date.now() / 1000) + 3600, + }; + + mockRequest.headers.authorization = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token'; + loginCoreService.verifyToken.mockResolvedValue(mockPayload); + + const result = await guard.canActivate(mockExecutionContext); + + expect(result).toBe(true); + expect(loginCoreService.verifyToken).toHaveBeenCalledWith( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token', + 'access' + ); + }); + + it('should handle empty token after Bearer', async () => { + mockRequest.headers.authorization = 'Bearer '; + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).not.toHaveBeenCalled(); + }); + + it('should handle authorization header with only Bearer', async () => { + mockRequest.headers.authorization = 'Bearer'; + + await expect(guard.canActivate(mockExecutionContext)) + .rejects.toThrow(UnauthorizedException); + + expect(loginCoreService.verifyToken).not.toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/src/business/auth/login.controller.spec.ts b/src/business/auth/login.controller.spec.ts new file mode 100644 index 0000000..8de3f8a --- /dev/null +++ b/src/business/auth/login.controller.spec.ts @@ -0,0 +1,208 @@ +/** + * LoginController 单元测试 + * + * 功能描述: + * - 测试登录控制器的HTTP请求处理 + * - 验证API响应格式和状态码 + * - 测试错误处理和异常情况 + * + * 最近修改: + * - 2026-01-12: 代码规范优化 - 创建缺失的控制器测试文件 (修改者: moyin) + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'express'; +import { HttpStatus } from '@nestjs/common'; +import { LoginController } from './login.controller'; +import { LoginService } from './login.service'; + +describe('LoginController', () => { + let controller: LoginController; + let loginService: jest.Mocked; + let mockResponse: jest.Mocked; + + beforeEach(async () => { + const mockLoginService = { + login: jest.fn(), + githubOAuth: jest.fn(), + sendPasswordResetCode: jest.fn(), + resetPassword: jest.fn(), + changePassword: jest.fn(), + verificationCodeLogin: jest.fn(), + sendLoginVerificationCode: jest.fn(), + refreshAccessToken: jest.fn(), + debugVerificationCode: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [LoginController], + providers: [ + { + provide: LoginService, + useValue: mockLoginService, + }, + ], + }).compile(); + + controller = module.get(LoginController); + loginService = module.get(LoginService); + + // Mock Response object + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } as any; + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('login', () => { + it('should handle successful login', async () => { + const loginDto = { + identifier: 'testuser', + password: 'password123' + }; + + const mockResult = { + success: true, + data: { + user: { + id: '1', + username: 'testuser', + nickname: '测试用户', + role: 1, + created_at: new Date() + }, + access_token: 'token', + refresh_token: 'refresh_token', + expires_in: 3600, + token_type: 'Bearer', + message: '登录成功' + }, + message: '登录成功' + }; + + loginService.login.mockResolvedValue(mockResult); + + await controller.login(loginDto, mockResponse); + + expect(loginService.login).toHaveBeenCalledWith({ + identifier: 'testuser', + password: 'password123' + }); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + + it('should handle login failure', async () => { + const loginDto = { + identifier: 'testuser', + password: 'wrongpassword' + }; + + const mockResult = { + success: false, + message: '用户名或密码错误', + error_code: 'LOGIN_FAILED' + }; + + loginService.login.mockResolvedValue(mockResult); + + await controller.login(loginDto, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('githubOAuth', () => { + it('should handle GitHub OAuth successfully', async () => { + const githubDto = { + github_id: '12345', + username: 'githubuser', + nickname: 'GitHub User', + email: 'github@example.com' + }; + + const mockResult = { + success: true, + data: { + user: { + id: '1', + username: 'githubuser', + nickname: 'GitHub User', + role: 1, + created_at: new Date() + }, + access_token: 'token', + refresh_token: 'refresh_token', + expires_in: 3600, + token_type: 'Bearer', + message: 'GitHub登录成功' + }, + message: 'GitHub登录成功' + }; + + loginService.githubOAuth.mockResolvedValue(mockResult); + + await controller.githubOAuth(githubDto, mockResponse); + + expect(loginService.githubOAuth).toHaveBeenCalledWith(githubDto); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('refreshToken', () => { + it('should handle token refresh successfully', async () => { + const refreshTokenDto = { + refresh_token: 'valid_refresh_token' + }; + + const mockResult = { + success: true, + data: { + access_token: 'new_access_token', + refresh_token: 'new_refresh_token', + expires_in: 3600, + token_type: 'Bearer' + }, + message: '令牌刷新成功' + }; + + loginService.refreshAccessToken.mockResolvedValue(mockResult); + + await controller.refreshToken(refreshTokenDto, mockResponse); + + expect(loginService.refreshAccessToken).toHaveBeenCalledWith('valid_refresh_token'); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + + it('should handle token refresh failure', async () => { + const refreshTokenDto = { + refresh_token: 'invalid_refresh_token' + }; + + const mockResult = { + success: false, + message: '刷新令牌无效或已过期', + error_code: 'TOKEN_REFRESH_FAILED' + }; + + loginService.refreshAccessToken.mockResolvedValue(mockResult); + + await controller.refreshToken(refreshTokenDto, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); +}); \ No newline at end of file diff --git a/src/business/auth/login.controller.ts b/src/business/auth/login.controller.ts index 9e05c2d..8aeb9e0 100644 --- a/src/business/auth/login.controller.ts +++ b/src/business/auth/login.controller.ts @@ -34,15 +34,12 @@ 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 './login.service'; -import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from './login.dto'; +import { LoginDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto, SendEmailVerificationDto } from './login.dto'; import { LoginResponseDto, - RegisterResponseDto, GitHubOAuthResponseDto, ForgotPasswordResponseDto, CommonResponseDto, - TestModeEmailVerificationResponseDto, - SuccessEmailVerificationResponseDto, RefreshTokenResponseDto } from './login_response.dto'; import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator'; @@ -51,14 +48,12 @@ import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decora // 错误代码到HTTP状态码的映射 const ERROR_STATUS_MAP = { LOGIN_FAILED: HttpStatus.UNAUTHORIZED, - REGISTER_FAILED: HttpStatus.BAD_REQUEST, TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT, TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED, GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED, SEND_CODE_FAILED: HttpStatus.BAD_REQUEST, RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST, CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST, - EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST, VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED, INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST, } as const; @@ -169,51 +164,6 @@ export class LoginController { this.handleResponse(result, res); } - /** - * 用户注册 - * - * @param registerDto 注册数据 - * @returns 注册结果 - */ - @ApiOperation({ - summary: '用户注册', - description: '创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码,然后在注册时提供验证码进行验证。' - }) - @ApiBody({ type: RegisterDto }) - @SwaggerApiResponse({ - status: 201, - description: '注册成功', - type: RegisterResponseDto - }) - @SwaggerApiResponse({ - status: 400, - description: '请求参数错误' - }) - @SwaggerApiResponse({ - status: 409, - description: '用户名或邮箱已存在' - }) - @SwaggerApiResponse({ - status: 429, - description: '注册请求过于频繁' - }) - @Throttle(ThrottlePresets.REGISTER) - @Timeout(TimeoutPresets.NORMAL) - @Post('register') - @UsePipes(new ValidationPipe({ transform: true })) - async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise { - const result = await this.loginService.register({ - username: registerDto.username, - password: registerDto.password, - nickname: registerDto.nickname, - email: registerDto.email, - phone: registerDto.phone, - email_verification_code: registerDto.email_verification_code - }); - - this.handleResponse(result, res, HttpStatus.CREATED); - } - /** * GitHub OAuth登录 * @@ -378,120 +328,6 @@ export class LoginController { this.handleResponse(result, res); } - /** - * 发送邮箱验证码 - * - * @param sendEmailVerificationDto 发送验证码数据 - * @param res Express响应对象 - * @returns 发送结果 - */ - @ApiOperation({ - summary: '发送邮箱验证码', - description: '向指定邮箱发送验证码' - }) - @ApiBody({ type: SendEmailVerificationDto }) - @SwaggerApiResponse({ - status: 200, - description: '验证码发送成功(真实发送模式)', - type: SuccessEmailVerificationResponseDto - }) - @SwaggerApiResponse({ - status: 206, - description: '测试模式:验证码已生成但未真实发送', - type: TestModeEmailVerificationResponseDto - }) - @SwaggerApiResponse({ - status: 400, - description: '请求参数错误' - }) - @SwaggerApiResponse({ - status: 429, - description: '发送频率过高' - }) - @Throttle(ThrottlePresets.SEND_CODE_PER_EMAIL) - @Timeout(TimeoutPresets.EMAIL_SEND) - @Post('send-email-verification') - @UsePipes(new ValidationPipe({ transform: true })) - async sendEmailVerification( - @Body() sendEmailVerificationDto: SendEmailVerificationDto, - @Res() res: Response - ): Promise { - const result = await this.loginService.sendEmailVerification(sendEmailVerificationDto.email); - this.handleResponse(result, res); - } - - /** - * 验证邮箱验证码 - * - * @param emailVerificationDto 邮箱验证数据 - * @returns 验证结果 - */ - @ApiOperation({ - summary: '验证邮箱验证码', - description: '使用验证码验证邮箱' - }) - @ApiBody({ type: EmailVerificationDto }) - @SwaggerApiResponse({ - status: 200, - description: '邮箱验证成功', - type: CommonResponseDto - }) - @SwaggerApiResponse({ - status: 400, - description: '验证码错误或已过期' - }) - @Post('verify-email') - @UsePipes(new ValidationPipe({ transform: true })) - async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto, @Res() res: Response): Promise { - const result = await this.loginService.verifyEmailCode( - emailVerificationDto.email, - emailVerificationDto.verification_code - ); - - this.handleResponse(result, res); - } - - /** - * 重新发送邮箱验证码 - * - * @param sendEmailVerificationDto 发送验证码数据 - * @param res Express响应对象 - * @returns 发送结果 - */ - @ApiOperation({ - summary: '重新发送邮箱验证码', - description: '重新向指定邮箱发送验证码' - }) - @ApiBody({ type: SendEmailVerificationDto }) - @SwaggerApiResponse({ - status: 200, - description: '验证码重新发送成功', - type: ForgotPasswordResponseDto - }) - @SwaggerApiResponse({ - status: 206, - description: '测试模式:验证码已生成但未真实发送', - type: ForgotPasswordResponseDto - }) - @SwaggerApiResponse({ - status: 400, - description: '邮箱已验证或用户不存在' - }) - @SwaggerApiResponse({ - status: 429, - description: '发送频率过高' - }) - @Throttle(ThrottlePresets.SEND_CODE) - @Post('resend-email-verification') - @UsePipes(new ValidationPipe({ transform: true })) - async resendEmailVerification( - @Body() sendEmailVerificationDto: SendEmailVerificationDto, - @Res() res: Response - ): Promise { - const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email); - this.handleResponse(result, res); - } - /** * 验证码登录 * diff --git a/src/business/auth/login.service.spec.ts b/src/business/auth/login.service.spec.ts index bd6bcde..cde4651 100644 --- a/src/business/auth/login.service.spec.ts +++ b/src/business/auth/login.service.spec.ts @@ -60,18 +60,14 @@ describe('LoginService', () => { const mockLoginCoreService = { login: jest.fn(), - register: jest.fn(), githubOAuth: jest.fn(), sendPasswordResetCode: jest.fn(), resetPassword: jest.fn(), changePassword: jest.fn(), - sendEmailVerification: jest.fn(), - verifyEmailCode: jest.fn(), - resendEmailVerification: jest.fn(), verificationCodeLogin: jest.fn(), sendLoginVerificationCode: jest.fn(), debugVerificationCode: jest.fn(), - deleteUser: jest.fn(), + refreshAccessToken: jest.fn(), generateTokenPair: jest.fn(), }; @@ -178,44 +174,6 @@ describe('LoginService', () => { }); }); - describe('register', () => { - it('should register successfully with JWT tokens', async () => { - loginCoreService.register.mockResolvedValue({ - user: mockUser, - isNewUser: true - }); - - const result = await service.register({ - username: 'newuser', - password: 'password123', - nickname: '新用户', - email: 'newuser@example.com', - email_verification_code: '123456' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBe(mockTokenPair.access_token); - expect(result.data?.is_new_user).toBe(true); - expect(loginCoreService.register).toHaveBeenCalled(); - expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser); - }); - - it('should handle register failure', async () => { - loginCoreService.register.mockRejectedValue(new Error('用户名已存在')); - - const result = await service.register({ - username: 'existinguser', - password: 'password123', - nickname: '用户' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('用户名已存在'); - expect(result.error_code).toBe('REGISTER_FAILED'); - }); - }); - describe('githubOAuth', () => { it('should handle GitHub OAuth successfully', async () => { loginCoreService.githubOAuth.mockResolvedValue({ @@ -282,34 +240,6 @@ describe('LoginService', () => { }); }); - describe('sendEmailVerification', () => { - 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); // Test mode returns false - expect(result.data?.verification_code).toBe('123456'); - expect(result.data?.is_test_mode).toBe(true); - expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com'); - }); - }); - - describe('verifyEmailCode', () => { - 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('邮箱验证成功'); - expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456'); - }); - }); - describe('verificationCodeLogin', () => { it('should handle verificationCodeLogin successfully', async () => { loginCoreService.verificationCodeLogin.mockResolvedValue({ diff --git a/src/business/auth/login.service.ts b/src/business/auth/login.service.ts index 303b2e5..e451810 100644 --- a/src/business/auth/login.service.ts +++ b/src/business/auth/login.service.ts @@ -2,28 +2,30 @@ * 登录业务服务 * * 功能描述: - * - 处理登录相关的业务逻辑和流程控制 - * - 整合核心服务,提供完整的业务功能 + * - 处理用户登录相关的业务逻辑和流程控制 + * - 整合核心服务,提供完整的登录功能 * - 处理业务规则、数据格式化和错误处理 + * - 管理JWT令牌刷新和验证码登录 * * 职责分离: - * - 专注于业务流程和规则实现 + * - 专注于登录业务流程和规则实现 * - 调用核心服务完成具体功能 - * - 为控制器层提供业务接口 + * - 为控制器层提供登录业务接口 * - JWT技术实现已移至Core层,符合架构分层原则 * * 最近修改: + * - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能 * - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构 * - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块,符合架构分层原则 * * @author moyin - * @version 1.0.3 + * @version 1.1.0 * @since 2025-12-17 - * @lastModified 2026-01-07 + * @lastModified 2026-01-12 */ import { Injectable, Logger, Inject } from '@nestjs/common'; -import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service'; +import { LoginCoreService, LoginRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service'; import { Users } from '../../core/db/users/users.entity'; import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; @@ -38,14 +40,10 @@ interface IZulipAccountsService { // 常量定义 const ERROR_CODES = { LOGIN_FAILED: 'LOGIN_FAILED', - REGISTER_FAILED: 'REGISTER_FAILED', GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED', SEND_CODE_FAILED: 'SEND_CODE_FAILED', RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED', CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED', - SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED', - EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED', - RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED', VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED', SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED', TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED', @@ -56,19 +54,14 @@ const ERROR_CODES = { const MESSAGES = { LOGIN_SUCCESS: '登录成功', - REGISTER_SUCCESS: '注册成功', - REGISTER_SUCCESS_WITH_ZULIP: '注册成功,Zulip账号已同步创建', GITHUB_LOGIN_SUCCESS: 'GitHub登录成功', GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功', PASSWORD_RESET_SUCCESS: '密码重置成功', PASSWORD_CHANGE_SUCCESS: '密码修改成功', - EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功', VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功', TOKEN_REFRESH_SUCCESS: '令牌刷新成功', DEBUG_INFO_SUCCESS: '调试信息获取成功', CODE_SENT: '验证码已发送,请查收', - EMAIL_CODE_SENT: '验证码已发送,请查收邮件', - EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件', VERIFICATION_CODE_ERROR: '验证码错误', TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', } as const; @@ -161,10 +154,38 @@ export class LoginService { // 1. 调用核心服务进行认证 const authResult = await this.loginCoreService.login(loginRequest); - // 2. 生成JWT令牌对(通过Core层) + // 2. 验证和更新Zulip API Key(如果用户有Zulip账号关联) + try { + const isZulipValid = await this.validateAndUpdateZulipApiKey(authResult.user); + if (!isZulipValid) { + // 尝试重新生成API Key(需要密码) + const regenerated = await this.regenerateZulipApiKey(authResult.user, loginRequest.password); + if (regenerated) { + this.logger.log('用户Zulip API Key已重新生成', { + operation: 'login', + userId: authResult.user.id.toString(), + }); + } else { + this.logger.warn('用户Zulip API Key重新生成失败', { + operation: 'login', + userId: authResult.user.id.toString(), + }); + } + } + } catch (zulipError) { + // Zulip验证失败不影响登录流程,只记录日志 + const err = zulipError as Error; + this.logger.warn('Zulip API Key验证失败,但不影响登录', { + operation: 'login', + userId: authResult.user.id.toString(), + zulipError: err.message, + }); + } + + // 3. 生成JWT令牌对(通过Core层) const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user); - // 3. 格式化响应数据 + // 4. 格式化响应数据 const response: LoginResponse = { user: this.formatUserInfo(authResult.user), access_token: tokenPair.access_token, @@ -211,235 +232,6 @@ export class LoginService { } } - /** - * 用户注册 - * - * @param registerRequest 注册请求 - * @returns 注册响应 - */ - async register(registerRequest: RegisterRequest): Promise> { - const startTime = Date.now(); - const operationId = `register_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - try { - this.logger.log(`[${operationId}] 步骤1: 开始用户注册流程`, { - operation: 'register', - operationId, - username: registerRequest.username, - email: registerRequest.email, - hasPassword: !!registerRequest.password, - timestamp: new Date().toISOString(), - }); - - // 1. 初始化Zulip管理员客户端 - this.logger.log(`[${operationId}] 步骤2: 开始初始化Zulip管理员客户端`, { - operation: 'register', - operationId, - step: 'initializeZulipAdminClient', - }); - - await this.initializeZulipAdminClient(); - - this.logger.log(`[${operationId}] 步骤2: Zulip管理员客户端初始化成功`, { - operation: 'register', - operationId, - step: 'initializeZulipAdminClient', - result: 'success', - }); - - // 2. 调用核心服务进行注册 - this.logger.log(`[${operationId}] 步骤3: 开始创建游戏用户账号`, { - operation: 'register', - operationId, - step: 'createGameUser', - username: registerRequest.username, - }); - - const authResult = await this.loginCoreService.register(registerRequest); - - this.logger.log(`[${operationId}] 步骤3: 游戏用户账号创建成功`, { - operation: 'register', - operationId, - step: 'createGameUser', - result: 'success', - gameUserId: authResult.user.id.toString(), - username: authResult.user.username, - email: authResult.user.email, - }); - - // 3. 创建Zulip账号(使用相同的邮箱和密码) - let zulipAccountCreated = false; - - if (registerRequest.email && registerRequest.password) { - this.logger.log(`[${operationId}] 步骤4: 开始创建/绑定Zulip账号`, { - operation: 'register', - operationId, - step: 'createZulipAccount', - gameUserId: authResult.user.id.toString(), - email: registerRequest.email, - }); - } else { - this.logger.warn(`[${operationId}] 步骤4: 跳过Zulip账号创建(缺少邮箱或密码)`, { - operation: 'register', - operationId, - step: 'createZulipAccount', - result: 'skipped', - username: registerRequest.username, - gameUserId: authResult.user.id.toString(), - hasEmail: !!registerRequest.email, - hasPassword: !!registerRequest.password, - }); - } - - try { - if (registerRequest.email && registerRequest.password) { - await this.createZulipAccountForUser(authResult.user, registerRequest.password); - zulipAccountCreated = true; - - this.logger.log(`[${operationId}] 步骤4: Zulip账号创建成功`, { - operation: 'register', - operationId, - step: 'createZulipAccount', - result: 'success', - 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(`[${operationId}] 步骤4: Zulip账号创建失败,开始回滚`, { - operation: 'register', - operationId, - step: 'createZulipAccount', - result: 'failed', - username: registerRequest.username, - gameUserId: authResult.user.id.toString(), - zulipError: err.message, - }, err.stack); - - // 回滚游戏用户注册 - this.logger.log(`[${operationId}] 步骤4.1: 开始回滚游戏用户注册`, { - operation: 'register', - operationId, - step: 'rollbackGameUser', - gameUserId: authResult.user.id.toString(), - }); - - try { - await this.loginCoreService.deleteUser(authResult.user.id); - this.logger.log(`[${operationId}] 步骤4.1: 游戏用户注册回滚成功`, { - operation: 'register', - operationId, - step: 'rollbackGameUser', - result: 'success', - username: registerRequest.username, - gameUserId: authResult.user.id.toString(), - }); - } catch (rollbackError) { - const rollbackErr = rollbackError as Error; - this.logger.error(`[${operationId}] 步骤4.1: 游戏用户注册回滚失败`, { - operation: 'register', - operationId, - step: 'rollbackGameUser', - result: 'failed', - username: registerRequest.username, - gameUserId: authResult.user.id.toString(), - rollbackError: rollbackErr.message, - }, rollbackErr.stack); - } - - // 抛出原始错误 - throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); - } - - // 4. 生成JWT令牌对(通过Core层) - this.logger.log(`[${operationId}] 步骤5: 开始生成JWT令牌`, { - operation: 'register', - operationId, - step: 'generateTokens', - gameUserId: authResult.user.id.toString(), - }); - - const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user); - - this.logger.log(`[${operationId}] 步骤5: JWT令牌生成成功`, { - operation: 'register', - operationId, - step: 'generateTokens', - result: 'success', - gameUserId: authResult.user.id.toString(), - tokenType: tokenPair.token_type, - expiresIn: tokenPair.expires_in, - }); - - // 5. 格式化响应数据 - this.logger.log(`[${operationId}] 步骤6: 格式化响应数据`, { - operation: 'register', - operationId, - step: 'formatResponse', - gameUserId: authResult.user.id.toString(), - zulipAccountCreated, - }); - - const response: LoginResponse = { - user: this.formatUserInfo(authResult.user), - 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 ? MESSAGES.REGISTER_SUCCESS_WITH_ZULIP : MESSAGES.REGISTER_SUCCESS - }; - - const duration = Date.now() - startTime; - - this.logger.log(`[${operationId}] 注册流程完成: 用户注册成功`, { - operation: 'register', - operationId, - result: 'success', - gameUserId: authResult.user.id.toString(), - username: authResult.user.username, - email: authResult.user.email, - zulipAccountCreated, - duration, - timestamp: new Date().toISOString(), - }); - - return { - success: true, - data: response, - message: response.message - }; - } catch (error) { - const duration = Date.now() - startTime; - const err = error as Error; - - this.logger.error(`[${operationId}] 注册流程失败: 用户注册失败`, { - operation: 'register', - operationId, - result: 'failed', - username: registerRequest.username, - email: registerRequest.email, - error: err.message, - duration, - timestamp: new Date().toISOString(), - }, err.stack); - - return { - success: false, - message: err.message || '注册失败', - error_code: ERROR_CODES.REGISTER_FAILED - }; - } - } - /** * GitHub OAuth登录 * @@ -500,7 +292,26 @@ export class LoginService { this.logger.log(`密码重置验证码已发送: ${identifier}`); - return this.handleTestModeResponse(result, MESSAGES.CODE_SENT); + // 处理测试模式响应 + if (result.isTestMode) { + return { + success: false, + data: { + verification_code: result.code, + is_test_mode: true + }, + message: MESSAGES.TEST_MODE_WARNING, + error_code: ERROR_CODES.TEST_MODE_ONLY + }; + } else { + return { + success: true, + data: { + is_test_mode: false + }, + message: MESSAGES.CODE_SENT + }; + } } catch (error) { this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error)); @@ -574,98 +385,6 @@ export class LoginService { } } - /** - * 发送邮箱验证码 - * - * @param email 邮箱地址 - * @returns 响应结果 - */ - async sendEmailVerification(email: string): Promise> { - try { - this.logger.log(`发送邮箱验证码: ${email}`); - - // 调用核心服务发送验证码 - const result = await this.loginCoreService.sendEmailVerification(email); - - this.logger.log(`邮箱验证码已发送: ${email}`); - - return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT); - } catch (error) { - this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); - - return { - success: false, - message: error instanceof Error ? error.message : '发送验证码失败', - error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED - }; - } - } - - /** - * 验证邮箱验证码 - * - * @param email 邮箱地址 - * @param code 验证码 - * @returns 响应结果 - */ - async verifyEmailCode(email: string, code: string): Promise { - try { - this.logger.log(`验证邮箱验证码: ${email}`); - - // 调用核心服务验证验证码 - const isValid = await this.loginCoreService.verifyEmailCode(email, code); - - if (isValid) { - this.logger.log(`邮箱验证成功: ${email}`); - return { - success: true, - message: MESSAGES.EMAIL_VERIFICATION_SUCCESS - }; - } else { - return { - success: false, - message: MESSAGES.VERIFICATION_CODE_ERROR, - error_code: ERROR_CODES.INVALID_VERIFICATION_CODE - }; - } - } catch (error) { - this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error)); - - return { - success: false, - message: error instanceof Error ? error.message : '邮箱验证失败', - error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED - }; - } - } - - /** - * 重新发送邮箱验证码 - * - * @param email 邮箱地址 - * @returns 响应结果 - */ - async resendEmailVerification(email: string): Promise> { - try { - this.logger.log(`重新发送邮箱验证码: ${email}`); - - // 调用核心服务重新发送验证码 - const result = await this.loginCoreService.resendEmailVerification(email); - - this.logger.log(`邮箱验证码已重新发送: ${email}`); - - return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT); - } catch (error) { - this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); - - return { - success: false, - message: error instanceof Error ? error.message : '重新发送验证码失败', - error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED - }; - } - } - /** * 格式化用户信息 * @@ -685,41 +404,6 @@ export class LoginService { }; } - /** - * 处理测试模式响应 - * - * @param result 核心服务返回的结果 - * @param successMessage 成功时的消息 - * @param emailMessage 邮件发送成功时的消息 - * @returns 格式化的响应 - * @private - */ - private handleTestModeResponse( - result: { code: string; isTestMode: boolean }, - successMessage: string, - emailMessage?: string - ): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> { - if (result.isTestMode) { - return { - success: false, - data: { - verification_code: result.code, - is_test_mode: true - }, - message: MESSAGES.TEST_MODE_WARNING, - error_code: ERROR_CODES.TEST_MODE_ONLY - }; - } else { - return { - success: true, - data: { - is_test_mode: false - }, - message: emailMessage || successMessage - }; - } - } - /** * 验证码登录 * @@ -780,7 +464,26 @@ export class LoginService { this.logger.log(`登录验证码已发送: ${identifier}`); - return this.handleTestModeResponse(result, MESSAGES.CODE_SENT); + // 处理测试模式响应 + if (result.isTestMode) { + return { + success: false, + data: { + verification_code: result.code, + is_test_mode: true + }, + message: MESSAGES.TEST_MODE_WARNING, + error_code: ERROR_CODES.TEST_MODE_ONLY + }; + } else { + return { + success: true, + data: { + is_test_mode: false + }, + message: MESSAGES.CODE_SENT + }; + } } catch (error) { this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error)); @@ -839,6 +542,13 @@ export class LoginService { }; } } + /** + * 调试验证码信息 + * 仅用于开发和调试 + * + * @param email 邮箱地址 + * @returns 验证码调试信息 + */ async debugVerificationCode(email: string): Promise { try { this.logger.log(`调试验证码信息: ${email}`); @@ -862,169 +572,179 @@ export class LoginService { } /** - * 初始化Zulip管理员客户端 + * 验证并更新用户的Zulip API Key * * 功能描述: - * 使用环境变量中的管理员凭证初始化Zulip客户端 + * 在用户登录时验证其Zulip账号的API Key是否有效,如果无效则重新获取 * * 业务逻辑: - * 1. 从环境变量获取管理员配置 - * 2. 验证配置完整性 - * 3. 初始化ZulipAccountService的管理员客户端 + * 1. 查找用户的Zulip账号关联 + * 2. 从Redis获取API Key + * 3. 验证API Key是否有效 + * 4. 如果无效,重新生成API Key并更新存储 * - * @throws Error 当配置缺失或初始化失败时 + * @param user 用户信息 + * @returns Promise 是否验证/更新成功 * @private */ - private async initializeZulipAdminClient(): Promise { + private async validateAndUpdateZulipApiKey(user: Users): Promise { + const startTime = Date.now(); + + this.logger.log('开始验证用户Zulip API Key', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), + username: user.username, + email: user.email, + }); + 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'); + // 1. 查找用户的Zulip账号关联 + const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString()); + if (!zulipAccount) { + this.logger.log('用户没有Zulip账号关联,跳过验证', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), + }); + return true; // 没有关联不算错误 } - // 初始化管理员客户端 - const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); - - if (!initialized) { - throw new Error('Zulip管理员客户端初始化失败'); + // 2. 从Redis获取API Key + const apiKeyResult = await this.apiKeySecurityService.getApiKey(user.id.toString()); + if (!apiKeyResult.success || !apiKeyResult.apiKey) { + this.logger.warn('用户Zulip API Key不存在,需要重新生成', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), + zulipEmail: zulipAccount.zulipEmail, + error: apiKeyResult.message, + }); + + return false; // 需要重新生成 } - this.logger.log('Zulip管理员客户端初始化成功', { - operation: 'initializeZulipAdminClient', - realm: adminConfig.realm, - adminEmail: adminConfig.username, + // 3. 验证API Key是否有效 + const validationResult = await this.zulipAccountService.validateZulipAccount( + zulipAccount.zulipEmail, + apiKeyResult.apiKey + ); + + if (validationResult.success && validationResult.isValid) { + this.logger.log('用户Zulip API Key验证成功', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), + zulipEmail: zulipAccount.zulipEmail, + }); + return true; + } + + // 4. API Key无效,需要重新生成 + this.logger.warn('用户Zulip API Key无效,需要重新生成', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), + zulipEmail: zulipAccount.zulipEmail, + validationError: validationResult.error, }); + return false; // 需要重新生成 + } catch (error) { const err = error as Error; - this.logger.error('Zulip管理员客户端初始化失败', { - operation: 'initializeZulipAdminClient', + const duration = Date.now() - startTime; + + this.logger.error('验证用户Zulip API Key失败', { + operation: 'validateAndUpdateZulipApiKey', + userId: user.id.toString(), error: err.message, + duration, }, err.stack); - throw error; + + return false; } } /** - * 为用户创建Zulip账号 + * 重新生成并更新用户的Zulip API Key * * 功能描述: - * 为新注册的游戏用户创建对应的Zulip账号并建立关联 + * 使用用户密码重新生成Zulip API Key并更新存储 * - * 业务逻辑: - * 1. 使用相同的邮箱和密码创建Zulip账号 - * 2. 加密存储API Key - * 3. 在数据库中建立关联关系 - * 4. 处理创建失败的情况 - * - * @param gameUser 游戏用户信息 + * @param user 用户信息 * @param password 用户密码(明文) - * @throws Error 当Zulip账号创建失败时 + * @returns Promise 是否更新成功 * @private */ - private async createZulipAccountForUser(gameUser: Users, password: string): Promise { + private async regenerateZulipApiKey(user: Users, password: string): Promise { const startTime = Date.now(); - this.logger.log('开始为用户创建Zulip账号', { - operation: 'createZulipAccountForUser', - gameUserId: gameUser.id.toString(), - email: gameUser.email, - nickname: gameUser.nickname, + this.logger.log('开始重新生成用户Zulip API Key', { + operation: 'regenerateZulipApiKey', + userId: user.id.toString(), + email: user.email, }); try { - // 1. 检查是否已存在Zulip账号关联 - const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString()); - if (existingAccount) { - this.logger.warn('用户已存在Zulip账号关联,跳过创建', { - operation: 'createZulipAccountForUser', - gameUserId: gameUser.id.toString(), - existingZulipUserId: existingAccount.zulipUserId, + // 1. 查找用户的Zulip账号关联 + const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString()); + if (!zulipAccount) { + this.logger.warn('用户没有Zulip账号关联,无法重新生成API Key', { + operation: 'regenerateZulipApiKey', + userId: user.id.toString(), }); - return; + return false; } - // 2. 创建Zulip账号 - const createResult = await this.zulipAccountService.createZulipAccount({ - email: gameUser.email, - fullName: gameUser.nickname, - password: password, - }); + // 2. 重新生成API Key + const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser( + zulipAccount.zulipEmail, + password + ); - if (!createResult.success) { - throw new Error(createResult.error || 'Zulip账号创建失败'); + if (!apiKeyResult.success) { + this.logger.error('重新生成Zulip API Key失败', { + operation: 'regenerateZulipApiKey', + userId: user.id.toString(), + zulipEmail: zulipAccount.zulipEmail, + error: apiKeyResult.error, + }); + return false; } - // 3. 存储API Key - if (createResult.apiKey) { - await this.apiKeySecurityService.storeApiKey( - gameUser.id.toString(), - createResult.apiKey - ); - } + // 3. 更新Redis中的API Key + await this.apiKeySecurityService.storeApiKey( + user.id.toString(), + apiKeyResult.apiKey! + ); - // 4. 在数据库中创建关联记录 - await this.zulipAccountsService.create({ - gameUserId: gameUser.id.toString(), - 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 - ); - } + // 4. 更新内存关联 + await this.zulipAccountService.linkGameAccount( + user.id.toString(), + zulipAccount.zulipUserId, + zulipAccount.zulipEmail, + apiKeyResult.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, + this.logger.log('重新生成Zulip API Key成功', { + operation: 'regenerateZulipApiKey', + userId: user.id.toString(), + zulipEmail: zulipAccount.zulipEmail, duration, }); + return true; + } 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, + this.logger.error('重新生成Zulip API Key失败', { + operation: 'regenerateZulipApiKey', + userId: user.id.toString(), error: err.message, duration, }, err.stack); - // 清理可能创建的部分数据 - try { - await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString()); - } catch (cleanupError) { - this.logger.warn('清理Zulip账号关联数据失败', { - operation: 'createZulipAccountForUser', - gameUserId: gameUser.id.toString(), - cleanupError: (cleanupError as Error).message, - }); - } - - throw error; + return false; } } } \ No newline at end of file diff --git a/src/business/auth/login.service.zulip_account.spec.ts b/src/business/auth/login.service.zulip_account.spec.ts deleted file mode 100644 index cc2636b..0000000 --- a/src/business/auth/login.service.zulip_account.spec.ts +++ /dev/null @@ -1,573 +0,0 @@ -/** - * LoginService Zulip账号创建属性测试 - * - * 功能描述: - * - 测试用户注册时Zulip账号创建的一致性 - * - 验证账号关联和数据完整性 - * - 测试失败回滚机制 - * - * 属性测试: - * - 属性 13: Zulip账号创建一致性 - * - 验证需求: 账号创建成功率和数据一致性 - * - * 最近修改: - * - 2026-01-08: 文件重命名 - 修正kebab-case为snake_case命名规范 (修改者: moyin) - * - * @author angjustinl - * @version 1.0.1 - * @since 2025-01-05 - * @lastModified 2026-01-08 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -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'; -import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; -import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; -import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; -import { Users } from '../../core/db/users/users.entity'; - -describe('LoginService - Zulip账号创建属性测试', () => { - let loginService: LoginService; - let loginCoreService: jest.Mocked; - let zulipAccountService: jest.Mocked; - let zulipAccountsService: 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(), - generateTokenPair: jest.fn(), - }; - - const mockZulipAccountService = { - initializeAdminClient: jest.fn(), - createZulipAccount: jest.fn(), - linkGameAccount: jest.fn(), - }; - - const mockZulipAccountsService = { - 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: 'ZulipAccountsService', - useValue: mockZulipAccountsService, - }, - { - 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(); - - loginService = module.get(LoginService); - loginCoreService = module.get(LoginCoreService); - zulipAccountService = module.get(ZulipAccountService); - zulipAccountsService = module.get('ZulipAccountsService'); - apiKeySecurityService = module.get(ApiKeySecurityService); - - // 设置默认的mock返回值 - const mockTokenPair = { - access_token: 'mock_access_token', - refresh_token: 'mock_refresh_token', - expires_in: 604800, - token_type: 'Bearer' - }; - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - - // 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'; - 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 = { - id: mockGameUser.id.toString(), - gameUserId: mockGameUser.id.toString(), - zulipUserId: mockZulipResult.userId, - zulipEmail: mockZulipResult.email, - zulipFullName: registerRequest.nickname, - zulipApiKeyEncrypted: 'encrypted_' + mockZulipResult.apiKey, - status: 'active' as const, - retryCount: 0, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // 设置模拟行为 - loginCoreService.register.mockResolvedValue({ - user: mockGameUser, - isNewUser: true, - }); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); - apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); - zulipAccountsService.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(loginService['initializeZulipAdminClient']).toHaveBeenCalled(); - - // 验证游戏用户注册 - 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(zulipAccountsService.create).toHaveBeenCalledWith({ - gameUserId: mockGameUser.id.toString(), - 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账号创建失败 - loginCoreService.register.mockResolvedValue({ - user: mockGameUser, - isNewUser: true, - }); - zulipAccountsService.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(zulipAccountsService.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 = { - id: Math.floor(Math.random() * 1000000).toString(), - gameUserId: mockGameUser.id.toString(), - zulipUserId: 12345, - zulipEmail: registerRequest.email, - zulipFullName: registerRequest.nickname, - zulipApiKeyEncrypted: 'existing_encrypted_key', - status: 'active' as const, - retryCount: 0, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // 设置模拟行为 - 已存在Zulip账号关联 - loginCoreService.register.mockResolvedValue({ - user: mockGameUser, - isNewUser: true, - }); - zulipAccountsService.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(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith(mockGameUser.id.toString()); - - // 验证没有尝试创建新的Zulip账号 - expect(zulipAccountService.createZulipAccount).not.toHaveBeenCalled(); - expect(zulipAccountsService.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; - - // 设置模拟行为 - 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(zulipAccountsService.create).not.toHaveBeenCalled(); - } - ), - { numRuns: 50 } - ); - }); - - it('应该正确处理Zulip管理员客户端初始化失败', async () => { - await fc.assert( - fc.asyncProperty(registerRequestArb, async (registerRequest) => { - // 设置模拟行为 - 管理员客户端初始化失败 - jest.spyOn(loginService as any, 'initializeZulipAdminClient') - .mockRejectedValue(new Error('Zulip管理员客户端初始化失败')); - - // 执行注册 - 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(); - - // 恢复 mock - jest.spyOn(loginService as any, 'initializeZulipAdminClient').mockResolvedValue(undefined); - }), - { 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; - - // 重新设置 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); - - // 验证结果 - 注册应该失败 - expect(result.success).toBe(false); - expect(result.message).toContain('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 } - ); - }); - }); - - /** - * 数据一致性验证测试 - * - * 验证游戏账号和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), - }; - - // 设置模拟行为 - loginCoreService.register.mockResolvedValue({ - user: mockGameUser, - isNewUser: true, - }); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipResult); - apiKeySecurityService.storeApiKey.mockResolvedValue(undefined); - zulipAccountsService.create.mockResolvedValue({} as any); - zulipAccountService.linkGameAccount.mockResolvedValue(true); - - // 执行注册 - await loginService.register(registerRequest); - - // 验证Zulip账号创建时使用了正确的数据 - expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ - email: registerRequest.email, // 相同的邮箱 - fullName: registerRequest.nickname, // 相同的昵称 - password: registerRequest.password, // 相同的密码 - }); - - // 验证账号关联存储了正确的数据 - expect(zulipAccountsService.create).toHaveBeenCalledWith( - expect.objectContaining({ - gameUserId: mockGameUser.id.toString(), - 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/auth/login.service.zulip_integration.spec.ts b/src/business/auth/login.service.zulip_integration.spec.ts deleted file mode 100644 index 1fda8e0..0000000 --- a/src/business/auth/login.service.zulip_integration.spec.ts +++ /dev/null @@ -1,443 +0,0 @@ -/** - * 登录服务Zulip集成测试 - * - * 功能描述: - * - 测试用户注册时的Zulip账号创建/绑定逻辑 - * - 测试用户登录时的Zulip集成处理 - * - 验证API Key的获取和存储机制 - * - 测试各种异常情况的处理 - * - * 测试场景: - * - 注册时Zulip中没有用户:创建新账号 - * - 注册时Zulip中已有用户:绑定已有账号 - * - 登录时没有Zulip关联:尝试创建/绑定 - * - 登录时已有Zulip关联:刷新API Key - * - 各种错误情况的处理和回滚 - * - * @author moyin - * @version 1.0.0 - * @since 2026-01-10 - * @lastModified 2026-01-10 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { Logger } from '@nestjs/common'; -import { LoginService } from './login.service'; -import { LoginCoreService } from '../../core/login_core/login_core.service'; -import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; -import { ZulipAccountsService } from '../../core/db/zulip_accounts/zulip_accounts.service'; -import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; -import { Users } from '../../core/db/users/users.entity'; -import { ZulipAccountResponseDto } from '../../core/db/zulip_accounts/zulip_accounts.dto'; - -describe('LoginService - Zulip Integration', () => { - let service: LoginService; - let loginCoreService: jest.Mocked; - let zulipAccountService: jest.Mocked; - let zulipAccountsService: jest.Mocked; - let apiKeySecurityService: jest.Mocked; - - const mockUser: Users = { - id: BigInt(12345), - username: 'testuser', - nickname: '测试用户', - email: 'test@example.com', - email_verified: false, - phone: null, - password_hash: 'hashedpassword', - github_id: null, - avatar_url: null, - role: 1, - status: 'active', - created_at: new Date(), - updated_at: new Date(), - } as Users; - - beforeEach(async () => { - const mockLoginCoreService = { - register: jest.fn(), - login: jest.fn(), - generateTokenPair: jest.fn(), - }; - - const mockZulipAccountService = { - createZulipAccount: jest.fn(), - initializeAdminClient: jest.fn(), - }; - - const mockZulipAccountsService = { - findByGameUserId: jest.fn(), - create: jest.fn(), - updateByGameUserId: jest.fn(), - }; - - const mockApiKeySecurityService = { - storeApiKey: jest.fn(), - getApiKey: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - LoginService, - { - provide: LoginCoreService, - useValue: mockLoginCoreService, - }, - { - provide: ZulipAccountService, - useValue: mockZulipAccountService, - }, - { - provide: 'ZulipAccountsService', - useValue: mockZulipAccountsService, - }, - { - provide: ApiKeySecurityService, - useValue: mockApiKeySecurityService, - }, - ], - }).compile(); - - service = module.get(LoginService); - loginCoreService = module.get(LoginCoreService); - zulipAccountService = module.get(ZulipAccountService); - zulipAccountsService = module.get('ZulipAccountsService'); - apiKeySecurityService = module.get(ApiKeySecurityService); - - // 模拟Logger以避免日志输出 - jest.spyOn(Logger.prototype, 'log').mockImplementation(); - jest.spyOn(Logger.prototype, 'error').mockImplementation(); - jest.spyOn(Logger.prototype, 'warn').mockImplementation(); - jest.spyOn(Logger.prototype, 'debug').mockImplementation(); - }); - - describe('用户注册时的Zulip集成', () => { - it('应该在Zulip中不存在用户时创建新账号', async () => { - // 准备测试数据 - const registerRequest = { - username: 'testuser', - password: 'password123', - nickname: '测试用户', - email: 'test@example.com', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: true, - }; - - const mockTokenPair = { - access_token: 'access_token', - refresh_token: 'refresh_token', - expires_in: 3600, - token_type: 'Bearer', - }; - - const mockZulipCreateResult = { - success: true, - userId: 67890, - email: 'test@example.com', - apiKey: 'test_api_key_12345678901234567890', - isExistingUser: false, - }; - - // 设置模拟返回值 - loginCoreService.register.mockResolvedValue(mockAuthResult); - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipCreateResult); - apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' }); - zulipAccountsService.create.mockResolvedValue({} as any); - - // 模拟私有方法 - const checkZulipUserExistsSpy = jest.spyOn(service as any, 'checkZulipUserExists') - .mockResolvedValue({ exists: false }); - jest.spyOn(service as any, 'initializeZulipAdminClient') - .mockResolvedValue(true); - - // 执行测试 - const result = await service.register(registerRequest); - - // 验证结果 - expect(result.success).toBe(true); - expect(result.data?.is_new_user).toBe(true); - expect(result.data?.message).toContain('Zulip'); - - // 验证调用 - expect(loginCoreService.register).toHaveBeenCalledWith(registerRequest); - expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345'); - expect(checkZulipUserExistsSpy).toHaveBeenCalledWith('test@example.com'); - expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ - email: 'test@example.com', - fullName: '测试用户', - password: 'password123', - }); - expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'test_api_key_12345678901234567890'); - expect(zulipAccountsService.create).toHaveBeenCalledWith({ - gameUserId: '12345', - zulipUserId: 67890, - zulipEmail: 'test@example.com', - zulipFullName: '测试用户', - zulipApiKeyEncrypted: 'stored_in_redis', - status: 'active', - lastVerifiedAt: expect.any(Date), - }); - }); - - it('应该在Zulip中已存在用户时绑定账号', async () => { - // 准备测试数据 - const registerRequest = { - username: 'testuser', - password: 'password123', - nickname: '测试用户', - email: 'test@example.com', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: true, - }; - - const mockTokenPair = { - access_token: 'access_token', - refresh_token: 'refresh_token', - expires_in: 3600, - token_type: 'Bearer', - }; - - // 设置模拟返回值 - loginCoreService.register.mockResolvedValue(mockAuthResult); - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' }); - zulipAccountsService.create.mockResolvedValue({} as any); - - // 模拟私有方法 - jest.spyOn(service as any, 'checkZulipUserExists') - .mockResolvedValue({ exists: true, userId: 67890 }); - const refreshZulipApiKeySpy = jest.spyOn(service as any, 'refreshZulipApiKey') - .mockResolvedValue({ success: true, apiKey: 'existing_api_key_12345678901234567890' }); - jest.spyOn(service as any, 'initializeZulipAdminClient') - .mockResolvedValue(true); - - // 执行测试 - const result = await service.register(registerRequest); - - // 验证结果 - expect(result.success).toBe(true); - expect(result.data?.message).toContain('绑定'); - - // 验证调用 - expect(refreshZulipApiKeySpy).toHaveBeenCalledWith('test@example.com', 'password123'); - expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'existing_api_key_12345678901234567890'); - expect(zulipAccountsService.create).toHaveBeenCalledWith({ - gameUserId: '12345', - zulipUserId: 67890, - zulipEmail: 'test@example.com', - zulipFullName: '测试用户', - zulipApiKeyEncrypted: 'stored_in_redis', - status: 'active', - lastVerifiedAt: expect.any(Date), - }); - }); - }); - - describe('用户登录时的Zulip集成', () => { - it('应该在用户没有Zulip关联时尝试创建/绑定', async () => { - // 准备测试数据 - const loginRequest = { - identifier: 'testuser', - password: 'password123', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: false, - }; - - const mockTokenPair = { - access_token: 'access_token', - refresh_token: 'refresh_token', - expires_in: 3600, - token_type: 'Bearer', - }; - - const mockZulipCreateResult = { - success: true, - userId: 67890, - email: 'test@example.com', - apiKey: 'new_api_key_12345678901234567890', - isExistingUser: false, - }; - - // 设置模拟返回值 - loginCoreService.login.mockResolvedValue(mockAuthResult); - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - zulipAccountService.createZulipAccount.mockResolvedValue(mockZulipCreateResult); - apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' }); - zulipAccountsService.create.mockResolvedValue({} as any); - - // 模拟私有方法 - jest.spyOn(service as any, 'checkZulipUserExists') - .mockResolvedValue({ exists: false }); - jest.spyOn(service as any, 'initializeZulipAdminClient') - .mockResolvedValue(true); - - // 执行测试 - const result = await service.login(loginRequest); - - // 验证结果 - expect(result.success).toBe(true); - expect(result.data?.is_new_user).toBe(false); - - // 验证调用 - expect(loginCoreService.login).toHaveBeenCalledWith(loginRequest); - expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345'); - expect(zulipAccountService.createZulipAccount).toHaveBeenCalledWith({ - email: 'test@example.com', - fullName: '测试用户', - password: 'password123', - }); - }); - - it('应该在用户已有Zulip关联时刷新API Key', async () => { - // 准备测试数据 - const loginRequest = { - identifier: 'testuser', - password: 'password123', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: false, - }; - - const mockTokenPair = { - access_token: 'access_token', - refresh_token: 'refresh_token', - expires_in: 3600, - token_type: 'Bearer', - }; - - const mockExistingAccount: ZulipAccountResponseDto = { - id: '1', - gameUserId: '12345', - zulipUserId: 67890, - zulipEmail: 'test@example.com', - zulipFullName: '测试用户', - status: 'active' as const, - lastVerifiedAt: new Date().toISOString(), - retryCount: 0, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - - // 设置模拟返回值 - loginCoreService.login.mockResolvedValue(mockAuthResult); - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - zulipAccountsService.findByGameUserId.mockResolvedValue(mockExistingAccount); - apiKeySecurityService.storeApiKey.mockResolvedValue({ success: true, message: 'Stored' }); - zulipAccountsService.updateByGameUserId.mockResolvedValue({} as any); - - // 模拟私有方法 - const refreshZulipApiKeySpy = jest.spyOn(service as any, 'refreshZulipApiKey') - .mockResolvedValue({ success: true, apiKey: 'refreshed_api_key_12345678901234567890' }); - - // 执行测试 - const result = await service.login(loginRequest); - - // 验证结果 - expect(result.success).toBe(true); - - // 验证调用 - expect(zulipAccountsService.findByGameUserId).toHaveBeenCalledWith('12345'); - expect(refreshZulipApiKeySpy).toHaveBeenCalledWith('test@example.com', 'password123'); - expect(apiKeySecurityService.storeApiKey).toHaveBeenCalledWith('12345', 'refreshed_api_key_12345678901234567890'); - expect(zulipAccountsService.updateByGameUserId).toHaveBeenCalledWith('12345', { - lastVerifiedAt: expect.any(Date), - status: 'active', - errorMessage: null, - }); - }); - }); - - describe('错误处理', () => { - it('应该在Zulip创建失败时回滚用户注册', async () => { - // 准备测试数据 - const registerRequest = { - username: 'testuser', - password: 'password123', - nickname: '测试用户', - email: 'test@example.com', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: true, - }; - - // 设置模拟返回值 - loginCoreService.register.mockResolvedValue(mockAuthResult); - loginCoreService.deleteUser = jest.fn().mockResolvedValue(true); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - - // 模拟Zulip创建失败 - jest.spyOn(service as any, 'checkZulipUserExists') - .mockResolvedValue({ exists: false }); - jest.spyOn(service as any, 'initializeZulipAdminClient') - .mockResolvedValue(true); - - zulipAccountService.createZulipAccount.mockResolvedValue({ - success: false, - error: 'Zulip服务器错误', - }); - - // 执行测试 - const result = await service.register(registerRequest); - - // 验证结果 - expect(result.success).toBe(false); - expect(result.message).toContain('Zulip账号创建失败'); - - // 验证回滚调用 - expect(loginCoreService.deleteUser).toHaveBeenCalledWith(mockUser.id); - }); - - it('应该在登录时Zulip集成失败但不影响登录', async () => { - // 准备测试数据 - const loginRequest = { - identifier: 'testuser', - password: 'password123', - }; - - const mockAuthResult = { - user: mockUser, - isNewUser: false, - }; - - const mockTokenPair = { - access_token: 'access_token', - refresh_token: 'refresh_token', - expires_in: 3600, - token_type: 'Bearer', - }; - - // 设置模拟返回值 - loginCoreService.login.mockResolvedValue(mockAuthResult); - loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); - zulipAccountsService.findByGameUserId.mockResolvedValue(null); - - // 模拟Zulip集成失败 - jest.spyOn(service as any, 'initializeZulipAdminClient') - .mockRejectedValue(new Error('Zulip服务器不可用')); - - // 执行测试 - const result = await service.login(loginRequest); - - // 验证结果 - 登录应该成功,即使Zulip集成失败 - expect(result.success).toBe(true); - expect(result.data?.access_token).toBe('access_token'); - }); - }); -}); \ No newline at end of file diff --git a/src/business/auth/register.controller.spec.ts b/src/business/auth/register.controller.spec.ts new file mode 100644 index 0000000..6702daf --- /dev/null +++ b/src/business/auth/register.controller.spec.ts @@ -0,0 +1,230 @@ +/** + * RegisterController 单元测试 + * + * 功能描述: + * - 测试注册控制器的HTTP请求处理 + * - 验证API响应格式和状态码 + * - 测试邮箱验证流程 + * + * 最近修改: + * - 2026-01-12: 代码规范优化 - 创建缺失的控制器测试文件 (修改者: moyin) + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { Response } from 'express'; +import { HttpStatus } from '@nestjs/common'; +import { RegisterController } from './register.controller'; +import { RegisterService } from './register.service'; + +describe('RegisterController', () => { + let controller: RegisterController; + let registerService: jest.Mocked; + let mockResponse: jest.Mocked; + + beforeEach(async () => { + const mockRegisterService = { + register: jest.fn(), + sendEmailVerification: jest.fn(), + verifyEmailCode: jest.fn(), + resendEmailVerification: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [RegisterController], + providers: [ + { + provide: RegisterService, + useValue: mockRegisterService, + }, + ], + }).compile(); + + controller = module.get(RegisterController); + registerService = module.get(RegisterService); + + // Mock Response object + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + } as any; + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('register', () => { + it('should handle successful registration', async () => { + const registerDto = { + username: 'newuser', + password: 'password123', + nickname: '新用户', + email: 'newuser@example.com', + email_verification_code: '123456' + }; + + const mockResult = { + success: true, + data: { + user: { + id: '1', + username: 'newuser', + nickname: '新用户', + role: 1, + created_at: new Date() + }, + access_token: 'token', + refresh_token: 'refresh_token', + expires_in: 3600, + token_type: 'Bearer', + is_new_user: true, + message: '注册成功' + }, + message: '注册成功' + }; + + registerService.register.mockResolvedValue(mockResult); + + await controller.register(registerDto, mockResponse); + + expect(registerService.register).toHaveBeenCalledWith(registerDto); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CREATED); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + + it('should handle registration failure', async () => { + const registerDto = { + username: 'existinguser', + password: 'password123', + nickname: '用户' + }; + + const mockResult = { + success: false, + message: '用户名已存在', + error_code: 'REGISTER_FAILED' + }; + + registerService.register.mockResolvedValue(mockResult); + + await controller.register(registerDto, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('sendEmailVerification', () => { + it('should handle email verification in production mode', async () => { + const sendEmailDto = { + email: 'test@example.com' + }; + + const mockResult = { + success: true, + data: { is_test_mode: false }, + message: '验证码已发送,请查收邮件' + }; + + registerService.sendEmailVerification.mockResolvedValue(mockResult); + + await controller.sendEmailVerification(sendEmailDto, mockResponse); + + expect(registerService.sendEmailVerification).toHaveBeenCalledWith('test@example.com'); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + + it('should handle email verification in test mode', async () => { + const sendEmailDto = { + email: 'test@example.com' + }; + + const mockResult = { + success: false, + data: { + verification_code: '123456', + is_test_mode: true + }, + message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + error_code: 'TEST_MODE_ONLY' + }; + + registerService.sendEmailVerification.mockResolvedValue(mockResult); + + await controller.sendEmailVerification(sendEmailDto, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.PARTIAL_CONTENT); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('verifyEmail', () => { + it('should handle email verification successfully', async () => { + const verifyEmailDto = { + email: 'test@example.com', + verification_code: '123456' + }; + + const mockResult = { + success: true, + message: '邮箱验证成功' + }; + + registerService.verifyEmailCode.mockResolvedValue(mockResult); + + await controller.verifyEmail(verifyEmailDto, mockResponse); + + expect(registerService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456'); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + + it('should handle invalid verification code', async () => { + const verifyEmailDto = { + email: 'test@example.com', + verification_code: '000000' + }; + + const mockResult = { + success: false, + message: '验证码错误', + error_code: 'INVALID_VERIFICATION_CODE' + }; + + registerService.verifyEmailCode.mockResolvedValue(mockResult); + + await controller.verifyEmail(verifyEmailDto, mockResponse); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); + + describe('resendEmailVerification', () => { + it('should handle resend email verification successfully', async () => { + const sendEmailDto = { + email: 'test@example.com' + }; + + const mockResult = { + success: true, + data: { is_test_mode: false }, + message: '验证码已重新发送,请查收邮件' + }; + + registerService.resendEmailVerification.mockResolvedValue(mockResult); + + await controller.resendEmailVerification(sendEmailDto, mockResponse); + + expect(registerService.resendEmailVerification).toHaveBeenCalledWith('test@example.com'); + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK); + expect(mockResponse.json).toHaveBeenCalledWith(mockResult); + }); + }); +}); \ No newline at end of file diff --git a/src/business/auth/register.controller.ts b/src/business/auth/register.controller.ts new file mode 100644 index 0000000..60759a1 --- /dev/null +++ b/src/business/auth/register.controller.ts @@ -0,0 +1,269 @@ +/** + * 注册控制器 + * + * 功能描述: + * - 处理用户注册相关的HTTP请求和响应 + * - 提供RESTful API接口 + * - 数据验证和格式化 + * - 邮箱验证功能 + * + * 职责分离: + * - 专注于HTTP请求处理和响应格式化 + * - 调用注册业务服务完成具体功能 + * - 处理API文档和参数验证 + * + * API端点: + * - POST /auth/register - 用户注册 + * - POST /auth/send-email-verification - 发送邮箱验证码 + * - POST /auth/verify-email - 验证邮箱验证码 + * - POST /auth/resend-email-verification - 重新发送邮箱验证码 + * + * 最近修改: + * - 2026-01-12: 代码分离 - 从login.controller.ts中分离注册相关功能 + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Controller, Post, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger, Res } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; +import { Response } from 'express'; +import { RegisterService, ApiResponse, RegisterResponse } from './register.service'; +import { RegisterDto, EmailVerificationDto, SendEmailVerificationDto } from './login.dto'; +import { + RegisterResponseDto, + CommonResponseDto, + TestModeEmailVerificationResponseDto, + SuccessEmailVerificationResponseDto +} from './login_response.dto'; +import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator'; +import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decorator'; + +// 错误代码到HTTP状态码的映射 +const ERROR_STATUS_MAP = { + REGISTER_FAILED: HttpStatus.BAD_REQUEST, + TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT, + SEND_EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST, + EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST, + RESEND_EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST, + INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST, +} as const; + +@ApiTags('auth') +@Controller('auth') +export class RegisterController { + private readonly logger = new Logger(RegisterController.name); + + constructor(private readonly registerService: RegisterService) {} + + /** + * 通用响应处理方法 + * + * 业务逻辑: + * 1. 根据业务结果设置HTTP状态码 + * 2. 处理不同类型的错误响应 + * 3. 统一响应格式和错误处理 + * + * @param result 业务服务返回的结果 + * @param res Express响应对象 + * @param successStatus 成功时的HTTP状态码,默认为200 + * @private + */ + private handleResponse(result: any, res: Response, successStatus: HttpStatus = HttpStatus.OK): void { + if (result.success) { + res.status(successStatus).json(result); + return; + } + + // 根据错误代码获取状态码 + const statusCode = this.getErrorStatusCode(result); + res.status(statusCode).json(result); + } + + /** + * 根据错误代码和消息获取HTTP状态码 + * + * @param result 业务服务返回的结果 + * @returns HTTP状态码 + * @private + */ + private getErrorStatusCode(result: any): HttpStatus { + // 优先使用错误代码映射 + if (result.error_code && ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP]) { + return ERROR_STATUS_MAP[result.error_code as keyof typeof ERROR_STATUS_MAP]; + } + + // 根据消息内容判断 + if (result.message?.includes('已存在') || result.message?.includes('已被注册')) { + return HttpStatus.CONFLICT; + } + + if (result.message?.includes('用户不存在')) { + return HttpStatus.NOT_FOUND; + } + + // 默认返回400 + return HttpStatus.BAD_REQUEST; + } + + /** + * 用户注册 + * + * @param registerDto 注册数据 + * @returns 注册结果 + */ + @ApiOperation({ + summary: '用户注册', + description: '创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码,然后在注册时提供验证码进行验证。' + }) + @ApiBody({ type: RegisterDto }) + @SwaggerApiResponse({ + status: 201, + description: '注册成功', + type: RegisterResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '请求参数错误' + }) + @SwaggerApiResponse({ + status: 409, + description: '用户名或邮箱已存在' + }) + @SwaggerApiResponse({ + status: 429, + description: '注册请求过于频繁' + }) + @Throttle(ThrottlePresets.REGISTER) + @Timeout(TimeoutPresets.NORMAL) + @Post('register') + @UsePipes(new ValidationPipe({ transform: true })) + async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise { + const result = await this.registerService.register({ + username: registerDto.username, + password: registerDto.password, + nickname: registerDto.nickname, + email: registerDto.email, + phone: registerDto.phone, + email_verification_code: registerDto.email_verification_code + }); + + this.handleResponse(result, res, HttpStatus.CREATED); + } + + /** + * 发送邮箱验证码 + * + * @param sendEmailVerificationDto 发送验证码数据 + * @param res Express响应对象 + * @returns 发送结果 + */ + @ApiOperation({ + summary: '发送邮箱验证码', + description: '向指定邮箱发送验证码' + }) + @ApiBody({ type: SendEmailVerificationDto }) + @SwaggerApiResponse({ + status: 200, + description: '验证码发送成功(真实发送模式)', + type: SuccessEmailVerificationResponseDto + }) + @SwaggerApiResponse({ + status: 206, + description: '测试模式:验证码已生成但未真实发送', + type: TestModeEmailVerificationResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '请求参数错误' + }) + @SwaggerApiResponse({ + status: 429, + description: '发送频率过高' + }) + @Throttle(ThrottlePresets.SEND_CODE_PER_EMAIL) + @Timeout(TimeoutPresets.EMAIL_SEND) + @Post('send-email-verification') + @UsePipes(new ValidationPipe({ transform: true })) + async sendEmailVerification( + @Body() sendEmailVerificationDto: SendEmailVerificationDto, + @Res() res: Response + ): Promise { + const result = await this.registerService.sendEmailVerification(sendEmailVerificationDto.email); + this.handleResponse(result, res); + } + + /** + * 验证邮箱验证码 + * + * @param emailVerificationDto 邮箱验证数据 + * @returns 验证结果 + */ + @ApiOperation({ + summary: '验证邮箱验证码', + description: '使用验证码验证邮箱' + }) + @ApiBody({ type: EmailVerificationDto }) + @SwaggerApiResponse({ + status: 200, + description: '邮箱验证成功', + type: CommonResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '验证码错误或已过期' + }) + @Post('verify-email') + @UsePipes(new ValidationPipe({ transform: true })) + async verifyEmail(@Body() emailVerificationDto: EmailVerificationDto, @Res() res: Response): Promise { + const result = await this.registerService.verifyEmailCode( + emailVerificationDto.email, + emailVerificationDto.verification_code + ); + + this.handleResponse(result, res); + } + + /** + * 重新发送邮箱验证码 + * + * @param sendEmailVerificationDto 发送验证码数据 + * @param res Express响应对象 + * @returns 发送结果 + */ + @ApiOperation({ + summary: '重新发送邮箱验证码', + description: '重新向指定邮箱发送验证码' + }) + @ApiBody({ type: SendEmailVerificationDto }) + @SwaggerApiResponse({ + status: 200, + description: '验证码重新发送成功', + type: SuccessEmailVerificationResponseDto + }) + @SwaggerApiResponse({ + status: 206, + description: '测试模式:验证码已生成但未真实发送', + type: TestModeEmailVerificationResponseDto + }) + @SwaggerApiResponse({ + status: 400, + description: '邮箱已验证或用户不存在' + }) + @SwaggerApiResponse({ + status: 429, + description: '发送频率过高' + }) + @Throttle(ThrottlePresets.SEND_CODE) + @Post('resend-email-verification') + @UsePipes(new ValidationPipe({ transform: true })) + async resendEmailVerification( + @Body() sendEmailVerificationDto: SendEmailVerificationDto, + @Res() res: Response + ): Promise { + const result = await this.registerService.resendEmailVerification(sendEmailVerificationDto.email); + this.handleResponse(result, res); + } +} \ No newline at end of file diff --git a/src/business/auth/register.service.spec.ts b/src/business/auth/register.service.spec.ts new file mode 100644 index 0000000..9124237 --- /dev/null +++ b/src/business/auth/register.service.spec.ts @@ -0,0 +1,223 @@ +/** + * RegisterService 单元测试 + * + * 功能描述: + * - 测试用户注册相关的业务逻辑 + * - 验证邮箱验证功能 + * - 测试Zulip账号集成 + * + * 最近修改: + * - 2026-01-12: 代码分离 - 从login.service.spec.ts中分离注册相关测试 + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { RegisterService } from './register.service'; +import { LoginCoreService } from '../../core/login_core/login_core.service'; +import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; +import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; + +describe('RegisterService', () => { + let service: RegisterService; + let loginCoreService: jest.Mocked; + let zulipAccountService: jest.Mocked; + let apiKeySecurityService: jest.Mocked; + + const mockUser = { + id: BigInt(1), + username: 'testuser', + nickname: 'Test User', + email: 'test@example.com', + phone: null, + avatar_url: null, + role: 1, + created_at: new Date(), + updated_at: new Date(), + password_hash: 'hashed_password', + github_id: null, + is_active: true, + last_login_at: null, + email_verified: false, + phone_verified: false, + }; + + beforeEach(async () => { + const mockLoginCoreService = { + register: jest.fn(), + sendEmailVerification: jest.fn(), + verifyEmailCode: jest.fn(), + resendEmailVerification: jest.fn(), + deleteUser: jest.fn(), + generateTokenPair: jest.fn(), + }; + + const mockZulipAccountService = { + initializeAdminClient: jest.fn(), + createZulipAccount: jest.fn(), + linkGameAccount: jest.fn(), + }; + + const mockZulipAccountsService = { + findByGameUserId: jest.fn(), + create: jest.fn(), + deleteByGameUserId: jest.fn(), + }; + + const mockApiKeySecurityService = { + storeApiKey: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RegisterService, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + { + provide: ZulipAccountService, + useValue: mockZulipAccountService, + }, + { + provide: 'ZulipAccountsService', + useValue: mockZulipAccountsService, + }, + { + provide: ApiKeySecurityService, + useValue: mockApiKeySecurityService, + }, + ], + }).compile(); + + service = module.get(RegisterService); + loginCoreService = module.get(LoginCoreService); + zulipAccountService = module.get(ZulipAccountService); + apiKeySecurityService = module.get(ApiKeySecurityService); + + // 设置默认的mock返回值 + const mockTokenPair = { + access_token: 'mock_access_token', + refresh_token: 'mock_refresh_token', + expires_in: 3600, + token_type: 'Bearer', + }; + + loginCoreService.generateTokenPair.mockResolvedValue(mockTokenPair); + zulipAccountService.initializeAdminClient.mockResolvedValue(true); + zulipAccountService.createZulipAccount.mockResolvedValue({ + success: true, + userId: 123, + email: 'test@example.com', + apiKey: 'mock_api_key', + isExistingUser: false + }); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('register', () => { + it('should handle user registration successfully', async () => { + loginCoreService.register.mockResolvedValue({ + user: mockUser, + isNewUser: true + }); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: 'Test User', + email: 'test@example.com' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.is_new_user).toBe(true); + expect(loginCoreService.register).toHaveBeenCalled(); + }); + + it('should handle registration failure', async () => { + loginCoreService.register.mockRejectedValue(new Error('Registration failed')); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: 'Test User', + email: 'test@example.com' + }); + + expect(result.success).toBe(false); + expect(result.message).toContain('Registration failed'); + }); + }); + + describe('sendEmailVerification', () => { + 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); // Test mode returns false + expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(true); + expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com'); + }); + + it('should handle sendEmailVerification in production mode', async () => { + loginCoreService.sendEmailVerification.mockResolvedValue({ + code: '123456', + isTestMode: false + }); + + const result = await service.sendEmailVerification('test@example.com'); + + expect(result.success).toBe(true); + expect(result.data?.is_test_mode).toBe(false); + expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com'); + }); + }); + + describe('verifyEmailCode', () => { + 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('邮箱验证成功'); + expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456'); + }); + + it('should handle invalid verification code', async () => { + loginCoreService.verifyEmailCode.mockResolvedValue(false); + + const result = await service.verifyEmailCode('test@example.com', '123456'); + + expect(result.success).toBe(false); + expect(result.message).toBe('验证码错误'); + }); + }); + + describe('resendEmailVerification', () => { + it('should handle resendEmailVerification successfully', async () => { + loginCoreService.resendEmailVerification.mockResolvedValue({ + code: '654321', + isTestMode: false + }); + + const result = await service.resendEmailVerification('test@example.com'); + + expect(result.success).toBe(true); + expect(result.data?.is_test_mode).toBe(false); + expect(loginCoreService.resendEmailVerification).toHaveBeenCalledWith('test@example.com'); + }); + }); +}); \ No newline at end of file diff --git a/src/business/auth/register.service.ts b/src/business/auth/register.service.ts new file mode 100644 index 0000000..78fbf43 --- /dev/null +++ b/src/business/auth/register.service.ts @@ -0,0 +1,578 @@ +/** + * 注册业务服务 + * + * 功能描述: + * - 处理用户注册相关的业务逻辑和流程控制 + * - 整合核心服务,提供完整的注册功能 + * - 处理业务规则、数据格式化和错误处理 + * - 集成Zulip账号创建和关联 + * + * 职责分离: + * - 专注于注册业务流程和规则实现 + * - 调用核心服务完成具体功能 + * - 为控制器层提供注册业务接口 + * - 处理注册相关的邮箱验证和Zulip集成 + * + * 最近修改: + * - 2026-01-12: 代码分离 - 从login.service.ts中分离注册相关业务逻辑 + * + * @author moyin + * @version 1.0.0 + * @since 2026-01-12 + * @lastModified 2026-01-12 + */ + +import { Injectable, Logger, Inject } from '@nestjs/common'; +import { LoginCoreService, RegisterRequest, TokenPair } from '../../core/login_core/login_core.service'; +import { Users } from '../../core/db/users/users.entity'; +import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; +import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; + +// Import the interface types we need +interface IZulipAccountsService { + findByGameUserId(gameUserId: string, includeGameUser?: boolean): Promise; + create(createDto: any): Promise; + deleteByGameUserId(gameUserId: string): Promise; +} + +// 常量定义 +const ERROR_CODES = { + REGISTER_FAILED: 'REGISTER_FAILED', + SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED', + EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED', + RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED', + TEST_MODE_ONLY: 'TEST_MODE_ONLY', + INVALID_VERIFICATION_CODE: 'INVALID_VERIFICATION_CODE', +} as const; + +const MESSAGES = { + REGISTER_SUCCESS: '注册成功', + REGISTER_SUCCESS_WITH_ZULIP: '注册成功,Zulip账号已同步创建', + EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功', + CODE_SENT: '验证码已发送,请查收', + EMAIL_CODE_SENT: '验证码已发送,请查收邮件', + EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件', + VERIFICATION_CODE_ERROR: '验证码错误', + TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', +} as const; + +/** + * 注册响应数据接口 + */ +export interface RegisterResponse { + /** 用户信息 */ + user: { + id: string; + username: string; + nickname: string; + email?: string; + phone?: string; + avatar_url?: string; + role: number; + created_at: Date; + }; + /** 访问令牌 */ + access_token: string; + /** 刷新令牌 */ + refresh_token: string; + /** 访问令牌过期时间(秒) */ + expires_in: number; + /** 令牌类型 */ + token_type: string; + /** 是否为新用户 */ + is_new_user?: boolean; + /** 消息 */ + message: string; +} + +/** + * 通用响应接口 + */ +export interface ApiResponse { + /** 是否成功 */ + success: boolean; + /** 响应数据 */ + data?: T; + /** 消息 */ + message: string; + /** 错误代码 */ + error_code?: string; +} + +@Injectable() +export class RegisterService { + private readonly logger = new Logger(RegisterService.name); + + constructor( + private readonly loginCoreService: LoginCoreService, + private readonly zulipAccountService: ZulipAccountService, + @Inject('ZulipAccountsService') private readonly zulipAccountsService: IZulipAccountsService, + private readonly apiKeySecurityService: ApiKeySecurityService, + ) {} + + /** + * 用户注册 + * + * @param registerRequest 注册请求 + * @returns 注册响应 + */ + async register(registerRequest: RegisterRequest): Promise> { + const startTime = Date.now(); + const operationId = `register_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + + try { + this.logger.log(`开始用户注册流程`, { + operation: 'register', + operationId, + username: registerRequest.username, + email: registerRequest.email, + timestamp: new Date().toISOString(), + }); + + // 1. 初始化Zulip管理员客户端 + await this.initializeZulipAdminClient(); + + // 2. 调用核心服务进行注册 + const authResult = await this.loginCoreService.register(registerRequest); + + // 3. 创建Zulip账号(使用相同的邮箱和密码) + let zulipAccountCreated = false; + + if (registerRequest.email && registerRequest.password) { + try { + await this.createZulipAccountForUser(authResult.user, registerRequest.password); + zulipAccountCreated = true; + + this.logger.log(`Zulip账号创建成功`, { + operation: 'register', + operationId, + gameUserId: authResult.user.id.toString(), + email: registerRequest.email, + }); + } catch (zulipError) { + const err = zulipError as Error; + this.logger.error(`Zulip账号创建失败,开始回滚`, { + operation: 'register', + operationId, + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + zulipError: err.message, + }, err.stack); + + // 回滚游戏用户注册 + try { + await this.loginCoreService.deleteUser(authResult.user.id); + this.logger.log(`游戏用户注册回滚成功`, { + operation: 'register', + operationId, + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + }); + } catch (rollbackError) { + const rollbackErr = rollbackError as Error; + this.logger.error(`游戏用户注册回滚失败`, { + operation: 'register', + operationId, + username: registerRequest.username, + gameUserId: authResult.user.id.toString(), + rollbackError: rollbackErr.message, + }, rollbackErr.stack); + } + + // 抛出原始错误 + throw new Error(`注册失败:Zulip账号创建失败 - ${err.message}`); + } + } else { + this.logger.log(`跳过Zulip账号创建:缺少邮箱或密码`, { + operation: 'register', + username: registerRequest.username, + hasEmail: !!registerRequest.email, + hasPassword: !!registerRequest.password, + }); + } + + // 4. 生成JWT令牌对 + const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user); + + // 5. 格式化响应数据 + const response: RegisterResponse = { + user: this.formatUserInfo(authResult.user), + 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 ? MESSAGES.REGISTER_SUCCESS_WITH_ZULIP : MESSAGES.REGISTER_SUCCESS + }; + + const duration = Date.now() - startTime; + + this.logger.log(`用户注册成功`, { + operation: 'register', + operationId, + gameUserId: authResult.user.id.toString(), + username: authResult.user.username, + email: authResult.user.email, + zulipAccountCreated, + duration, + timestamp: new Date().toISOString(), + }); + + return { + success: true, + data: response, + message: response.message + }; + } catch (error) { + const duration = Date.now() - startTime; + const err = error as Error; + + this.logger.error(`用户注册失败`, { + operation: 'register', + operationId, + username: registerRequest.username, + email: registerRequest.email, + error: err.message, + duration, + timestamp: new Date().toISOString(), + }, err.stack); + + return { + success: false, + message: err.message || '注册失败', + error_code: ERROR_CODES.REGISTER_FAILED + }; + } + } + + /** + * 发送邮箱验证码 + * + * @param email 邮箱地址 + * @returns 响应结果 + */ + async sendEmailVerification(email: string): Promise> { + try { + this.logger.log(`发送邮箱验证码: ${email}`); + + // 调用核心服务发送验证码 + const result = await this.loginCoreService.sendEmailVerification(email); + + this.logger.log(`邮箱验证码已发送: ${email}`); + + return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_SENT); + } catch (error) { + this.logger.error(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); + + return { + success: false, + message: error instanceof Error ? error.message : '发送验证码失败', + error_code: ERROR_CODES.SEND_EMAIL_VERIFICATION_FAILED + }; + } + } + + /** + * 验证邮箱验证码 + * + * @param email 邮箱地址 + * @param code 验证码 + * @returns 响应结果 + */ + async verifyEmailCode(email: string, code: string): Promise { + try { + this.logger.log(`验证邮箱验证码: ${email}`); + + // 调用核心服务验证验证码 + const isValid = await this.loginCoreService.verifyEmailCode(email, code); + + if (isValid) { + this.logger.log(`邮箱验证成功: ${email}`); + return { + success: true, + message: MESSAGES.EMAIL_VERIFICATION_SUCCESS + }; + } else { + return { + success: false, + message: MESSAGES.VERIFICATION_CODE_ERROR, + error_code: ERROR_CODES.INVALID_VERIFICATION_CODE + }; + } + } catch (error) { + this.logger.error(`邮箱验证失败: ${email}`, error instanceof Error ? error.stack : String(error)); + + return { + success: false, + message: error instanceof Error ? error.message : '邮箱验证失败', + error_code: ERROR_CODES.EMAIL_VERIFICATION_FAILED + }; + } + } + + /** + * 重新发送邮箱验证码 + * + * @param email 邮箱地址 + * @returns 响应结果 + */ + async resendEmailVerification(email: string): Promise> { + try { + this.logger.log(`重新发送邮箱验证码: ${email}`); + + // 调用核心服务重新发送验证码 + const result = await this.loginCoreService.resendEmailVerification(email); + + this.logger.log(`邮箱验证码已重新发送: ${email}`); + + return this.handleTestModeResponse(result, MESSAGES.CODE_SENT, MESSAGES.EMAIL_CODE_RESENT); + } catch (error) { + this.logger.error(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error)); + + return { + success: false, + message: error instanceof Error ? error.message : '重新发送验证码失败', + error_code: ERROR_CODES.RESEND_EMAIL_VERIFICATION_FAILED + }; + } + } + + /** + * 格式化用户信息 + * + * @param user 用户实体 + * @returns 格式化的用户信息 + */ + private formatUserInfo(user: Users) { + return { + id: user.id.toString(), // 将bigint转换为字符串 + username: user.username, + nickname: user.nickname, + email: user.email, + phone: user.phone, + avatar_url: user.avatar_url, + role: user.role, + created_at: user.created_at + }; + } + + /** + * 处理测试模式响应 + * + * @param result 核心服务返回的结果 + * @param successMessage 成功时的消息 + * @param emailMessage 邮件发送成功时的消息 + * @returns 格式化的响应 + * @private + */ + private handleTestModeResponse( + result: { code: string; isTestMode: boolean }, + successMessage: string, + emailMessage?: string + ): ApiResponse<{ verification_code?: string; is_test_mode?: boolean }> { + if (result.isTestMode) { + return { + success: false, + data: { + verification_code: result.code, + is_test_mode: true + }, + message: MESSAGES.TEST_MODE_WARNING, + error_code: ERROR_CODES.TEST_MODE_ONLY + }; + } else { + return { + success: true, + data: { + is_test_mode: false + }, + message: emailMessage || successMessage + }; + } + } + + /** + * 初始化Zulip管理员客户端 + * + * 功能描述: + * 使用环境变量中的管理员凭证初始化Zulip客户端 + * + * 业务逻辑: + * 1. 从环境变量获取管理员配置 + * 2. 验证配置完整性 + * 3. 初始化ZulipAccountService的管理员客户端 + * + * @throws Error 当配置缺失或初始化失败时 + * @private + */ + private async initializeZulipAdminClient(): Promise { + try { + // 从环境变量获取管理员配置 + const adminConfig = { + realm: process.env.ZULIP_SERVER_URL || process.env.ZULIP_REALM || '', + username: process.env.ZULIP_BOT_EMAIL || process.env.ZULIP_ADMIN_EMAIL || '', + apiKey: process.env.ZULIP_BOT_API_KEY || process.env.ZULIP_ADMIN_API_KEY || '', + }; + + // 验证配置完整性 + if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) { + throw new Error('Zulip管理员配置不完整,请检查环境变量'); + } + + // 初始化管理员客户端 + const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); + + if (!initialized) { + throw new Error('Zulip管理员客户端初始化失败'); + } + + } 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. 尝试创建Zulip账号(如果已存在则自动绑定) + * 3. 获取或生成API Key并存储到Redis + * 4. 在数据库中创建关联记录 + * 5. 建立内存关联(用于当前会话) + * + * @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.zulipAccountsService.findByGameUserId(gameUser.id.toString()); + 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 + let finalApiKey = createResult.apiKey; + + // 如果是绑定已有账号但没有API Key,尝试重新获取 + if (createResult.isExistingUser && !finalApiKey) { + const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser( + createResult.email!, + password + ); + + if (apiKeyResult.success) { + finalApiKey = apiKeyResult.apiKey; + } else { + this.logger.warn('无法获取已有Zulip账号的API Key', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + zulipEmail: createResult.email, + error: apiKeyResult.error, + }); + } + } + + // 4. 存储API Key到Redis + if (finalApiKey) { + await this.apiKeySecurityService.storeApiKey( + gameUser.id.toString(), + finalApiKey + ); + } + + // 5. 在数据库中创建关联记录 + await this.zulipAccountsService.create({ + gameUserId: gameUser.id.toString(), + zulipUserId: createResult.userId!, + zulipEmail: createResult.email!, + zulipFullName: gameUser.nickname, + zulipApiKeyEncrypted: finalApiKey ? 'stored_in_redis' : '', + status: 'active', + }); + + // 6. 建立游戏账号与Zulip账号的内存关联(用于当前会话) + if (finalApiKey) { + await this.zulipAccountService.linkGameAccount( + gameUser.id.toString(), + createResult.userId!, + createResult.email!, + finalApiKey + ); + } + + const duration = Date.now() - startTime; + + this.logger.log('Zulip账号创建/绑定和关联成功', { + operation: 'createZulipAccountForUser', + gameUserId: gameUser.id.toString(), + zulipUserId: createResult.userId, + zulipEmail: createResult.email, + isExistingUser: createResult.isExistingUser, + hasApiKey: !!finalApiKey, + 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.zulipAccountsService.deleteByGameUserId(gameUser.id.toString()); + } 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