style(auth):优化auth模块代码规范和测试覆盖

范围:src/business/auth/
- 统一命名规范和注释格式
- 完善文件头部注释和修改记录
- 分离登录和注册业务逻辑到独立服务
- 添加缺失的测试文件(JWT守卫、控制器测试)
- 清理未使用的测试文件
- 优化代码结构和依赖关系
This commit is contained in:
moyin
2026-01-12 18:04:33 +08:00
parent 16ae78ed12
commit 267f1b2263
14 changed files with 1908 additions and 1739 deletions

View File

@@ -1,3 +1,26 @@
<!--
Auth 用户认证业务模块文档
功能描述:
- 提供完整的用户认证业务模块文档
- 详细说明登录、注册、密码管理等功能
- 包含API接口、组件说明和使用指南
- 提供架构设计和安全特性说明
职责分离:
- 专注于业务模块的功能文档编写
- 提供开发者参考和使用指南
- 说明模块的设计理念和最佳实践
最近修改:
- 2026-01-12: 代码规范优化 - 添加文档头注释,完善注释规范 (修改者: moyin)
@author moyin
@version 1.0.1
@since 2025-12-17
@lastModified 2026-01-12
-->
# Auth 用户认证业务模块 # Auth 用户认证业务模块
Auth 是应用的核心用户认证业务模块提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能支持邮箱验证、验证码登录、安全防护和Zulip账号同步具备完善的业务流程控制、错误处理和安全审计能力。 Auth 是应用的核心用户认证业务模块提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能支持邮箱验证、验证码登录、安全防护和Zulip账号同步具备完善的业务流程控制、错误处理和安全审计能力。

View File

@@ -26,6 +26,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { LoginController } from './login.controller'; import { LoginController } from './login.controller';
import { LoginService } from './login.service'; 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 { LoginCoreModule } from '../../core/login_core/login_core.module';
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module'; import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.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(), ZulipAccountsModule.forRoot(),
UsersModule, UsersModule,
], ],
controllers: [LoginController], controllers: [LoginController, RegisterController],
providers: [ providers: [
LoginService, LoginService,
RegisterService,
], ],
exports: [LoginService], exports: [LoginService, RegisterService],
}) })
export class AuthModule {} export class AuthModule {}

View File

@@ -28,9 +28,11 @@ export * from './auth.module';
// 控制器 // 控制器
export * from './login.controller'; export * from './login.controller';
export * from './register.controller';
// 服务 // 服务
export * from './login.service'; export * from './login.service';
export * from './register.service';
// DTO // DTO
export * from './login.dto'; export * from './login.dto';

View File

@@ -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<LoginCoreService>;
let mockExecutionContext: jest.Mocked<ExecutionContext>;
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>(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();
});
});
});

View File

@@ -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<LoginService>;
let mockResponse: jest.Mocked<Response>;
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>(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);
});
});
});

View File

@@ -34,15 +34,12 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { LoginService, ApiResponse, LoginResponse } from './login.service'; 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 { import {
LoginResponseDto, LoginResponseDto,
RegisterResponseDto,
GitHubOAuthResponseDto, GitHubOAuthResponseDto,
ForgotPasswordResponseDto, ForgotPasswordResponseDto,
CommonResponseDto, CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto,
RefreshTokenResponseDto RefreshTokenResponseDto
} from './login_response.dto'; } from './login_response.dto';
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator'; import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
@@ -51,14 +48,12 @@ import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decora
// 错误代码到HTTP状态码的映射 // 错误代码到HTTP状态码的映射
const ERROR_STATUS_MAP = { const ERROR_STATUS_MAP = {
LOGIN_FAILED: HttpStatus.UNAUTHORIZED, LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
REGISTER_FAILED: HttpStatus.BAD_REQUEST,
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT, TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED, TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED, GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST, SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST, RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST, CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST,
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED, VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST, INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
} as const; } as const;
@@ -169,51 +164,6 @@ export class LoginController {
this.handleResponse(result, res); 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<void> {
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登录 * GitHub OAuth登录
* *
@@ -378,120 +328,6 @@ export class LoginController {
this.handleResponse(result, res); 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<void> {
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<void> {
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<void> {
const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
this.handleResponse(result, res);
}
/** /**
* 验证码登录 * 验证码登录
* *

View File

@@ -60,18 +60,14 @@ describe('LoginService', () => {
const mockLoginCoreService = { const mockLoginCoreService = {
login: jest.fn(), login: jest.fn(),
register: jest.fn(),
githubOAuth: jest.fn(), githubOAuth: jest.fn(),
sendPasswordResetCode: jest.fn(), sendPasswordResetCode: jest.fn(),
resetPassword: jest.fn(), resetPassword: jest.fn(),
changePassword: jest.fn(), changePassword: jest.fn(),
sendEmailVerification: jest.fn(),
verifyEmailCode: jest.fn(),
resendEmailVerification: jest.fn(),
verificationCodeLogin: jest.fn(), verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: jest.fn(), sendLoginVerificationCode: jest.fn(),
debugVerificationCode: jest.fn(), debugVerificationCode: jest.fn(),
deleteUser: jest.fn(), refreshAccessToken: jest.fn(),
generateTokenPair: 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', () => { describe('githubOAuth', () => {
it('should handle GitHub OAuth successfully', async () => { it('should handle GitHub OAuth successfully', async () => {
loginCoreService.githubOAuth.mockResolvedValue({ 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', () => { describe('verificationCodeLogin', () => {
it('should handle verificationCodeLogin successfully', async () => { it('should handle verificationCodeLogin successfully', async () => {
loginCoreService.verificationCodeLogin.mockResolvedValue({ loginCoreService.verificationCodeLogin.mockResolvedValue({

View File

@@ -2,28 +2,30 @@
* 登录业务服务 * 登录业务服务
* *
* 功能描述: * 功能描述:
* - 处理登录相关的业务逻辑和流程控制 * - 处理用户登录相关的业务逻辑和流程控制
* - 整合核心服务,提供完整的业务功能 * - 整合核心服务,提供完整的登录功能
* - 处理业务规则、数据格式化和错误处理 * - 处理业务规则、数据格式化和错误处理
* - 管理JWT令牌刷新和验证码登录
* *
* 职责分离: * 职责分离:
* - 专注于业务流程和规则实现 * - 专注于登录业务流程和规则实现
* - 调用核心服务完成具体功能 * - 调用核心服务完成具体功能
* - 为控制器层提供业务接口 * - 为控制器层提供登录业务接口
* - JWT技术实现已移至Core层符合架构分层原则 * - JWT技术实现已移至Core层符合架构分层原则
* *
* 最近修改: * 最近修改:
* - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构 * - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块符合架构分层原则 * - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块符合架构分层原则
* *
* @author moyin * @author moyin
* @version 1.0.3 * @version 1.1.0
* @since 2025-12-17 * @since 2025-12-17
* @lastModified 2026-01-07 * @lastModified 2026-01-12
*/ */
import { Injectable, Logger, Inject } from '@nestjs/common'; 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 { Users } from '../../core/db/users/users.entity';
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service'; import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service'; import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
@@ -38,14 +40,10 @@ interface IZulipAccountsService {
// 常量定义 // 常量定义
const ERROR_CODES = { const ERROR_CODES = {
LOGIN_FAILED: 'LOGIN_FAILED', LOGIN_FAILED: 'LOGIN_FAILED',
REGISTER_FAILED: 'REGISTER_FAILED',
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED', GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
SEND_CODE_FAILED: 'SEND_CODE_FAILED', SEND_CODE_FAILED: 'SEND_CODE_FAILED',
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED', RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
CHANGE_PASSWORD_FAILED: 'CHANGE_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', VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED', SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED', TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
@@ -56,19 +54,14 @@ const ERROR_CODES = {
const MESSAGES = { const MESSAGES = {
LOGIN_SUCCESS: '登录成功', LOGIN_SUCCESS: '登录成功',
REGISTER_SUCCESS: '注册成功',
REGISTER_SUCCESS_WITH_ZULIP: '注册成功Zulip账号已同步创建',
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功', GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功', GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
PASSWORD_RESET_SUCCESS: '密码重置成功', PASSWORD_RESET_SUCCESS: '密码重置成功',
PASSWORD_CHANGE_SUCCESS: '密码修改成功', PASSWORD_CHANGE_SUCCESS: '密码修改成功',
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功', VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
TOKEN_REFRESH_SUCCESS: '令牌刷新成功', TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
DEBUG_INFO_SUCCESS: '调试信息获取成功', DEBUG_INFO_SUCCESS: '调试信息获取成功',
CODE_SENT: '验证码已发送,请查收', CODE_SENT: '验证码已发送,请查收',
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
VERIFICATION_CODE_ERROR: '验证码错误', VERIFICATION_CODE_ERROR: '验证码错误',
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
} as const; } as const;
@@ -161,10 +154,38 @@ export class LoginService {
// 1. 调用核心服务进行认证 // 1. 调用核心服务进行认证
const authResult = await this.loginCoreService.login(loginRequest); 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); const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
// 3. 格式化响应数据 // 4. 格式化响应数据
const response: LoginResponse = { const response: LoginResponse = {
user: this.formatUserInfo(authResult.user), user: this.formatUserInfo(authResult.user),
access_token: tokenPair.access_token, access_token: tokenPair.access_token,
@@ -211,235 +232,6 @@ export class LoginService {
} }
} }
/**
* 用户注册
*
* @param registerRequest 注册请求
* @returns 注册响应
*/
async register(registerRequest: RegisterRequest): Promise<ApiResponse<LoginResponse>> {
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登录 * GitHub OAuth登录
* *
@@ -500,7 +292,26 @@ export class LoginService {
this.logger.log(`密码重置验证码已发送: ${identifier}`); 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) { } catch (error) {
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(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<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
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<ApiResponse> {
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<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
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}`); 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) { } catch (error) {
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(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<any> { async debugVerificationCode(email: string): Promise<any> {
try { try {
this.logger.log(`调试验证码信息: ${email}`); this.logger.log(`调试验证码信息: ${email}`);
@@ -862,169 +572,179 @@ export class LoginService {
} }
/** /**
* 初始化Zulip管理员客户端 * 验证并更新用户的Zulip API Key
* *
* 功能描述: * 功能描述:
* 使用环境变量中的管理员凭证初始化Zulip客户端 * 在用户登录时验证其Zulip账号的API Key是否有效如果无效则重新获取
* *
* 业务逻辑: * 业务逻辑:
* 1. 从环境变量获取管理员配置 * 1. 查找用户的Zulip账号关联
* 2. 验证配置完整性 * 2. 从Redis获取API Key
* 3. 初始化ZulipAccountService的管理员客户端 * 3. 验证API Key是否有效
* 4. 如果无效重新生成API Key并更新存储
* *
* @throws Error 当配置缺失或初始化失败时 * @param user 用户信息
* @returns Promise<boolean> 是否验证/更新成功
* @private * @private
*/ */
private async initializeZulipAdminClient(): Promise<void> { private async validateAndUpdateZulipApiKey(user: Users): Promise<boolean> {
const startTime = Date.now();
this.logger.log('开始验证用户Zulip API Key', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
username: user.username,
email: user.email,
});
try { try {
// 从环境变量获取管理员配置 // 1. 查找用户的Zulip账号关联
const adminConfig = { const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
realm: process.env.ZULIP_SERVER_URL || '', if (!zulipAccount) {
username: process.env.ZULIP_BOT_EMAIL || '', this.logger.log('用户没有Zulip账号关联跳过验证', {
apiKey: process.env.ZULIP_BOT_API_KEY || '', operation: 'validateAndUpdateZulipApiKey',
}; userId: user.id.toString(),
});
// 验证配置完整性 return true; // 没有关联不算错误
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
throw new Error('Zulip管理员配置不完整请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
} }
// 初始化管理员客户端 // 2. 从Redis获取API Key
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig); 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,
});
if (!initialized) { return false; // 需要重新生成
throw new Error('Zulip管理员客户端初始化失败');
} }
this.logger.log('Zulip管理员客户端初始化成功', { // 3. 验证API Key是否有效
operation: 'initializeZulipAdminClient', const validationResult = await this.zulipAccountService.validateZulipAccount(
realm: adminConfig.realm, zulipAccount.zulipEmail,
adminEmail: adminConfig.username, 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) { } catch (error) {
const err = error as Error; const err = error as Error;
this.logger.error('Zulip管理员客户端初始化失败', { const duration = Date.now() - startTime;
operation: 'initializeZulipAdminClient',
this.logger.error('验证用户Zulip API Key失败', {
operation: 'validateAndUpdateZulipApiKey',
userId: user.id.toString(),
error: err.message, error: err.message,
duration,
}, err.stack); }, err.stack);
throw error;
return false;
} }
} }
/** /**
* 为用户创建Zulip账号 * 重新生成并更新用户的Zulip API Key
* *
* 功能描述: * 功能描述:
* 为新注册的游戏用户创建对应的Zulip账号并建立关联 * 使用用户密码重新生成Zulip API Key并更新存储
* *
* 业务逻辑: * @param user 用户信息
* 1. 使用相同的邮箱和密码创建Zulip账号
* 2. 加密存储API Key
* 3. 在数据库中建立关联关系
* 4. 处理创建失败的情况
*
* @param gameUser 游戏用户信息
* @param password 用户密码(明文) * @param password 用户密码(明文)
* @throws Error 当Zulip账号创建失败时 * @returns Promise<boolean> 是否更新成功
* @private * @private
*/ */
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> { private async regenerateZulipApiKey(user: Users, password: string): Promise<boolean> {
const startTime = Date.now(); const startTime = Date.now();
this.logger.log('开始为用户创建Zulip账号', { this.logger.log('开始重新生成用户Zulip API Key', {
operation: 'createZulipAccountForUser', operation: 'regenerateZulipApiKey',
gameUserId: gameUser.id.toString(), userId: user.id.toString(),
email: gameUser.email, email: user.email,
nickname: gameUser.nickname,
}); });
try { try {
// 1. 检查是否已存在Zulip账号关联 // 1. 查找用户的Zulip账号关联
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString()); const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
if (existingAccount) { if (!zulipAccount) {
this.logger.warn('用户已存在Zulip账号关联跳过创建', { this.logger.warn('用户没有Zulip账号关联无法重新生成API Key', {
operation: 'createZulipAccountForUser', operation: 'regenerateZulipApiKey',
gameUserId: gameUser.id.toString(), userId: user.id.toString(),
existingZulipUserId: existingAccount.zulipUserId,
}); });
return; return false;
} }
// 2. 创建Zulip账号 // 2. 重新生成API Key
const createResult = await this.zulipAccountService.createZulipAccount({ const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
email: gameUser.email, zulipAccount.zulipEmail,
fullName: gameUser.nickname, password
password: password, );
});
if (!createResult.success) { if (!apiKeyResult.success) {
throw new Error(createResult.error || 'Zulip账号创建失败'); this.logger.error('重新生成Zulip API Key失败', {
operation: 'regenerateZulipApiKey',
userId: user.id.toString(),
zulipEmail: zulipAccount.zulipEmail,
error: apiKeyResult.error,
});
return false;
} }
// 3. 存储API Key // 3. 更新Redis中的API Key
if (createResult.apiKey) { await this.apiKeySecurityService.storeApiKey(
await this.apiKeySecurityService.storeApiKey( user.id.toString(),
gameUser.id.toString(), apiKeyResult.apiKey!
createResult.apiKey );
);
}
// 4. 在数据库中创建关联记录 // 4. 更新内存关联
await this.zulipAccountsService.create({ await this.zulipAccountService.linkGameAccount(
gameUserId: gameUser.id.toString(), user.id.toString(),
zulipUserId: createResult.userId!, zulipAccount.zulipUserId,
zulipEmail: createResult.email!, zulipAccount.zulipEmail,
zulipFullName: gameUser.nickname, apiKeyResult.apiKey!
zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中 );
status: 'active',
});
// 5. 建立游戏账号与Zulip账号的内存关联用于当前会话
if (createResult.apiKey) {
await this.zulipAccountService.linkGameAccount(
gameUser.id.toString(),
createResult.userId!,
createResult.email!,
createResult.apiKey
);
}
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
this.logger.log('Zulip账号创建和关联成功', { this.logger.log('重新生成Zulip API Key成功', {
operation: 'createZulipAccountForUser', operation: 'regenerateZulipApiKey',
gameUserId: gameUser.id.toString(), userId: user.id.toString(),
zulipUserId: createResult.userId, zulipEmail: zulipAccount.zulipEmail,
zulipEmail: createResult.email,
hasApiKey: !!createResult.apiKey,
duration, duration,
}); });
return true;
} catch (error) { } catch (error) {
const err = error as Error; const err = error as Error;
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
this.logger.error('为用户创建Zulip账号失败', { this.logger.error('重新生成Zulip API Key失败', {
operation: 'createZulipAccountForUser', operation: 'regenerateZulipApiKey',
gameUserId: gameUser.id.toString(), userId: user.id.toString(),
email: gameUser.email,
error: err.message, error: err.message,
duration, duration,
}, err.stack); }, err.stack);
// 清理可能创建的部分数据 return false;
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;
} }
} }
} }

View File

@@ -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<LoginCoreService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
// 测试用的模拟数据生成器
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>(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 }
);
});
});
});

View File

@@ -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<LoginCoreService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let zulipAccountsService: jest.Mocked<ZulipAccountsService>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
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>(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');
});
});
});

View File

@@ -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<RegisterService>;
let mockResponse: jest.Mocked<Response>;
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>(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);
});
});
});

View File

@@ -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<void> {
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<void> {
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<void> {
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<void> {
const result = await this.registerService.resendEmailVerification(sendEmailVerificationDto.email);
this.handleResponse(result, res);
}
}

View File

@@ -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<LoginCoreService>;
let zulipAccountService: jest.Mocked<ZulipAccountService>;
let apiKeySecurityService: jest.Mocked<ApiKeySecurityService>;
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>(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');
});
});
});

View File

@@ -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<any>;
create(createDto: any): Promise<any>;
deleteByGameUserId(gameUserId: string): Promise<boolean>;
}
// 常量定义
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<T = any> {
/** 是否成功 */
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<ApiResponse<RegisterResponse>> {
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<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
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<ApiResponse> {
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<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
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<void> {
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<void> {
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;
}
}
}