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