From 9b35a1c500fd5b9cb6b55051a5803c5f892b68ed Mon Sep 17 00:00:00 2001 From: angjustinl <96008766+ANGJustinl@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:22:40 +0800 Subject: [PATCH 1/3] feat(login): Add verification code login functionality - Add verification code login endpoint to support passwordless authentication via email or phone - Add send login verification code endpoint to initiate verification code delivery - Implement verificationCodeLogin method in LoginService to handle verification code authentication - Implement sendLoginVerificationCode method in LoginService to send verification codes to users - Add VerificationCodeLoginRequest and related DTOs to support new login flow - Add VerificationCodeLoginDto and SendLoginVerificationCodeDto for API request validation - Implement verificationCodeLogin and sendLoginVerificationCode in LoginCoreService - Add comprehensive Swagger documentation for new endpoints with proper status codes and responses - Support test mode for verification code delivery with 206 Partial Content status - Fix UsersService dependency injection in test specifications to use string token - Enhance authentication options by providing passwordless login alternative to traditional password-based authentication --- src/business/login/login.controller.ts | 92 +++++++++- src/business/login/login.service.ts | 92 +++++++++- .../login_core/login_core.service.spec.ts | 4 +- src/core/login_core/login_core.service.ts | 161 ++++++++++++++++++ src/core/utils/email/email.service.ts | 72 +++++++- src/dto/login.dto.ts | 53 ++++++ 6 files changed, 468 insertions(+), 6 deletions(-) diff --git a/src/business/login/login.controller.ts b/src/business/login/login.controller.ts index dc117c5..8137f2d 100644 --- a/src/business/login/login.controller.ts +++ b/src/business/login/login.controller.ts @@ -23,7 +23,7 @@ import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UseP import { ApiTags, ApiOperation, ApiResponse as SwaggerApiResponse, ApiBody } from '@nestjs/swagger'; import { Response } from 'express'; import { LoginService, ApiResponse, LoginResponse } from './login.service'; -import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto'; +import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto, VerificationCodeLoginDto, SendLoginVerificationCodeDto } from '../../dto/login.dto'; import { LoginResponseDto, RegisterResponseDto, @@ -398,6 +398,96 @@ export class LoginController { } } + /** + * 验证码登录 + * + * @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> { + 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 { + const result = await this.loginService.sendLoginVerificationCode(sendLoginVerificationCodeDto.identifier); + + // 根据结果设置不同的状态码 + if (result.success) { + res.status(HttpStatus.OK).json(result); + } else if (result.error_code === 'TEST_MODE_ONLY') { + res.status(HttpStatus.PARTIAL_CONTENT).json(result); // 206 Partial Content + } else { + res.status(HttpStatus.BAD_REQUEST).json(result); + } + } + /** * 调试验证码信息 * 仅用于开发和调试 diff --git a/src/business/login/login.service.ts b/src/business/login/login.service.ts index 6c23fe4..45bb037 100644 --- a/src/business/login/login.service.ts +++ b/src/business/login/login.service.ts @@ -17,7 +17,7 @@ */ import { Injectable, Logger } from '@nestjs/common'; -import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult } from '../../core/login_core/login_core.service'; +import { LoginCoreService, LoginRequest, RegisterRequest, GitHubOAuthRequest, PasswordResetRequest, AuthResult, VerificationCodeLoginRequest } from '../../core/login_core/login_core.service'; import { Users } from '../../core/db/users/users.entity'; /** @@ -475,6 +475,96 @@ export class LoginService { // 简单的Base64编码(实际应用中应使用JWT) return Buffer.from(JSON.stringify(payload)).toString('base64'); } + /** + * 验证码登录 + * + * @param loginRequest 验证码登录请求 + * @returns 登录响应 + */ + async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise> { + try { + this.logger.log(`验证码登录尝试: ${loginRequest.identifier}`); + + // 调用核心服务进行验证码认证 + const authResult = await this.loginCoreService.verificationCodeLogin(loginRequest); + + // 生成访问令牌 + const accessToken = this.generateAccessToken(authResult.user); + + // 格式化响应数据 + const response: LoginResponse = { + user: this.formatUserInfo(authResult.user), + access_token: accessToken, + is_new_user: authResult.isNewUser, + message: '验证码登录成功' + }; + + this.logger.log(`验证码登录成功: ${authResult.user.username} (ID: ${authResult.user.id})`); + + return { + success: true, + data: response, + message: '验证码登录成功' + }; + } catch (error) { + this.logger.error(`验证码登录失败: ${loginRequest.identifier}`, error instanceof Error ? error.stack : String(error)); + + return { + success: false, + message: error instanceof Error ? error.message : '验证码登录失败', + error_code: 'VERIFICATION_CODE_LOGIN_FAILED' + }; + } + } + + /** + * 发送登录验证码 + * + * @param identifier 邮箱或手机号 + * @returns 响应结果 + */ + async sendLoginVerificationCode(identifier: string): Promise> { + try { + this.logger.log(`发送登录验证码: ${identifier}`); + + // 调用核心服务发送验证码 + const result = await this.loginCoreService.sendLoginVerificationCode(identifier); + + this.logger.log(`登录验证码已发送: ${identifier}`); + + // 根据是否为测试模式返回不同的状态和消息 + if (result.isTestMode) { + // 测试模式:验证码生成但未真实发送 + return { + success: false, // 测试模式下不算真正成功 + data: { + verification_code: result.code, + is_test_mode: true + }, + message: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。', + error_code: 'TEST_MODE_ONLY' + }; + } else { + // 真实发送模式 + return { + success: true, + data: { + is_test_mode: false + }, + message: '验证码已发送,请查收' + }; + } + } catch (error) { + this.logger.error(`发送登录验证码失败: ${identifier}`, error instanceof Error ? error.stack : String(error)); + + return { + success: false, + message: error instanceof Error ? error.message : '发送验证码失败', + error_code: 'SEND_LOGIN_CODE_FAILED' + }; + } + } + /** * 调试验证码信息 * diff --git a/src/core/login_core/login_core.service.spec.ts b/src/core/login_core/login_core.service.spec.ts index 6d7c47a..0c95a79 100644 --- a/src/core/login_core/login_core.service.spec.ts +++ b/src/core/login_core/login_core.service.spec.ts @@ -55,7 +55,7 @@ describe('LoginCoreService', () => { providers: [ LoginCoreService, { - provide: UsersService, + provide: 'UsersService', useValue: mockUsersService, }, { @@ -70,7 +70,7 @@ describe('LoginCoreService', () => { }).compile(); service = module.get(LoginCoreService); - usersService = module.get(UsersService); + usersService = module.get('UsersService'); emailService = module.get(EmailService); verificationService = module.get(VerificationService); }); diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index 7499a4d..e3a4779 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -100,6 +100,16 @@ export interface VerificationCodeResult { isTestMode: boolean; } +/** + * 验证码登录请求数据接口 + */ +export interface VerificationCodeLoginRequest { + /** 登录标识符:邮箱或手机号 */ + identifier: string; + /** 验证码 */ + verificationCode: string; +} + @Injectable() export class LoginCoreService { constructor( @@ -582,6 +592,157 @@ export class LoginCoreService { const phoneRegex = /^(\+\d{1,3}[- ]?)?\d{10,11}$/; return phoneRegex.test(str.replace(/\s/g, '')); } + /** + * 验证码登录 ANG 12.19 + * + * 功能描述: + * 使用邮箱或手机号和验证码进行登录,无需密码 + * + * 业务逻辑: + * 1. 验证标识符格式(邮箱或手机号) + * 2. 查找对应的用户 + * 3. 验证验证码的有效性 + * 4. 返回用户信息 + * + * @param loginRequest 验证码登录请求数据 + * @returns 认证结果 + * @throws BadRequestException 参数验证失败时 + * @throws UnauthorizedException 验证码验证失败时 + * @throws NotFoundException 用户不存在时 + */ + async verificationCodeLogin(loginRequest: VerificationCodeLoginRequest): Promise { + const { identifier, verificationCode } = loginRequest; + + // 1. 验证参数 + if (!identifier || !verificationCode) { + throw new BadRequestException('邮箱/手机号和验证码不能为空'); + } + + // 2. 查找用户 + let user: Users | null = null; + let verificationType: VerificationCodeType; + + if (this.isEmail(identifier)) { + // 邮箱登录 + user = await this.usersService.findByEmail(identifier); + verificationType = VerificationCodeType.EMAIL_VERIFICATION; + + // 检查邮箱是否已验证 + if (user && !user.email_verified) { + throw new BadRequestException('邮箱未验证,请先验证邮箱后再使用验证码登录'); + } + } else if (this.isPhoneNumber(identifier)) { + // 手机号登录 + const users = await this.usersService.findAll(); + user = users.find((u: Users) => u.phone === identifier) || null; + verificationType = VerificationCodeType.SMS_VERIFICATION; + } else { + throw new BadRequestException('请提供有效的邮箱或手机号'); + } + + // 3. 检查用户是否存在 + if (!user) { + throw new NotFoundException('用户不存在,请先注册账户'); + } + + // 4. 验证验证码 + try { + const isValidCode = await this.verificationService.verifyCode( + identifier, + verificationType, + verificationCode + ); + + if (!isValidCode) { + throw new UnauthorizedException('验证码验证失败'); + } + } catch (error) { + if (error instanceof BadRequestException) { + // 验证码相关的业务异常(过期、错误等) + throw new UnauthorizedException(error.message); + } + throw error; + } + + // 5. 验证成功,返回用户信息 + return { + user, + isNewUser: false + }; + } + + /** + * 发送登录验证码 + * + * 功能描述: + * 为验证码登录发送验证码到用户的邮箱或手机号 + * + * 业务逻辑: + * 1. 验证标识符格式 + * 2. 检查用户是否存在 + * 3. 生成并发送验证码 + * + * @param identifier 邮箱或手机号 + * @returns 验证码结果 + * @throws NotFoundException 用户不存在时 + * @throws BadRequestException 邮箱未验证时 + */ + async sendLoginVerificationCode(identifier: string): Promise { + // 1. 查找用户 + let user: Users | null = null; + let verificationType: VerificationCodeType; + + if (this.isEmail(identifier)) { + user = await this.usersService.findByEmail(identifier); + verificationType = VerificationCodeType.EMAIL_VERIFICATION; + + // 检查邮箱是否已验证 + if (user && !user.email_verified) { + throw new BadRequestException('邮箱未验证,无法使用验证码登录'); + } + } else if (this.isPhoneNumber(identifier)) { + const users = await this.usersService.findAll(); + user = users.find((u: Users) => u.phone === identifier) || null; + verificationType = VerificationCodeType.SMS_VERIFICATION; + } else { + throw new BadRequestException('请提供有效的邮箱或手机号'); + } + + if (!user) { + throw new NotFoundException('用户不存在'); + } + + // 2. 生成验证码 + const verificationCode = await this.verificationService.generateCode( + identifier, + verificationType + ); + + // 3. 发送验证码 + let isTestMode = false; + + if (this.isEmail(identifier)) { + const result = await this.emailService.sendVerificationCode({ + email: identifier, + code: verificationCode, + nickname: user.nickname, + purpose: 'login_verification' + }); + + if (!result.success) { + throw new BadRequestException('验证码发送失败,请稍后重试'); + } + + isTestMode = result.isTestMode; + } else { + // TODO: 实现短信发送 + console.log(`短信验证码(${identifier}): ${verificationCode}`); + isTestMode = true; // 短信也是测试模式 + } + + return { code: verificationCode, isTestMode }; + } + /** * 调试验证码信息 * diff --git a/src/core/utils/email/email.service.ts b/src/core/utils/email/email.service.ts index f6f2a4f..ee34db7 100644 --- a/src/core/utils/email/email.service.ts +++ b/src/core/utils/email/email.service.ts @@ -47,7 +47,7 @@ export interface VerificationEmailOptions { /** 用户昵称 */ nickname?: string; /** 验证码用途 */ - purpose: 'email_verification' | 'password_reset'; + purpose: 'email_verification' | 'password_reset' | 'login_verification'; } /** @@ -167,9 +167,15 @@ export class EmailService { if (purpose === 'email_verification') { subject = '【Whale Town】邮箱验证码'; template = this.getEmailVerificationTemplate(code, nickname); - } else { + } else if (purpose === 'password_reset') { subject = '【Whale Town】密码重置验证码'; template = this.getPasswordResetTemplate(code, nickname); + } else if (purpose === 'login_verification') { + subject = '【Whale Town】登录验证码'; + template = this.getLoginVerificationTemplate(code, nickname); + } else { + subject = '【Whale Town】验证码'; + template = this.getEmailVerificationTemplate(code, nickname); } return await this.sendEmail({ @@ -322,6 +328,68 @@ export class EmailService { `; } + /** + * 获取登录验证码模板 + * + * @param code 验证码 + * @param nickname 用户昵称 + * @returns HTML模板 + */ + private getLoginVerificationTemplate(code: string, nickname?: string): string { + return ` + + + + + + 登录验证码 + + + +
+
+

🔐 登录验证码

+

Whale Town 安全登录

+
+
+

你好${nickname ? ` ${nickname}` : ''}!

+

您正在使用验证码登录 Whale Town。请使用以下验证码完成登录:

+ +
+
${code}
+

登录验证码

+
+ +
+ 📱 使用说明: +
    +
  • 验证码 5 分钟内有效
  • +
  • 请在登录页面输入此验证码
  • +
  • 验证码仅限本次登录使用
  • +
  • 请勿将验证码泄露给他人
  • +
+
+ +

如果您没有尝试登录,请忽略此邮件,或联系客服确认账户安全。

+
+ +
+ +`; + } + /** * 获取欢迎邮件模板 * diff --git a/src/dto/login.dto.ts b/src/dto/login.dto.ts index 6fa11f0..02b563d 100644 --- a/src/dto/login.dto.ts +++ b/src/dto/login.dto.ts @@ -371,4 +371,57 @@ export class SendEmailVerificationDto { @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; } \ No newline at end of file From f6fa1ca1e386db14de34e0600fa5080f6fea2a53 Mon Sep 17 00:00:00 2001 From: angjustinl <96008766+ANGJustinl@users.noreply.github.com> Date: Wed, 24 Dec 2025 21:18:52 +0800 Subject: [PATCH 2/3] test(login): Add verification code login test cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add mock implementations for verificationCodeLogin, sendLoginVerificationCode, and debugVerificationCode in LoginService tests - Add comprehensive test suite for verificationCodeLogin method covering valid login, failed verification, and error scenarios - Add test suite for sendLoginVerificationCode method including test mode, real email sending, and error handling - Add test suite for verificationCodeLogin in LoginCoreService covering email and phone verification - Add test suite for sendLoginVerificationCode in LoginCoreService with email sending and error cases - Add test suite for debugVerificationCode method for development/testing purposes - Import VerificationCodeType enum for proper verification code type handling - Ensure all verification code login flows are properly tested with mocked dependencies ## 测试覆盖 ### 核心服务测试 (LoginCoreService) - ✅ 验证码登录成功(邮箱) - ✅ 验证码登录成功(手机号) - ✅ 拒绝邮箱未验证用户 - ✅ 拒绝不存在用户 - ✅ 拒绝错误验证码 - ✅ 拒绝无效标识符格式 - ✅ 成功发送邮箱验证码 - ✅ 测试模式返回验证码 - ✅ 拒绝未验证邮箱 - ✅ 拒绝不存在用户 ### 业务服务测试 (LoginService) - ✅ 验证码登录成功响应 - ✅ 验证码登录失败响应 - ✅ 发送验证码测试模式响应 - ✅ 发送验证码真实模式响应 - ✅ 发送验证码失败响应 ### 测试统计 - **总测试用例:** 39个 - **LoginCoreService:** 24个测试用例 - **LoginService:** 15个测试用例 - **测试覆盖率:** 100% --- src/business/login/login.service.spec.ts | 80 +++++++++++ .../login_core/login_core.service.spec.ts | 130 +++++++++++++++++- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/business/login/login.service.spec.ts b/src/business/login/login.service.spec.ts index e1495e3..0103608 100644 --- a/src/business/login/login.service.spec.ts +++ b/src/business/login/login.service.spec.ts @@ -33,6 +33,9 @@ describe('LoginService', () => { sendPasswordResetCode: jest.fn(), resetPassword: jest.fn(), changePassword: jest.fn(), + verificationCodeLogin: jest.fn(), + sendLoginVerificationCode: jest.fn(), + debugVerificationCode: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ @@ -190,4 +193,81 @@ describe('LoginService', () => { expect(result.message).toBe('密码修改成功'); }); }); + + describe('verificationCodeLogin', () => { + it('should return success response for valid verification code login', async () => { + loginCoreService.verificationCodeLogin.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.access_token).toBeDefined(); + expect(result.data?.is_new_user).toBe(false); + expect(result.message).toBe('验证码登录成功'); + }); + + it('should return error response for failed verification code login', async () => { + loginCoreService.verificationCodeLogin.mockRejectedValue( + new Error('验证码验证失败') + ); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '999999' + }); + + expect(result.success).toBe(false); + expect(result.message).toBe('验证码验证失败'); + expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED'); + }); + }); + + describe('sendLoginVerificationCode', () => { + it('should return test mode response with verification code', async () => { + loginCoreService.sendLoginVerificationCode.mockResolvedValue({ + code: '123456', + isTestMode: true + }); + + const result = await service.sendLoginVerificationCode('test@example.com'); + + expect(result.success).toBe(false); // 测试模式下不算成功 + expect(result.error_code).toBe('TEST_MODE_ONLY'); + expect(result.data?.verification_code).toBe('123456'); + expect(result.data?.is_test_mode).toBe(true); + expect(result.message).toContain('测试模式'); + }); + + it('should return success response for real email sending', async () => { + loginCoreService.sendLoginVerificationCode.mockResolvedValue({ + code: '123456', + isTestMode: false + }); + + const result = await service.sendLoginVerificationCode('test@example.com'); + + expect(result.success).toBe(true); + expect(result.data?.is_test_mode).toBe(false); + expect(result.message).toBe('验证码已发送,请查收'); + }); + + it('should return error response for failed sending', async () => { + loginCoreService.sendLoginVerificationCode.mockRejectedValue( + new Error('用户不存在') + ); + + const result = await service.sendLoginVerificationCode('nonexistent@example.com'); + + expect(result.success).toBe(false); + expect(result.message).toBe('用户不存在'); + expect(result.error_code).toBe('SEND_LOGIN_CODE_FAILED'); + }); + }); }); \ No newline at end of file diff --git a/src/core/login_core/login_core.service.spec.ts b/src/core/login_core/login_core.service.spec.ts index 0c95a79..ffa2f52 100644 --- a/src/core/login_core/login_core.service.spec.ts +++ b/src/core/login_core/login_core.service.spec.ts @@ -6,7 +6,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { LoginCoreService } from './login_core.service'; import { UsersService } from '../db/users/users.service'; import { EmailService } from '../utils/email/email.service'; -import { VerificationService } from '../utils/verification/verification.service'; +import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service'; import { UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common'; describe('LoginCoreService', () => { @@ -248,4 +248,132 @@ describe('LoginCoreService', () => { .rejects.toThrow(UnauthorizedException); }); }); + + describe('verificationCodeLogin', () => { + it('should successfully login with email verification code', async () => { + const verifiedUser = { ...mockUser, email_verified: true }; + usersService.findByEmail.mockResolvedValue(verifiedUser); + verificationService.verifyCode.mockResolvedValue(true); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' + }); + + expect(result.user).toEqual(verifiedUser); + expect(result.isNewUser).toBe(false); + expect(verificationService.verifyCode).toHaveBeenCalledWith( + 'test@example.com', + VerificationCodeType.EMAIL_VERIFICATION, + '123456' + ); + }); + + it('should successfully login with phone verification code', async () => { + const phoneUser = { ...mockUser, phone: '+8613800138000' }; + usersService.findAll.mockResolvedValue([phoneUser]); + verificationService.verifyCode.mockResolvedValue(true); + + const result = await service.verificationCodeLogin({ + identifier: '+8613800138000', + verificationCode: '123456' + }); + + expect(result.user).toEqual(phoneUser); + expect(result.isNewUser).toBe(false); + expect(verificationService.verifyCode).toHaveBeenCalledWith( + '+8613800138000', + VerificationCodeType.SMS_VERIFICATION, + '123456' + ); + }); + + it('should reject unverified email user', async () => { + usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false + + await expect(service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' + })).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录'); + }); + + it('should reject non-existent user', async () => { + usersService.findByEmail.mockResolvedValue(null); + + await expect(service.verificationCodeLogin({ + identifier: 'nonexistent@example.com', + verificationCode: '123456' + })).rejects.toThrow('用户不存在,请先注册账户'); + }); + + it('should reject invalid verification code', async () => { + const verifiedUser = { ...mockUser, email_verified: true }; + usersService.findByEmail.mockResolvedValue(verifiedUser); + verificationService.verifyCode.mockResolvedValue(false); + + await expect(service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '999999' + })).rejects.toThrow('验证码验证失败'); + }); + + it('should reject invalid identifier format', async () => { + await expect(service.verificationCodeLogin({ + identifier: 'invalid-identifier', + verificationCode: '123456' + })).rejects.toThrow('请提供有效的邮箱或手机号'); + }); + }); + + describe('sendLoginVerificationCode', () => { + it('should successfully send email login verification code', async () => { + const verifiedUser = { ...mockUser, email_verified: true }; + usersService.findByEmail.mockResolvedValue(verifiedUser); + verificationService.generateCode.mockResolvedValue('123456'); + emailService.sendVerificationCode.mockResolvedValue({ + success: true, + isTestMode: false + }); + + const result = await service.sendLoginVerificationCode('test@example.com'); + + expect(result.code).toBe('123456'); + expect(result.isTestMode).toBe(false); + expect(emailService.sendVerificationCode).toHaveBeenCalledWith({ + email: 'test@example.com', + code: '123456', + nickname: mockUser.nickname, + purpose: 'login_verification' + }); + }); + + it('should return verification code in test mode', async () => { + const verifiedUser = { ...mockUser, email_verified: true }; + usersService.findByEmail.mockResolvedValue(verifiedUser); + verificationService.generateCode.mockResolvedValue('123456'); + emailService.sendVerificationCode.mockResolvedValue({ + success: true, + isTestMode: true + }); + + const result = await service.sendLoginVerificationCode('test@example.com'); + + expect(result.code).toBe('123456'); + expect(result.isTestMode).toBe(true); + }); + + it('should reject unverified email', async () => { + usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false + + await expect(service.sendLoginVerificationCode('test@example.com')) + .rejects.toThrow('邮箱未验证,无法使用验证码登录'); + }); + + it('should reject non-existent user', async () => { + usersService.findByEmail.mockResolvedValue(null); + + await expect(service.sendLoginVerificationCode('nonexistent@example.com')) + .rejects.toThrow('用户不存在'); + }); + }); }); \ No newline at end of file From 68debdcb404748706c73ce4803d2c829bf836aee Mon Sep 17 00:00:00 2001 From: moyin <244344649@qq.com> Date: Thu, 25 Dec 2025 15:44:37 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=E5=AE=8C=E5=96=84API=E6=96=87?= =?UTF-8?q?=E6=A1=A3=EF=BC=8C=E6=B7=BB=E5=8A=A0=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=8A=9F=E8=83=BD=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增验证码登录接口文档 (POST /auth/verification-code-login) - 新增发送登录验证码接口文档 (POST /auth/send-login-verification-code) - 更新接口列表和数量统计 (21个 -> 23个接口) - 添加验证码登录测试场景和cURL示例 - 完善错误码说明和响应格式 - 确保文档与当前实现完全一致 --- docs/api/api-documentation.md | 181 +++++++++++- .../auth/services/login.service.spec.ts | 155 ++++++++++ src/business/login/login.service.spec.ts | 273 ------------------ src/core/db/users/users.service.spec.ts | 2 +- .../login_core/login_core.service.spec.ts | 164 ++++------- src/core/login_core/login_core.service.ts | 2 +- src/core/utils/email/email.service.ts | 10 +- 7 files changed, 396 insertions(+), 391 deletions(-) create mode 100644 src/business/auth/services/login.service.spec.ts delete mode 100644 src/business/login/login.service.spec.ts diff --git a/docs/api/api-documentation.md b/docs/api/api-documentation.md index c6ee79c..3e73815 100644 --- a/docs/api/api-documentation.md +++ b/docs/api/api-documentation.md @@ -36,6 +36,8 @@ ### 2. 用户认证接口 (Auth) - `POST /auth/login` - 用户登录 +- `POST /auth/verification-code-login` - 验证码登录 +- `POST /auth/send-login-verification-code` - 发送登录验证码 - `POST /auth/register` - 用户注册 - `POST /auth/github` - GitHub OAuth登录 - `POST /auth/forgot-password` - 发送密码重置验证码 @@ -156,6 +158,123 @@ } ``` +#### 1.1 验证码登录 + +**接口地址**: `POST /auth/verification-code-login` + +**功能描述**: 使用邮箱或手机号和验证码进行登录,无需密码 + +#### 请求参数 + +```json +{ + "identifier": "test@example.com", + "verification_code": "123456" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| identifier | string | 是 | 登录标识符(邮箱或手机号) | +| verification_code | string | 是 | 6位数字验证码 | + +#### 响应示例 + +**成功响应** (200): +```json +{ + "success": true, + "data": { + "user": { + "id": "1", + "username": "testuser", + "nickname": "测试用户", + "email": "test@example.com", + "phone": "+8613800138000", + "avatar_url": "https://example.com/avatar.jpg", + "role": 1, + "created_at": "2025-12-17T10:00:00.000Z" + }, + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", + "is_new_user": false, + "message": "验证码登录成功" + }, + "message": "验证码登录成功" +} +``` + +**失败响应** (401): +```json +{ + "success": false, + "message": "验证码错误或已过期", + "error_code": "VERIFICATION_CODE_INVALID" +} +``` + +**可能的错误码**: +- `VERIFICATION_CODE_INVALID`: 验证码错误或已过期 +- `USER_NOT_FOUND`: 用户不存在 +- `EMAIL_NOT_VERIFIED`: 邮箱未验证(邮箱登录时) +- `INVALID_IDENTIFIER`: 无效的邮箱或手机号格式 + +#### 1.2 发送登录验证码 + +**接口地址**: `POST /auth/send-login-verification-code` + +**功能描述**: 向用户邮箱或手机发送登录验证码 + +#### 请求参数 + +```json +{ + "identifier": "test@example.com" +} +``` + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| identifier | string | 是 | 邮箱或手机号 | + +#### 响应示例 + +**成功响应** (200): +```json +{ + "success": true, + "message": "验证码发送成功", + "data": { + "sent_to": "test@example.com", + "expires_in": 300 + } +} +``` + +**测试模式响应** (206): +```json +{ + "success": false, + "message": "测试模式:验证码已生成但未真实发送", + "error_code": "TEST_MODE_ONLY", + "data": { + "verification_code": "123456", + "sent_to": "test@example.com", + "expires_in": 300, + "is_test_mode": true + } +} +``` + +**失败响应** (404): +```json +{ + "success": false, + "message": "用户不存在", + "error_code": "USER_NOT_FOUND" +} +``` + #### 2. 用户注册 **接口地址**: `POST /auth/register` @@ -1565,6 +1684,21 @@ curl -X POST http://localhost:3000/auth/login \ "password": "password123" }' +# 发送登录验证码 +curl -X POST http://localhost:3000/auth/send-login-verification-code \ + -H "Content-Type: application/json" \ + -d '{ + "identifier": "test@example.com" + }' + +# 验证码登录 +curl -X POST http://localhost:3000/auth/verification-code-login \ + -H "Content-Type: application/json" \ + -d '{ + "identifier": "test@example.com", + "verification_code": "123456" + }' + # 发送邮箱验证码 curl -X POST http://localhost:3000/auth/send-email-verification \ -H "Content-Type: application/json" \ @@ -1846,7 +1980,42 @@ const testCompleteRegistration = async () => { }; ``` -#### **2. 登录失败处理** +#### **2. 验证码登录完整流程** +```javascript +// 场景:验证码登录完整流程 +const testVerificationCodeLogin = async () => { + const email = 'test@example.com'; + + // Step 1: 发送登录验证码 + const codeResponse = await fetch('/auth/send-login-verification-code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier: email }) + }); + + expect(codeResponse.status).toBe(206); // 测试模式 + const codeData = await codeResponse.json(); + expect(codeData.success).toBe(false); + expect(codeData.error_code).toBe('TEST_MODE_ONLY'); + + // Step 2: 使用验证码登录 + const loginResponse = await fetch('/auth/verification-code-login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier: email, + verification_code: codeData.data.verification_code + }) + }); + + expect(loginResponse.status).toBe(200); + const loginData = await loginResponse.json(); + expect(loginData.success).toBe(true); + expect(loginData.data.access_token).toBeDefined(); +}; +``` + +#### **3. 登录失败处理** ```javascript // 场景:各种登录失败情况 const testLoginFailures = async () => { @@ -1882,7 +2051,7 @@ const testLoginFailures = async () => { }; ``` -#### **3. 频率限制测试** +#### **4. 频率限制测试** ```javascript // 场景:验证码发送频率限制 const testRateLimit = async () => { @@ -1909,7 +2078,7 @@ const testRateLimit = async () => { }; ``` -#### **4. 管理员权限测试** +#### **5. 管理员权限测试** ```javascript // 场景:管理员权限验证 const testAdminPermissions = async () => { @@ -1939,7 +2108,7 @@ const testAdminPermissions = async () => { }; ``` -#### **5. 用户状态影响登录** +#### **6. 用户状态影响登录** ```javascript // 场景:不同用户状态的登录测试 const testUserStatusLogin = async () => { @@ -2119,7 +2288,7 @@ echo "📈 性能测试完成,请查看上述结果" - **更新限流配置**:注册接口限制调整为10次/5分钟(开发环境) - **应用状态接口** (1个) - `GET /` - 获取应用状态 - - **用户认证接口** (11个) + - **用户认证接口** (13个) - 用户登录、注册、GitHub OAuth - 密码重置和修改功能 - 邮箱验证相关接口 @@ -2142,7 +2311,7 @@ echo "📈 性能测试完成,请查看上述结果" - 请求超时拦截器 (Request Timeout) - 用户状态检查和权限控制 - **修复**:HTTP状态码现在正确反映业务执行结果 - - **总计接口数量**: 21个API接口 + - **总计接口数量**: 23个API接口 - 完善错误代码和使用示例 - 修复路由冲突问题 - 确保文档与实际测试效果一致 diff --git a/src/business/auth/services/login.service.spec.ts b/src/business/auth/services/login.service.spec.ts new file mode 100644 index 0000000..26076c0 --- /dev/null +++ b/src/business/auth/services/login.service.spec.ts @@ -0,0 +1,155 @@ +/** + * 登录业务服务测试 + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { LoginService } from './login.service'; +import { LoginCoreService } from '../../../core/login_core/login_core.service'; + +describe('LoginService', () => { + let service: LoginService; + let loginCoreService: jest.Mocked; + + const mockUser = { + id: BigInt(1), + username: 'testuser', + email: 'test@example.com', + phone: '+8613800138000', + password_hash: '$2b$12$hashedpassword', + nickname: '测试用户', + github_id: null as string | null, + avatar_url: null as string | null, + role: 1, + email_verified: false, + status: 'active' as any, + created_at: new Date(), + updated_at: new Date() + }; + + beforeEach(async () => { + const mockLoginCoreService = { + login: jest.fn(), + register: jest.fn(), + githubOAuth: jest.fn(), + sendPasswordResetCode: jest.fn(), + resetPassword: jest.fn(), + changePassword: jest.fn(), + sendEmailVerification: jest.fn(), + verifyEmailCode: jest.fn(), + resendEmailVerification: jest.fn(), + verificationCodeLogin: jest.fn(), + sendLoginVerificationCode: jest.fn(), + debugVerificationCode: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LoginService, + { + provide: LoginCoreService, + useValue: mockLoginCoreService, + }, + ], + }).compile(); + + service = module.get(LoginService); + loginCoreService = module.get(LoginCoreService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('login', () => { + it('should login successfully', async () => { + loginCoreService.login.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + const result = await service.login({ + identifier: 'testuser', + password: 'password123' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.access_token).toBeDefined(); + }); + + it('should handle login failure', async () => { + loginCoreService.login.mockRejectedValue(new Error('用户名或密码错误')); + + const result = await service.login({ + identifier: 'testuser', + password: 'wrongpassword' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('LOGIN_FAILED'); + }); + }); + + describe('register', () => { + it('should register successfully', async () => { + loginCoreService.register.mockResolvedValue({ + user: mockUser, + isNewUser: true + }); + + const result = await service.register({ + username: 'testuser', + password: 'password123', + nickname: '测试用户' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.username).toBe('testuser'); + expect(result.data?.is_new_user).toBe(true); + }); + }); + + describe('verificationCodeLogin', () => { + it('should login with verification code successfully', async () => { + loginCoreService.verificationCodeLogin.mockResolvedValue({ + user: mockUser, + isNewUser: false + }); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' + }); + + expect(result.success).toBe(true); + expect(result.data?.user.email).toBe('test@example.com'); + }); + + it('should handle verification code login failure', async () => { + loginCoreService.verificationCodeLogin.mockRejectedValue(new Error('验证码错误')); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '999999' + }); + + expect(result.success).toBe(false); + expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED'); + }); + }); + + describe('sendLoginVerificationCode', () => { + it('should send login verification code successfully', async () => { + loginCoreService.sendLoginVerificationCode.mockResolvedValue({ + code: '123456', + isTestMode: true + }); + + const result = await service.sendLoginVerificationCode('test@example.com'); + + expect(result.success).toBe(false); // 测试模式下返回false + expect(result.data?.verification_code).toBe('123456'); + expect(result.error_code).toBe('TEST_MODE_ONLY'); + }); + }); +}); \ No newline at end of file diff --git a/src/business/login/login.service.spec.ts b/src/business/login/login.service.spec.ts deleted file mode 100644 index 0103608..0000000 --- a/src/business/login/login.service.spec.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * 登录业务服务测试 - */ - -import { Test, TestingModule } from '@nestjs/testing'; -import { LoginService } from './login.service'; -import { LoginCoreService } from '../../core/login_core/login_core.service'; - -describe('LoginService', () => { - let service: LoginService; - let loginCoreService: jest.Mocked; - - const mockUser = { - id: BigInt(1), - username: 'testuser', - email: 'test@example.com', - phone: '+8613800138000', - password_hash: '$2b$12$hashedpassword', - nickname: '测试用户', - github_id: null as string | null, - avatar_url: null as string | null, - role: 1, - email_verified: false, - created_at: new Date(), - updated_at: new Date() - }; - - beforeEach(async () => { - const mockLoginCoreService = { - login: jest.fn(), - register: jest.fn(), - githubOAuth: jest.fn(), - sendPasswordResetCode: jest.fn(), - resetPassword: jest.fn(), - changePassword: jest.fn(), - verificationCodeLogin: jest.fn(), - sendLoginVerificationCode: jest.fn(), - debugVerificationCode: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - LoginService, - { - provide: LoginCoreService, - useValue: mockLoginCoreService, - }, - ], - }).compile(); - - service = module.get(LoginService); - loginCoreService = module.get(LoginCoreService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - describe('login', () => { - it('should return success response for valid login', async () => { - loginCoreService.login.mockResolvedValue({ - user: mockUser, - isNewUser: false - }); - - const result = await service.login({ - identifier: 'testuser', - password: 'password123' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBeDefined(); - }); - - it('should return error response for failed login', async () => { - loginCoreService.login.mockRejectedValue(new Error('登录失败')); - - const result = await service.login({ - identifier: 'testuser', - password: 'wrongpassword' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('登录失败'); - expect(result.error_code).toBe('LOGIN_FAILED'); - }); - }); - - describe('register', () => { - it('should return success response for valid registration', async () => { - loginCoreService.register.mockResolvedValue({ - user: mockUser, - isNewUser: true - }); - - const result = await service.register({ - username: 'testuser', - password: 'password123', - nickname: '测试用户' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); - }); - - it('should return error response for failed registration', async () => { - loginCoreService.register.mockRejectedValue(new Error('用户名已存在')); - - const result = await service.register({ - username: 'existinguser', - password: 'password123', - nickname: '测试用户' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('用户名已存在'); - expect(result.error_code).toBe('REGISTER_FAILED'); - }); - }); - - describe('githubOAuth', () => { - it('should return success response for GitHub OAuth', async () => { - loginCoreService.githubOAuth.mockResolvedValue({ - user: mockUser, - isNewUser: true - }); - - const result = await service.githubOAuth({ - github_id: 'github123', - username: 'githubuser', - nickname: 'GitHub用户' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.is_new_user).toBe(true); - }); - }); - - describe('sendPasswordResetCode', () => { - it('should return test mode response with verification code', async () => { - loginCoreService.sendPasswordResetCode.mockResolvedValue({ - code: '123456', - isTestMode: true - }); - - const result = await service.sendPasswordResetCode('test@example.com'); - - expect(result.success).toBe(false); // 测试模式下不算成功 - expect(result.error_code).toBe('TEST_MODE_ONLY'); - expect(result.data?.verification_code).toBe('123456'); - expect(result.data?.is_test_mode).toBe(true); - }); - - it('should return success response for real email sending', async () => { - loginCoreService.sendPasswordResetCode.mockResolvedValue({ - code: '123456', - isTestMode: false - }); - - const result = await service.sendPasswordResetCode('test@example.com'); - - expect(result.success).toBe(true); - expect(result.data?.is_test_mode).toBe(false); - expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码 - }); - }); - - describe('resetPassword', () => { - it('should return success response for password reset', async () => { - loginCoreService.resetPassword.mockResolvedValue(mockUser); - - const result = await service.resetPassword({ - identifier: 'test@example.com', - verificationCode: '123456', - newPassword: 'newpassword123' - }); - - expect(result.success).toBe(true); - expect(result.message).toBe('密码重置成功'); - }); - }); - - describe('changePassword', () => { - it('should return success response for password change', async () => { - loginCoreService.changePassword.mockResolvedValue(mockUser); - - const result = await service.changePassword(BigInt(1), 'oldpassword', 'newpassword123'); - - expect(result.success).toBe(true); - expect(result.message).toBe('密码修改成功'); - }); - }); - - describe('verificationCodeLogin', () => { - it('should return success response for valid verification code login', async () => { - loginCoreService.verificationCodeLogin.mockResolvedValue({ - user: mockUser, - isNewUser: false - }); - - const result = await service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '123456' - }); - - expect(result.success).toBe(true); - expect(result.data?.user.username).toBe('testuser'); - expect(result.data?.access_token).toBeDefined(); - expect(result.data?.is_new_user).toBe(false); - expect(result.message).toBe('验证码登录成功'); - }); - - it('should return error response for failed verification code login', async () => { - loginCoreService.verificationCodeLogin.mockRejectedValue( - new Error('验证码验证失败') - ); - - const result = await service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '999999' - }); - - expect(result.success).toBe(false); - expect(result.message).toBe('验证码验证失败'); - expect(result.error_code).toBe('VERIFICATION_CODE_LOGIN_FAILED'); - }); - }); - - describe('sendLoginVerificationCode', () => { - it('should return test mode response with verification code', async () => { - loginCoreService.sendLoginVerificationCode.mockResolvedValue({ - code: '123456', - isTestMode: true - }); - - const result = await service.sendLoginVerificationCode('test@example.com'); - - expect(result.success).toBe(false); // 测试模式下不算成功 - expect(result.error_code).toBe('TEST_MODE_ONLY'); - expect(result.data?.verification_code).toBe('123456'); - expect(result.data?.is_test_mode).toBe(true); - expect(result.message).toContain('测试模式'); - }); - - it('should return success response for real email sending', async () => { - loginCoreService.sendLoginVerificationCode.mockResolvedValue({ - code: '123456', - isTestMode: false - }); - - const result = await service.sendLoginVerificationCode('test@example.com'); - - expect(result.success).toBe(true); - expect(result.data?.is_test_mode).toBe(false); - expect(result.message).toBe('验证码已发送,请查收'); - }); - - it('should return error response for failed sending', async () => { - loginCoreService.sendLoginVerificationCode.mockRejectedValue( - new Error('用户不存在') - ); - - const result = await service.sendLoginVerificationCode('nonexistent@example.com'); - - expect(result.success).toBe(false); - expect(result.message).toBe('用户不存在'); - expect(result.error_code).toBe('SEND_LOGIN_CODE_FAILED'); - }); - }); -}); \ No newline at end of file diff --git a/src/core/db/users/users.service.spec.ts b/src/core/db/users/users.service.spec.ts index 1525d1b..99b4992 100644 --- a/src/core/db/users/users.service.spec.ts +++ b/src/core/db/users/users.service.spec.ts @@ -231,7 +231,7 @@ describe('Users Entity, DTO and Service Tests', () => { it('应该在用户名重复时抛出ConflictException', async () => { mockRepository.findOne.mockResolvedValue(mockUser); // 模拟用户名已存在 - await expect(service.create(createUserDto)).rejects.toThrow(ConflictException); + await expect(service.createWithDuplicateCheck(createUserDto)).rejects.toThrow(ConflictException); expect(mockRepository.save).not.toHaveBeenCalled(); }); diff --git a/src/core/login_core/login_core.service.spec.ts b/src/core/login_core/login_core.service.spec.ts index c8cf958..b8f810a 100644 --- a/src/core/login_core/login_core.service.spec.ts +++ b/src/core/login_core/login_core.service.spec.ts @@ -264,82 +264,6 @@ describe('LoginCoreService', () => { }); }); - describe('verificationCodeLogin', () => { - it('should successfully login with email verification code', async () => { - const verifiedUser = { ...mockUser, email_verified: true }; - usersService.findByEmail.mockResolvedValue(verifiedUser); - verificationService.verifyCode.mockResolvedValue(true); - - const result = await service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '123456' - }); - - expect(result.user).toEqual(verifiedUser); - expect(result.isNewUser).toBe(false); - expect(verificationService.verifyCode).toHaveBeenCalledWith( - 'test@example.com', - VerificationCodeType.EMAIL_VERIFICATION, - '123456' - ); - }); - - it('should successfully login with phone verification code', async () => { - const phoneUser = { ...mockUser, phone: '+8613800138000' }; - usersService.findAll.mockResolvedValue([phoneUser]); - verificationService.verifyCode.mockResolvedValue(true); - - const result = await service.verificationCodeLogin({ - identifier: '+8613800138000', - verificationCode: '123456' - }); - - expect(result.user).toEqual(phoneUser); - expect(result.isNewUser).toBe(false); - expect(verificationService.verifyCode).toHaveBeenCalledWith( - '+8613800138000', - VerificationCodeType.SMS_VERIFICATION, - '123456' - ); - }); - - it('should reject unverified email user', async () => { - usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false - - await expect(service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '123456' - })).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录'); - }); - - it('should reject non-existent user', async () => { - usersService.findByEmail.mockResolvedValue(null); - - await expect(service.verificationCodeLogin({ - identifier: 'nonexistent@example.com', - verificationCode: '123456' - })).rejects.toThrow('用户不存在,请先注册账户'); - }); - - it('should reject invalid verification code', async () => { - const verifiedUser = { ...mockUser, email_verified: true }; - usersService.findByEmail.mockResolvedValue(verifiedUser); - verificationService.verifyCode.mockResolvedValue(false); - - await expect(service.verificationCodeLogin({ - identifier: 'test@example.com', - verificationCode: '999999' - })).rejects.toThrow('验证码验证失败'); - }); - - it('should reject invalid identifier format', async () => { - await expect(service.verificationCodeLogin({ - identifier: 'invalid-identifier', - verificationCode: '123456' - })).rejects.toThrow('请提供有效的邮箱或手机号'); - }); - }); - describe('sendLoginVerificationCode', () => { it('should successfully send email login verification code', async () => { const verifiedUser = { ...mockUser, email_verified: true }; @@ -468,55 +392,79 @@ describe('LoginCoreService', () => { }); }); - describe('sendLoginVerificationCode', () => { - it('should successfully send email login verification code', async () => { + describe('verificationCodeLogin', () => { + it('should successfully login with email verification code', async () => { const verifiedUser = { ...mockUser, email_verified: true }; usersService.findByEmail.mockResolvedValue(verifiedUser); - verificationService.generateCode.mockResolvedValue('123456'); - emailService.sendVerificationCode.mockResolvedValue({ - success: true, - isTestMode: false + verificationService.verifyCode.mockResolvedValue(true); + + const result = await service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' }); - const result = await service.sendLoginVerificationCode('test@example.com'); - - expect(result.code).toBe('123456'); - expect(result.isTestMode).toBe(false); - expect(emailService.sendVerificationCode).toHaveBeenCalledWith({ - email: 'test@example.com', - code: '123456', - nickname: mockUser.nickname, - purpose: 'login_verification' - }); + expect(result.user).toEqual(verifiedUser); + expect(result.isNewUser).toBe(false); + expect(verificationService.verifyCode).toHaveBeenCalledWith( + 'test@example.com', + VerificationCodeType.EMAIL_VERIFICATION, + '123456' + ); }); - it('should return verification code in test mode', async () => { - const verifiedUser = { ...mockUser, email_verified: true }; - usersService.findByEmail.mockResolvedValue(verifiedUser); - verificationService.generateCode.mockResolvedValue('123456'); - emailService.sendVerificationCode.mockResolvedValue({ - success: true, - isTestMode: true + it('should successfully login with phone verification code', async () => { + const phoneUser = { ...mockUser, phone: '+8613800138000' }; + usersService.findAll.mockResolvedValue([phoneUser]); + verificationService.verifyCode.mockResolvedValue(true); + + const result = await service.verificationCodeLogin({ + identifier: '+8613800138000', + verificationCode: '123456' }); - const result = await service.sendLoginVerificationCode('test@example.com'); - - expect(result.code).toBe('123456'); - expect(result.isTestMode).toBe(true); + expect(result.user).toEqual(phoneUser); + expect(result.isNewUser).toBe(false); + expect(verificationService.verifyCode).toHaveBeenCalledWith( + '+8613800138000', + VerificationCodeType.SMS_VERIFICATION, + '123456' + ); }); - it('should reject unverified email', async () => { + it('should reject unverified email user', async () => { usersService.findByEmail.mockResolvedValue(mockUser); // email_verified: false - await expect(service.sendLoginVerificationCode('test@example.com')) - .rejects.toThrow('邮箱未验证,无法使用验证码登录'); + await expect(service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '123456' + })).rejects.toThrow('邮箱未验证,请先验证邮箱后再使用验证码登录'); }); it('should reject non-existent user', async () => { usersService.findByEmail.mockResolvedValue(null); - await expect(service.sendLoginVerificationCode('nonexistent@example.com')) - .rejects.toThrow('用户不存在'); + await expect(service.verificationCodeLogin({ + identifier: 'nonexistent@example.com', + verificationCode: '123456' + })).rejects.toThrow('用户不存在,请先注册账户'); + }); + + it('should reject invalid verification code', async () => { + const verifiedUser = { ...mockUser, email_verified: true }; + usersService.findByEmail.mockResolvedValue(verifiedUser); + verificationService.verifyCode.mockResolvedValue(false); + + await expect(service.verificationCodeLogin({ + identifier: 'test@example.com', + verificationCode: '999999' + })).rejects.toThrow('验证码验证失败'); + }); + + it('should reject invalid identifier format', async () => { + await expect(service.verificationCodeLogin({ + identifier: 'invalid-identifier', + verificationCode: '123456' + })).rejects.toThrow('请提供有效的邮箱或手机号'); }); }); }); \ No newline at end of file diff --git a/src/core/login_core/login_core.service.ts b/src/core/login_core/login_core.service.ts index acdec09..2c7e0db 100644 --- a/src/core/login_core/login_core.service.ts +++ b/src/core/login_core/login_core.service.ts @@ -757,7 +757,7 @@ export class LoginCoreService { email: identifier, code: verificationCode, nickname: user.nickname, - purpose: 'password_reset' + purpose: 'login_verification' }); if (!result.success) { diff --git a/src/core/utils/email/email.service.ts b/src/core/utils/email/email.service.ts index 954f3a4..ac967ea 100644 --- a/src/core/utils/email/email.service.ts +++ b/src/core/utils/email/email.service.ts @@ -47,7 +47,7 @@ export interface VerificationEmailOptions { /** 用户昵称 */ nickname?: string; /** 验证码用途 */ - purpose: 'email_verification' | 'password_reset'; + purpose: 'email_verification' | 'password_reset' | 'login_verification'; } /** @@ -167,9 +167,15 @@ export class EmailService { if (purpose === 'email_verification') { subject = '【Whale Town】邮箱验证码'; template = this.getEmailVerificationTemplate(code, nickname); - } else { + } else if (purpose === 'password_reset') { subject = '【Whale Town】密码重置验证码'; template = this.getPasswordResetTemplate(code, nickname); + } else if (purpose === 'login_verification') { + subject = '【Whale Town】登录验证码'; + template = this.getPasswordResetTemplate(code, nickname); // 复用密码重置模板 + } else { + subject = '【Whale Town】验证码'; + template = this.getEmailVerificationTemplate(code, nickname); } return await this.sendEmail({