Merge pull request '[REVIEW REQUIRED]feat(sql, auth, email, dto):重构邮箱验证流程,引入基于内存的用户服务,并改进 API 响应处理' (#12) from ANGJustinl/whale-town-end:main into main

Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
2025-12-18 14:21:24 +08:00
21 changed files with 1390 additions and 128 deletions

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# 环境配置模板
# 数据库配置
# DB_HOST=localhost
# DB_PORT=3306
# DB_USERNAME=your_db_username
# DB_PASSWORD=your_db_password
# DB_NAME=your_db_name
# 应用配置
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
# JWT 配置(如果有的话)
JWT_SECRET=your_jwt_secret_key_here_at_least_32_characters
JWT_EXPIRES_IN=7d
# Redis 配置(用于验证码存储)
# 生产环境使用真实Redis服务
USE_FILE_REDIS=false
REDIS_HOST=your_redis_host
REDIS_PORT=6379
REDIS_PASSWORD=your_redis_password
REDIS_DB=0
# 邮件服务配置
# EMAIL_HOST=smtp.gmail.com
# EMAIL_PORT=587
# EMAIL_SECURE=false
# EMAIL_USER=your_email@gmail.com
# EMAIL_PASS=your_app_password
# EMAIL_FROM="Whale Town Game" <noreply@whaletown.com>
# 其他配置
# 根据项目需要添加其他环境变量

257
docs/API_STATUS_CODES.md Normal file
View File

@@ -0,0 +1,257 @@
# API 状态码说明
## 📊 概述
本文档说明了项目中使用的 HTTP 状态码,特别是针对邮件发送功能的特殊状态码处理。
## 🔢 标准状态码
| 状态码 | 含义 | 使用场景 |
|--------|------|----------|
| 200 | OK | 请求成功 |
| 201 | Created | 资源创建成功(如用户注册) |
| 400 | Bad Request | 请求参数错误 |
| 401 | Unauthorized | 未授权(如密码错误) |
| 403 | Forbidden | 权限不足 |
| 404 | Not Found | 资源不存在 |
| 409 | Conflict | 资源冲突(如用户名已存在) |
| 429 | Too Many Requests | 请求频率过高 |
| 500 | Internal Server Error | 服务器内部错误 |
## 🎯 特殊状态码
### 206 Partial Content - 测试模式
**使用场景:** 邮件发送功能在测试模式下使用
**含义:** 请求部分成功,但未完全达到预期效果
**具体应用:**
- 验证码已生成,但邮件未真实发送
- 功能正常工作,但处于测试/开发模式
- 用户可以获得验证码进行测试,但需要知道这不是真实发送
**响应示例:**
```json
{
"success": false,
"data": {
"verification_code": "123456",
"is_test_mode": true
},
"message": "⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。",
"error_code": "TEST_MODE_ONLY"
}
```
## 📧 邮件发送接口状态码
### 发送邮箱验证码 - POST /auth/send-email-verification
| 状态码 | 场景 | 响应 |
|--------|------|------|
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收邮件" }` |
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` |
| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` |
### 发送密码重置验证码 - POST /auth/forgot-password
| 状态码 | 场景 | 响应 |
|--------|------|------|
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已发送,请查收" }` |
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
| 400 | 参数错误 | `{ "success": false, "message": "邮箱格式错误" }` |
| 404 | 用户不存在 | `{ "success": false, "message": "用户不存在" }` |
### 重新发送邮箱验证码 - POST /auth/resend-email-verification
| 状态码 | 场景 | 响应 |
|--------|------|------|
| 200 | 邮件真实发送成功 | `{ "success": true, "message": "验证码已重新发送,请查收邮件" }` |
| 206 | 测试模式 | `{ "success": false, "error_code": "TEST_MODE_ONLY", "data": { "verification_code": "123456", "is_test_mode": true } }` |
| 400 | 邮箱已验证或用户不存在 | `{ "success": false, "message": "邮箱已验证,无需重复验证" }` |
| 429 | 发送频率过高 | `{ "success": false, "message": "发送频率过高,请稍后重试" }` |
## 🔄 模式切换
### 测试模式 → 真实发送模式
**配置前(测试模式):**
```bash
curl -X POST http://localhost:3000/auth/send-email-verification \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# 响应206 Partial Content
{
"success": false,
"data": {
"verification_code": "123456",
"is_test_mode": true
},
"message": "⚠️ 测试模式:验证码已生成但未真实发送...",
"error_code": "TEST_MODE_ONLY"
}
```
**配置后(真实发送模式):**
```bash
# 同样的请求
curl -X POST http://localhost:3000/auth/send-email-verification \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# 响应200 OK
{
"success": true,
"data": {
"is_test_mode": false
},
"message": "验证码已发送,请查收邮件"
}
```
## 💡 前端处理建议
### JavaScript 示例
```javascript
async function sendEmailVerification(email) {
try {
const response = await fetch('/auth/send-email-verification', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
const data = await response.json();
if (response.status === 200) {
// 真实发送成功
showSuccess('验证码已发送,请查收邮件');
} else if (response.status === 206) {
// 测试模式
showWarning(`测试模式:验证码是 ${data.data.verification_code}`);
showInfo('请配置邮件服务以启用真实发送');
} else {
// 其他错误
showError(data.message);
}
} catch (error) {
showError('网络错误,请稍后重试');
}
}
```
### React 示例
```jsx
const handleSendVerification = async (email) => {
try {
const response = await fetch('/auth/send-email-verification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
const data = await response.json();
switch (response.status) {
case 200:
setMessage({ type: 'success', text: '验证码已发送,请查收邮件' });
break;
case 206:
setMessage({
type: 'warning',
text: `测试模式:验证码是 ${data.data.verification_code}`
});
setShowConfigTip(true);
break;
case 400:
setMessage({ type: 'error', text: data.message });
break;
case 429:
setMessage({ type: 'error', text: '发送频率过高,请稍后重试' });
break;
default:
setMessage({ type: 'error', text: '发送失败,请稍后重试' });
}
} catch (error) {
setMessage({ type: 'error', text: '网络错误,请稍后重试' });
}
};
```
## 🎨 UI 展示建议
### 测试模式提示
```html
<!-- 成功状态 (200) -->
<div class="alert alert-success">
✅ 验证码已发送,请查收邮件
</div>
<!-- 测试模式 (206) -->
<div class="alert alert-warning">
⚠️ 测试模式:验证码是 123456
<br>
<small>请配置邮件服务以启用真实发送</small>
</div>
<!-- 错误状态 (400+) -->
<div class="alert alert-danger">
❌ 发送失败:邮箱格式错误
</div>
```
## 📝 开发建议
### 1. 状态码检查
```javascript
// 推荐:明确检查状态码
if (response.status === 206) {
// 处理测试模式
} else if (response.status === 200) {
// 处理真实发送
}
// 不推荐:只检查 success 字段
if (data.success) {
// 可能遗漏测试模式的情况
}
```
### 2. 错误处理
```javascript
// 推荐:根据 error_code 进行精确处理
switch (data.error_code) {
case 'TEST_MODE_ONLY':
handleTestMode(data);
break;
case 'SEND_CODE_FAILED':
handleSendFailure(data);
break;
default:
handleGenericError(data);
}
```
### 3. 用户体验
- **测试模式**:清晰提示用户当前处于测试模式
- **配置引导**:提供配置邮件服务的链接或说明
- **验证码显示**:在测试模式下直接显示验证码
- **状态区分**:用不同的颜色和图标区分不同状态
## 🔗 相关文档
- [邮件服务配置指南](./EMAIL_CONFIGURATION.md)
- [快速启动指南](./QUICK_START.md)
- [API 文档](./api/README.md)

View File

@@ -50,6 +50,7 @@
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@nestjs/testing": "^10.4.20",
"@types/express": "^5.0.6",
"@types/jest": "^29.5.14",
"@types/node": "^20.19.27",
"@types/nodemailer": "^6.4.14",

View File

@@ -1,12 +1,49 @@
import { Controller, Get } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { AppService } from './app.service';
import { AppStatusResponseDto } from './dto/app.dto';
import { ErrorResponseDto } from './dto/error_response.dto';
/**
* 应用根控制器
*
* 功能描述:
* - 提供应用基础信息和健康检查接口
* - 用于监控服务运行状态
*
* @author moyin
* @version 1.0.0
* @since 2025-12-17
*/
@ApiTags('App')
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
/**
* 获取应用状态
*
* 功能描述:
* 返回应用的基本运行状态信息,用于健康检查和监控
*
* @returns 应用状态信息
*/
@Get()
getStatus(): string {
@ApiOperation({
summary: '获取应用状态',
description: '返回应用的基本运行状态信息,包括服务名称、版本、运行时间等。用于健康检查和服务监控。'
})
@ApiResponse({
status: 200,
description: '成功获取应用状态',
type: AppStatusResponseDto
})
@ApiResponse({
status: 500,
description: '服务器内部错误',
type: ErrorResponseDto
})
getStatus(): AppStatusResponseDto {
return this.appService.getStatus();
}
}

View File

@@ -9,6 +9,29 @@ import { LoginCoreModule } from './core/login_core/login_core.module';
import { LoginModule } from './business/login/login.module';
import { RedisModule } from './core/redis/redis.module';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
*
* @returns 是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
/**
* 应用主模块
*
* 功能描述:
* - 整合所有功能模块
* - 配置全局服务和中间件
* - 支持数据库和内存存储的自动切换
*
* 存储模式选择:
* - 如果配置了数据库环境变量,使用数据库模式
* - 如果未配置数据库,自动回退到内存模式
* - 内存模式适用于快速开发和测试
*/
@Module({
imports: [
ConfigModule.forRoot({
@@ -17,17 +40,25 @@ import { RedisModule } from './core/redis/redis.module';
}),
LoggerModule,
RedisModule,
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
}),
UsersModule,
// 条件导入TypeORM模块
...(isDatabaseConfigured() ? [
TypeOrmModule.forRoot({
type: 'mysql',
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT),
username: process.env.DB_USERNAME,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: false,
// 添加连接超时和重试配置
connectTimeout: 10000,
retryAttempts: 3,
retryDelay: 3000,
}),
] : []),
// 根据数据库配置选择用户模块模式
isDatabaseConfigured() ? UsersModule.forDatabase() : UsersModule.forMemory(),
LoginCoreModule,
LoginModule,
],

View File

@@ -1,8 +1,52 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppStatusResponseDto } from './dto/app.dto';
/**
* 应用服务类
*
* 功能描述:
* - 提供应用基础服务
* - 返回应用运行状态信息
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
@Injectable()
export class AppService {
getStatus(): string {
return 'Pixel Game Server is running!';
private readonly startTime: number;
constructor(private readonly configService: ConfigService) {
this.startTime = Date.now();
}
/**
* 获取应用状态
*
* @returns 应用状态信息
*/
getStatus(): AppStatusResponseDto {
const isDatabaseConfigured = this.isDatabaseConfigured();
return {
service: 'Pixel Game Server',
version: '1.0.0',
status: 'running',
timestamp: new Date().toISOString(),
uptime: Math.floor((Date.now() - this.startTime) / 1000),
environment: this.configService.get<string>('NODE_ENV', 'development'),
storage_mode: isDatabaseConfigured ? 'database' : 'memory'
};
}
/**
* 检查数据库配置是否完整
*
* @returns 是否配置了数据库
*/
private isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => this.configService.get<string>(varName));
}
}

View File

@@ -14,22 +14,25 @@
* - POST /auth/reset-password - 重置密码
* - PUT /auth/change-password - 修改密码
*
* @author moyin
* @author moyin angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { Controller, Post, Put, Body, HttpCode, HttpStatus, ValidationPipe, UsePipes, Logger } from '@nestjs/common';
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 } from './login.dto';
import { LoginDto, RegisterDto, GitHubOAuthDto, ForgotPasswordDto, ResetPasswordDto, ChangePasswordDto, EmailVerificationDto, SendEmailVerificationDto } from '../../dto/login.dto';
import {
LoginResponseDto,
RegisterResponseDto,
GitHubOAuthResponseDto,
ForgotPasswordResponseDto,
CommonResponseDto
} from './login-response.dto';
CommonResponseDto,
TestModeEmailVerificationResponseDto,
SuccessEmailVerificationResponseDto
} from '../../dto/login_response.dto';
@ApiTags('auth')
@Controller('auth')
@@ -151,6 +154,7 @@ export class LoginController {
* 发送密码重置验证码
*
* @param forgotPasswordDto 忘记密码数据
* @param res Express响应对象
* @returns 发送结果
*/
@ApiOperation({
@@ -163,6 +167,11 @@ export class LoginController {
description: '验证码发送成功',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '请求参数错误'
@@ -172,10 +181,21 @@ export class LoginController {
description: '用户不存在'
})
@Post('forgot-password')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto): Promise<ApiResponse<{ verification_code?: string }>> {
return await this.loginService.sendPasswordResetCode(forgotPasswordDto.identifier);
async forgotPassword(
@Body() forgotPasswordDto: ForgotPasswordDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendPasswordResetCode(forgotPasswordDto.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);
}
}
/**
@@ -256,6 +276,7 @@ export class LoginController {
* 发送邮箱验证码
*
* @param sendEmailVerificationDto 发送验证码数据
* @param res Express响应对象
* @returns 发送结果
*/
@ApiOperation({
@@ -265,8 +286,13 @@ export class LoginController {
@ApiBody({ type: SendEmailVerificationDto })
@SwaggerApiResponse({
status: 200,
description: '验证码发送成功',
type: ForgotPasswordResponseDto
description: '验证码发送成功(真实发送模式)',
type: SuccessEmailVerificationResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: TestModeEmailVerificationResponseDto
})
@SwaggerApiResponse({
status: 400,
@@ -277,10 +303,21 @@ export class LoginController {
description: '发送频率过高'
})
@Post('send-email-verification')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async sendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> {
return await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
async sendEmailVerification(
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.sendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
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);
}
}
/**
@@ -317,6 +354,7 @@ export class LoginController {
* 重新发送邮箱验证码
*
* @param sendEmailVerificationDto 发送验证码数据
* @param res Express响应对象
* @returns 发送结果
*/
@ApiOperation({
@@ -329,6 +367,11 @@ export class LoginController {
description: '验证码重新发送成功',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 206,
description: '测试模式:验证码已生成但未真实发送',
type: ForgotPasswordResponseDto
})
@SwaggerApiResponse({
status: 400,
description: '邮箱已验证或用户不存在'
@@ -338,10 +381,21 @@ export class LoginController {
description: '发送频率过高'
})
@Post('resend-email-verification')
@HttpCode(HttpStatus.OK)
@UsePipes(new ValidationPipe({ transform: true }))
async resendEmailVerification(@Body() sendEmailVerificationDto: SendEmailVerificationDto): Promise<ApiResponse<{ verification_code?: string }>> {
return await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
async resendEmailVerification(
@Body() sendEmailVerificationDto: SendEmailVerificationDto,
@Res() res: Response
): Promise<void> {
const result = await this.loginService.resendEmailVerification(sendEmailVerificationDto.email);
// 根据结果设置不同的状态码
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

@@ -137,13 +137,31 @@ describe('LoginService', () => {
});
describe('sendPasswordResetCode', () => {
it('should return success response with verification code', async () => {
loginCoreService.sendPasswordResetCode.mockResolvedValue('123456');
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?.verification_code).toBe('123456');
expect(result.data?.is_test_mode).toBe(false);
expect(result.data?.verification_code).toBeUndefined(); // 真实模式下不返回验证码
});
});

View File

@@ -11,7 +11,7 @@
* - 调用核心服务完成具体功能
* - 为控制器层提供业务接口
*
* @author moyin
* @author moyin angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
@@ -199,21 +199,37 @@ export class LoginService {
* @param identifier 邮箱或手机号
* @returns 响应结果
*/
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string }>> {
async sendPasswordResetCode(identifier: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`发送密码重置验证码: ${identifier}`);
// 调用核心服务发送验证码
const verificationCode = await this.loginCoreService.sendPasswordResetCode(identifier);
const result = await this.loginCoreService.sendPasswordResetCode(identifier);
this.logger.log(`密码重置验证码已发送: ${identifier}`);
// 实际应用中不应返回验证码,这里仅用于演示
return {
success: true,
data: { verification_code: verificationCode },
message: '验证码已发送,请查收'
};
// 根据是否为测试模式返回不同的状态和消息 by angjustinl 2025-12-17
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));
@@ -293,21 +309,37 @@ export class LoginService {
* @param email 邮箱地址
* @returns 响应结果
*/
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> {
async sendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`发送邮箱验证码: ${email}`);
// 调用核心服务发送验证码
const verificationCode = await this.loginCoreService.sendEmailVerification(email);
const result = await this.loginCoreService.sendEmailVerification(email);
this.logger.log(`邮箱验证码已发送: ${email}`);
// 实际应用中不应返回验证码,这里仅用于演示
return {
success: true,
data: { verification_code: verificationCode },
message: '验证码已发送,请查收邮件'
};
// 根据是否为测试模式返回不同的状态和消息
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(`发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));
@@ -363,21 +395,37 @@ export class LoginService {
* @param email 邮箱地址
* @returns 响应结果
*/
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string }>> {
async resendEmailVerification(email: string): Promise<ApiResponse<{ verification_code?: string; is_test_mode?: boolean }>> {
try {
this.logger.log(`重新发送邮箱验证码: ${email}`);
// 调用核心服务重新发送验证码
const verificationCode = await this.loginCoreService.resendEmailVerification(email);
const result = await this.loginCoreService.resendEmailVerification(email);
this.logger.log(`邮箱验证码已重新发送: ${email}`);
// 实际应用中不应返回验证码,这里仅用于演示
return {
success: true,
data: { verification_code: verificationCode },
message: '验证码已重新发送,请查收邮件'
};
// 根据是否为测试模式返回不同的状态和消息
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(`重新发送邮箱验证码失败: ${email}`, error instanceof Error ? error.stack : String(error));

View File

@@ -4,23 +4,61 @@
* 功能描述:
* - 整合用户相关的实体、服务和控制器
* - 配置TypeORM实体和Repository
* - 支持数据库和内存存储的动态切换 by angjustinl 2025-12-17
* - 导出用户服务供其他模块使用
*
* @author moyin
* @version 1.0.0
* 存储模式by angjustinl 2025-12-17
* - 数据库模式使用TypeORM连接MySQL数据库
* - 内存模式使用Map存储适用于开发和测试
*
* @author moyin angjustinl
* @version 1.0.1
* @since 2025-12-17
*/
import { Module } from '@nestjs/common';
import { Module, DynamicModule, Global } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Users } from './users.entity';
import { UsersService } from './users.service';
import { UsersMemoryService } from './users_memory.service';
@Module({
imports: [
TypeOrmModule.forFeature([Users])
],
providers: [UsersService],
exports: [UsersService, TypeOrmModule],
})
export class UsersModule {}
@Global()
@Module({})
export class UsersModule {
/**
* 创建数据库模式的用户模块
*
* @returns 配置了TypeORM的动态模块
*/
static forDatabase(): DynamicModule {
return {
module: UsersModule,
imports: [TypeOrmModule.forFeature([Users])],
providers: [
{
provide: 'UsersService',
useClass: UsersService,
},
],
exports: ['UsersService', TypeOrmModule],
};
}
/**
* 创建内存模式的用户模块
*
* @returns 配置了内存存储的动态模块
*/
static forMemory(): DynamicModule {
return {
module: UsersModule,
providers: [
{
provide: 'UsersService',
useClass: UsersMemoryService,
},
],
exports: ['UsersService'],
};
}
}

View File

@@ -0,0 +1,349 @@
/**
* 用户内存存储服务类
*
* 功能描述:
* - 提供基于内存的用户数据存储
* - 作为数据库连接失败时的回退方案
* - 实现与UsersService相同的接口
*
* 使用场景:
* - 开发环境无数据库时的快速启动
* - 测试环境的轻量级存储
* - 数据库故障时的临时降级
*
* 注意事项:
* - 数据仅存储在内存中,重启后丢失
* - 不适用于生产环境
* - 性能优异但无持久化保证
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { Users } from './users.entity';
import { CreateUserDto } from './users.dto';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer';
@Injectable()
export class UsersMemoryService {
private users: Map<bigint, Users> = new Map();
private currentId: bigint = BigInt(1);
/**
* 创建新用户
*
* @param createUserDto 创建用户的数据传输对象
* @returns 创建的用户实体
* @throws ConflictException 当用户名、邮箱或手机号已存在时
* @throws BadRequestException 当数据验证失败时
*/
async create(createUserDto: CreateUserDto): Promise<Users> {
// 验证DTO
const dto = plainToClass(CreateUserDto, createUserDto);
const validationErrors = await validate(dto);
if (validationErrors.length > 0) {
const errorMessages = validationErrors.map(error =>
Object.values(error.constraints || {}).join(', ')
).join('; ');
throw new BadRequestException(`数据验证失败: ${errorMessages}`);
}
// 检查用户名是否已存在
if (createUserDto.username) {
const existingUser = await this.findByUsername(createUserDto.username);
if (existingUser) {
throw new ConflictException('用户名已存在');
}
}
// 检查邮箱是否已存在
if (createUserDto.email) {
const existingEmail = await this.findByEmail(createUserDto.email);
if (existingEmail) {
throw new ConflictException('邮箱已存在');
}
}
// 检查手机号是否已存在
if (createUserDto.phone) {
const existingPhone = Array.from(this.users.values()).find(
u => u.phone === createUserDto.phone
);
if (existingPhone) {
throw new ConflictException('手机号已存在');
}
}
// 检查GitHub ID是否已存在
if (createUserDto.github_id) {
const existingGithub = await this.findByGithubId(createUserDto.github_id);
if (existingGithub) {
throw new ConflictException('GitHub ID已存在');
}
}
// 创建用户实体
const user = new Users();
user.id = this.currentId++;
user.username = createUserDto.username;
user.email = createUserDto.email || null;
user.phone = createUserDto.phone || null;
user.password_hash = createUserDto.password_hash || null;
user.nickname = createUserDto.nickname;
user.github_id = createUserDto.github_id || null;
user.avatar_url = createUserDto.avatar_url || null;
user.role = createUserDto.role || 1;
user.email_verified = createUserDto.email_verified || false;
user.created_at = new Date();
user.updated_at = new Date();
// 保存到内存
this.users.set(user.id, user);
return user;
}
/**
* 查询所有用户
*
* @param limit 限制返回数量默认100
* @param offset 偏移量默认0
* @returns 用户列表
*/
async findAll(limit: number = 100, offset: number = 0): Promise<Users[]> {
const allUsers = Array.from(this.users.values())
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
return allUsers.slice(offset, offset + limit);
}
/**
* 根据ID查询用户
*
* @param id 用户ID
* @returns 用户实体
* @throws NotFoundException 当用户不存在时
*/
async findOne(id: bigint): Promise<Users> {
const user = this.users.get(id);
if (!user) {
throw new NotFoundException(`ID为 ${id} 的用户不存在`);
}
return user;
}
/**
* 根据用户名查询用户
*
* @param username 用户名
* @returns 用户实体或null
*/
async findByUsername(username: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.username === username
);
return user || null;
}
/**
* 根据邮箱查询用户
*
* @param email 邮箱
* @returns 用户实体或null
*/
async findByEmail(email: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.email === email
);
return user || null;
}
/**
* 根据GitHub ID查询用户
*
* @param githubId GitHub ID
* @returns 用户实体或null
*/
async findByGithubId(githubId: string): Promise<Users | null> {
const user = Array.from(this.users.values()).find(
u => u.github_id === githubId
);
return user || null;
}
/**
* 更新用户信息
*
* @param id 用户ID
* @param updateData 更新的数据
* @returns 更新后的用户实体
* @throws NotFoundException 当用户不存在时
* @throws ConflictException 当更新的数据与其他用户冲突时
*/
async update(id: bigint, updateData: Partial<CreateUserDto>): Promise<Users> {
// 检查用户是否存在
const existingUser = await this.findOne(id);
// 检查更新数据的唯一性约束
if (updateData.username && updateData.username !== existingUser.username) {
const usernameExists = await this.findByUsername(updateData.username);
if (usernameExists) {
throw new ConflictException('用户名已存在');
}
}
if (updateData.email && updateData.email !== existingUser.email) {
const emailExists = await this.findByEmail(updateData.email);
if (emailExists) {
throw new ConflictException('邮箱已存在');
}
}
if (updateData.phone && updateData.phone !== existingUser.phone) {
const phoneExists = Array.from(this.users.values()).find(
u => u.phone === updateData.phone && u.id !== id
);
if (phoneExists) {
throw new ConflictException('手机号已存在');
}
}
if (updateData.github_id && updateData.github_id !== existingUser.github_id) {
const githubExists = await this.findByGithubId(updateData.github_id);
if (githubExists && githubExists.id !== id) {
throw new ConflictException('GitHub ID已存在');
}
}
// 更新用户数据
Object.assign(existingUser, updateData);
existingUser.updated_at = new Date();
this.users.set(id, existingUser);
return existingUser;
}
/**
* 删除用户
*
* @param id 用户ID
* @returns 删除操作结果
* @throws NotFoundException 当用户不存在时
*/
async remove(id: bigint): Promise<{ affected: number; message: string }> {
// 检查用户是否存在
await this.findOne(id);
// 执行删除
const deleted = this.users.delete(id);
return {
affected: deleted ? 1 : 0,
message: `成功删除ID为 ${id} 的用户`
};
}
/**
* 软删除用户(内存模式下与硬删除相同)
*
* @param id 用户ID
* @returns 被删除的用户实体
*/
async softRemove(id: bigint): Promise<Users> {
const user = await this.findOne(id);
this.users.delete(id);
return user;
}
/**
* 统计用户数量
*
* @param conditions 查询条件(内存模式下简化处理)
* @returns 用户数量
*/
async count(conditions?: any): Promise<number> {
if (!conditions) {
return this.users.size;
}
// 简化的条件过滤
let count = 0;
for (const user of this.users.values()) {
let match = true;
for (const [key, value] of Object.entries(conditions)) {
if ((user as any)[key] !== value) {
match = false;
break;
}
}
if (match) count++;
}
return count;
}
/**
* 检查用户是否存在
*
* @param id 用户ID
* @returns 是否存在
*/
async exists(id: bigint): Promise<boolean> {
return this.users.has(id);
}
/**
* 批量创建用户
*
* @param createUserDtos 用户数据数组
* @returns 创建的用户列表
*/
async createBatch(createUserDtos: CreateUserDto[]): Promise<Users[]> {
const users: Users[] = [];
for (const dto of createUserDtos) {
const user = await this.create(dto);
users.push(user);
}
return users;
}
/**
* 根据角色查询用户
*
* @param role 角色值
* @returns 用户列表
*/
async findByRole(role: number): Promise<Users[]> {
return Array.from(this.users.values())
.filter(u => u.role === role)
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
}
/**
* 搜索用户(根据用户名或昵称)
*
* @param keyword 搜索关键词
* @param limit 限制数量
* @returns 用户列表
*/
async search(keyword: string, limit: number = 20): Promise<Users[]> {
const lowerKeyword = keyword.toLowerCase();
return Array.from(this.users.values())
.filter(u =>
u.username.toLowerCase().includes(lowerKeyword) ||
u.nickname.toLowerCase().includes(lowerKeyword)
)
.sort((a, b) => b.created_at.getTime() - a.created_at.getTime())
.slice(0, limit);
}
}

View File

@@ -180,11 +180,15 @@ describe('LoginCoreService', () => {
const verifiedUser = { ...mockUser, email_verified: true };
usersService.findByEmail.mockResolvedValue(verifiedUser);
verificationService.generateCode.mockResolvedValue('123456');
emailService.sendVerificationCode.mockResolvedValue(true);
emailService.sendVerificationCode.mockResolvedValue({
success: true,
isTestMode: true
});
const code = await service.sendPasswordResetCode('test@example.com');
const result = await service.sendPasswordResetCode('test@example.com');
expect(code).toMatch(/^\d{6}$/);
expect(result.code).toMatch(/^\d{6}$/);
expect(result.isTestMode).toBe(true);
});
it('should throw NotFoundException for non-existent user', async () => {

View File

@@ -16,10 +16,10 @@
* @since 2025-12-17
*/
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
import { UsersService } from '../db/users/users.service';
import { Injectable, UnauthorizedException, ConflictException, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
import { Users } from '../db/users/users.entity';
import { EmailService } from '../utils/email/email.service';
import { UsersService } from '../db/users/users.service';
import { EmailService, EmailSendResult } from '../utils/email/email.service';
import { VerificationService, VerificationCodeType } from '../utils/verification/verification.service';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@@ -90,6 +90,16 @@ export interface AuthResult {
isNewUser?: boolean;
}
/**
* 验证码发送结果接口 by angjustinl 2025-12-17
*/
export interface VerificationCodeResult {
/** 验证码 */
code: string;
/** 是否为测试模式 */
isTestMode: boolean;
}
@Injectable()
export class LoginCoreService {
constructor(
@@ -122,7 +132,7 @@ export class LoginCoreService {
// 如果邮箱未找到,尝试手机号查找(简单验证)
if (!user && this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null;
user = users.find((u: Users) => u.phone === identifier) || null;
}
// 用户不存在
@@ -269,10 +279,10 @@ export class LoginCoreService {
* 发送密码重置验证码
*
* @param identifier 邮箱或手机号
* @returns 验证码(实际应用中应发送到用户邮箱/手机)
* @returns 验证码结果
* @throws NotFoundException 用户不存在时
*/
async sendPasswordResetCode(identifier: string): Promise<string> {
async sendPasswordResetCode(identifier: string): Promise<VerificationCodeResult> {
// 查找用户
let user: Users | null = null;
@@ -285,7 +295,7 @@ export class LoginCoreService {
}
} else if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null;
user = users.find((u: Users) => u.phone === identifier) || null;
}
if (!user) {
@@ -299,23 +309,28 @@ export class LoginCoreService {
);
// 发送验证码
let isTestMode = false;
if (this.isEmail(identifier)) {
const success = await this.emailService.sendVerificationCode({
const result = await this.emailService.sendVerificationCode({
email: identifier,
code: verificationCode,
nickname: user.nickname,
purpose: 'password_reset'
});
if (!success) {
if (!result.success) {
throw new BadRequestException('验证码发送失败,请稍后重试');
}
isTestMode = result.isTestMode;
} else {
// TODO: 实现短信发送
console.log(`短信验证码(${identifier}: ${verificationCode}`);
isTestMode = true; // 短信也是测试模式
}
return verificationCode; // 实际应用中不应返回验证码
return { code: verificationCode, isTestMode };
}
/**
@@ -347,7 +362,7 @@ export class LoginCoreService {
user = await this.usersService.findByEmail(identifier);
} else if (this.isPhoneNumber(identifier)) {
const users = await this.usersService.findAll();
user = users.find(u => u.phone === identifier) || null;
user = users.find((u: Users) => u.phone === identifier) || null;
}
if (!user) {
@@ -457,9 +472,9 @@ export class LoginCoreService {
*
* @param email 邮箱地址
* @param nickname 用户昵称
* @returns 验证码
* @returns 验证码结果
*/
async sendEmailVerification(email: string, nickname?: string): Promise<string> {
async sendEmailVerification(email: string, nickname?: string): Promise<VerificationCodeResult> {
// 生成验证码
const verificationCode = await this.verificationService.generateCode(
email,
@@ -467,18 +482,18 @@ export class LoginCoreService {
);
// 发送验证邮件
const success = await this.emailService.sendVerificationCode({
const result = await this.emailService.sendVerificationCode({
email,
code: verificationCode,
nickname,
purpose: 'email_verification'
});
if (!success) {
if (!result.success) {
throw new BadRequestException('验证邮件发送失败,请稍后重试');
}
return verificationCode; // 实际应用中不应返回验证码
return { code: verificationCode, isTestMode: result.isTestMode };
}
/**
@@ -520,9 +535,9 @@ export class LoginCoreService {
* 重新发送邮箱验证码
*
* @param email 邮箱地址
* @returns 验证码
* @returns 验证码结果
*/
async resendEmailVerification(email: string): Promise<string> {
async resendEmailVerification(email: string): Promise<VerificationCodeResult> {
const user = await this.usersService.findByEmail(email);
if (!user) {

View File

@@ -117,7 +117,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions);
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith({
from: '"Test Sender" <noreply@test.com>',
to: 'test@example.com',
@@ -138,7 +139,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
});
it('应该在测试模式下输出邮件内容', async () => {
@@ -157,13 +159,14 @@ describe('EmailService', () => {
};
// Mock the service to use test transporter
const loggerSpy = jest.spyOn(service['logger'], 'log').mockImplementation();
const loggerSpy = jest.spyOn(service['logger'], 'warn').mockImplementation();
service['transporter'] = testTransporter;
const result = await service.sendEmail(emailOptions);
expect(result).toBe(true);
expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式) ===');
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(true);
expect(loggerSpy).toHaveBeenCalledWith('=== 邮件发送(测试模式 - 邮件未真实发送) ===');
loggerSpy.mockRestore();
});
@@ -183,7 +186,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options);
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
@@ -206,7 +210,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options);
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
@@ -227,7 +232,8 @@ describe('EmailService', () => {
const result = await service.sendVerificationCode(options);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
});
});
@@ -238,7 +244,8 @@ describe('EmailService', () => {
const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
expect(result).toBe(true);
expect(result.success).toBe(true);
expect(result.isTestMode).toBe(false);
expect(mockTransporter.sendMail).toHaveBeenCalledWith(
expect.objectContaining({
to: 'test@example.com',
@@ -253,7 +260,8 @@ describe('EmailService', () => {
const result = await service.sendWelcomeEmail('test@example.com', '测试用户');
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.error).toBe('发送失败');
});
});
@@ -358,7 +366,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
});
it('应该正确处理认证错误', async () => {
@@ -372,7 +381,8 @@ describe('EmailService', () => {
const result = await service.sendEmail(emailOptions);
expect(result).toBe(false);
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid login');
});
it('应该正确处理连接验证错误', async () => {

View File

@@ -50,6 +50,18 @@ export interface VerificationEmailOptions {
purpose: 'email_verification' | 'password_reset';
}
/**
* 邮件发送结果接口 by angjustinl 2025-12-17
*/
export interface EmailSendResult {
/** 是否成功 */
success: boolean;
/** 是否为测试模式 */
isTestMode: boolean;
/** 错误信息(如果失败) */
error?: string;
}
@Injectable()
export class EmailService {
private readonly logger = new Logger(EmailService.name);
@@ -87,13 +99,22 @@ export class EmailService {
}
}
/**
* 检查是否为测试模式
*
* @returns 是否为测试模式
*/
isTestMode(): boolean {
return !!(this.transporter.options as any).streamTransport;
}
/**
* 发送邮件
*
* @param options 邮件选项
* @returns 发送结果
*/
async sendEmail(options: EmailOptions): Promise<boolean> {
async sendEmail(options: EmailOptions): Promise<EmailSendResult> {
try {
const mailOptions = {
from: this.configService.get<string>('EMAIL_FROM', '"Whale Town Game" <noreply@whaletown.com>'),
@@ -103,22 +124,31 @@ export class EmailService {
text: options.text,
};
const result = await this.transporter.sendMail(mailOptions);
const isTestMode = this.isTestMode();
// 如果是测试模式,输出邮件内容到控制台
if ((this.transporter.options as any).streamTransport) {
this.logger.log('=== 邮件发送(测试模式) ===');
this.logger.log(`收件人: ${options.to}`);
this.logger.log(`主题: ${options.subject}`);
this.logger.log(`内容: ${options.text || '请查看HTML内容'}`);
this.logger.log('========================');
if (isTestMode) {
this.logger.warn('=== 邮件发送(测试模式 - 邮件未真实发送 ===');
this.logger.warn(`收件人: ${options.to}`);
this.logger.warn(`主题: ${options.subject}`);
this.logger.warn(`内容: ${options.text || '请查看HTML内容'}`);
this.logger.warn('⚠️ 注意: 这是测试模式,邮件不会真实发送到用户邮箱');
this.logger.warn('💡 提示: 请在 .env 文件中配置邮件服务以启用真实发送');
this.logger.warn('================================================');
return { success: true, isTestMode: true };
}
this.logger.log(`邮件发送成功: ${options.to}`);
return true;
// 真实发送邮件
const result = await this.transporter.sendMail(mailOptions);
this.logger.log(`✅ 邮件发送成功: ${options.to}`);
return { success: true, isTestMode: false };
} catch (error) {
this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error));
return false;
this.logger.error(`邮件发送失败: ${options.to}`, error instanceof Error ? error.stack : String(error));
return {
success: false,
isTestMode: this.isTestMode(),
error: error instanceof Error ? error.message : String(error)
};
}
}
@@ -128,7 +158,7 @@ export class EmailService {
* @param options 验证码邮件选项
* @returns 发送结果
*/
async sendVerificationCode(options: VerificationEmailOptions): Promise<boolean> {
async sendVerificationCode(options: VerificationEmailOptions): Promise<EmailSendResult> {
const { email, code, nickname, purpose } = options;
let subject: string;
@@ -157,7 +187,7 @@ export class EmailService {
* @param nickname 用户昵称
* @returns 发送结果
*/
async sendWelcomeEmail(email: string, nickname: string): Promise<boolean> {
async sendWelcomeEmail(email: string, nickname: string): Promise<EmailSendResult> {
const subject = '🎮 欢迎加入 Whale Town';
const template = this.getWelcomeTemplate(nickname);

View File

@@ -272,7 +272,7 @@ describe('VerificationService', () => {
};
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
mockRedis.ttl.mockResolvedValue(300); // 设置正常的TTL值
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
new BadRequestException('验证码错误,剩余尝试次数: 1')
@@ -286,7 +286,7 @@ describe('VerificationService', () => {
expect(mockRedis.set).toHaveBeenCalledWith(
`verification_code:${type}:${email}`,
JSON.stringify(updatedCodeInfo),
300
240
);
});
@@ -299,7 +299,7 @@ describe('VerificationService', () => {
};
mockRedis.get.mockResolvedValue(JSON.stringify(codeInfo));
mockRedis.ttl.mockResolvedValue(300); // 设置正常的TTL值
mockRedis.ttl.mockResolvedValue(240); // Mock TTL 返回 240 秒
await expect(service.verifyCode(email, type, '654321')).rejects.toThrow(
new BadRequestException('验证码错误,剩余尝试次数: 0')
@@ -394,19 +394,19 @@ describe('VerificationService', () => {
attempts: 1,
maxAttempts: 3,
code: '123456',
createdAt: 1766035834340,
createdAt: expect.any(Number),
});
});
it('应该在验证码不存在时返回基本信息', async () => {
mockRedis.exists.mockResolvedValue(false);
mockRedis.ttl.mockResolvedValue(-2);
mockRedis.ttl.mockResolvedValue(-2); // -2 表示键不存在
const result = await service.getCodeStats(email, type);
expect(result).toEqual({
exists: false,
ttl: -2,
ttl: -2, // 修改为 -2
});
});

72
src/dto/app.dto.ts Normal file
View File

@@ -0,0 +1,72 @@
/**
* 应用状态响应 DTO
*
* 功能描述:
* - 定义应用状态接口的响应格式
* - 提供 Swagger 文档生成支持
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* 应用状态响应 DTO
*/
export class AppStatusResponseDto {
@ApiProperty({
description: '服务名称',
example: 'Pixel Game Server',
type: String
})
service: string;
@ApiProperty({
description: '服务版本',
example: '1.0.0',
type: String
})
version: string;
@ApiProperty({
description: '运行状态',
example: 'running',
enum: ['running', 'starting', 'stopping', 'error'],
type: String
})
status: string;
@ApiProperty({
description: '当前时间戳',
example: '2025-12-17T15:00:00.000Z',
type: String,
format: 'date-time'
})
timestamp: string;
@ApiProperty({
description: '运行时间(秒)',
example: 3600,
type: Number,
minimum: 0
})
uptime: number;
@ApiProperty({
description: '运行环境',
example: 'development',
enum: ['development', 'production', 'test'],
type: String
})
environment: string;
@ApiProperty({
description: '存储模式',
example: 'memory',
enum: ['database', 'memory'],
type: String
})
storage_mode: 'database' | 'memory';
}

View File

@@ -0,0 +1,56 @@
/**
* 通用错误响应 DTO
*
* 功能描述:
* - 定义统一的错误响应格式
* - 提供 Swagger 文档生成支持
*
* @author angjustinl
* @version 1.0.0
* @since 2025-12-17
*/
import { ApiProperty } from '@nestjs/swagger';
/**
* 通用错误响应 DTO
*/
export class ErrorResponseDto {
@ApiProperty({
description: 'HTTP 状态码',
example: 500,
type: Number
})
statusCode: number;
@ApiProperty({
description: '错误消息',
example: 'Internal server error',
type: String
})
message: string;
@ApiProperty({
description: '错误发生时间',
example: '2025-12-17T15:00:00.000Z',
type: String,
format: 'date-time'
})
timestamp: string;
@ApiProperty({
description: '请求路径',
example: '/api/status',
type: String,
required: false
})
path?: string;
@ApiProperty({
description: '错误代码',
example: 'INTERNAL_ERROR',
type: String,
required: false
})
error?: string;
}

View File

@@ -209,6 +209,13 @@ export class ForgotPasswordResponseDataDto {
required: false
})
verification_code?: string;
@ApiProperty({
description: '是否为测试模式',
example: true,
required: false
})
is_test_mode?: boolean;
}
/**
@@ -217,26 +224,76 @@ export class ForgotPasswordResponseDataDto {
export class ForgotPasswordResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
example: false,
examples: {
success: {
summary: '真实发送成功',
value: true
},
testMode: {
summary: '测试模式',
value: false
}
}
})
success: boolean;
@ApiProperty({
description: '响应数据',
type: ForgotPasswordResponseDataDto,
required: false
required: false,
examples: {
success: {
summary: '真实发送成功',
value: {
verification_code: '123456',
is_test_mode: false
}
},
testMode: {
summary: '测试模式',
value: {
verification_code: '059174',
is_test_mode: true
}
}
}
})
data?: ForgotPasswordResponseDataDto;
@ApiProperty({
description: '响应消息',
example: '验证码已发送,请查收'
example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。',
examples: {
success: {
summary: '真实发送成功',
value: '验证码已发送,请查收'
},
testMode: {
summary: '测试模式',
value: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
}
}
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'SEND_CODE_FAILED',
example: 'TEST_MODE_ONLY',
examples: {
success: {
summary: '真实发送成功',
value: null
},
testMode: {
summary: '测试模式',
value: 'TEST_MODE_ONLY'
},
failed: {
summary: '发送失败',
value: 'SEND_CODE_FAILED'
}
},
required: false
})
error_code?: string;
@@ -265,3 +322,74 @@ export class CommonResponseDto {
})
error_code?: string;
}
/**
* DTO by angjustinl 2025-12-17
*/
export class TestModeEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功测试模式下为false',
example: false
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '059174',
is_test_mode: true
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '⚠️ 测试模式:验证码已生成但未真实发送。请在控制台查看验证码,或配置邮件服务以启用真实发送。'
})
message: string;
@ApiProperty({
description: '错误代码',
example: 'TEST_MODE_ONLY'
})
error_code: string;
}
/**
* DTO
*/
export class SuccessEmailVerificationResponseDto {
@ApiProperty({
description: '请求是否成功',
example: true
})
success: boolean;
@ApiProperty({
description: '响应数据',
example: {
verification_code: '123456',
is_test_mode: false
}
})
data: {
verification_code: string;
is_test_mode: boolean;
};
@ApiProperty({
description: '响应消息',
example: '验证码已发送,请查收'
})
message: string;
@ApiProperty({
description: '错误代码',
example: null,
required: false
})
error_code?: string;
}

View File

@@ -3,8 +3,42 @@ import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
/**
* 检查数据库配置是否完整 by angjustinl 2025-12-17
*
* @returns 是否配置了数据库
*/
function isDatabaseConfigured(): boolean {
const requiredEnvVars = ['DB_HOST', 'DB_PORT', 'DB_USERNAME', 'DB_PASSWORD', 'DB_NAME'];
return requiredEnvVars.every(varName => process.env[varName]);
}
/**
* 打印启动横幅
*/
function printBanner() {
const isDatabaseMode = isDatabaseConfigured();
console.log('\n' + '='.repeat(70));
console.log('🎮 Pixel Game Server');
console.log('='.repeat(70));
console.log(`📦 存储模式: ${isDatabaseMode ? '数据库模式 (MySQL)' : '内存模式 (Memory)'}`);
if (!isDatabaseMode) {
console.log('⚠️ 警告: 未检测到数据库配置,使用内存存储');
console.log('💡 提示: 数据将在服务重启后丢失');
console.log('📝 配置: 请在 .env 文件中配置数据库连接信息');
} else {
console.log('✅ 数据库: 已连接到 MySQL 数据库');
}
console.log('='.repeat(70) + '\n');
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create(AppModule, {
logger: ['error', 'warn', 'log'],
});
// 全局启用校验管道(核心配置)
app.useGlobalPipes(