Merge pull request 'feat(login): 添加验证码登录auth api' (#18) from ANGJustinl/whale-town-end:main into main

Reviewed-on: datawhale/whale-town-end#18
This commit is contained in:
2025-12-25 15:48:13 +08:00
9 changed files with 1002 additions and 12 deletions

View File

@@ -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接口
- 完善错误代码和使用示例
- 修复路由冲突问题
- 确保文档与实际测试效果一致

View File

@@ -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 '../services/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,
@@ -475,6 +475,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<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);
// 根据结果设置不同的状态码
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);
}
}
/**
* 调试验证码信息
* 仅用于开发和调试

View File

@@ -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;
}

View File

@@ -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<LoginCoreService>;
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>(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');
});
});
});

View File

@@ -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<ApiResponse<LoginResponse>> {
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<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
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'
};
}
}
/**
* 调试验证码信息
*

View File

@@ -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();
});

View File

@@ -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, ForbiddenException } from '@nestjs/common';
import { UserStatus } from '../../business/user-mgmt/enums/user-status.enum';
@@ -263,4 +263,208 @@ describe('LoginCoreService', () => {
.rejects.toThrow(UnauthorizedException);
});
});
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('用户不存在');
});
});
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('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('请提供有效的邮箱或手机号');
});
});
});

View File

@@ -101,6 +101,16 @@ export interface VerificationCodeResult {
isTestMode: boolean;
}
/**
* 验证码登录请求数据接口
*/
export interface VerificationCodeLoginRequest {
/** 登录标识符:邮箱或手机号 */
identifier: string;
/** 验证码 */
verificationCode: string;
}
@Injectable()
export class LoginCoreService {
constructor(
@@ -613,6 +623,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<AuthResult> {
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<VerificationCodeResult> {
// 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 };
}
/**
* 调试验证码信息
*

View File

@@ -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({
@@ -322,6 +328,68 @@ export class EmailService {
</html>`;
}
/**
* 获取登录验证码模板
*
* @param code 验证码
* @param nickname 用户昵称
* @returns HTML模板
*/
private getLoginVerificationTemplate(code: string, nickname?: string): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录验证码</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }
.content { background: #f9f9f9; padding: 30px; border-radius: 0 0 10px 10px; }
.code-box { background: #fff; border: 2px dashed #667eea; padding: 20px; text-align: center; margin: 20px 0; border-radius: 8px; }
.code { font-size: 32px; font-weight: bold; color: #667eea; letter-spacing: 5px; }
.footer { text-align: center; margin-top: 20px; color: #666; font-size: 12px; }
.info { background: #e3f2fd; border: 1px solid #bbdefb; padding: 15px; border-radius: 5px; margin: 20px 0; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🔐 登录验证码</h1>
<p>Whale Town 安全登录</p>
</div>
<div class="content">
<h2>你好${nickname ? ` ${nickname}` : ''}</h2>
<p>您正在使用验证码登录 Whale Town。请使用以下验证码完成登录</p>
<div class="code-box">
<div class="code">${code}</div>
<p style="margin: 10px 0 0 0; color: #666;">登录验证码</p>
</div>
<div class="info">
<strong>📱 使用说明:</strong>
<ul style="margin: 10px 0 0 20px;">
<li>验证码 5 分钟内有效</li>
<li>请在登录页面输入此验证码</li>
<li>验证码仅限本次登录使用</li>
<li>请勿将验证码泄露给他人</li>
</ul>
</div>
<p>如果您没有尝试登录,请忽略此邮件,或联系客服确认账户安全。</p>
</div>
<div class="footer">
<p>此邮件由系统自动发送,请勿回复</p>
<p>© 2025 Whale Town Game. All rights reserved.</p>
</div>
</div>
</body>
</html>`;
}
/**
* 获取欢迎邮件模板
*