nodejs + koa2 + ts + mysql + redis + vue 重构若依后台管理系统角色权限设计

4/25/2022 nodejsvue.jsmysqlrediskoa2

# 前言

具有用户、角色、权限等概念的系统、网站或者项目, 抛开业务功能, 它们本质上都有一个角色权限系统, 这是系统的地基.

若依的权限管理系统, 主要有角色管理、菜单管理和用户管理3部分, 每个用户都有自己的角色, 每个角色都有自己的菜单和按钮权限, 这是用户看得到的部分.在用户看不到的地方, 还有角色的接口权限.

在了解若依权限逻辑后, 用koa2 + Ts和vue + Antdv重构了其角色权限系统

# 项目开发

# 1. 前端

前端部分我主要是使用antdv pro去实现. 主要有token, 动态路由, v-action(控制按钮显示隐藏的自定义指令), 具体内容可以参考Antdv Pro官网

# 2. 后端

# 数据库设计

  1. mysql

数据库建表主要有角色表role, 菜单表menu, 用户表user等.

  • 每一个用户user都有一个角色id role_id;
  • 每一个角色role都有一个权限的集合字段 permissions, 这个集合里包含该角色拥有的所有权限id(以逗号分隔的id字符串);
  • 每个角色都拥有父级角色 parent_id, 角色的权限可继承, 即每个角色的权限等于自己权限和自己所有祖宗角色权限的并集;
  • 角色的权限体现在菜单分类、菜单、按钮的操作权限和接口的访问权限上, 这些都保存在菜单表menu的记录里, 角色role的权限的集合字段 permissions的数据即是多条menu的id的集合, 通过 type字段区分菜单分类、菜单和按钮, 每个菜单(即页面)和按钮都有自己的权限标识 permission, 通过权限标识来控制菜单的接口和按钮的接口访问权限, 以及按钮在前端的操作权限;
  • menu表的记录分为分类、菜单和按钮, 每个menu都有父级 parent_id, 分类的父级只能是分类, 菜单的父级只能是分类, 按钮的父级只能菜单.通过父子关系, 就能组合成改角色的前端路由
  1. redis

redis主要用来缓存token和角色

  • token以字符串类型保存, 以token为key, userId作为值
  • 角色以hash类型保存, 每一个角色都是一个对象, 角色id作为key, 每个值里有idparentId, 和permission, 通过id和parentId确定角色的父子关系, 通过permission保存该角色的权限标识, 通过权限标识来判断该该角色是否可以访问某个接口

# 目录介绍

src
+ api 存放接口
+ common 公共资源
+ apiValidate 接口参数json配置, 配置每个接口参数的数据类型等
+ lib 常量等
+ typings 类型声明
+ utils 工具函数
+ config 系统配置, 数据库配置等
+ core 核心静态类
+ HttpException http异常
+ Init 项目初始化入口
+ MongoDB MongoDB封装
+ Mysql Mysql封装
+ Redis 封装
+ middlewares 中间件
+ Auth 用户认证相关中间件, 校验token是否合法, 权限是否满足
+ code 验证码校验中间件
+ exception 全局异常监听和处理中间件
+ logs 日志中间件
+ security 安全中间件 限制ip访问次数等
+ upload 文件上传中间件
+ validator 接口请求参数json校验中间件
+ public 静态资源目录
+ service 公共业务
+ app.ts 入口文件

# 基础服务搭建

# 1. 项目创建

  1. 创建包管理文件
# 生成package.json
npm init -y

新建src文件夹, 在src根目录新建app.ts文件, app.ts就是整个项目的入口

  1. 安装依赖
# 依赖
npm i koa koa-bodyparser koa-router koa-session koa-static koa2-cors log4js mongodb mysql svg-captcha validator ajv ioredis jsonwebtoken
# 依赖注解
npm i --save-dev @types/koa @types/koa-bodyparser @types/koa-router @types/koa-session @types/koa-static @types/koa2-cors @types/log4js @types/mongodb @types/mysql @types/validator @types/ajv @types/ioredis @types/jsonwebtoken

  1. 初始化ts配置
# 生成tsconfig.json
tsc --init

修改tsconfig.json

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2015",  // 目标语言版本
    "module": "commonjs", // 指定生成代码的模板标准
    "rootDir": "./", // 指定输出目录, 默认是dist文件夹
    "strict": true, // 严格模式
    "allowSyntheticDefaultImports": true, // 没有默认导出时, 编译器会创建一个默认导出
    "esModuleInterop": true, // 允许export= 导出, 由import from导入
    "forceConsistentCasingInFileNames": true // 强制区分大小写 
  },
  "include": [ // 需要编译的的文件和目录
    "src/**/*"
  ],
  "files": [
    "src/app.ts"
  ]
}

  1. 运行项目

在app.ts里实例化一个koa服务器

// src/app.ts

// 引入koa
import Koa from 'koa'
import http from 'http'
// 创建koa实例
const app = new Koa()
// 创建服务器
const server: http.Server = new http.Server(app.callback())
// 中间件
app.use(async (ctx) => {
  ctx.body = 'Hello World'
})
// 监听端口
app.listen(9000, () => {
  console.log('run success')
  console.log('app started at port 9000...')
})

命令行输入

# 编译ts文件
tsc
# 运行编译的文件
node ./dist/src/app.js

命令行会打印

run success
app started at port 9000...

我们可以在浏览器访问 http://localhost:9000/
此时页面上应该会显示 Hello World

每次都需要执行编译和运行, 太麻烦, 我们可以在修改package.json, 添加dev命令

// package.json
{
  ...

  "scripts": {
    "dev": "tsc && node ./dist/src/app.js"
  },
  ...
}

之后每次运行npm run dev即可
但是这样依然繁琐, 我们希望可以自动监听文件的保存并自动刷新, 这个可以用nodemon和ts-node来实现
前者可以监听文件的变更,自动重启服务, 后者可以直接运行ts文件

安装nodemon

npm i nodemon ts-node typescript --save-dev

修改package.json的dev命令

// package.json
{

  ...
  "scripts": {
    "dev": "nodemon ./src/app.ts"
  },
  ...
}

为了控制代码风格和质量, 我们可以引入eslint和prettier

npm i eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin eslint-config-prettier prettier --save-dev

在根目录创建 .eslintrc.js

// .eslintrc.js
module.exports = {
  root: true,

  env: {
    node: true,
    es2021: true,
  },

  parser: '@typescript-eslint/parser',

  parserOptions: {
    ecmaVersion: 12,
    sourceType: 'module',
    tsconfigRootDir: __dirname,
    project: ['./tsconfig.json'],
  },

  plugins: ['@typescript-eslint'],
  rules: {
    "@typescript-eslint/no-unsafe-assignment": "off",
    "@typescript-eslint/no-non-null-assertion": "off",
    "no-useless-escape": "off",
    "@typescript-eslint/no-unsafe-member-access": "off",
    "@typescript-eslint/unbound-method": "off",
    "@typescript-eslint/await-thnable": "off",
    "@typescript-eslint/restrict-template-expressions": "off",
    "@typescript-eslint/no-misused-promises": "off",
    '@typescript-eslint/no-explicit-any': 'off',
    "@typescript-eslint/no-unsafe-call": "off",
    "@typescript-eslint/no-unsafe-argument": "off",
    "no-async-promise-executor": "off",
    "@typescript-eslint/no-floating-promises": "off",
    "@typescript-eslint/require-await": "off",
    "@typescript-eslint/no-var-requires": "off",
    "@typescript-eslint/ban-types": "off",
    "no-prototype-builtins": "off",
    "space-before-function-paren": 0
  },

  extends: [
    'eslint:recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:@typescript-eslint/recommended-requiring-type-checking',
    'prettier',
  ],
}


在根目录创建.prettierrc和.editorconfig

// .prettierrc
{
  "printWidth": 120,
  "semi": false,
  "singleQuote": true,
  "prettier.spaceBeforeFunctionParen": true
}

# .editorconfig
root = true

[*.{js,ts,json}]
charset=utf-8
end_of_line=lf
insert_final_newline=false
indent_style=space
trim_trailing_whitespace = true
indent_size=2
insert_final_newline = true

编辑vscode配置文件setting.json,在末尾添加下列行

// setting.json
{
  ...
  "eslint.format.enable": true,
  "editor.codeActionsOnSave": {
    // 启用ESLint规则格式化以上设为none的代码
      "source.fixAll.eslint": true
  },
  "editor.tabSize": 2,

  // 保存时格式化代码
  "editor.formatOnSave": true,
  "eslint.trace.server": "off",
  // 粘贴时格式化代码
  "editor.formatOnPaste": false,
  "[typescript]": {
      "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[markdown]": {
      "editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
  },
}

安装vscode插件Prettier - Code formatter

修改src/app.ts, 测试是否生效

// src/app.ts
let a = {
    num: 1
};

此时eslint会提示有2个警告1个错误, 代码有3处不符合我们设置的规范

  • 'a' is never reassigned. Use 'const' instead.
  • num这行应该使用2个空格作为缩进, 而不是4个空格
  • 结束行应该没有分号作为结束

当我们ctrl+s保存文件, 会发现编辑器将代码修改为

// src/app.ts
const a = {
  num: 1
}

不规范的地方被修复了

  1. 项目编译 在正式环境里部署项目, 我们不再需要对文件修改进行监听, 而是直接编译文件. 因此我们添加一个build命令
// package.json
{

  ...
  "scripts": {
    "dev": "nodemon ./src/app.ts",
    "build": "tsc && node ./dist/src/app.js"
  },
  ...
}

如果项目是部署window环境下, 直接ctrl+c就可以停止项目, 但是如果在linux环境下, 重启项目就比较麻烦, 可以考虑使用pm2来管理node服务

# 全局安装pm2
npm install pm2 -g

# 项目根目录
# 运行项目
npm run build

# 查看项目运行状态
pm2 list

# 重启项目
pm2 restart ./dist/src/app.js

为了区分正式环境和开发环境, 我们可以使用cross-env添加环境变量

# 安装cross-env
npm i cross-env --save-dev

修改dev和build命令

// package.json
{
  ...
  "scripts": {
    "dev": "cross-env NODE_ENV=development nodemon ./src/app.ts",
    "build": "tsc && cross-env NODE_ENV=production node dist/src/app.js"
  },
  ...
}

修改 src/app.ts

import Koa from "koa";
import http from "http";
// 创建koa实例
const app = new Koa();
// 创建服务器
const server: http.Server = new http.Server(app.callback());
// 中间件
app.use(async (ctx) => {
  ctx.body = "Hello World";
});
// 监听端口
app.listen(9000, () => {
  console.log("run success");
  console.log("app started at port 9000...");
  console.log(process.env.NODE_ENV);
});

我们就能看到控制台打印出来的development。

至此,基础环境搭建基本完成

# 2. 中间件开发

  1. 中间件的含义和作用

中间件是一种封装方式, 用于处理http请求的功能
如果将一个http请求比喻为一条运水的管道, 那么中间件就是管道上的仪表、阀门等处理装置
中间件主要有3个核心概念: 请求request、响应response和next函数
request和response是请求的上下文信息Context, next函数用于控制状态.
如果一个请求, 没有设置response, 那么这个请求就会返回404
如果一个请求, 进入某个中间件后, 只有当执行next函数后, 请求才会继续执行下一个中间件. 如果没有执行next函数, 而是设置response的状态码和返回值, 那么请求就会结束, 不再继续执行下面的中间件,而是直接把response返回给前端.

  1. koa如何使用中间件

koa使用use方法载入中间件

// 中间件middleware
const middleware1 = (ctx, next) => {
  console.log('1');
  next();
}
// 中间件middleware2
const middleware2 = (ctx, next) => {
  console.log('2');
  ctx.body = 'hello world'
}
app.use(middleware1)
app.use(middleware2)

值得注意的是, 中间件先被use的, 就先执行

下面我们介绍一些常用的中间件,并开发一些中间件

为了模块清晰, 我们在src目录下创建core文件夹, 用户存放核心静态类
在src/core目录下, 新建Init.ts, 用于初始化中间件

  1. 请求报文处理中间件

http请求里,报文主体(即body参数部分)是以二进制的数据在网络中进行传输,并且为了优化速度,还常常会对内容进行压缩编码,比如gzip
koa-bodyparser中间件,会将post请求的请求报文进行处理,将请求主体以json格式,挂载在ctx.request.body上

// src/core/Init.ts

import Koa from 'koa'
import http from 'http'
import koaBodyParser from 'koa-bodyparser'
class Init {
  public static app: Koa<Koa.DefaultState, Koa.DefaultContext>
  public static server: http.Server
  public static initCore(app: Koa<Koa.DefaultState, Koa.DefaultContext>, server: http.Server) {
    Init.app = app
    Init.server = server
    Init.loadBodyParser()
  }

  // 解析body参数
  public static loadBodyParser() {
    Init.app.use(koaBodyParser())
  }
}

export default Init.initCore

// src/app.ts
import Koa from 'koa'
import http from 'http'
import initCore from './core/Init'
// 创建koa实例
const app = new Koa()
// 创建服务器
const server: http.Server = new http.Server(app.callback())

// 执行初始化
initCore(app, server)
// 监听端口
app.listen(9000, () => {
  console.log('run success')
  console.log('app started at port 9000...')
  console.log(process.env.NODE_ENV)
})

  1. 路由中间件 服务器端路由,即根据不同路径的http请求做出对应的相应和处理
// src/core/Init.ts
class Init {
  ...
  static async initLoadRouters() {
    Init.app.use((ctx) => {
      console.log(ctx.path)
      switch (ctx.path) {
        case '/login':
          // 只允许post请求
          if (ctx.method === 'GET') {
            ctx.status = 404
            break
          }
          ctx.body = '登录成功'
          break
        case '/getUser':
          // 只允许get请求
          if (ctx.method === 'POST') {
            ctx.status = 404
            break
          }
          ctx.body = 'admin'
          break
        default: {
          ctx.status = 404
        }
      }
    })
  }
  ...
}

但是这样做,所有的处理都在一起,在实际开发中并不合适。我们希望把不同的请求处理函数放在不同的目录和文件里, 并且能方便地设置请求路径和请求方法
koa-router是koa的一个路由中间件,它可以将请求的URL和方法(如:GET 、 POST 、 PUT 、 DELETE 等) 匹配到对应的响应程序或页面

在src下新建目录api,在api下新建目录v1,表示接口的版本,当前版本的接口都放在v1目录下

在v1下新建文件text.ts, 在这个文件里,我们创建一个路由实例

// src/api/v1/test.ts
import Router from 'koa-router'
const router = new Router({
  prefix: '/api/v1',
})

router.get('/test', async (ctx) => {
  ctx.body = 'test'
})

export default router

然后在init里加载这个路由实例

// src/core/Init.ts
import test from '../api/v1/test'
class Init {
  ...
  static async initLoadRouters() {
    Init.app.use(test.routes())
  }
  ...
}

目前我们约定,src/api目录下,只存放http请求路由文件,因此手动引动太过繁琐,我们希望可以自动递归遍历src/api目录,自动加载所有的路由处理

在src目录下新建common目录,用于存放公共文件,在common下新建utils目录,用于存放工具函数,再在utils下新建utils.ts

我们先在utils里写一个遍历目录下所有文件默认导出的方法

// src/common/utils/utils.ts

import fs from 'fs'
import path from 'path'
/**
 * 获取某个目录下所有文件的默认导出
 * @param filePath 需要遍历的文件路径
 */
export async function getAllFilesExport(filePath: string, callback: Function) {
  // 根据文件路径读取文件,返回一个文件列表
  const files: string[] = fs.readdirSync(filePath)
  // 遍历读取到的文件列表
  files.forEach((fileName) => {
    // path.join得到当前文件的绝对路径
    const absFilePath: string = path.join(filePath, fileName)
    const stats: fs.Stats = fs.statSync(absFilePath)
    const isFile = stats.isFile() // 是否为文件
    const isDir = stats.isDirectory() // 是否为文件夹
    if (isFile) {
      const file = require(absFilePath)
      callback(file.default)
    }
    if (isDir) {
      getAllFilesExport(absFilePath, callback) // 递归,如果是文件夹,就继续遍历该文件夹里面的文件;
    }
  })
}

然后在init里调用

// src/core/Init.ts
class Init {
  ...
  static async initLoadRouters() {
    const dirPath = path.join(`${process.cwd()}/src/api/`)
    getAllFilesExport(dirPath, (file: Router) => {
      Init.app.use(file.routes())
    })
  }
  ...
}

此时src/api目录下的路由会被自动调用,但是还有一个问题,就是在build后,目录会变化,变成/dist/src/api/,我们需要根据环境变量,控制加载目录

其它需要使用绝对路径的地方,都会有这个问题,所以我们可以创建一个公共变量,利于复用

在src下创建目录config, 在config下创建Config.ts, 用于存放配置和公共变量

// src/config/Config.ts
const isDev = process.env.NODE_ENV === 'development'

export default class Config {
  // 服务器端口
  public static readonly HTTP_PORT = 9000
  // 接口前缀
  public static readonly API_PREFIX = '/api/'
  // 根目录
  public static readonly BASE = isDev ? 'src' : 'dist/src'
}


修改app.ts

// src/app.ts
import Koa from 'koa'
import http from 'http'
import initCore from './core/Init'
import Config from './config/Config'
// 创建koa实例
const app = new Koa()
// 创建服务器
const server: http.Server = new http.Server(app.callback())

// 执行初始化
initCore(app, server)
// 监听端口
app.listen(Config.HTTP_PORT, () => {
  console.log('run success')
  console.log(`app started at port ${Config.HTTP_PORT}...`)
  console.log(process.env.NODE_ENV)
})

修改src/api/v1/test.ts

// src/api/v1/test.ts

import Router from 'koa-router'
import Config from '../../config/Config'
const router = new Router({
  prefix: `${Config.API_PREFIX}v1`, // 路径前缀
})
// 指定一个url和请求方法匹配处理
router
  .get('/test', (ctx) => {
    ctx.body = 'test'
  })
  .post('/login', (ctx) => {
    ctx.body = '登录'
  })

export default router

修改src/core/Init.ts

// src/core/Init.ts

import Koa from 'koa'
import http from 'http'
import koaBodyParser from 'koa-bodyparser'
import path from 'path'
import { getAllFilesExport } from '../common/utils/utils'
import Router from 'koa-router'
import Config from '../config/Config'
class Init {
  public static app: Koa<Koa.DefaultState, Koa.DefaultContext>
  public static server: http.Server
  public static initCore(app: Koa<Koa.DefaultState, Koa.DefaultContext>, server: http.Server) {
    Init.app = app
    Init.server = server
    Init.loadBodyParser()
    Init.initLoadRouters()
  }

  // 解析body参数
  public static loadBodyParser() {
    Init.app.use(koaBodyParser())
  }

  // http路由加载
  static async initLoadRouters() {
    const dirPath = path.join(`${process.cwd()}/${Config.BASE}/api/`)
    getAllFilesExport(dirPath, (file: Router) => {
      Init.app.use(file.routes())
    })
  }
}

export default Init.initCore

  1. 错误监听和日志处理 koa可以通过ctx.throw()方法或者创建一个Error实例并使用throw关键字直接抛出错误, 错误会中断程序的执行.
    如果错误会被try...catch捕获, 一旦被程序就会执行catch里的语句, 然后继续往下执行.
// 中间件one
const one = (ctx, next) => {
  console.log('>> one');
  next();
  console.log('<< one');
}
// 中间件two
const two = (ctx, next) => {
  console.log('>> two');
  next();
  console.log('<< two');
}

app.use(one)
app.use(two)

最后的打印结果是

>> one
>> two
<< one
<< two

ctx的当前的上下文, next有点类型回调函数的意思, 会在执行完next后再执行下一步
因此, 在第一个中间件里, 用try...catch将next包裹, 就能监听往后所有中间件的错误, 知道给ctx.body赋值, 整个响应结束
如果我们自定义一些"错误", 当捕获到不同"错误"时, 做出响应的处理, 那这个中间件不仅仅可以捕获异常, 还能给接口响应一个统一的出口.
在src/core目录下,新建HttpException.ts,用于存放不同的http错误类型

// src/core/HttpException.ts
// http异常
export class HttpException extends Error {
  public message: string
  public errorCode: number
  public code: number
  public data: any
  public isBuffer = false
  public responseType: string | undefined
  constructor(data = {}, msg = '服务器异常,请联系管理员', errorCode = 10000, code = 400) {
    super()
    this.message = msg
    this.errorCode = errorCode
    this.code = code
    this.data = data
  }
}
// http参数异常
export class ParameterException extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 422
    this.message = msg || '参数错误'
    this.errorCode = errorCode || 10000
  }
}

// http请求成功
export class Success extends HttpException {
  public data
  public responseType
  public session
  constructor(data?: unknown, msg = 'ok', code = 200, errorCode = 0, responseType?: string, session?: string) {
    super()
    this.code = code //200查询成功,201操作成功
    this.message = msg
    this.errorCode = errorCode || 0
    this.data = data
    this.responseType = responseType
    this.session = session
  }
}
// 返回文件流
export class Buffer extends Success {
  public data
  public responseType
  public session
  public isBuffer
  constructor(data?: any, responseType?: string, session?: string) {
    super()
    this.code = 200 //200查询成功,201操作成功
    this.message = 'ok'
    this.errorCode = 0
    this.data = data
    this.responseType = responseType
    this.session = session
    this.isBuffer = true
  }
}
// 404
export class NotFount extends HttpException {
  constructor(msg: string, errorCode: number) {
    super()
    this.code = 404
    this.message = msg || '资源未找到'
    this.errorCode = errorCode || 10001
  }
}
// 授权失败
export class AuthFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 401
    this.message = msg || '授权失败'
    this.errorCode = errorCode || 10002
  }
}
// Forbbiden
export class Forbbiden extends HttpException {
  constructor(msg: string, errorCode?: number) {
    super()
    this.code = 403
    this.message = msg || '禁止访问'
    this.errorCode = errorCode || 100006
  }
}

// 查询失败
export class QueryFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 500
    this.message = msg || '未查到匹配的数据'
    this.errorCode = errorCode || 100006
  }
}

// 查询失败
export class dataBaseFailed extends HttpException {
  constructor(msg?: string, errorCode?: number) {
    super()
    this.code = 500
    this.message = msg || '数据库出错'
    this.errorCode = errorCode || 100005
  }
}

当请求过程中,出现一些我们预料之中的情况,比如

  • 请求成功应该返回数据
  • 请求参数错误或者校验失败
  • 请求成功需要返回文件
  • 登录失效 ...

我们可以抛出错误,并拦截做出处理,返回对应的状态码和数据。

如果是意料之外的错误,则按异常处理, 并打印日志

在src目录下新建目录middlewares,用户存放自定义中间件,然后在middlewares目录下新建catchError.ts,开发错误拦截中间件

// src/middlewares/catchError.ts
import koa from 'koa'
import { Success, HttpException } from '../core/HttpException'
export async function catchError(ctx: koa.Context, next: Function) {
  const { method, path } = ctx
  try {
    await next()
  } catch (error: any) {
    // 当前错误是否是我们自定义的Http错误
    const isHttpException = error instanceof HttpException

    // 如果不是, 则抛出错误
    if (!isHttpException) {
      ctx.body = {
        msg: '未知错误',
        errorCode: 9999,
        requestUrl: `${method} ${path}`,
      }
      ctx.status = 500
    }
    // 如果是已知错误
    else {
      if (error.responseType) {
        ctx.response.type = error.responseType
      }
      // 如果是文件流,则直接返回文件
      if (error.isBuffer) {
        ctx.body = error.data
      } else {
        ctx.body = {
          msg: error.message,
          errorCode: error.errorCode,
          data: error.data,
        }
      }

      ctx.status = error.code

    }
  }
}


然后再Init里使用中间件

// src/core/Init.ts
class Init {

  ...
  Init.initCatchError()
  ...

  ...
  // 错误监听和日志处理
  public static initCatchError() {
    Init.app.use(catchError)
  }

  ...
}

我们修改下src/api/v1/test.ts接口,根据不同的请求抛出不同的处理

// src/api/v1/test.ts
import { Success, ParameterException, AuthFailed } from '../../core/HttpException'
router
  .get('/test', (ctx) => {
    const { id } = ctx.request.body
    const token = ctx.header['authorization'] || ctx.cookies.get('authorization')
    // 如果没有携带登录信息
    if (!token) {
      throw new AuthFailed('未登录')
    }
    // 如果缺少参数或者参数类型错误
    if (typeof id !== 'number') {
      throw new ParameterException('缺少参数id')
    }
    // 请求成功
    throw new Success('text')
  })

打开浏览器访问http://localhost:9000/api/v1/test,可以看到接口返回

{"msg":"未登录","errorCode":10002,"data":{}}

这样当所有的错误, 以及http请求响应,都会集中在catchError中间件中处理,根据我们设置的HttpException返回对应的数据.

除去控制太和返回客户端的错误信息之外,我们还需要一个日志系统,将错误记录在一个固定目录下,便于日后查看

在src/common下创建文件夹lib, 然后新建logs.ts, 用于存放日志配置.我们的日志系统主要依赖于log4js.

由于日志是为了日后排查错误, 并没有记录在git版本里, 所以在服务启动时,需要先判断logs目录是否存在.

log4js配置项很多, 这里只介绍了appenders和categories配置

  • appenders 可以在appenders添加属性设置日志类型.我们这里添加了console和date,并通过type设置记录类型.type为console时,表示记录在控制台;type为dateFile, date表示记录在文件里,并根据时间将日志分片

  • categories 通过categories.defult.appenders,可以设置启用的appenders.通过categories.defult.level,可以对日志进行过滤

// src/common/lib/logs.ts
import log4js from 'log4js'
import fs from 'fs'
import { isDirectory } from '../utils/utils'

//检查某个目录是否存在
if (!isDirectory('logs')) {
  // 不存在则创建目录
  fs.mkdirSync('logs')
}

log4js.configure({
  appenders: {
    console: {
      type: 'console',
    },
    date: {
      type: 'dateFile',
      filename: 'logs/date',
      category: 'normal',
      alwaysIncludePattern: true,
      pattern: '-yyyy-MM-dd-hh.log',
    },
  },
  categories: {
    default: {
      appenders: ['console', 'date'],
      level: 'info',
    },
  },
})

const logger = log4js.getLogger('cheese')

export default logger

// src/common/utils/utils.ts

/**
 * 判断某个文件夹是否存在
 * @param path
 * @returns {boolean}
 */
export function isDirectory(path: string): boolean {
  try {
    const stat = fs.statSync(path)
    return stat.isDirectory()
  } catch (error) {
    return false
  }
}

然后在catchError中间件里,我们引入logger

import logger from '../common/lib/logs'
export default async function catchError(ctx: koa.Context, next: Function) {
  const { method, path } = ctx
  try {
    await next()
  } catch (error: any) {
    // 当前错误是否是我们自定义的Http错误
    const isHttpException = error instanceof HttpException

    // 如果不是, 则抛出错误
    if (!isHttpException) {
      logger.error(method, ctx.response.status, ctx.originalUrl, error)
      ...
    }
    // 如果是已知错误
    else {
      ...
      if (error instanceof Success || error instanceof Buffer) {
        logger.info(method, ctx.response.status, ctx.originalUrl)
      } else {
        logger.error(method, ctx.response.status, ctx.originalUrl, error)
      }
    }
  }
}

在当我们调用接口时,就能在logs里看到日志文件date.-2022-05-03-10.log内容更新了

[2022-05-03T10:09:40.680] [ERROR] cheese - GET 401 /api/v1/test?id=2 AuthFailed [Error]: 未登录
    at E:\project\git\node-project\koa-ts-learn\src\api\v1\test.ts:14:13
    at dispatch (E:\project\git\node-project\koa-ts-learn\node_modules\koa-compose\index.js:42:32)
    at E:\project\git\node-project\koa-ts-learn\node_modules\koa-router\lib\router.js:372:16
    at dispatch (E:\project\git\node-project\koa-ts-learn\node_modules\koa-compose\index.js:42:32)
    at E:\project\git\node-project\koa-ts-learn\node_modules\koa-compose\index.js:34:12
    at dispatch (E:\project\git\node-project\koa-ts-learn\node_modules\koa-router\lib\router.js:377:31)
    at dispatch (E:\project\git\node-project\koa-ts-learn\node_modules\koa-compose\index.js:42:32)
    at E:\project\git\node-project\koa-ts-learn\src\middlewares\catchError.ts:7:11
    at Generator.next (<anonymous>)
    at E:\project\git\node-project\koa-ts-learn\src\middlewares\catchError.ts:8:71 {
  isBuffer: false,
  errorCode: 10002,
  code: 401,
  data: undefined
}

  1. mysql封装
// src/core/Mysql.ts
import mysql from 'mysql'
import { Models } from '../common/typings/model'
import dbConfig from '../config/dbConfig'
import httpErrors from '../core/HttpException'
import { lineToHumpObject } from '../common/utils/Utils'
class Mysql {
  // 创建连接池
  public static readonly pool = mysql.createPool({
    host: dbConfig.database.host,
    port: dbConfig.database.port,
    user: dbConfig.database.username,
    password: dbConfig.database.password,
    database: dbConfig.database.dbName,
    multipleStatements: true, // 运行执行多条语句
    connectionLimit: 60 * 60 * 1000,
    connectTimeout: 1000 * 60 * 60 * 1000,
    acquireTimeout: 60 * 60 * 1000,
    timeout: 1000 * 60 * 60 * 1000,
  })

  /*
   * 数据库增删改查
   * @param command 增删改查语句
   * @param value 对应的值
   */
  public static async command(command: string, value?: Array<any>): Promise<Models.Result> {
    try {
      return new Promise<Models.Result>((resolve, reject) => {
        this.pool.getConnection((error: mysql.MysqlError, connection: mysql.PoolConnection) => {
          // 如果连接出错, 抛出错误
          if (error) {
            const result: Models.MysqlError = {
              error,
              msg: '数据库连接出错' + ':' + error.message,
            }
            reject(result)
          }

          const callback: mysql.queryCallback = (err, results?: any, fields?: mysql.FieldInfo[]) => {
            connection.release()
            if (err) {
              const result: Models.MysqlError = {
                error: err,
                msg: err.sqlMessage || '数据库增删改查出错',
              }

              reject(result)
            } else {
              const result: Models.Result = {
                msg: 'ok',
                state: 1,
                // 将数据库里的字段, 由下划线更改为小驼峰
                results: results instanceof Array ? results.map(lineToHumpObject) : results,
                fields: fields || [],
              }
              resolve(result)
            }
          }
          // 执行mysql语句
          if (value) {
            this.pool.query(command, value, callback)
          } else {
            this.pool.query(command, callback)
          }
        })
      })
    } catch (err) {
      throw new httpErrors.dataBaseFailed()
    }
  }
}
export default Mysql


  1. 登录和token

在用户登录后, 服务端创建一个令牌, 并返回给前端, 前端在请求后端时携带这个令牌, 服务端通过比对这个令牌, 就可以确定用户的身份, 避免请求中携带用户敏感信息导致的泄露, 也避免了频繁查询数据库获取用户密码造成的资源浪费.这个令牌就是token.
为了保证token的安全性, 我们需要将token加密并缓存下来, 并设置过期时间

// src/api/v1/system/auth/login.ts

import { Models } from '../../../../common/typings/model'
import KoaRouter from 'koa-router'
import Mysql from '../../../../core/Mysql'
import { Success, ParameterException } from '../../../../core/HttpException'
import validator from '../../../../middlewares/validator'
import { generateToken } from '../../../../common/utils/Utils'
import login from '../../../../common/apiValidate/system/auth/login'
import Config from '../../../../config/config'
import RedisClient from '../../../../core/Redis'

const router = new KoaRouter({
  prefix: `${Config.api_prefix}system/auth`,
})


router.post('/login', 
  validator(login, 'body'), // 校验body的参数是否符合要求
  checkCode, // 校验的验证码是否正确
  async (ctx) => {
    const { email, password, userName } = ctx.request.body
    const res: Models.Result = await Mysql.command(`
          SELECT
          id,email,deleted,info,password,role_id
          FROM
              user
          where
              user.user_name = '${name}'
      `)
    const {
      user, token
    } = getToken(res, password)
    throw new Success({ user, token })
  }
)



function getToken(res: Models.Result, password: string) {
  if (!res.error) {
    const user = res.results[0]
    const correct = checkPassword(password, user.password as string)
    if (!correct) {
      throw new ParameterException('密码不正确')
    }
    const token = generateToken(user.id as number, user.roleId)
    RedisClient.saveToken(token, user.id)
    return {
      token,
      user,
    }
  }
}



export default router

  1. 校验中间件开发

  1. 给接口添加权限校验中间件.

有些接口不需要任何权限, 如登录, 有些接口需要登录权限, 就需要校验token, 有些接口需要对应的菜单权限和操作权限, 就需要校验角色权限

后端部分主要通过mysql去存储对应的角色和权限, 并使用redis做缓存.

主要流程:

  • 当用户登录时保存生成token

# 代码实现

# 1. 前端

# 2. 后端

  • 在服务启动时, 从mysql里拉取角色相关数据, 并存在redis里

export default class Auth {
  /**
   * 获取用户权限
   * @param decode
   * @returns
   */
  static async getUserPermission(decode: Decode): Promise<Menu.Menu[]> {
    const { scope } = decode
    return new Promise(async (resolve, reject) => {
      let res: Models.Result

      try {
        res = await Mysql.command(`
            SELECT
              permissions
            FROM
              role
            where
              id = ${scope}
        `)
        if (!res.error) {
          const user = res.results[0]
          if (user) {
            const menuList: Menu.Menu[] = (
              await Mysql.command(`
                SELECT
                  permission
                FROM
                  menu
                WHERE
                  FIND_IN_SET(
                  id,
                  '${user.permissions}')
              `)
            ).results
            resolve(menuList)
          } else {
            resolve([])
          }
        } else {
          reject()
        }
      } catch (error) {
        console.log(error)
        reject()
      }
    })
  }
  /**
   * 获取所有角色的权限列表
   * @returns
   */
  static async getAllRolePermission(): Promise<Role[]> {
    return new Promise(async (resolve, reject) => {
      let res: Models.Result
      try {
        res = await Mysql.command(`
            SELECT
              id,
              permissions,
              parent_id parentId,
              name
            FROM
              role
        `)
        if (!res.error) {
          const menuList: Role[] = []
          for (let i = 0; i < res.results.length; i++) {
            const item: Menu.Menu = res.results[i]
            menuList.push({
              id: item.id,
              parentId: item.parentId,
              name: item.name,
              menuList: await Auth.getUserPermission({
                scope: item.id,
                uid: 0,
              }),
            })
          }
          resolve(menuList)
        } else {
          reject()
        }
      } catch (error) {
        reject()
      }
    })
  }

  /**
   * 更新redis里的角色
   */
  static updateRedisRole() {
    Auth.getAllRolePermission().then((list) => {
      list.forEach((res) => {
        if (res.menuList.length > 0) {
          redisClient.updateRoles(
            (res.id || '').toString(),
            new Map([
              ['id', res.id.toString()],
              ['parentId', res.parentId.toString()],
              ['permissions', res.menuList.map((item) => item.permission).join(',')],
            ])
          )
        }
      })
    })
  }
}
  • 权限校验中间件开发
export default class Auth {
  /**
   * 校验token
   * @param ctx
   * @param next
   * @param callback
   */
  static async verifyToken(ctx: Models.Ctx, next: Koa.Next, callback?: Function) {
    //检测token是否合法
    const userToken = Auth.getToken(ctx)
    let errMsg = 'token不合法'
    if (!userToken) {
      throw new Forbbiden('无访问权限')
    }
    const uid = (await redisClient.getTokenValue(userToken)).results
    let decode
    try {
      // 解析token
      decode = jwt.verify(userToken, config.security.secretKey) as string | Decode
    } catch (error) {
      if (error && (error as Error)?.name == 'TokenExpiredError') {
        errMsg = 'token已过期'
        throw new AuthFailed(errMsg)
      }
      throw new Forbbiden(errMsg)
    }
    if (typeof decode === 'object') {
      if (uid != decode.uid) {
        errMsg = 'token已过期'
        throw new AuthFailed(errMsg, 401)
      }
      // 保存uid和scope
      ctx.auth = {
        uid: decode.uid,
        scope: decode.scope,
      }
      if (callback) {
        await callback(decode)
      }
    } else {
      errMsg = '权限不足'
      throw new Forbbiden(errMsg)
    }
    await next()
  }

  /**
   * 获取token
   * @param ctx
   * @returns
   */
  static getToken(ctx: Models.Ctx) {
    return ctx.header['authorization'] || ctx.cookies.get('authorization')
  }

  /**
   * 校验token权限
   * @param ctx
   * @param next
   */
  static async verifyTokenPermission(ctx: Models.Ctx, next: Koa.Next) {
    await Auth.verifyToken(ctx, next, async (decode: Decode) => {
      const permissionList: string[] = await Auth.getRedisUserPermission(decode)

      const bool = permissionList.find((permission) => {
        const path = `${config.api_prefix}${permission.split(':').join('/')}`
        return path === ctx.path
      })
      if (!bool) {
        throw new Forbbiden('权限不足')
      }
    })
  }
}
  • 为接口添加校验中间件
const router = new KoaRouter({
  prefix: `${Config.api_prefix}system/user`,
})

/**
 * 编辑密码
 */
router.post(
  '/editPassword', 
  Auth.verifyTokenPermission, 
  validator(editPassword, 'body'),
  checkCode,
  async (ctx: Models.Ctx) => {
    const id = ctx.auth?.uid
    const { password, emailCode } = ctx.request.body
    if (ctx.session!.emailCode !== emailCode) {
      throw new ParameterException('邮箱验证码错误')
    }
    await Mysql.command(`
      UPDATE
        user
      SET password = '${password}'
      WHERE id = ${id}
  `)
    throw new Success()
  }
)

# 2. 目录结构

src下 + api 接口目录 + common

# 校验流程
  • 服务启动时, 查询mysql数据库查询所有角色并存入redis里
  • 当用户登录后, 后端根据用户的id和角色id生成token保存在redis里, 并设置过期时间, 然后返回给前端
  • 给需要校验的接口加上校验中间件
  • 首先去redis里校验请求里携带的token是否有效, 无效则返回401. 有效则继续在查询redis里角色数据, 并解析token拿到角色id, 来获取