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

本文涉及的产品
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
Redis 开源版,标准版 2GB
推荐场景:
搭建游戏排行榜
云数据库 Tair(兼容Redis),内存型 2GB
简介: 切图仔做全栈:React&Nest.js社区平台(一)——基础架构与邮箱注册、JWT登录实现

前言

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

  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: 要获取值的键名。

在这个例子中,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 解码出来会包含以下的信息:

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 组件显示成功消息。

最后

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

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

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

相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
相关文章
|
23天前
|
Cloud Native Java 编译器
将基于x86架构平台的应用迁移到阿里云倚天实例云服务器参考
随着云计算技术的不断发展,云服务商们不断推出高性能、高可用的云服务器实例,以满足企业日益增长的计算需求。阿里云推出的倚天实例,凭借其基于ARM架构的倚天710处理器,提供了卓越的计算能力和能效比,特别适用于云原生、高性能计算等场景。然而,有的用户需要将传统基于x86平台的应用迁移到倚天实例上,本文将介绍如何将基于x86架构平台的应用迁移到阿里云倚天实例的服务器上,帮助开发者和企业用户顺利完成迁移工作,享受更高效、更经济的云服务。
将基于x86架构平台的应用迁移到阿里云倚天实例云服务器参考
|
8天前
|
缓存 物联网 数据库
如何帮助我们改造升级原有架构——基于TDengine 平台
一、简介 TDengine 核心是一款高性能、集群开源、云原生的时序数据库(Time Series Database,TSDB),专为物联网IoT平台、工业互联网、电力、IT 运维等场景设计并优化,具有极强的弹性伸缩能力。同时它还带有内建的缓存、流式计算、数据订阅等系统功能,能大幅减少系统设计的复杂度,降低研发和运营成本,是一个高性能、分布式的物联网IoT、工业大数据平台。 二、TDengine 功能与组件 TDengine 社区版是一开源版本,采用的是 AGPL 许可证,它具备高效处理时序数据所需要的所有功能,包括: SQL 写入、无模式写入和通过第三方工具写入 S标准 SQL 查
39 13
|
7天前
|
前端开发 JavaScript API
React、Vue.js 和 Angular前端三大框架对比与选择
前端框架是用于构建用户界面的工具和库,它提供组件化结构、数据绑定、路由管理和状态管理等功能,帮助开发者高效地创建和维护 web 应用的前端部分。常见的前端框架如 React、Vue.js 和 Angular,能够提高开发效率并促进团队协作。
24 4
|
9天前
|
监控 Android开发 iOS开发
深入探索安卓与iOS的系统架构差异:理解两大移动平台的技术根基在移动技术日新月异的今天,安卓和iOS作为市场上最为流行的两个操作系统,各自拥有独特的技术特性和庞大的用户基础。本文将深入探讨这两个平台的系统架构差异,揭示它们如何支撑起各自的生态系统,并影响着全球数亿用户的使用体验。
本文通过对比分析安卓和iOS的系统架构,揭示了这两个平台在设计理念、安全性、用户体验和技术生态上的根本区别。不同于常规的技术综述,本文以深入浅出的方式,带领读者理解这些差异是如何影响应用开发、用户选择和市场趋势的。通过梳理历史脉络和未来展望,本文旨在为开发者、用户以及行业分析师提供有价值的见解,帮助大家更好地把握移动技术发展的脉络。
|
9天前
|
前端开发 JavaScript 开发者
React 和 Vue.js 框架的区别是什么?
React 和 Vue.js 框架的区别是什么?
|
2月前
|
JSON 前端开发 JavaScript
|
20天前
|
前端开发 JavaScript 开发者
Express.js与前端框架的集成:React、Vue和Angular的示例与技巧
本文介绍了如何将简洁灵活的Node.js后端框架Express.js与三大流行前端框架——React、Vue及Angular进行集成,以提升开发效率与代码可维护性。文中提供了详细的示例代码和实用技巧,展示了如何利用Express.js处理路由和静态文件服务,同时在React、Vue和Angular中构建用户界面,帮助开发者快速掌握前后端分离的开发方法,实现高效、灵活的Web应用构建。
36 3
|
8天前
|
编解码 Linux 开发工具
Linux平台x86_64|aarch64架构RTMP推送|轻量级RTSP服务模块集成说明
支持x64_64架构、aarch64架构(需要glibc-2.21及以上版本的Linux系统, 需要libX11.so.6, 需要GLib–2.0, 需安装 libstdc++.so.6.0.21、GLIBCXX_3.4.21、 CXXABI_1.3.9)。
|
2月前
|
前端开发 JavaScript API
构建高效Web应用:React与Node.js的完美结合
【8月更文挑战第29天】在当今快速变化的软件开发领域,构建高性能、可扩展的Web应用成为开发者的首要任务。本文将深入探讨如何利用React和Node.js这两大技术栈,打造一个高效且响应迅速的现代Web应用。从前端的用户界面设计到后端的服务逻辑处理,我们将一步步分析这两种技术如何协同工作,提升应用性能,并确保用户体验的流畅性。通过实际代码示例和架构设计的解析,本篇文章旨在为读者提供一套清晰的指南,帮助他们在项目开发中做出更明智的技术选择。
|
2月前
|
编解码 Linux 数据安全/隐私保护
Linux平台x86_64|aarch64架构如何实现轻量级RTSP服务
为满足在Linux平台(x86_64与aarch64架构)上实现轻量级RTSP服务的需求,我们开发了一套解决方案。该方案通过调用`start_rtsp_server()`函数启动RTSP服务,并设置端口号及认证信息。支持AAC音频和H.264视频编码,可推送纯音频、纯视频或音视频流。此外,还支持X11屏幕采集、部分V4L2摄像头采集、帧率/GOP/码率调整、摄像头设备选择与预览等功能。对于音频采集,支持alsa-lib和libpulse接口。整体设计旨在提供150-400ms的低延迟体验,适用于多种应用场景。
下一篇
无影云桌面