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:
angjustinl
2025-12-18 00:17:43 +08:00
parent 2a3698b26a
commit 26ea5ac815
19 changed files with 1362 additions and 111 deletions

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,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) {

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);