feat(sql, auth, email, dto):重构邮箱验证流程,引入基于内存的用户服务,并改进 API 响应处理
* 新增完整的 API 状态码文档,并对测试模式进行特殊处理(`206 Partial Content`) * 重组 DTO 结构,引入 `app.dto.ts` 与 `error_response.dto.ts`,以实现统一、规范的响应格式 * 重构登录相关 DTO,优化命名与结构,提升可维护性 * 实现基于内存的用户服务(`users_memory.service.ts`),用于开发与测试环境 * 更新邮件服务,增强验证码生成逻辑,并支持测试模式自动识别 * 增强登录控制器与服务层的错误处理能力,统一响应行为 * 优化核心登录服务,强化参数校验并集成邮箱验证流程 * 新增 `@types/express` 依赖,提升 TypeScript 类型支持与开发体验 * 改进 `main.ts`,优化应用初始化流程与配置管理 * 在所有服务中统一错误处理机制,采用标准化的错误响应格式 * 实现测试模式(`206`)与生产环境邮件发送(`200`)之间的无缝切换
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(); // 真实模式下不返回验证码
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
}
|
||||
349
src/core/db/users/users_memory.service.ts
Normal file
349
src/core/db/users/users_memory.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
* @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 { 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,10 +89,20 @@ export interface AuthResult {
|
||||
isNewUser?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证码发送结果接口 by angjustinl 2025-12-17
|
||||
*/
|
||||
export interface VerificationCodeResult {
|
||||
/** 验证码 */
|
||||
code: string;
|
||||
/** 是否为测试模式 */
|
||||
isTestMode: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LoginCoreService {
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
@Inject('UsersService') private readonly usersService: any,
|
||||
private readonly emailService: EmailService,
|
||||
private readonly verificationService: VerificationService,
|
||||
) {}
|
||||
@@ -122,7 +131,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 +278,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 +294,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 +308,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 +361,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 +471,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 +481,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 +534,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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
72
src/dto/app.dto.ts
Normal file
72
src/dto/app.dto.ts
Normal 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';
|
||||
}
|
||||
56
src/dto/error_response.dto.ts
Normal file
56
src/dto/error_response.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -264,4 +321,75 @@ export class CommonResponseDto {
|
||||
required: false
|
||||
})
|
||||
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;
|
||||
}
|
||||
36
src/main.ts
36
src/main.ts
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user