前言
框架搭建作为项目开发阶段的第一个步骤,相信有不少人参与或主导过。一个健壮易用的基本框架可以提升团队开发效率和减少踩坑,这一点是毋庸置疑的。本文利用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 国际惯例
欢迎访问原文链接