项目介绍

VueAdmin - 前后端分离后台管理系统

前端VueCli3,Vue2.x

后台SpringBoot2.4

Markerhub

线上演示:https://www.markerhub.com/vueadmin
登录密码:1234567

前端笔记:https://shimo.im/docs/pxwyJHgqcWjWkTKX/

后端笔记:https://shimo.im/docs/OnZDwoxFFL8bnP1c/

源码分享:
https://github.com/markerhub/vueadmin
https://gitee.com/markerhub/VueAdmin

视频讲解:https://www.bilibili.com/video/BV1af4y1s7Wh/

Spring Security做JWT认证和授权

好文:https://www.jianshu.com/p/d5ce890c67f7

但是本项目和他的不一样,我们没有采用让AuthenticationManager去调用具体自定义的Provider类处理,我们是自己在JwtAuthenticationFilter处理jwt的逻辑。

问题

路由

1.动态加载路由的时候

  • import('...')

    • 我在动态路有点的时候直接写出完整的文件加载路径导致出错
    • ...中路径不能直接用变量替换,必须动态拼接,'@/views'+路径变量+'.vue'
  • addRoute推荐使用,是添加路由的方法,直接往路由表的某个children数组push没用,必须最后走addRoute
  • 全局守卫

    • 无限跳转,判断url'/login'出错了,导致了无限循环
    • 确保全局守卫中只调用1次next()
  • async await

    • 带url刷新页面的时候,例如/sys/user,无法进入页面
    • 可能是因为全局路由守卫,在你去获取路由数组的时候已经调用了next,导致找不到路由。使用了async await,等待异步获取路由并且加载路由之后再next

      router.beforeEach(async (to, from, next) => {
      const hasRoutes = store.state.hasRoutes;
      if (!hasRoutes) {
      //参数的next是留给操作完再调用
      if (await addRoutes(next)) {
      } else {
          next();
      }
      } else {
      next();
      }
      });

    Vuex

  • 登出重置VUEX STATE

菜单页面 MENU

  • 菜单修改页面

    • 提交表单的时候需要清空表单内容不能直接调this.refs[formRef].resetFields()

      //并不是将表单中的props直接清空
      //将所有字段值重置为开始修改之前的初始值并移除校验结果
      //在这可调可不调    
      this.resetForm(formName);
      //需要手动清空
      this.editForm = {};
  • 消息气泡官方文档点击之后的确认事件写的有问题,文档写的是confirm查看源码实际上是onConfirm

用户和角色页面

  • 对话框不展示的情况下无法获取到其组件

    • 必须先设置其展示
    • 而且要放在axios外层,可能是初始化需要时间

      //1.展示角色表单对话框
      //必须放在axios外层,可能是初始化需要时间
      this.roleDialogVisible = true;
      //2.获取该用户的角色
      this.$axios.get('/sys/user/info/' + userId).then(res => {
      console.log(res);
      //2.1将其有的角色渲染到角色树上
      this.$refs.roleTree.setCheckedKeys(res.data.data.roles)
      //2.2 把当前角色的角色数据赋值给角色表单保存
      this.roleForm.personalData = res.data.data.roles;
      });

    跨域问题

    • 前端访问验证码的时候老是出现下面的错误

      image-20210813111528241

      import axios from 'axios'
      import router from "../router";
      import el from 'element-ui';
      
      //错误在这里没有加http
      axios.defaults.baseURL = 'http://localhost:8081'
      // create an axios instance
      const request = axios.create({
          timeout: 5000, // axios timeout,
          headers: {
              'Content-Type': "application/json; charset=utf-8",
              'Access-Control-Allow-Origin': '*' //cors错误是因为请求头没加Access-Control-Allow-XXX信息
          }
      })

在给axios设置baseURL的时候没有加http://前缀,导致出错

HttpServletRequest获取post方法的参数

Spring Security采用的是默认form登录,我们前端是用的json提交的body不能这样做,只能把参数放到url上。不然get不到。

post方法不能直接getParameter(key),要使用request.getReader()读取成字符串。
后面获取参数我使用的是fastjson转成map再读取参数

//校验验证码逻辑
    private void validate(HttpServletRequest request) throws IOException {
        //1.获取验证码和key
        //key=验证码的唯一标识

        //post方法不能直接getParameter

        //把读取出来的json字符串参数再用fastjson转成map获取参数
        BufferedReader br = request.getReader();
        String str = "";
        String paramString = "";
        while ((str = br.readLine()) != null) {
            paramString += str;
        }
    
        HashMap<String,String> paramMap = JSON.parseObject(paramString, HashMap.class);


        System.out.println(paramMap );
        String key = paramMap.get("key");
        String captchaCode = paramMap.get("captchaCode");
        
        //...
    }

后台返回给前端的动态子菜单列表和权限接口

  • JwtAuthenticationFilter,父类是BasicAuthenticationFilter

每次访问非登录接口的时候,我们都会对header中的jwt进行校验,我们在20行开始获取了用户的信息,并在方法中通过redis查找了用户的权限,设置在了Authentication的实现类UsernamePasswordAuthenticationToken

这个类中存放了主体Principal和权限GrantedAuthority集合

@Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        //请求头中获取jwt,header中的jwt参数是Authentication
        String jwt = request.getHeader(jwtUtils.getTokenName());
        //没有jwt交给后面过滤器处理
        if (StrUtil.isBlankOrUndefined(jwt)) {
            chain.doFilter(request, response);
            return;
        }
        
        //解析jwt
        Claims claim = jwtUtils.getClaimByToken(jwt);
        if (claim == null) {
            throw new JwtException("token异常");
        }
        if (jwtUtils.isTokenExpired(claim)) {
            throw new JwtException("token过期,重新登录");
        }
        //jwt验证成功取出主体,主体中存放的是用户名和用户id的json字符串
        String userInfo = claim.getSubject();
        SysUser sysUser = JSON.parseObject(userInfo, SysUser.class);
        //根据用户id获取用户拥有的权限
        List<GrantedAuthority> authInfo = userDetailService.getUserAuthorities(sysUser.getId());
        
        //把用户的id和权限放置在Authentication中,UsernamePasswordAuthenticationToken是1个Authentication实现类
        //用户id是放在principal主体中的
        UsernamePasswordAuthenticationToken token
                = new UsernamePasswordAuthenticationToken(sysUser.getId(), null, authInfo);
        //不加这句代码,会进入到AuthenticationEntryPoint,代表了jwt认证失败
        SecurityContextHolder.getContext().setAuthentication(token);
        //进入下一个过滤器
        chain.doFilter(request, response);
    }
  • SysUserController
    通过Security传过来的Principal主体可以获取到在JwtAuthenticationFilter extends BasicAuthenticationFilter这个类中设置的用户信息,我们在这设置的是用户id,方便service调用接口。
/**
     *获取前端子菜单列表和其拥有权限
     * @return
     */
@GetMapping("/menu")
public Result menu(Principal principal){
    String id = principal.getName();
    Long userId = Long.valueOf(id);
    //1.获取权限信息
    String authInfo = sysUserService.getUserAuthInfo(userId);
    String[] authArr = StringUtils.tokenizeToStringArray(authInfo, ",");

    //2.获取导航栏信息
    List<SubMenuDto> subMenuDtos = sysUserService.getCurrentUserSubMenuList(userId);
    //3.返回结果
    return Result.success(MapUtil.builder()
                          .put("permList", authArr)
                          .put("subMenuList", subMenuDtos).build());
}
  • SysUserServiceImpl

    这个类有3个方法通过用户id转化成用户的前端子菜单列表

    1. getCurrentUserSubMenuList()

      sys_menu表中获取到用户的List<SysMenu>sys_menu表中存储的是用户的权限信息:type字段包括 0:目录 1:菜单 2:按钮)

    2. buildMenuTree()

      因为我们需要的是树结构(目录->菜单->按钮的父子链)我们在SysMenu中添加了一个children属性,所以可以把查出来的List<SysMenu>通过遍历查找每个SysMenu的子节点构建成树结构

    3. convert()

      因为SysMenu的属性和前端不一致所以我们需要把第2步构建的树结构集合转成前端的树结构集合,新建了个SubMenuDto映射对象,这一步就是List<SysMenu>List<SubMenuDto>

  /**
     * 获取用户的子菜单列表
     * @param userId 用户id
     * @return SubMenuDto,对应前端子菜单列表的集合
     */
    @Override
    public List<SubMenuDto> getCurrentUserSubMenuList(Long userId) {
        //1.获取用户对应的权限列表
        List<Long> menusIds = sysUserMapper.getMenusIds(userId);
        List<SysMenu> menus = sysMenuService.listByIds(menusIds);
        //2.转成DTO对象

        //2.1 SysMenu 转成 SysMenuTree对象
        List<SysMenu> sysMenuTree =  buildMenuTree(menus);
        //2.2转化成Dto集合也就是对应前端数据的树返回
        return convert(sysMenuTree);
    }

    /**
     * 把SysMenu实体类列表转化成树状结构
     * @param menus SysMenu集合
     * @return SysMenu树结构集合,和SysMenu一样只是children属性形成了一条父子链一样被注入了,因为chilren属性也是List<SysMenu>
     */
    private List<SysMenu> buildMenuTree(List<SysMenu> menus) {
        //用于存放SysMenu树的集合
        List<SysMenu> sysMenuTree = new ArrayList<>();
        //1.遍历每一个SysMenu
        for (SysMenu menu : menus) {
            //2.继续第二次遍历,找到父循环遍历元素的子节点
            //根据id判断
            for (SysMenu m : menus) {
                //不能用==因为是包装类,有缓存127以后就不行了
                if (m.getParentId().equals(menu.getId())){
                    menu.getChildren().add(m);
                }
            }
            //3.每次父循环遍历完成判断是不是根节点,是就放入树集合中
            if (menu.getParentId() == 0 )
                sysMenuTree.add(menu);
        }
        return sysMenuTree;
    }

    /**
     * 把SysMenu的树状结构对应到前端属性
     * @param sysMenuTree SysMenu树结构,被填充了children属性的SysMenu
     * @return
     */
    private List<SubMenuDto> convert(List<SysMenu> sysMenuTree) {
        //新建用于存储前端接收的集合菜单树subMenuDtos
        List<SubMenuDto> subMenuDtoTree = new ArrayList<>();
        //1.把sysMenuTree转成subMenuDtos
        sysMenuTree.forEach(menuTree -> {
            SubMenuDto subMenuDto = new SubMenuDto()
                    .setId(menuTree.getId())
                    .setName(menuTree.getPerms())
                    .setTitle(menuTree.getName())
                    .setIcon(menuTree.getIcon())
                    .setPath(menuTree.getPath())
                    .setComponent(menuTree.getComponent());
            //2.如果menuTree有子节点,递归调用当前方法
            if (menuTree.getChildren().size() > 0){
                subMenuDto.setChildren(convert(menuTree.getChildren()));
            }
            //3.每次遍历完成添加到前端的dto集合菜单树
            subMenuDtoTree.add(subMenuDto);
        });
        return subMenuDtoTree;
    }

后台字段校验

SpringBott2.4之后必须添加,spring-boot-validation依赖。

给实体类属性添加校验注解如String类型可以添加@NotBlank,不能为空。引用类型例如@NotNull不能为空。

在接口中给实体参数前添加@Validate注解代表需要验证该实体类

@PreAuthorize("hasAuthority('sys:menu:save')")
    @PostMapping("/save")
    public Result save(@Validated @RequestBody SysMenu sysMenu){
        //设置创建事件
        sysMenu.setCreated(LocalDateTime.now());
        sysMenuService.save(sysMenu);
        return Result.success(sysMenu);
    }

权限校验

前端Vue

注册全局校验方法,把获取的权限存在store中,页面动态渲染根据该方法

import Vue from 'vue';
// 为自定义的选项 'myOption' 注入一个处理器。
Vue.mixin({
    methods: {
        hasPerm(perm){
            let perms = this.$store.state.user.permList;
            return perms.indexOf(perm) > -1;
        }
    }
})

页面渲染,v-if判断方法:

<el-form-item v-if="hasPerm('sys:user:save')">
    <el-button type="primary" @click="userDialogVisible=true">新增</el-button>
</el-form-item>

后台Spring Security

每次访问非白名单接口的时候,都会在jwt认证类中通过redis获取其权限,再和接口上的权限注解比对,如果没有权限注解代表可以直接访问。

需要接口校验就在接口中加上@PreAuthorize(xxx)

  • @PreAuthorize("hasAuthority('xxx')"):对权限校验
  • @PreAuthorize("hasRole('xxx')"):对角色校验
 @PreAuthorize("hasAuthority('sys:menu:save')")
    @PostMapping("/save")
    public Result save(@Validated @RequestBody SysMenu sysMenu){
        //设置创建事件
        sysMenu.setCreated(LoHcalDateTime.now());
        sysMenuService.save(sysMenu);
        return Result.success(sysMenu);
    }
@PreAuthorize("hasRole('admin')")
    @GetMapping("/test")
    public Object test() {
        List<SysUser> sysUsers = userService.list();
        return Result.success(sysUsers);
    }

包装类型

SysUserServiceImpl中的buildMenuTree()权限转化成前端Dto所需的树结构用于渲染权限树角色树,包装类型Id是Long,在比较是不是父子关系的时候用了==可能会出现问题,因为包装类型的缓存是[-127,128]必须用equals不能用\==

异常处理

GlobalExceptionHandler

字段校验,业务处理抛出异常都在这个类中处理,AOP。

Mybtais-plus

不用再写基本的增删改查,提高效率。

集合的stream方法

集合的stream方法非常方便和js的map、filter、reduce如出一辙非常方便和js的map、filter、reduce如出一辙,代码写起来简洁高效。

直接collection.stream()可以转化成流,然后有对应的流方法可以快速完成我们想实现的效果,最后将流可以转化成集合collect方法

整合Swagger2

一个框架,直接在接口上写注释,可以直接生成接口文档。

项目部署

单项目和多项目的部署不一样,因为一直无法实现像多项目那样通过路径去访问,所以最后项目vueadmin是部署在单项目中的。参考的是markerhub的部署方式

以下写的部署方式是不正确的,尝试多项目部署的方式,但是在具体的uri下刷新会404,仅作记录使用,复习单项目部署直接去看marerhub的视频。

  • 单项目

    直接部署在nginx的html文件夹中,没有单独一个文件夹去包裹vue代码

  • 多项目

    每个项目在html文件夹下以一个文件夹去包裹项目代码,通过项目路径访问

    可以搜索"Vue多项目部署"

    https://blog.csdn.net/weixin_38023551/article/details/88640939

    nginx的alias和base实际访问路径不一样

    • alias:替换掉uri
    • base:uri+base

docker nginx容器部署vueadmin项目

我们想通过服务器ip+项目路径的方式访问网页

  1. 所以我们需要在根目录添加vue.config.js文件指定静态资源的访问路径
module.exports = {
    publicPath:'/vueadmin'
}

image-20210820001731066

  1. 在路由index.js中添加base属性

    image-20210820001917374

  2. 修改请求路径image-20210820002200085
  3. 打包项目

    到根目录dist文件夹

     npm run build

    修改名称为dist文件夹名称为vueadmin,并压缩成zip

  4. 使用sftp上传zip文件

    解压在挂在目录下的html文件夹下

    image-20210820002458884

  5. 添加配置文件

    在挂在目录下的conf.d中添加vueadmin.conf配置文件

image-20210820000810584

server {
    listen       80;
    server_name  localhost;
    #charset koi8-r;
    access_log  /var/log/nginx/host.access.log  main;
    error_log  /var/log/nginx/error.log  error;
    #ip+/vueadmin可以访问项目
    location /vueadmin {
        root   /usr/share/nginx/html;
        index  index.html index.htm;
    }
  
    error_page   500 502 503 504  /50x.html;
    location /api/ {  # 匹配api接口,进行转发配置
        rewrite  /api/(.*)  /$1  break;
        proxy_pass http://45.647.88.99:8000;  #这是重点,转发到你的后端接口
    }

}

:information_source:注意: docker 容器无法解析域名,访问接口须使用ip或者去搜索解决域名解析问题。

docker部署springboot后台

1.编写dockerfile文件

# Docker image for springboot file run

# 基础镜像使用java
FROM java:8
# 作者信息
MAINTAINER whyat <whyat@foxmail.com>
# 暴露8081端口,可以被映射到8081
EXPOSE 8081
# 添加jar包到容器
ADD vueadmin-java-0.0.1-SNAPSHOT.jar vueadmin.jar
# 运行jar包
RUN bash -c 'touch /vueadmin.jar'
ENTRYPOINT ["java","-jar","/vueadmin.jar"]

2.制作镜像

-t 参数是指定此镜像的tag名

制作完成可以通过docker images查看镜像名称

docker build -t 镜像名称 .

3.运行容器

外部映射内部

  • -d参数是让容器后台运行
  • -p 是做端口映射,此时将服务器中的8081端口映射到容器中的8081(项目中端口配置的是881)端口\
  • --name别名
docker run --name vueadmin-springboot -d -p 8081:8081 镜像名称 

访问项目

http://www.whyat.top/vueadmin/

image-20210828191627039