Files
whale-town-end/src/business/auth/login.controller.ts
moyin bb796a2469 refactor:项目架构重构和命名规范化
- 统一文件命名为snake_case格式(kebab-case  snake_case)
- 重构zulip模块为zulip_core,明确Core层职责
- 重构user-mgmt模块为user_mgmt,统一命名规范
- 调整模块依赖关系,优化架构分层
- 删除过时的文件和目录结构
- 更新相关文档和配置文件

本次重构涉及大量文件重命名和模块重组,
旨在建立更清晰的项目架构和统一的命名规范。
2026-01-08 00:14:14 +08:00

731 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 登录控制器
*
* 功能描述:
* - 处理登录相关的HTTP请求和响应
* - 提供RESTful API接口
* - 数据验证和格式化
*
* 职责分离:
* - 专注于HTTP请求处理和响应格式化
* - 调用业务服务完成具体功能
* - 处理API文档和参数验证
*
* API端点
* - POST /auth/login - 用户登录
* - POST /auth/register - 用户注册
* - POST /auth/github - GitHub OAuth登录
* - POST /auth/forgot-password - 发送密码重置验证码
* - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码
* - POST /auth/refresh-token - 刷新访问令牌
*
* 最近修改:
* - 2026-01-07: 代码规范优化 - 文件夹扁平化,移除单文件文件夹结构
* - 2026-01-07: 代码规范优化 - 更新注释规范,修正文件引用路径
*
* @author moyin
* @version 1.0.2
* @since 2025-12-17
* @lastModified 2026-01-07
*/
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, ApiResponse, LoginResponse } from './login.service';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto, RefreshTokenDto } from './login.dto';
import {
LoginResponseDto,
RegisterResponseDto,
GitHubOAuthResponseDto,
ForgotPasswordResponseDto,
CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto,
RefreshTokenResponseDto
} from './login_response.dto';
import { Throttle, ThrottlePresets } from '../../core/security_core/throttle.decorator';
import { Timeout, TimeoutPresets } from '../../core/security_core/timeout.decorator';
// 错误代码到HTTP状态码的映射
const ERROR_STATUS_MAP = {
LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
REGISTER_FAILED: HttpStatus.BAD_REQUEST,
TEST_MODE_ONLY: HttpStatus.PARTIAL_CONTENT,
TOKEN_REFRESH_FAILED: HttpStatus.UNAUTHORIZED,
GITHUB_OAUTH_FAILED: HttpStatus.UNAUTHORIZED,
SEND_CODE_FAILED: HttpStatus.BAD_REQUEST,
RESET_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
CHANGE_PASSWORD_FAILED: HttpStatus.BAD_REQUEST,
EMAIL_VERIFICATION_FAILED: HttpStatus.BAD_REQUEST,
VERIFICATION_CODE_LOGIN_FAILED: HttpStatus.UNAUTHORIZED,
INVALID_VERIFICATION_CODE: HttpStatus.BAD_REQUEST,
} as const;
@ApiTags('auth')
@Controller('auth')
export class LoginController {
private readonly logger = new Logger(LoginController.name);
constructor(private readonly loginService: LoginService) {}
/**
* 通用响应处理方法
*
* 业务逻辑:
* 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('令牌验证失败') || result.message?.includes('已过期')) {
return HttpStatus.UNAUTHORIZED;
}
if (result.message?.includes('用户不存在')) {
return HttpStatus.NOT_FOUND;
}
// 默认返回400
return HttpStatus.BAD_REQUEST;
}
/**
* 用户登录
*
* @param loginDto 登录数据
* @returns 登录结果
*/
@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)
@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);
}
/**
* 用户注册
*
* @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登录
*
* @param githubDto GitHub OAuth数据
* @returns 登录结果
*/
@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响应对象
* @returns 发送结果
*/
@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 重置密码数据
* @returns 重置结果
*/
@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 修改密码数据
* @returns 修改结果
*/
@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> {
// 实际应用中应从JWT令牌中获取用户ID
// 这里为了演示使用请求体中的用户ID
const userId = BigInt(changePasswordDto.user_id);
const result = await this.loginService.changePassword(
userId,
changePasswordDto.old_password,
changePasswordDto.new_password
);
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)
@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);
}
/**
* 验证码登录
*
* @param verificationCodeLoginDto 验证码登录数据
* @returns 登录结果
*/
@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): Promise<ApiResponse<LoginResponse>> {
return await this.loginService.verificationCodeLogin({
identifier: verificationCodeLoginDto.identifier,
verificationCode: verificationCodeLoginDto.verification_code
});
}
/**
* 发送登录验证码
*
* @param sendLoginVerificationCodeDto 发送验证码数据
* @param res Express响应对象
* @returns 发送结果
*/
@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 sendEmailVerificationDto 邮箱信息
* @returns 验证码调试信息
*/
@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);
// 调试接口总是返回200
res.status(HttpStatus.OK).json(result);
}
/**
* 清除限流记录(仅开发环境)
*/
@ApiOperation({
summary: '清除限流记录',
description: '清除所有限流记录(仅开发环境使用)'
})
@Post('debug-clear-throttle')
async clearThrottle(@Res() res: Response): Promise<void> {
// 注入ThrottleGuard并清除记录
// 这里需要通过依赖注入获取ThrottleGuard实例
res.status(HttpStatus.OK).json({
success: true,
message: '限流记录已清除'
});
}
/**
* 刷新访问令牌
*
* 功能描述:
* 使用有效的刷新令牌生成新的访问令牌,实现无感知的令牌续期
*
* 业务逻辑:
* 1. 验证刷新令牌的有效性和格式
* 2. 检查用户状态是否正常
* 3. 生成新的JWT令牌对
* 4. 返回新的访问令牌和刷新令牌
*
* @param refreshTokenDto 刷新令牌数据
* @param res Express响应对象
* @returns 新的令牌对
*/
@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 startTime = Date.now();
try {
this.logRefreshTokenStart();
const result = await this.loginService.refreshAccessToken(refreshTokenDto.refresh_token);
this.handleRefreshTokenResponse(result, res, startTime);
} catch (error) {
this.handleRefreshTokenError(error, res, startTime);
}
}
/**
* 记录令牌刷新开始日志
* @private
*/
private logRefreshTokenStart(): void {
this.logger.log('令牌刷新请求', {
operation: 'refreshToken',
timestamp: new Date().toISOString(),
});
}
/**
* 处理令牌刷新响应
* @private
*/
private handleRefreshTokenResponse(result: any, res: Response, startTime: number): void {
const duration = Date.now() - startTime;
if (result.success) {
this.logger.log('令牌刷新成功', {
operation: 'refreshToken',
duration,
timestamp: new Date().toISOString(),
});
res.status(HttpStatus.OK).json(result);
} else {
this.logger.warn('令牌刷新失败', {
operation: 'refreshToken',
error: result.message,
errorCode: result.error_code,
duration,
timestamp: new Date().toISOString(),
});
this.handleResponse(result, res);
}
}
/**
* 处理令牌刷新异常
* @private
*/
private handleRefreshTokenError(error: unknown, res: Response, startTime: number): void {
const duration = Date.now() - startTime;
const err = error as Error;
this.logger.error('令牌刷新异常', {
operation: 'refreshToken',
error: err.message,
duration,
timestamp: new Date().toISOString(),
}, err.stack);
res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
message: '服务器内部错误',
error_code: 'INTERNAL_SERVER_ERROR'
});
}
}