内容

后端:SpringBoot + MyBatisPlus + SpringSecurity + Redis + Activiti + MySQL

前端:Vue-admin-template + Node.js + Npm + Vue + ElementUI + Axios

功能:管理端和员工端

  1. 管理端:权限管理、审批管理、公众号菜单管理
  2. 员工端:微信公众号操作,办公审批、微信授权登录、消息推送

数据缓存:Redis

数据库:MySQL

权限控制:SpringSecurity

工作流引擎:Activiti

前端:vue-admin-template、Node.js、Npm、Vue、ElementUI、Axios

微信公众号:公众号菜单、微信授权登录、消息推送

架构

  1. oa-parent
    1. common:通用内容
      1. common-util:核心工具类
      2. service-util:业务模块工具类
    2. model:模型层,包含所有实体类
    3. service-oa:业务层,包含所有业务模块

image-20230412175035896

构建依赖:service-util 的 xml 文件的依赖中包含了 common-util

lombok:用于简化实体类,加一个注解就不用写 get、set、toString 等等

MyBatis-Plus

MyBatis 是对 JDBC 的简化,MyBatis-Plus 是对 MyBatis 的进一步简化

特性:

  1. 强大的 CRUD 操作:内置了通用 Mapper、通用 Service,能够通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求

  2. 支持 Lambda 表达式:能够方便编写各类查询条件,不用担心字段会写错

  3. 支持主键自动生成:4种主键策略,可以自由配置,完美解决主键问题

  4. 内置代码生成器:采用代码或 Maven 插件可以快速生成 Mapper、Model、Service、Controller 层代码,支持模板引擎

  5. 内置分页插件:基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件后,写分页等同于普通 List 查询

  6. 分页插件支持多种数据库:MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等

角色管理:

  1. 创建数据库和角色表
  2. 创建 SpringBoot 配置文件
  3. 创建角色实体类
  4. 创建 Mapper 接口,继承 MP 封装接口
  5. 创建 SpringBoot 启动类
  6. 进行增删改查测试

SpringBoot 配置:

<!--application.yml-->
spring:
  application:
    name: service-oa
  profiles:
    active: dev

<!--application-dev.uml-->
server:
  port: 8800
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
spring:
  datasource:
    type: com.zaxxer.hikari.HikariDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/bluestragglers-oa?serverTimezone=GMT%2B8&useSSL=false&characterEncoding=utf-8
    username: root
    password: 123456

创建角色实体类:

// SysRole.java

@Data // 生成 get、set 方法
@ApiModel(description = "角色")
@TableName("sys_role") // MP提供的和表的对应
public class SysRole extends BaseEntity {

    private static final long serialVersionUID = 1L;

    //@NotBlank(message = "角色名称不能为空")
    @ApiModelProperty(value = "角色名称") // 对 model 属性进行说明或对数据操作进行更改,包含 value 说明、name 重写属性名、dataType 重写属性类型、required 是否必须、example 举例、hidden 隐藏
    @TableField("role_name") // MP提供的和属性的对应
    private String roleName;

    @ApiModelProperty(value = "角色编码")
    @TableField("role_code")
    private String roleCode;

    @ApiModelProperty(value = "描述")
    @TableField("description")
    private String description;

}

// BaseEntity.java

@Data
public class BaseEntity implements Serializable {

    @TableId(type = IdType.AUTO) // MP设置主键的增长方式,AUTO 是自动增长,还有 ASSIGN_ID、ASSIGN_UUID 随机分配、INPUT 输入主键、NONE 无策略
    private Long id;

    @TableField("create_time")
    private Date createTime;

    @TableField("update_time")
    private Date updateTime;

    @TableLogic
    @TableField("is_deleted")
    private Integer isDeleted;

    @TableField(exist = false) // exist 表示是否为数据库表字段,false 表示不是数据库表字段
    private Map<String,Object> param = new HashMap<>();
}

创建 Mapper 接口,继承 MP 封装接口:

@Mapper // 声明这个接口是个动态创建对象
public interface SysRoleMapper extends BaseMapper<SysRole> {

}
// BaseMapper 的所有方法 MP 都自动实现了

创建 SpringBoot 启动类:

@SpringBootApplication
@MapperScan("com.bluestragglers.*.mapper") // 扫描所有 mapper,从而找到 Mapper 动态创建对象
public class ServiceAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServiceAuthApplication.class, args);
    }

}

测试:

MP CRUD 操作

@SpringBootTest
public class TestMpDemo1 {

    // 自动注入
    @Autowired
    private SysRoleMapper mapper;

    @Test
    public void getAll() {
        List<SysRole> list = mapper.selectList(null);
        System.out.println(list);
    }

    @Test
    public void add() {
        SysRole sysRole = new SysRole();
        sysRole.setRoleName("角色管理员");
        sysRole.setRoleCode("role");
        sysRole.setDescription("角色管理员");
        int rows = mapper.insert(sysRole); // 影响的行数
        System.out.println(rows);
        System.out.println(sysRole);
    }

    @Test
    public void update() {
        // 根据 ID 查询
        SysRole sysRole = mapper.selectById(9);
        // 设置修改值
        sysRole.setDescription("BlueStragglers角色管理员");
        // 调用方法
        int rows = mapper.updateById(sysRole);
        System.out.println(rows);
    }

    @Test
    public void delete() {
        int rows = mapper.deleteById(9);
        System.out.println(rows);
    }
}

image-20230413155653103

同时,由于前面配置了日志,所以输出也包含了许多日志信息

image-20230413160647157

物理删除和逻辑删除:物理删除是真正删除,表里就没有了。逻辑删除是实现了删除操作,但数据还存在,只是数据查询不出来了。也就是说,逻辑删除是一个修改操作,通过标志位实现了逻辑删除。例如设置一个 is_deleted,默认情况下 0 表示没有删除,1 表示已经删除。在 BaseEntity 中:

// BaseEntity.java

@TableLogic // 其实 MP 默认的就是逻辑删除,所以不写这俩注释也是可以的
@TableField("is_deleted")
private Integer isDeleted;

image-20230413161318424

再查询,可以发现查不到了。因为查的是 is_deleted=0 的,也就是存在的数据

image-20230413161453021

MP 条件查询封装:Wrapper

  1. UpdateWrapper 是 Update 的条件封装,用于更新
  2. QueryWrapper 是查询实体的操作
  3. LambdaQueryWrapper、LambdaUpdateWrapper 是上面两种方法的 Lambda 版

image-20230413161853656

//TestMpDemo1.java

@Test
public void testQuery1() {
  // 创建 QueryWrapper 对象,调用方法封装条件
  QueryWrapper sysRoleQueryWrapper = new QueryWrapper<>();
  sysRoleQueryWrapper.eq("role_name", "总经理"); // 注意,这里是表中字段名称,也就是注解里面的
  // 调用 MP 方法实现查询操作
  List sysRoles = mapper.selectList(sysRoleQueryWrapper);
  System.out.println(sysRoles);
}

@Test
public void testQuery2() {
  LambdaQueryWrapper sysRoleLambdaQueryWrapper = new LambdaQueryWrapper<>();
  sysRoleLambdaQueryWrapper.eq(SysRole::getRoleName, "总经理"); // 这种写法优点:不需要关注表中字段名称
  List sysRoles = mapper.selectList(sysRoleLambdaQueryWrapper);
  System.out.println(sysRoles);
}

image-20230413163312567

MP 封装 service 层

MP 封装了 Service 层和 DAO 层

image-20230413164739240

// SysRoleService.java

// 这里继承 IService<T>
public interface SysRoleService extends IService<SysRole> {

}

// SysRoleServiceImpl.java

@Service
// 这里继承 ServiceImpl<BaseMapper<T>, T> 并实现 interface
public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> implements SysRoleService {

}

随后进行测试

@SpringBootTest
public class TestMpDemo2 {

    // 自动注入
    @Autowired
    private SysRoleService sysRoleService;

    @Test
    public void getAll() {
        List<SysRole> list = sysRoleService.list();
        System.out.println(list);
    }

}

可以发现,结果与直接调用 DAO 的 getAll() 一样。因此,MP 已经将 DAO 到 Service 的基础 CRUD 操作都实现了。IService 中提供了许多的操作,可以多看看

image-20230413165456158

角色管理

前后端分离开发

后端接口:

  1. 查询所有角色
  2. 条件分页查询角色
  3. 添加角色
  4. 修改角色
  5. 删除角色(根据 id、批量)

查询所有角色:

@RestController
@RequestMapping("/admin/system/sysRole")
public class SysRoleController {

    @Autowired
    private SysRoleService sysRoleService;

    // http://localhost:8800/admin/system/sysRole/findAll
    @GetMapping("/findAll")
    public List<SysRole> findAll() {
//        System.out.println(sysRoleService.list());
        return sysRoleService.list();
    }

}

统一返回值格式

因为所有人开发习惯都不同,所以为了保证一致性,需要让所有返回结果的格式一样。数据可以不一样,但格式要求一样。一般会封装为 json

// 列表
{
  "code": 200,
  "message": "成功",
  "data": [
    {
      "id": 2,
      "roleName": "系统管理员"
    }
  ],
  "ok": true
}

// 分页
{
  "code": 200,
  "message": "成功",
  "data": {
    "records": [
      {
        "id": 2,
        "roleName": "系统管理员"
      },
      {
        "id": 3,
        "name": "普通管理员"
      }
    ],
    "total": 10,
    "size": 3,
    "current": 1,
    "orders": [],
    "hitCount": false,
    "searchCount": true,
    "pages": 2
  },
  "ok": true
}

// 没有返回数据
{
  "code": 200,
  "message": "成功",
  "data": null,
  "ok": true
}

// 失败
{
  "code": 201,
  "message": "失败",
  "data": null,
  "ok": false
}

里面都包含三个内容:状态码、信息、数据

因为后面都要使用这套返回结果,所以要定义一个统一的返回结果类,放到 common-util 中

// ResultCodeEnum.java 枚举类

@Getter
public enum ResultCodeEnum {
    SUCCESS(200, "成功"),
    FAIL(201, "失败"),
//    SERVICE_ERROR(2012, "服务异常"),
//    DATA_ERROR(204, "数据异常"),
//    LOGIN_AUTH(208, "未登录"),
//    PERMISSION(209, "没有权限")
    ;

    private Integer code;
    private String message;

    private ResultCodeEnum(Integer code, String message) {
        this.code = code;
        this.message = message;
    }
}

// Result.java

@Data
public class Result<T> {

    private Integer code; // 状态码
    private String message; // 信息
    private T data; // 因为数据是不同的类型,所以加一个枚举类 T

    // 构造方法私有化,对外需要提供静态接口
    private Result() {}

    public static<T> Result<T> build(T data, ResultCodeEnum resultCodeEnum) {
        Result<T> result = new Result<>();
        if (data != null) {
            result.setData(data);
        }
        result.setCode(resultCodeEnum.getCode());
        result.setMessage(resultCodeEnum.getMessage());
        return result;
    }

    public static<T> Result<T> ok() {
        return build(null, ResultCodeEnum.SUCCESS);
    }

    public static<T> Result<T> ok(T data) {
        return build(data, ResultCodeEnum.SUCCESS);
    }

    public static<T> Result<T> fail() {
        return build(null, ResultCodeEnum.FAIL);
    }

    public Result<T> message(String message) {
        this.setMessage(message);
        return this;
    }

    public Result<T> code(Integer code) {
        this.setCode(code);
        return this;
    }
}

然后需要修改 SysRoleController 的返回结果

// SysRoleController.java

@GetMapping("/findAll")
public Result findAll() {
  List<SysRole> list = sysRoleService.list();
  return Result.ok(list);
}

结果:

{"code":200,"message":"成功","data":[{"id":1,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"管理员","roleCode":"admin","description":"1"},{"id":2,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"总经理","roleCode":"manager","description":"2"}]}

Knife4j

Knife4j 是为 Java MVC 框架集成 Swagger 生成 API 文档的增强解决方案

Swagger:前后端分离开发模式中,API 文档是最好的沟通方式。Swagger 是一个规范完整的框架,用于构建 RESTful 风格的 Web 服务。1. 及时性,接口变更后能够及时准确地通知相关前后端开发人员。2. 规范性,能够保证接口规范性。3. 一致性,接口信息一致。4. 可测性,可以直接在接口文档上进行测试,方便理解业务

操作模块:service-util

这个模块有问题,先不搞了

条件分页查询

首先需要配置分页插件,在 service-oa 下面编写 MybatisPlus 配置类

// MybatisPlusConfig.java

@Configuration
@MapperScan("com.bluestragglers.auth.mapper") // 配置 mapper 扫描位置
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

}

// ServiceAuthApplication.java

@SpringBootApplication
@ComponentScan("com.bluestragglers") // 去掉原来的 MapperScan 注解,换成 ComponentScan,避免默认扫描当前包
public class ServiceAuthApplication {

    public static void main(String[] args) {
        SpringApplication.run(ServiceAuthApplication.class, args);
    }

}

编写 controller 的分页方法。因为是条件查询,所以需要提供一系列参数,包括分页相关参数(当前页、每页显示记录)和条件参数。

// SysRoleController.java

// 条件分页查询,page 是当前页,limit 是每页显示记录数,sysRoleQueryVo 是条件对象
@GetMapping("{page}/{limit}")
public Result pageQueryRole(@PathVariable Long page,
                            @PathVariable Long limit,
                            SysRoleQueryVo sysRoleQueryVo) {
  return (Result) sysRoleService.getPageQueryRole(page, limit, sysRoleQueryVo);
}

最后调用 service 中的方法,实现条件分页查询

// SysRoleService.java

public Result getPageQueryRole(Long page,
                                   Long limit,
                                   SysRoleQueryVo sysRoleQueryVo);

// SysRoleServiceImpl.java

public Result getPageQueryRole(Long page,
                                   Long limit,
                                   SysRoleQueryVo sysRoleQueryVo) {
  // 调用 service 方法实现
  // 1. 创建 page 对象,传递分页相关参数
  Page<SysRole> pageParam = new Page<>(page, limit);

  // 2. 封装条件,需要判断条件值是否为空,不为空进行封装
  LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
  String roleName = sysRoleQueryVo.getRoleName();
  if (StringUtils.isNotEmpty(roleName)) {
    // 封装
    wrapper.like(SysRole::getRoleName, roleName);
  }

  // 3. 调用方法实现
  IPage<SysRole> pageModel = this.page(pageParam, wrapper);
  return Result.ok(pageModel);
}

最终效果:

http://localhost:8800/admin/system/sysRole/1/2

{"code":200,"message":"成功","data":{"records":[{"id":1,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"管理员","roleCode":"admin","description":"1"},{"id":2,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"总经理","roleCode":"manager","description":"2"}],"total":2,"size":2,"current":1,"orders":[],"optimizeCountSql":true,"searchCount":true,"maxLimit":null,"countId":null,"pages":1}}

http://localhost:8800/admin/system/sysRole/1/1

{"code":200,"message":"成功","data":{"records":[{"id":1,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"管理员","roleCode":"admin","description":"1"}],"total":2,"size":1,"current":1,"orders":[],"optimizeCountSql":true,"searchCount":true,"maxLimit":null,"countId":null,"pages":2}} 

http://localhost:8800/admin/system/sysRole/2/1

{"code":200,"message":"成功","data":{"records":[{"id":2,"createTime":"2023-04-13T07:43:54.000+00:00","updateTime":"2023-04-13T07:43:54.000+00:00","isDeleted":0,"param":{},"roleName":"总经理","roleCode":"manager","description":"2"}],"total":2,"size":1,"current":2,"orders":[],"optimizeCountSql":true,"searchCount":true,"maxLimit":null,"countId":null,"pages":2}}

批量删除:JAVA 对象对应的格式是 {"name": ..., "age": ...} 这样的 json 格式,而 List 集合对应的是 [1,2,3] 这样的 json 格式

image-20230414172923353

其他功能:

// 添加角色
    @PostMapping("save")
    public Result save(@RequestBody SysRole role) {
        boolean is_success = sysRoleService.save(role);
        if (is_success) {
            return Result.ok();
        } else {
            return Result.fail();
        }
    }

    // 修改角色 - 根据 id 查询
    @GetMapping("get/{id}")
    public Result get(@PathVariable Long id) {
        SysRole sysRole = sysRoleService.getById(id);
        return Result.ok(sysRole);
    }

    // 修改角色 - 最终修改
    @PutMapping("update")
    public Result update(@RequestBody SysRole role) {
        boolean is_success = sysRoleService.updateById(role);
        if (is_success) {
            return Result.ok();
        } else {
            return Result.fail();
        }
    }

    // 根据 id 删除
    @DeleteMapping("remove/{id}")
    public Result remove(@PathVariable Long id) {
        boolean is_success = sysRoleService.removeById(id);
        if (is_success) {
            return Result.ok();
        } else {
            return Result.fail();
        }
    }

    // 批量删除
    // 前端使用 json 的数组格式进行传递 [1,2,3]
    @DeleteMapping("batchRemove")
    public Result batchRemove(@RequestBody List<Long> idList) {
        boolean is_success = sysRoleService.removeByIds(idList);
        if (is_success) {
            return Result.ok();
        } else {
            return Result.fail();
        }
    }

image-20230414174049590

统一异常处理

目标:就算有异常,返回也是同样的 Result 格式

因为那里都会用到,所以放在 service-util 中

实现过程:

  1. 全局异常处理
  2. 特定异常处理
  3. 自定义异常处理

异常处理具体实现:

  1. 创建类,并添加注解 @ControllerAdvice
  2. 在类中添加执行的方法,方法上添加注解,指定哪个异常出现会执行 @ExceptionHandler(Exception.class) @ResponseBody
//GlobalExceptionHandler.java

@ControllerAdvice
public class GlobalExceptionHandler {

    // 全局异常执行方法
    @ExceptionHandler(Exception.class) // 异常切面,切点相当于是 controller 注解
    @ResponseBody // 返回 json 数据
    public Result error() {
        return Result.fail().message("执行了全局异常处理");
    }

    // 特定异常处理
    @ExceptionHandler(ArithmeticException.class)
    @ResponseBody
    public Result error(ArithmeticException e) {
        e.printStackTrace();
        return Result.fail().message("执行了特定异常处理");
    }

}

image-20230415105850498

可以发现,有特定异常处理时会先调用特定异常处理(也就是更小的异常类)

image-20230415110319087

自定义异常处理:

  1. 创建异常类,继承 RuntimeException
  2. 在异常类添加属性,状态码和描述信息
  3. 在出现异常的地方手动抛出异常
  4. 在异常处理类里面添加执行方法
// BlueStragglersException.java

@Data
public class BlueStragglersException extends RuntimeException {

    private Integer code; // 异常状态码
    private String message; // 异常描述信息

    public BlueStragglersException(Integer code, String message) {
        super(message);
        this.code = code;
        this.message = message;
    }

    public BlueStragglersException(ResultCodeEnum resultCodeEnum) {
        super(resultCodeEnum.getMessage());
        this.code = resultCodeEnum.getCode();
        this.message = resultCodeEnum.getMessage();
    }

    @Override
    public String toString() {
        return "BlueStragglersException{" +
                "code=" + this.code +
                ", message=" + this.message +
                "}";
    }

}

// GlobalExceptionHandler.java

@ExceptionHandler(BlueStragglersException.class)
@ResponseBody
public Result error(BlueStragglersException e) {
  e.printStackTrace();
  return Result.fail().code(e.getCode()).message(e.getMessage());
}

// SysRoleController.java

try {
  int val = 1/0;
} catch (Exception e) {
  throw new BlueStragglersException(12345, "执行了自定义异常处理");
}

image-20230415112809335

前端设计

后端开发接口,前端调用后端接口(Ajax),由接口返回 json 数据,前端显示 json 数据

image-20230415113130267

PRD(产品原型-产品经理)、PSD(视觉设计-UI工程师)、HTML/CSS/JavaScript(PC/移动端网页,实现网页端视觉展示和交互-前端工程师)

创建工作区

VS Code 开发前端需要创建工作区,创建步骤:

  1. 创建空文件夹
  2. 使用 vscode 打开文件夹
  3. 另存为工作区

image-20230415113851918

创建 HTML 文件测试一下:新建个文件,输入 !,点击第一个即可快速创建 HTML 模板。写完后,右键,选择 open with live server 即可在浏览器中打开。没有的话输入 127.0.0.1:5500/test/00-hello.html

ES入门

ES 是 ECMAScript 的简称,ES6 是最新的,于15年发布,是 JS 的一个标准

  1. 模板字符串:
<body>
    <script>
        var name = "lucy"
        var age = 20
        // 在 info 内获取 name 和 age,并让 age + 1
        // 写法:模板字符串 + 表达式,模板字符串:``,表达式:${}
        var info = `name is ${name}, age is ${age + 1}`
        console.log(info) // 控制台输出
    </script>
</body>
  1. 对象拓展运算符
<body>
    <script>
        let person1 = {name: "Amy", age: 15}
        // 对象复制
        let someone = {...person1}
        console.log(someone)
    </script>
</body>
  1. 箭头函数(lambda)
<body>
    <script>
        var f1 = function(a, b) {
            return a + b;
        }
        var f2 = (a, b) => (a + b)
        console.log(f2(5, 4))
    </script>
</body>

Vue入门

入门案例

<body>
    <div id="app">
        {{message}} 
    </div>
    <script src="vue.min.js"></script>
    <script>
        new Vue({ // 新建一个 Vue 对象
            el: '#app', // 显示位置,这里设置为上面的 div 的名字 app
            data: { // 模型数据
                message: 'hello vue'
            }
        })
    </script>
</body>

Vue.js 的核心是允许采用简洁的模板语法来声明式地将数据渲染到 DOM 系统,而没有繁琐的 DOM 操作。例如 jQuery 中需要先找到 div 节点,然后获取到 DOM 对象,然后进行节点操作,这里就不需要了

Vue 生命周期

主要用到的方法:created 和 mounted,一个在页面渲染前执行,一个在渲染后执行

这里面 debugger 也很重要,是 js 里面的断点

<body>
    <div id="app">
        {{message}}
    </div>
    <script src="vue.min.js"></script>
    <script>
        new Vue({
            el: '#app',
            data: {
                message: 'hello vue'
            },
            created() { // 渲染前执行
                debugger // js 的断点
                console.log('created...')
            },
            mounted() { // 渲染后执行
                debugger
                console.log('mounted...')
            }
        })
    </script>
</body>

image-20230415163651419

然后先后输出 created ... 和 mounted ...

created 用于先生成数据

mounted 用于进行渲染

Axios

Axios 是独立于 Vue 的一个项目,在浏览器中可以帮助完成 ajax 请求的发送,在 node.js 中可以向远程接口发送请求

步骤:

  1. 在 html 页面引入 js 文件,包括 vue 和 axios 的 js 文件
  2. 在 script 中编写 vue 代码
    1. el: 展示位置
    2. data: 数据
    3. created() 页面渲染前执行
    4. methods: 定义具体方法
  3. 创建 json 格式的文件,在代码中通过 axios 发送 ajax 请求得到数据,并在控制台显示

调用方法:

{
    "success": true,
    "code": 200,
    "data": [
        {
            "name": "lucy",
            "age": 20
        },
        {
            "name": "mary",
            "age": 30
        }
    ]
}
<body>
    <script src="vue.min.js"></script>
    <script src="axios.min.js"></script>
    <div id="app">

    </div>
    <script>
        new Vue({
            el: '#app',
            data: {
                userList:[] // 列表数组
            },
            created() { // 页面渲染前执行
                this.getList() // 调用方法
            },
            methods: {
                getList() { // 通过 axios 发送 ajax 请求
                    axios.get("data.json")  
                        .then(response => {
                            this.userList = response.data.data // 得到目标数据,第一个 data 是响应体,第二个 data 才是数据
                            console.log(this.userList)
                        }) // 当请求成功的时候,会调用 then()
                        .catch(error => {
                            console.log(error)
                        }) // 请求失败会调用 catch()
                }
            }
        })
    </script>
</body>

image-20230415165448398

返回内容:Http 协议、响应行、响应头、响应体,其中我们关注的 data 是响应体,data 中还有一个 data 是我们得到的 json 数据

NodeJS

NodeJS 类似于 Java 中的 JDK。简单来说 NodeJS 就是运行在服务器端的 JavaScript,是一个事件驱动 IO 服务端的 JavaScript 环境,基于 Google 的 v8 引擎,v8 执行 JavaScript 速度非常快,性能很好

NodeJS 有什么用?作为前端程序员,如果不懂得 PHP、Python、Ruby 等动态编程语言,然后想创建自己的服务,那么 NodeJS 是很好的选择。NodeJS 也可以用于部署高性能服务

在这个项目里只用 NodeJS 作为前端运行环境

image-20230415204916220

NPM

NPM 全称 Node Package Manager,是 Node.js 包管理工具,相当于前端的 Maven

使用 NPM 管理项目:

  1. 建一个空文件夹,初始化 npm init。想直接生成 package.json 文件,可以用 npm init -y。package.json 就类似 maven 的 pom.xml
  2. 下载包 npm install xxx@version,也可以改 package.json 然后执行 npm install 命令

NVM

NVM 是管理 NPM 的虚拟环境,相当于前端的 Conda

常用命令:

nvm ls # 查看所有版本
nvm install 17.0.0 # 安装指定版本
nvm use 17.0.0 # 使用指定版本
nvm alias dev 17.0.0 # 设置指定版本别名
nvm alias default dev # 设置默认版本

前端模块化开发

JavaScript 不是一种模块化编程语言,不支持类、包等概念,更不用说模块了。然而不采用模块化开发容易产生命名冲突、文件依赖。

ES6 模块化写法1:在 es6-01 文件夹下面写这两个文件

// 01.js

export function getList() {
    console.log('获取数据列表')
}

export function save() {
    console.log('保存数据')
}

// 02.js

// 调用 01.js 的方法
// 1. 引入 01.js 文件
import { getList, save } from "./01";

// 2. 调用方法
getList()
save()

然后,重点来了,如何利用环境进行调试呢?直接 node 02.js 会导致错误,因为 ES6 并不支持这种模块式开发方式。需要切换为 ES5 并进行编译才行

步骤:

  1. nvm use dev 切换 node 和 npm
  2. npm install --global babel-cli 下载转码工具,并测试: babel --version
  3. 配置 .babelrc,将 es2015 规则加入 .babelrc:{"presets": ["es2015"], "plugins": []}
  4. 安装转码器 npm install --save-dev babel-preset-es2015
  5. 转码 mkdir dist1,babel es6-01 -d dist1
  6. 运行程序 node dist1/02.js

    另一种写法:

// 001.js
export default { // default 写法
    getList() {
        console.log('获取数据列表2')
    },
    save() {
        console.log('保存数据2')
    }
}

// 002.js
import user from "./001" // 类似对象

user.getList()
user.save()

image-20230416150857604

前端框架

vue-element-admin 和 vue-admin-template

vue-admin-template 是 vue-element-admin 的最小精简版本,可以作为模板进行二次开发。可以把 vue-element-admin 作为工具箱,想要什么功能或组件就去 vue-element-admin 那里复制过来就可以

使用步骤:

  1. 去 github 下载代码
  2. 下载依赖 npm install
  3. 启动项目 npm run dev

image-20230416154149399

image-20230416154159562

源文件目录结构:

  1. dist:生产环境打包生成的打包项目
  2. mock:模拟接口,比如登录就是模拟的
  3. public:包含会被自动打包到项目根路径的文件夹。其中 index.html 是唯一的界面
  4. src:代码集合
    1. api:包含接口请求的函数模块
      1. table.js 表格列表,模拟数据接口的请求函数
      2. user.js 用户登录相关接口的请求函数
    2. assets:组件中需要使用的公共资源
    3. components:非路由组件
      1. SvgIcon、Breadcrumb(面包屑组件,头部水平方向的层级组件)、Hamburger(切换左侧菜单导航的图标组件)
    4. icons:
      1. svg:包含 svg 图片文件
      2. index.js:全局注册 SvgIcon 组件,加载所有 svg 图片并暴露所有 svg 文件名的数组
    5. layout:
      1. components:组成整体布局的一些组件
      2. mixin:组件中可复用的代码
      3. index.vue:后台管理的整体界面布局组件
    6. router:路由信息
      1. index.js
    7. store
    8. styles
    9. utils:封装工具类
      1. request.js:使用了 axios
    10. views:页面集合,内容都是 index.vue,后缀是 vue
      1. dashboard:首页
      2. login:登录
    11. main.js:程序入口,引入各个内容
    12. permission.js:全局守卫,实现路由权限控制
    13. settings.js:包含应用设置信息的模块
    14. App.vue:应用根组件
  5. vue.config.js:配置代理服务器等

后面需要修改的主要是 api 接口,随后需要修改 views 里面的相关页面

修改登录界面

首先看登录的 Header:http://localhost:9528/dev-api/vue-admin-template/user/info?token=admin-token

然后看 Response:

{"code":20000,"data":{"roles":["admin"],"introduction":"I am a super administrator","avatar":"https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif","name":"Super Admin"}}

这里先修改 Header 对应的 URL。首先看 http://localhost:9528/dev-api,需要修改成新的 ip 和端口号,修改方式有多种,这里列出两种,并采用第二种修改:

  1. 修改 .env.development,将 VUE_APP_BASE_API 修改成目标地址加端口号
  2. 修改 vue.config.js,将 mock 修改为本地地址
// before: require('./mock/mock-server.js')
proxy: {
  '/dev-api': {// 匹配所有以 '/dev-api' 开头的请求路径
    target: 'http://localhost:8800',
    changeOrigin: true, // 支持跨域
    pathRewrite: { // 重写路径,去掉路径中开头的 '/dev-api'
      '^/dev-api': ''
    }
  }
}

修改后,路径变成 http://localhost:8800/...

这里面的跨域:访问协议、地址、端口号

后端创建接口

随后,需要在后端创建接口,返回与 mock 相同的数据。这里需要和 src/api/user.js 中相对应

//IndexController.java

@RestController
@RequestMapping("/admin/system/index")
public class IndexController {

    // login 接口
    @PostMapping("login")
    public Result login() {
        // {"code": 20000, "data": {"token": "admin-token"}}
        Map<String, Object> map = new HashMap<>();
        map.put("token", "admin");
        return Result.ok(map);
    }

    // info 接口
    @GetMapping("info")
    public Result info() {
        Map<String, Object> map = new HashMap<>();
        map.put("roles", "[admin]");
        map.put("name", "admin");
        map.put("avatar", "https://oss.aliyuncs.com/aliyun_id_photo_bucket/default_handsome.jpg");
        return Result.ok(map);
    }

    @PostMapping("logout")
    public Result logout() {
        return Result.ok();
    }

}

修改前端

继续修改 url 信息,登录相关的 url 主要在 src/api/user.js 中,所以需要进去修改一下

import request from '@/utils/request'

export function login(data) {
  return request({
    // url: '/vue-admin-template/user/login',
    url: '/admin/system/index/login',
    method: 'post',
    data
  })
}

export function getInfo(token) {
  return request({
    // url: '/vue-admin-template/user/info',
    url: '/admin/system/index/info',
    method: 'get',
    params: { token }
  })
}

export function logout() {
  return request({
    // url: '/vue-admin-template/user/logout',
    url: '/admin/system/index/logout',
    method: 'post'
  })
}

状态码也需要修改一下,因为后端的 ok 返回的是 200,前端一开始是 20000,需要修改一下,修改位置在 src/utils/request.js

可以发现,request.js 中包含了两种拦截器:service.interceptors.request.use 和 service.interceptors.response.use。接收后端信息的是 response 这个,所以需要修改这里的内容。将 20000 修改成 200 即可

最后即可成功启动

image-20230416171954694

前端添加角色管理

步骤:

  1. 在 src/router/index.js 中添加路由
  2. 在 src/api 中创建 js 文件,定义接口信息
  3. 在 src/views 文件夹创建页面,在页面引入定义接口 js 文件,调用接口,通过 axios 实现功能

首先添加路由:

// src/router/index.js

    {
    path: '/system',
    component: Layout,
    meta: {
      title: '系统管理',
      icon: 'el-icon-s-tools'
    },
    alwaysShow: true,
    children: [
      {
        path: 'sysRole',
        component: () => import('@/views/system/sysRole/list'),
        meta: { 
          title: '角色管理', 
          icon: 'el-icon-s-help' 
        }
      }
    ]
  },

随后定义接口:

// src/api/system/sysRole.js

import request from '@/utils/request'

export default {
    // 角色列表 - 条件分页查询
    getPageQueryRole(current, limit, searchObj) {
        return request({
            url: `/admin/system/sysRole/${current}/${limit}`,
            method: 'get',
            // 随后是额外参数
            // 如果是普通的对象参数,写法是 params:xxx
            // 如果是 json 格式,写法是 data:xxx
            params: searchObj 
        })
    }
}

最后写展示代码:其中 script 部分是关键

<!--src/views/system/sysRole/list.vue-->

<template>
    <div class="app-container">
        <!--查询表单-->
        <div class="search-div">
            <el-form label-width="70px" size="small">
                <el-row>
                    <el-col :span="24">
                        <el-form-item label="角色名称">
                            <el-input style="width: 100%" v-model="searchObj.roleName" placeholder="角色名称"></el-input>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row style="display:flex">
                    <el-button type="primary" icon="el-icon-search" size="mini" :loading="loading" @click="fetchData()">搜索</el-button>
                    <el-button icon="el-icon-refresh" size="mini" @click="resetData">重置</el-button>
                </el-row>
            </el-form>
        </div>
        <!-- 表格 -->
        <el-table
            v-loading="listLoading"
            :data="list"
            stripe
            border
            style="width:100%; margin-top:10px;"
            @selection-change="handleSelectionChange">

            <el-table-column type="selection"/>

            <el-table-column
                label="序号"
                width="70"
                align="center">
                <template slot-scope="scope">
                {{ (page - 1) * limit + scope.$index + 1 }}
                </template>
            </el-table-column>

            <el-table-column prop="roleName" label="角色名称" />
            <el-table-column prop="roleCode" label="角色编码" />
            <el-table-column prop="createTime" label="创建时间" width="160"/>
            <el-table-column label="操作" width="200" align="center">
                <template slot-scope="scope">
                    <el-button type="primary" icon="el-icon-edit" size="mini" @click="edit(scope.row.id)" title="修改"/>
                    <el-button type="danger" icon="el-icon-delete" size="mini" @click="removeDataById(scope.row.id)" title="删除"/>
                    <el-button type="warning" icon="el-icon-baseball" size="mini" @click="showAssignAuth(scope.row)" title="分配权限"/>
                </template>
            </el-table-column>
        </el-table>
        <!-- 分页组件 -->
        <el-pagination
            :current-page="page"
            :total="total"
            :page-size="limit"
            style="padding: 30px 0; text-align: center;"
            layout="total, prev, pager, next, jumper"
            @current-change="fetchData"
        />
    </div>
</template>

<script>
import api from '@/api/system/sysRole'

export default {
    data() { // 初始值
        return {
            list: [], // 角色列表
            page: 1,
            limit: 1,
            total: 0, // 总记录数
            searchObj: {}, // 条件对象
        }
    },
    created() { // 渲染前执行
        this.fetchData()
    },
    methods: { // 具体方法
        // 条件分页查询
        fetchData(current = 1) { // 给定页码
            this.page = current
            api.getPageList(this.page, this.limit, this.searchObj) // 实际调用 @/api/system/sysRole 的内容
                .then(response => {
                    this.list = response.data.records
                    this.total = response.data.total
                })
        }
    }
}
</script>

界面效果:

image-20230417141630592

增删改查功能引入

删除内容,首先修改 API:

// sysRole.js

// 角色删除
removeById(id) {
  return request({
    url: `${api_name}/remove/${id}`,
    method: 'delete'
  })
},

然后修改前端代码。界面已经有了,直接改 javascript 就可以了:

removeDataById(id) {
  // 模块提供的 confirm 方法,点确定后进入 then,点取消执行 catch,由于取消什么都不用做所以不实现
  this.$confirm('此操作将永久删除该记录,是否继续?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    return api.removeById(id)
  }).then((response) => {
    this.fetchData(this.page)
    // 提示信息
    this.$message.success(response.message || '删除成功')
  })
}

image-20230417152205512

image-20230417152215249

添加和修改:

首先添加弹框,然后添加根据 id 进行查询的方法,最后加入添加或修改方法

// list.vue

// 点击添加弹出框
  add() {
    this.dialogVisible = true
  },
// 点击修改弹出框,根据 id 查询显示数据
  edit(id) {
    this.dialogVisible = true
    this.fetchDataById(id)
  },
// 添加或修改方法
  saveOrUpdate() {
    // this.saveBtnDisabled = true // 防止表单重复提交
    if (!this.sysRole.id) {
      this.save()
    } else {
      this.update()
    }
  },
// 添加方法
  save() {
    api.saveRole(this.sysRole)
      .then(response => {
      this.$message.success(response.message || '操作成功') // 提示信息
      this.dialogVisible = false // 关闭弹框
      this.fetchData() // 刷新页面
    })
  },
// 修改方法
  update() {
    api.updateById(this.sysRole)
      .then(response => {
      this.$message.success(response.message || '操作成功')
      this.dialogVisible = false
      this.fetchData()
    })
  },

  fetchDataById(id) {
    api.getById(id)
      .then(response => {
      this.sysRole = response.data
      // this.$message.success(response.message || '操作成功') // 提示信息
    })
  },

api 代码:

    // 角色添加
    saveRole(role) {
        return request({
            url: `${api_name}/save`,
            method: 'post',
            data: role
        })
    },
    // 获取角色
    getById(id) {
        return request({
            url: `${api_name}/get/${id}`,
            method: 'get'
        })
    },
    // 角色修改
    updateById(role) {
        return request({
            url: `${api_name}/update`,
            method: 'put',
            data: role
        })
    },

image-20230417164523625

image-20230417164543013

批量删除:首先写接口

// 批量删除
batchRemove(idList) {
  return request({
    url: `${api_name}/batchRemove`,
    method: 'delete',
    data: idList
  })
}

然后写前端,前端需要和复选框结合, 就是复选框。然后表格中加入这个内容:@selection-change="handleSelectionChange"。这里写一个测试方法:

handleSelectionChange(selections) {
  this.selections = selections
  console.log(this.selections)
}

image-20230417170416471

最后,参考删除方法,写出批量删除方法。注意这里使用了 this.selections.forEach 方法

// 批量删除
batchRemove() {
  // 首先判断是否有选择内容
  if (this.selections.length == 0) {
    this.$message.warning('请选择要删除的记录!')
    return
  }
  this.$confirm('此操作将永久删除该记录,是否继续?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    var idList = []
    this.selections.forEach(item => {
      idList.push(item.id)
    })
    return api.batchRemove(idList)
  }).then((response) => {
    this.fetchData()
    this.$message.success(response.message || '删除成功')
  })
},

最终效果

image-20230417171015826

image-20230417171121372

用户管理

用户和角色是多对多的关系,对用户和角色的分析如下面这张图所示,除了用户和角色两张表,还需要创建第三张表,维护用户和角色之间的关系。这个第三个表至少有两个字段,作为外键指向两个表的主键

image-20230417171747356

用户模块需要实现的功能:

  1. CRUD 操作
  2. 为用户分配角色

用户管理 CRUD

这部分使用了 MybatisPlus 的代码生成器。

首先需要引入依赖:

<dependency>
  <groupId>com.baomidou</groupId>
  <artifactId>mybatis-plus-generator</artifactId>
  <version>3.4.1</version>
</dependency>
<dependency>
  <groupId>org.apache.velocity</groupId>
  <artifactId>velocity-engine-core</artifactId>
  <version>2.0</version>
</dependency>

然后加入 MybatisPlus 代码生成器的代码,需要按需修改成想要的内容:

// CodeGet.java

public class CodeGet {

    public static void main(String[] args) {

        // 1、创建代码生成器
        AutoGenerator mpg = new AutoGenerator();

        // 2、全局配置
        // 全局配置
        GlobalConfig gc = new GlobalConfig();
//        gc.setOutputDir("C:\\Users\\Administrator\\Desktop\\guigu-oa\\guigu-oa-parent\\service-oa"+"/src/main/java");
        gc.setOutputDir("/Users/mac/Project/20230412_SSM/service-oa/src/main/java");

        gc.setServiceName("%sService");   //去掉Service接口的首字母I
        gc.setAuthor("bluestragglers");
        gc.setOpen(false);
        mpg.setGlobalConfig(gc);

        // 3、数据源配置
        DataSourceConfig dsc = new DataSourceConfig();
        dsc.setUrl("jdbc:mysql://localhost:3306/bluestragglers-oa?serverTimezone=GMT%2B8&useSSL=false");
        dsc.setDriverName("com.mysql.cj.jdbc.Driver");
        dsc.setUsername("root");
        dsc.setPassword("123456");
        dsc.setDbType(DbType.MYSQL);
        mpg.setDataSource(dsc);

        // 4、包配置
        PackageConfig pc = new PackageConfig();
        pc.setParent("com.bluestragglers");
        pc.setModuleName("auth"); //模块名
        pc.setController("controller");
        pc.setService("service");
        pc.setMapper("mapper");
        mpg.setPackageInfo(pc);

        // 5、策略配置
        StrategyConfig strategy = new StrategyConfig();

        strategy.setInclude("sys_user");

        strategy.setNaming(NamingStrategy.underline_to_camel);//数据库表映射到实体的命名策略

        strategy.setColumnNaming(NamingStrategy.underline_to_camel);//数据库表字段映射到实体的命名策略
        strategy.setEntityLombokModel(true); // lombok 模型 @Accessors(chain = true) setter链式操作

        strategy.setRestControllerStyle(true); //restful api风格控制器
        strategy.setControllerMappingHyphenStyle(true); //url中驼峰转连字符

        mpg.setStrategy(strategy);

        // 6、执行
        mpg.execute();
    }
}

最后,直接执行 main 即可。

image-20230417211031348

image-20230417211045705

实体类我们统一放 model 中,所以这里的 entity 直接删掉。同时各个文件里面的 entity 也修改成 model 中的

CRUD:需要注意的是,如果参数是 @RequestBody,那么不能用 @GetMapping,因为 Get 方法没有请求体

@RestController
@RequestMapping("/admin/system/sysUser")
public class SysUserController {

    @Autowired
    private SysUserService service;

    @GetMapping("{page}/{limit}")
    public Result index(@PathVariable Long page,
                        @PathVariable Long limit,
                        SysUserQueryVo sysUserQueryVo) {
        // 创建 page 对象
        Page<SysUser> pageParam = new Page<>(page, limit);
        // 封装条件,判断条件值不为空
        LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
        // 获取条件值,并要求内容
        String username = sysUserQueryVo.getKeyword();
        String createTimeBegin = sysUserQueryVo.getCreateTimeBegin();
        String createTimeEnd = sysUserQueryVo.getCreateTimeEnd();
        // like 模糊查询
        if (StringUtils.hasText(username)) {
            wrapper.like(SysUser::getUsername, username);
        }
        // ge 大于等于
        if (StringUtils.hasText(createTimeBegin)) {
            wrapper.ge(SysUser::getCreateTime, createTimeBegin);
        }
        // le 小于等于
        if (StringUtils.hasText(createTimeEnd)) {
            wrapper.le(SysUser::getCreateTime, createTimeEnd);
        }
        // 调用 mp 方法实现条件分页查询
        IPage<SysUser> pageModel = service.page(pageParam, wrapper);

        return Result.ok(pageModel);
    }

    @GetMapping("get/{id}")
    public Result get(@PathVariable Long id) {
        SysUser user = service.getById(id);
        return Result.ok(user);
    }

    @PostMapping("save")
    public Result save(@RequestBody SysUser user) {
        service.save(user);
        return Result.ok();
    }

    @PutMapping("update")
    public Result updateById(@RequestBody SysUser user) {
        service.updateById(user);
        return Result.ok();
    }

    @DeleteMapping("remove/{id}")
    public Result remove(@PathVariable Long id) {
        service.removeById(id);
        return Result.ok();
    }

}

用户管理前端

随后需要写前端,步骤:

  1. 改 router 中的内容
  2. 在 views/system 中加入 sysUser/list.vue
  3. 在 api/system 中加入 sysUser.js

image-20230418105725511

image-20230418105849260

用户分配角色

完成了用户管理部分,就需要完成给用户分配角色了,这时就要使用 user-role 表了

同时,前端需要能够满足的功能:

  1. 显示所有角色 - 查询角色表即可
  2. 显示当前用户所属角色 - 首先要根据用户 id 查询 user-role,获取用户的所有角色 id。随后根据角色 id 查询角色信息
  3. 将用户最终分配的角色添加到数据库 - 首先要把用户之前分配的角色删除,随后要保存最新分配的角色,这两个操作都是在 user-role 表中进行

image-20230418155258280

那么先写后端代码。还是可以用代码生成器。这里不需要 Controller 了,只需要 Service 和 Mapper。一键生成非常简单。注意,如果用完代码生成器后执行项目出错,记得把代码生成器的两个依赖注释掉,分别是 mybatis-plus-generator 和 velocity-engine-core

image-20230418155926032

随后,因为是首先获取角色,随后为用户分配角色,所以在角色的 Controller 中实现。在 SysRoleController 中编写接口:根据用户获取角色数据和根据用户分配角色

  1. 首先用 Autowired 获取 UserRoleService
  2. 然后由于 Impl 的参数已经包含了 baseMapper,所以直接获取就可以了
  3. 这一节对应的网课 p35 非常好,建议多看
  4. 这部分代码也很好!!!
@Service
public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> implements SysRoleService {

    @Autowired
    private SysUserRoleService sysUserRoleService;

    public Result getPageQueryRole(Long page,
                                   Long limit,
                                   SysRoleQueryVo sysRoleQueryVo) {
        // 调用 service 方法实现
        // 1. 创建 page 对象,传递分页相关参数
        Page<SysRole> pageParam = new Page<>(page, limit);

        // 2. 封装条件,需要判断条件值是否为空,不为空进行封装
        LambdaQueryWrapper<SysRole> wrapper = new LambdaQueryWrapper<>();
        String roleName = sysRoleQueryVo.getRoleName();
        if (StringUtils.isNotEmpty(roleName)) {
            // 封装
            wrapper.like(SysRole::getRoleName, roleName);
        }

        // 3. 调用方法实现
        IPage<SysRole> pageModel = this.page(pageParam, wrapper);
        return Result.ok(pageModel);
    }

    @Override
    public Map<String, Object> findRoleByAdminId(Long userId) {

        // 1, 查询所有角色,返回 list 集合
        List<SysRole> allRoleList = baseMapper.selectList(null);

        // 2. 根据 userid 查询角色用户关系表,查询 userid 对应的所有角色 id
        LambdaQueryWrapper<SysUserRole> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysUserRole::getUserId, userId);
        List<SysUserRole> existUserRoleList = sysUserRoleService.list(wrapper);
        List<Long> existRoleIdList = existUserRoleList
                .stream()
                .map(SysUserRole::getRoleId)
                .toList();

        // 3. 根据查询到的所有角色 id,找到对应角色信息(从第一步中进行获取)
        List<SysRole> assignRoleList = new ArrayList<>();
        for (SysRole sysRole : allRoleList) {
            if (existRoleIdList.contains(sysRole.getId())) {
                assignRoleList.add(sysRole);
            }
        }

        // 4. 数据封装成 map,返回
        Map<String, Object> roleMap = new HashMap<>();
        roleMap.put("assignRoleList", assignRoleList);
        roleMap.put("allRolesList", allRoleList);

        return roleMap;
    }

    @Override
    public void doAssign(AssignRoleVo assignRoleVo) {
        // 1. 删除用户之前分配的数据,在用户角色关系表中根据用户 id 删除
        LambdaQueryWrapper<SysUserRole> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(SysUserRole::getUserId, assignRoleVo.getUserId());
        sysUserRoleService.remove(wrapper);

        // 2. 重新分配数据
        List<Long> roleIdList = assignRoleVo.getRoleIdList();
        for (Long roleId : roleIdList) {
            if (roleId != null) {
                SysUserRole sysUserRole = new SysUserRole();
                sysUserRole.setUserId(assignRoleVo.getUserId());
                sysUserRole.setRoleId(roleId);
                sysUserRoleService.save(sysUserRole);
            }
        }
    }

}

更改用户状态

这部分内容就是:用户状态包含 0:停用,1:正常。当用户状态正常时,可以访问后台系统;用户状态停用后,就不可以登录后台系统

// SysUserController.java

// 更新用户状态
@GetMapping("updateStatus/{id}/{status}")
public Result updateStatus(@PathVariable Long id,
                           @PathVariable Integer status) {
  sysUserService.updateStatus(id, status);
  return Result.ok();
}

// SysUserServiceImpl.java

@Override
public void updateStatus(Long id, Integer status) {
  // 1. 根据 id 查询用户对象
  SysUser sysUser = baseMapper.selectById(id);

  // 2. 设置修改状态值
  sysUser.setStatus(status);

  // 3. 调用方法进行修改,注意要传对象
  baseMapper.updateById(sysUser);
}

修改用户前端

添加完前面的用户部分后,可以修改前端内容了。步骤:

  1. 添加 API
  2. 修改页面
// sysUser.js

updateStatus(id, status) {
  return request({
    url: `${api_name}/updateStatus/${id}/${status}`,
    method: 'get'
  })
},

// sysRole.js

// 获取角色
  getRoles(adminId) {
    return request({
      url: `${api_name}/toAssign/${adminId}`,
      method: 'get'
    })
  },
    // 分配角色
    assignRoles(assignRoleVo) {
      return request({
        url: `${api_name}/doAssign`,
        method: 'post',
        data: assignRoleVo
      })
    },

image-20230418170049472

菜单管理

不同用户拥有不同的菜单和功能权限,因此需要对菜单进行管理

菜单管理模块:

  1. 菜单列表和 CRUD 操作
  2. 为角色分配菜单

首先进行菜单列表设计。菜单结构:系统管理 - { 用户管理、角色管理 },因此,菜单表需要包含 id 和 parentId,并存储它们的层级关系。这里令 parentId = 0 表示顶层数据,则有 id=1, parentId=0, name=系统管理;id=10, parentId=1, name=用户管理;id=11, parentId=1, name=角色管理

image-20230419104708316

菜单管理的目标:

image-20230419104809653

同时,角色和菜单之间还存在关联,因此需要构建 角色 - 菜单表

image-20230419105031488

image-20230419105017113

菜单管理 CRUD

构建完表后,首先用代码生成器生成代码。随后删除不必要的 SysRoleMenuController.java 和 entity,并修改相应 service

image-20230419105604592

// SysMenuController.java

@RestController
@RequestMapping("/admin/system/sysMenu")
public class SysMenuController {

    @Autowired
    private SysMenuService sysMenuService;

    // 菜单列表接口
    @GetMapping("findNodes")
    public Result findNodes() {
        List<SysMenu> list = sysMenuService.findNodes();
        return Result.ok(list);
    }

    @PostMapping("save")
    public Result save(@RequestBody SysMenu permission) {
        sysMenuService.save(permission);
        return Result.ok();
    }

    @PutMapping("update")
    public Result updateById(@RequestBody SysMenu permission) {
        sysMenuService.updateById(permission);
        return Result.ok();
    }

    @DeleteMapping("remove/{id}")
    public Result remove(@PathVariable Long id) {
        sysMenuService.removeById(id);
        return Result.ok();
    }

}

由于菜单是递归的,所以也要递归地遍历。这里在 SysMenu 这个实体类中定义了 parentId、id 和 children,从而能够实现多层遍历。同时这个 children 设置的 exist=false,因此不一定存在

image-20230419110504246

为了实现构造这种 SysMenu 的树形结构,构建了一个 MenuHelper 类,能够实现这一过程:首先找到入口(parentId == 0),然后进入其中,获取所有子结构,最后返回 SysMenu。这个递归的写法要注意!!!

// utils/MenuHelper.java

public class MenuHelper {

    public static List<SysMenu> buildTree(List<SysMenu> sysMenuList) {

        List<SysMenu> tree = new ArrayList<>();

        // 把所有菜单数据进行遍历
        for (SysMenu sysMenu : sysMenuList) {
            // 递归入口:parentId = 0
            if (sysMenu.getParentId() == 0) {
                tree.add(getChildren(sysMenu, sysMenuList));
            }
        }

        return tree;
    }

    private static SysMenu getChildren(SysMenu sysMenu, List<SysMenu> sysMenuList) {

        sysMenu.setChildren(new ArrayList<>());

        // 遍历所有菜单数据,判断 id 和 parentId 的对应关系
        for (SysMenu menu : sysMenuList) {
            if (menu.getParentId().longValue() == sysMenu.getId().longValue()) {
                if (sysMenu.getChildren() == null) {
                    sysMenu.setChildren(new ArrayList<>());
                }
                sysMenu.getChildren().add(getChildren(menu, sysMenuList));
            }
        }

        return sysMenu;
    }
}

最终结果:

image-20230419114821229

此外,对删除方法需要加一个判断:是否存在子菜单。如果存在子菜单,那不能直接删除。如果不存在子菜单,那么可以进行删除

// SysMenuController.java

@DeleteMapping("remove/{id}")
public Result remove(@PathVariable Long id) {
  sysMenuService.removeMenuById(id);
  return Result.ok();
}

// SysMenuServiceImpl.java

@Override
public void removeMenuById(Long id) {
  // 判断当前菜单是否存在子菜单,没有才能删除
  LambdaQueryWrapper<SysMenu> wrapper = new LambdaQueryWrapper<>();
  wrapper.eq(SysMenu::getParentId, id);
  Long count = baseMapper.selectCount(wrapper);
  if (count > 0) {
    throw new BlueStragglersException(201, "菜单存在子菜单,不能删除");
  }
  baseMapper.deleteById(id);
}

菜单管理前端

前端依旧是:添加路由、定义基础 API、实现页面功能

完成后效果:

image-20230419132121347

image-20230419132438402

当某个菜单层级有子菜单时,显示无法点击删除按钮

角色分配菜单

完成了菜单自身的开发后,需要继续实现为角色分配菜单。这部分的逻辑和为用户分配角色是类似的,也是多对多。角色分配菜单需要包含以下两种功能:

  1. 查询功能:查询所有菜单,并按照树形结构进行封装。随后查询当前角色所属的菜单权限
  2. 分配功能:首先删除角色之前分配的菜单,然后为角色添加最新分配的菜单

image-20230419133416670

类似为用户分配角色,这里也是在菜单的 Controller 中实现。在 SysMenuController 中编写以下内容:

// SysMenuServiceImpl.java

@Override
public List<SysMenu> findMenuByRoleId(Long roleId) {
  // 1. 查询所有菜单 - 添加条件 status == 1
  LambdaQueryWrapper<SysMenu> wrapperSysMenu = new LambdaQueryWrapper<>();
  wrapperSysMenu.eq(SysMenu::getStatus, 1);
  List<SysMenu> allSysMenuList = baseMapper.selectList(wrapperSysMenu);

  // 2. 根据角色 roleID 进行查询,获取 SysRoleMenu 中 roleId 对应的所有 menuId
  LambdaQueryWrapper<SysRoleMenu> wrapperSysRoleMenu = new LambdaQueryWrapper<>();
  wrapperSysRoleMenu.eq(SysRoleMenu::getRoleId, roleId);
  List<SysRoleMenu> sysRoleMenuList = sysRoleMenuService.list(wrapperSysRoleMenu);
  List<Long> menuIdList = sysRoleMenuList.stream().map(SysRoleMenu::getMenuId).toList();

  // 3. 根据获取的 menuId,获取对应菜单对象,同样是去步骤 1 中获取对象,如果相同则 isSelected == true
  allSysMenuList.forEach(item -> {
    item.setSelect(menuIdList.contains(item.getId()));
  });

  // 4. 返回规定树形显示格式的菜单集合(调用递归方法)
  return MenuHelper.buildTree(allSysMenuList);
}

@Override
public void doAssign(AssignMenuVo assignMenuVo) {
  // 1. 根据 roleId 删除 SysRoleMenu 中的对象
  LambdaQueryWrapper<SysRoleMenu> wrapper = new LambdaQueryWrapper<>();
  wrapper.eq(SysRoleMenu::getRoleId, assignMenuVo.getRoleId());
  sysRoleMenuService.remove(wrapper);

  // 2. 根据 assignMenuVo,遍历 menuIdList,将所有内容加到数据库的 sysRoleMenu 中即可
  for (Long menuId : assignMenuVo.getMenuIdList()) {
    if (menuId == null) {
      continue;
    }
    SysRoleMenu sysRoleMenu = new SysRoleMenu();
    sysRoleMenu.setRoleId(assignMenuVo.getRoleId());
    sysRoleMenu.setMenuId(menuId);
    sysRoleMenuService.save(sysRoleMenu);
  }
}

随后进行前端实现。

  1. 添加路由
  2. 角色列表添加按钮和方法
  3. 添加 API
  4. 实现页面功能
{
  path: 'assignAuth',
    component: () => import('@/views/system/sysRole/assignAuth'),
      meta: {
        activeMenu: '/system/sysRole',
          title: '角色授权'
      },
        hidden: true,
},

可以看到,这段路由没有 icon,同时 hidden: true,说明这个路由不进行显式,但是能进行页面跳转

随后构建 assignAuth.vue,在里面写上跳转页的信息

插播一下:如何解决空格警告问题?有下面几种解决方式:

  1. 在 .eslintignore 中加入不校验的文件名
  2. 在 vue.config.js 中,修改为 lintOnSave: false,(后面的内容注释掉)

最终效果:

image-20230419194219155

image-20230419194204156

权限管理

整体介绍:

  1. 权限管理:大致可以分为三种:页面权限(菜单级)、操作权限(按钮级)和数据权限。当前系统只介绍菜单权限和按钮权限的控制

  2. 在权限管理过程中,会实现两个内容:

    1. 用户登录
    2. 获取用户信息
  3. 同时,这些过程中会使用 JWT 技术,通过 JWT 生成 Token,最后通过前端实现功能

  4. 随后,整合框架 Spring Security,完成权限管理的进一步实现,完成用户认证和用户授权

image-20230420100218680

页面权限:页面权限(菜单权限)是粗粒度权限,不同用户进入系统后,可以看到不同菜单。比如张三是系统管理员,登录系统后可以看到所有菜单。赵六是销售人员,登录系统后可以看到销售相关菜单

image-20230420100011351

操作权限:操作权限(按钮权限)是细粒度权限,比如用户管理模块,有具体功能(添加、修改、删除),对这些具体功能进行管理

image-20230420100050366

首先对表结构进行设计:

  • 用户表 <多对多> 角色表 <多对多> 菜单表,

  • 用户角色关系表 角色菜单关系表

image-20230420100920115

image-20230420100856108

具体操作:

  1. 用户 Lucy
  2. 用户分配角色,为 Lucy 分配管理员角色
  3. 角色分配菜单,为管理员角色分配用户管理 CRUD、角色管理 Create、Update

image-20230420101159331

具体操作的实现:使用用户登录,登录后获取用户可以操作的菜单和按钮权限,并进行相应的显示和提供相关操作支持。最终实现要包含两个接口:

  1. 用户登录
  2. 登录后,获取用户可以操作的菜单和按钮

用户登录接口:

  1. 根据用户名查询数据库,查看用户是否存在,信息是否正确
  2. 如果用户存在、信息正确,还要判断用户是否被禁用
  3. 登录后要保持登录状态
    1. 基于 token 实现:使用登录信息(用户 id、用户名称等)生成唯一的字符串,并对生成字符串进行编码加密处理,得到 token。使用 JWT(Json Web Token)生成 token,能够实现防伪
    2. 把唯一的字符串 token 放到 cookie 中,从 cookie 中可以获取到用户信息
    3. 但 cookie 存在缺陷:不能跨域传递,比如前段项目 9528 端口号,后端服务 8800 端口号,会产生跨域。解决方法:每次发送请求时,把 cookie 的值获取出来,放到请求头中,每次从请求头中获取用户信息

image-20230420105420150

登录后获取用户可以操作的菜单和按钮:

  1. 从请求头获取 token 字符串,从字符串获取用户 ID
  2. 根据用户 id 查询:用户可以操作的菜单和按钮

image-20230420110039985

JWT

JWT 是 Json Web Token 的缩写,是一种自包含令牌(里面包含用户信息)

JWT 包含三个部分:JWT 头、有效载荷、签名哈希

image-20230420110419852

  1. JWT 头:介绍 JWT 元数据的 Json 对象
  2. 有效载荷:内容部分,也是 Json 对象
  3. 签名哈希:对上面两部分数据进行签名,通过算法生成哈希,确保数据不会被篡改。首先指定一个密码 secret,这个密码只保留在服务器中,不向用户公开。随后制定一个签名算法(默认 HMAC SHA256)根据以下公式生成签名:

image-20230420112003327

最后将上述三个部分组成一个字符串,每个部分用 '.' 作为分隔,构成一个 JWT 对象

Base64URL 算法:这个算法和 Base64 算法类似,稍有差别。Base64 的字符 '+'、'/'、'=',Base64URL 去掉了 '=',用 '-' 替换 '+',用 '_'替换 '/',从而能够适用于 URL 中

image-20230420112135714

项目集成 JWT:添加到 common-util 中

  1. 引入依赖:jjwt
  2. 添加 JWT 帮助类:JwtHelper
// JwtHelper.java

public class JwtHelper {
    private static long tokenExpiration = 365 * 24 * 60 * 60 * 1000; // token 过期时间
    private static final String tokenSignKey = "123456"; // 签名加密密钥

    // 根据用户id和用户名称生成 token 字符串,也可以提供更多参数
    public static String createToken(Long userId, String username) {
        String token = Jwts.builder()
                // 分类
                .setSubject("AUTH-USER")

                // 设置 token 有效时长
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))

                // 设置主体部分
                .claim("userId", userId)
                .claim("username", username)

                // 签名部分
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                .compressWith(CompressionCodecs.GZIP)
                .compact();
        return token;
    }

    // 从生成 token 字符串获取用户id
    public static Long getUserId(String token) {
        try {
            if (StringUtils.isEmpty(token)) return null;

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            Integer userId = (Integer) claims.get("userId");
            return userId.longValue();
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    // 从生成 token 字符串获取用户名称
    public static String getUsername(String token) {
        try {
            if (StringUtils.isEmpty(token)) return "";

            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            Claims claims = claimsJws.getBody();
            return (String) claims.get("username");
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

    public static void main(String[] args) {
        String token = JwtHelper.createToken(10L, "bluestragglers");
        System.out.println(token);
        Long userId = JwtHelper.getUserId(token);
        String username = JwtHelper.getUsername(token);
        System.out.println(userId);
        System.out.println(username);
    }
}

实现效果:(需要添加依赖 jaxb-api,版本 2.3.0)

image-20230420113957052

image-20230420114408390

重写用户登录功能

整合 JWT,生成 token:在 IndexController 中修改 login()。同时密码使用 MD5 加密,修改密码的 save 流程,确保使用了 MD5 进行加密。步骤:

  1. 获取输入的用户名和密码
  2. 根据用户名查询数据库
  3. 判断用户信息是否存在
  4. 判断密码是否正确,需要转成 MD5 再比较
  5. 判断用户是否被禁用,1 可用,0 禁用
  6. 使用 JWT 根据用户 ID 和用户名称生成 token 字符串
  7. 返回 token
// MD5.java

public static String encrypt(String strSrc) {
  try {
    char[] hexChars = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' };
    byte[] bytes = strSrc.getBytes();
    MessageDigest md = MessageDigest.getInstance("MD5");
    md.update(bytes);
    bytes = md.digest();
    int j = bytes.length;
    char[] chars = new char[j * 2];
    int k = 0;
    for (byte b : bytes) {
      chars[k++] = hexChars[b >>> 4 & 0xf];
      chars[k++] = hexChars[b & 0xf];
    }
    return new String(chars);
  } catch (NoSuchAlgorithmException e) {
    e.printStackTrace();
    throw new RuntimeException("MD5加密出错!!+" + e);
  }
}
// SysUserController.java

@PostMapping("save")
public Result save(@RequestBody SysUser user) {
  // 对密码进行加密,采用 MD5 的方式。因为 MD5 不能解密,只能加密
  String password = user.getPassword();
  String passwordMD5 = MD5.encrypt(password);
  user.setPassword(passwordMD5);

  sysUserService.save(user);
  return Result.ok();
}
// login 接口
@PostMapping("login") // 为了使用 RequestBody,需要用 Post 方法,因为 Get 方法没有请求体
public Result login(@RequestBody LoginVo loginVo) {
  // 1. 获取输入的用户名和密码
  String username = loginVo.getUsername();
  String password = loginVo.getPassword();

  // 2. 根据用户名查询数据库
  LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
  wrapper.eq(SysUser::getUsername, username);
  SysUser sysUser = sysUserService.getOne(wrapper);// 因为用户名不允许重复,所以这里使用 getOne()

  // 3. 判断用户信息是否存在
  if (sysUser == null) {
    throw new BlueStragglersException(201, "用户不存在");
  }

  // 4. 判断密码是否正确,需要转成 MD5 再比较
  String loginPasswordMD5 = MD5.encrypt(loginVo.getPassword());
  if (!loginPasswordMD5.equals(password)) {
    throw new BlueStragglersException(201, "密码错误");
  }

  // 5. 判断用户是否被禁用,1 可用,0 禁用
  if (sysUser.getStatus() == 0) {
    throw new BlueStragglersException(201, "用户已被禁用");
  }

  // 6. 使用 JWT 根据用户 ID 和用户名称生成 token 字符串
  String token = JwtHelper.createToken(sysUser.getId(), sysUser.getUsername());

  // 7. 返回 token
  Map<String, Object> map = new HashMap<>();
  map.put("token", token);

  return Result.ok(map);
}

后面将 token 放到 cookie 中,用 cookie 获取用户信息。但是 cookie 不能跨域传递,也就是说后端是获取不到的。解决方案就是,每次发请求时,把 cookie 的值放到请求头里面,每次从请求头中获取 token。同时,cookie 和请求头都是在前端做处理,后端只需要处理 token 即可

从请求头中获取用户数据步骤:

  1. 从请求头获取用户信息(获取请求头 token 字符串)

  2. 从 token 中获取用户 ID 和用户名称

  3. 根据用户 ID 查询数据库,获取用户信息

  4. 根据用户 ID,获取用户的菜单列表(要查询数据库,动态构建出路由结构,进行最终显示)

  5. 根据用户 ID,获取用户的操作列表

  6. 返回相应数据

image-20230420145028984

具体实现:

// IndexController.java

// info 接口
@GetMapping("info")
public Result info(HttpServletRequest request) {
  // 1. 从请求头获取用户信息(获取请求头 token 字符串)
  String token = request.getHeader("header");

  // 2. 从 token 中获取用户 ID 和用户名称
  Long userId = JwtHelper.getUserId(token);

  // 3. 根据用户 ID 查询数据库,获取用户信息
  SysUser sysUser = sysUserService.getById(userId);

  // 4. 根据用户 ID,获取用户的菜单列表
  // 要查询数据库,动态构建出路由结构,进行最终显示
  // RouterVo 目标是构建出前端的路由
  List<RouterVo> routerList = sysMenuService.findUserMenuListByUserId(userId);

  // 5. 根据用户 ID,获取用户的操作列表
  List<String> permissionList = sysMenuService.findUserPermissionListByUserId(userId);

  // 6. 返回数据
  Map<String, Object> map = new HashMap<>();
  map.put("roles", "[admin]");
  map.put("name", sysUser.getUsername());
  map.put("avatar", "https://oss.aliyuncs.com/aliyun_id_photo_bucket/default_handsome.jpg");
  map.put("routers", routerList);
  map.put("buttons", permissionList);
  return Result.ok(map);
}

@PostMapping("logout")
public Result logout() {
  return Result.ok();
}

随后,需要完成里面的两个方法 sysMenuService.findUserMenuListByUserId(userId) 和 sysMenuService.findUserPermissionListByUserId(userId)。需要注意,方法涉及到多表查询,因为 SysMenu 表没有 userId,肯定无法查询。这时,就需要编写 XML 语句了,在 SysMenuMapper.xml 中编写

根据用户 id 获取用户菜单列表:注意需要整理成 RouterVo 格式(符合前端路由要求的格式)

// SysMenuServiceImpl.java 

private List<RouterVo> buildRouter(List<SysMenu> menus) {
        // SysMenu 的 type 属性,1 是菜单,2 是操作
        // 1. 创建 list 集合,存储最终数据
        List<RouterVo> routers = new ArrayList<>();

        // 2. 遍历 menus
        for (SysMenu menu : menus) {
            RouterVo routerVo = new RouterVo();
            routerVo.setHidden(false);
            routerVo.setAlwaysShow(false);
            routerVo.setPath(getRouterPath(menu));
            routerVo.setComponent(menu.getComponent());
            routerVo.setMeta(new MetaVo(menu.getName(), menu.getIcon()));

            // 封装下一层数据
            List<SysMenu> children = menu.getChildren();
            if (menu.getType() == 2) {
                // 加载隐藏路由
                List<SysMenu> hiddenMenuList = children.stream()
                        .filter(item -> StringUtils.hasText(item.getComponent()))
                        .toList();
                for (SysMenu hiddenMenu : hiddenMenuList) {
                    RouterVo hiddenRouter = new RouterVo();
                    // 这里 hidden == true
                    hiddenRouter.setHidden(true);
                    hiddenRouter.setAlwaysShow(false);
                    hiddenRouter.setPath(getRouterPath(hiddenMenu));
                    hiddenRouter.setComponent(hiddenMenu.getComponent());
                    hiddenRouter.setMeta(new MetaVo(hiddenMenu.getName(), hiddenMenu.getIcon()));

                    routers.add(hiddenRouter);
                }
            } else {
                if (CollectionUtils.isEmpty(children)) {
                    if (children.size() > 0) {
                        routerVo.setAlwaysShow(true);
                    }
                    // 递归构建
                    routerVo.setChildren(buildRouter(children));
                }
            }
            routers.add(routerVo);
        }
        return routers;
    }

    // 获取路由地址
    private String getRouterPath(SysMenu menu) {
        String routerPath = "/" + menu.getPath();
        if (menu.getParentId() != 0) {
            routerPath = menu.getPath();
        }
        return routerPath;
    }

隐藏路由情况:type == 2 同时 component 不为空,需要写进系统

image-20230420155823214

根据用户 id 获取用户的操作按钮列表:这个相对就比较简单了

// SysMenuServiceImpl.java

// 根据用户 ID 获取用户操作列表
@Override
public List<String> findUserPermissionListByUserId(Long userId) {
  // 1. 判断用户是否是管理员
  List<SysMenu> sysMenuList = null;
  if (userId == 1) {
    LambdaQueryWrapper<SysMenu> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(SysMenu::getStatus, 1);
    sysMenuList = baseMapper.selectList(wrapper);
  } else {
    // 2. 如果不是,根据 userId 查询操作按钮列表,同样是多表关联
    sysMenuList = baseMapper.findMenuListByUserId(userId);
  }
  // 3. 从查询出来的数据里获取可以操作的按钮值 List 集合
  List<String> permsList = sysMenuList.stream()
    .filter(item -> item.getType() == 2)
    .map(SysMenu::getPerms)
    .toList();

  // 4. 返回数据
  return permsList;
}

最后,完成多表查询功能:

  1. @Param("...") 是 Mybatis 的内容,如果有多个参数必须加
  2. 在 SysMenuMapper.xml 中写 sql,实现多表查询

image-20230420163653358

// SysMenuMapper.java

public interface SysMenuMapper extends BaseMapper<SysMenu> {

    // @Param 是 Mybatis 的内容,如果有多个参数,必须加这个注解
    List<SysMenu> findMenuListByUserId(@Param("userId") Long userId);
}
<resultMap id="sysMenuMap"
               type="com.bluestragglers.model.system.SysMenu"
               autoMapping="true">
</resultMap>

<sql id="columns">
  sm.id,
  sm.parent_id,
  sm.name,
  sm.type,
  sm.path,
  sm.component,
  sm.perms,
  sm.icon,
  sm.sort_value,
  sm.status,
  sm.create_time,
  sm.update_time,
  sm.is_deleted
</sql>

<select id="findMenuListByUserId"
        resultMap="sysMenuMap">

  SELECT DISTINCT
  <include refid="columns"/>
  FROM sys_menu sm
  INNER JOIN sys_role_menu srm ON srm.role_id = sm.id
  INNER JOIN sys_user_role sur ON sur.role_id = srm.role_id
  WHERE sur.user_id = #{userId}
  AND sm.status = 1
  AND srm.is_deleted = 0
  AND sur.is_deleted = 0
  AND sm.is_deleted = 0
</select>

image-20230420165540152

最终效果:

image-20230420171053234

在查询用户权限时,报错,原因是 Maven 默认情况下只会加载编译 src-main-java 下的 java 类型文件,其他文件不会进行加载

解决方式:有下面几种

  1. 将 xml 文件放到 resource 中
  2. 通过配置的方式进行加载
    1. 在 pom.xml 中配置
    2. 在项目配置文件中配置

这里选择在项目配置文件中配置:

<!--service-oa/pom.xml-->

<build>
  <finalName>${project.artifactId}</finalName>
  <plugins>
    <plugin>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-maven-plugin</artifactId>
      <version>3.0.4</version>
    </plugin>
  </plugins>
  <resources>
    <resource>
      <directory>src/main/java</directory>
      <includes>
        <include>**/*.yml</include>
        <include>**/*.properties</include>
        <include>**/*.xml</include>
      </includes>
      <filtering>false</filtering>
    </resource>
    <resource>
      <directory>src/main/resources</directory>
      <includes> <include>**/*.yml</include>
        <include>**/*.properties</include>
        <include>**/*.xml</include>
      </includes>
      <filtering>false</filtering>
    </resource>
  </resources>
</build>

<!--application-dev.yml-->

mybatis-plus:
  classpath:com/bluestragglers/auth/mapper/xml/*.xml

image-20230420172355622

前端整合

前端首先要获取 token。修改 utils/request.js

//utils/request.js

if (store.getters.token) {
  config.headers['token'] = getToken()
}
return config

export function getToken() {
  return Cookies.get(TokenKey)
}

// store/modules/user.js

const getDefaultState = () => {
  return {
    token: getToken(),
    name: '',
    avatar: '',

    buttons: [], // 新增
    menus: '' // 新增
  }
}

const mutations = {
  ...
  SET_BUTTONS: (state, buttons) => {
    state.buttons = buttons
  },
  SET_MENUS: (state, menus) => {
    state.menus = menus
  }
}

getInfo({ commit, state }) {
  ...
  commit('SET_BUTTONS', data.buttons)
  commit('SET_MENUS', data.routers)
  ...
}

// store/getters.js

const getters = {
  ...
  buttons: state => state.user.buttons,
  menus: state => state.user.menus
}

// src/router 下新建 _import_production.js 和 _import_development.js

// 生产环境导入组件
module.exports = file => () => import('@/views/' + file + '.vue')

// 开发环境导入组件
module.exports = file => require('@/views/' + file + '.vue').default // vue-loader at least v13.0.0+

// src/permission.js
//全部替换

// src/router
// 删除 index.js 中自定义的路由

// src/components/ParentView/index.vue

<template>
  <router-view />
</template>

// src/layout/components/SideBar/index.vue

computed: {
  ...mapGetters([
    'sideBar'
  ]),
  routers() {
        return this.$router.options.routes.concat(global.antRouter)
  }
  ...
}

// src/utils/btn-permission.js

import store from '@/store'

/**
 * 判断当前用户是否有此按钮权限
 * 按钮权限字符串 permission
 */

export default function hasBtnPermission(permission) {
    // 得到当前用户的所有按钮权限
    const myBtns = store.getters.buttons
    // 如果指定的功能权限在 myBtns 中,返回 true ==> 这个按钮就会显示,否则隐藏
    return myBtns.indexOf(permission) !== -1
}

// main.js

import hasBtnPermission from '@/utils/btn-permission'
Vue.prototype.$hasBP = hasBtnPermission

// src/views/login/index.vue

const validateUsername = (rule, value, callback) => {
  if (value.length < 4) {
    callback(new Error('Please enter the correct user name'))
  } else {
    callback()
  }
}

随后就可以测试了。需要注意:数据库不能包含没有写的菜单!否则会报错,导致无法登录成功

后端权限控制

前端整合时加入了权限控制,但是可以直接通过输入 url 访问,因此后端也需要进行权限控制,也就是进入 Controller 方法前判断当前用户是否有访问权限

可行的实现方法:

  1. If 判断
  2. Filter + AOP
  3. 现成的开源技术框架:Spring Security、Shiro 等

Spring Security 介绍

Spring Security 的两个核心功能是:

  1. 用户认证:验证某个用户是否是系统中的合法主体,能否访问该系统。用户认证一般要用户提供用户名和密码,系统评估用户能否登录
  2. 用户授权:验证某个用户是否有权限执行某个操作。系统会为不同用户分配不同的角色,每个角色对应一系列的权限,系统评估用户是否有权限做某些事

Spring Security 和 Shiro 的对比:

  1. Spring Security 的特点:能够和 Spring 无缝整合,能够全面的控制权限,专门为 Web 开发而设计,重量级(依赖很多其他组件进行实现)
  2. Shiro 是 Apache 旗下的轻量级权限控制框架,是轻量级,对性能有更高要求的 Web 应用有更好表现
  3. Spring Security 一开始发展的不是很好,不如 Shiro 使用的多,后来随着 Spring Boot 发展起来,用的更多了

要想对 Web 资源进行保护,最好的方法莫过于 Filter。要想对方法调用进行保护,最好的方法莫过于 AOP

因此,Spring Security 进行认证和判定权限时,就会利用一系列 Filter 进行拦截

image-20230421162254399

请求过滤的过程:

  1. 认证:UsernamePasswordAuthenticationFilter 过滤器,用于对 /login 的 POST 请求做拦截,校验用户信息
  2. 异常处理:ExceptionTranslationFilter 异常过滤器,用来拦截认证过程中抛出的异常
  3. 授权:FilterSecurityInterceptor 方法级的权限过滤器,位于过滤链的最底部
  4. API

Spring Security 的核心全在这些过滤器中,过滤器中会调用各种组件完成功能,掌握了这些过滤器和组件,就掌握了 Spring Security!

Spring Security 入门案例

Spring Security 使用步骤:

  1. 创建 Spring Security 模块
  2. 添加依赖
  3. 添加配置类
  4. Service-oa 模块引入
  5. 启动项目测试

添加依赖后,Spring Security 就默认提供了许多功能,能够将应用保护起来:

  1. 要求身份验证后用户才能与应用程序交互
  2. 创建好了默认登录表单
  3. 生成用户名为 user 的随机密码并打印在控制台

用户认证流程:

  1. 输入用户名、密码
  2. 将信息封装到 Authentication,实现类是 UsernamePasswordAuthenticationToken
  3. 调用认证方法 authenticate()
  4. 调用委托认证 authenticate()
  5. 查数据库,获取用户信息 loadUserByUsername()
  6. 返回 UserDetails
  7. 通过 PasswordEncoder 对比 UserDetails 中的密码与 Authentication 中的密码是否一致
  8. 填充并返回(回填) Authentication
  9. 通过 SecurityContextHolder.getContest().setAuthentication(value) 方法将 Authentication 保存到上下文中

image-20230421203603000

用户验证

为了实现用户验证功能,需要定义下面的组件:

  1. 自定义对象 UserDetails,实现 UserDetailsService
  2. 自定义根据用户名称得到用户信息方法 localUserByUsername()
  3. 自定义密码校验器 PasswordEncoder

下面的代码就自定义了组件,首先实现 PasswordEncoder,然后实现 User,然后实现 UserDetailsService,并实现 loadUserByUsername() 方法

// CustomMd5PasswordEncoder

@Component
public class CustomMd5PasswordEncoder implements PasswordEncoder {

    @Override
    public String encode(CharSequence rawPassword) {
        return MD5.encrypt(rawPassword.toString());
    }

    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        return encodedPassword.equals(MD5.encrypt(rawPassword.toString()));
    }
}

// CustomUser.java

public class CustomUser extends User {

    private SysUser sysUser;

    public CustomUser(SysUser sysUser, Collection<? extends GrantedAuthority> authorities) {
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    }

    public SysUser getSysUser() {
        return sysUser;
    }

    public void setSysUser(SysUser sysUser) {
        this.sysUser = sysUser;
    }
}

// UserDetailsServiceImpl.java

public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = sysUserService.getByUsername(username);

        if (sysUser == null) {
            throw new UsernameNotFoundException("用户名不存在!");
        }

        if (sysUser.getStatus() == 0) {
            throw new BlueStragglersException(201, "账号已停用");
        }

        return new CustomUser(sysUser, Collections.emptyList());
    }
}

// 实现以下方法

@Override
public SysUser getByUsername(String username) {
  LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
  wrapper.eq(SysUser::getUsername, username);
  SysUser sysUser = baseMapper.selectOne(wrapper);
  return sysUser;
}

具体核心组件:

  1. 登录 filter:TokenLoginFilter,判断用户名和密码是否正确,调用校验器进行密码判断,登录成功后则会生成 token
  2. 认证解析 token 组件:判断请求头是否有 token,如果有则解析 token
  3. 在配置类配置相关功能

下面实现了 TokenLoginFilter:

// TokenLoginFilter.java

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    // 构造方法
    public TokenLoginFilter(AuthenticationManager authenticationManager) {
        this.setAuthenticationManager(authenticationManager);
        this.setPostOnly(false);
        // 制定登录接口和提交方式,可以制定任意路径
        this.setRequiresAuthenticationRequestMatcher(
                new AntPathRequestMatcher("/admin/system/index/login", "POST")
        );
    }

    // 登录认证
    // 重写父类方法,得到用户名和密码,进行校验
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response) throws AuthenticationException {
        try {
            // 1. 获取输入用户信息
            LoginVo loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class);
            // 2. 封装为 Authentication 对象
            Authentication authentication = new UsernamePasswordAuthenticationToken(loginVo.getUsername(),
                    loginVo.getPassword());
            // 3.
            return this.getAuthenticationManager().authenticate(authentication);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    // 认证成功后调用方法
    @Override
    protected void successfulAuthentication(HttpServletRequest request,
                                            HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication auth) throws IOException, ServletException {
        // 1. 获取当前认证用户对象
        CustomUser customUser = (CustomUser) auth.getPrincipal();
        // 2. 生成 Token 字符串
        String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername());
        // 3. 返回 Token
        Map<String, Object> map = new HashMap<>();
        map.put("token", token);
        ResponseUtil.out(response, Result.ok(map));
    }

    // 认证失败后调用方法
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request,
                                              HttpServletResponse response,
                                              AuthenticationException failed) throws IOException, ServletException {
        ResponseUtil.out(response, Result.build(null, ResultCodeEnum.LOGIN_ERROR));
    }

}

// ResponseUtil.java

public class ResponseUtil {

    public static void out(HttpServletResponse response, Result r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        try {
            mapper.writeValue(response.getWriter(), r);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

随后需要构建认证解析 token 组件,判断请求头是否有 token,有的话成功,没有则失败。也就是判断是否是登录过程:

  1. 如果是登录接口,直接放行
  2. 如果不是登录接口,从请求头中获取用户名称,如果得不到失败,如果得到则封装为对象,存到上下文中
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    public TokenAuthenticationFilter() {}

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 如果是登录接口,直接放行
        if ("/admin/system/index/login".equals(request.getRequestURI())) {
            filterChain.doFilter(request, response);
            return;
        }

        // 如果不是登录接口,判断请求头里面是否有 token,有 token 则是登陆
        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if (authentication != null) {
            // SecurityContextHolder 类似于 Context,保存上下文内容
            SecurityContextHolder.getContext().setAuthentication(authentication);
            filterChain.doFilter(request, response);
        } else {
            ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION));
        }
    }

    // 判断请求头中是否有信息
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader("token");
        if (StringUtils.isNotEmpty(token)) {
            String username = JwtHelper.getUsername(token);
            if (StringUtils.isNotEmpty(username)) {
                return new UsernamePasswordAuthenticationToken(username,
                        null,
                        Collections.emptyList());
            }
        }
        return null;
    }
}

最后配置相关认证类就可以了:

// Spring Security 最新版和以前不一样了,没配出来

用户授权

用户授权思路:

  1. 登录时,根据用户名查询用户操作权限,随后将用户操作权限放到 Redis 中
  2. 校验时,从请求头获取 token 字符串,从 token 获取 username,最后拿着 username 到 redis 查询权限数据

具体实现:

  1. 修改 loadUserByUsername 接口方法,根据用户名查询用户权限,封装返回:CustomUser(sysUser, Collections.emptyList()),后面这个 Collection 就是权限数据集合
  2. 修改 TokenLoginFilter 方法,增加权限数据部分,获取当前登录用户的权限数据,把权限数据放到 Redis 中。key: username, value: 权限数据
  3. 修改 TokenAuthenticationFilter 方法,增加权限数据部分,从请求头获取 token,获取 username,使用 username 到 Redis 中查询权限数据,执行相关操作。此外使用 ThreadLocal 存储当前登录人信息
  4. 修改配置类,添加 Redis 设置和注解
  5. 在 Controller 中加注解,进行权限控制

步骤1:

// UserDetailsServiceImpl.java

public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private SysUserService sysUserService;

    @Autowired
    private SysMenuService sysMenuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = sysUserService.getByUsername(username);

        if (sysUser == null) {
            throw new UsernameNotFoundException("用户名不存在!");
        }

        if (sysUser.getStatus() == 0) {
            throw new BlueStragglersException(201, "账号已停用");
        }

        // 根据用户 ID 查询用户操作权限数据
        List<String> userPermsList = sysMenuService.findUserPermissionListByUserId(sysUser.getId());
        // 封装为目标集合
        List<SimpleGrantedAuthority> authList = new ArrayList<>();
        for (String perm : userPermsList) {
            authList.add(new SimpleGrantedAuthority(perm.trim()));
        }
        return new CustomUser(sysUser, authList);
    }
}

步骤2:首先需要添加 redis 依赖:spring-boot-starter-data-redis

// TokenLoginFilter.java

public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {
        private RedisTemplate redisTemplate;

    // 构造方法
    public TokenLoginFilter(AuthenticationManager authenticationManager, RedisTemplate redisTemplate) {
      ...;
    }

    // 认证成功后:
  // 获取当前用户权限,放到 Redis 中,让 key: username, value: perms
        redisTemplate.opsForValue().set(customUser.getUsername(),            JSON.toJSONString(customUser.getAuthorities()));
}

步骤3:

private RedisTemplate redisTemplate;

public TokenAuthenticationFilter(RedisTemplate redisTemplate) {
  this.redisTemplate = redisTemplate;
}

private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        String token = request.getHeader("token");
        if (StringUtils.isNotEmpty(token)) {
            String username = JwtHelper.getUsername(token);
            if (StringUtils.isNotEmpty(username)) {
                // 通过 username 从 redis 中获取权限数据,并转换为相应集合类型
                String authString = (String)redisTemplate.opsForValue().get(username);
                if (StringUtils.isNotEmpty(authString)) {
                    // 这个 Map 的 key 就是 auth
                    List<Map> mapList = JSON.parseArray(authString, Map.class);
                    List<SimpleGrantedAuthority> authList = new ArrayList<>();
                    for (Map map : mapList) {
                        authList.add(new SimpleGrantedAuthority((String)map.get("authority")));
                    }
                    return new UsernamePasswordAuthenticationToken(username,
                            null,
                            authList);
                } else {
                    return new UsernamePasswordAuthenticationToken(username,
                            null,
                            Collections.emptyList());
                }
            }
        }
        return null;
    }

步骤4:

// SecurityConfig.java

@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)

@Autowired
    private RedisTemplate redisTemplate;

@Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception{
        return authenticationConfiguration.getAuthenticationManager();
    }

.addFilterBefore(new TokenAuthenticationFilter(redisTemplate),
                        UsernamePasswordAuthenticationFilter.class)
                .addFilter(new TokenLoginFilter(authenticationManager(new AuthenticationConfiguration()),
                        redisTemplate));

步骤5:在 application-dev.yml 中加入 Redis 配置信息

spring:
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 1800000
    password:
    jedis:
      pool:
        max-active: 20 # 最大连接数
        max-wait: -1 # 最大阻塞等待时间(负数表示没有限制)
        max-idle: 5 # 最大空闲
        min-idle: 0 # 最小空闲

步骤6:在 Controller 层接口上加入 @PreAuthorize 标签控制接口权限

Activiti 入门

工作流 Workflow 就是业务上的完整审批流程,例如员工请假、出差、采购、合同审批等。具体:填写请假单 -> 部门经理审批 -> 总经理审批 -> 人事备案

工作流引擎:可以在调整工作流的同时,程序可以不用改变

image-20230424145632313

常见的工作流引擎:Activiti、JBPM、Camunda、Flowable 等

Activiti:是一个工作流引擎,可以将业务系统从复杂的业务流程中抽取出来,使用专门的建模语言 BPMN 进行定义

BPM:Business Process Management 业务流程管理

BPMN:业务流程建模符号,提供建模符号

主要符号:

  1. 事件 Event:开始、中间、结束

image-20230424150450900

  1. 活动 Activities:活动可以是一个任务,也可以是当前流程的子处理流程

image-20230424150506543

  1. 网关 GateWay:可以理解为下一个流程该去哪个地方,流程的分支和合并
    1. 排他网关:只有一条路径会被选择
    2. 并行网关:所有路径会被同时选择
    3. 包容网关:可以同时执行多条线路,也可以在网关上设置条件
    4. 事件网关:专门为中间捕获事件设置的,允许设置多个输出流指向不同的中间捕获事件。当流程执行到事件网关后,流程处于等待状态,需要等待抛出事件才能将等待状态转换为活动状态

image-20230424150614670

image-20230424150909078

  1. 流向 Flow:连接两个流程节点的连线,常见的流向包含:
    1. 顺序流:用实线表示,指定活动执行的顺序
    2. 信息流:用虚线表示,描述两个独立的业务参与者之间发送和接收的消息流动
    3. 关联:用没有箭头的虚线表示,将相关的数据、文本和其他人工信息与流对象联系起来,用于展示活动的输入和输出

image-20230424151158118

流程样例:

image-20230424151238418

Activiti 使用流程

【未完待续】