todoAdmin-像todoList一样简单又丰富的接口框架/nestjs教学/验证码开发(一)

119 阅读8分钟

《让验证码像todolist一样简单!我的todoAdmin首秀就整这个》

该项目参考开源项目ruoyi框架nestjs版nest-admin: nestjs全栈快速开发平台

image.png

"每个后台系统都要验证码?那就让它像TodoList加个Item一样简单!" —— 水逆中想到的产品哲学

〇、先放屁(前言)

经过一个月和 Nest.js 的肉搏(其实大多时间都在摸鱼),终于让 todoAdmin 的第一个功能模块验证码能像todoList一样自然呈现出来了。虽然现在:

  • 市面上已经有很多开源的企业级接口框架,但如果大家自行和我逐步开发出一套自己喜欢的可魔改的框架,对提升自身项目经验和nestjs开发能力也是大有益处的

我要的就是从马桶里捞出来的原创感(其实是为了后面权限系统铺垫)

👉 线上预览(www.jialun.top) | 👾 GitHub仓库
(用户名:admin 密码:你猜我要放什么屁(123456)) 当然,看了也没用,因为这篇只是给你介绍验证码模块的开发(我不会告诉你我现在只做了验证码这一个功能)

一、为什么选验证码当首发功能?

竞争对手实现方式我们的优势
xxx直接跳过这步安全与装逼我全都要
xxx直接跳过这步没有优势,拿着第三方库直接用就完了

真实场景暴击
上周有个大学生粉丝私信:"老师,我接的私单因为没验证码被甲方骂成狗..."

好的,说点正话,每次接点私活都想着重新开发一个通用的框架要么就去别人GitHub上拉取各种魔改,那何不自己开发一套通用框架出来使用呢?

二、技术放屁(实现思路)

1. 核心屁学四要素

  • 屁式代码结构(其实有严谨设计模式)
  • nestjs路由
  • nestjs装饰器
  • nestjs模块
  • nestjs提供器

这些概念如何关联起来呢?

首先我们需要用nestjs去创建一个nestjs项目

// 安装cli脚手架
npm i -g @nestjs/cli
// 创建项目
nest new project-name

你将会看见以下目录结构,有路由,有提供器以及main.js可以进行全局配置,spec为测试用,你可以直接删除 image.png 我们改吧改吧一下,就会变成这个样子

image.png

image.png 可以看见,我的app.controller中啥也没干就是自动生成的内容,这是因为我想让每个模块做每个模块自己的事情,最后将这些模块关联起来即可,省的最后都变成一坨,由此我们可以创建很多个文件夹,每个文件夹都是一个资源,每个资源中都会包含模块、路由、提供者以及dto等概念,例如我们接下来实现验证码功能,我们将创建sys模块

nest g mo sys // g其实是生成的简写 mo为模块的简写 s为提供者的简写
nest g s sys --no-spec // 不需要测试

如图:

image.png

import { Controller, Get, Post, Body, Inject, Query } from '@nestjs/common';
import { SysService } from './sys.service';
import { UserService } from '../user/user.service';
import { AllowNoToken } from '../common/decorators/token.decorator';
import { CreateUserDto } from '../user/dto/create-user.dto';
import { LoginUserDto } from './dto/login-user.dto';
import { ForgotUserDto } from './dto/forgot-user.dto';
import { ApiOperation } from '@nestjs/swagger';
import { ConfigService } from '../common/config/config.service';
import { createMath } from '../common/utils/captcha';
import { GenerateUUID } from '../common/utils';
import { RedisService } from '../common/redis/redis.service';
import { RedisKeyPrefix } from '../common/enums/redis-key.enum';
import { ResultData } from '../common/utils/result';

@Controller('sys')
export class SysController {
  @Inject(UserService)
  private userService: UserService;
  constructor(
    private readonly sysService: SysService,
    private readonly configService: ConfigService,
    private readonly redisService: RedisService,
  ) {}

  // 用户注册
  @Post('registry')
  @AllowNoToken()
  registry(@Body() createUserDto: CreateUserDto) {
    return this.userService.registry(createUserDto);
  }

  // 用户登录
  @Post('login')
  login(@Body() loginUserDto: LoginUserDto) {
    return this.userService.login(loginUserDto);
  }
  // 获取验证码图片
  @ApiOperation({
    summary: '获取验证图片',
  })
  @Get('/captchaImage')
  async captchaImage() {
    //是否开启验证码
    const enable = await this.configService.getConfigValue(
      'sys.account.captchaEnabled',
    );
    const captchaEnabled: boolean = enable === 'true';
    const data = {
      captchaEnabled,
      img: '',
      uuid: '',
    };
    try {
      if (captchaEnabled) {
        const captchaInfo = createMath();
        data.img = captchaInfo.data;
        data.uuid = GenerateUUID();
        await this.redisService.set(
          RedisKeyPrefix.CAPTCHA_CODE_KEY + data.uuid,
          captchaInfo.text.toLowerCase(),
          1000 * 60 * 5,
        );
      }
      return ResultData.ok(data, '操作成功');
    } catch (err) {
      return ResultData.fail(500, '生成验证码错误,请重试');
    }
  }

  // 发送注册邮箱验证码
  @Get('sendEmailForRegistry')
  @AllowNoToken()
  sendEmailForRegistry(@Query() dto: { email: string }) {
    return this.sysService.sendMailForRegistry(dto.email, '注册验证码');
  }

  // 找回密码
  @Post('forgot')
  @AllowNoToken()
  forgot(@Body() forgotUserDto: ForgotUserDto) {
    return this.userService.updatePassword(forgotUserDto);
  }
  // 发送找回密码邮箱验证码
  @Get('sendEmailForGorgot')
  @AllowNoToken()
  sendEmailForGorgot(@Query() dto: { email: string }) {
    return this.sysService.sendEmailForGorgot(dto.email);
  }

  //
  // // 上传文件
  // @Post('upload')
  // @UseInterceptors(FileInterceptor('file'))
  // uploadFile(
  //   @UploadedFile() file: Express.Multer.File,
  //   @Body() data: { type: string },
  // ) {
  //   return this.sysService.upload(file, data.type);
  // }
}
  • sys为路由前缀,你也可以理解为分组,带这sys这个前缀的路由有以下路由...例如/sys/login
  • 今天我们聚焦在验证码模块的实现
  • /sys/captchaImage,当前端访问该路径,则会触发captchaImage函数,该路由由configService这个提供者提供服务,这就是路由与提供者的概念,controller负责定义路由,而提供者/服务者提供具体服务
  • 接下来我们就可以看看服务者如何实现
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ResultData } from 'src/common/utils/result';
import { SysConfigEntity } from './entities/config.entity';
import { Cacheable } from 'src/common/decorators/redis.decorator';
import { RedisKeyPrefix } from '../enums/redis-key.enum';

@Injectable()
export class ConfigService {
  constructor(
    @InjectRepository(SysConfigEntity)
    private readonly sysConfigEntityRep: Repository<SysConfigEntity>,
  ) {}

  async findOne(configId: number) {
    const data = await this.sysConfigEntityRep.findOne({
      where: {
        configId: configId,
      },
    });
    return ResultData.ok(data);
  }

  /**
   * 根据配置键值异步查找一条配置信息。
   *
   * @param configKey 配置的键值,用于查询配置信息。
   * @returns 返回一个结果对象,包含查询到的配置信息。如果未查询到,则返回空结果。
   */
  @Cacheable(RedisKeyPrefix.SYS_CONFIG_KEY, '{configKey}')
  async getConfigValue(configKey: string) {
    const data = await this.sysConfigEntityRep.findOne({
      where: { configKey: configKey },
    });
    return data?.configValue;
  }
}

该服务首先自定义了一个方法装饰器Cacheable用于缓存,装饰器返回一个函数,所以可以让你调用并携带参数,然后该装饰器通过RedisService提供服务,事实上就是连接了redis并存储,如果大家对nestjs不熟悉可以先忽略这个方法装饰器,因为没有他项目也是可以正常运行的

import { Inject } from '@nestjs/common';

import { paramsKeyFormat } from '../../utils/decorator';
import { RedisService } from '../redis/redis.service';

export function Cacheable(
  CACHE_NAME: string,
  CACHE_KEY: string,
  CACHE_EXPIRESIN?: number,
) {
  const injectRedis = Inject(RedisService);

  return function (
    target: any,
    propertyKey: string,
    descriptor: PropertyDescriptor,
  ) {
    injectRedis(target, 'redis');

    const originMethod = descriptor.value;

    descriptor.value = async function (...args: any[]) {
      const key = paramsKeyFormat(originMethod, CACHE_KEY, args);

      if (key === null) {
        return await originMethod.apply(this, args);
      }

      const cacheResult = await this.redis.get(`${CACHE_NAME}${key}`);

      if (!cacheResult) {
        const result = await originMethod.apply(this, args);

        await this.redis.set(`${CACHE_NAME}${key}`, result, CACHE_EXPIRESIN);

        return result;
      }

      return cacheResult;
    };
  };
}

然后该服务注入一个仓库,这个概念其实就是typeorm(定义实体操作数据库),告知仓库需要操作哪个实体,该实体其实对应的就是数据库中的某张数据库表,仓库拥有操作数据库的方法,例如findall,查询全部,findone查询一个,只需要用typeorm语法(where xxx = xxx)类似sql语法

通过查询数据库返回true

如果是true,则通过createMath方法创建验证码,并将验证码缓存起来。

async captchaImage() {
  //是否开启验证码
  const enable = await this.configService.getConfigValue(
    'sys.account.captchaEnabled',
  );
  // true
  const captchaEnabled: boolean = enable === 'true';
  const data = {
    captchaEnabled,
    img: '',
    uuid: '',
  };
  try {
    if (captchaEnabled) {
      const captchaInfo = createMath();
      data.img = captchaInfo.data;
      data.uuid = GenerateUUID();
      await this.redisService.set(
        RedisKeyPrefix.CAPTCHA_CODE_KEY + data.uuid,
        captchaInfo.text.toLowerCase(),
        1000 * 60 * 5,
      );
    }
    return ResultData.ok(data, '操作成功');
  } catch (err) {
    return ResultData.fail(500, '生成验证码错误,请重试');
  }

那么事实上也是通过svg-captcha包提供的方法得到验证码

import * as svgCaptcha from 'svg-captcha';

const options = {
  // 验证码字符集,可以是字母、数字或者组合
  charPreset: '0123456789QWERTYUIOPSDFGHJKLAZXCVBNMzxcvbnmasdfghjklqwertyuiop',
  // 验证码长度
  size: 4,
  // 验证码字体大小
  fontSize: 60,
  // 验证码图像宽度
  width: 100,
  // 验证码图像高度
  height: 40,
  // 干扰线的数量
  noise: 2,
  // 验证码背景颜色
  background: '#ffffff',
  // 验证码文字颜色
  // color: "#33ccff",
  // 验证码文字倾斜度
  rotate: 15,
  // 验证码字符间距
  letterSpacing: 0,
  // 验证码噪点的颜色
  noiseColor: '#000000',
  // 验证码噪点的透明度
  opacity: 0.1,
  // 验证码噪点的密度
  pointSize: 1,
  // 验证码噪点的样式,可以是 'circle' 或 'line'
  pointStyle: 'circle',
  // 验证码噪点的半径
  pointRadius: 2,
  // 验证码噪点的位置,可以是 'random' 或 'top' 或 'left' 或 'right' 或 'bottom'
  pointPosition: 'random',
};

export function createMath() {
  return svgCaptcha.createMathExpr({
    ...options,
    mathMin: 1,
    mathMax: 50,
    mathOperator: '+',
  });
}

export function createText() {
  return svgCaptcha.create(options);
}

此时有小伙伴要问了,那你直接去调用这个方法不就可以了吗,干嘛还要去判断true呢?其实这就是我们做的一层配置了,用户可以直接配置数据库中对应的字段为false以关闭验证码功能(如果嫌麻烦)这就是为了配置项的灵活性,允许大家在不改变代码的情况下通过配置文件来控制应用的行为

2. 模块关联

目前功能已经实现,但是我们知道,这只是完成了sys模块,目前的sys与我们的app.module之间还没有任何关联,所以代码还未生效。

因此接下来我们可以在app模块中引入sys模块以产生关联,同时配置好mysql

@Module({
  imports: [
    UserModule,
    SysModule,
    LoggerModule,
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        return {
          type: 'mysql',
          host: config.get<string>('MYSQL_HOST'),
          port: config.get<number>('MYSQL_PORT'),
          username: config.get<string>('MYSQL_USER'),
          password: config.get<string>('MYSQL_PASSWORD'),
          database: config.get<string>('MYSQL_DATABASE'),
          entities: [__dirname + '/**/*.entity{.ts,.js}'],
          charset: 'utf8mb4',
          autoLoadEntities: true,
          // 生产环境中禁止开启,应该使用数据迁移
          synchronize: true,
        };
      },
    }),

当然由于sys模块自身也和其他模块存在关联,因此也需要在sys.module中引入其他模块并导出用到的一些服务以供内部使用,例如引入redis模块,在redis模块中需要配置redis参数,与上述mysql配置类似

三、效果放送

image.png

四、怎么用这个屁?

安装与使用

1. 前后端代码拉取,前端为admin-vue3,后端为todoAdmin
2. 前端依赖安装
npm i 
3. 后端依赖安装
pnpm i
4. 前端项目启动
npm run dev
5. 后端项目启动(热启动)
pnpm run start:dev

五、下个屁预告

"既然验证码都有了,下次教你们怎么用 todoAdmin 白嫖甲方——权限系统/中间件拦截器/日志管理等开发实录"

互动彩蛋
在评论区用「放屁体」说出你最讨厌的验证码类型,抽3位送逗你玩的

当然如果你有想开发的板块,也可以在评论区留言,让其真正成为用户心中像todoList一样简单又丰富的接口框架。

如当前开发功能:你的验证码支持自定义字体吗?