内容
后端:SpringBoot + MyBatisPlus + SpringSecurity + Redis + Activiti + MySQL
前端:Vue-admin-template + Node.js + Npm + Vue + ElementUI + Axios
功能:管理端和员工端
- 管理端:权限管理、审批管理、公众号菜单管理
- 员工端:微信公众号操作,办公审批、微信授权登录、消息推送
数据缓存:Redis
数据库:MySQL
权限控制:SpringSecurity
工作流引擎:Activiti
前端:vue-admin-template、Node.js、Npm、Vue、ElementUI、Axios
微信公众号:公众号菜单、微信授权登录、消息推送
架构
- oa-parent
- common:通用内容
- common-util:核心工具类
- service-util:业务模块工具类
- model:模型层,包含所有实体类
- service-oa:业务层,包含所有业务模块
- common:通用内容
构建依赖:service-util 的 xml 文件的依赖中包含了 common-util
lombok:用于简化实体类,加一个注解就不用写 get、set、toString 等等
MyBatis-Plus
MyBatis 是对 JDBC 的简化,MyBatis-Plus 是对 MyBatis 的进一步简化
特性:
-
强大的 CRUD 操作:内置了通用 Mapper、通用 Service,能够通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求
-
支持 Lambda 表达式:能够方便编写各类查询条件,不用担心字段会写错
-
支持主键自动生成:4种主键策略,可以自由配置,完美解决主键问题
-
内置代码生成器:采用代码或 Maven 插件可以快速生成 Mapper、Model、Service、Controller 层代码,支持模板引擎
-
内置分页插件:基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件后,写分页等同于普通 List 查询
-
分页插件支持多种数据库:MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等
角色管理:
- 创建数据库和角色表
- 创建 SpringBoot 配置文件
- 创建角色实体类
- 创建 Mapper 接口,继承 MP 封装接口
- 创建 SpringBoot 启动类
- 进行增删改查测试
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);
}
}
同时,由于前面配置了日志,所以输出也包含了许多日志信息
物理删除和逻辑删除:物理删除是真正删除,表里就没有了。逻辑删除是实现了删除操作,但数据还存在,只是数据查询不出来了。也就是说,逻辑删除是一个修改操作,通过标志位实现了逻辑删除。例如设置一个 is_deleted,默认情况下 0 表示没有删除,1 表示已经删除。在 BaseEntity 中:
// BaseEntity.java
@TableLogic // 其实 MP 默认的就是逻辑删除,所以不写这俩注释也是可以的
@TableField("is_deleted")
private Integer isDeleted;
再查询,可以发现查不到了。因为查的是 is_deleted=0 的,也就是存在的数据
MP 条件查询封装:Wrapper
- UpdateWrapper 是 Update 的条件封装,用于更新
- QueryWrapper 是查询实体的操作
- LambdaQueryWrapper、LambdaUpdateWrapper 是上面两种方法的 Lambda 版
//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);
}
MP 封装 service 层
MP 封装了 Service 层和 DAO 层
// 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 中提供了许多的操作,可以多看看
角色管理
前后端分离开发
后端接口:
- 查询所有角色
- 条件分页查询角色
- 添加角色
- 修改角色
- 删除角色(根据 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 格式
其他功能:
// 添加角色
@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();
}
}
统一异常处理
目标:就算有异常,返回也是同样的 Result 格式
因为那里都会用到,所以放在 service-util 中
实现过程:
- 全局异常处理
- 特定异常处理
- 自定义异常处理
异常处理具体实现:
- 创建类,并添加注解 @ControllerAdvice
- 在类中添加执行的方法,方法上添加注解,指定哪个异常出现会执行 @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("执行了特定异常处理");
}
}
可以发现,有特定异常处理时会先调用特定异常处理(也就是更小的异常类)
自定义异常处理:
- 创建异常类,继承 RuntimeException
- 在异常类添加属性,状态码和描述信息
- 在出现异常的地方手动抛出异常
- 在异常处理类里面添加执行方法
// 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, "执行了自定义异常处理");
}
前端设计
后端开发接口,前端调用后端接口(Ajax),由接口返回 json 数据,前端显示 json 数据
PRD(产品原型-产品经理)、PSD(视觉设计-UI工程师)、HTML/CSS/JavaScript(PC/移动端网页,实现网页端视觉展示和交互-前端工程师)
创建工作区
VS Code 开发前端需要创建工作区,创建步骤:
- 创建空文件夹
- 使用 vscode 打开文件夹
- 另存为工作区
创建 HTML 文件测试一下:新建个文件,输入 !,点击第一个即可快速创建 HTML 模板。写完后,右键,选择 open with live server 即可在浏览器中打开。没有的话输入 127.0.0.1:5500/test/00-hello.html
ES入门
ES 是 ECMAScript 的简称,ES6 是最新的,于15年发布,是 JS 的一个标准
- 模板字符串:
<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>
- 对象拓展运算符
<body>
<script>
let person1 = {name: "Amy", age: 15}
// 对象复制
let someone = {...person1}
console.log(someone)
</script>
</body>
- 箭头函数(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>
然后先后输出 created ... 和 mounted ...
created 用于先生成数据
mounted 用于进行渲染
Axios
Axios 是独立于 Vue 的一个项目,在浏览器中可以帮助完成 ajax 请求的发送,在 node.js 中可以向远程接口发送请求
步骤:
- 在 html 页面引入 js 文件,包括 vue 和 axios 的 js 文件
- 在 script 中编写 vue 代码
- el: 展示位置
- data: 数据
- created() 页面渲染前执行
- methods: 定义具体方法
- 创建 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>
返回内容:Http 协议、响应行、响应头、响应体,其中我们关注的 data 是响应体,data 中还有一个 data 是我们得到的 json 数据
NodeJS
NodeJS 类似于 Java 中的 JDK。简单来说 NodeJS 就是运行在服务器端的 JavaScript,是一个事件驱动 IO 服务端的 JavaScript 环境,基于 Google 的 v8 引擎,v8 执行 JavaScript 速度非常快,性能很好
NodeJS 有什么用?作为前端程序员,如果不懂得 PHP、Python、Ruby 等动态编程语言,然后想创建自己的服务,那么 NodeJS 是很好的选择。NodeJS 也可以用于部署高性能服务
在这个项目里只用 NodeJS 作为前端运行环境
NPM
NPM 全称 Node Package Manager,是 Node.js 包管理工具,相当于前端的 Maven
使用 NPM 管理项目:
- 建一个空文件夹,初始化 npm init。想直接生成 package.json 文件,可以用 npm init -y。package.json 就类似 maven 的 pom.xml
- 下载包 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 并进行编译才行
步骤:
- nvm use dev 切换 node 和 npm
- npm install --global babel-cli 下载转码工具,并测试: babel --version
- 配置 .babelrc,将 es2015 规则加入 .babelrc:{"presets": ["es2015"], "plugins": []}
- 安装转码器 npm install --save-dev babel-preset-es2015
- 转码 mkdir dist1,babel es6-01 -d dist1
-
运行程序 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()
前端框架
vue-element-admin 和 vue-admin-template
vue-admin-template 是 vue-element-admin 的最小精简版本,可以作为模板进行二次开发。可以把 vue-element-admin 作为工具箱,想要什么功能或组件就去 vue-element-admin 那里复制过来就可以
使用步骤:
- 去 github 下载代码
- 下载依赖 npm install
- 启动项目 npm run dev
源文件目录结构:
- dist:生产环境打包生成的打包项目
- mock:模拟接口,比如登录就是模拟的
- public:包含会被自动打包到项目根路径的文件夹。其中 index.html 是唯一的界面
- src:代码集合
- api:包含接口请求的函数模块
- table.js 表格列表,模拟数据接口的请求函数
- user.js 用户登录相关接口的请求函数
- assets:组件中需要使用的公共资源
- components:非路由组件
- SvgIcon、Breadcrumb(面包屑组件,头部水平方向的层级组件)、Hamburger(切换左侧菜单导航的图标组件)
- icons:
- svg:包含 svg 图片文件
- index.js:全局注册 SvgIcon 组件,加载所有 svg 图片并暴露所有 svg 文件名的数组
- layout:
- components:组成整体布局的一些组件
- mixin:组件中可复用的代码
- index.vue:后台管理的整体界面布局组件
- router:路由信息
- index.js
- store
- styles
- utils:封装工具类
- request.js:使用了 axios
- views:页面集合,内容都是 index.vue,后缀是 vue
- dashboard:首页
- login:登录
- main.js:程序入口,引入各个内容
- permission.js:全局守卫,实现路由权限控制
- settings.js:包含应用设置信息的模块
- App.vue:应用根组件
- api:包含接口请求的函数模块
- 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 和端口号,修改方式有多种,这里列出两种,并采用第二种修改:
- 修改 .env.development,将 VUE_APP_BASE_API 修改成目标地址加端口号
- 修改 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 即可
最后即可成功启动
前端添加角色管理
步骤:
- 在 src/router/index.js 中添加路由
- 在 src/api 中创建 js 文件,定义接口信息
- 在 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>
界面效果:
增删改查功能引入
删除内容,首先修改 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 || '删除成功')
})
}
添加和修改:
首先添加弹框,然后添加根据 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
})
},
批量删除:首先写接口
// 批量删除
batchRemove(idList) {
return request({
url: `${api_name}/batchRemove`,
method: 'delete',
data: idList
})
}
然后写前端,前端需要和复选框结合,
handleSelectionChange(selections) {
this.selections = selections
console.log(this.selections)
}
最后,参考删除方法,写出批量删除方法。注意这里使用了 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 || '删除成功')
})
},
最终效果
用户管理
用户和角色是多对多的关系,对用户和角色的分析如下面这张图所示,除了用户和角色两张表,还需要创建第三张表,维护用户和角色之间的关系。这个第三个表至少有两个字段,作为外键指向两个表的主键
用户模块需要实现的功能:
- CRUD 操作
- 为用户分配角色
用户管理 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 即可。
实体类我们统一放 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();
}
}
用户管理前端
随后需要写前端,步骤:
- 改 router 中的内容
- 在 views/system 中加入 sysUser/list.vue
- 在 api/system 中加入 sysUser.js
用户分配角色
完成了用户管理部分,就需要完成给用户分配角色了,这时就要使用 user-role 表了
同时,前端需要能够满足的功能:
- 显示所有角色 - 查询角色表即可
- 显示当前用户所属角色 - 首先要根据用户 id 查询 user-role,获取用户的所有角色 id。随后根据角色 id 查询角色信息
- 将用户最终分配的角色添加到数据库 - 首先要把用户之前分配的角色删除,随后要保存最新分配的角色,这两个操作都是在 user-role 表中进行
那么先写后端代码。还是可以用代码生成器。这里不需要 Controller 了,只需要 Service 和 Mapper。一键生成非常简单。注意,如果用完代码生成器后执行项目出错,记得把代码生成器的两个依赖注释掉,分别是 mybatis-plus-generator 和 velocity-engine-core
随后,因为是首先获取角色,随后为用户分配角色,所以在角色的 Controller 中实现。在 SysRoleController 中编写接口:根据用户获取角色数据和根据用户分配角色
- 首先用 Autowired 获取 UserRoleService
- 然后由于 Impl 的参数已经包含了 baseMapper,所以直接获取就可以了
- 这一节对应的网课 p35 非常好,建议多看
- 这部分代码也很好!!!
@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);
}
修改用户前端
添加完前面的用户部分后,可以修改前端内容了。步骤:
- 添加 API
- 修改页面
// 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
})
},
菜单管理
不同用户拥有不同的菜单和功能权限,因此需要对菜单进行管理
菜单管理模块:
- 菜单列表和 CRUD 操作
- 为角色分配菜单
首先进行菜单列表设计。菜单结构:系统管理 - { 用户管理、角色管理 },因此,菜单表需要包含 id 和 parentId,并存储它们的层级关系。这里令 parentId = 0 表示顶层数据,则有 id=1, parentId=0, name=系统管理;id=10, parentId=1, name=用户管理;id=11, parentId=1, name=角色管理
菜单管理的目标:
同时,角色和菜单之间还存在关联,因此需要构建 角色 - 菜单表
菜单管理 CRUD
构建完表后,首先用代码生成器生成代码。随后删除不必要的 SysRoleMenuController.java 和 entity,并修改相应 service
// 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,因此不一定存在
为了实现构造这种 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;
}
}
最终结果:
此外,对删除方法需要加一个判断:是否存在子菜单。如果存在子菜单,那不能直接删除。如果不存在子菜单,那么可以进行删除
// 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、实现页面功能
完成后效果:
当某个菜单层级有子菜单时,显示无法点击删除按钮
角色分配菜单
完成了菜单自身的开发后,需要继续实现为角色分配菜单。这部分的逻辑和为用户分配角色是类似的,也是多对多。角色分配菜单需要包含以下两种功能:
- 查询功能:查询所有菜单,并按照树形结构进行封装。随后查询当前角色所属的菜单权限
- 分配功能:首先删除角色之前分配的菜单,然后为角色添加最新分配的菜单
类似为用户分配角色,这里也是在菜单的 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);
}
}
随后进行前端实现。
- 添加路由
- 角色列表添加按钮和方法
- 添加 API
- 实现页面功能
{
path: 'assignAuth',
component: () => import('@/views/system/sysRole/assignAuth'),
meta: {
activeMenu: '/system/sysRole',
title: '角色授权'
},
hidden: true,
},
可以看到,这段路由没有 icon,同时 hidden: true,说明这个路由不进行显式,但是能进行页面跳转
随后构建 assignAuth.vue,在里面写上跳转页的信息
插播一下:如何解决空格警告问题?有下面几种解决方式:
- 在 .eslintignore 中加入不校验的文件名
- 在 vue.config.js 中,修改为 lintOnSave: false,(后面的内容注释掉)
最终效果:
权限管理
整体介绍:
-
权限管理:大致可以分为三种:页面权限(菜单级)、操作权限(按钮级)和数据权限。当前系统只介绍菜单权限和按钮权限的控制
-
在权限管理过程中,会实现两个内容:
- 用户登录
- 获取用户信息
-
同时,这些过程中会使用 JWT 技术,通过 JWT 生成 Token,最后通过前端实现功能
-
随后,整合框架 Spring Security,完成权限管理的进一步实现,完成用户认证和用户授权
页面权限:页面权限(菜单权限)是粗粒度权限,不同用户进入系统后,可以看到不同菜单。比如张三是系统管理员,登录系统后可以看到所有菜单。赵六是销售人员,登录系统后可以看到销售相关菜单
操作权限:操作权限(按钮权限)是细粒度权限,比如用户管理模块,有具体功能(添加、修改、删除),对这些具体功能进行管理
首先对表结构进行设计:
-
用户表 <多对多> 角色表 <多对多> 菜单表,
-
用户角色关系表 角色菜单关系表
具体操作:
- 用户 Lucy
- 用户分配角色,为 Lucy 分配管理员角色
- 角色分配菜单,为管理员角色分配用户管理 CRUD、角色管理 Create、Update
具体操作的实现:使用用户登录,登录后获取用户可以操作的菜单和按钮权限,并进行相应的显示和提供相关操作支持。最终实现要包含两个接口:
- 用户登录
- 登录后,获取用户可以操作的菜单和按钮
用户登录接口:
- 根据用户名查询数据库,查看用户是否存在,信息是否正确
- 如果用户存在、信息正确,还要判断用户是否被禁用
- 登录后要保持登录状态
- 基于 token 实现:使用登录信息(用户 id、用户名称等)生成唯一的字符串,并对生成字符串进行编码加密处理,得到 token。使用 JWT(Json Web Token)生成 token,能够实现防伪
- 把唯一的字符串 token 放到 cookie 中,从 cookie 中可以获取到用户信息
- 但 cookie 存在缺陷:不能跨域传递,比如前段项目 9528 端口号,后端服务 8800 端口号,会产生跨域。解决方法:每次发送请求时,把 cookie 的值获取出来,放到请求头中,每次从请求头中获取用户信息
登录后获取用户可以操作的菜单和按钮:
- 从请求头获取 token 字符串,从字符串获取用户 ID
- 根据用户 id 查询:用户可以操作的菜单和按钮
JWT
JWT 是 Json Web Token 的缩写,是一种自包含令牌(里面包含用户信息)
JWT 包含三个部分:JWT 头、有效载荷、签名哈希
- JWT 头:介绍 JWT 元数据的 Json 对象
- 有效载荷:内容部分,也是 Json 对象
- 签名哈希:对上面两部分数据进行签名,通过算法生成哈希,确保数据不会被篡改。首先指定一个密码 secret,这个密码只保留在服务器中,不向用户公开。随后制定一个签名算法(默认 HMAC SHA256)根据以下公式生成签名:
最后将上述三个部分组成一个字符串,每个部分用 '.' 作为分隔,构成一个 JWT 对象
Base64URL 算法:这个算法和 Base64 算法类似,稍有差别。Base64 的字符 '+'、'/'、'=',Base64URL 去掉了 '=',用 '-' 替换 '+',用 '_'替换 '/',从而能够适用于 URL 中
项目集成 JWT:添加到 common-util 中
- 引入依赖:jjwt
- 添加 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)
重写用户登录功能
整合 JWT,生成 token:在 IndexController 中修改 login()。同时密码使用 MD5 加密,修改密码的 save 流程,确保使用了 MD5 进行加密。步骤:
- 获取输入的用户名和密码
- 根据用户名查询数据库
- 判断用户信息是否存在
- 判断密码是否正确,需要转成 MD5 再比较
- 判断用户是否被禁用,1 可用,0 禁用
- 使用 JWT 根据用户 ID 和用户名称生成 token 字符串
- 返回 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 即可
从请求头中获取用户数据步骤:
-
从请求头获取用户信息(获取请求头 token 字符串)
-
从 token 中获取用户 ID 和用户名称
-
根据用户 ID 查询数据库,获取用户信息
-
根据用户 ID,获取用户的菜单列表(要查询数据库,动态构建出路由结构,进行最终显示)
-
根据用户 ID,获取用户的操作列表
-
返回相应数据
具体实现:
// 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 不为空,需要写进系统
根据用户 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;
}
最后,完成多表查询功能:
- @Param("...") 是 Mybatis 的内容,如果有多个参数必须加
- 在 SysMenuMapper.xml 中写 sql,实现多表查询
// 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>
最终效果:
在查询用户权限时,报错,原因是 Maven 默认情况下只会加载编译 src-main-java 下的 java 类型文件,其他文件不会进行加载
解决方式:有下面几种
- 将 xml 文件放到 resource 中
- 通过配置的方式进行加载
- 在 pom.xml 中配置
- 在项目配置文件中配置
这里选择在项目配置文件中配置:
<!--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
前端整合
前端首先要获取 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 方法前判断当前用户是否有访问权限
可行的实现方法:
- If 判断
- Filter + AOP
- 现成的开源技术框架:Spring Security、Shiro 等
Spring Security 介绍
Spring Security 的两个核心功能是:
- 用户认证:验证某个用户是否是系统中的合法主体,能否访问该系统。用户认证一般要用户提供用户名和密码,系统评估用户能否登录
- 用户授权:验证某个用户是否有权限执行某个操作。系统会为不同用户分配不同的角色,每个角色对应一系列的权限,系统评估用户是否有权限做某些事
Spring Security 和 Shiro 的对比:
- Spring Security 的特点:能够和 Spring 无缝整合,能够全面的控制权限,专门为 Web 开发而设计,重量级(依赖很多其他组件进行实现)
- Shiro 是 Apache 旗下的轻量级权限控制框架,是轻量级,对性能有更高要求的 Web 应用有更好表现
- Spring Security 一开始发展的不是很好,不如 Shiro 使用的多,后来随着 Spring Boot 发展起来,用的更多了
要想对 Web 资源进行保护,最好的方法莫过于 Filter。要想对方法调用进行保护,最好的方法莫过于 AOP
因此,Spring Security 进行认证和判定权限时,就会利用一系列 Filter 进行拦截
请求过滤的过程:
- 认证:UsernamePasswordAuthenticationFilter 过滤器,用于对 /login 的 POST 请求做拦截,校验用户信息
- 异常处理:ExceptionTranslationFilter 异常过滤器,用来拦截认证过程中抛出的异常
- 授权:FilterSecurityInterceptor 方法级的权限过滤器,位于过滤链的最底部
- API
Spring Security 的核心全在这些过滤器中,过滤器中会调用各种组件完成功能,掌握了这些过滤器和组件,就掌握了 Spring Security!
Spring Security 入门案例
Spring Security 使用步骤:
- 创建 Spring Security 模块
- 添加依赖
- 添加配置类
- Service-oa 模块引入
- 启动项目测试
添加依赖后,Spring Security 就默认提供了许多功能,能够将应用保护起来:
- 要求身份验证后用户才能与应用程序交互
- 创建好了默认登录表单
- 生成用户名为 user 的随机密码并打印在控制台
用户认证流程:
- 输入用户名、密码
- 将信息封装到 Authentication,实现类是 UsernamePasswordAuthenticationToken
- 调用认证方法 authenticate()
- 调用委托认证 authenticate()
- 查数据库,获取用户信息 loadUserByUsername()
- 返回 UserDetails
- 通过 PasswordEncoder 对比 UserDetails 中的密码与 Authentication 中的密码是否一致
- 填充并返回(回填) Authentication
- 通过 SecurityContextHolder.getContest().setAuthentication(value) 方法将 Authentication 保存到上下文中
用户验证
为了实现用户验证功能,需要定义下面的组件:
- 自定义对象 UserDetails,实现 UserDetailsService
- 自定义根据用户名称得到用户信息方法 localUserByUsername()
- 自定义密码校验器 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;
}
具体核心组件:
- 登录 filter:TokenLoginFilter,判断用户名和密码是否正确,调用校验器进行密码判断,登录成功后则会生成 token
- 认证解析 token 组件:判断请求头是否有 token,如果有则解析 token
- 在配置类配置相关功能
下面实现了 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,有的话成功,没有则失败。也就是判断是否是登录过程:
- 如果是登录接口,直接放行
- 如果不是登录接口,从请求头中获取用户名称,如果得不到失败,如果得到则封装为对象,存到上下文中
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 最新版和以前不一样了,没配出来
用户授权
用户授权思路:
- 登录时,根据用户名查询用户操作权限,随后将用户操作权限放到 Redis 中
- 校验时,从请求头获取 token 字符串,从 token 获取 username,最后拿着 username 到 redis 查询权限数据
具体实现:
- 修改 loadUserByUsername 接口方法,根据用户名查询用户权限,封装返回:CustomUser(sysUser, Collections.emptyList()),后面这个 Collection 就是权限数据集合
- 修改 TokenLoginFilter 方法,增加权限数据部分,获取当前登录用户的权限数据,把权限数据放到 Redis 中。key: username, value: 权限数据
- 修改 TokenAuthenticationFilter 方法,增加权限数据部分,从请求头获取 token,获取 username,使用 username 到 Redis 中查询权限数据,执行相关操作。此外使用 ThreadLocal 存储当前登录人信息
- 修改配置类,添加 Redis 设置和注解
- 在 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 就是业务上的完整审批流程,例如员工请假、出差、采购、合同审批等。具体:填写请假单 -> 部门经理审批 -> 总经理审批 -> 人事备案
工作流引擎:可以在调整工作流的同时,程序可以不用改变
常见的工作流引擎:Activiti、JBPM、Camunda、Flowable 等
Activiti:是一个工作流引擎,可以将业务系统从复杂的业务流程中抽取出来,使用专门的建模语言 BPMN 进行定义
BPM:Business Process Management 业务流程管理
BPMN:业务流程建模符号,提供建模符号
主要符号:
- 事件 Event:开始、中间、结束
- 活动 Activities:活动可以是一个任务,也可以是当前流程的子处理流程
- 网关 GateWay:可以理解为下一个流程该去哪个地方,流程的分支和合并
- 排他网关:只有一条路径会被选择
- 并行网关:所有路径会被同时选择
- 包容网关:可以同时执行多条线路,也可以在网关上设置条件
- 事件网关:专门为中间捕获事件设置的,允许设置多个输出流指向不同的中间捕获事件。当流程执行到事件网关后,流程处于等待状态,需要等待抛出事件才能将等待状态转换为活动状态
- 流向 Flow:连接两个流程节点的连线,常见的流向包含:
- 顺序流:用实线表示,指定活动执行的顺序
- 信息流:用虚线表示,描述两个独立的业务参与者之间发送和接收的消息流动
- 关联:用没有箭头的虚线表示,将相关的数据、文本和其他人工信息与流对象联系起来,用于展示活动的输入和输出
流程样例:
Activiti 使用流程
【未完待续】
Comments | NOTHING