NestJs + TypeOrm + Ant Design Pro 搭建股票估值查询(三)
传送门
本章内容
- 请求参数服务端验证
-
JWT
权限认证 - 用户身份信息注入
- 定时任务同步数据
参数验证
上一篇实现了创建用户的
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: [