添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

NestJs + TypeOrm + Ant Design Pro 搭建股票估值查询(三)

传送门

本章内容

  1. 请求参数服务端验证
  2. JWT 权限认证
  3. 用户身份信息注入
  4. 定时任务同步数据

参数验证

上一篇实现了创建用户的 api ,但是对于输入的参数没有做任何的约束,例如 mobile 字段,除了不能为空以外还需要满足手机号码的格式, libs 目录下创建管道:

$ nest g pi libs/pipes/params-validation

安装依赖所需要的 class-validator 包:

$ npm i class-validator class-transformer -S

修改 params-validation.pipe.ts 模板代码:

import {
  ArgumentMetadata,
  HttpStatus,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { BusiException } from '../filters/busi.exception';
import { BusiErrorCode } from '../enums/error-code-enum';
import { validate } from 'class-validator';
@Injectable()
export class ParamsValidationPipe implements PipeTransform {
  async transform(value: any, metadata: ArgumentMetadata) {
    const { metatype } = metadata;
    // 如果参数不是 类 而是普通的 JavaScript 对象则不进行验证
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    // 通过元数据和对象实例,去构建原有类型
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      console.log('错误提示:', JSON.stringify(errors));
      // 获取到第一个没有通过验证的错误对象
      const error = errors.shift();
      // 将未通过验证的字段的错误信息和状态码,以BusiException的形式抛给全局异常过滤器
      for (const key in error.constraints) {
        throw new BusiException(
          BusiErrorCode.PARAM_ERROR,
          error.constraints[key],
          HttpStatus.BAD_REQUEST,
    return value;
  private toValidate(metatype): boolean {
    const types = [String, Boolean, Number, Array, Object];
    return !types.find((type) => metatype === type);
}

main.ts 中全局注册:

import { NestFactory } from '@nestjs/core';
import { TransformInterceptor } from './libs/interceptors/data.interceptor';
import { HttpExceptionFilter } from './libs/filters/http-exception.filter';
import { ParamsValidationPipe } from './libs/pipes/params-validation.pipe';
import { AppModule } from './app.module';
async function bootstrap() {
  app.useGlobalPipes(new ParamsValidationPipe());
bootstrap();

user.dto.ts 中定义 CreateUserDto 入参约束:

import { IsString, IsNotEmpty, IsMobilePhone } from 'class-validator';
import { BusiErrorCode } from '../../../libs/enums/error-code-enum';
 * 创建用户
export class CreateUserDto {
  @IsNotEmpty({
    message: '姓名不能为空',
    context: { errorCode: BusiErrorCode.PARAM_ERROR },
  @IsString({
    message: '姓名必须是字符串',
    context: { errorCode: BusiErrorCode.PARAM_ERROR },
  readonly name: string;
  @IsNotEmpty({
    message: '手机号不能为空',
    context: { errorCode: BusiErrorCode.PARAM_ERROR },
  @IsMobilePhone('zh-CN')
  readonly mobile: string;
  @IsNotEmpty({
    message: '密码不能为空',
    context: { errorCode: BusiErrorCode.PARAM_ERROR },
  readonly password: string;
}

运行程序,访问: http://localhost:3000/user/create POST Content-Type:application/json ,参数:

{
    "name":"",
    "mobile":"18888888888",
    "password":"123456"
}

返回值如下,参数验证生效(可自行修改 mobile , password 测试相关规则)

{
    "code": 10000,
    "message": "姓名必须是字符串",
    "data": null,
    "date": "2021/3/28",
    "path": "/user/create"
}

权限认证

目前为止,所有api都是可以匿名访问的,实际业务场景则可能是:用户使用账号密码登陆,获得身份令牌,携带令牌访问其他api,如果访问到没有权限的api则返回错误提示。 modules 目录下新建 auth 模块及其守卫:

$ nest g mo modules/auth
$ nest g s modules/auth
$ nest g gu modules/auth/guards/role

role.guard.ts 修改为如下代码:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}
  canActivate(context: ExecutionContext): boolean {
    const roles = this.reflector.get<string[]>('roles', context.getHandler());
    if (!roles) {
      return true;
    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const hasRole = () => user.roles.some((role) => roles.includes(role));
    return user && user.roles && hasRole();
}

安装相关依赖:

$ npm i passport passport-local passport-jwt @nestjs/passport @nestjs/jwt -S

src 目录下创建 config 目录,添加 jwt-key.ts (项目中推荐使用环境变量):

export const jwtConstants = {
  secret: 'your jwt secret',
};

user.service.ts 添加使用手机号查询用户方法:

@Injectable()
export class UserService {
  async findByMobile(mobile: string): Promise<StUser> {
    const result = await this.stUserRepository.findOne({ mobile });
    return result;
}

方便起见,使用两组固定角色,普通用户和管理员角色, libs/enums 目录下添加 role-enum.ts :

export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
}

修改 auth.service.ts 中模板代码:

import { Injectable } from '@nestjs/common';
import { UserService } from '../user/user.service';
import { JwtService } from '@nestjs/jwt';
import { UserRole } from '../../libs/enums/role-enum';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const bcrypt = require('bcryptjs');
@Injectable()
export class AuthService {
  constructor(
    private readonly usersService: UserService,
    private readonly jwtService: JwtService,
  async validateUser(mobile: string, pass: string): Promise<any> {
    const user = await this.usersService.findByMobile(mobile);
    if (user) {
      const userpwd = bcrypt.hashSync(pass, user.salt);
      if (user.password === userpwd) {
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        const { password, ...result } = user;
        return result;
    return null;
  async login(user: any) {
    let roles = [];
    if (user.role === 100) {
      roles = [UserRole.ADMIN];
    } else {
      roles = [UserRole.USER];
    const payload = {
      id: user.id,
      mobile: user.mobile,
      sub: user.id,
      roles,
      name: user.name,
      avatar: user.avatar,
    return {
      id: user.id,
      name: user.name,
      mobile: user.mobile,
      roles,
      avatar: user.avatar,
      accessToken: this.jwtService.sign(payload),
}

modules/auth 目录下新建 strategies 目录,添加两个文件 local.strategy.ts jwt.strategy.ts ,添加如下代码:

// local.strategy.ts 
import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, HttpStatus } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { BusiException } from '../../../libs/filters/busi.exception';
import { BusiErrorCode } from '../../../libs/enums/error-code-enum';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly authService: AuthService) {
    super();
  async validate(username: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(username, password);
    if (!user) {
      throw new BusiException(
        BusiErrorCode.PWD_ERROR,
        '账号或者密码错误',
        HttpStatus.OK,
    return user;
// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from '../../../config/jwt-key';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
  async validate(payload: any) {
    return {
      uid: payload.sub,
      mobile: payload.mobile,
      roles: payload.roles,
      name: payload.name,
      avatar: payload.avatar,
}

app.controller.ts 内添加用户登录接口:

import { Controller, Post, UseGuards, Request } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './modules/auth/auth.service';
@Controller()
export class AppController {
  constructor(private readonly authService: AuthService) {}
  // @Get()
  // getHello(): string {
  //   return this.authService.getHello();
  // 登录
  @UseGuards(AuthGuard('local'))
  @Post('login')
  async login(@Request() req) {
    return this.authService.login(req.user);
}

修改 auth.module.ts 相关 JWT 依赖:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UserModule } from '../user/user.module';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from '../../config/jwt-key';
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
  providers: [AuthService, LocalStrategy, JwtStrategy],
  imports: [
    UserModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '86400s' }, // 一天过期时间
  exports: [AuthService],
export class AuthModule {}

运行程序,访问: http://localhost:3000/login ,参数如下:

{
    "username":"18888888888",
    "password":"123456"
}

返回结果:

{
    "data": {
        "id": 7,
        "name": "少年",
        "mobile": "18888888888",
        "roles": [
            "user"
        "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NywibW9iaWxlIjoiMTg4ODg4ODg4ODgiLCJzdWIiOjcsInJvbGVzIjpbInVzZXIiXSwibmFtZSI6IuWwkeW5tCIsImlhdCI6MTYxNjk0ODM5NCwiZXhwIjoxNjE3MDM0Nzk0fQ.KSPRyF2Oo9oPB2uJOsTIzb2BQK1FHUJpiI9j3g4trMg"
    "code": 0,
    "message": "success"
}

说明登录成功,返回了所需的 token ,此时创建用户接口依然是可以匿名访问的, 将创建用户接口调整为只有管理员 [admin] 角色的用户才能访问,创建 role 装饰器:

$ nest g d libs/decorators/role

修改为如下代码:

import { SetMetadata } from '@nestjs/common';
export const Roles = (...args: string[]) => SetMetadata('roles', args);

修改 user.controller.ts 代码,为类和创建用户接口添加权限装饰器:

import {
  Controller,
  Body,
  Post,
  HttpStatus,
  UseGuards,
} from '@nestjs/common';
import { BusiException } from '../../libs/filters/busi.exception';
import { BusiErrorCode } from '../../libs/enums/error-code-enum';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/user.dto';
import { Roles } from '../../libs/decorators/role.decorator';
import { RolesGuard } from '../auth/guards/role.guard';
import { UserRole } from '../../libs/enums/role-enum';
@Controller('user')
@UseGuards(AuthGuard('jwt'))
export class UserController {
  @UseGuards(RolesGuard)
  @Roles(UserRole.ADMIN)
  @Post('create')
  async create(@Body() user: CreateUserDto) {
    return this.userService.create(user);
}

运行程序,访问: http://localhost:3000/user/create 参数:

{
    "name":"小明",
    "mobile":"13888888888",
    "password":"123456"
}

返回结果:

{
    "code": 403,
    "message": "Forbidden resource",
    "data": null,
    "date": "2021/3/29",
    "path": "/user/create"
}

成功终止了没有权限的访问,修改请求头,添加 Authorization 属性,值为 Bearer xxx 其中 xxx 为登陆接口返回的 accessToken 值,再次发送请求,依然是 403 Forbidden 成功验证非 admin 用户没有访问权限。按照我们创建数据表时的约定, admin 用户的 role 100 ,查询数据库表:

mysql> select * from st_user

发现用户小明的 role 0 ,修改为 100 提交保存,使用 18888888888 账号重新登陆,返回结果:

{
    "data": {
        "id": 7,
        "name": "少年",
        "mobile": "18888888888",
        "roles": [
            "admin"
        "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6NywibW9iaWxlIjoiMTg4ODg4ODg4ODgiLCJzdWIiOjcsInJvbGVzIjpbImFkbWluIl0sIm5hbWUiOiLlsJHlubQiLCJpYXQiOjE2MTY5NTAxOTYsImV4cCI6MTYxNzAzNjU5Nn0.61pMxFXKvmJzw7TEAE5AI_c0Rl-h56-hWsGOF4Clxv4"
    "code": 0,
    "message": "success"
}

roles 此时已显示为 admin ,使用新的 accessToken 再次访问创建用户接口,返回结果:

{
    "data": {
        "id": 9
    "code": 0,
    "message": "success"
}

至此权限验证模块完成。

身份信息

实际应用中,绝大部分请求都是需要结合用户身份信息进行查询的,比如电商场景“我的订单”,“浏览记录”等,而这个身份信息是可以从请求上下文获取的,创建用户身份信息装饰器:

$ nest g d libs/decorators/user

修改生成的模板代码:

import { createParamDecorator } from '@nestjs/common';
export const User = createParamDecorator((data, req) => {
  const request = req.switchToHttp().getRequest();
  return request.user;
});

libs/decorators 目录下创建用户身份信息类型文件 profileInfo.ts

export class ProfileInfo {
  mobile: string;
  uid: number;
  name: string;
  roles: string[];
  avatar: string;
}

修改 app.controller.ts getHellow 方法,使用上一步创建的 User 装饰器获取身份信息:

import { Controller, Post, UseGuards, Request, Get } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { AuthService } from './modules/auth/auth.service';
import { ProfileInfo } from './libs/decorators/profileInfo';
import { User } from './libs/decorators/user.decorator';
@Controller()
export class AppController {
  @UseGuards(AuthGuard('jwt'))
  @Get('hello')
  async getHellow(@User() user: ProfileInfo) {
    return `${user.name},你好!`;
}

使用登陆获得的 accessToken 访问 http://localhost:3000/hello ,返回结果:

{
    "data": "少年,你好!",
    "code": 0,
    "message": "success"
}

定时任务

NestJs的定时任务使用 @Cron() 装饰器,定时规则和标准 crontab 相似,不同在于多了一位精度到秒,安装依赖:

$ npm i @nestjs/schedule -S

创建同步股票信息的定时任务 service

$ nest g s modules/stock/tasks/sync-source

将生成的 sync-source.service.ts 移动到外层 tasks 目录下。 src 目录下创建 utils 目录,在其中创建 common.ts 文件,用户存放通用工具函数:

import * as dayjs from 'dayjs';  // npm i dayjs -S
export const getOneDayTimeStamp = () => {
  const date = new Date();
  const time = date.getTime(); //当前的毫秒数
  const oneDay = 86400000; //1000 * 60 * 60 * 24; //一天的毫秒数
  return time + oneDay;
// 休眠函数
export const sleepPromise = (ms) =>
  new Promise((resolve) => setTimeout(resolve, ms));
 * 返回当前时间
export const currentDateTime = () => {
  return dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss');
 * 返回若干年、月、日、前的日期,默认:day 格式 20210101
export const getDateOfBefore = (
  n: number,
  scale: 'day' | 'month' | 'year' = 'day',
) => {
  return dayjs().subtract(n, scale).format('YYYYMMDD');
};

股票估值查询的思路是:每天同步最新的PE等数据存储到 stock_log 表,然后进行一次计算得出最新的最近5年PE平均值,以及最新PE占历史平均PE值的百分位,通过排序功能快速查询到用户关注的股票的估值历史水平。由于目前PE数据来源不稳定,在创建数据库脚本中直接插入了一些测试数据进行演示,此处只演示定时计算平均值的功能,修改 sync-source.service.ts

import { Injectable, Logger, HttpService } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
@Injectable()
export class TasksService {
  @Cron('30 * * * * *')
  async taskDemo() {
    console.log('每分钟第30秒执行一次');
   * 获取第三方pe数据略
}

创建 stock module

$ nest g mo modules/stock

调整 stock.module.ts 中代码:

import { Module, HttpModule } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StUser } from '../../entities/StUser';
import { Stock } from '../../entities/Stock';
import { TasksService } from './tasks/sync-source.service';
@Module({
  imports: [TypeOrmModule.forFeature([StUser, Stock]), HttpModule],
  providers: [TasksService],
export class StockModule {}

app.module.ts 中注入 ScheduleModule 依赖:

...
import { ScheduleModule } from '@nestjs/schedule';
@Module({
  imports: [