SpringBoot+Jwt+Vue的前后端分离后台管理系统

1. 前言

准备自己动手实践一个SpringBoot的前后端分离的后台管理项目,前端选用Vue,上手快,结合自身所学,在B站上选了一个比较简单的视频,练练手

参考的视频是B站 MarkerHub 的:https://www.bilibili.com/video/BV1af4y1s7Wh?p=1

他提供的前后端文档:

2. 前端项目

前端Vue项目不是很难,如果有时间的话,可以把B站前端视频跟完,对应着文档,最好一步一步跟着视频,逐步改进代码,没时间或者没兴趣,也可以直接下载下来

简单记录一些遇到的问题以及总结

  1. 由于是前后端分离项目,然后因为后台我们现在还没有搭建,无法与前端完成数据交互,因此我们这里需要mock数据,因此我们引入mockjs,方便后续我们提供api返回数据。所以在src目录下新建mock.js文件,用于编写随机数据的api,然后我们需要在main.js中引入这个文件

  2. 所有的页面可以在 element-ui 组件中找到,Form表单,弹出框,下拉框等等

  3. Vue项目,如果没其他配置,那么默认的就是一个单页面应用,通过/login链接看到的页面效果就是App.vue+Login.vue的结果

  4. 登录交互过程需要token的状态同步,从Header中获取用户的authorization,也就是含有用户登录信息的jwt,然后提交到store中进行状态管理。这一部分可以好好看看视频和文档

  5. 定义全局axios拦截器,包括前置拦截器和后置拦截器

  • 前置拦截,其实可以统一为所有需要权限的请求装配上header的token信息

  • 后置拦截,判断status.code和error.response.status,如果是401未登录没权限的就调到登录页面,其他的就直接弹窗显示错误

  1. 后台管理界面开发,需要抽取公共部分,一般来说Header和Aside都是不会变化的,只有Main部分会跟着链接变化而变化,所以我们可以提炼公共部分出来,放在Home.vue中,然后Main部分放在Index.vue中,那么问题来了,我们如何才能做到点击左边的Aside,然后局部刷新Main中的内容呢?在Vue中,我们可以通过嵌套路由(子路由)的形式。也就是我们需要重新定义路由,一级路由是Home.vue,Index.vue是作为Home.vue页面的子路由,然后Home.vue中我们通过来展示Index.vue的内容即可。

  2. 动态获取用户信息以及动态菜单显示(根据登录用户的权限动态显示菜单),后端数据库中应该设计的权限

  3. 动态标签页开发,我们可以这样设计:在Store中统一存储:1、当前标签Tab,2、已存在的标签Tab列表,然后页面从Store中获取列表显示,并切换到当前Tab即可。删除时候我们循环当前Tab列表,剔除Tab,并切换到指定Tab。

  4. 菜单界面,角色界面,用户界面,需要注意的是点击事件的处理以及分配权限

  5. 按钮权限的控制 hasAuth(perm),没权限的用户我们应该隐藏按钮,因此我们需要通过条件来判断按钮是否应该显示

2. 后端项目

2.1 新建springboot项目,准备数据库

技术栈

  • SpringBoot
  • mybatis plus
  • spring security
  • lombok
  • redis
  • hibernate validatior
  • jwt

导入对应的jar包:web,devtools,lombok,mysql,spring security等

创建数据库和表

因为是后台管理系统的权限模块,所以我们需要考虑的表主要就几个:用户表、角色表、菜单权限表、以及关联的用户角色中间表、菜单角色中间表。至于什么字段其实随意的,用户表里面除了用户名、密码字段必要,其他其实都听随意,然后角色和菜单我们可以参考一下其他的系统、或者自己在做项目的过程中需要的时候在添加也行。

1、数据库名称为vueadmin

2、建表语句

DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `parent_id` BIGINT(20) DEFAULT NULL COMMENT '父菜单ID,一级菜单为0',
  `name` VARCHAR(64) NOT NULL,
  `path` VARCHAR(255) DEFAULT NULL COMMENT '菜单URL',
  `perms` VARCHAR(255) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
  `component` VARCHAR(255) DEFAULT NULL,
  `type` INT(5) NOT NULL COMMENT '类型     0:目录   1:菜单   2:按钮',
  `icon` VARCHAR(32) DEFAULT NULL COMMENT '菜单图标',
  `orderNum` INT(11) DEFAULT NULL COMMENT '排序',
  `created` DATETIME NOT NULL,
  `updated` DATETIME DEFAULT NULL,
  `statu` INT(5) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of sys_menu
-- ----------------------------
INSERT INTO `sys_menu` VALUES ('1', '0', '系统管理', '', 'sys:manage', '', '0', 'el-icon-s-operation', '1', '2021-01-15 18:58:18', '2021-01-15 18:58:20', '1');
INSERT INTO `sys_menu` VALUES ('2', '1', '用户管理', '/sys/users', 'sys:user:list', 'sys/User', '1', 'el-icon-s-custom', '1', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('3', '1', '角色管理', '/sys/roles', 'sys:role:list', 'sys/Role', '1', 'el-icon-rank', '2', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('4', '1', '菜单管理', '/sys/menus', 'sys:menu:list', 'sys/Menu', '1', 'el-icon-menu', '3', '2021-01-15 19:03:45', '2021-01-15 19:03:48', '1');
INSERT INTO `sys_menu` VALUES ('5', '0', '系统工具', '', 'sys:tools', NULL, '0', 'el-icon-s-tools', '2', '2021-01-15 19:06:11', NULL, '1');
INSERT INTO `sys_menu` VALUES ('6', '5', '数字字典', '/sys/dicts', 'sys:dict:list', 'sys/Dict', '1', 'el-icon-s-order', '1', '2021-01-15 19:07:18', '2021-01-18 16:32:13', '1');
INSERT INTO `sys_menu` VALUES ('7', '3', '添加角色', '', 'sys:role:save', '', '2', '', '1', '2021-01-15 23:02:25', '2021-01-17 21:53:14', '0');
INSERT INTO `sys_menu` VALUES ('9', '2', '添加用户', NULL, 'sys:user:save', NULL, '2', NULL, '1', '2021-01-17 21:48:32', NULL, '1');
INSERT INTO `sys_menu` VALUES ('10', '2', '修改用户', NULL, 'sys:user:update', NULL, '2', NULL, '2', '2021-01-17 21:49:03', '2021-01-17 21:53:04', '1');
INSERT INTO `sys_menu` VALUES ('11', '2', '删除用户', NULL, 'sys:user:delete', NULL, '2', NULL, '3', '2021-01-17 21:49:21', NULL, '1');
INSERT INTO `sys_menu` VALUES ('12', '2', '分配角色', NULL, 'sys:user:role', NULL, '2', NULL, '4', '2021-01-17 21:49:58', NULL, '1');
INSERT INTO `sys_menu` VALUES ('13', '2', '重置密码', NULL, 'sys:user:repass', NULL, '2', NULL, '5', '2021-01-17 21:50:36', NULL, '1');
INSERT INTO `sys_menu` VALUES ('14', '3', '修改角色', NULL, 'sys:role:update', NULL, '2', NULL, '2', '2021-01-17 21:51:14', NULL, '1');
INSERT INTO `sys_menu` VALUES ('15', '3', '删除角色', NULL, 'sys:role:delete', NULL, '2', NULL, '3', '2021-01-17 21:51:39', NULL, '1');
INSERT INTO `sys_menu` VALUES ('16', '3', '分配权限', NULL, 'sys:role:perm', NULL, '2', NULL, '5', '2021-01-17 21:52:02', NULL, '1');
INSERT INTO `sys_menu` VALUES ('17', '4', '添加菜单', NULL, 'sys:menu:save', NULL, '2', NULL, '1', '2021-01-17 21:53:53', '2021-01-17 21:55:28', '1');
INSERT INTO `sys_menu` VALUES ('18', '4', '修改菜单', NULL, 'sys:menu:update', NULL, '2', NULL, '2', '2021-01-17 21:56:12', NULL, '1');
INSERT INTO `sys_menu` VALUES ('19', '4', '删除菜单', NULL, 'sys:menu:delete', NULL, '2', NULL, '3', '2021-01-17 21:56:36', NULL, '1');

-- ----------------------------
-- Table structure for sys_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `name` VARCHAR(64) NOT NULL,
  `code` VARCHAR(64) NOT NULL,
  `remark` VARCHAR(64) DEFAULT NULL COMMENT '备注',
  `created` DATETIME DEFAULT NULL,
  `updated` DATETIME DEFAULT NULL,
  `statu` INT(5) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `name` (`name`) USING BTREE,
  UNIQUE KEY `code` (`code`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of sys_role
-- ----------------------------
INSERT INTO `sys_role` VALUES ('3', '普通用户', 'normal', '只有基本查看功能', '2021-01-04 10:09:14', '2021-01-30 08:19:52', '1');
INSERT INTO `sys_role` VALUES ('6', '超级管理员', 'admin', '系统默认最高权限,不可以编辑和任意修改', '2021-01-16 13:29:03', '2021-01-17 15:50:45', '1');

-- ----------------------------
-- Table structure for sys_role_menu
-- ----------------------------
DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `role_id` BIGINT(20) NOT NULL,
  `menu_id` BIGINT(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=102 DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of sys_role_menu
-- ----------------------------
INSERT INTO `sys_role_menu` VALUES ('60', '6', '1');
INSERT INTO `sys_role_menu` VALUES ('61', '6', '2');
INSERT INTO `sys_role_menu` VALUES ('62', '6', '9');
INSERT INTO `sys_role_menu` VALUES ('63', '6', '10');
INSERT INTO `sys_role_menu` VALUES ('64', '6', '11');
INSERT INTO `sys_role_menu` VALUES ('65', '6', '12');
INSERT INTO `sys_role_menu` VALUES ('66', '6', '13');
INSERT INTO `sys_role_menu` VALUES ('67', '6', '3');
INSERT INTO `sys_role_menu` VALUES ('68', '6', '7');
INSERT INTO `sys_role_menu` VALUES ('69', '6', '14');
INSERT INTO `sys_role_menu` VALUES ('70', '6', '15');
INSERT INTO `sys_role_menu` VALUES ('71', '6', '16');
INSERT INTO `sys_role_menu` VALUES ('72', '6', '4');
INSERT INTO `sys_role_menu` VALUES ('73', '6', '17');
INSERT INTO `sys_role_menu` VALUES ('74', '6', '18');
INSERT INTO `sys_role_menu` VALUES ('75', '6', '19');
INSERT INTO `sys_role_menu` VALUES ('76', '6', '5');
INSERT INTO `sys_role_menu` VALUES ('77', '6', '6');
INSERT INTO `sys_role_menu` VALUES ('96', '3', '1');
INSERT INTO `sys_role_menu` VALUES ('97', '3', '2');
INSERT INTO `sys_role_menu` VALUES ('98', '3', '3');
INSERT INTO `sys_role_menu` VALUES ('99', '3', '4');
INSERT INTO `sys_role_menu` VALUES ('100', '3', '5');
INSERT INTO `sys_role_menu` VALUES ('101', '3', '6');

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `username` VARCHAR(64) DEFAULT NULL,
  `password` VARCHAR(64) DEFAULT NULL,
  `avatar` VARCHAR(255) DEFAULT NULL,
  `email` VARCHAR(64) DEFAULT NULL,
  `city` VARCHAR(64) DEFAULT NULL,
  `created` DATETIME DEFAULT NULL,
  `updated` DATETIME DEFAULT NULL,
  `last_login` DATETIME DEFAULT NULL,
  `statu` INT(5) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `UK_USERNAME` (`username`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES ('1', 'admin', '$2a$10$R7zegeWzOXPw871CmNuJ6upC0v8D373GuLuTw8jn6NET4BkPRZfgK', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', '123@qq.com', '广州', '2021-01-12 22:13:53', '2021-01-16 16:57:32', '2020-12-30 08:38:37', '1');
INSERT INTO `sys_user` VALUES ('2', 'test', '$2a$10$0ilP4ZD1kLugYwLCs4pmb.ZT9cFqzOZTNaMiHxrBnVIQUGUwEvBIO', 'https://image-1300566513.cos.ap-guangzhou.myqcloud.com/upload/images/5a9f48118166308daba8b6da7e466aab.jpg', 'test@qq.com', NULL, '2021-01-30 08:20:22', '2021-01-30 08:55:57', NULL, '1');

-- ----------------------------
-- Table structure for sys_user_role
-- ----------------------------
DROP TABLE IF EXISTS `sys_user_role`;
CREATE TABLE `sys_user_role` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `user_id` BIGINT(20) NOT NULL,
  `role_id` BIGINT(20) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4;

-- ----------------------------
-- Records of sys_user_role
-- ----------------------------
INSERT INTO `sys_user_role` VALUES ('4', '1', '6');
INSERT INTO `sys_user_role` VALUES ('7', '1', '3');
INSERT INTO `sys_user_role` VALUES ('13', '2', '3');

2.2 整合mybatis plus,生成代码

2.2.1 导入jar包

<!--整合mybatis plus-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.4.1</version>
</dependency>
<!--mp代码生成器-->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-generator</artifactId>
    <version>3.4.1</version>
</dependency>
<dependency>
    <groupId>org.freemarker</groupId>
    <artifactId>freemarker</artifactId>
    <version>2.3.30</version>
</dependency>

2.2.2 配置文件

server:
  port: 8081
# DataSource Config
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/vueadmin?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
mybatis-plus:
  mapper-locations: classpath*:/mapper/**Mapper.xml

2.2.3 编写MP配置类,开启mapper接口扫描,添加分页、防全表更新插件

@Configuration
@MapperScan("com.qi.mapper")
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
        // 防止全表更新插件
        interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());

        return interceptor;
    }

    /**
     * 新的分页插件,一缓和二缓遵循mybatis的规则,
     * 需要设置 MybatisConfiguration#useDeprecatedExecutor = false
     * 避免缓存出现问题(该属性会在旧插件移除后一同移除)
     */
    @Bean
    public ConfigurationCustomizer configurationCustomizer() {
        return configuration -> configuration.setUseDeprecatedExecutor(false);
    }
}

2.2.4 代码生成

  • 我们可以从MySQL数据库中,information_schema这个数据库找到我们所需要的 vueadmin 这个数据库的 TABLES 以及 COLUMNS 信息

  • 官方给我们提供了一个 代码生成器,然后写上自己的参数之后,就可以直接根据数据库表信息生成entity、service、mapper等接口和实现类。

1、在代码生成之前,可以先编写这两个基类:BaseEntity,BaseController 作为公共父类

com.qi.entity.BaseEntity

@Data
public class BaseEntity implements Serializable {

    @TableId(value = "id", type = IdType.AUTO)
    private Long id;

    private LocalDateTime created;
    private LocalDateTime updated;

    private Integer statu;
}

com.qi.controller.BaseController

public class BaseController {
}

2、CodeGenerator

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.generator.AutoGenerator;
import com.baomidou.mybatisplus.generator.InjectionConfig;
import com.baomidou.mybatisplus.generator.config.*;
import com.baomidou.mybatisplus.generator.config.po.TableInfo;
import com.baomidou.mybatisplus.generator.config.rules.NamingStrategy;
import com.baomidou.mybatisplus.generator.engine.FreemarkerTemplateEngine;

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

// 演示例子,执行 main 方法控制台输入模块表名回车自动生成对应项目目录中
public class CodeGenerator {

    /**
     * <p>
     * 读取控制台内容
     * </p>
     */
    public static String scanner(String tip) {
        Scanner scanner = new Scanner(System.in);
        StringBuilder help = new StringBuilder();
        help.append("请输入" + tip + ":");
        System.out.println(help.toString());
        if (scanner.hasNext()) {
            String ipt = scanner.next();
            if (StringUtils.isNotBlank(ipt)) {
                return ipt;
            }
        }
        throw new MybatisPlusException("请输入正确的" + tip + "!");
    }

    public static void main(String[] args) {
        // 代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 全局配置
        GlobalConfig gc = new GlobalConfig();
        String projectPath = System.getProperty("user.dir");
        gc.setOutputDir(projectPath + "/src/main/java");
        gc.setAuthor("qi-chao");
        gc.setOpen(false);
        // gc.setSwagger2(true); 实体属性 Swagger2 注解
        gc.setServiceName("%sService");
        mpg.setGlobalConfig(gc);

        // 数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/vueadmin?useUnicode=true&useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai");
        dsc.setDriverName("com.mysql.cj.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        dsc.setDbType(DbType.MYSQL);
        mpg.setDataSource(dsc);

        // 包配置
        PackageConfig pc = new PackageConfig();
        pc.setParent("com.qi");
        mpg.setPackageInfo(pc);

        // 自定义配置
        InjectionConfig cfg = new InjectionConfig() {
            @Override
            public void initMap() {
                // to do nothing
            }
        };

        // 如果模板引擎是 freemarker
        String templatePath = "/templates/mapper.xml.ftl";

        // 自定义输出配置
        List<FileOutConfig> focList = new ArrayList<>();
        // 自定义配置会被优先输出
        focList.add(new FileOutConfig(templatePath) {
            @Override
            public String outputFile(TableInfo tableInfo) {
                // 自定义输出文件名 , 如果你 Entity 设置了前后缀、此处注意 xml 的名称会跟着发生变化!!
                return projectPath + "/src/main/resources/mapper/" + pc.getModuleName()
                        + "/" + tableInfo.getEntityName() + "Mapper" + StringPool.DOT_XML;
            }
        });

        cfg.setFileOutConfigList(focList);
        mpg.setCfg(cfg);

        // 配置模板
        TemplateConfig templateConfig = new TemplateConfig();
        templateConfig.setXml(null);
        mpg.setTemplate(templateConfig);

        // 策略配置
        StrategyConfig strategy = new StrategyConfig();
        strategy.setNaming(NamingStrategy.underline_to_camel);
        strategy.setColumnNaming(NamingStrategy.underline_to_camel);
        strategy.setSuperEntityClass("BaseEntity");
        strategy.setEntityLombokModel(true);
        strategy.setRestControllerStyle(true);
        // 公共父类
        strategy.setSuperControllerClass("BaseController");
        // 写于父类中的公共字段
        strategy.setSuperEntityColumns("id", "created", "updated", "statu");
        strategy.setInclude(scanner("表名,多个英文逗号分割").split(","));
        strategy.setControllerMappingHyphenStyle(true);
        mpg.setStrategy(strategy);
        mpg.setTemplateEngine(new FreemarkerTemplateEngine());
        mpg.execute();
    }
}

3、单独运行CodeGenerator的main方法,控制台输入:sys_menu,sys_role,sys_role_menu,sys_user,sys_user_role

4、注意:运行后,把entity和controller包中 import BaseEntity以及 import BaseController 删除;关联的用户角色中间表、菜单角色中间表我们是没有created等几个公共字段的,所以我们把这两个实体继承BaseEntity去掉

2.3 结果封装

因为是前后端分离的项目,所以我们有必要统一一个结果返回封装类,这样前后端交互的时候有个统一的标准,约定结果返回的数据是正常的或者遇到异常了。

这里我们用到了一个Result的类,这个用于我们的异步统一返回的结果封装。一般来说,结果里面有几个要素必要的

  • 是否成功,可用code表示(如200表示成功,400表示异常)

  • 结果消息 msg

  • 结果数据 data

【com.qi.common.lang.Result】

import lombok.Data;
import java.io.Serializable;

@Data
public class Result implements Serializable {

    private int code;
    private String msg;
    private Object data;

    public static Result success(Object data) {
        return success(200, "操作成功", data);
    }

    public static Result success(int code, String msg, Object data) {
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }

    public static Result fail(String msg) {
        return fail(400, msg, null);
    }

    public static Result fail(int code, String msg, Object data) {
        Result r = new Result();
        r.setCode(code);
        r.setMsg(msg);
        r.setData(data);
        return r;
    }
}

写个Controller测试一下

@RestController
public class TestController {
    @Autowired
    SysUserService userService;

    @GetMapping("/test")
    public Result test() {
        return Result.success(userService.list());
    }
}

注意:如果整合了Spring Security,会有登录页面,用户名默认:user,密码是程序启动时自动生成的一串字符串,控制台可以看见

2.4 全局异常处理

  • @ControllerAdvice 表示定义全局控制器异常处理

  • @ExceptionHandler 表示针对性异常处理,可对每种异常针对性处理

【com.qi.common.exception.GlobalExceptionHandler】

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 实体校验异常捕获
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    public Result handler(MethodArgumentNotValidException e) {

        BindingResult result = e.getBindingResult();
        ObjectError objectError = result.getAllErrors().stream().findFirst().get();

        log.error("实体校验异常:----------------{}", objectError.getDefaultMessage());
        return Result.fail(objectError.getDefaultMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(value = IllegalArgumentException.class)
    public Result handler(IllegalArgumentException e) {
        log.error("Assert异常:----------------{}", e.getMessage());
        return Result.fail(e.getMessage());
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)   // 400
    @ExceptionHandler(value = RuntimeException.class)
    public Result handler(RuntimeException e) {
        log.error("运行时异常:----------------{}", e.getMessage());
        return Result.fail(e.getMessage());
    }
}

2.5 整合Spring Security和jwt(重点)

2.5.1 Spring Security

security是责任链的设计模式,是一堆过滤器链的组合,参考博客:(https://blog.csdn.net/u012702547/article/details/89629415)

流程图:https://www.processon.com/view/link/606b0b5307912932d09adcb3

1、客户端发起一个请求,进入 Security 过滤器链。

2、当到 LogoutFilter 的时候判断是否是登出路径,如果是登出路径则到 logoutHandler ,如果登出成功则到 logoutSuccessHandler 登出成功处理。如果不是登出路径则直接进入下一个过滤器。

3、当到 UsernamePasswordAuthenticationFilter 的时候判断是否为登录路径,如果是,则进入该过滤器进行登录操作,如果登录失败则到 AuthenticationFailureHandler ,登录失败处理器处理,如果登录成功则到 AuthenticationSuccessHandler 登录成功处理器处理,如果不是登录请求则不进入该过滤器。

4、进入认证BasicAuthenticationFilter进行用户认证,成功的话会把认证了的结果写入到SecurityContextHolder中SecurityContext的属性authentication上面。如果认证失败就会交给AuthenticationEntryPoint认证失败处理类,或者抛出异常被后续ExceptionTranslationFilter过滤器处理异常,如果是AuthenticationException就交给AuthenticationEntryPoint处理,如果是AccessDeniedException异常则交给AccessDeniedHandler处理。

5、当到 FilterSecurityInterceptor 的时候会拿到 uri ,根据 uri 去找对应的鉴权管理器,鉴权管理器做鉴权工作,鉴权成功则到 Controller 层,否则到 AccessDeniedHandler 鉴权失败处理器处理。

需要了解的几个组件

  • LogoutFilter - 登出过滤器

  • logoutSuccessHandler - 登出成功之后的操作类

  • UsernamePasswordAuthenticationFilter - from提交用户名密码登录认证过滤器

  • AuthenticationFailureHandler - 登录失败操作类

  • AuthenticationSuccessHandler - 登录成功操作类

  • BasicAuthenticationFilter - Basic身份认证过滤器

  • SecurityContextHolder - 安全上下文静态工具类

  • AuthenticationEntryPoint - 认证失败入口

  • ExceptionTranslationFilter - 异常处理过滤器

  • AccessDeniedHandler - 权限不足操作类

FilterSecurityInterceptor - 权限判断拦截器、出口

2.5.2 jwt

JSON Web Token (JWT)是一个开放标准(RFC 7519),它定义了一种紧凑的、自包含的方式,用于作为JSON对象在各方之间安全地传输信息。该信息可以被验证和信任,因为它是数字签名的。

JSON Web Token由三部分组成:

  • Header
  • Payload
  • Signature

JWT不保证数据不泄露,因为JWT的设计目的就不是数据加密和保护,而是为了认证来源

参考资料:https://zhuanlan.zhihu.com/p/86937325

2.5.3 redis

由于前后端分离项目,一些需要共享的信息就保存在中间件中,比如验证码信息就存到redis中

  • RedisUtil--> 操作Redis的工具类

  • RedisConfig--> 重写redisTemplate,重新定义Redis序列化规则

@Configuration
public class RedisConfig {

    @Bean
    RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) {

        RedisTemplate redisTemplate = new RedisTemplate();
        redisTemplate.setConnectionFactory(redisConnectionFactory);

        Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
        jackson2JsonRedisSerializer.setObjectMapper(new ObjectMapper());

        redisTemplate.setKeySerializer(new StringRedisSerializer());     // key:string
        redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);   // value:json

        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);

        return redisTemplate;
    }
}

2.6 用户认证

  • 首次登录认证:用户名、密码和验证码完成登录

  • 二次token认证:请求头携带Jwt进行身份认证

我们这次解决方式是在UsernamePasswordAuthenticationFilter之前自定义一个图片过滤器CaptchaFilter,提前校验验证码是否正确,这样我们就可以使用UsernamePasswordAuthenticationFilter了,然后登录正常或失败我们都可以通过对应的Handler来返回我们特定格式的封装结果数据。

生成验证码

引用了google的验证码生成器,配置一下图片验证码的生成规则:【com.qi.config.KaptchaConfig】

@Configuration
public class KaptchaConfig {

    @Bean
    DefaultKaptcha producer() {
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "4");
        properties.put("kaptcha.image.height", "40");
        properties.put("kaptcha.image.width", "120");
        properties.put("kaptcha.textproducer.font.size", "30");
        properties.put("kaptcha.textproducer.char.length","4");

        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);

        return defaultKaptcha;
    }
}

然后我们通过控制器提供生成验证码的方法:【com.qi.controller.AuthController】

@Slf4j
@RestController
public class AuthController extends BaseController{

    @Autowired
    Producer producer;

    // 图片验证码
    @GetMapping("/captcha")
    public Result captcha() throws IOException {

        String key = UUID.randomUUID().toString();
        String code = producer.createText();


        BufferedImage image = producer.createImage(code);
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        ImageIO.write(image, "jpg", outputStream);

        Encoder encoder = Base64.getEncoder();

        String str = "data:image/jpeg;base64,";

        String base64Img = str + encoder.encodeToString(outputStream.toByteArray());

        // 存储到redis中
        redisUtil.hset(Const.CAPTCHA_KEY, key, code, 120);
        log.info("验证码 -- {} - {}", key, code);
        return Result.success(
                MapUtil.builder()
                        .put("token", key)
                        .put("captchaImg", base64Img)
                        .build()

        );
    }
}

1、因为前后端分离,我们禁用了session,所以我们把验证码放在了redis中,使用一个随机字符串作为key,并传送到前端,前端再把随机字符串和用户输入的验证码提交上来,这样我们就可以通过随机字符串获取到保存的验证码和用户的验证码进行比较了是否正确

2、因为图片验证码的方式,所以我们进行了encode,把图片进行了base64编码,这样前端就可以显示图片了。

3、前端的处理,我们之前是使用了mockjs进行随机生成数据的,现在后端有接口之后,我们只需要在main.js中去掉mockjs的引入即可,并且在 axios.js 中加上 axios.defaults.baseURL = "http://localhost:8081",这样前端就可以访问后端的接口而不被mock拦截了。

【说明】:如果JDK大于8,BASE64Encoder就不适用了,需要用 import java.util.Base64.Encoder 代替

解决跨域问题

模板比较固定

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    private CorsConfiguration buildConfig() {
        CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedHeader("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.addExposedHeader("Authorization");
        return corsConfiguration;
    }

    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", buildConfig());
        return new CorsFilter(source);
    }

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                .maxAge(3600);
    }
}

我们打开redis,开启前端项目和后端项目,访问:http://localhost:8080/login,可以看到验证码显示出来了,刷新之后会随机生成

验证码认证过滤器

图片验证码校验过滤器,在登录过滤器前

@Slf4j
@Component
public class CaptchaFilter extends OncePerRequestFilter {

    @Autowired
    RedisUtil redisUtil;

    @Autowired
    LoginFailureHandler loginFailureHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {

        String url = httpServletRequest.getRequestURI();

        if ("/login".equals(url) && httpServletRequest.getMethod().equals("POST")) {
            log.info("获取到login链接,正在校验验证码 -- " + url);
            try{
                // 校验验证码
                validate(httpServletRequest);
            } catch (CaptchaException e) {
                log.info(e.getMessage());
                // 交给认证失败处理器
                loginFailureHandler.onAuthenticationFailure(httpServletRequest, httpServletResponse, e);
            }
        }
        filterChain.doFilter(httpServletRequest, httpServletResponse);
    }

    // 校验验证码逻辑
    private void validate(HttpServletRequest httpServletRequest) {

        String code = httpServletRequest.getParameter("code");
        String key = httpServletRequest.getParameter("token");

        if (StringUtils.isBlank(code) || StringUtils.isBlank(key)) {
            throw new CaptchaException("验证码不能为空");
        }

        if (!code.equals(redisUtil.hget(Const.CAPTCHA_KEY, key))) {
            throw new CaptchaException("验证码错误");
        }

        // 一次性使用
        redisUtil.hdel(Const.CAPTCHA_KEY, key);
    }
}

认证失败的话,我们之前说过,登录失败的时候交给AuthenticationFailureHandler,所以我们自定义了LoginFailureHandler,其实主要就是获取异常的消息,然后封装到Result,最后转成json返回给前端而已

身份认证过滤器 JWTAuthenticationFilter

1、后端进行用户身份识别的时候,我们需要通过请求头中获取jwt,然后解析出我们的用户名,这样我们就可以知道是谁在访问我们的接口啦,然后判断用户是否有权限等操作

2、获取到用户名之后我们直接把封装成UsernamePasswordAuthenticationToken,之后交给SecurityContextHolder参数传递authentication对象,这样后续security就能获取到当前登录的用户信息了,也就完成了用户认证。

3、当认证失败的时候会进入AuthenticationEntryPoint

4、数据库中的密码是加密的,我们使用Security内置了的BCryptPasswordEncoder加密

5、从数据库中获取用户信息以及对应权限:【UserDetailsServiceImpl】

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        SysUser sysUser = sysUserService.getByUsername(username);
        if (sysUser == null) {
            throw new UsernameNotFoundException("用户名或密码不正确");
        }
        return new AccountUser(sysUser.getId(), sysUser.getUsername(), sysUser.getPassword(), getUserAuthority(sysUser.getId()));
    }

    /**
     * 获取用户权限信息(角色、菜单权限)
     * @param userId
     * @return
     */
    public List<GrantedAuthority> getUserAuthority(Long userId){

        // 角色(ROLE_admin)、菜单操作权限 sys:user:list
        String authority = sysUserService.getUserAuthorityInfo(userId);  // ROLE_admin,ROLE_normal,sys:user:list,....

        return AuthorityUtils.commaSeparatedStringToAuthorityList(authority);
    }
}

2.7 授权

关于权限部分,也是security的重要功能,当用户认证成功之后,我们就知道谁在访问系统接口,这里又有一个问题,就是这个用户有没有权限来访问我们这个接口呢,要解决这个问题,我们需要知道用户有哪些权限,哪些角色,这样security才能我们做权限判断。

问题1:我们是在哪里赋予用户权限的?有两个地方:

1、用户登录,调用调用UserDetailsService.loadUserByUsername()方法时候可以返回用户的权限信息。

2、接口调用进行身份认证过滤器时候JWTAuthenticationFilter,需要返回用户权限信息

问题2:在哪里决定什么接口需要什么权限?

Security内置的权限注解:

  • @PreAuthorize:方法执行前进行权限检查
  • @PostAuthorize:方法执行后进行权限检查
  • @Secured:类似于 @PreAuthorize

可以在Controller的方法前添加这些注解表示接口需要什么权限。比如需要Admin角色权限:@PreAuthorize("hasRole('admin')")

整体梳理一下授权、验证权限的流程

1、用户登录或者调用接口时候识别到用户,并获取到用户的权限信息

2、注解标识Controller中的方法需要的权限或角色

3、Security通过FilterSecurityInterceptor匹配URI和权限是否匹配

4、有权限则可以访问接口,当无权限的时候返回异常交给AccessDeniedHandler操作类处理

【getUserAuthorityInfo是重点】

@Override
public String getUserAuthorityInfo(Long userId) {

    SysUser sysUser = sysUserMapper.selectById(userId);
    log.info("sysUser: "+sysUser);
    //  ROLE_admin,ROLE_normal,sys:user:list,....
    String authority = "";

    // 优先从缓存获取
    if (redisUtil.hasKey("GrantedAuthority:" + sysUser.getUsername())) {
        authority = (String) redisUtil.get("GrantedAuthority:" + sysUser.getUsername());
        log.info("authority: "+authority);

    } else {
        // 获取角色编码
        List<SysRole> roles = sysRoleService.list(new QueryWrapper<SysRole>()
                .inSql("id", "select role_id from sys_user_role where user_id = " + userId));

        if (roles.size() > 0) {
            String roleCodes = roles.stream().map(r -> "ROLE_" + r.getCode()).collect(Collectors.joining(","));
            authority = roleCodes.concat(",");
            log.info("authority: "+authority);
        }

        // 获取菜单操作编码
        List<Long> menuIds = sysUserMapper.getNavMenuIds(userId);
        if (menuIds.size() > 0) {

            List<SysMenu> menus = sysMenuService.listByIds(menuIds);
            String menuPerms = menus.stream().map(m -> m.getPerms()).collect(Collectors.joining(","));

            authority = authority.concat(menuPerms);
        }

        redisUtil.set("GrantedAuthority:" + sysUser.getUsername(), authority, 60 * 60);
    }

    return authority;
}

以看到,我通过用户id分别获取到用户的角色信息和菜单信息,然后通过逗号链接起来,因为角色信息我们需要这样“ROLE_”+角色,所以才有了上面的写法:比如用户拥有Admin角色和添加用户权限,则最后的字符串是:ROLE_admin,sys:user:save。

权限缓存

在获取用户权限那里添加了个缓存,这时候问题来了,就是权限缓存的实时更新问题,比如当后台更新某个管理员的权限角色信息的时候如果权限缓存信息没有实时更新,就会出现操作无效的问题,那么我们现在点定义几个方法,用于清除某个用户或角色或者某个菜单的权限的方法:【com.qi.service.impl.SysUserServiceImpl】

// 删除某个用户的权限信息
@Override
public void clearUserAuthorityInfo(String username) {
    redisUtil.del("GrantedAuthority:" + username);
}

// 删除所有与该角色关联的用户的权限信息
@Override
public void clearUserAuthorityInfoByRoleId(Long roleId) {

    List<SysUser> sysUsers = this.list(new QueryWrapper<SysUser>()
            .inSql("id", "select user_id from sys_user_role where role_id = " + roleId));

    sysUsers.forEach(u -> {
        this.clearUserAuthorityInfo(u.getUsername());
    });

}

// 删除所有与该菜单关联的所有用户的权限信息
@Override
public void clearUserAuthorityInfoByMenuId(Long menuId) {
    List<SysUser> sysUsers = sysUserMapper.listByMenuId(menuId);

    sysUsers.forEach(u -> {
        this.clearUserAuthorityInfo(u.getUsername());
    });
}

2.8 菜单接口开发

用户表、角色表、菜单表,只有菜单表是不需要通过其他表来获取信息的,因此菜单表的增删改查是最简单的

1、获取菜单导航和权限的链接是/sys/menu/nav,然后我们的菜单导航的json数据应该是这样的:

{
  title: '角色管理',   
  icon: 'el-icon-rank',   
  path: '/sys/roles',   
  name: 'SysRoles',   
  component: 'sys/Role',   
  children: []
}

2、然后返回的权限数据应该是个数组:["sys:menu:list","sys:menu:save","sys:user:list"...]

3、我们的SysMenu实体类中有个parentId,但是没有children,因此我们可以在SysMenu中添加一个children

4、然后我们也先来定义一个SysMenuDto吧,知道要返回什么样的数据,我们就只需要去填充数据就好了

@Data
public class SysMenuDto implements Serializable {

    private Long id;
    private String name;
    private String title;
    private String icon;
    private String path;
    private String component;
    private List<SysMenuDto> children = new ArrayList<>();
}

5、获取当前用户的菜单栏以及权限

@GetMapping("/nav")
public Result nav(Principal principal) {
    SysUser sysUser = sysUserService.getByUsername(principal.getName());

    // 获取权限信息
    String authorityInfo = sysUserService.getUserAuthorityInfo(sysUser.getId());// ROLE_admin,ROLE_normal,sys:user:list,....
    String[] authorityInfoArray = StringUtils.tokenizeToStringArray(authorityInfo, ",");

    // 获取导航栏信息
    List<SysMenuDto> navs = sysMenuService.getCurrentUserNav();

    return Result.success(MapUtil.builder()
            .put("authoritys", authorityInfoArray)
            .put("nav", navs)
            .map()
    );
}

方法中Principal principal表示注入当前用户的信息,getName就可以获取当当前用户的用户名了。sysUserService.getUserAuthorityInfo方法我们之前已经说过了,就在我们登录完成或者身份认证时候需要返回用户权限时候编写的。然后通过StringUtils.tokenizeToStringArray把字符串通过逗号分开组成数组形式。

6、重点在与sysMenuService.getcurrentUserNav,获取当前用户的菜单导航

@Override
public List<SysMenuDto> getCurrentUserNav() {
    String username = (String) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
    SysUser sysUser = sysUserService.getByUsername(username);

    // 获取用户的所有菜单
    List<Long> menuIds = sysUserMapper.getNavMenuIds(sysUser.getId());
    List<SysMenu> menus = this.listByIds(menuIds);

    // 转树状结构
    List<SysMenu> menuTree = buildTreeMenu(menus);

    // 实体转DTO
    return convert(menuTree);
}

7、菜单管理的增删改查,因为菜单列表也是个树形接口,这次我们就不是获取当前用户的菜单列表的,而是所有菜单然后组成树形结构,一样的思想,数据不一样而已。

@GetMapping("/info/{id}")
@PreAuthorize("hasAuthority('sys:menu:list')")
public Result info(@PathVariable(name = "id") Long id) {
    return Result.success(sysMenuService.getById(id));
}

@GetMapping("/list")
@PreAuthorize("hasAuthority('sys:menu:list')")
public Result list() {

    List<SysMenu> menus = sysMenuService.tree();
    return Result.success(menus);
}

@PostMapping("/save")
@PreAuthorize("hasAuthority('sys:menu:save')")
public Result save(@Validated @RequestBody SysMenu sysMenu) {

    sysMenu.setCreated(LocalDateTime.now());

    sysMenuService.save(sysMenu);
    return Result.success(sysMenu);
}

@PostMapping("/update")
@PreAuthorize("hasAuthority('sys:menu:update')")
public Result update(@Validated @RequestBody SysMenu sysMenu) {

    sysMenu.setUpdated(LocalDateTime.now());

    sysMenuService.updateById(sysMenu);

    // 清除所有与该菜单相关的权限缓存
    sysUserService.clearUserAuthorityInfoByMenuId(sysMenu.getId());
    return Result.success(sysMenu);
}

@PostMapping("/delete/{id}")
@PreAuthorize("hasAuthority('sys:menu:delete')")
public Result delete(@PathVariable("id") Long id) {

    int count = sysMenuService.count(new QueryWrapper<SysMenu>().eq("parent_id", id));
    if (count > 0) {
        return Result.fail("请先删除子菜单");
    }

    // 清除所有与该菜单相关的权限缓存
    sysUserService.clearUserAuthorityInfoByMenuId(id);

    sysMenuService.removeById(id);

    // 同步删除中间关联表
    sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("menu_id", id));
    return Result.success("");
}

删除、更新菜单的时候记得调用根据菜单id清除用户权限缓存信息的方法,然后每个方法前都会带有权限注解:@PreAuthorize("hasAuthority('sys:menu:delete')"),这就要求用户有特定的操作权限才能调用这个接口,这些数据不是乱写出来的,我们必须和数据库的数据保持一致才行,然后component字段,也是要和前端进行沟通,因为这个是链接到的前端的组件页面。

2.9 角色接口开发和用户接口开发

@RestController
@RequestMapping("/sys/role")
public class SysRoleController extends BaseController {

    @PreAuthorize("hasAuthority('sys:role:list')")
    @GetMapping("/info/{id}")
    public Result info(@PathVariable("id") Long id) {

        SysRole sysRole = sysRoleService.getById(id);

        // 获取角色相关联的菜单id
        List<SysRoleMenu> roleMenus = sysRoleMenuService.list(new QueryWrapper<SysRoleMenu>().eq("role_id", id));
        List<Long> menuIds = roleMenus.stream().map(p -> p.getMenuId()).collect(Collectors.toList());

        sysRole.setMenuIds(menuIds);
        return Result.success(sysRole);
    }

    @PreAuthorize("hasAuthority('sys:role:list')")
    @GetMapping("/list")
    public Result list(String name) {

        Page<SysRole> pageData = sysRoleService.page(getPage(),
                new QueryWrapper<SysRole>()
                        .like(StrUtil.isNotBlank(name), "name", name)
        );

        return Result.success(pageData);
    }

    @PostMapping("/save")
    @PreAuthorize("hasAuthority('sys:role:save')")
    public Result save(@Validated @RequestBody SysRole sysRole) {

        sysRole.setCreated(LocalDateTime.now());
        sysRole.setStatu(Const.STATUS_ON);

        sysRoleService.save(sysRole);
        return Result.success(sysRole);
    }

    @PostMapping("/update")
    @PreAuthorize("hasAuthority('sys:role:update')")
    public Result update(@Validated @RequestBody SysRole sysRole) {

        sysRole.setUpdated(LocalDateTime.now());

        sysRoleService.updateById(sysRole);

        // 更新缓存
        sysUserService.clearUserAuthorityInfoByRoleId(sysRole.getId());

        return Result.success(sysRole);
    }

    @PostMapping("/delete")
    @PreAuthorize("hasAuthority('sys:role:delete')")
    @Transactional
    public Result info(@RequestBody Long[] ids) {

        sysRoleService.removeByIds(Arrays.asList(ids));

        // 删除中间表
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("role_id", ids));
        sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().in("role_id", ids));

        // 缓存同步删除
        Arrays.stream(ids).forEach(id -> {
            // 更新缓存
            sysUserService.clearUserAuthorityInfoByRoleId(id);
        });

        return Result.success("");
    }

    @Transactional
    @PostMapping("/perm/{roleId}")
    @PreAuthorize("hasAuthority('sys:role:perm')")
    public Result info(@PathVariable("roleId") Long roleId, @RequestBody Long[] menuIds) {

        List<SysRoleMenu> sysRoleMenus = new ArrayList<>();

        Arrays.stream(menuIds).forEach(menuId -> {
            SysRoleMenu roleMenu = new SysRoleMenu();
            roleMenu.setMenuId(menuId);
            roleMenu.setRoleId(roleId);

            sysRoleMenus.add(roleMenu);
        });

        // 先删除原来的记录,再保存新的
        sysRoleMenuService.remove(new QueryWrapper<SysRoleMenu>().eq("role_id", roleId));
        sysRoleMenuService.saveBatch(sysRoleMenus);

        // 删除缓存
        sysUserService.clearUserAuthorityInfoByRoleId(roleId);

        return Result.success(menuIds);
    }
}

获取角色信息的方法,因为我们不仅仅在编辑角色时候会用到这个方法,在回显角色关联菜单的时候也需要被调用,因此我们需要把角色关联的所有的菜单的id也一并查询出来,也就是分配权限的操作。对应到前端就是这样的,点击分配权限,会弹出出所有的菜单列表,然后根据角色已经关联的菜单的id回显勾选上已经关联过的.

用户管理里面有个用户关联角色的分配角色操作,和角色关联菜单的写法差不多的
【SysUserController】

@RestController
@RequestMapping("/sys/user")
public class SysUserController extends BaseController {

    @Autowired
    BCryptPasswordEncoder passwordEncoder;

    @GetMapping("/info/{id}")
    @PreAuthorize("hasAuthority('sys:user:list')")
    public Result info(@PathVariable("id") Long id) {

        SysUser sysUser = sysUserService.getById(id);
        Assert.notNull(sysUser, "找不到该管理员");

        List<SysRole> roles = sysRoleService.listRolesByUserId(id);

        sysUser.setSysRoles(roles);
        return Result.success(sysUser);
    }

    @GetMapping("/list")
    @PreAuthorize("hasAuthority('sys:user:list')")
    public Result list(String username) {

        Page<SysUser> pageData = sysUserService.page(getPage(), new QueryWrapper<SysUser>()
                .like(StrUtil.isNotBlank(username), "username", username));

        pageData.getRecords().forEach(u -> {

            u.setSysRoles(sysRoleService.listRolesByUserId(u.getId()));
        });

        return Result.success(pageData);
    }

    @PostMapping("/save")
    @PreAuthorize("hasAuthority('sys:user:save')")
    public Result save(@Validated @RequestBody SysUser sysUser) {

        sysUser.setCreated(LocalDateTime.now());
        sysUser.setStatu(Const.STATUS_ON);

        // 默认密码
        String password = passwordEncoder.encode(Const.DEFULT_PASSWORD);
        sysUser.setPassword(password);

        // 默认头像
        sysUser.setAvatar(Const.DEFULT_AVATAR);

        sysUserService.save(sysUser);
        return Result.success(sysUser);
    }

    @PostMapping("/update")
    @PreAuthorize("hasAuthority('sys:user:update')")
    public Result update(@Validated @RequestBody SysUser sysUser) {

        sysUser.setUpdated(LocalDateTime.now());

        sysUserService.updateById(sysUser);
        return Result.success(sysUser);
    }

    @Transactional
    @PostMapping("/delete")
    @PreAuthorize("hasAuthority('sys:user:delete')")
    public Result delete(@RequestBody Long[] ids) {

        sysUserService.removeByIds(Arrays.asList(ids));
        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().in("user_id", ids));

        return Result.success("");
    }

    @Transactional
    @PostMapping("/role/{userId}")
    @PreAuthorize("hasAuthority('sys:user:role')")
    public Result rolePerm(@PathVariable("userId") Long userId, @RequestBody Long[] roleIds) {

        List<SysUserRole> userRoles = new ArrayList<>();

        Arrays.stream(roleIds).forEach(r -> {
            SysUserRole sysUserRole = new SysUserRole();
            sysUserRole.setRoleId(r);
            sysUserRole.setUserId(userId);

            userRoles.add(sysUserRole);
        });

        sysUserRoleService.remove(new QueryWrapper<SysUserRole>().eq("user_id", userId));
        sysUserRoleService.saveBatch(userRoles);

        // 删除缓存
        SysUser sysUser = sysUserService.getById(userId);
        sysUserService.clearUserAuthorityInfo(sysUser.getUsername());

        return Result.success("");
    }

    @PostMapping("/repass")
    @PreAuthorize("hasAuthority('sys:user:repass')")
    public Result repass(@RequestBody Long userId) {

        SysUser sysUser = sysUserService.getById(userId);

        sysUser.setPassword(passwordEncoder.encode(Const.DEFULT_PASSWORD));
        sysUser.setUpdated(LocalDateTime.now());

        sysUserService.updateById(sysUser);
        return Result.success("");
    }

    @PostMapping("/updatePass")
    public Result updatePass(@Validated @RequestBody PassDto passDto, Principal principal) {

        SysUser sysUser = sysUserService.getByUsername(principal.getName());

        boolean matches = passwordEncoder.matches(passDto.getCurrentPass(), sysUser.getPassword());
        if (!matches) {
            return Result.fail("旧密码不正确");
        }

        sysUser.setPassword(passwordEncoder.encode(passDto.getPassword()));
        sysUser.setUpdated(LocalDateTime.now());

        sysUserService.updateById(sysUser);
        return Result.success("");
    }
}

3. 项目总结以及自己遇到的问题

1、前后端交接注意名字相互对应,保证JSON数据成功读取

2、版本问题;我的JDK是大于1.8的,所以整合Spring Security的时候,报了一个错:javax.xml.bind.DatatypeConverter,这时候需要引入依赖

<dependency>
	<groupId>javax.xml.bind</groupId>
	<artifactId>jaxb-api</artifactId>
	<version>2.3.0</version>
</dependency>

3、spring security中密码问题,Encoded password does not look like BCrypt,解决方法:用Security内置了的BCryptPasswordEncoder加密,我写了bcPwd工具类加密密码,然后存到数据库中

4、用户认证与授权部分,Spring Security的责任链需要熟悉熟悉,这个项目重写了一些过滤器,最重要的就是JWT认证,从token中获取用户,然后获取相应权限,实现授权

5、登录中添加了验证码,两种思路:一是在用户登录之前添加验证码过滤器,二是重写UsernamePasswordAuthtionFilter,把验证码逻辑加上去

6、登录成功之后我们利用用户名生成jwt,然后把jwt作为请求头返回回去,名称就叫Authorization,登录成功之后前端就可以获取到了jwt的信息,前端中我们是保存在了store中,同时也保存在了localStorage中,然后每次axios请求之前,我们都会添加上我们的请求头信息

7、前后端对接的问题,开发前端的时候,我们都是使用mockjs返回随机数据。有后端数据后,去后端拿数据:axios.defaults.baseURL = "http://localhost:8081" 并且注释掉mock

8、线上部署问题:我的服务器上redis是集群配置,SpringBoot会出错READONLY You can’t write against a read only slave,进入redis.conf配置文件,修改配置文件的slave-read-only为no

4. 项目地址

GitHub:https://github.com/chaoqi666/vueadmin

线上访问:http://www.qi-chao.com:8090
(服务器中病毒了,崩了,访问不了,正在修复。。。)

posted @ 2021-09-07 17:36  qi_chao  阅读(625)  评论(0编辑  收藏  举报