React + NestJS购物平台练习【4】用户注册功能
全栈实践又到了我们喜闻乐见的用户注册功能……
1. 修 BUG
我们先修一下先前写的 BUG……
1.1. 导出 BUG
在我们先前创建的 src/stores/index.ts
中,我们写的是:
1 | import useUserStore from './user'; |
这样写的话,实际运行项目后会报错。
正确的写法是:
1 | export { useUserStore } from './user'; |
-
index.ts
是用于导出所有的 Store,因此是export
而不是import
-
要理解为什么必须使用
{ 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15TypeOrmModule.forRoot({
//...
entities: [
Users,
Products,
Payments,
Orders,
OrderItems,
InventoryLogs,
Categories,
Carts,
CartItems,
Addresses,
],
}),这种方式适合开发阶段,明确知道所有实体的数量和位置时,每当创建新实体时手动添加即可。但在项目复杂、实体较多或依赖多模块的情况下,逐一引入会显得繁琐,容易出错,且不便于代码维护。
-
自动加载:
1
2
3
4TypeOrmModule.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
,导致报错。 -
自定义引入路径:
1
2
3
4TypeOrmModule.forRoot({
//...
entities: ['dist/**/*.entity{.ts,.js}'],
}),这是官方推荐的方式,使用通配符路径直接加载所有编译后的实体文件(如
dist/entities/*.entity.js
),避免了逐一手动添加的麻烦,并保证所有实体自动注册,减少遗漏问题。
在我们的 database.module.ts
中添加第三种方式的配置,即可解决报错。
2. 实现注册表单组件
2.1. 基础结构
我们的注册页面由两个主要部分组成:
Register
组件:处理表单逻辑、验证以及提交请求AuthLayout
组件:负责提供页面的布局和样式
Register
组件嵌套在 AuthLayout
中,这样可以确保页面结构保持一致。
首先,我们来看看 AuthLayout.tsx
组件的代码,它定义了一个简单的容器,将任何传递给它的内容居中显示,并设置一些基本的样式:
1 | import React from 'react'; |
这里使用了 Tailwind CSS 进行样式布局,AuthLayout
用于包装页面的子元素,确保其在屏幕上居中显示,并有一定的阴影和内边距。
接着在 src/pages
目录下创建 Register.tsx
。
2.2. 表单开发
在我们的 Register
组件中,我们将使用 Formik
来处理表单的状态管理和提交,并使用 Yup
来进行表单验证。Formik
和 Yup
的结合提供了简洁且强大的表单验证和管理能力。
用以下命令安装 Formik
和 Yup
:
1 | yarn add formik yup |
2.2.1. Formik
用法
Formik
通过 useFormik
Hook 管理表单的状态和行为。在 Register
页面中,我们初始化了一个表单,提供了初始值和 onSubmit
处理函数。
1 | import { useFormik } from 'formik'; |
initialValues
:初始化表单的默认值validationSchema
:使用Yup
创建的验证规则(稍后会详细介绍)onSubmit
:处理表单提交的函数setIsSubmitting(true)
:在表单提交时,我们将isSubmitting
设置为true
,这会触发按钮禁用以及按钮文本更新为“注册中……”setTimeout
:为了模拟实际的提交过程,我们使用setTimeout
延迟了2秒钟。实际应用中这里应该替换为 API 请求,不过我们的 API 还没完成呢setIsSubmitting(false)
:当提交操作完成时,我们将isSubmitting
设置为false
,恢复按钮的正常状态
2.2.2. Yup
验证
Yup
是一个 JavaScript 的对象模式验证库,我们通过它来定义表单的验证规则。下面是每个字段的验证规则:
1 | import * as Yup from 'yup'; |
username
:必须填写,并且是字符串email
:必须添加,并且符合邮箱格式password
:必须至少有 6 个字符confirmPassword
:必须与密码匹配
Yup
和 Formik
的结合提供了简洁的验证机制,自动管理每个字段的错误信息,并在表单提交时触发验证。
2.2.3. 表单渲染
表单输入框的渲染非常直观。我们通过 formik.handleChange
来处理用户输入,并通过 formik.errors
和 formik.touched
来显示错误信息。
1 | const Register = () => { |
value
:绑定表单值onChange
和onBlur
:处理表单输入和失去焦点事件formik.errors
:在字段发生错误时显示错误信息
用同样的写法,写完“邮箱地址”、“密码”和“确认密码”输入框:
1 | const Register = () => { |
2.2.4. 按钮的状态管理
按钮的状态管理对于处理表单提交时的交互反馈是非常重要的。
useState
是 React 中用于管理组件状态的 Hook。它允许我们在函数组件内部创建一个可变的状态,并返回一个更新该状态的函数。在注册页面中,我们使用 useState
来管理表单是否正在提交。
1 | import React, { useState } from 'react'; |
isSubmitting
:表示表单是否正在提交,初始值是false
,即默认情况下表单没有提交setIsSubmitting
:用于更新isSubmitting
状态的函数
每当表单提交时,我们会将 isSubmitting
设置为 true
,表示正在进行提交操作。当提交完成后,再将其设置回 false
。
2.2.5. 按钮的状态变化
在注册页面中,表单的提交按钮(<button>
)会根据 isSubmitting
的状态进行显示不同的文本内容:
1 | const Register = () => { |
disabled={isSubmitting}
:按钮在提交时被禁用,防止用户多次点击。每次表单提交时,isSubmitting
会被设为true
,这将使按钮处于禁用状态,直到提交结束- 按钮文本切换:根据
isSubmitting
的值,按钮的文本会动态改变- 如果表单正在提交(
isSubmitting === true
),按钮文本会显示为“注册中……” - 如果表单没有提交(
isSubmitting === false
),按钮显示为 “注册”
- 如果表单正在提交(
2.3. 路由配置
为了使我们的页面能够被访问和管理,在我们的 router.tsx
中添加路由 /register
:
1 | import Register from './pages/Register'; |
运行 React 项目,访问 localhost:3000/register
:
图片是另外加的。
3. 实现后端注册 API
在现代的 Web 应用开发中,用户注册功能是几乎每个系统都需要的基础部分。用户注册不仅需要保存用户的基本信息,还要确保密码等敏感数据的安全性。
设想这样一个场景:我们正在开发一个用户系统,要求用户可以通过提供必要的个人信息进行注册,并创建一个账号。由于用户密码是非常敏感的信息,我们必须在保存密码之前进行加密,以确保其安全性。此外,我们还需要在需要时提供其他用户管理的接口,如更新、删除等操作。
3.1. 创建用户模块
用户模块负责管理用户相关的逻辑,创建 users.module.ts
文件:
1 | import { Module } from '@nestjs/common'; |
在此模块中,我们使用了 TypeOrmModule.forFeature([Users])
来将用户实体与 TypeORM 绑定,以便在 UsersService
中使用数据库的增删查改功能。
3.2. 创建用户控制器
控制器负责定义 API 路由和对应的处理方法,创建 users.controller.ts
文件:
1 | import { Controller, Post, Get, Body, Param } from '@nestjs/common'; |
我们定义了以下几个方法:
create
:用于处理用户注册的 POST 请求,调用UsersService.create
方法以保存用户信息findById
、update
和remove
方法:分别用于获取、更新和删除用户信息
3.3. 编写用户服务
UsersService
负责处理具体的业务逻辑,包括数据的加密和与数据库的交互。在实现注册功能时,我们需要对用户密码进行加密,并将加密后的密码与其他信息一起保存到数据库。
创建 users.service.ts
:
1 | import { Injectable, NotFoundException } from '@nestjs/common'; |
UsersService
实现了以下方法:
create
:用于创建用户。在保存用户数据前,通过bcrypt.hash
方法对密码进行加密,然后将加密后的用户数据保存到数据库update
:用于更新用户信息。如果更新的数据中包含password
,则需要再次加密后再保存remove
:用于删除用户信息。如果删除操作没有影响任何行,则会抛出NotFoundException
错误findById
:用于根据用户 ID 查询用户信息。找不到用户时同样会抛出NotFoundException
3.4. 通过 Swagger 生成文档标签
我们将为每个控制器方法添加 Swagger 标签:
1 | // ... |
@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 并用它来顶替原本的写法?
答案是:增强数据验证和规范化输入输出。
-
增强验证机制:DTO 通过类验证器(如
class-validator
)来确保传入的数据符合预期。例如,我们可以设置MinLength
来确保密码长度不小于 6 个字符、使用IsEmail
来验证邮箱格式。这不仅使代码更加规范,而且还大大提高了API的可靠性和安全性 -
清晰的 API 结构:DTO 使 API 请求和响应体更加清晰。通过 DTO,前后端可以明确约定需要的数据字段和格式,这样可以减少由于数据格式不匹配导致的错误
-
易于扩展和维护:DTO 提供了一个灵活的扩展点。如果未来业务需求发生变化,我们只需修改 DTO,而不必修改整个业务逻辑。这样,系统的可维护性和扩展性更强
我们已经有了一个用于用户注册的业务逻辑,现在我们要将 DTO 集成到 UsersController
和 UsersService
中。
3.5.1. 创建 DTO
在 src/users
目录下创建 dto
目录,并在其中再创建一个 create-user.dto.ts
文件:
1 | import { IsString, IsEmail, MinLength } from 'class-validator'; |
CreateUserDto
定义了用户注册时需要传入的字段,并且为这些字段添加了验证规则:
username
字段要求是字符串类型email
字段要求是有效的邮箱格式password
字段要求密码长度至少为 6 个字符
3.5.2. 在控制器中使用 DTO
接下来,在 UsersController
中,我们将 CreateUserDto
引入,并将其用于 create
方法的请求体验证。我们需要使用 @Body()
装饰器将请求体映射到 DTO 类。
1 | // ... |
在上述代码中:
- 使用
@ApiBody
注解指定了请求体的描述,type
字段使用CreateUserDto
@Body()
装饰器会将请求体映射到CreateUserDto
类型,从而进行数据验证
3.5.3. 在服务中使用 DTO
在 UsersService
中,create
方法接收了 CreateUserDto
类型的参数,并在保存到数据库之前进行密码的哈希处理:
1 | // ... |
在这里,我们在服务层接收的参数是 CreateUserDto
类型,它包含了所有必要的字段和验证规则。通过这种方式,我们避免了在控制器中进行复杂的验证操作,将其交给 DTO 来处理,使得业务逻辑更加简洁和清晰。
3.5.4. 定义 Swagger Schema
在 NestJS 中,你可以通过在 DTO 中使用 Swagger 的注解来定义和生成 API 的 Schema。
1 | import { IsString, IsEmail, MinLength } from 'class-validator'; |
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 | import { useUserStore } from '../stores'; |
register
:一个用于注册用户的全局方法isLoading
:表单提交的状态error
:记录注册过程中发生的错误信息
3.6.2. 新增注册方法
在修改 Register.tsx
时,我们同样需要在 stores
目录下的状态管理逻辑中进行调整,以支持注册功能的完整实现。
首先在 stores/user/types.ts
中新增注册凭据类型:
1 | export interface RegisterCredentials { |
并更新用户状态类型:
1 | export interface UserState { |
接着在 stores/user/actions.ts
中新添注册功能:
1 | register: async (credentials: RegisterCredentials) => { |
主要逻辑为:
- 初始化状态:设置
isLoading
为true
,清空可能的旧错误 - 调用注册 API:发送用户的注册信息到
/users/create
,并解析后端返回的用户数据 - 成功处理:更新状态,但不设置
user
值,因为注册完成后还需登录 - 错误处理:捕获错误并设置错误信息
- 状态恢复:无论成功或失败,都将
isLoading
恢复为false
3.6.3. 实现与后端的连接
先前我们用了 setTimeout
来模拟注册:
1 | setTimeout(() => { |
现在我们应当与实际的后端进行交互,发送注册请求:
1 | onSubmit: async (values) => { |
成功后,跳转到 /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 | return ( |
用户提交表单时,如果后端返回错误信息,将会在表单顶部显示一条清晰的错误提示。这提升了用户体验,让用户了解失败的原因并尝试修正。
3.6.5. 禁用表单控件
将 Register.tsx
中所有的 <input>
标签都添加上这个属性:
1 | disabled={isLoading} |
这代表着提交过程中表单控件会被禁用,避免用户重复提交。
isLoading
由状态管理工具提供,确保整个应用对状态变化的感知一致。
同样的,对 <button>
也进行修改:
1 | <button |
运行前端和后端,进行注册测试。
别忘了在前端目录下创建 .env
:
1 | REACT_APP_API_URL=http://localhost:后端的端口 |
注册成功后你应该会被导向 /login
页面,上面会弹出:
1 | Unexpected Application Error! |
4. 实现邮箱验证
邮箱验证是用户注册流程中的重要安全环节,旨在确认用户提供的邮箱地址是真实且可用、防止机器人和垃圾注册、提高账号安全性,同时为后续通信建立可靠的联系渠道。
典型的邮箱验证流程包括:
- 用户注册提供邮箱
- 系统生成唯一验证令牌
- 发送包含验证链接的邮件
- 用户点击链接完成验证
- 系统校验令牌的有效性
4.1. 前端实现
我们首先需要在路由中添加邮箱验证页面的路由。在 router.tsx
中进行修改:
1 | import VerifyEmail from './pages/VerifyEmail'; |
4.1.1. Zustand 状态管理更新
接下来,我们需要更新 Zustand 的用户状态管理,增加邮箱验证相关的状态的方法。
更新 types.ts
,添加邮箱验证相关的枚举和接口:
1 | export enum EmailVerificationError { |
在实现邮箱验证功能时,我定义了三种可能的验证错误状态:
TOKEN_INVALID
(无效令牌)
- 当用户提供的验证链接被篡改、不完整或不存在于系统
- 可能是用户误点击了错误的链接
- 可能是验证链接已被恶意修改
- 系统将拒绝这类验证请求,并显示错误提示
TOKEN_EXPIRED
(令牌过期)
- 验证链接已超过有效期限(24 小时)
- 防止长期未使用的过期链接被重复使用
- 用户需要重新请求发送验证邮件
- 提示用户链接已过期,需要重新获取
ALREADY_VERIFIED
(已验证)
- 用户尝试使用已经验证过的邮箱链接再次验证
- 可能是用户重复点击验证链接
- 系统将提示用户邮箱已成功验证
- 通常会直接引导用户登录
1 | export interface VerificationResponse { |
验证响应接口则设计了一个标准的响应接口:
- 验证是否成功的标志
- 返回消息
- 可选的错误类型
- 可选的用户 ID
1 | export interface UserState { |
4.1.2. 邮箱验证组件基础结构
在 pages
目录下创建 VerifyEmail.tsx
。首先我们来构建组件的基本结构和状态管理:
1 | import React, { useCallback, useEffect } from 'react'; |
状态管理部分详解:
- 从 Zustand store 中提取多个状态和方法
verifyEmail
:邮箱验证方法resendVerificationEmail
:重新发送验证邮件方法isLoading
:加载状态error
:错误信息emailVerified
:邮箱是否已验证verificationError
:验证错误类型verificationUserId
:用于重发验证邮件的用户 IDverificationInProgress
:验证是否正在进行中
接下来,我们实现邮箱验证的核心处理逻辑:
1 | const handleVerification = useCallback(async (token: string) => { |
该方法的主要目的是处理邮箱验证逻辑。通过 token
调用 verifyEmail
函数,判断验证是否成功,并在页面历史状态中作出相应的更新。
这里使用了 useCallback
进行性能优化,确保在依赖项未变化时,返回的函数引用不会发生变化。
window.history.replaceState
方法则是替换了当前历史记录的状态。当用户被验证成功后,token
会被移除,也避免了用户刷新页面时重复验证。
1 | const handleResendVerification = async () => { |
这个函数的主要功能是为特定用户重新发送验证邮件。调用异步函数 resendVerificationEmail
成功后就会自动导航到登录页面。
添加两个 useEffect
钩子来管理验证流程:
1 | // 处理邮箱验证 |
- 使用
URLSearchParams
解析location.search
,获取查询参数中的token
- 在满足以下条件时调用
handleVerification
:- URL 中存在
token
- 当前未在进行验证
- 邮箱尚未验证成功
- 传入的
token
不等于当前用户的verificationToken
- URL 中存在
- 调用
handleVerification
处理邮箱验证
1 | // 处理验证成功后的跳转 |
- 当
emailVerified
为true
且isLoading
为false
时,触发跳转逻辑 - 使用
setTimeout
在 1.5 秒后执行navigate('/login')
,给用户留出视觉反馈时间 - 在组件卸载或依赖更新时,通过
clearTimeout
清除定时器,避免潜在内存泄漏或多余跳转
最后,我们根据不同的验证状态渲染相应的界面:
1 | return ( |
4.1.3. 邮件验证逻辑
上面说到了几个我们并没有写的方法:verifyEmail
和 resendVerificationEmail
。
现在我们要在 actions.ts
中完善这两个方法。
先在文件开头位置导入 types.ts
中新增的内容:
1 | import { |
1 | const createUserSlice: StateCreator<UserState> = (set) => ({ |
-
verifyEmail
方法:1
2
3verifyEmail: async (token: string): Promise<VerificationResponse> => {
// 下面继续...
},verifyEmail
方法的目的在于确保邮箱有效且记录验证状态。首先为了避免用户重复提交相同的
token
、导致不必要的 API 调用,我们需要检查当前的状态:- 若
verificationInProgress
为true
,那么就提示用户“验证正在进行中” - 若
emailVerified
为true
且verificationToken
匹配,那么直接返回成功信息,避免重复请求
1
2
3
4
5
6
7const state = useUserStore.getState();
if (state.verificationInProgress || (state.verificationToken === token && state.emailVerified)) {
return {
success: state.emailVerified,
message: state.emailVerified ? '邮箱已验证' : '验证正在进行中'
};
}进入验证流程前,我们需要确保应用的状态是明确的,并为用户显示验证的进度:
1
2
3
4
5
6set({
isLoading: true,
error: null,
verificationInProgress: true,
verificationToken: token
});验证邮箱地址需要服务端支持,因此发送带
token
的 API 请求进行校验(我这里设计的服务端 API 是要GET
的):1
2
3
4
5
6try {
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
14try {
// ...
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
13try {
// ...
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
17catch (error) {
const errorMessage = error instanceof Error
? error.message
: '验证邮箱地址的过程中发生意外错误';
set({
isLoading: false,
error: errorMessage,
emailVerified: false,
verificationInProgress: false
});
return {
success: false,
message: errorMessage
};
} - 若
-
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
25resendVerificationEmail: 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. 后端实现
在实现用户注册和邮箱验证功能时,我们通常会面临以下几个关键问题:
- 如何确保用户提供的邮箱是真实有效的?
- 如何防止垃圾注册和恶意用户?
- 如何安全地管理用户的验证状态?
4.2.1. 用户实体扩展
为了支持邮箱验证功能,我们要在 Users
实体中添加以下字段:
1 | default: false }) ({ |
这三个新增字段解决了邮箱验证的核心需求:
verified
:标记用户是否已验证邮箱verificationToken
:存储唯一的验证令牌verificationTokenExpires
:设置令牌的过期时间
4.2.2. 用户服务中的邮箱验证逻辑
先在 users.service.ts
的上方引入我们需要的内容:
1 | import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; |
1 | () |
-
create
方法用于创建一个用户。当一个用户尝试注册自己时,系统应当通过以下步骤确保注册过程的安全性和可靠性:-
检查用户是否已存在,防止重复注册带来的冲突和逻辑错误:
1
2
3
4
5
6
7
8
9
10
11
12async 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('用户名或邮箱已存在');
}
// 下面继续...
} -
对用户密码进行加密后,生成验证令牌:
1
2
3const hashedPassword = await bcrypt.hash(user.password, 10);
const verificationToken = uuidv4();
this.logger.debug('验证码已生成:' + verificationToken); -
结合用户信息和生成的安全数据,创建一个完整的用户对象:
1
2
3
4
5
6
7
8const 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)); -
将新用户记录写入数据库,完成用户创建:
1
2const savedUser = await this.usersRepository.save(newUser);
this.logger.debug('保存的用户:' + JSON.stringify(savedUser)); -
发送验证邮件:
1
await this.emailService.sendVerificationEmail(savedUser);
-
最终返回精简信息,避免返回敏感数据:
1
2
3
4return {
id: savedUser.id,
email: savedUser.email
};
-
-
verifyEmail
方法用于验证用户邮箱。当用户点击验证链接时,系统通过该方法完成验证流程。此方法通过检查令牌的有效性和状态,确保邮箱验证的安全性和可靠性:-
根据提供的
token
查找用户,验证请求是否有效:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23async 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}`
);
// 下面继续...
} -
检查用户的验证状态。这里会有多个条件:
-
用户是否已验证:
1
2
3
4
5
6
7
8if (user.verified) {
this.logger.debug(`验证失败:用户 ${user.email} 已经验证过了`);
return {
success: false,
message: '邮箱已经验证过了',
error: EmailVerificationError.ALREADY_VERIFIED
};
} -
验证令牌是否过期:
1
2
3
4
5
6
7
8
9
10
11
12if (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
};
}
-
-
更新用户验证状态,将
verified
属性设置为true
并保存到数据库:1
2
3
4
5
6
7
8
9user.verified = true;
try {
await this.usersRepository.save(user);
this.logger.debug(`用户 ${user.email} 验证成功,已更新验证状态`);
} catch (error) {
this.logger.error(`更新用户验证状态失败:${error.message}`, error.stack);
throw error;
} -
返回成功信息,告诉调用方邮箱验证完成:
1
2
3
4
5this.logger.debug(`验证流程完成,用户 ${user.email} 验证成功`);
return {
success: true,
message: '邮箱验证成功'
};
-
-
resendVerificationEmail
方法用于重新发送验证邮件。当用户请求重发验证邮件时,此方法处理生成新的验证令牌并发送邮件的整个流程,确保未验证用户能够完成邮箱验证:-
通过
userId
查询目标用户,确保用户存在:1
2
3
4
5
6
7
8async resendVerificationEmail(userId: string): Promise<VerificationResponse> {
try {
const user = await this.findById(userId);
this.logger.debug(`找到用户:${user.email}, 当前验证状态:${user.verified}`);
// 下面继续...
}
} -
检查用户的验证状态;用户是否已验证:
1
2
3
4
5
6
7
8if (user.verified) {
this.logger.debug(`重发失败:用户 ${user.email} 已经验证过了`);
return {
success: false,
message: '邮箱已经验证过了',
error: EmailVerificationError.ALREADY_VERIFIED
};
} -
生成新的验证令牌,确保安全性,并更新过期时间:
1
2
3
4
5
6
7
8
9
10const 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}`
); -
保存用户数据:
1
2await this.usersRepository.save(user);
this.logger.debug(`用户验证信息已更新:${user.email}`); -
发送验证邮件:
1
2await this.emailService.sendVerificationEmail(user);
this.logger.debug(`验证邮件已重发至:${user.email}`); -
返回成功信息:
1
2
3
4return {
success: true,
message: '验证邮件已重新发送'
}; -
如果出现异常,就得记录错误日志、返回失败消息:
1
2
3
4
5
6
7
8catch (error) {
this.logger.error(`重发验证邮件失败:userId=${userId}, error=${error.message}`, error.stack);
return {
success: false,
message: '重新发送验证邮件失败',
error: EmailVerificationError.TOKEN_INVALID
};
}
-
4.2.3. 用户控制器新增接口
我们需要以下两个接口:
GET /verify-email
:通过查询参数接收验证token
,完成用户邮箱验证POST /resend-verification/:id
:接收用户id
,重新发送验证邮件
1 | summary: '验证用户邮箱' }) ({ |
关于
HttpStatus
和@HttpCode
,请见这里。
4.2.4. 开发邮件模块
安装邮件模块 @nestjs-modules/mailer
:
1 | yarn add @nestjs-modules/mailer |
在 src
目录下创建 email
目录,并在内创建 email.module.ts
:
1 | import { Module } from '@nestjs/common'; |
我们需要在
.env
里新增几个值:
1
2
3
4
5 SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
SMTP_FROM_ADDRESS=这些是常见的 SMTP(Simple Mail Transfer Protocol)配置参数,用于设置邮件服务器的基本信息以发送电子邮件。
SMTP_HOST
:邮件服务器的主机地址,通常是邮件服务提供商的域名或 IP 地址(例如用 Gmail 的话就是写smtp.gmail.com
)
SMTP_PORT
:邮件服务器使用的端口号,用于建立与邮件服务器的连接
- 常用端口:
587
:用于明文连接后升级为加密连接(STARTTLS)465
:用于加密连接(SSL/TLS)25
:传统的 SMTP 端口,可能被 ISP 限制587
或465
是现代邮件服务中最常见的选择
SMTP_USER
:用于身份验证的用户名,通常是发送邮件的邮箱地址
SMTP_PASS
:与SMTP_USER
对应的密码,用于 SMTP 身份验证(一些服务——如 Gmail——可能要求使用应用专用密码而不是账户密码)
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18import { 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';
()
export class EmailService {
private readonly logger = winstonLogger;
private readonly templatesDir: string;
private readonly partialsDir: string;
// 下面继续...
} -
在构造函数中注入
ConfigService
和MailerService
。同时设置模板目录和部分模板目录的路径:1
2
3
4
5
6
7constructor(
private configService: ConfigService,
private mailerService: MailerService
) {
this.templatesDir = resolve(__dirname, 'templates');
this.partialsDir = resolve(this.templatesDir, 'partials');
} -
初始化模块:
1
2
3
4async onModuleInit() {
this.ensureTemplatesExist();
await this.registerPartials();
}onModuleInit
方法会在模块初始化时被调用ensureTemplatesExist
方法应当是检查模板是否存在,如果不存在则通过执行命令 `yarn copy-templates 来复制模板文件registerPartials
方法则用于注册模板中的部分文件,确保这些部分模板可以在主模板中引用
-
确保模板文件存在:
1
2
3
4
5
6private 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
用法:-
安装
copyfiles
为开发依赖(或者直接全局安装):1
yarn add -D copyfiles
-
如果是复制
src
下的所有.js
文件到dist
,且保留目录结构:1
yarn copyfiles 'src/**/*.js' dist/
-
不保留目录结构:
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
目录。 -
-
注册部分模板:
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
29private 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
文件会在后面完善。 -
编译模板:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22private 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
对象(包含动态内容)来渲染模板。 -
发送验证邮件:
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
35async 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
11async onModuleInit() {
this.ensureAssetsExist();
}
private ensureAssetsExist() {
const assetsPath = resolve(__dirname, 'assets');
if (!existsSync(assetsPath)) {
this.logger.warn('dist 中无法找到 assets,复制中...');
execSync('yarn copy-assets');
}
}
现在我们可以开始写验证邮件的实际内容了:
-
在
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
<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>这里使用了三个动态内容插值:
{{username}}
:动态填充用户名称{{verification}}
:用户唯一的验证链接{{expiresIn}}
:链接有效期提示
-
在
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 图片,然后这里直接调用前端链接/图片文件名称.图片文件类型
。但是这意味着如果前端项目是在本地环境运行的,图片链接便无效。
-
在
src/email/templates/partials
目录下创建footer.hbs
:1
2
3
4<footer class="email-footer">
<p>© {{year}} 你的公司名称. 保留所有权利。</p>
<p>此邮件由系统自动发送,请勿回复。</p>
</footer>这里可以自行修改。
-
在
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
59body {
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;
}同样可以自行修改。
最后便是在各个模块中进行引用:
-
users.module.ts
:1
2
3
4
5
6
7
8
9
10
11
12
13
14import { 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';
({
imports: [TypeOrmModule.forFeature([Users]), EmailModule],
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService]
})
export class UsersModule {} -
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';
({
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 | import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common'; |
5.2. 修改 Swagger 标签
从
1 | status: 200, description: 'xxx' }) ({ |
统统修改为
1 | status: HttpStatus.OK, description: 'xxx' }) ({ |
状态码本身是数字,对于大多数人而言并不直观、需要对其有较深的理解。若需要统一调整状态码(如规范化状态码的使用),代码中可能需要逐一查找和替换。更何况状态码本身也更容易被误写为其他数字,难以自动检测错误。
而枚举值提供了清晰的语义,直接描述了状态码的含义,因此更容易理解。当需要统一调整时,可以直接通过 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 | import dataSource from '../config/data-source'; |
可以在 package.json
中添加以下命令来使用:
1 | "scripts": { |