切图仔做全栈:React&Nest.js社区平台(一)——基础架构与邮箱注册、JWT登录实现

2 阅读15分钟

前言

开始之前让我们先来思考一个问题,前端同学有没有必要了解一些后端的知识?我觉得是有必要的,以下是我的一些个人观点:

  1. 大多数的项目中,后端天然更接近数据,也就意味着后端更接近整个业务。了解后端可以帮助我们以更广阔的视野去判断我们正在做的业务是什么,它有什么价值,更了解整个业务的流程。
  2. 在跟后端对接的时候,如果你知道后端是怎么思考的。就不会出现那种:诶,我这个不好处理,你前端来处理一下吧。类似这种事情,如果你具有一定的后端知识,就不会被他唬住牵着鼻子走。
  3. 招聘软件上有不少岗位会要求你使用过或了解过一种后端语言,了解后端能让我们有更多机会。

总结来说就是:学后端可以拓展我们的职业道路,如果没有机会写后端,也可以让你具备后端思维,以后端的角度去思考问题,以更大的视野审视整个系统

以上仅代表我的个人观点,如果你有其他的观点,欢迎评论区补充~

对于前端同学来说,最熟悉的语言莫过于 JS ,所以我们肯定更倾向于用 JS 去写后端。而 Nest.js 是这段时间很火的一个 Node 开发框架。

它提供了一套企业级的后端开发解决方案,它提供了一个结构化、模块化的方式来构建服务端应用。它采用 TS (当然你也可以选择 JS ),提供了清晰的语法和强类型支持,极大提高开发效率和代码质量。通过内置的依赖注入机制和装饰器,使得代码更易于组织和扩展。同时,它支持范围广泛的功能,如中间件、拦截器、 GuardsPipes 以及异常处理等,以及对微服务的支持,使它对复杂、大规模项目非常友好。

学一门语言或者学一个框架,最好是通过实战出发,所以我打算做一个系列,使用 React+Nest 来实现一个文章社区平台,记录自己的思考与学习的过程,分享我所学到的知识。

以下是我们第一期会实现的功能,可能后期会有所增删,可以先参考看看,如下图

image.png

仓库地址

项目技术栈

前端技术栈:

  • react+ts
  • vite
  • mobx
  • antd
  • axios
  • websocket
  • ...

后端技术栈:

  • nest
  • mysql
  • redis
  • websocket
  • 消息队列
  • 对象存储
  • 搜索引擎
  • 日志系统
  • ...

前端工程目录

前端的工程目录我是采用vite的脚手架搭建的:

npx create-vite my-vite-react-app --template react-ts

在此之上我加了一些分层,目录图如下:

image.png

  • api :接口请求模块
  • components :公共组件
  • hooks :抽离出来的 Hook
  • store :状态管理仓库
  • views :具体的页面路由

后端工程目录

Nest 的工程目录还是使用官方的脚手架搭建的,即

$ npm i -g @nestjs/cli
$ nest new project-name

在此之上我加了一些模块与分层,便于我们后续的开发,主要的工程目录图如下:

image.png

大概介绍一下各个模块的信息:

  • main.ts :入口文件
  • app.module.ts :根模块,入口文件中会以此模块为入口解析依赖关系启动程序
  • dtos :用来接收请求参数,传输给 service
  • entities :实体文件,一个entity对应一张数据库的表,属性与数据库字段一一对应
  • filters :异常过滤器,用来自定义异常
  • guards :守卫,用来判断是否有权限进入某个控制器或者某个路由
  • interceptors :拦截器,用来转换路由的返回值
  • middilewares :中间件,处理请求前后的信息
  • services :一些公共的业务方法
  • decorators :自定义装饰器
  • pipes :数据验证和转换
  • utils :一些公共的通用方法
  • modules :业务模块
    • xx.controller :控制器,用于接收前端请求
    • xx.service :用于处理业务逻辑
    • xx.module :管理当前模块的依赖
  • .env :配置文件,数据库配置、 Redis 配置等

Nest.js快速入门

这里先简要介绍一下 Nest 相关的几个最常用也最核心的概念,其他的概念我们会在实战环节中再一一去了解,附上Nest文档

依赖注入

Nest.js 使用依赖注入模式来管理应用程序中的组件之间的依赖关系。在 Nest.js 中,依赖注入通过 TypeScript 的装饰器和元数据来实现。

示例:

// 依赖注入装饰器
@Injectable()
class MyService {
  getHello(): string {
    return 'Hello, Nest!';
  }
}

@Controller('example')
class MyController {
  // 在构造函数中注入依赖
  constructor(private readonly myService: MyService) {}

  @Get('hello')
  getHello(): string {
    return this.myService.getHello();
  }
}

Controller

Nest.js 中,Controller 负责处理传入的 HTTP 请求,并将结果返回给客户端。它主要包含路由处理函数,用于定义不同动作的行为。

示例:

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }

  @Get(':id')
  findOne(@Param('id') id: string): string {
    return `This action returns a cat with id ${id}`;
  }
}

Service

Service 负责处理应用程序的业务逻辑,它可以被注入到 Controller 中。服务通常包含了一些方法,用于处理数据、执行业务逻辑等。

示例

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat): void {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }

  findOne(id: string): Cat {
    return this.cats.find(cat => cat.id === id);
  }
}

Module

ModuleNest.js 中组织应用程序结构的基本单元。每个应用程序都至少包含一个根模块,而其他模块则通过导入和导出关系进行组织。模块负责将应用程序划分为一组逻辑上相关的功能单元。

示例:

@Module({
  controllers: [CatsController],
  providers: [CatsService],
})
export class CatsModule {}

在这个例子中,CatsModule 导出了 CatsControllerCatsService,这样其他模块就可以导入 CatsModule 以使用这些组件。

数据库配置

接下来开始处理数据库连接的配置,这里我用的 orm 工具类是 typeorm

这里需要如下的包:

  • @nestjs/config
  • @nestjs/typeorm
  • typeorm
  • mysql2

在连接数据库的时候必不可少的就是配置数据库的信息,比如域名、用户名、密码等。这种信息常常用一个配置文件来管理,就是我们上面提到的 .env 文件。

.env 的内容如下,这个根据具体情况进行修改:

DB_HOST=host
DB_PORT=3306
DB_USERNAME=username
DB_PASSWORD=password
DB_DATABASE=jueyin

创建好配置文件之后,需要让 nest 去读取这个配置文件,这里用到了 @nestjs/config 这个模块,同时使用到 @nestjs/typeorm 这个模块去把 nesttypeorm 联系起来。

app.module.ts 中就可以按照如下配置,来使用我们的配置文件去连接 mysql

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';

const getDatabaseConfig = () => {
  const configService = new ConfigService()
  return TypeOrmModule.forRoot({
    type: 'mysql',
    host: configService.get<string>('DB_HOST', 'localhost'),
    port: configService.get<number>('DB_PORT', 3306),
    username: configService.get<string>('DB_USERNAME', 'myuser'),
    password: configService.get<string>('DB_PASSWORD', 'mypassword'),
    database: configService.get<string>('DB_DATABASE', 'mydatabase'),
    autoLoadEntities: true
  })
};

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    getDatabaseConfig()
  ],
})
export class AppModule { }

表设计

配置好数据库之后,我们就可以开始设计表结构了,我们今天要实现的是用户的注册以及登录功能,所以需要建一张 users 用户表,用户表的字段目前来说有以下几个:

  • id:用户id
  • username :用户名
  • email :用户登录的邮箱
  • password :用户密码
  • info :用户的个人简介
  • avatar :用户头像
  • created_time :创建时间,通用字段
  • updated_time :更新时间,通用字段

建表的 DDL 语句如下:

CREATE TABLE `users` (
`id` int(20) NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`avatar` varchar(255) DEFAULT NULL,
`info` varchar(255) DEFAULT NULL,
`email` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`created_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `users_email_IDX` (`email`,`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4;

这里我使用自增主键 id作为用户的 id ,同时给 email 加了一个二级索引,以加速后面根据邮箱查询的逻辑(不过数据量少的话这个索引可能都不会走哈哈哈)。

邮箱注册

我们要实现邮箱注册的话,需要注册一个邮箱用来发验证码,这里我用的是 163邮箱。注册好 163邮箱 之后,根据下面图示去配置一下就好了。

image.png

image.png

统一接口路径前缀

在开始写接口之前,很有必要统一接口的路径和前缀,这也方便我们后续拓展,就像前端会在 axios 中配置接口的公共部分一样。

main.ts中加入下面一行代码,就可以统一接口的前缀了

app.setGlobalPrefix('/api');

获取验证码接口

用户使用邮箱注册的时候,我们会给用户的邮箱发送一个验证码,在用户真正提交注册请求的时候,我们需要比对他提交的验证码跟我们生成的验证码是否一致。

发送验证码可以使用 nodemailer 这个库,我们新建一个 EmailService ,实现一个 sendEmail 方法用于抽取邮件发送的通用逻辑。

import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import * as nodemailer from 'nodemailer'
@Injectable()
export class EmailService {
  private transporter: any
  private from: string
  constructor() {
    const configService = new ConfigService()
    this.from = configService.get<string>('EMAIL', '')
    this.transporter = nodemailer.createTransport({
      host: configService.get<string>('EMAIL_HOST', 'smtp.163.com'), // SMTP主机地址
      port: 465, // SMTP端口号
      auth: {
        user: configService.get<string>('EMAIL', ''), // 发件人邮箱地址
        pass: configService.get<string>('EMAIL_SECERT', '') // 发件人邮箱密码
      }
    })
  }
  async sendMail(to: string, subject: string, text: string): Promise<void> {
    const mailOptions = {
      from: this.from,
      to,
      subject,
      text,
    };

    await this.transporter.sendMail(mailOptions);
  }
}

同样的,发送邮件的一些相关配置也可以写入到 .env 文件中,示例配置如下:

EMAIL_HOST=smtp.163.com
EMAIL=email@163.com
EMAIL_SECRET=secret

搞定了这个通用的发邮件方法之后,业务层调用起来就十分简单了。由于是我们写的第一个接口,我尽量会描述的详细一些。

controller 方面,接收的是我们需要发邮件的邮箱,所以可以定义一个 get 路由,使用 query 参数的形式把邮箱参数传递过来,调用示例:/api/users/getVerifyCode?email=xx@qq.com

import { Controller, Get, Query } from '@nestjs/common';
import { UserService } from './user.service';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) { }

  @Get('getVerifyCode')
  async getVerifyCode(@Query('email') email: string): Promise<string> {
    const code = await this.userService.sendVerifyCode(email)
    return code
  }
}

随着业务的发展,发送邮件的场景会越来越多,邮件的内容也会大相径庭。对于这个场景,是发送一条验证码,所以可以在 userService 中,实现一个 sendVerifyCode 方法,基于前面封装的通用的发送邮件方法,组装本场景下的邮件内容。

import { Injectable } from '@nestjs/common';
import { EmailService } from '../../services/email.service';
import { generateRandomNumber } from '../../utils';


@Injectable()
export class UserService {
  constructor(
    private emailService: EmailService
  ) { }

  async sendVerifyCode(email: string): Promise<string> {
    const code = generateRandomNumber()
    const text = `您的验证码是:${code},5分钟内有效`
    await this.emailService.sendMail(email, 'jueyin注册', text)
    return code
  }
}

这里就实现了发送邮件主题为 jueyin注册 ,邮件内容包含了6位随机验证码的邮件推送。

看起来我们写了好几个文件,但是实际书写的代码其实没几行。分的这么散的原因还是开发的时候尽量让各个模块各司其职,在一开始的时候就定好模块的边界,什么事情应该由什么模块来做,尽量多抽取公共的逻辑与方法,这样对于后期拓展与维护都有很大的帮助。

验证码有效期

在我们收到注册邮件的时候,验证码其实是有有效期的,有效期大概是5-10分钟之间,这也是出于安全性的考虑。

如果我们这里的有效期定为5分钟,那么该如何实现5分钟后验证码过期的逻辑?

容易想到的是定义一个 map ,邮箱是 key ,验证码是 value 。在过了5分钟之后,从这个 map 中移除掉这个 key ;注册接口中从这个 map 中取验证码与用户提交的验证码进行对比校验有效性。

那么就有一个问题,这个 map 应该存在哪里?可以存在我们的 nest 应用的内存中吗,然后用一个 setTimeout 让他过期。讲道理是可以的,但是最好不要那么做。

如果你的系统是分布式的,起了多个 nest 应用,或者说有多个 nest 应用的容器,那么就很有可能出现一种情况:下发并存储验证码的是A容器,注册请求走到了B容器,那你在B容器的内存中肯定是找不到这个验证码的。

存在数据库里可以吗?当然可以,但是读数据库读的始终还是磁盘,读磁盘的速度肯定不如读内存。所以这里我们要引入一套缓存的机制—— Redis

首先安装@nestjs-modules/ioredis ioredis,然后定义一个连接方法:

const getRedisConfig = () => {
  const configService = new ConfigService()
  const host = configService.get<string>('REDIS_HOST', 'localhost')
  const port = configService.get<number>('REDIS_PORT', 6379)
  return RedisModule.forRoot({
    type: 'single',
    url: `redis://${host}:${port}`,
    options: {
      password: configService.get<string>('REDIS_PASSWORD', 'password'),
      db: 2 //选择2数据库,Redis中数据库是0-16
    }
  })
}

app.module.tsimports 数组中调用这个方法,建立起 Redis 连接。建立好连接之后定义一个RedisService类,来统一管理 Redis 的相关操作。

import { InjectRedis } from "@nestjs-modules/ioredis";
import { Injectable } from "@nestjs/common";
import Redis from "ioredis";
@Injectable()
export class RedisService {
  constructor(@InjectRedis() private readonly redis: Redis) {
    this.redis = redis
    this.redis.select(2)
  }
  set(key: string, value: any, expire?: number) {
    if (expire > 0) {
      return this.redis.setex(key, expire, value)
    } else {
      return this.redis.set(key, value)
    }
  }
  get(key: string) {
    return this.redis.get(key)
  }
}

SET 命令用于在 Redis 中设置指定键的值。如果键已经存在,则会覆盖现有的值。如果键不存在,则创建一个新的键值对。SET 命令的基本语法如下:

SET key value [EX seconds] [PX milliseconds] [NX|XX]
  • key: 要设置的键名。
  • value: 要设置的值。
  • EX seconds: 可选参数,表示在指定的秒数后过期。例如,EX 3600 表示键在 3600 秒(1 小时)后过期。
  • PX milliseconds: 可选参数,表示在指定的毫秒数后过期。例如,PX 60000 表示键在 60000 毫秒(1 分钟)后过期。
  • NX|XX: 可选参数,表示只有在键不存在时才设置(NX),或者只有在键已经存在时才设置(XX

示例:

SET mykey "Hello, Redis!"

SETEX 命令

SETEX 命令是 SET 命令的扩展,用于在 Redis 中设置键的值并同时设置过期时间。SETEX 命令的语法如下:

SETEX key seconds value
  • key: 要设置的键名。
  • seconds: 键的过期时间(以秒为单位)。
  • value: 要设置的值。
SETEX mykey 3600 "Hello, Redis! This key will expire in 1 hour."

在这个例子中,mykey 键的值被设置为 "Hello, Redis! This key will expire in 1 hour.",并且在 3600 秒(1 小时)后自动过期。

GET 命令用于从 Redis 中获取存储在指定键中的值。它是 Redis 提供的最基本的命令之一。GET 命令的基本语法如下:

GET key
  • key: 要获取值的键名。
GET mykey

在这个例子中,mykey 是一个键名,GET mykey 命令将返回存储在该键中的值。

如果键不存在,GET 命令将返回特殊值 nil,表示找不到对应的键。

这里小小的封装了一个 set 方法,主要根据判断有没有传入过期时间,有的话则调用 setex ,没有的话直接调用 set 就好。

统一接口返回值

对于所有接口来说,都应该有一个统一的返回值。这里我们可以使用 Nest 的拦截器来实现,我们希望返回的值 JSON 结构如下:

  • status
  • data
  • message
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';

@Injectable()
export class ResponseInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      map(data => {
        return {
          status: 200,
          data,
          message: 'success',
        };
      }),
    );
  }
}

这里我们定义了一个拦截器 ResponseInterceptor,它对请求的结果进行了统一的格式化处理。这样,无论具体的业务逻辑返回什么样的数据,最终响应的数据都会以我们定义好的格式呈现,使响应格式更加一致。

这有助于提高代码的可维护性,统一响应的结构,以便更容易处理和理解在不同接口中返回的数据。

app.module.ts中的 provider 中注册全局拦截器:

{
  provide: APP_INTERCEPTOR,
  useClass: ResponseInterceptor,
}

image.png

统一异常处理

对于异常,我们也希望统一拦截并返回统一的数据结构,这个时候可以使用 Nest 的异常过滤器来实现。

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  catch(exception: any, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const status = exception instanceof HttpException ? exception.getStatus() : 500;
    const message = exception instanceof Error ? exception.message : '服务端异常';
    response.status(200).json({
      status,
      message,
      data: false
    });
  }
}

这里实现了一个异常过滤器,用于应用程序中的所有异常,从exception 对象中获取异常信息,最后返回一个包含异常信息的JSON对象。

app.module.ts中的provider中注册全局异常过滤器:

{
  provide: APP_INTERCEPTOR,
  useClass: GlobalExceptionFilter,
}

邮箱注册接口

处理完验证码的逻辑之后,就可以真正开始实现用户的注册。首先要先来介绍几个概念:

DTO与数据校验

DTO 是一种用于定义数据传输格式的对象。 通常用于验证和传递数据,尤其是在处理 HTTP 请求时。 DTO 有助于将输入数据规范化,并提供对输入数据的验证,以确保它符合应用程序的期望。

注册接口接受三个参数:

  • email:邮箱号
  • password:密码
  • code:验证码

这三个是必填参数,而且我们希望 email 是一个合法的邮箱格式,所以需要进行入参的校验。安装这两个库:class-validator、class-transformer,它们常常一起搭配 DTO 来使用。

class-validator 是一个基于装饰器的数据验证库,用于验证类实例的属性是否符合指定的规则。它提供了一组内置的验证器,也允许你创建自定义验证器。

class-transformer 是一个用于对象转换和映射的库,它可以方便地将对象从一种形式转换为另一种形式。

class-validatorclass-transformer 经常与 DTO 一起使用,以验证请求体并将其转换为实际的数据对象(例如 Entity )。这通常通过在控制器的方法上使用 ValidationPipeUsePipes 装饰器来实现。

我们可以定义一个用户注册的 DTO 如下:

import { IsEmail, IsNotEmpty, Length } from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty({
    message: '邮箱不能为空',
  })
  @IsEmail({}, { message: '邮箱格式不正确' })
  email: string;

  @IsNotEmpty({
    message: '密码不能为空',
  })
  @Length(6, 30, {
    message: '密码最小长度为6位,最大长度为30位',
  })
  password: string;

  @IsNotEmpty({
    message: '验证码不能为空',
  })
  code: string;
}

然后实现一个全局的数据验证:

import { HttpException, HttpStatus, ValidationPipe } from '@nestjs/common';
export class GlobalValidationPipe extends ValidationPipe {
  constructor() {
    super({
      transform: true,
      transformOptions: {
        enableImplicitConversion: true,
      },
      exceptionFactory: (errors) => {
        let message = [];
        errors.map((error) => {
          const constraints = error.constraints || {};
          message.push(...Object.values(constraints));
        });
        return new HttpException(message.join(';'), HttpStatus.BAD_REQUEST);
      },
    });
  }

上面主要对于验证不通过的字段收集了对应的报错信息,然后丢给全局异常过滤器处理

app.module.ts下记得引入这个类

providers: [
    // ...
    {
      provide: APP_PIPE,
      useClass: GlobalValidationPipe,
    },
  ],

image.png

Entity

实体通常用于表示应用程序中的数据模型,并与数据库交互。在 Nest.js 中,实体通常与数据库中的表相映射,用于对数据库进行 CRUD 操作。Nest.js 中常使用 TypeORM 或者其他数据库模块来操作实体。

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';

@Entity({
  name: 'users',
})
export class UserEntity {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  username: string;

  @Column()
  email: string;

  @Column()
  password: string;

  @Column()
  avatar: string;

  @Column()
  info: string;

  @CreateDateColumn({
    name: 'created_time',
  })
  createdTime: Date;

  @UpdateDateColumn({
    name: 'updated_time',
  })
  updatedTime: Date;
}


在上面的例子中,UserEntity 是一个使用 TypeORM 装饰器标记的实体。它表示数据库中的 users 表,拥有 idusernameemailpasswordavatarinfocreated_timeupdated_time 这些字段。在实际应用中,可以通过 TypeORM 对 UserEntity 进行数据库操作。

其中 @CreateDateColumn 装饰器会在创建数据的时候自动写入创建时间,而 @UpdateDateColumn 装饰器则会在更新数据的时候更新创建时间。

总的来说:

  • DTO 主要用于处理输入数据的验证和传递,通常在控制器中使用。
  • Entity 用于表示应用程序的数据模型,通常与数据库交互,进行持久化操作。

Repository

Repository 是一个由 TypeORM 提供的用于进行数据库操作的类。Repository 类允许你执行常见的 CRUD(创建、读取、更新、删除)操作,并提供了许多查询和事务功能。

可以通过依赖注入的方式将 Repository 注入到服务中

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { UserEntity } from './user.entity';

@Injectable()
export class UserService {
  constructor(
    @InjectRepository(UserEntity)
    private readonly userRepository: Repository<UserEntity>,
  ) {}
}

CRUD 操作

Repository 提供了一系列的方法,用于执行常见的 CRUD 操作,例如 savefindOneupdatedelete 等。

// 创建新用户
const newUser = await userRepository.save({
  username: 'john_doe',
  email: 'john@example.com',
  password: 'password123',
});

// 查询用户
const foundUser = await userRepository.findOne({ username: 'john_doe' });

// 更新用户
foundUser.email = 'new_email@example.com';
await userRepository.update(foundUser.id, foundUser);

// 删除用户
await userRepository.delete(foundUser.id);

查询功能

Repository 提供了强大的查询功能,可以使用链式调用的方式构建复杂的查询。

// 查询用户名以 'john' 开头的用户
const users = await userRepository
  .createQueryBuilder('user')
  .where('user.username LIKE :username', { username: 'john%' })
  .getMany();

Repository 还有很多强大的功能,以及 TypeORM 的详细介绍,还请查阅 TypeORM官方文档

邮箱注册具体实现

接着我们可以在user.controller中编写一个邮箱注册的路由,在有了上面的基础搭建之后,我们只需要做如下的事情:

  1. 判断验证码是否有效
  2. 判断 email 是否存在
  3. 对密码加密入库

user.service.ts中实现一个 createUser 方法:

  async createUser(createUserDto: CreateUserDto): Promise<UserEntity> {
    const { email, password } = createUserDto;
    const code = await this.redisService.get(`${VERIFY_CODE_PREFIX}:${email}`);
    if (!code) {
      throw new Error('验证码已过期');
    }
    const isEmailExist = await this.getUserByEmail(email);
    if (isEmailExist) {
      throw new Error('用户已存在');
    }
    const res = await this.userRepository.save({
      email,
      password: hashPassword(password, email),
      username: `用户${Date.now()}`,
    });
    return res;
  }

JWT登录

做好了注册之后,接着就可以来做登录了,这里使用的是 JWT 登录。

JWT(JSON Web Token)是一种用于在网络上安全地传输信息的开放标准(RFC 7519)JWT 可以包含任意 JSON 数据,并使用数字签名或 HMAC 算法进行签名,以保证传输过程中的数据完整性和验证身份。

JWT 包含了所需的信息,服务器不需要存储用户的登录状态。

生成好 JWT 后,服务端可以把 JWT 作为写入 cookie 中,也可以返回 token ,让前端把拿到的 token 存在本地缓存后,在 Header 中添加相关字段。

这里我们生成 JWT ,然后把 JWT 写入 cookie 中。

使用到的是@nestjs/jwt这个库,首先在app.module.ts中注册一下这个模块:

@Module({
  imports: [
    // ...
    JwtModule.register({}),
  ],
})
export class AppModule {}

然后新建一个auth.service.ts,这个service主要用来生成和解码 jwt

import { Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JWT_SECRET } from '../utils/constant';

@Injectable()
export class AuthService {
  private readonly jwtService: JwtService;
  constructor(@Inject(JwtService) jwtService: JwtService) {
    this.jwtService = jwtService;
  }
  generateJwtToken({ id, email }: { id: number; email: string }): string {
    const payload = { id, email };
    return this.jwtService.sign(payload, {
      secret: JWT_SECRET,
      expiresIn: '7d',
    });
  }
  async decodeJwtToken(token: string): Promise<any> {
    try {
      const decoded = await this.jwtService.verifyAsync(token, {
        secret: JWT_SECRET,
      });
      return {
        id: decoded.id,
        emial: decoded.email,
        iat: decoded.iat * 1000,
        exp: decoded.exp * 1000,
      };
    } catch (error) {
      return {};
    }
  }
}

可以看到我们把用户的id跟邮箱都加入到了 jwt 中,生成的 token 解码出来会包含以下的信息:

{
    id: decoded.id,
    emial: decoded.email,
    iat: decoded.iat * 1000,
    exp: decoded.exp * 1000,
  }

iat 是生成时间, exp 是过期时间

接着在user.service.ts中实现一个登录的方法,验证用户的邮箱密码,验证通过则生成 jwt

  async login(user: LoginDto) {
    const entity = await this.getUserByEmailAndPassword(
      user.email,
      hashPassword(user.password, user.email),
    );
    if (!entity) {
      throw new Error('邮箱或密码错误');
    }
    const { id, email } = entity;
    const token = this.authService.generateJwtToken({ id, email });
    return token;
  }

最后,在 controller 中把生成好的 jwt 写入 cookie 中。

  @Post('login')
  async login(@Body() user: LoginDto, @Res() res: Response): Promise<boolean> {
    const token = await this.userService.login(user);
    res.cookie('token', token, { httpOnly: true });
    res.status(200).json({
      message: 'success',
      data: true,
      status: 200,
    });
    return true;
  }

由于我们这里其实还没有用到这个 token 去鉴权,所以用户鉴权以及什么接口需要登录、什么接口不需要登录,这个我们放到下期再说。

登出

顺便实现一个登出的接口,这个接口很简单,不需要什么 service 的逻辑,就是访问的时候把 cookie 里的 token 清掉就可以。

  @Get('logout')
  async logout(@Res() res: Response) {
    res.clearCookie('token');
    res.send(200);
  }

前端实现

把后端相关的接口实现了之后,就可以开始写前端的逻辑了。

axios封装

可以简单的封装一下 axios ,因为我们后端的接口都已经约定好数据结构:

let _BASE_URL = "/api";
if (import.meta.env.PROD) {
} else {
  _BASE_URL = "/api";
}
import { message } from "antd";
import _axios from "axios";

const axiosInstance = _axios.create({
  withCredentials: true, // 是否允许带cookie这些
});
axiosInstance.interceptors.request.use((request) => {
  return request;
});
axiosInstance.interceptors.response.use(
  (response: any) => {
    const data = response.data;
    if (data.status !== 200) {
      message.error(data.message);
      return Promise.reject(data.message);
    }
    return response.data;
  },
  (error) => {
    console.error("请求错误: ", error);
    return Promise.reject(error);
  }
);

export const BASE_URL = _BASE_URL;
export const axios = axiosInstance;

这里封装了一个请求模块,定义了基础URL _BASE_URL,根据环境设置不同的值。

通过 Axios 创建实例 axiosInstance,并添加了请求和响应拦截器。在响应拦截器中,如果响应状态不为 200,使用 antdmessage.error 显示错误消息。

具体的接口请求配置如下:

import { BASE_URL, axios } from ".";
export const getVerifyCode = (email: string) => {
  return axios.get(`${BASE_URL}/users/getVerifyCode?email=${email}`);
};

export const register = (params: {
  email: string;
  password: string;
  code: string;
}) => {
  return axios.post(`${BASE_URL}/users/register`, params);
};

export const login = (params: { email: string; password: string }) => {
  return axios.post(`${BASE_URL}/users/login`, params);
};

如果你是本地开发调试,别忘了在打包工具配置上转发代理,以规避跨域。我用的是 vite ,配置如下:

  server: {
    proxy: {
      "/api": {
        target: "http://localhost:3000",
      },
    },
    host: "0.0.0.0",
  },

登录注册页面

image.png

使用 antd 的表单简单搭建了一个登录注册的页面,这里应该都是前端同学熟悉的逻辑,所以就不再赘述了。

具体代码可以参考下面:

import { Button, Form, FormInstance, Input, Row, Tabs, message } from "antd";
import TabPane from "antd/es/tabs/TabPane";
import styles from "./index.module.less";
import { useState } from "react";
import { getVerifyCode, login, register } from "../../api/user";
const REQUIRED_RULE = [{ required: true, message: "请输入${label}" }];
const VerifyCodeButton = ({ form }: { form: FormInstance }) => {
  const [seconds, setSeconds] = useState(0);
  const handleClick = async () => {
    const res = await form.validateFields(["email"]);
    await getVerifyCode(res.email);
    setSeconds(60);
    let timer = setInterval(() => {
      setSeconds((preSeconds) => {
        if (preSeconds <= 1) {
          clearInterval(timer);
          return 0;
        } else {
          return preSeconds - 1;
        }
      });
    }, 1000);
  };

  return (
    <Button type="primary" disabled={seconds !== 0} onClick={handleClick}>
      {seconds > 0 ? `重新发送 (${seconds}s)` : "获取验证码"}
    </Button>
  );
};
const Login = () => {
  const [form] = Form.useForm();
  const [loading, setLoading] = useState(false);
  const renderForm = (withCode?: boolean) => {
    const handleSubmit = async () => {
      const fields = await form.validateFields();
      try {
        if (withCode) {
          await register(fields);
        }
        await login({ email: fields.email, password: fields.password });
        message.success("登录成功");
      } finally {
        setLoading(false);
      }
    };
    return (
      <div>
        <Form form={form}>
          <Form.Item name="email" label="邮箱" rules={REQUIRED_RULE}>
            <Input placeholder="输入邮箱" />
          </Form.Item>
          <Form.Item name="password" label="密码" rules={REQUIRED_RULE}>
            <Input type="password" placeholder="输入密码" />
          </Form.Item>
          {withCode && (
            <Row>
              <Form.Item
                style={{ marginRight: 20 }}
                name="code"
                label="验证码"
                rules={REQUIRED_RULE}
              >
                <Input placeholder="输入验证码" />
              </Form.Item>
              <VerifyCodeButton form={form} />
            </Row>
          )}
        </Form>
        <Button loading={loading} onClick={handleSubmit} type="primary">
          提交
        </Button>
      </div>
    );
  };
  return (
    <div className={styles.container}>
      <div className={styles.content}>
        <Tabs>
          <TabPane key="login" tab="登录">
            {renderForm()}
          </TabPane>
          <TabPane key="register" tab="注册">
            {renderForm(true)}
          </TabPane>
        </Tabs>
      </div>
    </div>
  );
};

export default Login;

这里使用 antd 编写了一个登录和注册组件。主要包括了输入框、验证码按钮、提交按钮等。

在提交按钮点击时,通过调用相应的 API 进行登录或注册,同时使用 message 组件显示成功消息。

最后

本来这篇文章只是想讲一下登录注册,没想到也写了这么多了,因为咱们把很多时间都花在了基础搭建上,毕竟打好了基础,后续才更好拓展。

这是咱们这个系列的第一篇,后续我也会继续采取这样的方式,一步步地去分享实现一个社区平台。

以上就是本文的全部内容,如果你觉得有意思的话,点点关注点点赞吧,您的关注和赞是我的最大动力~欢迎评论区一起交流!