开发利器之管理系统模板分享

374 阅读4分钟

前言

    框架搭建作为项目开发阶段的第一个步骤,相信有不少人参与或主导过。一个健壮易用的基本框架可以提升团队开发效率和减少踩坑,这一点是毋庸置疑的。本文利用vue3、element-plus、nestjs实现了一套管理系统模板,希望对各位童鞋有所帮助!

正文

1 后端部分

    这部分采用nestjs作为技术栈进行开发,数据库则采用mysql,消息中间件采用redis。下面将围绕技术点逐步展开说明。

1.1 数据库设计

    本文设计了5个数据表:user(用户表)、role(角色表)、permission(权限表)、user_role(用户角色关联表)、role_permission(角色权限关联表)。关联关系如下所示: 数据库设计图     orm框架采用的是typeorm,详细用法参照官网链接,支持由model定义反向生成数据表。

1.2 token处理方案

(1) token生成和解析

    本文采用的是@nestjs/jwt包生成和解析token,token密钥则采用随机生成的uuid,这意味着服务器重启后token将全部失效,token内容则可以设置为由用户ID、用户名等用户标识的普通对象,token有效期设置为1h;

(2) token保存

    开发环境下可以直接保存在内存中,为了减少内存膨胀,可以在用户登出时清理相应token。生产环境下可利用redis进行token存储。redis具体配置参照1.5小节;

(3) token校验

    对需要检验的接口,取出Authorization请求头信息进行token校验。

1.3 拦截器和过滤器

(1) 配置全局权限拦截器

    本文采用了拦截器进行权限校验,why not route guard?按照nestjs官方的文档实施发现route guard无法进行依赖注入,则无法利用jwt模块中的jwtServie进行token校验.校验不通过则抛出UnauthorizedException异常。核心代码如下:

intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const req = context.switchToHttp().getRequest()
    const token = req.header('Authorization')
    try {
      if (isTokenApi(req.url)) { // 判断url是否需要校验
        // 开启redis,通过redis判断是否登出
        ...
        this.jwtService.verify(token)
          if (!cache.loginTokenList.includes(token)) {
            return throwError(() => new UnauthorizedException())
        }
      }
      return next.handle().pipe()
    } catch (err) {
      this.logger.error(err)
      if (
        err instanceof TokenExpiredError ||
        err instanceof JsonWebTokenError
      ) {
        // token 无效处理
        return throwError(() => new UnauthorizedException())
      } else {
        return throwError(() => err)
      }
    }
  }
}

(2) 配置全局异常过滤器

    除对于上一步抛出的nauthorizedException外,还有数据库相关的异常需要处理,因此配置全绝异常过滤器进行统一化处理,方便前端进行数据展示,核心代码如下:

catch(exception: any, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost
    const ctx = host.switchToHttp()
    let httpStatus = HttpStatus.INTERNAL_SERVER_ERROR
    if (exception instanceof HttpException) {
      httpStatus = exception.getStatus()
    } else if (exception?.status) {
      httpStatus = exception.status
    }
    const responseBody = `${exception}`
    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus)
  }

1.4 日志模块

(1) 如何配置

    日志模块作为线上排查问题的工具,显得尤为重要。本文采用的时winston包完成日志部分,开发环境下进行控制台输出,生产环境则需要输出文件。具体配置如下:

const logFormatter = [
  format.timestamp({
    format: 'YYYY-MM-DD HH:mm:ss',
  }),
  isDev && format.colorize(),
  format.printf((info) => {
    const { timestamp, level, stack } = info
    const message = stack || info.message
    return `[${timestamp}]-[${level}]: ${message}`
  }),
].filter(Boolean)

export const logger = createLogger({
  transports: [
    !isDev &&
      new transports.DailyRotateFile({
        level: 'info',
        filename: '%DATE%.log',
        dirname: resolve(process.cwd(), 'logs'),
        datePattern: 'YYYY-MM-DD',
        zippedArchive: true,
        maxSize: '20m',
        format: format.combine(...logFormatter),
      }),
    isDev &&
      new transports.Console({
        level: 'silly',
        format: format.combine(...logFormatter),
      }),
  ].filter(Boolean),
})

    为了便于排查SQL问题,需要将typeorm的日志模块替换,替换方法见官网链接,同时需要关闭并替换nestjs默认logger:

const { logger } = config
const app = await NestFactory.create(AppModule, {
    ...
    logger,
})

(2) 如何使用

    由于替换了nestjs默认logger, 因此有直接实例化和依赖注入两种使用方式,具体使用方法见链接,本文推荐采用直接实例化方式,如在异常过滤器中添加日志打印:

private readonly logger = new Logger(ExceptionsFilter.name)
constructor(private readonly httpAdapterHost: HttpAdapterHost) {}
catch(exception: any, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost
    const ctx = host.switchToHttp()
    ...
    const responseBody = `${exception}`
    this.logger.error(
      `${httpAdapter.getRequestUrl(ctx.getRequest())} ${exception}`,
    )
    ...
  }

1.5 数据库和消息中间件

(1) mysql

import { WinstonAdaptor } from 'typeorm-logger-adaptor/logger/winston'
const mysql: TypeOrmModuleOptions[] = [
  {
    name: 'ms',
    type: 'mysql',
    host: 'localhost',
    port: 3306,
    username: 'sa',
    password: '123456',
    database: 'ms',
    synchronize: true,
    autoLoadEntities: true,
    logger: new WinstonAdaptor(logger, 'all'),
  },
]

    如上所示,利用typeorm-logger-adaptor包对typeorm日志进行了适配。

(2) redis

const redis: {
  enable: boolean
  host: string
  port: number
  options: RedisOptions
} = {
  enable: false, // false则不启动redis客户端服务,由内存代替
  host: '192.168.2.130',
  port: 6379,
  options: {
    db: 0,
    password: '123456',
    lazyConnect: true,
  },
}

// main.ts
if (config.redis.enable) {
    const redisClient = new Redis(redis.port, redis.host, redis.options)
    redisClient.connect(() => {
      setRedisClient(redisClient)
      console.log(`Redis client connected!`)
    })
  }
// 如何使用
if (config.redis.enable) {
    const redisClient = getRedisClient()
    await redisClient.hset('loginTokenList', token, 1)
}

1.6 验证码

    本文采用的是svg-captcha包生成验证码。

import * as svgCapcha from 'svg-captcha'
const { text, data } = svgCapcha.create({
    inverse: true,
})

    该包生成的验证码识别度较低,有需要的话可以替换别的方案.

1.7 加密解密

    用户密码在传输和写入数据库均需加密,数据传输本文采用的是RSA非对称加密算法,公私钥可使用openssl生成,保存数据库则采用的是hmac算法。具体过程为:登录用户可以通过接口获取公钥,加密后传递给服务端,服务端解密后再次hmac加密。

2 前端部分

2.1 登录验证

    如1.7所述,需要对密码进行rsa加密,完成登录之后,每次请求需要将登录获取的token设置为Authorization请求头。同时,路由守卫逻辑需要加入有token直接进入页面,否则回到登录页面。

2.2 请求拦截器

    Axios拦截器作用有三:1.添加token;2.统一接口返回处理;3.控制顶部加载进度条显示。核心代码如下:

axios.interceptors.request.use(
  (config) => {
    startProgress()
    const token = getToken()
    if (token && lastTime && Date.now() - lastTime > 3600000) {
      // 超过一个小时未操作回登陆页
      logout()
      return false
    } else if (
      token &&
      (!lastTime || (lastTime && Date.now() - lastTime <= 3600000))
    ) {
      lastTime = Date.now()
      config.headers['Authorization'] = token
    }
    config.baseURL = import.meta.env.VITE_BASE_URL
    return config
  },
  (err) => Promise.reject(err),
)

axios.interceptors.response.use(
  (res) => {
    // res.data (data, error)
    const { error } = res.data
    if (error) {
      ElMessage.error(error)
      endProgress()
      return Promise.reject(error)
    }
    endProgress()
    return res.data
  },
  (err) => {
    console.error(err)
    const { response } = err
    if (response?.status === 401) {
      ElMessage.error(t('messages.tokenInvalid'))
      logout()
    } else {
      ElMessage.error(`${err}`)
    }
    endProgress()
    return Promise.reject(err)
  },
)

    如上所示,业务代码中只需要处理正常情况,异常直接利用element-plust message进行消息提示并reject error。

2.3 国际化

(1) 如何配置

    本文采用的vue-i18n进行本地初始化,并设置了element-plus国际化相关配置,支持中英文两种语言,根据浏览器语言环境(window.navigator.language)进行切换,具体实现如下:

export const getElementLanguage = () => {
  if (navigator.language.startsWith('zh')) {
    //根据客户端决定显示语言
    return el_zh
  } else {
    return el_en
  }
}

const messages = {
  zh,
  en,
}

const i18n = createI18n({
  legacy: false, // 组合api使用
  locale: navigator.language.startsWith('zh') ? 'zh' : 'en', //根据客户端决定显示语言
  messages,
})

// main.js
const app = createApp(App)
app
  .use(i18n)
  .use(ElementPlus, {
    locale: getElementLanguage(window.navigator.language),
  })

(2) 如何使用

// 1.在vue脚本中使用
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const label = t('labels.label')
// 2.在vue模板中使用
<template><el-button type="primary">{{$t('labels.add')}}</el-button></template>
// 3.在.vue之外使用
import i18n from '../locales'
const { t } = i18n.global
const label = t('labels.label')

3 源码分享

    欢迎访问Github链接,欢迎star、issue!

5 国际惯例

    欢迎访问原文链接