全栈实践又到了我们喜闻乐见的用户注册功能……

1. 修 BUG

我们先修一下先前写的 BUG……

1.1. 导出 BUG

在我们先前创建的 src/stores/index.ts 中,我们写的是:

1
import useUserStore from './user';

这样写的话,实际运行项目后会报错。

正确的写法是:

1
export { useUserStore } from './user';
  1. index.ts 是用于导出所有的 Store,因此是 export 而不是 import

  2. 要理解为什么必须使用 { useUserStore } 而不是 useUserStore,我们需要了解 JavaScript 和 TypeScript 中的默认导出(default export)和具名导出(named export)之间的区别

1.1.1. 具名导出

stores/user/index.ts 中,useUserStore 是通过具名导出方式来导出的:

1
export const useUserStore = createSelectors(useUserStoreBase);

这表示我们将 useUserStore 作为一个具名导出,并且它可以通过具名导出来引用。具名导出的语法正是:

1
export { useUserStore } from './user';

1.1.2. 默认导出

如果我们使用的是默认导出,那么导出时应该写成:

1
export default useUserStore;

然后我们才可以在其他文件使用默认导出语法:

1
export useUserStore from './user';

1.2. 实体关系 BUG

后续在后端开发完注册 API、运行应用时,会遇到实体关系配置错误:

1
TypeORMError: Entity metadata for Users#orders was not found. Check if you specified a correct entity object and if it's connected in the connection options.

这是因为 NestJS 使用 TypeORM 时需要通过 TypeOrmModule 注册所有的实体。如果一个实体(例如 Users)中引用了其他实体(例如 Orders),TypeORM 会尝试加载并解析这些关系。如果 Orders 没有在 TypeOrmModule 配置中注册,TypeORM 将会因为找不到该实体的元数据而抛出错误。

唉,这时候有人该问了,第二篇里不是已经写了个 database.module.ts 来统一配置吗?里面还配置了个 autoLoadEntities 呢。

在数据库模块的配置中,我们确实加上了 autoLoadEntities: true,希望能自动加载所有实体,避免每次手动注册实体。然而,autoLoadEntities 的工作机制并非全局加载所有实体,它仅自动加载通过 TypeOrmModule.forFeature() 注册的实体。因此,如果某个实体未被 forFeature 导入到任何模块中,TypeORM 无法找到它。

TypeOrmModule 中,有三种方式来配置实体加载,每种方式有不同的适用场景:

  1. 单独定义:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    TypeOrmModule.forRoot({
    //...
    entities: [
    Users,
    Products,
    Payments,
    Orders,
    OrderItems,
    InventoryLogs,
    Categories,
    Carts,
    CartItems,
    Addresses,
    ],
    }),

    这种方式适合开发阶段,明确知道所有实体的数量和位置时,每当创建新实体时手动添加即可。但在项目复杂、实体较多或依赖多模块的情况下,逐一引入会显得繁琐,容易出错,且不便于代码维护。

  2. 自动加载:

    1
    2
    3
    4
    TypeOrmModule.forRoot({
    //...
    autoLoadEntities: true,
    }),

    autoLoadEntities 会自动加载通过 TypeOrmModule.forFeature() 引入的实体。这意味着在模块中显式使用 TypeOrmModule.forFeature([Entity]) 的实体将被自动添加到连接配置的 entities 数组中。

    注意:

    这要求每个模块中要包含实体的 forFeature 配置。否则未注册的实体将无法自动加载,容易引发实体关系配置错误。

    Users 实体中,Orders 实体被作为关联实体引用 (@OneToMany(() => Orders, order => order.user)),因此 TypeORM 会尝试在 entities 配置数组中找到并加载 Orders 的元数据。由于 Orders 没有在任何模块的 forFeature 中注册,TypeORM 会在解析 Users 实体的关系时找不到 Orders,导致报错。

  3. 自定义引入路径:

    1
    2
    3
    4
    TypeOrmModule.forRoot({
    //...
    entities: ['dist/**/*.entity{.ts,.js}'],
    }),

    这是官方推荐的方式,使用通配符路径直接加载所有编译后的实体文件(如 dist/entities/*.entity.js),避免了逐一手动添加的麻烦,并保证所有实体自动注册,减少遗漏问题。

在我们的 database.module.ts 中添加第三种方式的配置,即可解决报错。

2. 实现注册表单组件

2.1. 基础结构

我们的注册页面由两个主要部分组成:

  • Register 组件:处理表单逻辑、验证以及提交请求
  • AuthLayout 组件:负责提供页面的布局和样式

Register 组件嵌套在 AuthLayout 中,这样可以确保页面结构保持一致。

首先,我们来看看 AuthLayout.tsx 组件的代码,它定义了一个简单的容器,将任何传递给它的内容居中显示,并设置一些基本的样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import React from 'react';

type AuthLayoutProps = {
children: React.ReactNode;
};

const AuthLayout = ({ children }: AuthLayoutProps) => {
return (
<div className="flex min-h-screen items-center justify-center bg-base-200">
<div className="w-full max-w-md bg-base-100 p-8 rounded-lg shadow-xl">
{children}
</div>
</div>
);
};

export default AuthLayout;

这里使用了 Tailwind CSS 进行样式布局,AuthLayout 用于包装页面的子元素,确保其在屏幕上居中显示,并有一定的阴影和内边距。

接着在 src/pages 目录下创建 Register.tsx

2.2. 表单开发

在我们的 Register 组件中,我们将使用 Formik 来处理表单的状态管理和提交,并使用 Yup 来进行表单验证。FormikYup 的结合提供了简洁且强大的表单验证和管理能力。

用以下命令安装 FormikYup

1
yarn add formik yup

2.2.1. Formik 用法

Formik 通过 useFormik Hook 管理表单的状态和行为。在 Register 页面中,我们初始化了一个表单,提供了初始值和 onSubmit 处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useFormik } from 'formik';

const Register = () => {
const formik = useFormik({
initialValues: {
username: "",
email: "",
password: "",
confirmPassword: ""
},
validationSchema,
onSubmit: (values) => {
setIsSubmitting(true);
setTimeout(() => {
console.log(values); // 模拟提交
setIsSubmitting(false);
}, 2000);
}
});

// ...
}
  • initialValues:初始化表单的默认值
  • validationSchema:使用 Yup 创建的验证规则(稍后会详细介绍)
  • onSubmit:处理表单提交的函数
    • setIsSubmitting(true):在表单提交时,我们将 isSubmitting 设置为 true,这会触发按钮禁用以及按钮文本更新为“注册中……”
    • setTimeout:为了模拟实际的提交过程,我们使用 setTimeout 延迟了2秒钟。实际应用中这里应该替换为 API 请求,不过我们的 API 还没完成呢
    • setIsSubmitting(false):当提交操作完成时,我们将 isSubmitting 设置为 false,恢复按钮的正常状态

2.2.2. Yup 验证

Yup 是一个 JavaScript 的对象模式验证库,我们通过它来定义表单的验证规则。下面是每个字段的验证规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import * as Yup from 'yup';

const Register = () => {
const validationSchema = Yup.object({
username: Yup.string().required("请输入用户名!"),
email: Yup.string().email("请输入有效的邮箱地址!").required("请输入邮箱地址!"),
password: Yup.string().min(6, "密码必须至少包含6个字符!").required("请输入密码!"),
confirmPassword: Yup.string()
.oneOf([Yup.ref("password")], "密码不匹配!")
.required("请输入确认密码!"),
});

// ...
}
  • username:必须填写,并且是字符串
  • email:必须添加,并且符合邮箱格式
  • password:必须至少有 6 个字符
  • confirmPassword:必须与密码匹配

YupFormik 的结合提供了简洁的验证机制,自动管理每个字段的错误信息,并在表单提交时触发验证。

2.2.3. 表单渲染

表单输入框的渲染非常直观。我们通过 formik.handleChange 来处理用户输入,并通过 formik.errorsformik.touched 来显示错误信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
const Register = () => {
// ...

return (
<AuthLayout>
<h2 className="text-2xl font-bold text-center mb-6 text-neutral-content">
注册账户
</h2>
<form onSubmit={formik.handleSubmit}>
<div className="mb-4">
<label className="block text-sm font-semibold text-neutral-content" htmlFor="username">
用户名
</label>
<input
type="text"
id="username"
name="username"
className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.username && formik.errors.username ? "border-error" : "border-neutral-600"} text-base-content`}
value={formik.values.username}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.username && formik.errors.username && (
<p className="text-sm text-error mt-1">{formik.errors.username}</p>
)}
</div>

{/* ... */}
</form>
</AuthLayout>
);
};
  • value:绑定表单值
  • onChangeonBlur:处理表单输入和失去焦点事件
  • formik.errors:在字段发生错误时显示错误信息

用同样的写法,写完“邮箱地址”、“密码”和“确认密码”输入框:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
const Register = () => {
// ...

return (
<AuthLayout>
<h2 className="text-2xl font-bold text-center mb-6 text-neutral-content">
注册账户
</h2>
<form onSubmit={formik.handleSubmit}>
{/* ... */}

<div className="mb-4">
<label className="block text-sm font-semibold text-neutral-content" htmlFor="email">
邮箱地址
</label>
<input
type="email"
id="email"
name="email"
className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.email && formik.errors.email ? "border-error" : "border-neutral-600"} text-base-content`}
value={formik.values.email}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.email && formik.errors.email && (
<p className="text-sm text-error mt-1">{formik.errors.email}</p>
)}
</div>

<div className="mb-4">
<label className="block text-sm font-semibold text-neutral-content" htmlFor="password">
密码
</label>
<input
type="password"
id="password"
name="password"
className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.password && formik.errors.password ? "border-error" : "border-neutral-600"} text-base-content`}
value={formik.values.password}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.password && formik.errors.password && (
<p className="text-sm text-error mt-1">{formik.errors.password}</p>
)}
</div>

<div className="mb-4">
<label className="block text-sm font-semibold text-neutral-content" htmlFor="confirmPassword">
确认密码
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
className={`w-full mt-2 p-2 border rounded-lg bg-base-300 ${formik.touched.confirmPassword && formik.errors.confirmPassword ? "border-error" : "border-neutral-600"} text-base-content`}
value={formik.values.confirmPassword}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
/>
{formik.touched.confirmPassword && formik.errors.confirmPassword && (
<p className="text-sm text-error mt-1">{formik.errors.confirmPassword}</p>
)}
</div>
</form>
</AuthLayout>
);
};

2.2.4. 按钮的状态管理

按钮的状态管理对于处理表单提交时的交互反馈是非常重要的。

useState 是 React 中用于管理组件状态的 Hook。它允许我们在函数组件内部创建一个可变的状态,并返回一个更新该状态的函数。在注册页面中,我们使用 useState 来管理表单是否正在提交。

1
2
3
4
5
6
7
import React, { useState } from 'react';

const Register = () => {
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

// ...
};
  • isSubmitting:表示表单是否正在提交,初始值是 false,即默认情况下表单没有提交
  • setIsSubmitting:用于更新 isSubmitting 状态的函数

每当表单提交时,我们会将 isSubmitting 设置为 true,表示正在进行提交操作。当提交完成后,再将其设置回 false

2.2.5. 按钮的状态变化

在注册页面中,表单的提交按钮(<button>)会根据 isSubmitting 的状态进行显示不同的文本内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Register = () => {
// ...

return (
<AuthLayout>
<h2 className="text-2xl font-bold text-center mb-6 text-neutral-content">
注册账户
</h2>
<form onSubmit={formik.handleSubmit}>
{/* ... */}

<button type="submit" className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90" disabled={isSubmitting}>
{isSubmitting ? "注册中……" : "注册"}
</button>
</form>
);
};
</button>
  • disabled={isSubmitting}:按钮在提交时被禁用,防止用户多次点击。每次表单提交时,isSubmitting 会被设为 true,这将使按钮处于禁用状态,直到提交结束
  • 按钮文本切换:根据 isSubmitting 的值,按钮的文本会动态改变
    • 如果表单正在提交(isSubmitting === true),按钮文本会显示为“注册中……”
    • 如果表单没有提交(isSubmitting === false),按钮显示为 “注册”

2.3. 路由配置

为了使我们的页面能够被访问和管理,在我们的 router.tsx 中添加路由 /register

1
2
3
4
5
6
7
8
9
import Register from './pages/Register';

const router = createBrowserRouter([
// ...
{
path: '/register',
element: <Register />
}
]);

运行 React 项目,访问 localhost:3000/register

Register页面预览

图片是另外加的。

3. 实现后端注册 API

在现代的 Web 应用开发中,用户注册功能是几乎每个系统都需要的基础部分。用户注册不仅需要保存用户的基本信息,还要确保密码等敏感数据的安全性。

设想这样一个场景:我们正在开发一个用户系统,要求用户可以通过提供必要的个人信息进行注册,并创建一个账号。由于用户密码是非常敏感的信息,我们必须在保存密码之前进行加密,以确保其安全性。此外,我们还需要在需要时提供其他用户管理的接口,如更新、删除等操作。

3.1. 创建用户模块

用户模块负责管理用户相关的逻辑,创建 users.module.ts 文件:

1
2
3
4
5
6
7
8
9
10
11
12
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { Users } from '../entities/users.entity';

@Module({
imports: [TypeOrmModule.forFeature([Users])],
controllers: [UsersController],
providers: [UsersService]
})
export class UsersModule {}

在此模块中,我们使用了 TypeOrmModule.forFeature([Users]) 来将用户实体与 TypeORM 绑定,以便在 UsersService 中使用数据库的增删查改功能。

3.2. 创建用户控制器

控制器负责定义 API 路由和对应的处理方法,创建 users.controller.ts 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { Controller, Post, Get, Body, Param } from '@nestjs/common';
import { UsersService } from './users.service';
import { Users } from '../entities/users.entity';

@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}

@Post('create')
create(@Body() user: Partial<Users>): Promise<Users> {
return this.usersService.create(user);
}

@Get(':id')
findById(@Param('id') id: string): Promise<Users> {
return this.usersService.findById(id);
}

@Post('update/:id')
update(@Param('id') id: string, @Body() updateUser: Partial<Users>): Promise<Users> {
return this.usersService.update(id, updateUser);
}

@Post('delete/:id')
remove(@Param('id') id: string): Promise<void> {
return this.usersService.remove(id);
}
}

我们定义了以下几个方法:

  • create:用于处理用户注册的 POST 请求,调用 UsersService.create 方法以保存用户信息
  • findByIdupdateremove 方法:分别用于获取、更新和删除用户信息

3.3. 编写用户服务

UsersService 负责处理具体的业务逻辑,包括数据的加密和与数据库的交互。在实现注册功能时,我们需要对用户密码进行加密,并将加密后的密码与其他信息一起保存到数据库。

创建 users.service.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { Users } from '../entities/users.entity';

@Injectable()
export class UsersService {
constructor(@InjectRepository(Users) private usersRepository: Repository<Users>) {}

async create(user: Partial<Users>): Promise<Users> {
const hashedPassword = await bcrypt.hash(user.password, 10);
const newUser = this.usersRepository.create({
...user,
password: hashedPassword
});
return this.usersRepository.save(newUser);
}

async update(id: string, updateUser: Partial<Users>): Promise<Users> {
const user = await this.findById(id);
if (updateUser.password) {
updateUser.password = await bcrypt.hash(updateUser.password, 10);
}
Object.assign(user, updateUser);
return this.usersRepository.save(user);
}

async remove(id: string): Promise<void> {
const result = await this.usersRepository.delete(id);
if (result.affected === 0) {
throw new NotFoundException(`User with ID ${id} is not found`);
}
}

async findById(id: string): Promise<Users> {
const user = await this.usersRepository.findOneBy({ id });
if (!user) {
throw new NotFoundException(`User with ID ${id} is not found`);
}
return user;
}
}

UsersService 实现了以下方法:

  • create:用于创建用户。在保存用户数据前,通过 bcrypt.hash 方法对密码进行加密,然后将加密后的用户数据保存到数据库
  • update:用于更新用户信息。如果更新的数据中包含 password,则需要再次加密后再保存
  • remove:用于删除用户信息。如果删除操作没有影响任何行,则会抛出 NotFoundException 错误
  • findById:用于根据用户 ID 查询用户信息。找不到用户时同样会抛出 NotFoundException

3.4. 通过 Swagger 生成文档标签

我们将为每个控制器方法添加 Swagger 标签:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ...
import { ApiTags, ApiOperation, ApiBody, ApiResponse, ApiParam } from '@nestjs/swagger';

@ApiTags('Users')
@Controller('users')
export class UsersController {
// ...

@ApiOperation({ summary: '创建新用户' })
@ApiBody({ description: '用户信息', type: Users })
@ApiResponse({ status: 201, description: '用户成功创建', type: Users })
@Post('create')
// ...

@ApiOperation({ summary: '根据ID获取用户信息' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiResponse({ status: 200, description: '获取用户信息成功', type: Users })
@Get(':id')
// ...

@ApiOperation({ summary: '更新用户信息' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiBody({ description: '更新的用户信息', type: Users })
@ApiResponse({ status: 200, description: '用户更新成功', type: Users })
@Post('update/:id')
// ...

@ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiResponse({ status: 200, description: '用户删除成功' })
@Post('delete/:id')
// ...
}
  • @ApiTags('Users'):将此标签添加到控制器顶部,使得所有方法都归入 Users 类别,便于在 Swagger 文档中管理
  • @ApiOperation:为每个方法添加操作说明,使其清晰描述了 API 的功能
  • @ApiBody:为 POST 请求体参数添加描述,指定了参数内容的描述和数据类型
  • @ApiParam:为路径参数添加说明,方便开发者了解需要传入的参数
  • @ApiResponse:指定响应的状态码和描述信息,以及返回的数据类型,便于用户了解响应格式

访问 localhost:APP_PORT/api-docs 来查看 Swagger 文档。

3.5. 使用 DTO 来进行优化

在现代Web应用中,数据传输对象(DTO)是与外部通信的标准化方式之一。在 NestJS 项目中,DTO(Data Transfer Object)不仅帮助你确保传递的数据格式一致,还提供了结构化和验证机制,使得 API 接口更加清晰、安全和可维护。

DTO 通常用于:

  • 定义 API 接口所需的请求体和响应体的结构
  • 对数据进行验证和转换
  • 确保前后端在数据传输时遵循相同的约定

在我们已经编写了注册用户的逻辑后,为什么还需要创建 DTO 并用它来顶替原本的写法?

答案是:增强数据验证和规范化输入输出

  1. 增强验证机制:DTO 通过类验证器(如 class-validator)来确保传入的数据符合预期。例如,我们可以设置 MinLength 来确保密码长度不小于 6 个字符、使用 IsEmail 来验证邮箱格式。这不仅使代码更加规范,而且还大大提高了API的可靠性和安全性

  2. 清晰的 API 结构:DTO 使 API 请求和响应体更加清晰。通过 DTO,前后端可以明确约定需要的数据字段和格式,这样可以减少由于数据格式不匹配导致的错误

  3. 易于扩展和维护:DTO 提供了一个灵活的扩展点。如果未来业务需求发生变化,我们只需修改 DTO,而不必修改整个业务逻辑。这样,系统的可维护性和扩展性更强

我们已经有了一个用于用户注册的业务逻辑,现在我们要将 DTO 集成到 UsersControllerUsersService 中。

3.5.1. 创建 DTO

src/users 目录下创建 dto 目录,并在其中再创建一个 create-user.dto.ts 文件:

1
2
3
4
5
6
7
8
9
10
11
12
import { IsString, IsEmail, MinLength } from 'class-validator';

export class CreateUserDto {
@IsString()
username: string;

@IsEmail()
email: string;

@MinLength(6, { message: '密码长度必须不小于6个字符' })
password: string;
}

CreateUserDto 定义了用户注册时需要传入的字段,并且为这些字段添加了验证规则:

  • username 字段要求是字符串类型
  • email 字段要求是有效的邮箱格式
  • password 字段要求密码长度至少为 6 个字符

3.5.2. 在控制器中使用 DTO

接下来,在 UsersController 中,我们将 CreateUserDto 引入,并将其用于 create 方法的请求体验证。我们需要使用 @Body() 装饰器将请求体映射到 DTO 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ...
import { CreateUserDto } from './dto/create-user.dto';

@ApiTags('Users')
@Controller('users')
export class UsersController {
// ...

@ApiOperation({ summary: '创建新用户' })
@ApiBody({ description: '用户信息', type: CreateUserDto }) // 使用DTO类型
@ApiResponse({ status: 201, description: '用户成功创建', type: Users })
@Post('create')
create(@Body() user: CreateUserDto): Promise<Users> { // 接收CreateUserDto类型
return this.usersService.create(user);
}

// ...
}

在上述代码中:

  • 使用 @ApiBody 注解指定了请求体的描述,type 字段使用 CreateUserDto
  • @Body() 装饰器会将请求体映射到 CreateUserDto 类型,从而进行数据验证

3.5.3. 在服务中使用 DTO

UsersService 中,create 方法接收了 CreateUserDto 类型的参数,并在保存到数据库之前进行密码的哈希处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ...
import { CreateUserDto } from './dto/create-user.dto';

@Injectable()
export class UsersService {
constructor(@InjectRepository(Users) private usersRepository: Repository<Users>) {}

async create(user: CreateUserDto): Promise<Users> {
const hashedPassword = await bcrypt.hash(user.password, 10); // 使用DTO中的password
const newUser = this.usersRepository.create({
...user,
password: hashedPassword
});
return this.usersRepository.save(newUser);
}
}

在这里,我们在服务层接收的参数是 CreateUserDto 类型,它包含了所有必要的字段和验证规则。通过这种方式,我们避免了在控制器中进行复杂的验证操作,将其交给 DTO 来处理,使得业务逻辑更加简洁和清晰。

3.5.4. 定义 Swagger Schema

在 NestJS 中,你可以通过在 DTO 中使用 Swagger 的注解来定义和生成 API 的 Schema。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { IsString, IsEmail, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';

export class CreateUserDto {
@ApiProperty({
description: '用户名,用户的唯一标识',
example: 'john_doe'
})
@IsString()
username: string;

@ApiProperty({
description: '用户的邮箱地址,必须为有效的邮箱格式',
example: '[email protected]'
})
@IsEmail()
email: string;

@ApiProperty({
description: '密码,必须至少包含6个字符',
example: 'password123456',
minLength: 6
})
@MinLength(6, { message: '密码长度必须不小于6个字符' })
password: string;
}

访问localhost:APP_PORT/api-docs,即可看到CreateUserDto Schema

3.6. 与前端的集成

先前写前端代码的时候,我们的 Register.tsx 并没有连接到后端 API,而是使用 setTimeout 模拟了一下提交。

因此我们要对现有的前端代码进行修改和优化。

3.6.1. 添加状态管理逻辑

Register.tsx 中,我们使用了本地状态管理 isSubmitting

1
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

为了让状态的管理更统一,我们需要将其改为使用全局状态管理,也为之后添加更多功能奠定了基础。

首先是修改 stores/index.ts

1
export { useUserStore } from './user';

接着在 Register.tsx 中使用 useUserStore

1
2
3
4
5
6
7
import { useUserStore } from '../stores';

// ...

const register = useUserStore(state => state.register);
const isLoading = useUserStore(state => state.isLoading);
const error = useUserStore(state => state.error);
  • register:一个用于注册用户的全局方法
  • isLoading:表单提交的状态
  • error:记录注册过程中发生的错误信息

3.6.2. 新增注册方法

在修改 Register.tsx 时,我们同样需要在 stores 目录下的状态管理逻辑中进行调整,以支持注册功能的完整实现。

首先在 stores/user/types.ts 中新增注册凭据类型:

1
2
3
4
5
export interface RegisterCredentials { 
username: string;
email: string;
password: string;
}

并更新用户状态类型:

1
2
3
4
export interface UserState {
// ...
register: (credentials: RegisterCredentials) => Promise<User>;
}

接着在 stores/user/actions.ts 中新添注册功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
register: async (credentials: RegisterCredentials) => { 
set({ isLoading: true, error: null });
try {
const response: AxiosResponse<User> = await api.post<User>('/users/create', credentials);
const user = response.data;

set({
isLoading: false,
error: null,
lastUpdated: Date.now()
});

return user;
} catch (error) {
const errorMessage = error instanceof Error
? error.message
: '注册过程中发生意外错误';

set({
isLoading: false,
error: errorMessage
});

throw error;
}
},

主要逻辑为:

  • 初始化状态:设置 isLoadingtrue,清空可能的旧错误
  • 调用注册 API:发送用户的注册信息到 /users/create,并解析后端返回的用户数据
  • 成功处理:更新状态,但不设置 user 值,因为注册完成后还需登录
  • 错误处理:捕获错误并设置错误信息
  • 状态恢复:无论成功或失败,都将 isLoading 恢复为 false

3.6.3. 实现与后端的连接

先前我们用了 setTimeout 来模拟注册:

1
2
3
4
setTimeout(() => {
console.log(values);
setIsSubmitting(false);
}, 2000);

现在我们应当与实际的后端进行交互,发送注册请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
onSubmit: async (values) => {
try {
const { username, email, password } = values;
await register({ username, email, password });
navigate('/login', {
state: {
message: '注册成功!请登录您的账号。',
email: values.email
}
});
} catch (error) {
console.error('注册失败:', error);
}
}

成功后,跳转到 /login 页面,并通过 state 传递一条注册成功的信息。就算失败了,也会捕获错误信息,便于显示给用户。

由于我们在 store 中已经处理了注册错误,这里就不需要额外处理,直接 console.error 即可。

navigate 来自于 react-router-dom 库:

1
2
3
4
5
import { useNavigate } from 'react-router-dom';

// ...

const navigate = useNavigate();

3.6.4. 用户错误信息提示

Register.tsx 中添加以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return (
<AuthLayout>
<img src={logo} className="w-1/4 mx-auto" alt="Shopping Nest的Logo" />
<h2 className="text-2xl font-bold text-center m-6 text-neutral-content">
注册账户
</h2>

{error && (
<div className="mb-4 p-3 text-sm text-error-content bg-error rounded-lg">
{error}
</div>
)}

{/* ... */}
</AuthLayout>
);

用户提交表单时,如果后端返回错误信息,将会在表单顶部显示一条清晰的错误提示。这提升了用户体验,让用户了解失败的原因并尝试修正。

3.6.5. 禁用表单控件

Register.tsx 中所有的 <input> 标签都添加上这个属性:

1
disabled={isLoading}

这代表着提交过程中表单控件会被禁用,避免用户重复提交。

isLoading 由状态管理工具提供,确保整个应用对状态变化的感知一致。

同样的,对 <button> 也进行修改:

1
2
3
4
5
6
7
<button
type="submit"
className="w-full bg-primary text-primary-content py-2 rounded-lg mt-4 hover:brightness-90 disabled:opacity-50"
disabled={isLoading}
>
{isLoading ? "注册中…" : "注册"}
</button>

运行前端和后端,进行注册测试。

别忘了在前端目录下创建 .env

1
REACT_APP_API_URL=http://localhost:后端的端口

注册成功后你应该会被导向 /login 页面,上面会弹出:

1
2
3
4
5
Unexpected Application Error!
404 Not Found
💿 Hey developer 👋

You can provide a way better UX than this when your app throws errors by providing your own ErrorBoundary or errorElement prop on your route.

4. 实现邮箱验证

邮箱验证是用户注册流程中的重要安全环节,旨在确认用户提供的邮箱地址是真实且可用、防止机器人和垃圾注册、提高账号安全性,同时为后续通信建立可靠的联系渠道。

典型的邮箱验证流程包括:

  1. 用户注册提供邮箱
  2. 系统生成唯一验证令牌
  3. 发送包含验证链接的邮件
  4. 用户点击链接完成验证
  5. 系统校验令牌的有效性

4.1. 前端实现

我们首先需要在路由中添加邮箱验证页面的路由。在 router.tsx 中进行修改:

1
2
3
4
5
6
7
8
9
import VerifyEmail from './pages/VerifyEmail';

const router = createBrowserRouter([
// ...
{
path: '/verify-email',
element: <VerifyEmail />
}
]);

4.1.1. Zustand 状态管理更新

接下来,我们需要更新 Zustand 的用户状态管理,增加邮箱验证相关的状态的方法。

更新 types.ts,添加邮箱验证相关的枚举和接口:

1
2
3
4
5
export enum EmailVerificationError { 
TOKEN_INVALID = 'TOKEN_INVALID',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
ALREADY_VERIFIED = 'ALREADY_VERIFIED'
}

在实现邮箱验证功能时,我定义了三种可能的验证错误状态:

  1. TOKEN_INVALID(无效令牌)
  • 当用户提供的验证链接被篡改、不完整或不存在于系统
  • 可能是用户误点击了错误的链接
  • 可能是验证链接已被恶意修改
  • 系统将拒绝这类验证请求,并显示错误提示
  1. TOKEN_EXPIRED(令牌过期)
  • 验证链接已超过有效期限(24 小时)
  • 防止长期未使用的过期链接被重复使用
  • 用户需要重新请求发送验证邮件
  • 提示用户链接已过期,需要重新获取
  1. ALREADY_VERIFIED(已验证)
  • 用户尝试使用已经验证过的邮箱链接再次验证
  • 可能是用户重复点击验证链接
  • 系统将提示用户邮箱已成功验证
  • 通常会直接引导用户登录
1
2
3
4
5
6
export interface VerificationResponse { 
success: boolean;
message: string;
error?: EmailVerificationError;
userId?: string;
}

验证响应接口则设计了一个标准的响应接口:

  • 验证是否成功的标志
  • 返回消息
  • 可选的错误类型
  • 可选的用户 ID
1
2
3
4
5
6
7
8
9
10
11
export interface UserState {
// ...
emailVerified: boolean;
verificationError?: EmailVerificationError;
verificationUserId?: string;
verificationInProgress: boolean;
verificationToken?: string;

verifyEmail: (token: string) => Promise<VerificationResponse>;
resendVerificationEmail: (userId: string) => Promise<VerificationResponse>;
}

4.1.2. 邮箱验证组件基础结构

pages 目录下创建 VerifyEmail.tsx。首先我们来构建组件的基本结构和状态管理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import React, { useCallback, useEffect } from 'react'; 
import { useLocation, useNavigate } from 'react-router-dom';
import { useUserStore } from '../stores';
import { EmailVerificationError } from '../stores/user/types';

const VerifyEmail = () => {
// 获取路由和导航相关钩子
const location = useLocation();
const navigate = useNavigate();

// 从 Zustand store 中获取状态和方法
const verifyEmail = useUserStore(state => state.verifyEmail);
const resendVerificationEmail = useUserStore(state => state.resendVerificationEmail);
const isLoading = useUserStore(state => state.isLoading);
const error = useUserStore(state => state.error);
const emailVerified = useUserStore(state => state.emailVerified);
const verificationError = useUserStore(state => state.verificationError);
const verificationUserId = useUserStore(state => state.verificationUserId);
const verificationInProgress = useUserStore(state => state.verificationInProgress);

// 渲染逻辑将在这里实现

return (
<div className="min-h-screen flex items-center justify-center bg-base-200">
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body items-center text-center">
<h2 className="card-title mb-4">验证电子邮件</h2>
{/* 不同状态的渲染将在这里实现 */}
</div>
</div>
</div>
);
};

export default VerifyEmail;

状态管理部分详解:

  • 从 Zustand store 中提取多个状态和方法
  • verifyEmail:邮箱验证方法
  • resendVerificationEmail:重新发送验证邮件方法
  • isLoading:加载状态
  • error:错误信息
  • emailVerified:邮箱是否已验证
  • verificationError:验证错误类型
  • verificationUserId:用于重发验证邮件的用户 ID
  • verificationInProgress:验证是否正在进行中

接下来,我们实现邮箱验证的核心处理逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
const handleVerification = useCallback(async (token: string) => { 
try {
const result = await verifyEmail(token);
if (result.success) window.history.replaceState({}, '', window.location.pathname);
return result;
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : '验证失败'
};
}
}, [verifyEmail]);

该方法的主要目的是处理邮箱验证逻辑。通过 token 调用 verifyEmail 函数,判断验证是否成功,并在页面历史状态中作出相应的更新。

这里使用了 useCallback 进行性能优化,确保在依赖项未变化时,返回的函数引用不会发生变化。

window.history.replaceState 方法则是替换了当前历史记录的状态。当用户被验证成功后,token 会被移除,也避免了用户刷新页面时重复验证。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const handleResendVerification = async () => { 
if (verificationUserId) {
try {
const response = await resendVerificationEmail(verificationUserId);
if (response.success) {
navigate('/login', {
state: { message: '新的验证邮件已发送,请查收邮箱' }
});
}
} catch (error) {
console.error('重新发送验证邮件失败:', error);
}
}
}

这个函数的主要功能是为特定用户重新发送验证邮件。调用异步函数 resendVerificationEmail 成功后就会自动导航到登录页面。

添加两个 useEffect 钩子来管理验证流程:

1
2
3
4
5
6
7
8
9
10
11
12
// 处理邮箱验证
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const token = searchParams.get('token');
const currentToken = useUserStore.getState().verificationToken;

if (token && !verificationInProgress && !emailVerified && token !== currentToken) {
handleVerification(token).then(r => {
if (r.success && 'message' in r) console.log('邮箱验证成功:', r.message);
});
}
}, [handleVerification, verificationInProgress, emailVerified]);
  1. 使用 URLSearchParams 解析 location.search,获取查询参数中的 token
  2. 在满足以下条件时调用 handleVerification
    • URL 中存在 token
    • 当前未在进行验证
    • 邮箱尚未验证成功
    • 传入的 token 不等于当前用户的 verificationToken
  3. 调用 handleVerification 处理邮箱验证
1
2
3
4
5
6
7
8
9
// 处理验证成功后的跳转
useEffect(() => {
if (emailVerified && !isLoading) {
const redirectTimer = setTimeout(() => {
navigate('/login');
}, 1500);
return () => clearTimeout(redirectTimer);
}
}, [emailVerified, isLoading, navigate]);
  1. emailVerifiedtrueisLoadingfalse 时,触发跳转逻辑
  2. 使用 setTimeout 在 1.5 秒后执行 navigate('/login'),给用户留出视觉反馈时间
  3. 在组件卸载或依赖更新时,通过 clearTimeout 清除定时器,避免潜在内存泄漏或多余跳转

最后,我们根据不同的验证状态渲染相应的界面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
return ( 
<div className="min-h-screen flex items-center justify-center bg-base-200">
<div className="card w-96 bg-base-100 shadow-xl">
<div className="card-body items-center text-center">
<h2 className="card-title mb-4">验证电子邮件</h2>

{/* 加载中状态 */}
{ isLoading && (
<div className="flex flex-col items-center gap-4">
<span className="loading loading-spinner loading-lg" />
<p>正在验证您的电子邮件...</p>
</div>
)}

{/* 令牌过期状态 */}
{ !isLoading && verificationError === EmailVerificationError.TOKEN_EXPIRED && (
<div className="flex flex-col gap-4">
<div className="alert alert-warning">
<svg>...</svg>
<span>{error}</span>
</div>
<button
className="btn btn-primary"
onClick={handleResendVerification}
disabled={isLoading}
>
重新发送验证邮件
</button>
</div>
)}

{/* 已验证状态 */}
{!isLoading && emailVerified && (
<div className="alert alert-success">
<svg>...</svg>
<span>邮箱验证成功!即将跳转到登录页面...</span>
</div>
)}
</div>
</div>
</div>
);

4.1.3. 邮件验证逻辑

上面说到了几个我们并没有写的方法:verifyEmailresendVerificationEmail

现在我们要在 actions.ts 中完善这两个方法。

先在文件开头位置导入 types.ts 中新增的内容:

1
2
3
4
5
6
7
import { 
User,
UserState,
LoginCredentials,
RegisterCredentials,
VerificationResponse
} from './types';
1
2
3
4
5
6
7
8
9
10
const createUserSlice: StateCreator<UserState> = (set) => ({ 
// ...
emailVerified: false,
verificationError: undefined,
verificationUserId: undefined,
verificationInProgress: false,
verificationToken: undefined,

// ...
});
  1. verifyEmail 方法:

    1
    2
    3
    verifyEmail: async (token: string): Promise<VerificationResponse> => {
    // 下面继续...
    },

    verifyEmail 方法的目的在于确保邮箱有效且记录验证状态。

    首先为了避免用户重复提交相同的 token、导致不必要的 API 调用,我们需要检查当前的状态:

    • verificationInProgresstrue,那么就提示用户“验证正在进行中”
    • emailVerifiedtrueverificationToken 匹配,那么直接返回成功信息,避免重复请求
    1
    2
    3
    4
    5
    6
    7
    const state = useUserStore.getState(); 
    if (state.verificationInProgress || (state.verificationToken === token && state.emailVerified)) {
    return {
    success: state.emailVerified,
    message: state.emailVerified ? '邮箱已验证' : '验证正在进行中'
    };
    }

    进入验证流程前,我们需要确保应用的状态是明确的,并为用户显示验证的进度:

    1
    2
    3
    4
    5
    6
    set({ 
    isLoading: true,
    error: null,
    verificationInProgress: true,
    verificationToken: token
    });

    验证邮箱地址需要服务端支持,因此发送带 token 的 API 请求进行校验(我这里设计的服务端 API 是要 GET
    的):

    1
    2
    3
    4
    5
    6
    try {
    const response = await api.get(`/users/verify-email?token=${token}`);
    const data = response.data;

    // 下面继续...
    }

    验证成功后,更新状态以记录邮箱已验证,并清理其他临时状态:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    try {
    // ...

    if (data.success) {
    set({
    isLoading: false,
    error: null,
    emailVerified: true,
    verificationError: undefined,
    verificationUserId: undefined,
    verificationInProgress: false
    });
    }
    }

    如果验证失败,那就保存失败信息、供用户查看,并允许用户再次尝试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    try {
    // ...
    else {
    set({
    isLoading: false,
    error: data.message,
    emailVerified: false,
    verificationError: data.error,
    verificationUserId: data.userId,
    verificationInProgress: false
    });
    }
    }

    当然,API 请求也有可能失败,我们需要显示错误提示、避免影响用户体验:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    catch (error) { 
    const errorMessage = error instanceof Error
    ? error.message
    : '验证邮箱地址的过程中发生意外错误';

    set({
    isLoading: false,
    error: errorMessage,
    emailVerified: false,
    verificationInProgress: false
    });

    return {
    success: false,
    message: errorMessage
    };
    }
  2. resendVerificationEmail 方法

    verifyEmail 基本上差不多:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    resendVerificationEmail: async (userId: string) => { 
    set({ isLoading: true, error: null });
    try {
    const response = await api.post<VerificationResponse>(`/users/resend-verification/${userId}`);
    const data = response.data;

    set({
    isLoading: false,
    error: data.success ? null : data.message
    });

    return data;
    } catch (error) {
    const errorMessage = error instanceof Error
    ? error.message
    : '重新发送验证邮件失败';

    set({
    isLoading: false,
    error: errorMessage
    });

    throw error;
    }
    },

4.2. 后端实现

在实现用户注册和邮箱验证功能时,我们通常会面临以下几个关键问题:

  1. 如何确保用户提供的邮箱是真实有效的?
  2. 如何防止垃圾注册和恶意用户?
  3. 如何安全地管理用户的验证状态?

4.2.1. 用户实体扩展

为了支持邮箱验证功能,我们要在 Users 实体中添加以下字段:

1
2
3
4
5
6
7
8
@Column({ default: false })
verified: boolean;

@Column({ nullable: true })
verificationToken: string;

@Column({ nullable: true })
verificationTokenExpires: Date;

这三个新增字段解决了邮箱验证的核心需求:

  • verified:标记用户是否已验证邮箱
  • verificationToken:存储唯一的验证令牌
  • verificationTokenExpires:设置令牌的过期时间

4.2.2. 用户服务中的邮箱验证逻辑

先在 users.service.ts 的上方引入我们需要的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { v4 as uuidv4 } from 'uuid';
import { EmailService } from '../email/email.service';
import winstonLogger from '../loggers/winston.logger';

export enum EmailVerificationError {
TOKEN_INVALID = 'TOKEN_INVALID',
TOKEN_EXPIRED = 'TOKEN_EXPIRED',
ALREADY_VERIFIED = 'ALREADY_VERIFIED'
}

interface VerificationResponse {
success: boolean;
message: string;
error?: EmailVerificationError;
userId?: string;
}
1
2
3
4
5
6
7
8
9
10
11
12
@Injectable()
export class UsersService {
private readonly logger = winstonLogger;

constructor(
@InjectRepository(Users)
private usersRepository: Repository<Users>,
private emailService: EmailService
) {}

// ...
}
  1. create 方法用于创建一个用户。当一个用户尝试注册自己时,系统应当通过以下步骤确保注册过程的安全性和可靠性:

    1. 检查用户是否已存在,防止重复注册带来的冲突和逻辑错误:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      async create(user: CreateUserDto): Promise<{ id: string; email: string }> {
      const existingUser = await this.usersRepository.findOne({
      where: [{ email: user.email }, { username: user.username }]
      });

      if (existingUser) {
      this.logger.warn(`用户名为 ${user.username} 或者邮箱地址为 ${user.email} 的用户已存在`);
      throw new ConflictException('用户名或邮箱已存在');
      }

      // 下面继续...
      }
    2. 对用户密码进行加密后,生成验证令牌:

      1
      2
      3
      const hashedPassword = await bcrypt.hash(user.password, 10);
      const verificationToken = uuidv4();
      this.logger.debug('验证码已生成:' + verificationToken);
    3. 结合用户信息和生成的安全数据,创建一个完整的用户对象:

      1
      2
      3
      4
      5
      6
      7
      8
      const newUser = this.usersRepository.create({
      ...user,
      password: hashedPassword,
      verificationToken,
      verificationTokenExpires: new Date(Date.now() + 24 * 60 * 60 * 1000),
      verified: false
      });
      this.logger.info('用户已被保存:' + JSON.stringify(newUser));
    4. 将新用户记录写入数据库,完成用户创建:

      1
      2
      const savedUser = await this.usersRepository.save(newUser);
      this.logger.debug('保存的用户:' + JSON.stringify(savedUser));
    5. 发送验证邮件:

      1
      await this.emailService.sendVerificationEmail(savedUser);
    6. 最终返回精简信息,避免返回敏感数据:

      1
      2
      3
      4
      return {
      id: savedUser.id,
      email: savedUser.email
      };
  2. verifyEmail 方法用于验证用户邮箱。当用户点击验证链接时,系统通过该方法完成验证流程。此方法通过检查令牌的有效性和状态,确保邮箱验证的安全性和可靠性:

    1. 根据提供的 token 查找用户,验证请求是否有效:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      async verifyEmail(token: string): Promise<VerificationResponse> {
      this.logger.debug(`接收到的 token:${token}`);
      const user = await this.usersRepository.findOne({
      where: {
      verificationToken: token
      }
      });

      if (!user) {
      this.logger.debug(`验证失败:未找到对应 token 的用户,token:${token}`);
      return {
      success: false,
      message: '无效的验证链接',
      error: EmailVerificationError.TOKEN_INVALID
      };
      }

      this.logger.debug(
      `找到用户:${user.email}, 验证状态:${user.verified}, token 过期时间:${user.verificationTokenExpires}`
      );

      // 下面继续...
      }
    2. 检查用户的验证状态。这里会有多个条件:

      1. 用户是否已验证:

        1
        2
        3
        4
        5
        6
        7
        8
        if (user.verified) {
        this.logger.debug(`验证失败:用户 ${user.email} 已经验证过了`);
        return {
        success: false,
        message: '邮箱已经验证过了',
        error: EmailVerificationError.ALREADY_VERIFIED
        };
        }
      2. 验证令牌是否过期:

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        12
        if (user.verificationTokenExpires < new Date()) {
        this.logger.debug(
        `验证失败:token 已过期, 用户: ${user.email}, ` +
        `过期时间:${user.verificationTokenExpires}, 当前时间:${new Date()}`
        );
        return {
        success: false,
        message: '验证链接已过期,请重新发送验证邮件',
        error: EmailVerificationError.TOKEN_EXPIRED,
        userId: user.id
        };
        }
    3. 更新用户验证状态,将 verified 属性设置为 true 并保存到数据库:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      user.verified = true;

      try {
      await this.usersRepository.save(user);
      this.logger.debug(`用户 ${user.email} 验证成功,已更新验证状态`);
      } catch (error) {
      this.logger.error(`更新用户验证状态失败:${error.message}`, error.stack);
      throw error;
      }
    4. 返回成功信息,告诉调用方邮箱验证完成:

      1
      2
      3
      4
      5
      this.logger.debug(`验证流程完成,用户 ${user.email} 验证成功`);
      return {
      success: true,
      message: '邮箱验证成功'
      };
  3. resendVerificationEmail 方法用于重新发送验证邮件。当用户请求重发验证邮件时,此方法处理生成新的验证令牌并发送邮件的整个流程,确保未验证用户能够完成邮箱验证:

    1. 通过 userId 查询目标用户,确保用户存在:

      1
      2
      3
      4
      5
      6
      7
      8
      async resendVerificationEmail(userId: string): Promise<VerificationResponse> {
      try {
      const user = await this.findById(userId);
      this.logger.debug(`找到用户:${user.email}, 当前验证状态:${user.verified}`);

      // 下面继续...
      }
      }
    2. 检查用户的验证状态;用户是否已验证:

      1
      2
      3
      4
      5
      6
      7
      8
      if (user.verified) {
      this.logger.debug(`重发失败:用户 ${user.email} 已经验证过了`);
      return {
      success: false,
      message: '邮箱已经验证过了',
      error: EmailVerificationError.ALREADY_VERIFIED
      };
      }
    3. 生成新的验证令牌,确保安全性,并更新过期时间:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      const oldToken = user.verificationToken;
      user.verificationToken = uuidv4();
      user.verificationTokenExpires = new Date(Date.now() + 24 * 60 * 60 * 1000);

      this.logger.debug(
      `更新验证信息:用户:${user.email}, ` +
      `旧 token:${oldToken}, ` +
      `新 token:${user.verificationToken}, ` +
      `过期时间:${user.verificationTokenExpires}`
      );
    4. 保存用户数据:

      1
      2
      await this.usersRepository.save(user);
      this.logger.debug(`用户验证信息已更新:${user.email}`);
    5. 发送验证邮件:

      1
      2
      await this.emailService.sendVerificationEmail(user);
      this.logger.debug(`验证邮件已重发至:${user.email}`);
    6. 返回成功信息:

      1
      2
      3
      4
      return {
      success: true,
      message: '验证邮件已重新发送'
      };
    7. 如果出现异常,就得记录错误日志、返回失败消息:

      1
      2
      3
      4
      5
      6
      7
      8
      catch (error) {
      this.logger.error(`重发验证邮件失败:userId=${userId}, error=${error.message}`, error.stack);
      return {
      success: false,
      message: '重新发送验证邮件失败',
      error: EmailVerificationError.TOKEN_INVALID
      };
      }

4.2.3. 用户控制器新增接口

我们需要以下两个接口:

  1. GET /verify-email:通过查询参数接收验证 token,完成用户邮箱验证
  2. POST /resend-verification/:id:接收用户 id,重新发送验证邮件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@ApiOperation({ summary: '验证用户邮箱' })
@ApiQuery({ name: 'token', description: '验证token' })
@ApiResponse({ status: HttpStatus.OK, description: '邮箱验证成功' })
@HttpCode(HttpStatus.OK)
@Get('verify-email')
async verifyEmail(@Query('token') token: string): Promise<{ message: string }> {
return this.usersService.verifyEmail(token);
}

@ApiOperation({ summary: '重新发送验证邮件' })
@ApiParam({ name: 'id', description: '用户ID' })
@ApiResponse({ status: HttpStatus.OK, description: '验证邮件已重新发送' })
@HttpCode(HttpStatus.OK)
@Post('resend-verification/:id')
async resendVerification(@Param('id') id: string): Promise<{ message: string }> {
return this.usersService.resendVerificationEmail(id);
}

关于 HttpStatus@HttpCode,请见这里

4.2.4. 开发邮件模块

安装邮件模块 @nestjs-modules/mailer

1
yarn add @nestjs-modules/mailer

src 目录下创建 email 目录,并在内创建 email.module.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { Module } from '@nestjs/common';
import { MailerModule } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { join } from 'path';
import { EmailService } from './email.service';

@Module({
imports: [
MailerModule.forRootAsync({
useFactory: async (configService: ConfigService) => {
const transport = {
host: configService.getOrThrow<string>('SMTP_HOST'),
port: configService.getOrThrow<number>('SMTP_PORT'),
secure: false,
auth: {
user: configService.getOrThrow<string>('SMTP_USER'),
pass: configService.getOrThrow<string>('SMTP_PASS')
}
};

return {
transport,
defaults: {
from: `"Shopping Nest" <${configService.getOrThrow<string>('SMTP_FROM_ADDRESS')}>`
},
template: {
dir: join(__dirname, 'templates'),
adapter: new HandlebarsAdapter(),
options: {
strict: true
}
}
};
},
inject: [ConfigService]
})
],
providers: [EmailService],
exports: [EmailService]
})
export class EmailModule {}

我们需要在 .env 里新增几个值:

1
2
3
4
5
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
SMTP_FROM_ADDRESS=

这些是常见的 SMTP(Simple Mail Transfer Protocol)配置参数,用于设置邮件服务器的基本信息以发送电子邮件。

  1. SMTP_HOST:邮件服务器的主机地址,通常是邮件服务提供商的域名或 IP 地址(例如用 Gmail 的话就是写 smtp.gmail.com

  2. SMTP_PORT:邮件服务器使用的端口号,用于建立与邮件服务器的连接

    • 常用端口:
      • 587:用于明文连接后升级为加密连接(STARTTLS)
      • 465:用于加密连接(SSL/TLS)
      • 25:传统的 SMTP 端口,可能被 ISP 限制
    • 587465 是现代邮件服务中最常见的选择
  3. SMTP_USER:用于身份验证的用户名,通常是发送邮件的邮箱地址

  4. SMTP_PASS:与 SMTP_USER 对应的密码,用于 SMTP 身份验证(一些服务——如 Gmail——可能要求使用应用专用密码而不是账户密码)

  5. SMTP_FROM_ADDRESS:邮件发送的来源地址,显示为邮件的 From 字段,通常是一个经过认证的邮箱地址

    • [email protected](用于自动邮件)
    • [email protected](用于支持邮件)
    • 有些服务可能要求 SMTP_FROM_ADDRESS 必须与 SMTP_USER 保持一致(我使用的 MailGun 就是如此)

Handlebars 是一个简单但强大的模板语言,允许我们通过 {{变量}} 语法轻松插入动态内容。它支持部分模板(Partials),让我们可以模块化地构建电子邮件模板。

部分模版示例:

1
2
3
4
5
<header class="email-header">
<div class="logo">
<img src="{{ base64Image }}" alt="Logo" style="max-height: 50px;">
</div>
</header>

我们希望邮件的内容是动态生成的,并且邮件的布局或样式能够灵活调整,那么就需要结合模板引擎来渲染邮件内容。

接着创建 email.service.ts,它会封装邮件发送的所有复杂逻辑:

  1. 导入模块并初始化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    import { Injectable } from '@nestjs/common';
    import { ConfigService } from '@nestjs/config';
    import { MailerService } from '@nestjs-modules/mailer';
    import { readFileSync, existsSync } from 'fs';
    import { join, resolve } from 'path';
    import { execSync } from 'child_process';
    import * as HandleBars from 'handlebars';
    import { Users } from '../entities/users.entity';
    import winstonLogger from '../loggers/winston.logger';

    @Injectable()
    export class EmailService {
    private readonly logger = winstonLogger;
    private readonly templatesDir: string;
    private readonly partialsDir: string;

    // 下面继续...
    }
  2. 在构造函数中注入 ConfigServiceMailerService。同时设置模板目录和部分模板目录的路径:

    1
    2
    3
    4
    5
    6
    7
    constructor(
    private configService: ConfigService,
    private mailerService: MailerService
    ) {
    this.templatesDir = resolve(__dirname, 'templates');
    this.partialsDir = resolve(this.templatesDir, 'partials');
    }
  3. 初始化模块:

    1
    2
    3
    4
    async onModuleInit() {
    this.ensureTemplatesExist();
    await this.registerPartials();
    }
    • onModuleInit 方法会在模块初始化时被调用
    • ensureTemplatesExist 方法应当是检查模板是否存在,如果不存在则通过执行命令 `yarn copy-templates 来复制模板文件
    • registerPartials 方法则用于注册模板中的部分文件,确保这些部分模板可以在主模板中引用
  4. 确保模板文件存在:

    1
    2
    3
    4
    5
    6
    private ensureTemplatesExist() {
    if (!existsSync(this.templatesDir)) {
    this.logger.warn('dist 中无法找到 templates,复制中...');
    execSync('yarn copy-templates');
    }
    }

    该方法检查模板文件夹是否存在。如果文件夹不存在,则执行命令将模板文件复制过来。

    这确保了在构建后的 dist 文件夹中也能找到模板文件。

    这需要在 package.json 里定义一条命令:

    1
    2
    3
    4
    "scripts": {
    // ...
    "copy-templates": "copyfiles -u 3 src/email/templates/**/* dist/email/templates"
    }

    copyfiles 用法:

    1. 安装 copyfiles 为开发依赖(或者直接全局安装):

      1
      yarn add -D copyfiles
    2. 如果是复制 src 下的所有 .js 文件到 dist,且保留目录结构:

      1
      yarn copyfiles 'src/**/*.js' dist/
    3. 不保留目录结构:

      1
      yarn copyfiles -f 'src/**/*.js' dist/

    copyfiles -u 3 src/email/templates/**/* dist/email/templates 的含义是将 src/email/templates/ 文件夹下的所有文件和子文件夹复制到 dist/email/templates/,并通过 -u 3 参数调整复制时的目标路径结构。

    其中 -u 3 表示忽略源路径中从末尾数起的 3 个目录层级,也就是不包含这些层级到目标路径中。

    ensureTemplatesExist 方法解决了 NestJS 项目中、构建过程不会自动复制静态资源文件的问题。

    当执行 yarn build 时,TypeScript 编译器只处理 .ts 文件。静态模板和资源不会自动复制到 dist 目录。

  5. 注册部分模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    private async registerPartials() {
    const partials = [
    { name: 'header', file: 'header.hbs' },
    { name: 'footer', file: 'footer.hbs' },
    { name: 'styles', file: 'styles.hbs' }
    ];

    for (const partial of partials) {
    const partialPath = join(this.partialsDir, partial.file);

    if (!existsSync(partialPath)) {
    this.logger.error(`部分模板文件不存在:${partialPath}`);
    throw new Error(`找不到部分模板文件:${partial.file}`);
    }

    try {
    const template = readFileSync(partialPath, 'utf8');
    HandleBars.registerPartial(partial.name, template);
    this.logger.debug(`成功注册部分模板:${partial.name}`);
    } catch (error) {
    this.logger.error(`注册部分模板失败:${partial.name}`, {
    error: error.message,
    stack: error.stack,
    path: partialPath
    });
    throw error;
    }
    }
    }

    在模板中,通常会有一些公共部分(如页头、页脚、样式等),这些部分可以通过 Handlebars 的 registerPartial 方法来注册,之后可以在主模板中调用。

    这里的三个 .hbs 文件会在后面完善。

  6. 编译模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    private async compileTemplate(templateName: string, context: any): Promise<string> {
    const templatePath = join(this.templatesDir, `${templateName}.hbs`);

    if (!existsSync(templatePath)) {
    this.logger.error(`模板文件不存在:${templatePath}`);
    throw new Error(`找不到模板文件:${templateName}.hbs`);
    }

    try {
    const templateContent = readFileSync(templatePath, 'utf8');
    const template = HandleBars.compile(templateContent);
    return template(context);
    } catch (error) {
    this.logger.error('编译模板失败', {
    error: error.message,
    stack: error.stack,
    template: templateName,
    path: templatePath
    });
    throw error;
    }
    }

    compileTemplate 方法用于读取模板文件并将其编译为 HTML 内容。它通过 Handlebars.compile 将模板文件内容编译为渲染函数,然后使用传入的 context 对象(包含动态内容)来渲染模板。

  7. 发送验证邮件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    async sendVerificationEmail(user: Users) {
    const logoPath = join(__dirname, '../assets/ShoppingNest.png');
    this.logger.debug('Logo Path:' + logoPath);
    const logoBase64 = readFileSync(logoPath).toString('base64');
    const logoMimeType = 'image/png';
    const base64Image = `data:${logoMimeType};base64,${logoBase64}`;

    const frontendUrl = this.configService.get<string>('CORS_ORIGIN');
    const verificationUrl = `${frontendUrl}/verify-email?token=${user.verificationToken}`;

    try {
    const html = await this.compileTemplate('verification', {
    username: user.username,
    verificationUrl,
    expiresIn: '24小时',
    year: new Date().getFullYear(),
    base64Image
    });

    await this.mailerService.sendMail({
    to: user.email,
    subject: '验证你的邮箱地址',
    html
    });

    this.logger.info(`验证邮件已成功发送至 ${user.email}`);
    } catch (error) {
    this.logger.error('发送验证邮件失败', {
    error: error.message,
    stack: error.stack,
    user: user.email
    });
    throw new Error(`发送验证邮件失败:${error.message}`);
    }
    }

    首先,读取图片文件并将其转换为 base64 格式,之后拼接验证链接,并使用 Handlebars 模板渲染邮件内容。

    最后调用 MailerService.sendMail 发送邮件。

    图片位置被我写在了 src/assets 目录中。但是它和 src/email/templates 目录一样,有着不会被自动复制到 dist 目录的问题。

    解决方法也很简单,同样在 package.json 中定义一个命令:

    1
    2
    3
    4
    "scripts": {
    // ...
    "copy-assets": "copyfiles -u 2 src/assets/**/* dist/assets"
    }

    然后在 app.service.ts 被初始化时调用(也可以在 email.service.ts 中调用,只是我认为 src/assets 内的静态文件并不是仅被邮件模块使用):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    async onModuleInit() {
    this.ensureAssetsExist();
    }

    private ensureAssetsExist() {
    const assetsPath = resolve(__dirname, 'assets');
    if (!existsSync(assetsPath)) {
    this.logger.warn('dist 中无法找到 assets,复制中...');
    execSync('yarn copy-assets');
    }
    }

现在我们可以开始写验证邮件的实际内容了:

  1. src/email 目录下创建 templates 目录,并创建 verification.hbs,作为验证邮件的主体模板:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    <!DOCTYPE html>
    <html lang="zh">
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>验证你的邮箱地址</title>
    <style>
    {{> styles}}
    </style>
    </head>
    <body>
    {{> header}}

    <div class="email-content">
    <h2>您好,{{username}}</h2>

    <p>感谢您注册我们的服务。请点击下面的按钮验证您的邮箱地址:</p>

    <div class="button-container">
    <a href="{{verificationUrl}}" class="verify-button">验证邮箱</a>
    </div>

    <p>如果上面的按钮无法点击,请复制以下链接到浏览器地址栏:</p>
    <p class="verification-url">{{verificationUrl}}</p>

    <p>此验证链接将在{{expiresIn}}后过期。</p>
    <p>如果您没有注册我们的服务,请忽略此邮件。</p>
    </div>

    {{> footer}}
    </body>
    </html>

    这里使用了三个动态内容插值:

    1. {{username}}:动态填充用户名称
    2. {{verification}}:用户唯一的验证链接
    3. {{expiresIn}}:链接有效期提示
  2. src/email/templates 目录下创建 partials 目录,并在内创建 header.hbs

    1
    2
    3
    4
    5
    <header class="email-header">
    <div class="logo">
    <img src="{{ base64Image }}" alt="Logo" style="max-height: 50px;">
    </div>
    </header>

    这里的 style 可以根据自己的 Logo 图片进行调整。

    其实还有一种方法,是在前端项目中的 public 目录里放 Logo 图片,然后这里直接调用 前端链接/图片文件名称.图片文件类型

    但是这意味着如果前端项目是在本地环境运行的,图片链接便无效。

  3. src/email/templates/partials 目录下创建 footer.hbs

    1
    2
    3
    4
    <footer class="email-footer">
    <p>© {{year}} 你的公司名称. 保留所有权利。</p>
    <p>此邮件由系统自动发送,请勿回复。</p>
    </footer>

    这里可以自行修改。

  4. src/email/templates/partials 目录下创建 styles.hbs

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    body {
    margin: 0;
    padding: 0;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    line-height: 1.6;
    color: #333;
    background-color: #f9f9f9;
    }

    .email-header {
    text-align: center;
    padding: 20px;
    background-color: #ffffff;
    border-bottom: 1px solid #eee;
    }

    .email-content {
    max-width: 600px;
    margin: 0 auto;
    padding: 20px;
    background-color: #ffffff;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }

    .button-container {
    text-align: center;
    margin: 30px 0;
    }

    .verify-button {
    display: inline-block;
    padding: 12px 24px;
    background-color: #4CAF50;
    color: #ffffff;
    text-decoration: none;
    border-radius: 5px;
    font-weight: bold;
    transition: background-color 0.3s;
    }

    .verify-button:hover {
    background-color: #45a049;
    }

    .verification-url {
    word-break: break-all;
    padding: 10px;
    background-color: #f5f5f5;
    border-radius: 4px;
    font-family: monospace;
    }

    .email-footer {
    text-align: center;
    padding: 20px;
    color: #666;
    font-size: 0.9em;
    }

    同样可以自行修改。

最后便是在各个模块中进行引用:

  1. users.module.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    import { Module } from '@nestjs/common';
    import { TypeOrmModule } from '@nestjs/typeorm';
    import { UsersController } from './users.controller';
    import { UsersService } from './users.service';
    import { Users } from '../entities/users.entity';
    import { EmailModule } from '../email/email.module';

    @Module({
    imports: [TypeOrmModule.forFeature([Users]), EmailModule],
    controllers: [UsersController],
    providers: [UsersService],
    exports: [UsersService]
    })
    export class UsersModule {}
  2. app.module.ts

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // ...
    import { EmailModule } from './email/email.module';

    @Module({
    imports: [
    ConfigModule.forRoot({
    isGlobal: true,
    validationSchema: Joi.object({
    // ...
    CORS_ORIGIN: Joi.string().required(),
    // SMTP 配置
    SMTP_HOST: Joi.string().required(),
    SMTP_PORT: Joi.number().required(),
    SMTP_USER: Joi.string().required(),
    SMTP_PASS: Joi.string().required(),
    SMTP_FROM_ADDRESS: Joi.string().required()
    })
    }),
    // ...
    EmailModule
    ]
    })

5. 其他

5.1. 修改 http-exception.filter.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import winstonLogger from '../loggers/winston.logger';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = winstonLogger;

catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;

const errorResponse = {
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
message: exception instanceof HttpException ? exception.getResponse() : 'Internal server error'
};

// 用 winstonLogger 输出异常
if (exception instanceof HttpException) {
this.logger.warn(`HTTP 异常:${JSON.stringify(errorResponse)}`);
} else {
this.logger.error(`未处理异常:${exception}`);
}

response.status(status).json(errorResponse);
}
}

5.2. 修改 Swagger 标签

1
@ApiResponse({ status: 200, description: 'xxx' })

统统修改为

1
2
@ApiResponse({ status: HttpStatus.OK, description: 'xxx' })
@HttpCode(HttpStatus.OK)

状态码本身是数字,对于大多数人而言并不直观、需要对其有较深的理解。若需要统一调整状态码(如规范化状态码的使用),代码中可能需要逐一查找和替换。更何况状态码本身也更容易被误写为其他数字,难以自动检测错误。

而枚举值提供了清晰的语义,直接描述了状态码的含义,因此更容易理解。当需要统一调整时,可以直接通过 IDE 的自动补全功能快速选择合适的状态码。

以下为对应:

HTTP 状态码 HttpStatus 枚举值
200 OK
201 CREATED
204 NO_CONTENT
400 BAD_REQUEST
401 UNAUTHORIZED
404 NOT_FOUND
409 CONFLICT
500 INTERNAL_SERVER_ERROR

5.3. 清空数据库中所有表的内容

在开发和测试中,尤其是运行集成测试或重置开发环境时,可能需要快速清空数据库中的所有表数据。

我根据 MySQL 写了一个清空所有表数据的脚本,确保测试数据或旧数据被完全移除,便于后续的干净测试或初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import dataSource from '../config/data-source';

async function deleteAllData() {
try {
await dataSource.initialize();
console.log('已连接到数据库');

const entityMetadatas = dataSource.entityMetadatas;
const totalTables = entityMetadatas.length;

await dataSource.query('BEGIN');
await dataSource.query('SET FOREIGN_KEY_CHECKS = 0');

for (let i = 0; i < totalTables; i++) {
const entityMetadata = entityMetadatas[i];
const tableName = entityMetadata.tableName;

const startTime = Date.now();

try {
console.log(`清空表:${tableName}`);

await dataSource.query(`TRUNCATE TABLE \`${tableName}\``);

const elapsedTime = (Date.now() - startTime) / 1000;
console.log(`清空表 (${i + 1}/${totalTables}): ${tableName} 完成,耗时: ${elapsedTime.toFixed(2)} 秒`);
} catch (error) {
console.error(`清空表 ${tableName} 时报错:`, error);
}
}

await dataSource.query('COMMIT');
console.log('所有表都被清空');
} catch (error) {
console.error('删除数据期间报错:', error);
await dataSource.query('ROLLBACK');
} finally {
await dataSource.query('SET FOREIGN_KEY_CHECKS = 1');
await dataSource.destroy();
}
}

deleteAllData().catch(error => {
console.error('删除数据期间报错:', error);
});

可以在 package.json 中添加以下命令来使用:

1
2
3
4
"scripts": {
// ...
"delete-all-data": "ts-node src/database/delete-all-data.ts"
}