# 云上OA **Repository Path**: swis/on-cloud-oa ## Basic Information - **Project Name**: 云上OA - **Description**: 2023尚硅谷云尚办公视频项目练习 - **Primary Language**: Java - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 4 - **Forks**: 1 - **Created**: 2023-05-07 - **Last Updated**: 2024-03-22 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README 新手的项目记录 > 视频链接:[云尚办公](https://www.bilibili.com/video/BV1oM41177Jd/?p=109&spm_id_from=333.1007.top_right_bar_window_history.content.click) ### 环境搭建 Maven搭建 #### 创建工程 image-20230327192531049 创建工程后删除src目录,新建模块,工程结构如图,注意创建子模块父模块的选择 image-20230327193011437 #### 引入依赖 image-20230327204840240 #### 配置mp [开启mysql时遇到的问题](https://blog.csdn.net/weixin_44235530/article/details/115760098?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522167992242416800217275176%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=167992242416800217275176&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_click~default-1-115760098-null-null.142^v76^insert_down2,201^v4^add_ask,239^v2^insert_chatgpt&utm_term=net%20start%20mysql%20%E6%9C%8D%E5%8A%A1%E5%90%8D%E6%97%A0%E6%95%88%E3%80%82&spm=1018.2226.3001.4187) 在service-util中新建springboot的配置文件 image-20230327221614483 application.yml ```yml spring: application: name: service-oa profiles: active: dev ``` application-dev.yml image-20230327222521657 可以改成Druid 实体类,mp注解 image-20230328091852077 继承自公共类BeanEntity 使用IdType.AUTO会自动把该字段注入进来 image-20230328092311625 表里面可以没有这个对应的字段 mapper的增删改操作可以直接继承mp的提供的接口 ```java package com.auth.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.sy.system.SysRole; public interface SysRoleMapper extends BaseMapper { } ``` 建立测试类 在java,test包下创建测试类,使用注解,就可以进行测试了 记得@SpringBootTest(classes = 启动类名) ```java package com.sy.auth; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest(classes = ServiceAuthApplication.class) public class testService { @Autowired SysRoleService sysRoleService; @Test public void test(){ System.out.println(sysRoleService.list()); } } ``` 测试mapper的操作 上面mapper是一个接口,调用方法的时候需要用到它的实现类对象 image-20230328160605949 更新的时候,updateById传的是一个对象 删除操作时一般都是对字段进行逻辑删除的 首先表中要有is_deleted的字段 mp中提供的注解 image-20230328161007964 调用mapper中的删除方面,自动进行逻辑删除 image-20230328161326841 批量删除 ```java sysRoleMapper.deleteBatchIds(Arrays.asList(11, 12)) ``` 条件查询 ```java @Test public void Query1(){ //条件查询 QueryWrapper wrapper = new QueryWrapper<>(); //这里需要跟表中的字段名一致 // wrapper.eq("role_name","总经理"); // wrapper.ge("id",2); wrapper.clear(); System.out.println(sysRoleMapper.selectList(wrapper)); } @Test public void Query2(){ //lambda条件查询 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); // wrapper.eq("role_name","总经理"); // wrapper.ge("id",2); //这里只要写对应的属性 wrapper.eq(SysRole::getRoleName, "普通管理员"); System.out.println(sysRoleMapper.selectList(wrapper)); } ``` controller返回数据有统一的格式要求,例 ```json { "code": 200, "message": "成功", "data":[ { "id": 2, "rolename": "系统管理员" } ], "ok": true } ``` 这是有数据的结果 无数据的结果,data部分为null 还有分页格式的结果 #### 统一返回格式 项目中都要用到统一返回结果 这里设计一个统一结果返回类Result,根据controller的处理结果返回前端一个Result类型的对象 因为不同的数据返回结果之间存在出入 这里用泛型接收要传回的数据,根据数据生成真正的类型 Result统一消息类型 ```java public class Result { //三个属性 private Integer code; private String message; private T data; //构造私有化 private Result(){} } ``` 我们要传回去的就是这种类型的对象,前端直接拿对象里的值就行了 因为消息只有那么几种,所以限制开发者不能随意设置,统一规范,所以将构造方法私有化 后端开发者只需要选择返回消息的类型就好了,通过静态方法向外部提供 因为状态码和消息是一一对应的,而且只有固定的选择,这里用枚举类进行封装 image-20230328191916357 相当于是一个本身类型的常量,跟构造方法的格式有点像 image-20230328192137654 现在开始给Result设计传入数据的方法 ```java package com.sy.common.result; import lombok.Data; @Data public class Result { private Integer code; private String message; private T data; //构造私有化,外部不能调用内部的方法,只能设置static方法给外部调用 private Result(){} //封装返回的数据 public static Result build(T body, ResultCodeEnum resultCodeEnum){ Result result = new Result<>(); if(body != null){ result.setData( body); } result.setCode(resultCodeEnum.getCode()); result.setMessage(resultCodeEnum.getMessage()); return result; } //设置消息类型 //无数据的成功消息 public static Result ok(){ return build(null, ResultCodeEnum.SUCCESS); } //有数据成功消息 public static Result ok(T data){ return build(data, ResultCodeEnum.SUCCESS); } //失败 public static Result fail(){ return build(null, ResultCodeEnum.FAIL); } } ``` 调用: ```java @GetMapping("/findAll") public Result findAll(){ List list = sysRoleService.list(); return Result.ok(list); } ``` #### 关于Service的接口 image-20230330095704329 ```java public interface SysRoleService extends IService { } ``` ```java @Service public class SysRoleServiceImpl extends ServiceImpl implements SysRoleService { } ``` #### 接口测试 前后端分离开发中,api文档是最好的沟通方式 Swagger是一个规范和完整的框架,能够生成在线文档 knife4j美化了swagger-ui 这个也是公共的 1. 引入依赖 ```xml com.github.xiaoymin knife4j-spring-boot-starter ``` 2. 创建配置类 使用注解@Configuration,@Bean 3. 使用 可以在Controller中添加提示注解 image-20230328195544351 由于springboot版本导致的报错[参考博客](https://blog.csdn.net/weixin_46411355/article/details/128884811?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522167999515616800215074941%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fall.%2522%257D&request_id=167999515616800215074941&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~first_rank_ecpm_v1~rank_v31_ecpm-2-128884811-null-null.142^v76^insert_down2,201^v4^add_ask,239^v2^insert_chatgpt&utm_term=Failed%20to%20start%20bean%20documentationPluginsBootstrapper%3B%20nested%20exception%20is%20java.lang.NullPointerException&spm=1018.2226.3001.4187) 在配置文件中加这个 ```yml spring: mvc: pathmatch: matching-strategy: ANT_PATH_MATCHER ``` #### 分页查询 1. 配置分页插件 [官网](https://baomidou.com/pages/2976a3/#spring-boot) mapper扫描到配置文件中 springboot启动类换一个扫描包 > 在这里出现了一个bug,因为换了包名,service扫描不到了,把service的包加上,**大括号加逗号**(字符串数组) > > 后面还是改了一下包名,我太马虎了 image-20230329204825919 2. 编写controller分页方法 image-20230328203551593 由前端传来两个参数,页号和条数,和一个条件对象 这个条件对象是自己写的,用来对应前端传来的参数,里面只有一个字段,就是用户输入的信息 mp提供一个Page类型来接收page信息 获得信息,使用StringUtils(Spring提供的)对字符串进行判空操作,是否为空或空字符串 image-20230328204839660 使用lambda条件查询 3. 调动service实现分页查询 image-20230329210349651 仔细观察一下这里,使用的是@GetMapping,前端除了发送页号和限制项数之外,还有模糊查询所输入的字符。 这里直接用一个对象接收完全没有问题,**此时要求传参的名称必须用controller中的相同**,否则要使用@RequrestParam注解 [参考博客](https://blog.csdn.net/qq_36685996/article/details/120155107?ops_request_misc=&request_id=&biz_id=102&utm_term=@GetMapper%E5%8F%AF%E4%BB%A5%E7%9B%B4%E6%8E%A5%E6%8E%A5%E6%94%B6%E5%8F%82%E6%95%B0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduweb~default-1-120155107.142^v77^insert_down38,201^v4^add_ask,239^v2^insert_chatgpt&spm=1018.2226.3001.4187) 使用get请求传参是url传参,是没有请求体的,如果想使用请求体传参则使用@PostMapper,使用@RequestBody(请求体)解析JSON格式的对象 image-20230329213520303 > 在rest或者restful风格中,查询使用get请求,添加使用post,修改使用put请求(@PutMapping),删除使用@DeleteMapping #### 批量删除 image-20230329214749951 如图,JSON数据常见的格式有两种,对象格式和数组格式,正好可以与java对应,使用@RequestBody接收 [@PathVariable、@RequestParam、@RequestBody三者使用](https://blog.csdn.net/weixin_43605266/article/details/114991039?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168009795916800184116473%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=168009795916800184116473&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_click~default-1-114991039-null-null.142^v77^insert_down38,201^v4^add_ask,239^v2^insert_chatgpt&utm_term=%40pathvariable%E5%92%8C%40requestbody&spm=1018.2226.3001.4187) @PathVariable对应url中{}中的参数 @RequestParam、@RequestBody都对应的是请求中的数据,例: ```java @GetMapping("role") @ResponseBody public UserRole getUserRole(@RequestParam Integer id) { return userRoleMapper.getRole(id); } ``` 根据type不同,使用不同的注解 ```java @Api(tags = "角色管理") @RestController @RequestMapping("/admin/system/sysRole") public class SysRoleController { @Autowired SysRoleService sysRoleService; @ApiOperation("查询所有角色") @GetMapping("findAll") public Result findAll(){ List list = sysRoleService.list(); return Result.ok(list); } @ApiOperation("条件分页查询") @GetMapping("{page}/{limit}") public Result pageQueryRole(@PathVariable Long page, @PathVariable Long limit, SysRoleQueryVo sysRoleQueryVo){ Page pageParam = new Page<>(page, limit); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); String sysRoleName = sysRoleQueryVo.getRoleName(); if(!StringUtils.isEmpty(sysRoleName)){ wrapper.like(SysRole::getRoleName, sysRoleName); } sysRoleService.page(pageParam, wrapper); return Result.ok(pageParam); } @ApiOperation("添加角色") @PostMapping("save") public Result save(@RequestBody SysRole sysRole){ boolean isSuccess = sysRoleService.save(sysRole); return isSuccess ? Result.ok() : Result.fail(); } @ApiOperation("根据id进行查询") @GetMapping("get/{id}") public Result get(@PathVariable Long id){ SysRole sysRole = (SysRole) sysRoleService.getById(id); return Result.ok(sysRole); } @ApiOperation("修改角色") @PutMapping("update") public Result update(@RequestBody SysRole sysRole){ boolean isSuccess = sysRoleService.updateById(sysRole); return isSuccess ? Result.ok() : Result.fail(); } @ApiOperation("根据id删除") @DeleteMapping("remove/{id}") public Result remove(@PathVariable Long id){ boolean isSuccess = sysRoleService.removeById(id); return isSuccess ? Result.ok() : Result.fail(); } @ApiOperation("批量删除") @DeleteMapping("batchRemove") public Result BatchRemove(@RequestBody List idList){ boolean isSuccess = sysRoleService.removeBatchByIds(idList); return isSuccess ? Result.ok() : Result.fail(); } } ``` #### 时间格式 json的默认时区问题 application-dev.yml添加以下内容,注意跟DataSource同级 ```yaml jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 ``` #### 统一返回格式(异常) 根据不同的异常返回不同的情况,使用统一的格式 有三种情况: - 全局异常 - 特定异常 - 自定义异常 实现过程: 1. 创建一个类专门对捕获的异常进行处理 2. 使用相应的注解(@ControllerAdvice)将该类交给SpringBoot管理,本质上是一个Controller 3. 处理异常(@ExceptionHandler) 自定义异常过程: 创建一个自定义异常类,继承自RuntimeException 两个变量,一个状态码和message ```java @Data public class CustomException extends RuntimeException { private Integer code; private String msg; public CustomException(Integer code, String msg){ super(msg); this.code = code; this.msg = msg; } /** * 枚举类型的参数 * @Param resultCodeEnum */ public CustomException(ResultCodeEnum resultCodeEnum) { super(resultCodeEnum.getMessage()); this.code = resultCodeEnum.getCode(); this.msg = resultCodeEnum.getMessage(); } @Override public String toString() { return "CustomException{" + "code=" + code + ", msg='" + msg + '\'' + '}'; } } ``` 自定义异常系统不会自定义捕获,需要自己手动捕获抛出 如这里0作为除数抛出自定义异常 ```java try{ int i/0; } catch(Exception e){ throw new CustomException(2001, "执行自定义异常..."); } ``` 首先创建一个处理异常的类,名称这里是GlobalExceptionHandler > [@ControllerAdvice解释](https://blog.csdn.net/weixin_57726902/article/details/128018761?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168010474816800225580833%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=168010474816800225580833&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~top_click~default-1-128018761-null-null.142^v77^insert_down38,201^v4^add_ask,239^v2^insert_chatgpt&utm_term=%40controlleradvice%E6%B3%A8%E8%A7%A3%E4%BD%9C%E7%94%A8&spm=1018.2226.3001.4187) > > 本质上就是@Controller,在此基础上做了一些增强 加上@RespondBody返回结果 ```java @ControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseBody public Result error(){ return Result.fail().message("全局异常处理。。。。。"); } @ExceptionHandler(CustomException.class) @ResponseBody public Result error(CustomException e){ e.printStackTrace(); return Result.fail().code(e.getCode()).message(e.getMsg()); } } ``` ![image-20230330090647503](assets/image-20230330090647503.png) image-20230330090706335 [不知道mysql怎么搞的](https://blog.csdn.net/m0_46278037/article/details/113923726?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522166752741216782390575410%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=166752741216782390575410&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-1-113923726-null-null.142%5Ev63%5Econtrol,201%5Ev3%5Econtrol,213%5Ev1%5Et3_esquery_v3&utm_term=ERROR%201045%20%2828000%29%3A%20Access%20denied%20for%20user%20root%40localhost%20%28using%20password%3A%20NO%29&spm=1018.2226.3001.4187) ### 前端知识 #### 基本知识 模板字符串: ``,${} 对象扩展运算符:就是对象的复制 ```java let obj = {name: "xxx", age: 20} let objCopy = { ... obj } ``` 箭头函数:跟lambda表达式差不多,只不过箭头是`=>` ​ 一个参数就不用括号,多个参数加括号,用逗号隔开 vue的简单使用 在一个html文件中,创建一个div标签设置id 在script标签中创建一个vue对象,我们在script标签中书写js代码 生命周期 ```html ``` Axios 先将axios引入,直接调用axios里的方法 ```js //js 的普通方法 getList(){ //使用axios发送get请求 axios.get("接口名称").then().catch() } ``` 根据返回的结果,如果返回成功,则进入被`then()`方法捕获,失败被`catch()`捕获 需要用一个对象来接收,这里直接使用箭头表达式来创建对象 ```js axois.get("xxx").then(response => { this.userList = response.data.data }) ``` `reponse`是响应体,后端返回的是Result对象,实际的数据在Result对象的data中 Node.js 类似于java中的JDK,是js的运行环境 一般使用js需要引入CND,在node.js环境下能够直接运行js文件 在控制台输入`node 文件名.js` 直接执行js文件,在控制台输出 [环境配置](https://blog.csdn.net/m0_67392273/article/details/126113759?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522168015034816800211598175%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=168015034816800211598175&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~all~sobaiduend~default-2-126113759-null-null.142^v77^insert_down38,201^v4^add_ask,239^v2^insert_chatgpt&utm_term=%E9%85%8D%E7%BD%AEnode.js&spm=1018.2226.3001.4187) NPM(npm) node.js的包管理工具,相当于java中的maven 可以创建一个标准的前端项目、管理前端依赖 安装node.js时会附带安装npm工具 1. 生成项目 ```js //项目初始化 npm init //使用默认值初始化 npm init -y ``` 会创建一个文件,package.json,类似于java中的maven的pom.xml 2. 下载依赖 可以配置镜像(跟配置阿里镜像一样) ```js //设置镜像地址 npm config set registry https://registry.npm.taobao.org //查看配置 npm config list //下载依赖 npm install 依赖名称 [指定版本,默认为最新版本] ``` 或者根据配置文件下载依赖,`npm install` 模块化开发 就是js文件之间的相互引用。每一个js文件中的变量、函数、类都是私有的,对其他文件不可见 导出模块:`export`,在方法或者对象前加这个表示能被其他模块调用,相当于public 导入模块:`import` 局部导出和导入 ```js //文件1 export function fun1(){ console.log("fun1") } //文件2 import {fun1, fun2...} from "文件路径(.js可以省略)" fun1() ``` 文件内容全部导出 ```js export default { //方法1 //方法2 } import 名称 from "文件" ``` 这个名称自定义,相当于起了一个代表文件的对象名 使用文件里的函数时可以这样,类似于对象调用成员方法 ```js 名称.方法1 ``` 在控制台中输入:` node 文件2`,无法正常执行 因为ES6的模块化方式无法在node.js中执行,需要用**Bable**编辑成ES5后再执行,node执行转码后的文件 Bable 框架里一般会直接有,要知道是干什么的 Babel的配置文件是`babelrc`,存放在项目的根目录下,该文件用来设置转码规则和插件 presets字段设定转码规则,然后要安装转码器 转码,把当前文件里的文件转码到目标目录 ```js babel src -d dir ``` dir为指定输出目录,可以`mkdir`一个 #### 引入框架 vue-admin-template是后台管理的精简版 [GitHub地址](https://github.com/PanJiaChen/vue-admin-template) 1. 在GitHub中下载zip文件,解压到工作区 2. 根据配置文件下载相关依赖 3. 启动项目`npm run dev` src目录下的main.js作为全局导入 框架本身模拟接口在mock文件夹下,要把模板的接口改成自己的 image-20230330132736273 #### 前端接口路径修改 1. 在vue.config.js中做如下修改 image-20230330133441086 ```js // before: require('./mock/mock-server.js') proxy: { '/dev-api': { // 匹配所有以 '/dev-api'开头的请求路径 target: 'http://localhost:8800', changeOrigin: true, // 支持跨域 pathRewrite: { // 重写路径: 去掉路径中开头的'/dev-api' '^/dev-api': '' } } } ``` 登录接口 image-20230330134745942 根据模拟响应给出对应格式的消息 ```java @Api(tags = "登录") @RestController @RequestMapping("/admin/system/index") public class IndexController { //login @PostMapping("login") public Result login(){ //{"code":20000,"data":{"token":"admin-token"}},这个状态码由前端去修改 Map map = new HashMap<>(); map.put("token", "admin-token"); return Result.ok(map); } //info @GetMapping("info") public Result info(){ Map 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(); } } ``` 2. 修改前端的访问路径 image-20230330140118248 3. 修改状态码,找到响应体部分的状态码修改为200 image-20230330193502239 > 写完这三步之后,还是显示404,我vscode虽然有自动保存功能,最后还是重启了一下问题就解决了 #### 路由 1. api中集中配置接口信息 2. router中配置路由 3. 在页面中调用接口 image-20230330194456323 配置路由: image-20230330195850292 ```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' } } ] }, ``` 定义接口: image-20230330201504098 这里说一下前面提到的,在那个get方法中输入的查询条件,后端是用普通对象接收的,所以这里就用`params`进行传递,如果使用的是`json`格式,那么就用`data: 对象名称` ```js import request from '@/utils/request' const api_name = '/admin/system/sysRole' export default { // 分页查询 getPageList(current, limit, searchObj) { return request({ url: `${api_name}/${current}/${limit}`, method: 'get', params: searchObj }) } } ``` 编辑页面: 引入js ```html ``` #### 接口实现 ##### 分页查询 ```js // 初始值 data() { return { list: [], // 结果列表 page: 1, // 页号 limit: 10, // 每页条数 total: 0, // 总数 searchObj: {} // 条件对象 } }, ``` 来看一下后端返回的对象 返回的是一个IPage对象(或子类) image-20230330204827037 ```js // 方法 methods: { fetchData(current = 1) { this.page = current // 根据所传参数更新页号 api.getPageList(this.page, this.limit, this.searchObj).then(res => { this.list = res.data.records this.total = res.data.total }) } } ``` 这里就不用catch方法了,框架自带的拦截器已经拦截了失败方法 引入element-ui ##### 删除角色 点击删除按钮之后有一个确认窗口的弹窗,这里element-ui有进行提供 点击确认之后进入`then`方法:即调用删除接口 取消进入`catch`方法,这里取消没有操作则不用catch image-20230330211939151 ```js // 根据id删除数据 removeDataById(id) { // debugger this.$confirm('此操作将永久删除该记录, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { // promise // 点击确定,远程调用ajax return api.removeById(id) }).then((response) => { this.fetchData(this.page) this.$message.success(response.message || '删除成功') }) } ``` ##### 添加角色 要创建一个对象进行接收信息,引入element-ui对话框,用一个变量控制是否可见 image-20230330214452070 默认为不可见,引入添加按钮(编辑按钮),点击事件为控制对话框的可见度 根据对话框确定按钮绑定的点击事件,编写一个方法 如果传入的对象没有id值,即为添加方法,调用save接口,有id值即调用修改接口 ```js // 添加或者修改 saveOrUpdate() { this.saveBtnDisabled = true // 防止表单重复提交 if (!this.sysRole.id) { this.save() } else { this.update() } }, save() { api.save(this.sysRole).then(res => { // 提示 this.$message.success(res.message || '操作成功') // 关闭弹窗 this.dialogVisible = false // 刷新页面 this.fetchData(this.page) }) } ``` ##### 修改角色 修改还是不一样的,要考虑怎么将id注入进来,不然的话就会一直调用添加方法 所以这里需要调用getById方法 image-20230330225835446 对应的前端代码 1. 实现两个接口 image-20230330230257297 2. 给edit按钮添加点击事件,调出弹窗,同时获得当前id对应的数据显示在对话框 这里是双向绑定,数据会自动回显 image-20230330230840428 看到edit方法将当前的id传了过来 ```vue edit(id) { this.dialogVisible = true api.getById(id).then(res => { this.sysRole = res.data }) }, ``` > ps: 拦截器已经把第一层data过滤了 3. 更新,注意 提示、关弹窗、刷新 ```vue update() { api.updateById(this.sysRole).then(res => { this.$message.success(res.message || '操作成功') this.dialogVisible = false this.fetchData(this.page) }) } ``` ##### 批量删除 添加批量删除的按钮,添加复选框组件 image-20230330235057854 这里定义了一个数组`selections`保存获得的数据,在此基础上获得id数组 ```js handleSelectionChange(selection) { this.selections = selection }, batchRemove() { if (this.selections.length === 0) { this.$message.warning('请选择要删除的记录!') return } this.$confirm('此操作将永久删除该记录, 是否继续?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' }).then(() => { // 获得idList var idList = [] this.selections.forEach(item => idList.push(item.id)) // 调用接口 return api.batchRemove(idList) }).then((response) => { this.fetchData() this.$message.success(response.message || '删除成功') }) } } ``` ### 用户管理 用户和角色之间属于多对多,需要创建中间表 image-20230331084830926 #### mp代码生成器 依赖,3.51之后的要不一样,我这里用的是[新版](https://baomidou.com/pages/981406/#%E6%A8%A1%E6%9D%BF%E9%85%8D%E7%BD%AE-templateconfig) ```xml com.baomidou mybatis-plus-generator 3.5.3.1 org.freemarker freemarker 2.3.31 ``` 在test文件夹中创建类 ```java public class Generator { public static void main(String[] args) { FastAutoGenerator.create("jdbc:mysql://localhost:3306/co-oa?serverTimezone=GMT%2B8&useSSL=false", "root", "root") // 全局配置 .globalConfig(builder -> { builder.author("sy") // 设置作者 .enableSwagger() // 开启 swagger 模式 .fileOverride() // 覆盖已生成文件 .outputDir("D:\\Java\\project\\oa-system\\oa-parent\\service-oa\\src\\main\\java"); // 指定输出目录 }) // 包配置 .packageConfig(builder -> { builder.parent("com.sy") // 设置父包名 .moduleName("auth"); // 设置父包模块名 }) // 策略配置 .strategyConfig(builder -> { builder.addInclude("sys_user") .serviceBuilder() .formatServiceFileName("%sService") // 去掉I .formatServiceImplFileName("%sServiceImp"); }) .templateEngine(new FreemarkerTemplateEngine()) // 使用Freemarker引擎模板,默认的是Velocity引擎模板 .execute(); } } ``` 删除多余的部分,修改类的包,修改路径 image-20230331095052632 #### 接口实现 ##### 基本CURD操作 分页查询、获得用户、保存用户、修改用户、删除用户(ID)、分配角色、修改状态 前五个仿照上面角色界面的写 ```java // 分页查询 @ApiOperation(value = "分页查询") @GetMapping("{page}/{limit}") public Result index(@PathVariable Long page, @PathVariable Long limit, SysUserQueryVo sysUserQueryVo){ Page sysUserPage = new Page(page, limit); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); String username = sysUserQueryVo.getKeyword(); String createTimeBegin = sysUserQueryVo.getCreateTimeBegin(); String createTimeEnd = sysUserQueryVo.getCreateTimeEnd(); // 设置查询条件 if(!StringUtils.isEmpty(username)){ wrapper.like(SysUser::getName, username); } if(!StringUtils.isEmpty(createTimeBegin)){ wrapper.ge(SysUser::getCreateTime, createTimeBegin); } if(!StringUtils.isEmpty(createTimeEnd)){ wrapper.le(SysUser::getCreateTime, createTimeEnd); } Page userPage = sysUserService.page(sysUserPage, wrapper); return Result.ok(userPage); } ``` 这里有一个与创建时间做比较的查询条件 > StringUtils还是用的baomidou包下的 数据库中的创建时间要在传来参数的时间之内,就是说查询条件中有根据创建时间查询用户这条选项 > mp中的wapper.like模糊查询、wapper.ge大于、le小于 直接测试保存数据的时候,这里报了一个错误 image-20230401100028085 直接测试service是没有问题的,根据提示是在JSON解析的时候出了问题,String不能转换成Data格式,查了一下,好像是关于时区的问题 去掉一些字段进行保存,测试成功 image-20230401100748840 ##### 给用户分配角色 image-20230401104231979 涉及的操作 1. 显示所有角色 和 当前用户的角色 2. 点击确定后 为用户分配角色 删除用户原有的角色 添加新的角色 关系表和角色表 image-20230401105217702 image-20230401105340360 使用代码生成器,删掉多余的部分,Controller,entity等 把对应的的业务操作写在了SysRoleController.java中 1. 显示所有角色 和 当前用户的角色 这里使用一个map对象进行返回,一个map中有两条entry对象,key为String类型,value为List类型 即所有角色的list和当前角色的list 将操作的具体过程封装到`SysRoleService`中 ```java @ApiOperation("查询所有角色 和 当前用户所属角色") @GetMapping("toAssign/{userId}") public Result toAssign(@PathVariable Long userId){ Map map = sysUserRoleService.findRolesByUserId(userId); return Result.ok(map); } ``` 下面编写`findRolesByUserId`方法 有两种方式实现 - 调用默认方法 - 直接在xml文件中编写一条SQL语句 使用默认方法: 查询所有角色 ```java List allRoles = baseMapper.selectList(null);// 查询所有参数是null ``` image-20230401114429055 继承的父类已经将对应的代理注入,可以直接调用。也可以调用service(当前对象)的list方法 查询用户所属角色 这里可以偷个懒,通过用户关系表获得的id,直接和所有角色list中的对比 考验基础的时候到了 ![image-20230401122843174](assets/image-20230401122843174.png) 2. 点击确定后 为用户分配角色 所需要的条件有 用户id 和 对应角色id的列表 这里用一个对象进行封装 image-20230401133137724 image-20230401134522967 ```java @Service public class SysRoleServiceImpl extends ServiceImpl implements SysRoleService { @Autowired SysUserRoleService sysUserRoleService; public Map findRolesByUserId(Long userId){ // 查询所有角色 // List allRoles = baseMapper.selectList(null); List allRoles = this.list(); // 当前用户所属的角色 List userRoleList = sysUserRoleService.list(new LambdaQueryWrapper().eq(SysUserRole::getUserId, userId)); List userRoleId = userRoleList.stream().map(sysUserRole -> sysUserRole.getRoleId()).collect(Collectors.toList()); List roles = allRoles.stream().filter(s -> userRoleId.contains(s.getId())).collect(Collectors.toList()); // 返回结果 Map map = new HashMap<>(); map.put("allRoles", allRoles); map.put("roles", roles); return map; } public void doAssign(AssginRoleVo assginRoleVo){ // 删除已有的角色 根据条件删除 sysUserRoleService.remove(new LambdaQueryWrapper().eq(SysUserRole::getUserId, assginRoleVo.getUserId())); // 添加新的角色 for(Long roleId : assginRoleVo.getRoleIdList()){ if(StringUtils.isEmpty(roleId)) continue; SysUserRole sysUserRole = new SysUserRole(); sysUserRole.setUserId(assginRoleVo.getUserId()); sysUserRole.setRoleId(roleId); sysUserRoleService.save(sysUserRole); } } ``` 设置用户状态 1表示开启,0表示停用,即用户不能再登录 #### 前端 1. 添加路由 2. 定义接口 3. 编写页面调用接口 调用接口的时候注意接口名称是否与js文件中的一致 这里遇到前端数据渲染不出来的问题 image-20230401172458769解决方法 image-20230401175947900 注意要用`@RequestBody`接收post的对象 ### 菜单管理 菜单之间有层级关系(相当于课程号对应的先行课)sys_menu 每种角色有不同的菜单(对应微信公众号里的菜单)sys_role_menu 每个菜单也可以对应多个角色 下面代码生成器生成将两个表 image-20230402100604850 这里将SysRoleMenuController删除 #### curd接口实现 - 获取菜单列表 - 新增菜单 - 修改菜单 - 删除菜单 ##### 菜单列表 菜单之间的关系为一个树形结构,使用递归 image-20230402102944826 返回对象的时候往children里加入对象 controller: ```java // 菜单的列表接口 @ApiOperation(value = "菜单列表") @GetMapping("findNodes") public Result findNodes(){ List list = sysMenuService.findNodes(); return Result.ok(list); } ``` serviceImpl ```java @Override public List findNodes() { // 1. 查询所有菜单的数据 List sysMenuList = this.list(); // 2. 构建树形结构 使用工具类menuHelper List resultList = MenuHelper.buildTree(sysMenuList); return resultList; } ``` MenuHelper工具类 ```java public class MenuHelper { public static List buildTree(List sysMenuList) { // 结果 List trees = new ArrayList<>(); // 遍历列表,对第一层的菜单元素进行递归 parentId = 0 for (SysMenu sysMenu : sysMenuList){ if(sysMenu.getParentId().longValue() == 0){ trees.add(getChildren(sysMenu, sysMenuList)); } } return trees; } private static SysMenu getChildren(SysMenu sysMenu, List sysMenuList) { List children = new ArrayList<>(); // 当前节点的id = 下一层节点的parentId for (SysMenu menu : sysMenuList){ if(menu.getParentId().longValue() == sysMenu.getId()){ children.add(getChildren(menu, sysMenuList)); } } sysMenu.setChildren(children); return sysMenu; } } ``` ##### 删除 要求有子集菜单的目录不能直接删除(非级联删除) serviceImpl ```java @Override public void removeMenuById(Long id) { /** * 判断当前菜单是否有子菜单,有则不能删除 * 只需在表中查找 是否存在parentId = id的元素 */ Long count = baseMapper.selectCount(new LambdaQueryWrapper().eq(SysMenu::getParentId, id)); if (count > 0L) { throw new CustomException(201, "不能删除当前菜单"); } baseMapper.deleteById(id); } ``` #### 为角色分配菜单 过程跟给用户分配角色是差不多的 在角色界面添加分配按钮 点击分配后,显示当前角色 - 获得菜单列表 和 已分配的菜单 - 分配菜单(删除原有的和添加新的) 需要一个中间表sys_role_menu 查询已经分配的菜单 ```java @Override public List findSysMenuByRoleId(Long roleId) { //筛选状态为1的列表 1代表可用 List allSysMenuList = this.list(new LambdaQueryWrapper().eq(SysMenu::getStatus, 1)); //根据角色id获得关系表里的元素 List sysRoleMenuList = sysRoleMenuMapper.selectList(new LambdaQueryWrapper().eq(SysRoleMenu::getRoleId, roleId)); //根据获得的元素获得相应的菜单id List menuIdList = sysRoleMenuList.stream().map(e -> e.getMenuId()).collect(Collectors.toList()); // 判断当前菜单是否已添加 allSysMenuList.forEach(permission -> { if (menuIdList.contains(permission.getId())) { permission.setSelect(true); } else { permission.setSelect(false); } }); List sysMenuList = MenuHelper.buildTree(allSysMenuList); return sysMenuList; } ``` 为角色分配菜单 - 根据角色id删除原有的菜单 - 获得新分配的菜单列表进行遍历添加 #### 前端 1. 添加分配的隐藏路由 router的index.js中 image-20230402123905414 2. 在角色页面添加跳转按钮 跳转方法image-20230402124255693 3. 配置接口 4. 书写界面 ### 权限管理 不同用户有不同角色,不同角色有不同的权限,不同的权限显示不同的菜单(可操作的功能不一样) 所以前端的菜单实际上是动态的 实现接口: - 用户登录 - 获得可操作菜单 用户登录 1. 根据用户名,查询用户是否存在 2. 是否被禁用 3. 登录成功之后保持登录状态 **基于token实现** 把登录信息加密(生成一个唯一的字符串)塞到cookie中 cookie不能跨域传递,所以把cookie的值放到request头部 > **jwt**,JSON Web Token,一个工具,生成唯一的字符串,并对其进行加密处理,有三部分组成:jwt头部(算法,类型)、有效载荷(携带的信息)、签名哈希(根据签名的内容计算出来的唯一的字符串,保证信息不被篡改) 获得可操作菜单 1. 从token中获得用户id 2. 根据用户id获得可操作菜单 jwt配置 在common-util中引入依赖jwt依赖,创建工具类 ```xml io.jsonwebtoken jjwt ``` 工具类 image-20230402203326012 #### 实现登录接口 > 之前添加用户的时候,输入的密码也需要进行加密。这里使用MD5的加密方式。 > > MD5加密是不可逆的。登录时判断密码是否正确,就是对登录的密码进行一次MD5加密,比较跟数据库中的是否一致 ```java @PostMapping("login") public Result login(@RequestBody LoginVo loginVo){ // 1. 根据用户名查询数据库 String username = loginVo.getUsername(); SysUser user = sysUserService.getOne(new LambdaQueryWrapper().eq(SysUser::getUsername, username)); // 2. 判断用户是否存在 if (user == null) { throw new CustomException(201, "用户不存在"); } // 3. 验证密码 String pws_db = user.getPassword(); String psw = MD5.encrypt(loginVo.getPassword()); if (!pws_db.equals(psw)) { throw new CustomException(201, "密码错误"); } // 4. 用户是否被禁用 if (user.getStatus().intValue() == 0) { throw new CustomException(201, "用户已经被禁用,请联系管理员"); } // 5. 生成token String token = JwtHelper.createToken(user.getId(), user.getUsername()); // 6. 返回token Map map = new HashMap<>(); map.put("token", token); return Result.ok(map); } ``` #### 获得可操作的菜单和按钮 菜单表中记录了菜单和按钮,type字段,0表示根菜单,1表示菜单,2表示按钮 image-20230402212558833 ##### Controller ```java @GetMapping("info") public Result info(HttpServletRequest request){ // 1. 从请求头中获取token String token = request.getHeader("token"); // 2. 从token中获得用户id或者名称,把用户信息查出来 Long userId= JwtHelper.getUserId(token); SysUser sysUser = sysUserService.getById(userId); // 3. 根据用户Id获得可操作菜单 List routerList = sysMenuService.findUserMenuListByUserId(userId); // 4. 根据用户Id获得可操作按钮 List permsList = sysMenuService.findUserPermsByUserId(userId); // 6. 返回相关数据 Map map = new HashMap<>(); map.put("roles", "[admin]"); map.put("name", sysUser.getName()); map.put("avatar","https://oss.aliyuncs.com/aliyun_id_photo_bucket/default_handsome.jpg"); map.put("routers", routerList); map.put("buttons", permsList); return Result.ok(map); } ``` ##### service实现 1. 根据用户id获得用户可以操作菜单列表 这里涉及多表关联,需要自己写SQL语句 用户id ——> 角色id——>菜单列表 ```java 1. 判断角色是不是管理员,管理员能获得所有菜单,用户要根据角色筛选菜单 2. 根据返回的菜单 封装成前端路由对象的形式 3. 返回结果列表 ``` ```java @Override public List findUserMenuListByUserId(Long userId) { List sysMenusList = null; // 1. 判断是不是管理员 userId = 1,是则返回所有列表 if (userId.longValue() == 1) { sysMenusList = baseMapper.selectList(new LambdaQueryWrapper() .eq(SysMenu::getStatus, 1).orderByAsc(SysMenu::getSortValue)); } else { sysMenusList = baseMapper.findMenuListByUserId(userId); } // 2. 把查询出来的数据封装成前端路由的结构 List sysMenuTreeList = MenuHelper.buildTree(sysMenusList); List routerList = this.buildRouter(sysMenuTreeList); return routerList; } ``` ###### 封装RouterVo RouterVo的属性 ```java @Data public class RouterVo { private String path; private boolean hidden; private String component; private Boolean alwaysShow; private MetaVo meta; private List children; } ``` 对应的前端路由对象 image-20230403133332266 下面对封装过程进行书写,依然使用了递归 脑子不够,想了很多遍,记录一下 ```java private List buildRouter(List menus){ 1. 创建数组,收集结果 2. 遍历菜单树列表 新建一个routerVo封装当前菜单的信息 // 添加隐藏路由 如果当前菜单对象的type=1的话,那么在它的children中可能会存在隐藏路由,这个路由跟当前的菜单对象是平级的。判断component是否为空筛选,将隐藏hidden的值改为true。 ps:一个menu对应一个routerVo,只是说如果menu里包含隐藏路由的话就多加一个routerVo // 递归下一层 如果是一个根目录(存在子菜单)setAlwaysShow(true) } ``` image-20230403141031808 ```java private List buildRouter(List menus) { List routers = new ArrayList<>(); for (SysMenu menu : menus) { RouterVo router = new RouterVo(); router.setHidden(false); router.setAlwaysShow(false); router.setPath(getRouterPath(menu)); router.setComponent(menu.getComponent()); router.setMeta(new MetaVo(menu.getName(), menu.getIcon())); List children = menu.getChildren(); // 添加隐藏路由 if(menu.getType().intValue() == 1) { // 挑出来 List hiddenMenuList = children.stream() .filter(item -> !StringUtils.isEmpty(item.getComponent())) .collect(Collectors.toList()); // 修改hidden值 for (SysMenu hiddenMenu : hiddenMenuList) { RouterVo hiddenRouter = new RouterVo(); 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){ router.setAlwaysShow(true); } router.setChildren(buildRouter(children)); } } routers.add(router); } return routers; } ``` 获得可操作的按钮就不写了,那个很容易理解 ###### 实现多表查询 编写xml文件 ![image-20230403182501731](assets/image-20230403182501731.png) 前面传入的参数加上`@Param("属性名")`,一个参数可以不加 userId ——> userId,roleId——>roleId,menuId 这里要获得的是菜单列表 image-20230403183025081 测试的SQL语句 ```sql SELECT DISTINCT * FROM sys_menu m INNER JOIN sys_role_menu rm ON rm.menu_id = m.id INNER JOIN sys_user_role ur ON ur.role_id = rm.role_id WHERE ur.user_id = 2 AND m.status = 1 AND rm.is_deleted = 0 AND ur.is_deleted = 0 AND m.is_deleted = 0 ``` xml中 ```xml m.id,m.parent_id,m.name,m.type,m.path,m.component,m.perms,m.icon,m.sort_value,m.status,m.create_time,m.update_time,m.is_deleted ``` 之前写mapperXml的时候有时候放在了resources里面,这里没有,maven默认情况下,只会加载编译src-main-java目录里面的java类型的文件 解决方法: 1. 把xml文件放到resources目录下面 2. 通过配置方式进行加载 在pom.xml和配置文件中添加配置 让maven加载文件类型 配置文件中的mp部分写上xml的路径 一定要记住接口名别写错 #### Spring Security 服务端权限控制,包括认证和授权 引入 1. 在公共模块中新建模块,引入spring-security相关依赖 2. 在新模块中创建一个配置类,开启默认功能 3. 在业务模块中引入新模块 image-20230404133100747 ```java @Configuration @EnableWebSecurity //@EnableWebSecurity是开启SpringSecurity的默认行为 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { } ``` ```xml com.sy common-util 1.0-SNAPSHOT org.springframework.boot spring-boot-starter-security org.springframework.boot spring-boot-starter-web provided ``` image-20230404133218698 测试默认功能 image-20230404133428492 image-20230404133524395 ##### 认证授权分析 SpringSecurity的默认功能,即每访问一个接口都要输入用户名和密码,针对本项目,设置自己的拦截方式 客户端发送请求访问接口 1. 认证 - 当前用户是否合法(跟登录接口功能部分重叠) - 当前用户是否处于登录状态,即请求头中是否包含token 2. 授权 - 当前用户是否具有访问该接口的权限 时序图 image-20230404224552284 用户提交信息,先进入`UserPasswordAuthenticationFilter`这个过滤器,该过滤器将请求中的信息封装成一个对象 先看默认是怎么处理的 双击shift进入源码查看 image-20230404231126594 这里进行登录认证处理,注意源码中是从request中获得的用户名和密码(通过`getParameter`的方式),将用户信息封装成一个对象 image-20230404142331733 直接看到后面的具体实现 image-20230405141635852 进入`retrieveUser()`中,调用`loadUserByUsername()`根据用户名称查询,返回一个**查询对象** image-20230404144252305 进入`additionalAuthenticationChecks()`中,看到使用了`passwordEncoder`对封装的用户密码和返回的查询结果的密码进行校验。 image-20230404144057854 debug就不写了,默认的流程大概就是这样 由上面的分析大概可以知道我们需要自定义哪些地方 ##### 登录认证 1. SpringSecurity默认是从请求中直接获得`username`的 所以自定义一个类`TokenLoginFilter`,继承自`UsernamePasswordAuthenticationFilter` image-20230404234222202 重写`attemptAuthentication`方法 ```java // 登录认证 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { try { // 1. 通过输入流获得信息 LoginVo loginVo = new ObjectMapper().readValue(request.getInputStream(), LoginVo.class); // 2. 封装成一个对象 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword()); // 3. 调用方法完成认证 return this.getAuthenticationManager().authenticate(authenticationToken); } catch (IOException e) { throw new RuntimeException(e); } } ``` 2. 根据用户名查询用户信息肯定是需要自己写的 SpringSecurity默认又不知道查我们的数据库,查询返回的用户信息也需要定义一个类来封装(UserDetails) image-20230404235408555 查询方法 新建一个接口,继承自`org.springframework.security.core.userdetails.UserDetailsService` 将实现类`UserDetailsServiceImpl`写到service实现类的包中,也可以让实现类直接实现springsecurity的`UserDetailsService`接口 重写方法`loadUserByUsername` image-20230405134642250 判断用户是否存在,判断用户是否被停用 **将查询到的结果封装到自定义的用户类中(CustomUser)** ```java @Service public class UserDetailsServiceImpl implements UserDetailsService { @Autowired SysUserService sysUserService; @Autowired SysMenuService sysMenuService; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser sysUser = sysUserService.getUserByUserName(username); if(null == sysUser) { throw new UsernameNotFoundException("用户名不存在!"); } if(sysUser.getStatus().intValue() == 0) { throw new CustomException(ResultCodeEnum.ACCOUNT_STOP); } List userPermsList = sysMenuService.findUserPermsByUserId(sysUser.getId()); List authorities = new ArrayList<>(); for (String perm : userPermsList) { authorities.add(new SimpleGrantedAuthority(perm.trim())); } return new CustomUser(sysUser, authorities); } } ``` 使用了自定义异常,新建枚举`ACCOUNT_STOP` 这里注意接收权限列表的类型`SimpleGrantedAuthority` 3. 校验密码 自定义一个密码校验器,实现`PasswordEncoder`接口,使用MD5的方法进行校验 ![image-20230404145330867](assets/image-20230404145330867.png) 4. 登录认证 进行以上操作后,把返回的UserDetails对象,还有密码校验的结果封装到authentication对象中返回到`TokenLoginFilter` 认证成功后,生成`token` 并把返回的用户权限保存到缓存中,这里使用redis做缓存,key:用户名,value:权限列表 配置一下redis > redis连接不上有几个点,配置文件修改保护模式,bind本机 > > linux端口6379的防火墙关闭 > > 有密码springboot配置文件中写密码 ```yml redis: host: 192.168.235.128 port: 6379 database: 0 timeout: 1800000 password: 111111 jedis: pool: max-active: 20 #最大连接数 max-wait: -1 #最大阻塞等待时间(负数表示没限制) max-idle: 5 #最大空闲 min-idle: 0 #最小空闲 ``` 在`TokenLoginFilter`类中继续重写两个方法 image-20230405143042065 添加构造方法,将redis传进来 ```java private RedisTemplate redisTemplate; public TokenLoginFilter(AuthenticationManager authenticationManager, RedisTemplate redisTemplate) { this.setAuthenticationManager(authenticationManager); this.setPostOnly(false); //指定登录接口及提交方式,可以指定任意路径 this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/admin/system/index/login","POST")); this.redisTemplate = redisTemplate; } ``` ```java /** * 登录成功 * @param request * @param response * @param chain * @param auth * @throws IOException * @throws ServletException */ @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication auth) throws IOException, ServletException { CustomUser customUser = (CustomUser) auth.getPrincipal(); String token = JwtHelper.createToken(customUser.getSysUser().getId(), customUser.getSysUser().getUsername()); //保存权限数据 redisTemplate.opsForValue().set(customUser.getUsername(), JSON.toJSONString(customUser.getAuthorities())); Map map = new HashMap<>(); map.put("token", token); ResponseUtil.out(response, Result.ok(map)); } /** * 登录失败 * @param request * @param response * @param e * @throws IOException * @throws ServletException */ @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException { if(e.getCause() instanceof RuntimeException) { ResponseUtil.out(response, Result.build(null, ResultCodeEnum.SERVICE_ERROR)); } else { ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION)); } } ``` 因为不是在RestController中,所以使用原生方式进行返回 image-20230405202504333 使用工具类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_UTF8_VALUE); try { mapper.writeValue(response.getWriter(), r); } catch (IOException e) { e.printStackTrace(); } } } ``` ##### 解析token 我们已经将当前用户的权限加到缓存中了,下面要进行判断,当前用户的请求中是否包含token,并对token中的用户名进行校验 如果用户名校验成功,我们将缓存中的权限列表拿出来一起封装成一个authentication对象,并将其放到上下文中 校验失败返回null ```java public class TokenAuthenticationFilter extends OncePerRequestFilter { private RedisTemplate redisTemplate; public TokenAuthenticationFilter(RedisTemplate redisTemplate) { this.redisTemplate = redisTemplate; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { logger.info("uri:"+request.getRequestURI()); //如果是登录接口,直接放行 if("/admin/system/index/login".equals(request.getRequestURI())) { chain.doFilter(request, response); return; } UsernamePasswordAuthenticationToken authentication = getAuthentication(request); if(null != authentication) { SecurityContextHolder.getContext().setAuthentication(authentication); chain.doFilter(request, response); } else { ResponseUtil.out(response, Result.build(null, ResultCodeEnum.PERMISSION)); } } // 自定义解析方法 private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) { // 从请求中取出token String token = request.getHeader("token"); logger.info("token:"+token); // 判断是否有token if (!StringUtils.isEmpty(token)) { String username = JwtHelper.getUsername(token); logger.info("useruame:"+username); // 校验token if (!StringUtils.isEmpty(username)) { // 从缓存中获得权限列表 // 转换类型 String authoritiesString = (String) redisTemplate.opsForValue().get(useruame); List mapList = JSON.parseArray(authoritiesString, Map.class); List authorities = new ArrayList<>(); for (Map map : mapList) { authorities.add(new SimpleGrantedAuthority((String)map.get("authority"))); } return new UsernamePasswordAuthenticationToken(useruame, null, authorities); } else { return new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>()); } } return null; } } ``` ##### 配置类和异常处理 WebSecurityConfig,就看代码注解,会配置就行了 image-20230405205439710 image-20230405205842278 需要授权的接口上加上注解,添加授权标识 ```java @PreAuthorize("hasAuthority('bnt.sysUser.assignRole')") ``` image-20230406164153864 由于权限抛出的异常上SpringSecurity抛出的,在全局异常所在的模块下添加依赖,定义统一异常结果 ```java /** * spring security异常 * @param e * @return */ @ExceptionHandler(AccessDeniedException.class) public Result error(AccessDeniedException e) throws AccessDeniedException { return Result.build(null, ResultCodeEnum.PERMISSION); } ``` ```xml org.springframework.boot spring-boot-starter-security provided ``` ##### 测试 测试一下李四的分配角色的权限 - 给用户分配角色,按钮在用户界面添加按钮权限 ```vue :disabled="$hasBP('bnt.sysUser.assignRole') === false" ``` ![image-20230406165542973](assets/image-20230406165542973.png) - 管理员身份创建一个角色 image-20230406165225554 image-20230406165256222 - 使用李四登录 image-20230406165616514 - 前端按钮不禁用 image-20230406165801078 权限管理终于搞完了 ### Activit7 工作流引擎,一个用于企业管理的实现框架。 #### 使用 ##### 配置 配置引入依赖 image-20230406173023424 内置了SpringSecurity,认证只用系统用户才能操作工作流 配置文件中进行配置 ```yml activiti: # false:默认,数据库表不变,但是如果版本不对或者缺失表会抛出异常(生产使用) # true:表不存在,自动创建(开发使用) # create_drop: 启动时创建,关闭时删除表(测试使用) # drop_create: 启动时删除表,在创建表 (不需要手动关闭引擎) database-schema-update: true #监测历史表是否存在,activities7默认不开启历史表 db-history-used: true #none:不保存任何历史数据,流程中这是最高效的 #activity:只保存流程实例和流程行为 #audit:除了activity,还保存全部的流程任务以及其属性,audit为history默认值 #full:除了audit、还保存其他全部流程相关的细节数据,包括一些流程参数 history-level: full #校验流程文件,默认校验resources下的process 文件夹的流程文件 check-process-definitions: true ``` > 我这里配置的时候报了错,清理了maven之后就好了 启动生成表 ##### 初步使用 我用tomcat打不开,所以我使用Activiti BPMN visualizer插件绘制流程设计 > [这个工具也不错](https://blog.csdn.net/thinkingcao/article/details/105946306),不过好像都没有tasklistener(问题不大) image-20230407173807555 image-20230407185244555 使用插件打开 image-20230407185327750 画图,右键选择事件 image-20230407185925689 保存为png,选择路径,刷新一下就好了 ##### 部署 根据流程图修改数据库 使用RepositoryService 新建测试类 修改pom.xml文件 image-20230407191641573 > 血的教训,springboot的版本真的不要图新,有些错误真的会报的莫名其妙,还是老老实实用原来的吧 image-20230407200424247 ##### 流程实例 使用RuntimeService 根据流程图创建java实例 image-20230407201925473 ##### 查询任务 使用TaskService 查询某个人代办的任务 ![image-20230407202708887](assets/image-20230407202708887.png) ##### 处理当前任务 ```java @Test public void completeTask(){ // 获得当前角色的一条任务 String assign = "张三"; Task task = taskService.createTaskQuery().taskAssignee(assign).singleResult(); taskService.complete(task.getId()); } ``` 任务自动跳到下一个节点 ##### 查看历史任务 使用HistoryService image-20230407204039721 ##### 总结 1. 绘制流程图 2. 流程图部署(根据流程图修改数据库),RepositoryService 3. 创建流程实例(如流程定义的id,当前流程的id,当前活动的id),RuntimeService 4. 查询某人的代办任务,TaskService 5. 完成一项任务,TaskService 6. 查看历史任务,HistoryService #### 流程实例 ##### 绑定业务 流程的类型, 当前流程的id, 当前流程中的具体业务id ```java // 创建流程实例,指定BusinessKey @Test public void startUpProcessAddBusinessKey() { ProcessInstance instance = runtimeService.startProcessInstanceByKey("请假", "1001"); System.out.println(instance.getBusinessKey()); } ``` image-20230409144029490 ##### 挂起和激活 挂起的流程实例不能被处理 两种情况 1. 全部流程实例进行挂起 全部流程实例都会对应一个流程定义id,只需要对这个单个流程定义对象进行操作就可以了 使用RepositoryService获得流程定义对象 注意不要引错方法了,这里是ByKey ```java // 全部流程实例进行挂起和激活 @Test public void suspendProcessInstanceAll() { // 1. 获得流程定义对象 ProcessDefinition qingjia = repositoryService.createProcessDefinitionQuery().processDefinitionKey("qingjia").singleResult(); // 2. 调用方法判断状态 boolean isSuspended = qingjia.isSuspended(); // 3. 挂起状态转换成激活 if (isSuspended) { // 参数: 流程定义id,是否激活,激活时间点 repositoryService.activateProcessDefinitionById(qingjia.getId(), true, null); System.out.println(qingjia.getId()+"被激活了"); } else { repositoryService.suspendProcessDefinitionById(qingjia.getId(), true, null); System.out.println(qingjia.getId()+"被挂起了"); } } ``` image-20230409151451618 当前流程被挂起了,所以流程中相应的待办任务是不能够被完成的 所以处理任务的时候会报错 image-20230409152521279 再将实例进行激活 image-20230409152753653 2. 单个流程实例挂起 单个实例就不能使用RepositoryService了,这里使用RuntimeService 根据流程实例id获得实例,调用方法 ```java // 单个流程实例挂起 @Test public void singleSuspendProcessInstance() { ProcessInstance processInstance = runtimeService.createProcessInstanceQuery().processInstanceId("5424f4d2-d6a1-11ed-adb8-5cbaef37902d").singleResult(); if (processInstance.isSuspended()) { runtimeService.activateProcessInstanceById("5424f4d2-d6a1-11ed-adb8-5cbaef37902d"); System.out.println(processInstance.getProcessInstanceId()+"被激活了"); } else { runtimeService.suspendProcessInstanceById("5424f4d2-d6a1-11ed-adb8-5cbaef37902d"); System.out.println(processInstance.getProcessInstanceId()+"被挂起了"); } } ``` #### 任务分配 给每个节点分配任务,包括分配给谁,分配的时间 有三种分配方式: 1. 固定分配 前面进行的就是固定分配,直接指定分配者,直接指定任务 image-20230409155153344 2. 表达式分配 使用UEL(统一的表达式语言) activiti支持两个UEL表达式:UEL-value和UEL-method UEL-value: 新建一个加班流程 image-20230409155408846 image-20230409160240122 右键保存png image-20230409160429041 创建好流程图后,开始部署和创建实例 image-20230409162456128 UEL-method: 将${}当中的值换成方法的形式,注意返回值为String - 创建一个UserBean,编写方法,使用`@Component`注解,将UserBean交给spring容器管理 - ${userBean.方法名} - 创建实例时,不需要传map参数,直接创建即可 3. 监听器分配 监听事件,进行自动分配,如监听到该节点被创建,就自动分配assignee `delegate:委派` ```java public class MyTaskListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { if (delegateTask.getAssignee().equals("经理审批")) { delegateTask.setAssignee("zhangsan"); } else if (delegateTask.getAssignee().equals("人事审批")) { delegateTask.setAssignee("lisi"); } } } ``` 在TaskListener那一栏输入全类名,设置监听事件 BPMN-Activiti-Diagram插件上没有任务监听器,不过这个好像也不常用 #### 其他要点 - 流程变量 - 任务组 Candidate-users候选人,用逗号隔开,可以从候选人中选出一个人来处理委派节点 拾取(claim)任务,把任务指定给某一位候选人 ```java @Test public void claimTask(){ //拾取任务,即使该用户不是候选人也能拾取(建议拾取时校验是否有资格) //校验该用户有没有拾取任务的资格 Task task = taskService.createTaskQuery() .taskCandidateUser("zhangsan01")//根据候选人查询 .singleResult(); if(task!=null){ //拾取任务 taskService.claim(taskId, "zhangsan01"); System.out.println("任务拾取成功"); } } ``` 归还组任务 任务交接 - 网关(GateWay) image-20230409200429108 控制流程的流向,通常和流程变量一起使用 常见的网关 1、排他网关:只有一条路径会被选择 2、并行网关:所有路径都会选择,不会解析条件 image-20230409200006421 两个部门审批完成之后,人事才能看到 3、包含网关 有排他的特点也有并行的特点,可以根据条件判断选择路径,等所有任务都完成后才能进入下一个节点 ### 审批管理 为系统添加审批功能 分为管理端和员工端,员工端主要是在微信公众号中进行申请 #### 审批设置 审批设置主要分为两方面 1、审批类型:人事,财务,其他 2、审批模板:审批类型里设计的具体事件。设计要填写的表单,管理员可以设计流程图(将流程图工具集成进来,或者画好上传) 管理员添加审批类型,并且为该类型设计审批模板 ##### 审批类型 表单 审批类型表和审批模板表 image-20230409202408743 使用代码生成器生成对应的controller,service,mapper,生成代码后进行相应的修改 ![image-20230409203803558](assets/image-20230409203803558.png) ```java .controllerBuilder() .enableRestStyle() //开启生成@RestController 控制器 ``` 注意新创建的mapper在mp配置类中需要扫描进来 ```java @MapperScan(basePackages = {"com.sy.auth.mapper","com.sy.process.mapper"}) ``` 启动类需要换一个位置 image-20230409203328943 > 会默认扫描【启动类所在的包以及其子包中的类,然后把其实例化为bean,使用IoC管理起来】 编写curd接口,整合前端 ![image-20230430111250449](assets/image-20230430111250449.png) image-20230430111345529 测试 ![image-20230430111747628](assets/image-20230430111747628.png) ##### 审批模板 审批模板表单中,写的是审批类型的id值,而后端实体类中对应的是类型名称 ![image-20230430112451032](assets/image-20230430112451032.png) image-20230430112902264 新建方法完成属性的封装 image-20230430113452872 ```java @Override public IPage selectPageProcessTempate(Page pageParam) { // 1. 正常分页查询 Page processTemplatePage = baseMapper.selectPage(pageParam, null); // 2. 获得查询出来的list集合 List processTemplateList = processTemplatePage.getRecords(); // 3. 根据id获得名称 for (ProcessTemplate p : processTemplateList){ // 获得id Long processTypeId = p.getProcessTypeId(); // 设置查询条件 LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); wrapper.eq(ProcessType::getId, processTypeId); ProcessType one = processTypeService.getOne(wrapper); if (one == null) continue; // 4. 实现封装 p.setProcessTypeName(one.getName()); } return processTemplatePage; // 引用不变 } ``` 测试,正常显示审批类型 image-20230430121926332 添加审批模板功能表 ![image-20230430122129033](assets/image-20230430122129033.png) 添加模板的步骤: 基本设置:审批名称和类型等等 表单设置:设计要填写的表单 流程设置:设计流程,上传zip格式的文件 上传文件 这个接口在template里面的 ![image-20230430140823908](assets/image-20230430140823908.png) 整合前端 引入依赖 image-20230430141343464 上传成功 发布 根据上传的zip文件部署流程定义 创建新方法 image-20230430144909562 将发布状态的值置为1,表示已发布,不可再更改,后面再完成部署 image-20230430145124376 #### 审批管理 流程发布后,用户使用发布的模板填写申请,正式进入审批流 这里是对审批流进行的管理 相关表,生成对应的实体类 image-20230430150546933 分页查询列表接口,创建新的service方法 image-20230430195447889 image-20230430200626193 mapper接口中添加方法 image-20230430201352984 ```java public interface ProcessMapper extends BaseMapper { IPage selectPage(Page page, @Param("vo") ProcessQueryVo processQueryVo); } ``` image-20230430201812140 添加SQL 整合前端 修改路径 image-20230430202608858 ##### 完成部署 在`ProcessService`接口中定义方法 image-20230430203513426 导入`repositoryService`,读取zip文件,完成部署 ```java @Override public void deployByZip(String deployPath) { InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(deployPath); ZipInputStream zipInputStream = new ZipInputStream(inputStream); // 部署 repositoryService.createDeployment().addZipInputStream(zipInputStream).deploy(); } ``` 路径在之前定义模板的时候已经加到表中了,可以直接获取字段 ```java public void publish(Long id) { ProcessTemplate processTemplate = this.getById(id); processTemplate.setStatus(1); baseMapper.updateById(processTemplate); if (!StringUtils.isEmpty(processTemplate.getProcessDefinitionPath())){ processService.deployByZip(processTemplate.getProcessDefinitionPath()); } } ``` ### 员工端审批 审批模板发布后,员工端(微信公众号)就可以填写表单提交流程了 #### 界面渲染 这里前端使用另一个工程 > 跨域:协议,ip地址,端口号,有任何一个地方不一致就不能访问 注意这里两个Controller不要重名(我这里写错了) image-20230430212140792 获得审批类型及其对应的审批模板 image-20230430212515954 编写接口 image-20230430213123101 ```java @Autowired ProcessTemplateService processTemplateService; @Override public List findProcessType() { List processTypes = baseMapper.selectList(null); for (ProcessType processType : processTypes){ List processTemplatelist = processTemplateService.list(new LambdaQueryWrapper().eq(ProcessTemplate::getProcessTypeId, processType.getId())); processType.setProcessTemplateList(processTemplatelist); } return processTypes; } ``` 整合前端 生成临时token image-20230430215127406 image-20230430215318301 image-20230430215919766 这里下载依赖的时候又报错了 image-20230501204945021 [参考博客](https://blog.csdn.net/qq_40610003/article/details/129348036),卸了一大堆软件装上了vs2017 image-20230502164949942 image-20230502165655315 点击当前的图标(审批模板)返回审批模板的表单信息 根据模板id获得模板 image-20230502172546408 #### 启动流程实例 启动流程实例是需要用户信息的,用户登录后会携带token,spring-security对token进行解析通过用户名和密码进行比对实现认证 下面将用户信息存储到ThreadLocal中 添加帮助类 image-20230502173641758 image-20230502174156595 发布流程 image-20230502174823473 创建一个类来接收表单信息 image-20230502174956579 ```java @ApiOperation(value = "启动流程") @PostMapping("/startUp") public Result start(@RequestBody ProcessFormVo processFormVo) { processService.startUp(processFormVo); return Result.ok(); } ``` 创建startUp方法 回顾一下发布流程实例,使用runtimeService ![image-20230502175927808](assets/image-20230502175927808.png) 首先需要确定使用哪一个流程,流程定义key 该流程与哪一项具体的业务进行绑定,process_Id 传过来的表单数据就是业务的表单数据 image-20230502184602637 相关表 ![image-20230502200533748](assets/image-20230502200533748.png) startUp分两个阶段 - 第一阶段,创建流程实例 ```java // 1. 获得用户 SysUser sysUser = sysUserService.getById(LoginUserInfoHelper.getUserId()); // 2. 保存表单信息到业务表 // 获得审批模板 ProcessTemplate processTemplate = processTemplateService.getById(processFormVo.getProcessTemplateId()); Process process = new Process(); // 使用工具复制相同属性 BeanUtils.copyProperties(processFormVo, process); // 其他值一个一个set process.setStatus(1); // 审批中 String workNo = System.currentTimeMillis() + ""; process.setProcessCode(workNo); process.setUserId(LoginUserInfoHelper.getUserId()); process.setFormValues(processFormVo.getFormValues()); process.setTitle(sysUser.getName() + "发起" + processTemplate.getName() + "申请"); // 保存信息 baseMapper.insert(process); // 3. 启动流程实例 // 3.1 流程定义key,直接从processTemplate中获得 String processDefinitionKey = processTemplate.getProcessDefinitionKey(); // 3.2 ProcessId String businessKey = String.valueOf(process.getId()); // 3.3 流程参数,需要map集合 String formValues = processFormVo.getFormValues(); JSONObject formData = JSONObject.parseObject(formValues); Map map = new HashMap<>(); for (Map.Entry entry : formData.entrySet()){ map.put(entry.getKey(),entry.getValue()); } Map variables = new HashMap<>(); variables.put("data", map); // 3.4 启动 ProcessInstance instance = runtimeService.startProcessInstanceByKey(processDefinitionKey, businessKey, variables); process.setProcessInstanceId(processInstance.getId()); ``` - 将消息推送给下一个审批人 ![image-20230502200032390](assets/image-20230502200032390.png) ```java // 4. 给审批人推送消息 // 任务清单 List taskList = taskService.createTaskQuery().processInstanceId(processInstance.getId()).list(); if (!CollectionUtils.isEmpty(taskList)) { // 审批人清单 List assigneeList = new ArrayList<>(); for (Task task : taskList) { String assigneeName = task.getAssignee(); SysUser user = sysUserService.getUserByUserName(assigneeName); String name = user.getName(); assigneeList.add(name); // TODO 推送消息 } process.setDescription("等待" + StringUtils.join(assigneeList.toArray(), ",") + "审批"); } // 5. 更新信息 baseMapper.updateById(process); ``` #### 记录提交记录 相关表 ![image-20230502200612592](assets/image-20230502200612592.png) 把提交记录保存在这个表中,前端显示记录信息 使用代码生成器生成实体类 创建`record`的方法,添加记录 ```java @Service public class ProcessRecordServiceImp extends ServiceImpl implements ProcessRecordService { @Autowired SysUserService sysUserService; public void record(Long processId, Integer status, String description){ ProcessRecord processRecord = new ProcessRecord(); processRecord.setProcessId(processId); processRecord.setStatus(status); processRecord.setDescription(description); Long userId = LoginUserInfoHelper.getUserId(); SysUser sysUser = sysUserService.getById(userId); processRecord.setOperateUserId(sysUser.getId()); processRecord.setOperateUser(sysUser.getName()); baseMapper.insert(processRecord); } } ``` 保存记录 image-20230502202511781 #### 代办任务 提交申请之后,审批人能看到自己的待办任务列表,是以processVo列表返回的 ![image-20230502221619420](assets/image-20230502221619420.png) > 这里启动的时候ProcessServiceImp莫名被创建到test下面去了 #### 新添加审批模板的规则 zip文件的名称与xml文件中process id 标签一致 被压缩的xml文件一定是`.bpmn20.xml`结尾的 新建文件`qingjiaceshi` ![image-20230504095016737](assets/image-20230504095016737.png) ![image-20230504100016172](assets/image-20230504100016172.png) 添加请假测试审批模板 image-20230504100842877 image-20230504100821610 image-20230504100947822 刷新数据库 image-20230504101116545 image-20230504101246129 image-20230504101710563 点击发布,这里名称是nullimage-20230504102429366 应该是流程部署的时候没有设置,问题不大 image-20230504102810903 image-20230504103059772 #### 员工端测试 ##### 查看代办任务 测试三个角色 根据xml文件里的名称,在数据库(sys_user)中改一个 image-20230504103742641 ![image-20230504104324144](assets/image-20230504104324144.png) 前端添加方法 image-20230504104818965 生成三个角色的token![image-20230504105015362](assets/image-20230504105015362.png) image-20230504105237838 把之前写死的admin去掉,测试用户登录 image-20230504105500530 image-20230504105700681 点击admin,然后回退就登录进来了 image-20230504105838552 填写表单,提交发布流程实例,检查表中是否发布成功 ![image-20230504110123311](assets/image-20230504110123311.png) 切换账号后,我这里报错403,没有权限访问 image-20230504145106032 查了一圈,token肯定解析出来了,说是redis缓存里面没有,我的redis里确实没有 image-20230504145153612 这边登录一下,缓存一下用户 image-20230504145731381 刷新一下就可以显示了 image-20230504145821897 ##### 查看审批详情信息 点击代办任务可以看到详情信息 process——>processRecord 新建show方法,返回map集合 image-20230504155109667 实现 ```java @Override public Map show(Long id) { // 1. 根据流程id获得流程信息 Process process = baseMapper.selectById(id); // 2. 根据流程id获得流程记录信息 List processRecordList = processRecordService .list(new LambdaQueryWrapper().eq(ProcessRecord::getProcessId, id)); // 3. 根据流程id获得模板信息 ProcessTemplate processTemplate = processTemplateService.getById(process.getProcessTemplateId()); // 4. 判断当前用户是否可以审批 // 不能重复审批,遍历任务,查看任务的审批人是不是当前用户 boolean isApprove = false; List taskList = this.getCurrentTaskList(process.getProcessInstanceId()); if (!CollectionUtils.isEmpty(taskList)) { for(Task task : taskList) { if(task.getAssignee().equals(LoginUserInfoHelper.getUsername())) { isApprove = true; } } } Map map = new HashMap<>(); map.put("process", process); map.put("processRecordList", processRecordList); map.put("processTemplate", processTemplate); map.put("isApprove",isApprove); return map; } ``` ##### 审批通过和审批拒绝 image-20230504185852077 表中的状态字段来表示当前实例的审批状态 image-20230504185551820 编写接口,创建`approve`方法然后实现 这里要做的是审批,根据status字段的值进行不同的操作 ```java @Override public void approve(ApprovalVo approvalVo) { // 1. 获得task参数 String taskId = approvalVo.getTaskId(); Map variables1 = taskService.getVariables(taskId); for (Map.Entry entry : variables1.entrySet()){ System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue()); } // 2. 根据status的值进行不同的操作 if (approvalVo.getStatus() == 1) { //已通过 Map variables = new HashMap(); //流程变量 taskService.complete(taskId, variables); } else { //驳回 this.endTask(taskId); } // 3. 记录过程 String description = approvalVo.getStatus().intValue() == 1 ? "已通过" : "驳回"; processRecordService.record(approvalVo.getProcessId(), approvalVo.getStatus(), description); // 4. 查询下一个审批人 // 更新process,并给下一个审批人进行消息推送 Process process = baseMapper.selectById(approvalVo.getProcessId()); List currentTaskList = this.getCurrentTaskList(process.getProcessInstanceId()); if (!CollectionUtils.isEmpty(currentTaskList)){ List assigneeList = new ArrayList<>(); for (Task task : currentTaskList){ SysUser assignee = sysUserService.getUserByUserName(task.getAssignee()); assigneeList.add(assignee.getName()); // TODO 公众号推送消息 } // 更新流程信息 process.setDescription("等待" + StringUtils.join(assigneeList.toArray(), ",") + "审批"); process.setStatus(1); } else { // 没有下一个审批人 if(approvalVo.getStatus().intValue() == 1) { process.setDescription("审批完成(同意)"); process.setStatus(2); } else { process.setDescription("审批完成(拒绝)"); process.setStatus(-1); } } this.updateById(process); } ``` 驳回,结束流程`endTask` image-20230504202824613 ```java @Override public void approve(ApprovalVo approvalVo) { // 1. 获得task参数 String taskId = approvalVo.getTaskId(); Map variables1 = taskService.getVariables(taskId); for (Map.Entry entry : variables1.entrySet()){ System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue()); } // 2. 根据status的值进行不同的操作 if (approvalVo.getStatus() == 1) { //已通过 Map variables = new HashMap(); //流程变量 taskService.complete(taskId, variables); } else { //驳回 this.endTask(taskId); } // 3. 记录过程 String description = approvalVo.getStatus().intValue() == 1 ? "已通过" : "驳回"; processRecordService.record(approvalVo.getProcessId(), approvalVo.getStatus(), description); // 4. 查询下一个审批人 // 更新process,并给下一个审批人进行消息推送 Process process = baseMapper.selectById(approvalVo.getProcessId()); List currentTaskList = this.getCurrentTaskList(process.getProcessInstanceId()); if (!CollectionUtils.isEmpty(currentTaskList)){ List assigneeList = new ArrayList<>(); for (Task task : currentTaskList){ SysUser assignee = sysUserService.getUserByUserName(task.getAssignee()); assigneeList.add(assignee.getName()); // TODO 公众号推送消息 } // 更新流程信息 process.setDescription("等待" + StringUtils.join(assigneeList.toArray(), ",") + "审批"); process.setStatus(1); } else { // 没有下一个审批人 if(approvalVo.getStatus().intValue() == 1) { process.setDescription("审批完成(同意)"); process.setStatus(2); } else { process.setDescription("审批完成(拒绝)"); process.setStatus(-1); } } this.updateById(process); } ``` 不知道为什么又开始报全局异常,表里面是通过了,还有李四的token过期了 但是点击审批通过之后全局异常,原来是前面把流程实例id忘记更新了,成功显示,一定要细心 image-20230505122917100 ##### 查看已处理和已发起 通过HistoryService来实现 已处理 根据前端新建接口`findProcessed` image-20230505130952556 方法跟前面显示代办任务差不多 主要注意是掌握分页查询这一点 先查询历史记录列表,然后封装成processVo返回 ```java HistoricTaskInstanceQuery query = historyService.createHistoricTaskInstanceQuery() .taskAssignee(LoginUserInfoHelper.getUsername()) .finished() .orderByTaskCreateTime().desc(); int begin = (int) ((pageParam.getCurrent()-1)*pageParam.getSize()); int size = (int) pageParam.getSize(); List historicTaskInstances = query.listPage(begin, size); long totalCount = query.count(); ``` > 这里前端报错403,不知这么的写成了post > > image-20230505150118878 > > 403出错一定和路径有关,还有get,postmapping有没有搞错 已发起 就是直接查询oa_process表中的值,因为涉及到其他三个表的id,之前写的SQL进行的查询 ```java @Override public IPage findStarted(Page pageParam) { ProcessQueryVo processQueryVo = new ProcessQueryVo(); processQueryVo.setUserId(LoginUserInfoHelper.getUserId()); IPage page = baseMapper.selectPage(pageParam, processQueryVo); for (ProcessVo item : page.getRecords()) { item.setTaskId("0"); } return page; } ``` ##### 显示用户信息 注意一般返回数据的时候常用map进行返回 ```java @Override public Map getCurrentUser() { SysUser sysUser = baseMapper.selectById(LoginUserInfoHelper.getUserId()); Map map = new HashMap<>(); map.put("name", sysUser.getName()); map.put("phone", sysUser.getPhone()); return map; } ``` 这里前端路径写在了sysUser的控制层,接口写过去的话注意跨域问题,添加`@CrossOrign`注解 image-20230505152843877 部门和岗位可以使用代码生成器新生成,然后查询 要结束了要结束了:relieved: ### 微信公众号 微信公众号主要功能:菜单,授权登录,推送消息 对`wechat_menu`生成对应的映射 image-20230505154856860 #### 自定义菜单 这里注意实现查询所有列表功能 注意菜单之间有层间关系 > 阿里巴巴Java开发手册中介绍: > > DO(Data Object):此对象与数据库表结构一一对应,通过 DAO层向上传输数据源对象。 > DTO(Data Transfer Object):数据传输对象,Service 或Manager 向外传输的对象。 > VO(View Object):显示层对象,通常是 Web 向模板渲染引擎层传输的对象。 筛选菜单: 当前的菜单id值和其他菜单的parentid值进行比较,如果两者相同,那么就把那个菜单作为当前菜单的子菜单 ```java @Override public List finMenuInfo() { // 1. 查询表中的所有菜单 List menus = baseMapper.selectList(null); // 2. 先筛选出一级菜单 // 封装其对应的二级菜单,parentId和id进行比较 List menuList = menus.stream().filter(m -> m.getParentId().longValue() == 0).collect(Collectors.toList()); List menus1 = new ArrayList<>(); for (Menu m : menuList){ MenuVo menuVo = new MenuVo(); BeanUtils.copyProperties(m,menuVo); // 3. 获得二级菜单 List menus2 = menus.stream() .filter(menu -> m.getId().longValue() == menu.getParentId().longValue()) .map(menu -> { MenuVo menuVo1 = new MenuVo(); BeanUtils.copyProperties(menu, menuVo1); return menuVo1; }) .collect(Collectors.toList()); // 4. 封装 menuVo.setChildren(menus2); menus1.add(menuVo); } return menus1; } ``` > `.longValue()`注意细节,Long类型用`==`号进行比较的时候有可能比较的是引用(大于1字节) 记得添加一下扫描路径 image-20230505183105563 整合前端 #### 微信公众号菜单 使用测试号完成功能。https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login 扫码登录 image-20230505185316601 使用weixin-java-mp工具进行配置 > “自定义菜单”api文档。https://developers.weixin.qq.com/doc/offiaccount/Custom_Menus/Creating_Custom-Defined_Menu.html - 配置文件中配置 image-20230505185707306 - 引入依赖 ```xml com.github.binarywang weixin-java-mp 4.1.0 ``` - 创建工具类`config.WechatAccountConfig` 配置类`WeChatMpConfig` image-20230505191111998 添加同步菜单接口 微信要求的格式如下:一个JSON对象,里面一个button数组 image-20230505191911436 ```java @Autowired private WxMpService wxMpService; @Override public void syncMenu() { List menuVoList = this.findMenuInfo(); //菜单 JSONArray buttonList = new JSONArray(); for(MenuVo oneMenuVo : menuVoList) { JSONObject one = new JSONObject(); one.put("name", oneMenuVo.getName()); if(CollectionUtils.isEmpty(oneMenuVo.getChildren())) { one.put("type", oneMenuVo.getType()); one.put("url", "http://oa.atguigu.cn/#"+oneMenuVo.getUrl()); } else { JSONArray subButton = new JSONArray(); for(MenuVo twoMenuVo : oneMenuVo.getChildren()) { JSONObject view = new JSONObject(); view.put("type", twoMenuVo.getType()); if(twoMenuVo.getType().equals("view")) { view.put("name", twoMenuVo.getName()); //H5页面地址 view.put("url", "http://oa.atguigu.cn#"+twoMenuVo.getUrl()); } else { view.put("name", twoMenuVo.getName()); view.put("key", twoMenuVo.getMeunKey()); } subButton.add(view); } one.put("sub_button", subButton); } buttonList.add(one); } //菜单 JSONObject button = new JSONObject(); button.put("button", buttonList); try { wxMpService.getMenuService().menuCreate(button.toJSONString()); } catch (WxErrorException e) { throw new RuntimeException(e); } } ``` 整合前端 添加删除菜单接口 调用工具类进行删除 image-20230505194909123 出现了,出现了 image-20230505200548471 微信公众号的界面怎么来的,让我捋一捋 1. 首先,登录网页开启了测试号功能 获得对应的id和密钥 2. 在后端配置文件中进行了配置 添加了依赖,生成配置类 3. 封装数据库表中的菜单 4. 当前端调用同步菜单接口时 `wxMpService`将封装好的菜单发给微信官方服务器 image-20230506182922827 image-20230506183303593 5. 微信官方解析封装的菜单 image-20230506183559936 根据之前配置的id和密钥信息返回给对应的公众号 所以说,目前为止所看到的菜单并不是自己9090那个端口的菜单 使用内网穿透工具,前端后端分别生成对应的域名,替换 image-20230506185451476 #### 微信授权 微信号和后台的员工端进行关联,即通过员工的手机号和微信openId建立绑定 image-20230506191553256 用户关注公众号后,选择菜单功能,这时需要微信授权 1. 配置指定域名(8800所对应) ![image-20230506190208408](assets/image-20230506190208408.png) 2. 后端指定具体回调路径 image-20230506192119223 3. 修改前端,获得token和openId,没有就会弹窗,绑定手机号,调用后端授权接口 image-20230506192615321 4. 编写回调接口 创建`WeChatController` image-20230506193959500 创建三个属性 ```java @Autowired private SysUserService sysUserService; @Autowired private WxMpService wxMpService; @Value("${wechat.userInfoUrl}") private String userInfoUrl; ``` 接口一:`/authorize` 就是一个转发功能,自动跳转到指定路径 这里使用了微信的api,可能考虑到安全性做了这层封装 接口二:`/userInfo` 获得用户信息,主要是openId,跟数据库中的表进行比对 如果有openId,就生成token进行返回 如果没有openId,返回空token 接口三:`/bindphone` 传入手机号和openid,更新用户表,返回token ```java @GetMapping("/authorize") public String authorize(@RequestParam("returnUrl") String returnUrl, HttpServletRequest request) { String redirectURL = wxMpService.getOAuth2Service().buildAuthorizationUrl(userInfoUrl, WxConsts.OAuth2Scope.SNSAPI_USERINFO, URLEncoder.encode(returnUrl.replace("guiguoa", "#"))); // 把#替换回来 log.info("【微信网页授权】获取code,redirectURL={}", redirectURL); return "redirect:" + redirectURL; } @GetMapping("/userInfo") public String userInfo(@RequestParam("code") String code, @RequestParam("state") String returnUrl) throws Exception { // 获取accessToken WxOAuth2AccessToken accessToken = wxMpService.getOAuth2Service().getAccessToken(code); // 获取openId String openId = accessToken.getOpenId(); log.info("openId:", openId); // 获得用户信息 WxOAuth2UserInfo userInfo = wxMpService.getOAuth2Service().getUserInfo(accessToken, null); log.info("userInfo:", JSON.toJSONString(userInfo)); // 查询用户 SysUser user = sysUserService.getOne(new LambdaQueryWrapper().eq(SysUser::getOpenId, openId)); String token = ""; if (user != null){ // 生成token token = JwtHelper.createToken(user.getId(),user.getUsername()); } if(returnUrl.indexOf("?") == -1) { return "redirect:" + returnUrl + "?token=" + token + "&openId=" + openId; } else { return "redirect:" + returnUrl + "&token=" + token + "&openId=" + openId; } } @PostMapping("bindPhone") @ResponseBody public Result bindPhone(@RequestBody BindPhoneVo bindPhoneVo) { SysUser sysUser = sysUserService.getOne(new LambdaQueryWrapper().eq(SysUser::getPhone, bindPhoneVo.getPhone())); if(null != sysUser) { sysUser.setOpenId(bindPhoneVo.getOpenId()); sysUserService.updateById(sysUser); String token = JwtHelper.createToken(sysUser.getId(), sysUser.getUsername()); return Result.ok(token); } else { return Result.fail("手机号码不存在,绑定失败"); } } ``` 5. 排除拦截 image-20230506202649324 6. 修改前端 image-20230506202912864 ![image-20230506203054294](assets/image-20230506203054294.png) image-20230506204715916 #### 消息推送 image-20230506205730826 添加模板 image-20230506205751952 创建逻辑层`MessageService` image-20230506210319716 实现类,记得加注解`@Service` 推送给待审批的人,参数:流程id,审批人id,任务id > `@SneakyThrows`注解是由lombok为我们封装的,它可以为我们的代码生成try..catch块, 并把异常向上抛出来,而之前的ex.getStackTrace()是没有这种能力的,有时,我们从底层抛出的异常需要被上层统一收集,而又不想在底层new出一大堆业务相关的异常实例,这时使用@SneakyThrows可以简化我们的代码。(摘自百度文库) ```java @SneakyThrows @Override public void pushPendingMessage(Long processId, Long userId, String taskId) { Process process = processService.getById(processId); SysUser sysUser = sysUserService.getById(userId); // 查询审批模板 ProcessTemplate processTemplate = processTemplateService.getById(process.getProcessTemplateId()); // 提交审批人 SysUser submitSysUser = sysUserService.getById(process.getUserId()); // 消息模板 WxMpTemplateMessage wxMpTemplate = WxMpTemplateMessage.builder() .toUser(sysUser.getOpenId()) // 推送给谁(openId) .templateId("hHMluPg27ZyGaobvuabmrb7-ZOFOCXJElWvlH1LMHJY") // 消息模板id .url("http://8gqsyp.natappfree.cc/#/show/" + processId + "/0")// 跳转到详情 .build(); // 设置值 JSONObject jsonObject = JSON.parseObject(process.getFormValues()); JSONObject formShowData = jsonObject.getJSONObject("formShowData"); StringBuffer content = new StringBuffer(); for (Map.Entry entry : formShowData.entrySet()) { content.append(entry.getKey()).append(":").append(entry.getValue()).append("\n "); } wxMpTemplate.addData(new WxMpTemplateData("first", submitSysUser.getName()+"提交了"+processTemplate.getName()+"审批申请,请注意查看。", "#272727")); wxMpTemplate.addData(new WxMpTemplateData("keyword1", process.getProcessCode(), "#272727")); wxMpTemplate.addData(new WxMpTemplateData("keyword2", new DateTime(process.getCreateTime()).toString("yyyy-MM-dd HH:mm:ss"), "#272727")); wxMpTemplate.addData(new WxMpTemplateData("content", content.toString(), "#272727")); // 生成消息 String msg = wxMpService.getTemplateMsgService().sendTemplateMsg(wxMpTemplate); System.out.println(msg); } ``` 注意方法2的模板id要改一下,跳转的域名记得该 方法调用 注入 image-20230506214332717 `startUp`,`approve` ```java // 推送给审批人 messageService.pushPendingMessage(process.getId(), sysUser.getId(), task.getId()); ``` ```java // 推送给申请人 messageService.pushProcessedMessage(process.getId(), process.getUserId(), approvalVo.getStatus()); ``` 测试测试 报错 ![image-20230506220712183](assets/image-20230506220712183.png) image-20230506230428913 记得转发的时候改一下taskId,视频测试用的0 感天动地,完结撒花:cherry_blossom: image-20230506232252501 image-20230506231952547