nodejs + koa2 + ts + mysql + redis + vue 重构若依后台管理系统角色权限设计
# 前言
具有用户、角色、权限等概念的系统、网站或者项目, 抛开业务功能, 它们本质上都有一个角色权限系统, 这是系统的地基.
若依的权限管理系统, 主要有角色管理、菜单管理和用户管理3部分, 每个用户都有自己的角色, 每个角色都有自己的菜单和按钮权限, 这是用户看得到的部分.在用户看不到的地方, 还有角色的接口权限.
在了解若依权限逻辑后, 用koa2 + Ts和vue + Antdv重构了其角色权限系统
# 项目开发
# 1. 前端
前端部分我主要是使用antdv pro去实现. 主要有token, 动态路由, v-action(控制按钮显示隐藏的自定义指令), 具体内容可以参考Antdv Pro官网
# 2. 后端
# 数据库设计
- 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, 分类的父级只能是分类, 菜单的父级只能是分类, 按钮的父级只能菜单.通过父子关系, 就能组合成改角色的前端路由
- redis
redis主要用来缓存token和角色
- token以字符串类型保存, 以token为key, userId作为值
- 角色以hash类型保存, 每一个角色都是一个对象, 角色id作为key, 每个值里有id、parentId, 和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. 项目创建
- 创建包管理文件
# 生成package.json
npm init -y
新建src文件夹, 在src根目录新建app.ts文件, app.ts就是整个项目的入口
- 安装依赖
# 依赖
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
- 初始化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"
]
}
- 运行项目
在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
}
不规范的地方被修复了
- 项目编译 在正式环境里部署项目, 我们不再需要对文件修改进行监听, 而是直接编译文件. 因此我们添加一个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. 中间件开发
- 中间件的含义和作用
中间件是一种封装方式, 用于处理http请求的功能
如果将一个http请求比喻为一条运水的管道, 那么中间件就是管道上的仪表、阀门等处理装置
中间件主要有3个核心概念: 请求request、响应response和next函数
request和response是请求的上下文信息Context, next函数用于控制状态.
如果一个请求, 没有设置response, 那么这个请求就会返回404
如果一个请求, 进入某个中间件后, 只有当执行next函数后, 请求才会继续执行下一个中间件. 如果没有执行next函数, 而是设置response的状态码和返回值, 那么请求就会结束, 不再继续执行下面的中间件,而是直接把response返回给前端.
- 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, 用于初始化中间件
- 请求报文处理中间件
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)
})
- 路由中间件 服务器端路由,即根据不同路径的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
- 错误监听和日志处理
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
}
- 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
- 登录和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
- 校验中间件开发
- 给接口添加权限校验中间件.
有些接口不需要任何权限, 如登录, 有些接口需要登录权限, 就需要校验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, 来获取