feature/code-standard-merge-docs-20260112 #44
@@ -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 是应用的核心用户认证业务模块,提供完整的用户登录、注册、密码管理、JWT令牌认证和GitHub OAuth集成功能,支持邮箱验证、验证码登录、安全防护和Zulip账号同步,具备完善的业务流程控制、错误处理和安全审计能力。
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoginController } from './login.controller';
|
||||
import { LoginService } from './login.service';
|
||||
import { RegisterController } from './register.controller';
|
||||
import { RegisterService } from './register.service';
|
||||
import { LoginCoreModule } from '../../core/login_core/login_core.module';
|
||||
import { ZulipCoreModule } from '../../core/zulip_core/zulip_core.module';
|
||||
import { ZulipAccountsModule } from '../../core/db/zulip_accounts/zulip_accounts.module';
|
||||
@@ -38,10 +40,11 @@ import { UsersModule } from '../../core/db/users/users.module';
|
||||
ZulipAccountsModule.forRoot(),
|
||||
UsersModule,
|
||||
],
|
||||
controllers: [LoginController],
|
||||
controllers: [LoginController, RegisterController],
|
||||
providers: [
|
||||
LoginService,
|
||||
RegisterService,
|
||||
],
|
||||
exports: [LoginService],
|
||||
exports: [LoginService, RegisterService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -28,9 +28,11 @@ export * from './auth.module';
|
||||
|
||||
// 控制器
|
||||
export * from './login.controller';
|
||||
export * from './register.controller';
|
||||
|
||||
// 服务
|
||||
export * from './login.service';
|
||||
export * from './register.service';
|
||||
|
||||
// DTO
|
||||
export * from './login.dto';
|
||||
|
||||
163
src/business/auth/jwt_auth.guard.spec.ts
Normal file
163
src/business/auth/jwt_auth.guard.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
208
src/business/auth/login.controller.spec.ts
Normal file
208
src/business/auth/login.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,15 +34,12 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP
|
||||
import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { LoginService, ApiResponse, LoginResponse } from './login.service';
|
||||
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from './login.dto';
|
||||
import { LoginDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto, SendEmailVerificationDto } from './login.dto';
|
||||
import {
|
||||
LoginResponseDto,
|
||||
RegisterResponseDto,
|
||||
GitHubOAuthResponseDto,
|
||||
ForgotPasswordResponseDto,
|
||||
CommonResponseDto,
|
||||
TestModeEmailVerificationResponseDto,
|
||||
SuccessEmailVerificationResponseDto,
|
||||
RefreshTokenResponseDto
|
||||
} from './login_response.dto';
|
||||
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
|
||||
@@ -51,14 +48,12 @@ import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decora
|
||||
// 错误代码到HTTP状态码的映射
|
||||
const ERROR_STATUS_MAP = {
|
||||
LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
REGISTER_FAILED: HttpStatus.BAD_REQUEST,
|
||||
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
|
||||
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
|
||||
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
|
||||
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
|
||||
EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST,
|
||||
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
|
||||
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
|
||||
} as const;
|
||||
@@ -169,51 +164,6 @@ export class LoginController {
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerDto 注册数据
|
||||
* @returns 注册结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '用户注册',
|
||||
description: '创建新用户账户。如果提供邮箱,需要先调用发送验证码接口获取验证码,然后在注册时提供验证码进行验证。'
|
||||
})
|
||||
@ApiBody({ type: RegisterDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 201,
|
||||
description: '注册成功',
|
||||
type: RegisterResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 409,
|
||||
description: '用户名或邮箱已存在'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '注册请求过于频繁'
|
||||
})
|
||||
@Throttle(ThrottlePresets.REGISTER)
|
||||
@Timeout(TimeoutPresets.NORMAL)
|
||||
@Post('register')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async register(@Body() registerDto: RegisterDto, @Res() res: Response): Promise<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登录
|
||||
*
|
||||
@@ -378,120 +328,6 @@ export class LoginController {
|
||||
this.handleResponse(result, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param sendEmailVerificationDto 发送验证码数据
|
||||
* @param res Express响应对象
|
||||
* @returns 发送结果
|
||||
*/
|
||||
@ApiOperation({
|
||||
summary: '发送邮箱验证码',
|
||||
description: '向指定邮箱发送验证码'
|
||||
})
|
||||
@ApiBody({ type: SendEmailVerificationDto })
|
||||
@SwaggerApiResponse({
|
||||
status: 200,
|
||||
description: '验证码发送成功(真实发送模式)',
|
||||
type: SuccessEmailVerificationResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 206,
|
||||
description: '测试模式:验证码已生成但未真实发送',
|
||||
type: TestModeEmailVerificationResponseDto
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 400,
|
||||
description: '请求参数错误'
|
||||
})
|
||||
@SwaggerApiResponse({
|
||||
status: 429,
|
||||
description: '发送频率过高'
|
||||
})
|
||||
@Throttle(ThrottlePresets.SEND_CODE_PER_EMAIL)
|
||||
@Timeout(TimeoutPresets.EMAIL_SEND)
|
||||
@Post('send-email-verification')
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async sendEmailVerification(
|
||||
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
|
||||
@Res() res: Response
|
||||
): Promise<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码登录
|
||||
*
|
||||
|
||||
@@ -60,18 +60,14 @@ describe('LoginService', () => {
|
||||
|
||||
const mockLoginCoreService = {
|
||||
login: jest.fn(),
|
||||
register: jest.fn(),
|
||||
githubOAuth: jest.fn(),
|
||||
sendPasswordResetCode: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
changePassword: jest.fn(),
|
||||
sendEmailVerification: jest.fn(),
|
||||
verifyEmailCode: jest.fn(),
|
||||
resendEmailVerification: jest.fn(),
|
||||
verificationCodeLogin: jest.fn(),
|
||||
sendLoginVerificationCode: jest.fn(),
|
||||
debugVerificationCode: jest.fn(),
|
||||
deleteUser: jest.fn(),
|
||||
refreshAccessToken: jest.fn(),
|
||||
generateTokenPair: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -178,44 +174,6 @@ describe('LoginService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should register successfully with JWT tokens', async () => {
|
||||
loginCoreService.register.mockResolvedValue({
|
||||
user: mockUser,
|
||||
isNewUser: true
|
||||
});
|
||||
|
||||
const result = await service.register({
|
||||
username: 'newuser',
|
||||
password: 'password123',
|
||||
nickname: '新用户',
|
||||
email: 'newuser@example.com',
|
||||
email_verification_code: '123456'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.user.username).toBe('testuser');
|
||||
expect(result.data?.access_token).toBe(mockTokenPair.access_token);
|
||||
expect(result.data?.is_new_user).toBe(true);
|
||||
expect(loginCoreService.register).toHaveBeenCalled();
|
||||
expect(loginCoreService.generateTokenPair).toHaveBeenCalledWith(mockUser);
|
||||
});
|
||||
|
||||
it('should handle register failure', async () => {
|
||||
loginCoreService.register.mockRejectedValue(new Error('用户名已存在'));
|
||||
|
||||
const result = await service.register({
|
||||
username: 'existinguser',
|
||||
password: 'password123',
|
||||
nickname: '用户'
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.message).toBe('用户名已存在');
|
||||
expect(result.error_code).toBe('REGISTER_FAILED');
|
||||
});
|
||||
});
|
||||
|
||||
describe('githubOAuth', () => {
|
||||
it('should handle GitHub OAuth successfully', async () => {
|
||||
loginCoreService.githubOAuth.mockResolvedValue({
|
||||
@@ -282,34 +240,6 @@ describe('LoginService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendEmailVerification', () => {
|
||||
it('should handle sendEmailVerification in test mode', async () => {
|
||||
loginCoreService.sendEmailVerification.mockResolvedValue({
|
||||
code: '123456',
|
||||
isTestMode: true
|
||||
});
|
||||
|
||||
const result = await service.sendEmailVerification('test@example.com');
|
||||
|
||||
expect(result.success).toBe(false); // Test mode returns false
|
||||
expect(result.data?.verification_code).toBe('123456');
|
||||
expect(result.data?.is_test_mode).toBe(true);
|
||||
expect(loginCoreService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyEmailCode', () => {
|
||||
it('should handle verifyEmailCode successfully', async () => {
|
||||
loginCoreService.verifyEmailCode.mockResolvedValue(true);
|
||||
|
||||
const result = await service.verifyEmailCode('test@example.com', '123456');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.message).toBe('邮箱验证成功');
|
||||
expect(loginCoreService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verificationCodeLogin', () => {
|
||||
it('should handle verificationCodeLogin successfully', async () => {
|
||||
loginCoreService.verificationCodeLogin.mockResolvedValue({
|
||||
|
||||
@@ -2,28 +2,30 @@
|
||||
* 登录业务服务
|
||||
*
|
||||
* 功能描述:
|
||||
* - 处理登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的业务功能
|
||||
* - 处理用户登录相关的业务逻辑和流程控制
|
||||
* - 整合核心服务,提供完整的登录功能
|
||||
* - 处理业务规则、数据格式化和错误处理
|
||||
* - 管理JWT令牌刷新和验证码登录
|
||||
*
|
||||
* 职责分离:
|
||||
* - 专注于业务流程和规则实现
|
||||
* - 专注于登录业务流程和规则实现
|
||||
* - 调用核心服务完成具体功能
|
||||
* - 为控制器层提供业务接口
|
||||
* - 为控制器层提供登录业务接口
|
||||
* - JWT技术实现已移至Core层,符合架构分层原则
|
||||
*
|
||||
* 最近修改:
|
||||
* - 2026-01-12: 代码分离 - 移除注册相关业务逻辑,专注于登录功能
|
||||
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
|
||||
* - 2026-01-07: 架构优化 - 将JWT技术实现移至login_core模块,符合架构分层原则
|
||||
*
|
||||
* @author moyin
|
||||
* @version 1.0.3
|
||||
* @version 1.1.0
|
||||
* @since 2025-12-17
|
||||
* @lastModified 2026-01-07
|
||||
* @lastModified 2026-01-12
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, Inject } from '@nestjs/common';
|
||||
import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
|
||||
import { LoginCoreService, LoginRequest, GitHubOAuthRequest, PasswordResetRequest, VerificationCodeLoginRequest, TokenPair } from '../../core/login_core/login_core.service';
|
||||
import { Users } from '../../core/db/users/users.entity';
|
||||
import { ZulipAccountService } from '../../core/zulip_core/services/zulip_account.service';
|
||||
import { ApiKeySecurityService } from '../../core/zulip_core/services/api_key_security.service';
|
||||
@@ -38,14 +40,10 @@ interface IZulipAccountsService {
|
||||
// 常量定义
|
||||
const ERROR_CODES = {
|
||||
LOGIN_FAILED: 'LOGIN_FAILED',
|
||||
REGISTER_FAILED: 'REGISTER_FAILED',
|
||||
GITHUB_OAUTH_FAILED: 'GITHUB_OAUTH_FAILED',
|
||||
SEND_CODE_FAILED: 'SEND_CODE_FAILED',
|
||||
RESET_PASSWORD_FAILED: 'RESET_PASSWORD_FAILED',
|
||||
CHANGE_PASSWORD_FAILED: 'CHANGE_PASSWORD_FAILED',
|
||||
SEND_EMAIL_VERIFICATION_FAILED: 'SEND_EMAIL_VERIFICATION_FAILED',
|
||||
EMAIL_VERIFICATION_FAILED: 'EMAIL_VERIFICATION_FAILED',
|
||||
RESEND_EMAIL_VERIFICATION_FAILED: 'RESEND_EMAIL_VERIFICATION_FAILED',
|
||||
VERIFICATION_CODE_LOGIN_FAILED: 'VERIFICATION_CODE_LOGIN_FAILED',
|
||||
SEND_LOGIN_CODE_FAILED: 'SEND_LOGIN_CODE_FAILED',
|
||||
TOKEN_REFRESH_FAILED: 'TOKEN_REFRESH_FAILED',
|
||||
@@ -56,19 +54,14 @@ const ERROR_CODES = {
|
||||
|
||||
const MESSAGES = {
|
||||
LOGIN_SUCCESS: '登录成功',
|
||||
REGISTER_SUCCESS: '注册成功',
|
||||
REGISTER_SUCCESS_WITH_ZULIP: '注册成功,Zulip账号已同步创建',
|
||||
GITHUB_LOGIN_SUCCESS: 'GitHub登录成功',
|
||||
GITHUB_BIND_SUCCESS: 'GitHub账户绑定成功',
|
||||
PASSWORD_RESET_SUCCESS: '密码重置成功',
|
||||
PASSWORD_CHANGE_SUCCESS: '密码修改成功',
|
||||
EMAIL_VERIFICATION_SUCCESS: '邮箱验证成功',
|
||||
VERIFICATION_CODE_LOGIN_SUCCESS: '验证码登录成功',
|
||||
TOKEN_REFRESH_SUCCESS: '令牌刷新成功',
|
||||
DEBUG_INFO_SUCCESS: '调试信息获取成功',
|
||||
CODE_SENT: '验证码已发送,请查收',
|
||||
EMAIL_CODE_SENT: '验证码已发送,请查收邮件',
|
||||
EMAIL_CODE_RESENT: '验证码已重新发送,请查收邮件',
|
||||
VERIFICATION_CODE_ERROR: '验证码错误',
|
||||
TEST_MODE_WARNING: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
|
||||
} as const;
|
||||
@@ -161,10 +154,38 @@ export class LoginService {
|
||||
// 1. 调用核心服务进行认证
|
||||
const authResult = await this.loginCoreService.login(loginRequest);
|
||||
|
||||
// 2. 生成JWT令牌对(通过Core层)
|
||||
// 2. 验证和更新Zulip API Key(如果用户有Zulip账号关联)
|
||||
try {
|
||||
const isZulipValid = await this.validateAndUpdateZulipApiKey(authResult.user);
|
||||
if (!isZulipValid) {
|
||||
// 尝试重新生成API Key(需要密码)
|
||||
const regenerated = await this.regenerateZulipApiKey(authResult.user, loginRequest.password);
|
||||
if (regenerated) {
|
||||
this.logger.log('用户Zulip API Key已重新生成', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
} else {
|
||||
this.logger.warn('用户Zulip API Key重新生成失败', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (zulipError) {
|
||||
// Zulip验证失败不影响登录流程,只记录日志
|
||||
const err = zulipError as Error;
|
||||
this.logger.warn('Zulip API Key验证失败,但不影响登录', {
|
||||
operation: 'login',
|
||||
userId: authResult.user.id.toString(),
|
||||
zulipError: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. 生成JWT令牌对(通过Core层)
|
||||
const tokenPair = await this.loginCoreService.generateTokenPair(authResult.user);
|
||||
|
||||
// 3. 格式化响应数据
|
||||
// 4. 格式化响应数据
|
||||
const response: LoginResponse = {
|
||||
user: this.formatUserInfo(authResult.user),
|
||||
access_token: tokenPair.access_token,
|
||||
@@ -211,235 +232,6 @@ export class LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* @param registerRequest 注册请求
|
||||
* @returns 注册响应
|
||||
*/
|
||||
async register(registerRequest: RegisterRequest): Promise<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登录
|
||||
*
|
||||
@@ -500,7 +292,26 @@ export class LoginService {
|
||||
|
||||
this.logger.log(`密码重置验证码已发送: ${identifier}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送密码重置验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
@@ -574,98 +385,6 @@ export class LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮箱验证码
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 响应结果
|
||||
*/
|
||||
async sendEmailVerification(email: string): Promise<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}`);
|
||||
|
||||
return this.handleTestModeResponse(result, MESSAGES.CODE_SENT);
|
||||
// 处理测试模式响应
|
||||
if (result.isTestMode) {
|
||||
return {
|
||||
success: false,
|
||||
data: {
|
||||
verification_code: result.code,
|
||||
is_test_mode: true
|
||||
},
|
||||
message: MESSAGES.TEST_MODE_WARNING,
|
||||
error_code: ERROR_CODES.TEST_MODE_ONLY
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
is_test_mode: false
|
||||
},
|
||||
message: MESSAGES.CODE_SENT
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error));
|
||||
|
||||
@@ -839,6 +542,13 @@ export class LoginService {
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 调试验证码信息
|
||||
* 仅用于开发和调试
|
||||
*
|
||||
* @param email 邮箱地址
|
||||
* @returns 验证码调试信息
|
||||
*/
|
||||
async debugVerificationCode(email: string): Promise<any> {
|
||||
try {
|
||||
this.logger.log(`调试验证码信息: ${email}`);
|
||||
@@ -862,169 +572,179 @@ export class LoginService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化Zulip管理员客户端
|
||||
* 验证并更新用户的Zulip API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 使用环境变量中的管理员凭证初始化Zulip客户端
|
||||
* 在用户登录时验证其Zulip账号的API Key是否有效,如果无效则重新获取
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 从环境变量获取管理员配置
|
||||
* 2. 验证配置完整性
|
||||
* 3. 初始化ZulipAccountService的管理员客户端
|
||||
* 1. 查找用户的Zulip账号关联
|
||||
* 2. 从Redis获取API Key
|
||||
* 3. 验证API Key是否有效
|
||||
* 4. 如果无效,重新生成API Key并更新存储
|
||||
*
|
||||
* @throws Error 当配置缺失或初始化失败时
|
||||
* @param user 用户信息
|
||||
* @returns Promise<boolean> 是否验证/更新成功
|
||||
* @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 {
|
||||
// 从环境变量获取管理员配置
|
||||
const adminConfig = {
|
||||
realm: process.env.ZULIP_SERVER_URL || '',
|
||||
username: process.env.ZULIP_BOT_EMAIL || '',
|
||||
apiKey: process.env.ZULIP_BOT_API_KEY || '',
|
||||
};
|
||||
|
||||
// 验证配置完整性
|
||||
if (!adminConfig.realm || !adminConfig.username || !adminConfig.apiKey) {
|
||||
throw new Error('Zulip管理员配置不完整,请检查环境变量 ZULIP_SERVER_URL, ZULIP_BOT_EMAIL, ZULIP_BOT_API_KEY');
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.log('用户没有Zulip账号关联,跳过验证', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return true; // 没有关联不算错误
|
||||
}
|
||||
|
||||
// 初始化管理员客户端
|
||||
const initialized = await this.zulipAccountService.initializeAdminClient(adminConfig);
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error('Zulip管理员客户端初始化失败');
|
||||
// 2. 从Redis获取API Key
|
||||
const apiKeyResult = await this.apiKeySecurityService.getApiKey(user.id.toString());
|
||||
if (!apiKeyResult.success || !apiKeyResult.apiKey) {
|
||||
this.logger.warn('用户Zulip API Key不存在,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.message,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
}
|
||||
|
||||
this.logger.log('Zulip管理员客户端初始化成功', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
realm: adminConfig.realm,
|
||||
adminEmail: adminConfig.username,
|
||||
// 3. 验证API Key是否有效
|
||||
const validationResult = await this.zulipAccountService.validateZulipAccount(
|
||||
zulipAccount.zulipEmail,
|
||||
apiKeyResult.apiKey
|
||||
);
|
||||
|
||||
if (validationResult.success && validationResult.isValid) {
|
||||
this.logger.log('用户Zulip API Key验证成功', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. API Key无效,需要重新生成
|
||||
this.logger.warn('用户Zulip API Key无效,需要重新生成', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
validationError: validationResult.error,
|
||||
});
|
||||
|
||||
return false; // 需要重新生成
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
this.logger.error('Zulip管理员客户端初始化失败', {
|
||||
operation: 'initializeZulipAdminClient',
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('验证用户Zulip API Key失败', {
|
||||
operation: 'validateAndUpdateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
throw error;
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为用户创建Zulip账号
|
||||
* 重新生成并更新用户的Zulip API Key
|
||||
*
|
||||
* 功能描述:
|
||||
* 为新注册的游戏用户创建对应的Zulip账号并建立关联
|
||||
* 使用用户密码重新生成Zulip API Key并更新存储
|
||||
*
|
||||
* 业务逻辑:
|
||||
* 1. 使用相同的邮箱和密码创建Zulip账号
|
||||
* 2. 加密存储API Key
|
||||
* 3. 在数据库中建立关联关系
|
||||
* 4. 处理创建失败的情况
|
||||
*
|
||||
* @param gameUser 游戏用户信息
|
||||
* @param user 用户信息
|
||||
* @param password 用户密码(明文)
|
||||
* @throws Error 当Zulip账号创建失败时
|
||||
* @returns Promise<boolean> 是否更新成功
|
||||
* @private
|
||||
*/
|
||||
private async createZulipAccountForUser(gameUser: Users, password: string): Promise<void> {
|
||||
private async regenerateZulipApiKey(user: Users, password: string): Promise<boolean> {
|
||||
const startTime = Date.now();
|
||||
|
||||
this.logger.log('开始为用户创建Zulip账号', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
nickname: gameUser.nickname,
|
||||
this.logger.log('开始重新生成用户Zulip API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. 检查是否已存在Zulip账号关联
|
||||
const existingAccount = await this.zulipAccountsService.findByGameUserId(gameUser.id.toString());
|
||||
if (existingAccount) {
|
||||
this.logger.warn('用户已存在Zulip账号关联,跳过创建', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
existingZulipUserId: existingAccount.zulipUserId,
|
||||
// 1. 查找用户的Zulip账号关联
|
||||
const zulipAccount = await this.zulipAccountsService.findByGameUserId(user.id.toString());
|
||||
if (!zulipAccount) {
|
||||
this.logger.warn('用户没有Zulip账号关联,无法重新生成API Key', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
});
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 创建Zulip账号
|
||||
const createResult = await this.zulipAccountService.createZulipAccount({
|
||||
email: gameUser.email,
|
||||
fullName: gameUser.nickname,
|
||||
password: password,
|
||||
});
|
||||
// 2. 重新生成API Key
|
||||
const apiKeyResult = await this.zulipAccountService.generateApiKeyForUser(
|
||||
zulipAccount.zulipEmail,
|
||||
password
|
||||
);
|
||||
|
||||
if (!createResult.success) {
|
||||
throw new Error(createResult.error || 'Zulip账号创建失败');
|
||||
if (!apiKeyResult.success) {
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
error: apiKeyResult.error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 3. 存储API Key
|
||||
if (createResult.apiKey) {
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
gameUser.id.toString(),
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
// 3. 更新Redis中的API Key
|
||||
await this.apiKeySecurityService.storeApiKey(
|
||||
user.id.toString(),
|
||||
apiKeyResult.apiKey!
|
||||
);
|
||||
|
||||
// 4. 在数据库中创建关联记录
|
||||
await this.zulipAccountsService.create({
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId!,
|
||||
zulipEmail: createResult.email!,
|
||||
zulipFullName: gameUser.nickname,
|
||||
zulipApiKeyEncrypted: createResult.apiKey ? 'stored_in_redis' : '', // 标记API Key已存储在Redis中
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
// 5. 建立游戏账号与Zulip账号的内存关联(用于当前会话)
|
||||
if (createResult.apiKey) {
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
gameUser.id.toString(),
|
||||
createResult.userId!,
|
||||
createResult.email!,
|
||||
createResult.apiKey
|
||||
);
|
||||
}
|
||||
// 4. 更新内存关联
|
||||
await this.zulipAccountService.linkGameAccount(
|
||||
user.id.toString(),
|
||||
zulipAccount.zulipUserId,
|
||||
zulipAccount.zulipEmail,
|
||||
apiKeyResult.apiKey!
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.log('Zulip账号创建和关联成功', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
zulipUserId: createResult.userId,
|
||||
zulipEmail: createResult.email,
|
||||
hasApiKey: !!createResult.apiKey,
|
||||
this.logger.log('重新生成Zulip API Key成功', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
zulipEmail: zulipAccount.zulipEmail,
|
||||
duration,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
this.logger.error('为用户创建Zulip账号失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
email: gameUser.email,
|
||||
this.logger.error('重新生成Zulip API Key失败', {
|
||||
operation: 'regenerateZulipApiKey',
|
||||
userId: user.id.toString(),
|
||||
error: err.message,
|
||||
duration,
|
||||
}, err.stack);
|
||||
|
||||
// 清理可能创建的部分数据
|
||||
try {
|
||||
await this.zulipAccountsService.deleteByGameUserId(gameUser.id.toString());
|
||||
} catch (cleanupError) {
|
||||
this.logger.warn('清理Zulip账号关联数据失败', {
|
||||
operation: 'createZulipAccountForUser',
|
||||
gameUserId: gameUser.id.toString(),
|
||||
cleanupError: (cleanupError as Error).message,
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
230
src/business/auth/register.controller.spec.ts
Normal file
230
src/business/auth/register.controller.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
269
src/business/auth/register.controller.ts
Normal file
269
src/business/auth/register.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
223
src/business/auth/register.service.spec.ts
Normal file
223
src/business/auth/register.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
578
src/business/auth/register.service.ts
Normal file
578
src/business/auth/register.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user