refactor(auth): 重构认证模块架构 - 将Gateway层组件从Business层分离

范围:src/gateway/auth/, src/business/auth/, src/app.module.ts
涉及文件:
- 新增:src/gateway/auth/ 目录及所有文件
- 移动:Controller、Guard、Decorator、DTO从business层移至gateway层
- 修改:src/business/auth/index.ts(移除Gateway层组件导出)
- 修改:src/app.module.ts(使用AuthGatewayModule替代AuthModule)

主要改进:
- 明确Gateway层和Business层的职责边界
- Controller、Guard、Decorator属于Gateway层职责
- Business层专注于业务逻辑和服务
- 符合分层架构设计原则
This commit is contained in:
moyin
2026-01-14 13:07:11 +08:00
parent f7c3983cc1
commit 73e3e0153c
21 changed files with 565 additions and 220 deletions

338
src/gateway/auth/README.md Normal file
View File

@@ -0,0 +1,338 @@
# 认证网关模块 (Auth Gateway Module)
认证网关模块是系统的HTTP API入口负责处理所有认证相关的HTTP请求包括用户登录、注册、密码管理、邮箱验证等功能提供统一的API接口和完善的安全保护机制。
## 架构层级
**Gateway Layer网关层**
## 职责定位
网关层是系统的HTTP API入口负责
1. **协议处理**处理HTTP请求和响应
2. **数据验证**使用DTO进行请求参数验证
3. **路由管理**定义API端点和路由规则
4. **认证守卫**JWT令牌验证和权限检查
5. **错误转换**将业务错误转换为HTTP状态码
6. **API文档**提供Swagger API文档
## 模块组成
```
src/gateway/auth/
├── login.controller.ts # 登录API控制器
├── login.controller.spec.ts # 登录控制器测试
├── register.controller.ts # 注册API控制器
├── register.controller.spec.ts # 注册控制器测试
├── jwt_auth.guard.ts # JWT认证守卫
├── jwt_auth.guard.spec.ts # JWT认证守卫测试
├── current_user.decorator.ts # 当前用户装饰器
├── jwt_usage_example.ts # JWT使用示例开发参考
├── dto/ # 数据传输对象
│ ├── login.dto.ts # 登录相关DTO
│ └── login_response.dto.ts # 响应DTO
├── auth.gateway.module.ts # 网关模块配置
└── README.md # 模块文档
```
## 依赖关系
```
Gateway Layer (auth.gateway.module)
↓ 依赖
Business Layer (auth.module)
↓ 依赖
Core Layer (login_core.module)
```
## 核心原则
### 1. 只做协议转换,不做业务逻辑
```typescript
// ✅ 正确只做HTTP协议处理
@Post('login')
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.login({
identifier: loginDto.identifier,
password: loginDto.password
});
this.handleResponse(result, res);
}
// ❌ 错误在Controller中包含业务逻辑
@Post('login')
async login(@Body() loginDto: LoginDto): Promise<any> {
// 验证用户
const user = await this.userService.findByIdentifier(loginDto.identifier);
// 检查密码
const isValid = await this.comparePassword(loginDto.password, user.password);
// ... 更多业务逻辑
}
```
### 2. 统一的错误处理
```typescript
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);
}
```
### 3. 使用DTO进行数据验证
```typescript
export class LoginDto {
@IsString()
@IsNotEmpty()
identifier: string;
@IsString()
@IsNotEmpty()
password: string;
}
```
## 对外提供的接口
### LoginController
#### login(loginDto: LoginDto, res: Response): Promise<void>
处理用户登录请求,支持用户名、邮箱或手机号多种方式登录。
#### githubOAuth(githubDto: GitHubOAuthDto, res: Response): Promise<void>
处理GitHub OAuth登录请求支持使用GitHub账户登录或注册。
#### refreshToken(refreshTokenDto: RefreshTokenDto, res: Response): Promise<void>
刷新访问令牌,使用有效的刷新令牌生成新的访问令牌。
#### forgotPassword(forgotPasswordDto: ForgotPasswordDto, res: Response): Promise<void>
发送密码重置验证码到用户邮箱或手机。
#### resetPassword(resetPasswordDto: ResetPasswordDto, res: Response): Promise<void>
使用验证码重置用户密码。
#### changePassword(changePasswordDto: ChangePasswordDto, res: Response): Promise<void>
修改用户密码,需要提供旧密码验证。
#### verificationCodeLogin(verificationCodeLoginDto: VerificationCodeLoginDto, res: Response): Promise<void>
使用邮箱或手机号和验证码进行登录,无需密码。
#### sendLoginVerificationCode(sendLoginVerificationCodeDto: SendLoginVerificationCodeDto, res: Response): Promise<void>
向用户邮箱或手机发送登录验证码。
#### debugVerificationCode(sendEmailVerificationDto: SendEmailVerificationDto, res: Response): Promise<void>
获取验证码的详细调试信息,仅用于开发环境。
### RegisterController
#### register(registerDto: RegisterDto, res: Response): Promise<void>
处理用户注册请求,创建新用户账户。
#### sendEmailVerification(sendEmailVerificationDto: SendEmailVerificationDto, res: Response): Promise<void>
向指定邮箱发送验证码。
#### verifyEmail(emailVerificationDto: EmailVerificationDto, res: Response): Promise<void>
使用验证码验证邮箱。
#### resendEmailVerification(sendEmailVerificationDto: SendEmailVerificationDto, res: Response): Promise<void>
重新向指定邮箱发送验证码。
### JwtAuthGuard
#### canActivate(context: ExecutionContext): Promise<boolean>
验证请求中的JWT令牌提取用户信息并添加到请求上下文。
### CurrentUser Decorator
#### CurrentUser(data?: keyof JwtPayload): ParameterDecorator
从请求上下文中提取当前认证用户信息的参数装饰器。
## 对外API接口
### POST /auth/login
用户登录接口支持用户名、邮箱或手机号多种方式登录返回JWT令牌。
### POST /auth/github
GitHub OAuth登录接口使用GitHub账户登录或注册。
### POST /auth/verification-code-login
验证码登录接口,使用邮箱或手机号和验证码进行登录,无需密码。
### POST /auth/refresh-token
刷新访问令牌接口,使用有效的刷新令牌生成新的访问令牌。
### POST /auth/forgot-password
发送密码重置验证码接口,向用户邮箱或手机发送密码重置验证码。
### POST /auth/reset-password
重置密码接口,使用验证码重置用户密码。
### PUT /auth/change-password
修改密码接口,用户修改自己的密码,需要提供旧密码验证。
### POST /auth/send-login-verification-code
发送登录验证码接口,向用户邮箱或手机发送登录验证码。
### POST /auth/debug-verification-code
调试验证码信息接口,获取验证码的详细调试信息,仅用于开发环境。
### POST /auth/register
用户注册接口,创建新用户账户。
### POST /auth/send-email-verification
发送邮箱验证码接口,向指定邮箱发送验证码。
### POST /auth/verify-email
验证邮箱接口,使用验证码验证邮箱。
### POST /auth/resend-email-verification
重新发送邮箱验证码接口,重新向指定邮箱发送验证码。
## 使用的项目内部依赖
### LoginService (来自 business/auth/login.service)
登录业务服务,提供用户登录、密码管理、令牌刷新等业务逻辑。
### RegisterService (来自 business/auth/register.service)
注册业务服务,提供用户注册、邮箱验证等业务逻辑。
### LoginCoreService (来自 core/login_core/login_core.service)
登录核心服务提供JWT令牌验证和生成等技术实现。
### JwtPayload (来自 core/login_core/login_core.service)
JWT令牌载荷类型定义包含用户ID、用户名、角色等信息。
### ThrottlePresets (来自 core/security_core/throttle.decorator)
限流预设配置,提供登录、注册、发送验证码等场景的限流规则。
### TimeoutPresets (来自 core/security_core/timeout.decorator)
超时预设配置,提供不同场景的超时时间设置。
## 核心特性
### 统一的响应处理机制
- 智能错误状态码映射根据错误代码和消息自动选择合适的HTTP状态码
- 统一响应格式所有API返回统一的JSON格式
- 错误信息标准化:提供清晰的错误代码和消息
### 完善的安全保护
- JWT令牌认证使用JWT进行用户身份验证
- 限流保护防止API滥用和暴力破解
- 超时控制:防止长时间阻塞和资源占用
- 请求验证使用DTO和class-validator进行严格的数据验证
### 完整的API文档
- Swagger集成自动生成交互式API文档
- 详细的接口说明每个API都有完整的描述和示例
- 请求响应示例:提供清晰的数据格式说明
### 灵活的认证方式
- 多种登录方式:支持用户名、邮箱、手机号登录
- 验证码登录:支持无密码的验证码登录
- OAuth集成支持GitHub OAuth登录
- 令牌刷新:支持无感知的令牌续期
## 使用示例
### JWT认证完整示例
本模块提供了完整的JWT认证使用示例文件 `jwt_usage_example.ts`,展示了以下场景:
1. **公开接口**:无需认证的接口
2. **受保护接口**需要JWT令牌的接口
3. **用户信息获取**:使用 `@CurrentUser()` 装饰器
4. **特定属性提取**:使用 `@CurrentUser('username')` 获取特定字段
5. **角色权限检查**:基于用户角色的访问控制
详细代码请参考 `src/gateway/auth/jwt_usage_example.ts` 文件。
### 在其他模块中使用认证守卫
```typescript
import { Controller, Get, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../gateway/auth/jwt_auth.guard';
import { CurrentUser } from '../gateway/auth/current_user.decorator';
@Controller('profile')
export class ProfileController {
@Get()
@UseGuards(JwtAuthGuard)
getProfile(@CurrentUser() user: any) {
return { user };
}
}
```
## 与业务层的交互
网关层通过依赖注入使用业务层服务:
```typescript
constructor(
private readonly loginService: LoginService,
private readonly registerService: RegisterService
) {}
```
业务层返回统一的响应格式:
```typescript
interface ApiResponse<T = any> {
success: boolean;
data?: T;
message: string;
error_code?: string;
}
```
## 最佳实践
1. **保持Controller轻量**:只做请求响应处理
2. **使用DTO验证**所有输入都要经过DTO验证
3. **统一错误处理**:使用统一的错误处理方法
4. **完善API文档**使用Swagger装饰器
5. **限流保护**使用Throttle装饰器防止滥用
6. **超时控制**使用Timeout装饰器防止长时间阻塞
## 潜在风险
### 限流配置不当风险
- 限流阈值过低可能影响正常用户使用
- 限流阈值过高无法有效防止攻击
- 缓解措施:根据实际业务场景调整限流参数,监控限流触发情况
### JWT令牌安全风险
- 令牌泄露可能导致账户被盗用
- 令牌过期时间设置不当影响用户体验
- 缓解措施使用HTTPS传输合理设置令牌过期时间实现令牌刷新机制
### 验证码安全风险
- 验证码被暴力破解
- 验证码发送频率过高导致资源浪费
- 缓解措施:限制验证码发送频率,增加验证码复杂度,设置验证码有效期
### API滥用风险
- 恶意用户频繁调用API消耗服务器资源
- 自动化工具批量注册账户
- 缓解措施:实施限流策略,添加人机验证,监控异常请求模式
### 错误信息泄露风险
- 详细的错误信息可能泄露系统实现细节
- 帮助攻击者了解系统弱点
- 缓解措施:生产环境使用通用错误消息,详细日志仅记录在服务器端
## 注意事项
- 网关层不应该直接访问数据库
- 网关层不应该包含复杂的业务逻辑
- 网关层不应该直接调用Core层服务Guard除外
- 所有业务逻辑都应该在Business层实现

View File

@@ -0,0 +1,52 @@
/**
* 认证网关模块
*
* 架构层级Gateway Layer网关层
*
* 功能描述:
* - 整合所有认证相关的网关组件
* - 提供HTTP API接口
* - 配置认证守卫和中间件
* - 处理请求验证和响应格式化
*
* 职责分离:
* - 专注于HTTP协议处理和API网关功能
* - 依赖业务层服务,不包含业务逻辑
* - 提供统一的API入口和文档
*
* 依赖关系:
* - 依赖 Business Layer 的 AuthModule
* - 提供 Controller 和 Guard
*
* @author moyin
* @version 2.0.0
* @since 2026-01-14
* @lastModified 2026-01-14
*/
import { Module } from '@nestjs/common';
import { LoginController } from './login.controller';
import { RegisterController } from './register.controller';
import { JwtAuthGuard } from './jwt_auth.guard';
import { AuthModule } from '../../business/auth/auth.module';
@Module({
imports: [
// 导入业务层模块
AuthModule,
],
controllers: [
// 网关层控制器
LoginController,
RegisterController,
],
providers: [
// 认证守卫
JwtAuthGuard,
],
exports: [
// 导出守卫供其他模块使用
JwtAuthGuard,
],
})
export class AuthGatewayModule {}

View File

@@ -0,0 +1,69 @@
/**
* 当前用户装饰器
*
* 功能描述:
* - 从请求上下文中提取当前认证用户信息
* - 简化控制器中获取用户信息的操作
* - 支持获取用户对象的特定属性
*
* 职责分离:
* - 专注于用户信息提取和参数装饰
* - 提供类型安全的用户信息访问
* - 简化控制器方法的参数处理
*
* 使用示例:
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* getProfile(@CurrentUser() user: JwtPayload) {
* return { user };
* }
* ```
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 文件重命名为snake_case格式更新注释规范
*
* @author moyin
* @version 1.0.2
* @since 2025-01-05
* @lastModified 2026-01-07
*/
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { JwtPayload } from '../../core/login_core/login_core.service';
import { AuthenticatedRequest } from './jwt_auth.guard';
/**
* 当前用户装饰器实现
*
* 业务逻辑:
* 1. 从执行上下文获取HTTP请求对象
* 2. 提取请求中的用户信息由JwtAuthGuard注入
* 3. 根据data参数返回完整用户对象或特定属性
* 4. 提供类型安全的用户信息访问
*
* @param data 可选的属性名,用于获取用户对象的特定属性
* @param ctx 执行上下文包含HTTP请求信息
* @returns JwtPayload | any 用户信息或用户的特定属性
* @throws 无异常抛出依赖JwtAuthGuard确保用户信息存在
*
* @example
* ```typescript
* // 获取完整用户对象
* @Get('profile')
* getProfile(@CurrentUser() user: JwtPayload) { }
*
* // 获取特定属性
* @Get('username')
* getUsername(@CurrentUser('username') username: string) { }
* ```
*/
export const CurrentUser = createParamDecorator(
(data: keyof JwtPayload | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,454 @@
/**
* 登录业务数据传输对象
*
* 功能描述:
* - 定义登录相关API的请求数据结构
* - 提供数据验证规则和错误提示
* - 确保API接口的数据格式一致性
*
* 职责分离:
* - 专注于数据结构定义和验证规则
* - 提供Swagger文档生成支持
* - 确保类型安全和数据完整性
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正作者信息
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import {
IsString,
IsEmail,
IsPhoneNumber,
IsNotEmpty,
Length,
IsOptional,
Matches,
IsNumberString
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
/**
* 登录请求DTO
*/
export class LoginDto {
/**
* 登录标识符
* 支持用户名、邮箱或手机号登录
*/
@ApiProperty({
description: '登录标识符,支持用户名、邮箱或手机号',
example: 'testuser',
minLength: 1,
maxLength: 100
})
@IsString({ message: '登录标识符必须是字符串' })
@IsNotEmpty({ message: '登录标识符不能为空' })
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
identifier: string;
/**
* 密码
*/
@ApiProperty({
description: '用户密码',
example: 'password123',
minLength: 1,
maxLength: 128
})
@IsString({ message: '密码必须是字符串' })
@IsNotEmpty({ message: '密码不能为空' })
@Length(1, 128, { message: '密码长度需在1-128字符之间' })
password: string;
}
/**
* 注册请求DTO
*/
export class RegisterDto {
/**
* 用户名
*/
@ApiProperty({
description: '用户名,只能包含字母、数字和下划线',
example: 'testuser',
minLength: 1,
maxLength: 50,
pattern: '^[a-zA-Z0-9_]+$'
})
@IsString({ message: '用户名必须是字符串' })
@IsNotEmpty({ message: '用户名不能为空' })
@Length(1, 50, { message: '用户名长度需在1-50字符之间' })
@Matches(/^[a-zA-Z0-9_]+$/, { message: '用户名只能包含字母、数字和下划线' })
username: string;
/**
* 密码
*/
@ApiProperty({
description: '密码必须包含字母和数字长度8-128字符',
example: 'password123',
minLength: 8,
maxLength: 128
})
@IsString({ message: '密码必须是字符串' })
@IsNotEmpty({ message: '密码不能为空' })
@Length(8, 128, { message: '密码长度需在8-128字符之间' })
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '密码必须包含字母和数字' })
password: string;
/**
* 昵称
*/
@ApiProperty({
description: '用户昵称',
example: '测试用户',
minLength: 1,
maxLength: 50
})
@IsString({ message: '昵称必须是字符串' })
@IsNotEmpty({ message: '昵称不能为空' })
@Length(1, 50, { message: '昵称长度需在1-50字符之间' })
nickname: string;
/**
* 邮箱(可选)
*/
@ApiProperty({
description: '邮箱地址(可选)',
example: 'test@example.com',
required: false
})
@IsOptional()
@IsEmail({}, { message: '邮箱格式不正确' })
email?: string;
/**
* 手机号(可选)
*/
@ApiProperty({
description: '手机号码(可选)',
example: '+8613800138000',
required: false
})
@IsOptional()
@IsPhoneNumber(null, { message: '手机号格式不正确' })
phone?: string;
/**
* 邮箱验证码(当提供邮箱时必填)
*/
@ApiProperty({
description: '邮箱验证码,当提供邮箱时必填',
example: '123456',
pattern: '^\\d{6}$',
required: false
})
@IsOptional()
@IsString({ message: '验证码必须是字符串' })
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
email_verification_code?: string;
}
/**
* GitHub OAuth登录请求DTO
*/
export class GitHubOAuthDto {
/**
* GitHub用户ID
*/
@ApiProperty({
description: 'GitHub用户ID',
example: '12345678',
minLength: 1,
maxLength: 100
})
@IsString({ message: 'GitHub ID必须是字符串' })
@IsNotEmpty({ message: 'GitHub ID不能为空' })
@Length(1, 100, { message: 'GitHub ID长度需在1-100字符之间' })
github_id: string;
/**
* 用户名
*/
@ApiProperty({
description: 'GitHub用户名',
example: 'octocat',
minLength: 1,
maxLength: 50
})
@IsString({ message: '用户名必须是字符串' })
@IsNotEmpty({ message: '用户名不能为空' })
@Length(1, 50, { message: '用户名长度需在1-50字符之间' })
username: string;
/**
* 昵称
*/
@ApiProperty({
description: 'GitHub显示名称',
example: 'The Octocat',
minLength: 1,
maxLength: 50
})
@IsString({ message: '昵称必须是字符串' })
@IsNotEmpty({ message: '昵称不能为空' })
@Length(1, 50, { message: '昵称长度需在1-50字符之间' })
nickname: string;
/**
* 邮箱(可选)
*/
@ApiProperty({
description: 'GitHub邮箱地址可选',
example: 'octocat@github.com',
required: false
})
@IsOptional()
@IsEmail({}, { message: '邮箱格式不正确' })
email?: string;
/**
* 头像URL可选
*/
@ApiProperty({
description: 'GitHub头像URL可选',
example: 'https://github.com/images/error/octocat_happy.gif',
required: false
})
@IsOptional()
@IsString({ message: '头像URL必须是字符串' })
avatar_url?: string;
}
/**
* 忘记密码请求DTO
*/
export class ForgotPasswordDto {
/**
* 邮箱或手机号
*/
@ApiProperty({
description: '邮箱或手机号',
example: 'test@example.com',
minLength: 1,
maxLength: 100
})
@IsString({ message: '标识符必须是字符串' })
@IsNotEmpty({ message: '邮箱或手机号不能为空' })
@Length(1, 100, { message: '标识符长度需在1-100字符之间' })
identifier: string;
}
/**
* 重置密码请求DTO
*/
export class ResetPasswordDto {
/**
* 邮箱或手机号
*/
@ApiProperty({
description: '邮箱或手机号',
example: 'test@example.com',
minLength: 1,
maxLength: 100
})
@IsString({ message: '标识符必须是字符串' })
@IsNotEmpty({ message: '邮箱或手机号不能为空' })
@Length(1, 100, { message: '标识符长度需在1-100字符之间' })
identifier: string;
/**
* 验证码
*/
@ApiProperty({
description: '6位数字验证码',
example: '123456',
pattern: '^\\d{6}$'
})
@IsString({ message: '验证码必须是字符串' })
@IsNotEmpty({ message: '验证码不能为空' })
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
verification_code: string;
/**
* 新密码
*/
@ApiProperty({
description: '新密码必须包含字母和数字长度8-128字符',
example: 'newpassword123',
minLength: 8,
maxLength: 128
})
@IsString({ message: '新密码必须是字符串' })
@IsNotEmpty({ message: '新密码不能为空' })
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
new_password: string;
}
/**
* 修改密码请求DTO
*/
export class ChangePasswordDto {
/**
* 用户ID
* 实际应用中应从JWT令牌中获取这里为了演示放在请求体中
*/
@ApiProperty({
description: '用户ID实际应用中应从JWT令牌中获取',
example: '1'
})
@IsNumberString({}, { message: '用户ID必须是数字字符串' })
@IsNotEmpty({ message: '用户ID不能为空' })
user_id: string;
/**
* 旧密码
*/
@ApiProperty({
description: '当前密码',
example: 'oldpassword123',
minLength: 1,
maxLength: 128
})
@IsString({ message: '旧密码必须是字符串' })
@IsNotEmpty({ message: '旧密码不能为空' })
@Length(1, 128, { message: '旧密码长度需在1-128字符之间' })
old_password: string;
/**
* 新密码
*/
@ApiProperty({
description: '新密码必须包含字母和数字长度8-128字符',
example: 'newpassword123',
minLength: 8,
maxLength: 128
})
@IsString({ message: '新密码必须是字符串' })
@IsNotEmpty({ message: '新密码不能为空' })
@Length(8, 128, { message: '新密码长度需在8-128字符之间' })
@Matches(/^(?=.*[a-zA-Z])(?=.*\d)/, { message: '新密码必须包含字母和数字' })
new_password: string;
}
/**
* 邮箱验证请求DTO
*/
export class EmailVerificationDto {
/**
* 邮箱地址
*/
@ApiProperty({
description: '邮箱地址',
example: 'test@example.com'
})
@IsEmail({}, { message: '邮箱格式不正确' })
@IsNotEmpty({ message: '邮箱不能为空' })
email: string;
/**
* 验证码
*/
@ApiProperty({
description: '6位数字验证码',
example: '123456',
pattern: '^\\d{6}$'
})
@IsString({ message: '验证码必须是字符串' })
@IsNotEmpty({ message: '验证码不能为空' })
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
verification_code: string;
}
/**
* 发送邮箱验证码请求DTO
*/
export class SendEmailVerificationDto {
/**
* 邮箱地址
*/
@ApiProperty({
description: '邮箱地址',
example: 'test@example.com'
})
@IsEmail({}, { message: '邮箱格式不正确' })
@IsNotEmpty({ message: '邮箱不能为空' })
email: string;
}
/**
* 验证码登录请求DTO
*/
export class VerificationCodeLoginDto {
/**
* 登录标识符
* 支持邮箱或手机号登录
*/
@ApiProperty({
description: '登录标识符,支持邮箱或手机号',
example: 'test@example.com',
minLength: 1,
maxLength: 100
})
@IsString({ message: '登录标识符必须是字符串' })
@IsNotEmpty({ message: '登录标识符不能为空' })
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
identifier: string;
/**
* 验证码
*/
@ApiProperty({
description: '6位数字验证码',
example: '123456',
pattern: '^\\d{6}$'
})
@IsString({ message: '验证码必须是字符串' })
@IsNotEmpty({ message: '验证码不能为空' })
@Matches(/^\d{6}$/, { message: '验证码必须是6位数字' })
verification_code: string;
}
/**
* 发送登录验证码请求DTO
*/
export class SendLoginVerificationCodeDto {
/**
* 登录标识符
* 支持邮箱或手机号
*/
@ApiProperty({
description: '登录标识符,支持邮箱或手机号',
example: 'test@example.com',
minLength: 1,
maxLength: 100
})
@IsString({ message: '登录标识符必须是字符串' })
@IsNotEmpty({ message: '登录标识符不能为空' })
@Length(1, 100, { message: '登录标识符长度需在1-100字符之间' })
identifier: string;
}
/**
* 刷新令牌请求DTO
*/
export class RefreshTokenDto {
/**
* 刷新令牌
*/
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
minLength: 1
})
@IsString({ message: '刷新令牌必须是字符串' })
@IsNotEmpty({ message: '刷新令牌不能为空' })
refresh_token: string;
}

View File

@@ -0,0 +1,479 @@
/**
* 登录业务响应数据传输对象
*
* 功能描述:
* - 定义登录相关API的响应数据结构
* - 提供Swagger文档生成支持
* - 确保API响应的数据格式一致性
*
* 职责分离:
* - 专注于响应数据结构定义
* - 提供完整的API文档支持
* - 确保响应格式的统一性
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正作者信息
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* 用户信息响应DTO
*/
export class UserInfoDto {
@ApiProperty({
description: '用户ID',
example: '1'
})
id: string;
@ApiProperty({
description: '用户名',
example: 'testuser'
})
username: string;
@ApiProperty({
description: '用户昵称',
example: '测试用户'
})
nickname: string;
@ApiProperty({
description: '邮箱地址',
example: 'test@example.com',
required: false
})
email?: string;
@ApiProperty({
description: '手机号码',
example: '+8613800138000',
required: false
})
phone?: string;
@ApiProperty({
description: '头像URL',
example: 'https://example.com/avatar.jpg',
required: false
})
avatar_url?: string;
@ApiProperty({
description: '用户角色',
example: 1
})
role: number;
@ApiProperty({
description: '创建时间',
example: '2025-12-17T10:00:00.000Z'
})
created_at: Date;
}
/**
* 登录响应数据DTO
*/
export class LoginResponseDataDto {
@ApiProperty({
description: '用户信息',
type: UserInfoDto
})
user: UserInfoDto;
@ApiProperty({
description: 'JWT访问令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
access_token: string;
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
refresh_token: string;
@ApiProperty({
description: '访问令牌过期时间(秒)',
example: 604800
})
expires_in: number;
@ApiProperty({
description: '令牌类型',
example: 'Bearer'
})
token_type: string;
@ApiProperty({
description: '是否为新用户',
example: false,
required: false
})
is_new_user?: boolean;
@ApiProperty({
description: '响应消息',
example: '登录成功'
})
message: string;
}
/**
* 登录响应DTO
*/
export class LoginResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: LoginResponseDataDto,
required: false
})
data?: LoginResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '登录成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'LOGIN_FAILED',
required: false
})
error_code?: string;
}
/**
* 注册响应DTO
*/
export class RegisterResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: LoginResponseDataDto,
required: false
})
data?: LoginResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '注册成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'REGISTER_FAILED',
required: false
})
error_code?: string;
}
/**
* GitHub OAuth响应DTO
*/
export class GitHubOAuthResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: LoginResponseDataDto,
required: false
})
data?: LoginResponseDataDto;
@ApiProperty({
description: '响应消息',
example: 'GitHub登录成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'GITHUB_OAUTH_FAILED',
required: false
})
error_code?: string;
}
/**
* 忘记密码响应数据DTO
*/
export class ForgotPasswordResponseDataDto {
@ApiProperty({
description: '验证码(仅用于演示,实际应用中不应返回)',
example: '123456',
required: false
})
verification_code?: string;
@ApiProperty({
description: '是否为测试模式',
example: true,
required: false
})
is_test_mode?: boolean;
}
/**
* 忘记密码响应DTO
*/
export class ForgotPasswordResponseDto {
@ApiProperty({
description: '请求是否成功',
example: false,
examples: {
success: {
summary: '真实发送成功',
value: true
},
testMode: {
summary: '测试模式',
value: false
}
}
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: ForgotPasswordResponseDataDto,
required: false,
examples: {
success: {
summary: '真实发送成功',
value: {
verification_code: '123456',
is_test_mode: false
}
},
testMode: {
summary: '测试模式',
value: {
verification_code: '059174',
is_test_mode: true
}
}
}
})
data?: ForgotPasswordResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
examples: {
success: {
summary: '真实发送成功',
value: '验证码已发送,请查收'
},
testMode: {
summary: '测试模式',
value: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
}
}
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TEST_MODE_ONLY',
examples: {
success: {
summary: '真实发送成功',
value: null
},
testMode: {
summary: '测试模式',
value: 'TEST_MODE_ONLY'
},
failed: {
summary: '发送失败',
value: 'SEND_CODE_FAILED'
}
},
required: false
})
error_code?: string;
}
/**
* 通用响应DTO用于重置密码、修改密码等
*/
export class CommonResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应消息',
example: '操作成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'OPERATION_FAILED',
required: false
})
error_code?: string;
}
/**
* 测试模式邮件验证码响应DTO
*
* 最近修改:
* - 2025-12-17: 功能新增 - 添加测试模式响应DTO (修改者: angjustinl)
*/
export class TestModeEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功测试模式下为false',
example: false
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '059174',
is_test_mode: true
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TEST_MODE_ONLY'
})
error_code: string;
}
/**
* 成功发送邮件验证码响应DTO
*/
export class SuccessEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '123456',
is_test_mode: false
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '验证码已发送,请查收'
})
message: string;
@ApiProperty({
description: '错误代码',
example: null,
required: false
})
error_code?: string;
}
/**
* 令牌刷新响应数据DTO
*/
export class RefreshTokenResponseDataDto {
@ApiProperty({
description: 'JWT访问令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
access_token: string;
@ApiProperty({
description: 'JWT刷新令牌',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'
})
refresh_token: string;
@ApiProperty({
description: '访问令牌过期时间(秒)',
example: 604800
})
expires_in: number;
@ApiProperty({
description: '令牌类型',
example: 'Bearer'
})
token_type: string;
}
/**
* 令牌刷新响应DTO
*/
export class RefreshTokenResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: RefreshTokenResponseDataDto,
required: false
})
data?: RefreshTokenResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '令牌刷新成功'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TOKEN_REFRESH_FAILED',
required: false
})
error_code?: string;
}

View File

@@ -0,0 +1,164 @@
/**
* JwtAuthGuard 单元测试
*
* 功能描述:
* - 测试JWT认证守卫的令牌验证功能
* - 验证用户信息提取和注入
* - 测试认证失败的异常处理
*
* 最近修改:
* - 2026-01-14: 架构重构 - 从business层移动到gateway层 (修改者: moyin)
* - 2026-01-12: 代码规范优化 - 创建缺失的守卫测试文件 (修改者: moyin)
*
* @author moyin
* @version 1.1.0
* @since 2026-01-12
* @lastModified 2026-01-14
*/
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtAuthGuard } from './jwt_auth.guard';
import { LoginCoreService } from '../../core/login_core/login_core.service';
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let loginCoreService: jest.Mocked<LoginCoreService>;
let mockExecutionContext: jest.Mocked<ExecutionContext>;
let mockRequest: any;
beforeEach(async () => {
const mockLoginCoreService = {
verifyToken: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
JwtAuthGuard,
{
provide: LoginCoreService,
useValue: mockLoginCoreService,
},
],
}).compile();
guard = module.get<JwtAuthGuard>(JwtAuthGuard);
loginCoreService = module.get(LoginCoreService);
// Mock request object
mockRequest = {
headers: {},
user: undefined,
};
// Mock execution context
mockExecutionContext = {
switchToHttp: jest.fn().mockReturnValue({
getRequest: jest.fn().mockReturnValue(mockRequest),
}),
} as any;
});
it('should be defined', () => {
expect(guard).toBeDefined();
});
describe('canActivate', () => {
it('should allow access with valid JWT token', async () => {
const mockPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
};
mockRequest.headers.authorization = 'Bearer valid_jwt_token';
loginCoreService.verifyToken.mockResolvedValue(mockPayload);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(mockRequest.user).toEqual(mockPayload);
expect(loginCoreService.verifyToken).toHaveBeenCalledWith('valid_jwt_token', 'access');
});
it('should deny access when authorization header is missing', async () => {
mockRequest.headers.authorization = undefined;
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
});
it('should deny access when token format is invalid', async () => {
mockRequest.headers.authorization = 'InvalidFormat token';
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
});
it('should deny access when token is not Bearer type', async () => {
mockRequest.headers.authorization = 'Basic dXNlcjpwYXNz';
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
});
it('should deny access when JWT token verification fails', async () => {
mockRequest.headers.authorization = 'Bearer invalid_jwt_token';
loginCoreService.verifyToken.mockRejectedValue(new Error('Token expired'));
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).toHaveBeenCalledWith('invalid_jwt_token', 'access');
});
it('should extract token correctly from Authorization header', async () => {
const mockPayload = {
sub: '1',
username: 'testuser',
role: 1,
type: 'access' as const,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 3600,
};
mockRequest.headers.authorization = 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token';
loginCoreService.verifyToken.mockResolvedValue(mockPayload);
const result = await guard.canActivate(mockExecutionContext);
expect(result).toBe(true);
expect(loginCoreService.verifyToken).toHaveBeenCalledWith(
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test.token',
'access'
);
});
it('should handle empty token after Bearer', async () => {
mockRequest.headers.authorization = 'Bearer ';
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
});
it('should handle authorization header with only Bearer', async () => {
mockRequest.headers.authorization = 'Bearer';
await expect(guard.canActivate(mockExecutionContext))
.rejects.toThrow(UnauthorizedException);
expect(loginCoreService.verifyToken).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,119 @@
/**
* JWT 认证守卫
*
* 功能描述:
* - 验证请求中的 JWT 令牌
* - 提取用户信息并添加到请求上下文
* - 保护需要认证的路由
*
* 职责分离:
* - 专注于JWT令牌验证和用户认证
* - 提供统一的认证守卫机制
* - 处理认证失败的异常情况
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 文件重命名为snake_case格式更新注释规范
*
* @author moyin
* @version 1.0.2
* @since 2025-01-05
* @lastModified 2026-01-07
*/
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { Request } from 'express';
import { LoginCoreService, JwtPayload } from '../../core/login_core/login_core.service';
/**
* 扩展的请求接口,包含用户信息
*/
export interface AuthenticatedRequest extends Request {
user: JwtPayload;
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly logger = new Logger(JwtAuthGuard.name);
constructor(private readonly loginCoreService: LoginCoreService) {}
/**
* JWT令牌验证和用户认证
*
* 业务逻辑:
* 1. 从请求头中提取Bearer令牌
* 2. 验证令牌的有效性和签名
* 3. 解码令牌获取用户信息
* 4. 将用户信息添加到请求上下文
* 5. 记录认证成功或失败的日志
* 6. 返回认证结果
*
* @param context 执行上下文包含HTTP请求信息
* @returns Promise<boolean> 认证是否成功
* @throws UnauthorizedException 当令牌缺失或无效时
*
* @example
* ```typescript
* @Get('protected')
* @UseGuards(JwtAuthGuard)
* getProtectedData() {
* // 此方法需要有效的JWT令牌才能访问
* }
* ```
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (!token) {
this.logger.warn('访问被拒绝:缺少认证令牌');
throw new UnauthorizedException('缺少认证令牌');
}
try {
// 使用Core层服务验证JWT令牌
const payload = await this.loginCoreService.verifyToken(token, 'access');
// 将用户信息添加到请求对象
(request as AuthenticatedRequest).user = payload;
this.logger.log(`用户认证成功: ${payload.username} (ID: ${payload.sub})`);
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误';
this.logger.warn(`JWT 令牌验证失败: ${errorMessage}`);
throw new UnauthorizedException('无效的认证令牌');
}
}
/**
* 从请求头中提取JWT令牌
*
* 业务逻辑:
* 1. 获取Authorization请求头
* 2. 解析Bearer令牌格式
* 3. 验证令牌类型是否为Bearer
* 4. 返回提取的令牌字符串
*
* @param request HTTP请求对象
* @returns string | undefined JWT令牌字符串或undefined
* @throws 无异常抛出返回undefined表示令牌不存在
*
* @example
* ```typescript
* // 请求头格式Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
* const token = this.extractTokenFromHeader(request);
* ```
*/
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,150 @@
/**
* JWT 使用示例
*
* 架构层级Gateway Layer网关层
*
* 功能描述:
* - 展示如何在控制器中使用 JWT 认证守卫和当前用户装饰器
* - 提供完整的JWT认证使用示例和最佳实践
* - 演示不同场景下的认证和授权处理
*
* 职责分离:
* - 专注于JWT认证功能的使用演示
* - 提供开发者参考的代码示例
* - 展示认证守卫和装饰器的最佳实践
*
* 架构说明:
* - 本文件位于Gateway层符合Controller的架构定位
* - JWT Guard和装饰器位于同层src/gateway/auth
* - 本文件作为使用示例参考
*
* 最近修改:
* - 2026-01-14: 架构优化 - 将文件从Business层移至Gateway层符合架构分层原则 (Modified by: moyin)
* - 2026-01-14: 代码规范优化 - 修正导入路径指向Gateway层 (Modified by: moyin)
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
*
* @author moyin
* @version 1.2.0
* @since 2025-01-05
* @lastModified 2026-01-14
*/
import { Controller, Get, UseGuards, Post, Body } from '@nestjs/common';
import { JwtAuthGuard } from './jwt_auth.guard';
import { JwtPayload } from '../../core/login_core/login_core.service';
import { CurrentUser } from './current_user.decorator';
/**
* 示例控制器 - 展示 JWT 认证的使用方法
*/
@Controller('example')
export class ExampleController {
/**
* 公开接口 - 无需认证
*/
@Get('public')
getPublicData() {
return {
message: '这是一个公开接口,无需认证',
timestamp: new Date().toISOString(),
};
}
/**
* 受保护的接口 - 需要 JWT 认证
*
* 请求头示例:
* Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
*/
@Get('protected')
@UseGuards(JwtAuthGuard)
getProtectedData(@CurrentUser() user: JwtPayload) {
return {
message: '这是一个受保护的接口,需要有效的 JWT 令牌',
user: {
id: user.sub,
username: user.username,
role: user.role,
},
timestamp: new Date().toISOString(),
};
}
/**
* 获取当前用户信息
*/
@Get('profile')
@UseGuards(JwtAuthGuard)
getUserProfile(@CurrentUser() user: JwtPayload) {
return {
profile: {
userId: user.sub,
username: user.username,
role: user.role,
tokenIssuedAt: new Date(user.iat * 1000).toISOString(),
tokenExpiresAt: new Date(user.exp * 1000).toISOString(),
},
};
}
/**
* 获取用户的特定属性
*/
@Get('username')
@UseGuards(JwtAuthGuard)
getUsername(@CurrentUser('username') username: string) {
return {
username,
message: `你好,${username}`,
};
}
/**
* 需要特定角色的接口
*/
@Post('admin-only')
@UseGuards(JwtAuthGuard)
adminOnlyAction(@CurrentUser() user: JwtPayload, @Body() data: any) {
// 检查用户角色
if (user.role !== 1) { // 假设 1 是管理员角色
return {
success: false,
message: '权限不足,仅管理员可访问',
};
}
return {
success: true,
message: '管理员操作执行成功',
data,
operator: user.username,
};
}
}
/**
* 使用说明:
*
* 1. 首先调用登录接口获取 JWT 令牌:
* POST /auth/login
* {
* "identifier": "username",
* "password": "password"
* }
*
* 2. 从响应中获取 access_token
*
* 3. 在后续请求中添加 Authorization 头:
* Authorization: Bearer <access_token>
*
* 4. 访问受保护的接口:
* GET /example/protected
* GET /example/profile
* GET /example/username
* POST /example/admin-only
*
* 错误处理:
* - 401 Unauthorized: 令牌缺失或无效
* - 403 Forbidden: 令牌有效但权限不足
*/

View File

@@ -0,0 +1,209 @@
/**
* LoginController 单元测试
*
* 功能描述:
* - 测试登录控制器的HTTP请求处理
* - 验证API响应格式和状态码
* - 测试错误处理和异常情况
*
* 最近修改:
* - 2026-01-14: 架构重构 - 从business层移动到gateway层 (修改者: moyin)
* - 2026-01-12: 代码规范优化 - 创建缺失的控制器测试文件 (修改者: moyin)
*
* @author moyin
* @version 1.1.0
* @since 2026-01-12
* @lastModified 2026-01-14
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Response } from 'express';
import { HttpStatus } from '@nestjs/common';
import { LoginController } from './login.controller';
import { LoginService } from '../../business/auth/login.service';
describe('LoginController', () => {
let controller: LoginController;
let loginService: jest.Mocked<LoginService>;
let mockResponse: jest.Mocked<Response>;
beforeEach(async () => {
const mockLoginService = {
login: jest.fn(),
githubOAuth: jest.fn(),
sendPasswordResetCode: jest.fn(),
resetPassword: jest.fn(),
changePassword: jest.fn(),
verificationCodeLogin: jest.fn(),
sendLoginVerificationCode: jest.fn(),
refreshAccessToken: jest.fn(),
debugVerificationCode: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [LoginController],
providers: [
{
provide: LoginService,
useValue: mockLoginService,
},
],
}).compile();
controller = module.get<LoginController>(LoginController);
loginService = module.get(LoginService);
// Mock Response object
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as any;
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('login', () => {
it('should handle successful login', async () => {
const loginDto = {
identifier: 'testuser',
password: 'password123'
};
const mockResult = {
success: true,
data: {
user: {
id: '1',
username: 'testuser',
nickname: '测试用户',
role: 1,
created_at: new Date()
},
access_token: 'token',
refresh_token: 'refresh_token',
expires_in: 3600,
token_type: 'Bearer',
message: '登录成功'
},
message: '登录成功'
};
loginService.login.mockResolvedValue(mockResult);
await controller.login(loginDto, mockResponse);
expect(loginService.login).toHaveBeenCalledWith({
identifier: 'testuser',
password: 'password123'
});
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
it('should handle login failure', async () => {
const loginDto = {
identifier: 'testuser',
password: 'wrongpassword'
};
const mockResult = {
success: false,
message: '用户名或密码错误',
error_code: 'LOGIN_FAILED'
};
loginService.login.mockResolvedValue(mockResult);
await controller.login(loginDto, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
describe('githubOAuth', () => {
it('should handle GitHub OAuth successfully', async () => {
const githubDto = {
github_id: '12345',
username: 'githubuser',
nickname: 'GitHub User',
email: 'github@example.com'
};
const mockResult = {
success: true,
data: {
user: {
id: '1',
username: 'githubuser',
nickname: 'GitHub User',
role: 1,
created_at: new Date()
},
access_token: 'token',
refresh_token: 'refresh_token',
expires_in: 3600,
token_type: 'Bearer',
message: 'GitHub登录成功'
},
message: 'GitHub登录成功'
};
loginService.githubOAuth.mockResolvedValue(mockResult);
await controller.githubOAuth(githubDto, mockResponse);
expect(loginService.githubOAuth).toHaveBeenCalledWith(githubDto);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
describe('refreshToken', () => {
it('should handle token refresh successfully', async () => {
const refreshTokenDto = {
refresh_token: 'valid_refresh_token'
};
const mockResult = {
success: true,
data: {
access_token: 'new_access_token',
refresh_token: 'new_refresh_token',
expires_in: 3600,
token_type: 'Bearer'
},
message: '令牌刷新成功'
};
loginService.refreshAccessToken.mockResolvedValue(mockResult);
await controller.refreshToken(refreshTokenDto, mockResponse);
expect(loginService.refreshAccessToken).toHaveBeenCalledWith('valid_refresh_token');
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
it('should handle token refresh failure', async () => {
const refreshTokenDto = {
refresh_token: 'invalid_refresh_token'
};
const mockResult = {
success: false,
message: '刷新令牌无效或已过期',
error_code: 'TOKEN_REFRESH_FAILED'
};
loginService.refreshAccessToken.mockResolvedValue(mockResult);
await controller.refreshToken(refreshTokenDto, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
});

View File

@@ -0,0 +1,503 @@
/**
* 登录网关控制器
*
* 架构层级Gateway Layer网关层
*
* 功能描述:
* - 处理登录相关的HTTP请求和响应
* - 提供RESTful API接口
* - 数据验证和格式化
* - 协议处理和错误响应
*
* 职责分离:
* - 专注于HTTP协议处理和请求响应
* - 调用业务层服务完成具体功能
* - 处理API文档和参数验证
* - 不包含业务逻辑,只做数据转换和路由
*
* 依赖关系:
* - 依赖 Business Layer 的 LoginService
* - 使用 DTO 进行数据验证
* - 使用 Guard 进行认证保护
*
* API端点
* - POST /auth/login - 用户登录
* - POST /auth/github - GitHub OAuth登录
* - POST /auth/forgot-password - 发送密码重置验证码
* - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码
* - POST /auth/refresh-token - 刷新访问令牌
* - POST /auth/verification-code-login - 验证码登录
* - POST /auth/send-login-verification-code - 发送登录验证码
*
* @author moyin
* @version 2.0.0
* @since 2026-01-14
* @lastModified 2026-01-14
*/
import {
Controller,
Post,
Put,
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 { LoginService } from '../../business/auth/login.service';
import {
LoginDto,
GitHubOAuthDto,
ForgotPasswordDto,
ResetPasswordDto,
ChangePasswordDto,
VerificationCodeLoginDto,
SendLoginVerificationCodeDto,
RefreshTokenDto,
SendEmailVerificationDto
} from './dto/login.dto';
import {
LoginResponseDto,
GitHubOAuthResponseDto,
ForgotPasswordResponseDto,
CommonResponseDto,
RefreshTokenResponseDto
} from './dto/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 = {
LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
} as const;
@ApiTags('auth')
@Controller('auth')
export class LoginController {
private readonly logger = new Logger(LoginController.name);
constructor(private readonly loginService: LoginService) {}
/**
* 通用响应处理方法
*
* 职责:
* - 根据业务结果设置HTTP状态码
* - 处理不同类型的错误响应
* - 统一响应格式和错误处理
*
* @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('令牌验证失败') || result.message?.includes('已过期')) {
return HttpStatus.UNAUTHORIZED;
}
if (result.message?.includes('用户不存在')) {
return HttpStatus.NOT_FOUND;
}
return HttpStatus.BAD_REQUEST;
}
/**
* 用户登录
*
* @param loginDto 登录数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '用户登录',
description: '支持用户名、邮箱或手机号登录'
})
@ApiBody({ type: LoginDto })
@SwaggerApiResponse({
status: 200,
description: '登录成功',
type: LoginResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 401,
description: '用户名或密码错误'
})
@SwaggerApiResponse({
status: 403,
description: '账户被禁用或锁定'
})
@SwaggerApiResponse({
status: 429,
description: '登录尝试过于频繁'
})
@Throttle(ThrottlePresets.LOGIN_PER_ACCOUNT)
@Timeout(TimeoutPresets.NORMAL)
@Post('login')
@UsePipes(new ValidationPipe({ transform: true }))
async login(@Body() loginDto: LoginDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.login({
identifier: loginDto.identifier,
password: loginDto.password
});
this.handleResponse(result, res);
}
/**
* GitHub OAuth登录
*
* @param githubDto GitHub OAuth数据
* @param res Express响应对象
*/
@ApiOperation({
summary: 'GitHub OAuth登录',
description: '使用GitHub账户登录或注册'
})
@ApiBody({ type: GitHubOAuthDto })
@SwaggerApiResponse({
status: 200,
description: 'GitHub登录成功',
type: GitHubOAuthResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 401,
description: 'GitHub认证失败'
})
@Post('github')
@UsePipes(new ValidationPipe({ transform: true }))
async githubOAuth(@Body() githubDto: GitHubOAuthDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.githubOAuth({
github_id: githubDto.github_id,
username: githubDto.username,
nickname: githubDto.nickname,
email: githubDto.email,
avatar_url: githubDto.avatar_url
});
this.handleResponse(result, res);
}
/**
* 发送密码重置验证码
*
* @param forgotPasswordDto 忘记密码数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '发送密码重置验证码',
description: '向用户邮箱或手机发送密码重置验证码'
})
@ApiBody({ type: ForgotPasswordDto })
@SwaggerApiResponse({
status: 200,
description: '验证码发送成功',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在'
})
@SwaggerApiResponse({
status: 429,
description: '发送频率过高'
})
@Throttle(ThrottlePresets.SEND_CODE)
@Post('forgot-password')
@UsePipes(new ValidationPipe({ transform: true }))
async forgotPassword(
@Body() forgotPasswordDto: ForgotPasswordDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier);
this.handleResponse(result, res);
}
/**
* 重置密码
*
* @param resetPasswordDto 重置密码数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '重置密码',
description: '使用验证码重置用户密码'
})
@ApiBody({ type: ResetPasswordDto })
@SwaggerApiResponse({
status: 200,
description: '密码重置成功',
type: CommonResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误或验证码无效'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在'
})
@SwaggerApiResponse({
status: 429,
description: '重置请求过于频繁'
})
@Throttle(ThrottlePresets.RESET_PASSWORD)
@Post('reset-password')
@UsePipes(new ValidationPipe({ transform: true }))
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.resetPassword({
identifier: resetPasswordDto.identifier,
verificationCode: resetPasswordDto.verification_code,
newPassword: resetPasswordDto.new_password
});
this.handleResponse(result, res);
}
/**
* 修改密码
*
* @param changePasswordDto 修改密码数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '修改密码',
description: '用户修改自己的密码(需要提供旧密码)'
})
@ApiBody({ type: ChangePasswordDto })
@SwaggerApiResponse({
status: 200,
description: '密码修改成功',
type: CommonResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误或旧密码不正确'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在'
})
@Put('change-password')
@UsePipes(new ValidationPipe({ transform: true }))
async changePassword(@Body() changePasswordDto: ChangePasswordDto, @Res() res: Response): Promise<void> {
const userId = BigInt(changePasswordDto.user_id);
const result = await this.loginService.changePassword(
userId,
changePasswordDto.old_password,
changePasswordDto.new_password
);
this.handleResponse(result, res);
}
/**
* 验证码登录
*
* @param verificationCodeLoginDto 验证码登录数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '验证码登录',
description: '使用邮箱或手机号和验证码进行登录,无需密码'
})
@ApiBody({ type: VerificationCodeLoginDto })
@SwaggerApiResponse({
status: 200,
description: '验证码登录成功',
type: LoginResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 401,
description: '验证码错误或已过期'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在'
})
@Post('verification-code-login')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async verificationCodeLogin(
@Body() verificationCodeLoginDto: VerificationCodeLoginDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.verificationCodeLogin({
identifier: verificationCodeLoginDto.identifier,
verificationCode: verificationCodeLoginDto.verification_code
});
this.handleResponse(result, res);
}
/**
* 发送登录验证码
*
* @param sendLoginVerificationCodeDto 发送验证码数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '发送登录验证码',
description: '向用户邮箱或手机发送登录验证码'
})
@ApiBody({ type: SendLoginVerificationCodeDto })
@SwaggerApiResponse({
status: 200,
description: '验证码发送成功',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在'
})
@SwaggerApiResponse({
status: 429,
description: '发送频率过高'
})
@Post('send-login-verification-code')
@UsePipes(new ValidationPipe({ transform: true }))
async sendLoginVerificationCode(
@Body() sendLoginVerificationCodeDto: SendLoginVerificationCodeDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendLoginVerificationCode(sendLoginVerificationCodeDto.identifier);
this.handleResponse(result, res);
}
/**
* 刷新访问令牌
*
* @param refreshTokenDto 刷新令牌数据
* @param res Express响应对象
*/
@ApiOperation({
summary: '刷新访问令牌',
description: '使用有效的刷新令牌生成新的访问令牌'
})
@ApiBody({ type: RefreshTokenDto })
@SwaggerApiResponse({
status: 200,
description: '令牌刷新成功',
type: RefreshTokenResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
})
@SwaggerApiResponse({
status: 401,
description: '刷新令牌无效或已过期'
})
@SwaggerApiResponse({
status: 404,
description: '用户不存在或已被禁用'
})
@SwaggerApiResponse({
status: 429,
description: '刷新请求过于频繁'
})
@Throttle(ThrottlePresets.REFRESH_TOKEN)
@Timeout(TimeoutPresets.NORMAL)
@Post('refresh-token')
@UsePipes(new ValidationPipe({ transform: true }))
async refreshToken(@Body() refreshTokenDto: RefreshTokenDto, @Res() res: Response): Promise<void> {
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
this.handleResponse(result, res);
}
/**
* 调试验证码信息(仅开发环境)
*
* @param sendEmailVerificationDto 邮箱信息
* @param res Express响应对象
*/
@ApiOperation({
summary: '调试验证码信息',
description: '获取验证码的详细调试信息(仅开发环境)'
})
@ApiBody({ type: SendEmailVerificationDto })
@Post('debug-verification-code')
@UsePipes(new ValidationPipe({ transform: true }))
async debugVerificationCode(
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.debugVerificationCode(sendEmailVerificationDto.email);
res.status(HttpStatus.OK).json(result);
}
}

View File

@@ -0,0 +1,231 @@
/**
* RegisterController 单元测试
*
* 功能描述:
* - 测试注册控制器的HTTP请求处理
* - 验证API响应格式和状态码
* - 测试邮箱验证流程
*
* 最近修改:
* - 2026-01-14: 架构重构 - 从business层移动到gateway层 (修改者: moyin)
* - 2026-01-12: 代码规范优化 - 创建缺失的控制器测试文件 (修改者: moyin)
*
* @author moyin
* @version 1.1.0
* @since 2026-01-12
* @lastModified 2026-01-14
*/
import { Test, TestingModule } from '@nestjs/testing';
import { Response } from 'express';
import { HttpStatus } from '@nestjs/common';
import { RegisterController } from './register.controller';
import { RegisterService } from '../../business/auth/register.service';
describe('RegisterController', () => {
let controller: RegisterController;
let registerService: jest.Mocked<RegisterService>;
let mockResponse: jest.Mocked<Response>;
beforeEach(async () => {
const mockRegisterService = {
register: jest.fn(),
sendEmailVerification: jest.fn(),
verifyEmailCode: jest.fn(),
resendEmailVerification: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [RegisterController],
providers: [
{
provide: RegisterService,
useValue: mockRegisterService,
},
],
}).compile();
controller = module.get<RegisterController>(RegisterController);
registerService = module.get(RegisterService);
// Mock Response object
mockResponse = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
} as any;
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('register', () => {
it('should handle successful registration', async () => {
const registerDto = {
username: 'newuser',
password: 'password123',
nickname: '新用户',
email: 'newuser@example.com',
email_verification_code: '123456'
};
const mockResult = {
success: true,
data: {
user: {
id: '1',
username: 'newuser',
nickname: '新用户',
role: 1,
created_at: new Date()
},
access_token: 'token',
refresh_token: 'refresh_token',
expires_in: 3600,
token_type: 'Bearer',
is_new_user: true,
message: '注册成功'
},
message: '注册成功'
};
registerService.register.mockResolvedValue(mockResult);
await controller.register(registerDto, mockResponse);
expect(registerService.register).toHaveBeenCalledWith(registerDto);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CREATED);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
it('should handle registration failure', async () => {
const registerDto = {
username: 'existinguser',
password: 'password123',
nickname: '用户'
};
const mockResult = {
success: false,
message: '用户名已存在',
error_code: 'REGISTER_FAILED'
};
registerService.register.mockResolvedValue(mockResult);
await controller.register(registerDto, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
describe('sendEmailVerification', () => {
it('should handle email verification in production mode', async () => {
const sendEmailDto = {
email: 'test@example.com'
};
const mockResult = {
success: true,
data: { is_test_mode: false },
message: '验证码已发送,请查收邮件'
};
registerService.sendEmailVerification.mockResolvedValue(mockResult);
await controller.sendEmailVerification(sendEmailDto, mockResponse);
expect(registerService.sendEmailVerification).toHaveBeenCalledWith('test@example.com');
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
it('should handle email verification in test mode', async () => {
const sendEmailDto = {
email: 'test@example.com'
};
const mockResult = {
success: false,
data: {
verification_code: '123456',
is_test_mode: true
},
message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
error_code: 'TEST_MODE_ONLY'
};
registerService.sendEmailVerification.mockResolvedValue(mockResult);
await controller.sendEmailVerification(sendEmailDto, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.PARTIAL_CONTENT);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
describe('verifyEmail', () => {
it('should handle email verification successfully', async () => {
const verifyEmailDto = {
email: 'test@example.com',
verification_code: '123456'
};
const mockResult = {
success: true,
message: '邮箱验证成功'
};
registerService.verifyEmailCode.mockResolvedValue(mockResult);
await controller.verifyEmail(verifyEmailDto, mockResponse);
expect(registerService.verifyEmailCode).toHaveBeenCalledWith('test@example.com', '123456');
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
it('should handle invalid verification code', async () => {
const verifyEmailDto = {
email: 'test@example.com',
verification_code: '000000'
};
const mockResult = {
success: false,
message: '验证码错误',
error_code: 'INVALID_VERIFICATION_CODE'
};
registerService.verifyEmailCode.mockResolvedValue(mockResult);
await controller.verifyEmail(verifyEmailDto, mockResponse);
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
describe('resendEmailVerification', () => {
it('should handle resend email verification successfully', async () => {
const sendEmailDto = {
email: 'test@example.com'
};
const mockResult = {
success: true,
data: { is_test_mode: false },
message: '验证码已重新发送,请查收邮件'
};
registerService.resendEmailVerification.mockResolvedValue(mockResult);
await controller.resendEmailVerification(sendEmailDto, mockResponse);
expect(registerService.resendEmailVerification).toHaveBeenCalledWith('test@example.com');
expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.OK);
expect(mockResponse.json).toHaveBeenCalledWith(mockResult);
});
});
});

View File

@@ -0,0 +1,285 @@
/**
* 注册网关控制器
*
* 架构层级Gateway Layer网关层
*
* 功能描述:
* - 处理用户注册相关的HTTP请求和响应
* - 提供RESTful API接口
* - 数据验证和格式化
* - 邮箱验证功能
*
* 职责分离:
* - 专注于HTTP协议处理和请求响应
* - 调用业务层服务完成具体功能
* - 处理API文档和参数验证
* - 不包含业务逻辑,只做数据转换和路由
*
* 依赖关系:
* - 依赖 Business Layer 的 RegisterService
* - 使用 DTO 进行数据验证
*
* API端点
* - POST /auth/register - 用户注册
* - POST /auth/send-email-verification - 发送邮箱验证码
* - POST /auth/verify-email - 验证邮箱验证码
* - POST /auth/resend-email-verification - 重新发送邮箱验证码
*
* @author moyin
* @version 2.0.0
* @since 2026-01-14
* @lastModified 2026-01-14
*/
import {
Controller,
Post,
Body,
HttpStatus,
ValidationPipe,
UsePipes,
Logger,
Res
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse as SwaggerApiResponse,
ApiBody
} from '@nestjs/swagger';
import { Response } from 'express';
import { RegisterService } from '../../business/auth/register.service';
import {
RegisterDto,
EmailVerificationDto,
SendEmailVerificationDto
} from './dto/login.dto';
import {
RegisterResponseDto,
CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto
} from './dto/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) {}
/**
* 通用响应处理方法
*
* 职责:
* - 根据业务结果设置HTTP状态码
* - 处理不同类型的错误响应
* - 统一响应格式和错误处理
*
* @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;
}
return HttpStatus.BAD_REQUEST;
}
/**
* 用户注册
*
* @param registerDto 注册数据
* @param res Express响应对象
*/
@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响应对象
*/
@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 邮箱验证数据
* @param res Express响应对象
*/
@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响应对象
*/
@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);
}
}