likes
comments
collection
share

雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

作者站长头像
站长
· 阅读数 12

往期回顾

前端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(一)

后端框架搭建——从零开始搭建一个高颜值后台管理系统全栈框架(二)

实现登录功能jwt or token+redis?——从零开始搭建一个高颜值后台管理系统全栈框架(三)

封装axios,让请求变得丝滑——从零开始搭建一个高颜值后台管理系统全栈框架(四)

实现前后端全自动化部署,解放你的双手。——从零开始搭建一个高颜值后台管理系统全栈框架(五)

前言

这一期文章的内容有点杂,实现了通过雪花算法生成id、通用的附件方案,邮箱验证、修改密码功能。有很多兄弟对这个项目会开发哪些功能感到好奇,在后面我列了一下后续项目的功能清单。

雪花算法

数据库主键id生成方案

数据库主键id有几种常用的方案:

自增方案

数据库可以设置主键id自增,但是后续数据量大的情况下,如果使用水平分表方式优化,可能会生成重复ID。并且id是连续的,别人可以轻松猜到下一条数据id。

uuid

UUID 生成的是一个无序且唯一的字符串,这种数据正常来说很适合做数据库id,但是它也有自己的缺点。

  1. 存储空间占用:UUID通常以128位的形式表示,相比于较短的整型或字符串标识符,需要更多的存储空间。这可能在大规模数据集合和索引中导致存储成本和性能方面的负担。

  2. 可读性差:UUID是由数字和字母组成的字符串,对人类来说不易读写和记忆。当需要手动查询或分析数据库时,可读性差可能造成不便。

  3. 索引效率下降:UUID具有随机性,生成的值没有明显的顺序性或局部性。这导致在使用UUID作为主键或索引时,插入新记录的效率会下降,因为新记录通常被插入到已有索引的各个位置,而不是一个连续的位置。

  4. 查询性能影响:在某些情况下,由于UUID的无序性,查询效率可能受到影响。特别是基于范围的查询、排序和连接操作可能不如使用递增整数类型的标识符高效。

  5. 数据库碎片化:由于UUID的随机性,插入新记录时可能导致数据库表的碎片化。碎片化会增加数据库的存储空间占用和查询性能下降。

利用redis原子性生成自增id

这个和上面自增id方案差不多,虽然解决了水平分表可能带来的问题,但是它也有自己的缺陷。

  1. 生成的id也是连续的,和自增id一样,下一条数据的id很容易被别人猜到。
  2. 当并发请求生成自增ID较高时,单个Redis实例可能成为性能瓶颈。

雪花算法

目前在分布式系统中常用的生成数据库主键算法,它没有上面方案的缺点,性能也比较高。

雪花算法介绍

雪花算法(Snowflake Algorithm)是一种用于生成全局唯一标识符(Unique Identifier)的算法,最初由Twitter开发并开源。它主要用于分布式系统中,以解决在分布式环境下生成唯一ID的需求。

雪花算法原理就是生成一个的64位比特位的 long 类型的唯一 id。

  • 最高1位固定值0,没有意义。
  • 接下来41位存储毫秒级时间戳,2^41/(1000606024365)=69,大概可以使用69年。
  • 再接下10位存储机器码,包括5位 datacenterId 和5位 workerId。最多可以部署2^10=1024台机器。
  • 最后12位存储序列号。同一毫秒时间戳时,通过这个递增的序列号来区分。即对于同一台机器而言,同一毫秒时间戳下,可以生成2^12=4096个不重复 id。

node版本算法实现

网上有很多java版本的实现,我找了一篇,仿造用node实现了一下,具体实现看代码中的注释。

export class SnowFlake {
  // 系统上线的时间戳,我这里设置为 2023-06-22 00:00:00 的时间戳
  epoch = BigInt(1687392000000);

  // 数据中心的位数
  dataCenterIdBits = 5;
  // 机器id的位数
  workerIdBits = 5;
  // 自增序列号的位数
  sequenceBits = 12;

  // 最大的数据中心id 这段位运算可以理解为2^5-1 = 31
  maxDataCenterId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
  // 最大的机器id 这段位运算可以理解为2^5-1 = 31
  maxWorkerId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);

  // 时间戳偏移位数
  timestampShift = BigInt(
    this.dataCenterIdBits + this.workerIdBits + this.sequenceBits
  );
  // 数据中心偏移位数
  dataCenterIdShift = BigInt(this.workerIdBits + this.sequenceBits);
  // 机器id偏移位数
  workerIdShift = BigInt(this.sequenceBits);
  // 自增序列号的掩码
  sequenceMask = (BigInt(1) << BigInt(this.sequenceBits)) - BigInt(1);
  // 记录上次生成id的时间戳
  lastTimestamp = BigInt(-1);
  // 数据中心id
  dataCenterId = BigInt(0);
  // 机器id
  workerId = BigInt(0);
  // 自增序列号
  sequence = BigInt(0);
  constructor(dataCenterId: number, workerId: number) {
    // 校验数据中心 ID 和工作节点 ID 的范围
    if (dataCenterId > this.maxDataCenterId || dataCenterId < 0) {
      throw new Error(
        `Data center ID must be between 0 and ${this.maxDataCenterId}`
      );
    }

    if (workerId > this.maxWorkerId || workerId < 0) {
      throw new Error(`Worker ID must be between 0 and ${this.maxWorkerId}`);
    }

    this.dataCenterId = BigInt(dataCenterId);
    this.workerId = BigInt(workerId);
  }

  nextId() {
    let timestamp = BigInt(Date.now());
    // 如果上一次生成id的时间戳比下一次生成的还大,说明服务器时间有问题,出现了回退,这时候再生成id,可能会生成重复的id,所以直接抛出异常。
    if (timestamp < this.lastTimestamp) {
      // 时钟回拨,抛出异常并拒绝生成 ID
      throw new Error('Clock moved backwards. Refusing to generate ID.');
    }

    // 如果当前时间戳和上一次的时间戳相等,序列号加一
    if (timestamp === this.lastTimestamp) {
      // 同一毫秒内生成多个 ID,递增序列号,防止冲突
      this.sequence = (this.sequence + BigInt(1)) & this.sequenceMask;
      if (this.sequence === BigInt(0)) {
        // 序列号溢出,等待下一毫秒
        timestamp = this.waitNextMillis(this.lastTimestamp);
      }
    } else {
      // 不同毫秒,重置序列号
      this.sequence = BigInt(0);
    }

    this.lastTimestamp = timestamp;

    // 组合各部分生成最终的 ID,可以理解为把64位二进制转换位十进制数字
    const id =
      ((timestamp - this.epoch) << this.timestampShift) |
      (this.dataCenterId << this.dataCenterIdShift) |
      (this.workerId << this.workerIdShift) |
      this.sequence;

    return id.toString();
  }

  waitNextMillis(lastTimestamp) {
    let timestamp = BigInt(Date.now());
    while (timestamp <= lastTimestamp) {
      // 主动等待,直到当前时间超过上次记录的时间戳
      timestamp = BigInt(Date.now());
    }
    return timestamp;
  }
}

代码里使用了BitInt,这个node10.4.0才支持。

测试算法

雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 可以看到生成的id都是递增的,使用这个类有个需要注意的地方,不要在使用的地方每次都去new,应该给他做成单例,所有地方都用同一个实例,不然可能会生成出重复id。

pm2中使用雪花算法

这个其实才是我这篇文章中关于雪花算法的重点,因为上面那些东西网上都能搜索到,而在pm2中使用雪花算法生成id,我没看到类似的文章。 pm2中使用雪花算法的问题是啥,因为pm2启动服务是多进程的,也就是有多个SnowFlake实例,如果并发高的情况下,很可能会生成重复的id。怎么解决这个问题呢,上文中机器id派上用场了,我们只要保证每个实例的机器id不一样就行了。从网上了找了一些资料,没有找到答案。突然想到我以前用pm2 list查看每个进程前面有个id。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 只要在服务里获取这个id就行了,最后发现pm2启动服务的时候,会把当前实例id注入到当前环境变量pm_id中,我们只要在new SnowFlake的时候把当前pm_id当成机器id传进去就行了。

项目中引用SnowFlake

新建src/utils/snow.flake.ts文件,把上面代码复制进去,为了让每一个地方使用同一个SnowFlake实例,我们在这个文件中提前new好,然后再导出new好的实例。(js中实现单例模式真是超级简单)

import { env } from 'process';

export class SnowFlake {
  // 系统上线的时间戳,我这里设置为 2023-06-22 00:00:00 的时间戳
  epoch = BigInt(1687392000000);

  // 数据中心的位数
  dataCenterIdBits = 5;
  // 机器id的位数
  workerIdBits = 5;
  // 自增序列号的位数
  sequenceBits = 12;

  // 最大的数据中心id 这段位运算可以理解为2^5-1 = 31
  maxDataCenterId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);
  // 最大的机器id 这段位运算可以理解为2^5-1 = 31
  maxWorkerId = (BigInt(1) << BigInt(this.workerIdBits)) - BigInt(1);

  // 时间戳偏移位数
  timestampShift = BigInt(
    this.dataCenterIdBits + this.workerIdBits + this.sequenceBits
  );

  // 数据中心偏移位数
  dataCenterIdShift = BigInt(this.workerIdBits + this.sequenceBits);
  // 机器id偏移位数
  workerIdShift = BigInt(this.sequenceBits);
  // 自增序列号的掩码
  sequenceMask = (BigInt(1) << BigInt(this.sequenceBits)) - BigInt(1);
  // 记录上次生成id的时间戳
  lastTimestamp = BigInt(-1);
  // 数据中心id
  dataCenterId = BigInt(0);
  // 机器id
  workerId = BigInt(0);
  // 自增序列号
  sequence = BigInt(0);
  constructor(dataCenterId: number, workerId: number) {
    // 校验数据中心 ID 和工作节点 ID 的范围
    if (dataCenterId > this.maxDataCenterId || dataCenterId < 0) {
      throw new Error(
        `Data center ID must be between 0 and ${this.maxDataCenterId}`
      );
    }

    if (workerId > this.maxWorkerId || workerId < 0) {
      throw new Error(`Worker ID must be between 0 and ${this.maxWorkerId}`);
    }

    this.dataCenterId = BigInt(dataCenterId);
    this.workerId = BigInt(workerId);
  }

  nextId() {
    let timestamp = BigInt(Date.now());
    // 如果上一次生成id的时间戳比下一次生成的还大,说明服务器时间有问题,出现了回退,这时候再生成id,可能会生成重复的id,所以直接抛出异常。
    if (timestamp < this.lastTimestamp) {
      // 时钟回拨,抛出异常并拒绝生成 ID
      throw new Error('Clock moved backwards. Refusing to generate ID.');
    }

    // 如果当前时间戳和上一次的时间戳相等,序列号加一
    if (timestamp === this.lastTimestamp) {
      // 同一毫秒内生成多个 ID,递增序列号,防止冲突
      this.sequence = (this.sequence + BigInt(1)) & this.sequenceMask;
      if (this.sequence === BigInt(0)) {
        // 序列号溢出,等待下一毫秒
        timestamp = this.waitNextMillis(this.lastTimestamp);
      }
    } else {
      // 不同毫秒,重置序列号
      this.sequence = BigInt(0);
    }

    this.lastTimestamp = timestamp;

    // 组合各部分生成最终的 ID,可以理解为把64位二进制转换位十进制数字
    const id =
      ((timestamp - this.epoch) << this.timestampShift) |
      (this.dataCenterId << this.dataCenterIdShift) |
      (this.workerId << this.workerIdShift) |
      this.sequence;

    return id.toString();
  }

  waitNextMillis(lastTimestamp) {
    let timestamp = BigInt(Date.now());
    while (timestamp <= lastTimestamp) {
      // 主动等待,直到当前时间超过上次记录的时间戳
      timestamp = BigInt(Date.now());
    }
    return timestamp;
  }
}

// 如果有pm_id,把pm_id当机器id传进去
export const snowFlake = new SnowFlake(0, +env.pm_id || 0);

改造base.entity

把base.entity里面id自增给删除,同时把数据库类型设置为bigint,然后字段类型设置为string,typeorm会自动把数据库中的bigint转换为string。

这里相信大多数前端都遇到过一个问题,如果你的后端使用雪花算法生成id,然后以long类型返回给前端,前端会因为精度问题,会把最后几位变成0。这种情况有两个解决方案,第一个是前端解决,在请求拦截器中判断然后转成字符串。第二种方案是后端转。第一种方案性能很差,相信大部分兄弟都是使用第二种方案。

使用typeorm插入数据拦截器注入id

上面我们把id自增给删除了,所以需要我们自己手动传id,我们不可能每次new实体的时候,掉snowFlake.nextId()方法生成一个id然后赋值给当前实体id,这样做其实也可以,但是有点麻烦。

好在typeorm支持插入实体拦截器,所有的插入都会执行这个拦截器。我们只需要在这个拦截器中,把id注入进去就行了。

创建src/typeorm-event-subscriber.ts文件

import { EventSubscriberModel } from '@midwayjs/typeorm';
import { EntitySubscriberInterface, InsertEvent } from 'typeorm';
import { snowFlake } from './utils/snow.flake';

@EventSubscriberModel()
export class EverythingSubscriber implements EntitySubscriberInterface {
  beforeInsert(event: InsertEvent<any>) {
    if (!event.entity.id) {
      event.entity.id = snowFlake.nextId();
    }
  }
}

把这个拦截器配置到typeorm中 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 这样每次插入数据的时候,都会自动注入雪花算法生成的id。

附件方案

介绍

后台管理系统肯定少不了附件上传功能,下面给大家分享一个使用起来比较简单的方案。

文件服务器

要做附件,肯定要先有一个文件服务,常用的有腾讯的cos,阿里的oss,七牛云也可以。不过这些都是收费的,大家如果自己有服务器,可以自己使用minio搭建一个。

使用镜像启动minio

先拉minio镜像

docker pull minio/minio

启动minio镜像,9000对应的是控制台前端服务,9001是接口调用的上传下载文件服务。账号是minio,密码是minio@123。

docker run  -p 9000:9000 -p 9001:9001 --name minio \
          -d --restart=always \
          -e MINIO_ACCESS_KEY=minio \
          -e MINIO_SECRET_KEY=minio@123 \
          -v /usr/local/minio/data:/data \
          -v /usr/local/minio/config:/root/.minio \
           minio/minio server /data  --console-address ":9000" --address ":9001"

启动完成后,访问http://localhost:9000,出现下面的界面 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 输入上面设置的帐号密码登录进去,创建一个桶。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 改变桶的权限,不然可以上传文件,但是别人无法访问,所以给桶设置public权限。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 我们上传一个文件测试一下 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 上传成功后,可以通过桶名称和文件名访问,注意这里的端口号是上面设置的文件服务端口号。 http://localhost:7101/fluxy-admin/242091682079921_.pic_hd.jpg

后端服务对接minio

实现思路

整个流程很简单,前端上传文件,后端接受到文件,然后调用minio的接口上传到minio服务器,把文件访问地址返回给前端。

文件服务可能会在后面很多地方使用,比如用户头像,这种一对一的方案还好(一个用户只有一个头像),上传成功后,把图片地址存到头像字段中。一对多的情况下就不能这样用了,比如一个单据有多个附件,这种就需要加关联关系表了,下面给大家分享一个不要加关联关系吧。

我们建一个文件表,把单据id存到文件表里,这样就不用加关联关系表了,虽然用雪花算法可以保证不同业务单据id是唯一的,但是如果后面按业务模块拆分微服务单独部署了,机器id可能是一样的,就有可能生成重复的单据id,所以我们再加一个业务字段来保证当前业务下id是不会重复的。

因为是先上传文件再保存单据,然后把单据id存到文件表里。假设文件上传了,但是用户又取消了创建单据,这样文件表和文件服务器就会有很多脏数据,这时候我们可以写一个定时任务定期去清理没有单据id的文件。

引入upload组件

后端服务引入upload组件,这里miday官方文档写的很清楚,这里我就不说了。

封装minio服务

安装minio依赖

pnpm i minio

新建src/autoload/minio.ts文件

import { Config, IMidwayContainer, Singleton } from '@midwayjs/core';
import { ApplicationContext, Autoload, Init } from '@midwayjs/decorator';
import * as Minio from 'minio';

import { MinioConfig } from '../interface';

export type MinioClient = Minio.Client;

@Autoload()
@Singleton()
export class MinioAutoLoad {
  @ApplicationContext()
  applicationContext: IMidwayContainer;
  @Config('minio')
  minioConfig: MinioConfig;
  @Init()
  async init() {
    const minioClient = new Minio.Client(this.minioConfig);
    this.applicationContext.registerObject('minioClient', minioClient);
  }
}

这里用到了@Singleton单例装饰器和@Autoload自动执行装饰器,只要在方法上使用@Init()装饰器,下面的init方法就会在项目启动后自动执行。init方法中,先根据配置new了一个Minio.Client实例,然后注入到上下文中,这样就可以在代码中直接使用这个服务了。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 代码中使用上面注入的minioClient实例 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)src/config/config.default.ts添加minio配置 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

新建file服务

使用下面命令创建file服务

node ./script/create-module file

file实体文件。pkName就是业务单据类型,pkValue就是单据id。

// src/module/file/entity/file.ts
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '../../../common/base.entity';

@Entity('sys_file')
export class FileEntity extends BaseEntity {
  @Column({ comment: '文件名' })
  fileName?: string;
  @Column({ comment: '文件路径' })
  filePath?: string;
  @Column({ comment: '外健名称', nullable: true })
  pkName: string;
  @Column({ comment: '外健值', nullable: true })
  pkValue?: string;
}

file service文件。封装了三个方法,一个功能的上传方法、设置pkName和pkValue方法、清理脏数据。

import { Config, Inject, Provide } from '@midwayjs/decorator';
import { InjectDataSource, InjectEntityModel } from '@midwayjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { BaseService } from '../../../common/base.service';
import { FileEntity } from '../entity/file';
import { UploadFileInfo } from '@midwayjs/upload';
import { MinioClient } from '../../../autoload/minio';
import { MinioConfig } from '../../../interface';

@Provide()
export class FileService extends BaseService<FileEntity> {
  @InjectEntityModel(FileEntity)
  fileModel: Repository<FileEntity>;
  @Inject()
  minioClient: MinioClient;
  @Config('minio')
  minioConfig: MinioConfig;
  @InjectDataSource()
  defaultDataSource: DataSource;

  getModel(): Repository<FileEntity> {
    return this.fileModel;
  }

  // 上传方法
  async upload(file: UploadFileInfo<string>) {
    // 生成文件名。因为文件名可能重复,这里手动拼了时间戳。
    const fileName = `${new Date().getTime()}_${file.filename}`;

    // 这里使用了typeorm的事务,如果文件信息存表失败的情况下,就不用上传到minio服务器了,如果后面上传文件失败了,前面插入的数据,也会自动会滚。保证了不会有脏数据。
    const data = await this.defaultDataSource.transaction(async manager => {
      const fileEntity = new FileEntity();
      fileEntity.fileName = fileName;
      fileEntity.filePath = `/file/${this.minioConfig.bucketName}/${fileName}`;
      await manager.save(FileEntity, fileEntity);

      await this.minioClient.fPutObject(
        this.minioConfig.bucketName,
        fileName,
        file.data
      );

      return fileEntity;
    });

    return data;
  }

  // 上传单据时,把单据id注入进去
  async setPKValue(id: string, pkValue: string, pkName: string) {
    const entity = await this.getById(id);
    if (!entity) return;
    entity.pkValue = pkValue;
    entity.pkName = pkName;
    await this.fileModel.save(entity);
    return entity;
  }

  // 清理脏数据,清理前一天的数据
  async clearEmptyPKValueFiles() {
    const curDate = new Date();
    curDate.setDate(curDate.getDate() - 1);

    const records = await this.fileModel
      .createQueryBuilder()
      .where('createDate < :date', { date: curDate })
      .andWhere('pkValue is null')
      .getMany();

    this.defaultDataSource.transaction(async manager => {
      await manager.remove(FileEntity, records);
      await Promise.all(
        records.map(record =>
          this.minioClient.removeObject(
            this.minioConfig.bucketName,
            record.fileName
          )
        )
      );
    });
  }
}

file controller。使用upload组件上传文件后,upload会把文件暂时存在服务器临时目录下,files里面存的有临时文件地址。

import { Controller, Inject, Post, Provide, Files } from '@midwayjs/core';
import { FileService } from '../service/file';
import { NotLogin } from '../../../decorator/not.login';
import { ApiBody } from '@midwayjs/swagger';

@Provide()
@Controller('/file')
export class FileController {
  @Inject()
  fileService: FileService;
  @Inject()
  minioClient;

  @Post('/upload')
  @ApiBody({ description: 'file' })
  @NotLogin()
  async upload(@Files() files) {
    if (files.length) {
      return await this.fileService.upload(files[0]);
    }
    return {};
  }
}

实战,实现上传头像功能

前端头像上传

封装前端头像上传组件,这里使用了antd-img-crop头像剪裁组件。因为这个组件要在FormItem组件下使用,所以参数里有value,onChange参数。

// src/pages/user/avatar.tsx
import React from 'react';
import { PlusOutlined } from '@ant-design/icons';
import { Upload } from 'antd';
import type { UploadChangeParam } from 'antd/es/upload';
import type { RcFile, UploadFile, UploadProps } from 'antd/es/upload/interface';
import ImgCrop from 'antd-img-crop';
import { antdUtils } from '@/utils/antd';

const beforeUpload = (file: RcFile) => {
  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
  if (!isJpgOrPng) {
    antdUtils.message?.error('文件类型错误');
  }
  const isLt2M = file.size / 1024 / 1024 < 2;
  if (!isLt2M) {
    antdUtils.message?.error('文件大小不能超过2M');
  }

  if (!(isJpgOrPng && isLt2M)) {
    return Upload.LIST_IGNORE;
  }

  return true;
};

interface PropsType {
  value?: UploadFile[];
  onChange?: (value: UploadFile[]) => void;
}

const Avatar: React.FC<PropsType> = ({
  value,
  onChange,
}) => {

  const handleChange: UploadProps['onChange'] = (info: UploadChangeParam<UploadFile>) => {
    if (onChange) {
      onChange(info.fileList);
    }
  };

  const onPreview = async (file: UploadFile) => {
    const src = file.url || file?.response?.filePath;
    if (src) {
      const imgWindow = window.open(src);

      if (imgWindow) {
        const image = new Image();
        image.src = src;
        imgWindow.document.write(image.outerHTML);
      } else {
        window.location.href = src;
      }
    }
  };


  return (
    <ImgCrop showGrid rotationSlider showReset>
      <Upload
        name="avatar"
        listType="picture-card"
        className="avatar-uploader"
        action="/api/file/upload"
        onChange={handleChange}
        fileList={value}
        beforeUpload={beforeUpload}
        onPreview={onPreview}
      >
        {(value?.length || 0) < 1 && <PlusOutlined />}
      </Upload>
    </ImgCrop>
  );
};

export default Avatar;

在表单中使用这个组件 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 表单提交后,把附件id传到后台 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 编辑时给默认值 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 这里的代码平平无奇,没啥可说的。

后端头像上传功能实现

改造创建用户的方法,如果添加用户时上传了头像,根据当前文件id更新pkName和pkValue字段数据。pkValue时当前用户id,pkName是类型,建议用表名_字段名。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 编辑用户时稍微复杂一点 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 改造分页查询方法,把当前用户表和file表关联查询,把查询出来的file信息映射到user的avatarEntity字段上。这里可以使用typeorm的关联关系,查询用户的时候会自动把头像信息查出来,这种对于新手很友好,上手简单,基本不用学习sql。但是这种方式会自动给创建外键,现在很多公司都不推荐使用外键了,所以我这里都使用join自己去查。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

使用定时任务清除脏数据

midway内置了任务队列组件。

安装组件

pnpm i @midwayjs/bull@3

src/configuration.ts文件中导入组件 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 因为任务队列依赖redis,所以需要在配置中配置redis信息 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 创建src/queue/clear.file.ts文件

// src/queue/clear.file.ts
import { Processor, IProcessor } from '@midwayjs/bull';
import { Inject } from '@midwayjs/core';
import { FileService } from '../module/file/service/file';

// 每天凌晨00:00:00定时执行下面清理文件的方法
@Processor('clear_file', {
  repeat: {
    cron: '0 0 0 * * *',
  },
})
export class ClearFileProcessor implements IProcessor {
  @Inject()
  fileService: FileService;

  async execute() {
    // 调用文件服务里清理文件方法
    this.fileService.clearEmptyPKValueFiles();
  }
}

邮箱验证

前言

一般后台管理系统不会开放注册功能,很多都是管理员给员工开通帐号,开通完帐号后,随机生成一个密码,然后通过邮箱发送给当前用户,员工收到邮件就可以登录系统了。这里面有个问题,万一管理员手滑了,邮箱写错了发送给了别人,别人知道了帐号密码就能登录系统了。所以我们在添加用户的时候,先给员工发一个验证码,然后员工把验证码发给管理员,管理员填写验证码才能添加用户,这样就防止手滑写错邮箱的问题了。

开通个人邮箱服务

想发送邮件,需要先开启邮箱服务。下面我以qq邮箱为例开通邮件服务。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 这里开启后会给密钥记得要存起来,后面会用到。

封装发邮件的公共服务

创建src/common/mail.service.ts文件,使用nodemailer这个库去发送邮件。

// src/common/mail.service.ts
import { Config, Provide, Singleton } from '@midwayjs/core';
import * as nodemailer from 'nodemailer';
import { MailConfig } from '../interface';

interface MailInfo {
  // 目标邮箱
  to: string;
  // 标题
  subject: string;
  // 文本
  text?: string;
  // 富文本,如果文本和富文本同时设置,富文本生效。
  html?: string;
}

@Provide()
@Singleton()
export class MailService {
  @Config('mail')
  mailConfig: MailConfig;

  async sendMail(mailInfo: MailInfo) {
    const transporter = nodemailer.createTransport(this.mailConfig);
    // 定义transport对象并发送邮件
    const info = await transporter.sendMail({
      from: this.mailConfig.auth.user, // 发送方邮箱的账号
      ...mailInfo,
    });
    return info;
  }
}

在配置文件中添加邮箱服务器配置 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

  • host:邮箱服务器地址,qq邮箱是smtp.qq.com
  • port:邮箱服务器端口号,qq邮箱是465
  • secure:表示使用安全连接
  • auth.user:服务器的邮箱账号
  • auth.pass:上面生成的密钥

实战,实现添加用户时邮箱验证

前端实现

封装一个带有邮箱输入框和计时器的表单组件,代码很简单,我就不详细说了。

import { useRequest } from '@/hooks/use-request';
import { Button, Form, Input } from 'antd';
import React, { ChangeEventHandler, useEffect, useRef, useState } from "react";
import userService from './service';

interface PropsType {
  value?: string;
  onChange?: ChangeEventHandler;
  disabled?: boolean;
}

const EmailInput: React.FC<PropsType> = ({
  value,
  onChange,
  disabled,
}) => {

  const [timer, setTimer] = useState<number>(0);
  const form = Form.useFormInstance();
  const intervalTimerRef = useRef<number>();

  const { runAsync } = useRequest(userService.sendEmailCaptcha, { manual: true });

  const sendEmailCaptcha = async () => {
    const values = await form.validateFields(['email']);
    setTimer(180);

    await runAsync(values.email);

    intervalTimerRef.current = window.setInterval(() => {
      setTimer(prev => {
        if (prev - 1 === 0) {
          window.clearInterval(intervalTimerRef.current);
        }
        return prev - 1;
      });
    }, 1000);
  }


  useEffect(() => {
    return () => {
      if (intervalTimerRef.current) {
        window.clearInterval(intervalTimerRef.current);
      }
    }
  }, []);


  return (
    <div className='flex items-center gap-[12px]'>
      <Input disabled={disabled} onChange={onChange} value={value} className='flex-1' />
      {!disabled && (
        <Button
          disabled={timer > 0}
          onClick={sendEmailCaptcha}>
          {timer > 0 ? `重新发送(${timer}秒)` : '发送邮箱验证码'}
        </Button>
      )}
    </div>
  )
}

export default EmailInput;

在表单中添加刚加的组件和添加一个邮箱验证码输入框 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 效果展示 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

后端实现

在user controler中添加一个发送邮箱验证码的接口 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 效果 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 改造添加用户方法,先校验邮箱验证码对不对,然后随机生成一个密码加盐保存到数据库中,最后把帐号和密码发送给对应的用户。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

修改密码

前言

可以看到上面生成密码很难记,不可能用户每次登录都看一下邮箱,所以增加了修改密码的功能。 正好我前两天在掘金重置密码,看到掘金重置密码发送邮件的弹框有点意思,这里就拿来用用。(如果不能用,可以联系我删除。)

掘金的效果图,注意看上面的图片

邮箱输入框未获到焦点 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 邮箱输入框获取到焦点时 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 这种交互挺有意思的,为掘金的设计人员点个赞。

前端代码实现

雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 代码实现很简单,搞两张图片,加一个标记输入框是否获取到焦点的变量,获取到焦点显示一张图片,失去焦点显示另外一张图片。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 在做一个修改密码的页面,用户在邮件中点击链接重定向到这个页面修改密码,url上会带两个参数一个是邮箱,另外一个是邮箱验证码,这两个参数是后端发送邮件时注入到url上的。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

后端实现

在auth controller中添加一个发送重置邮箱验证码的接口,因为这个接口不需要登录也能调用,所以加了NotLogin装饰器。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 邮件内容展示 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 添加修改密码接口。实现比较简单,先校验邮箱和邮箱验证码,然后再把前端传过来的密码加盐更新到当前用户。这里有个小细节,用户修改完密码,需要把当前用户以前颁发过的token和refreshToken全部删除掉,然后让用户重新登录。这样子的话,需要在登录的时候把当前颁发的token和refreshToken存起来。redis的smembers方法获取某个key的数组值,redis的sadd方法往某个key里面添加一项。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 登录的时候,把当前token和refreshToken存起来 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

docker-compose中增加minio服务和邮箱服务配置

上篇写部署的时候,我其实已经把minio文件服务部署上去了。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 后端服务增加邮箱服务器配置 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

把路由方式从hash模式改成history模式

很简单把createHashRouter方法改成createBrowserRouter就行了 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六) 还需要改一下nginx配置,不然进入一个功能后,然后手动刷新一下页面就会404了。因为如果当前路由为https://fluxyadmin.cn/dashboard这个,刷新一下,相当于向nginx请求这个dashboard这个资源,这个资源当然不存在,所以我们需要改一下nginx配置,在请求不到资源的时候尝试加载根目录下的index.html文件。 雪花算法,附件方案,邮箱验证,修改密码。——从零开始搭建一个高颜值后台管理系统全栈框架(六)

后续项目功能清单

有不少兄弟对这个项目会实现哪些功能很好奇,这里和大家说一下。

  • 前端动态菜单、动态路由、按钮权限。
  • 后端接口权限
  • 项目实战、封装常用组件、可能会集成工作流。实战项目暂定人事管理系统。
  • 前端低代码平台
  • 使用前端低代码平台重构前面项目的前端部分
  • 后端低代码平台
  • 使用后端低代码平台重构前面做的实战项目后端接口部分
  • 低代码平台对接chatgpt,实现用户说个需求,就能实现一个功能或系统
  • 搭建框架文档平台
  • 编写框架脚手架

这些功能大部分我在公司里都已经实现过,所以大家不用担心做不出来。

总结

这篇文章我写的很糟心,这种实现业务功能的文章,其实没啥可写的,写了也没啥亮点。但是我这个专栏,主打的是从零开始,不可能绕过一些功能点不写的,因为有些刚入门的同学在跟着项目做,如果我漏了一些功能没写,然后后面突然冒出来一个功能,他们可能会感到疑惑。为了照顾这些人,我会把我这个框架做的功能都给写出来,无论大小,简单的功能我就写思路,复杂一点的会把核心代码贴出来。

写作不易,如果文章对你有帮助,就帮忙给个赞吧,你们赞和收藏就是我写文章的动力。

项目体验地址:fluxyadmin.cn/user/login

前端仓库地址:github.com/dbfu/fluxy-…

后端仓库地址:github.com/dbfu/fluxy-…