学习链接
Mybatis-Plus入门系列(3)- MybatisPlus之数据权限插件DataPermissionInterceptor
Mybatis-Plus入门系列(18) -基于注解的动态数据权限实现方案
SpringCloud微服务实战——搭建企业级开发框架(二十八):扩展MybatisPlus插件DataPermissionInterceptor实现数据权限控制
从零搭建开发脚手架 基于Mybatis-Plus的数据权限实现
【RuoYi-Vue-Plus】学习笔记 09 - 数据权限调用流程分析(参照 Mybatis Plus 数据权限插件)
Mybatisplus生成代码配置 & p6spy打印sql & mybatis日志打印 & mybatisplus用法
jsqlparser学习 - 自己收藏的链接
Mysql递归查询子级(父子级结构)&从子级ID查询所有父级(及扩展知识) - 自己的链接
Mysql带层级(父子级)的递归查询案例 - 自己的链接
mybatisplus数据权限插件学习初探
前言
对于系统中的不同用户,对于同一接口,可能都有权限访问此接口,但是由于用户各自的权限大小,看到的数据不一样(数据权限
)。
就比如:有一张用户表,每个用户只能属于某一个部门,每个用户都有自己的用户类型(用户类型有老板、部门经理、普通职工),每个员工的订单记录在订单表中,订单属于创建这个订单的用户,订单也属于这个用户的部门。
- 对于老板来说,能看到所有的订单
- 对于部门经理来说,能够看到自己部门(可能还有子部门,这里暂不考虑)的订单
- 对于普通职工来说,只能看到自己的订单
如果对于订单表的查询有多个地方,或者又不仅仅是订单表需要按上面的规则来作数据权限控制,那么在service层的每个地方几乎都要来上面的用户类型判断,然后再写不同的sql来做对应的查询。
所以如果能在dao层能够根据当前用户类型,自动的拼接对应的条件,那样是比较方便的,下面演示的案例仅作为自己入门学习案例记录,对于复杂的sql,需要深入学习下Jsqlparser,参考一些开源项目的做法。
值得思考的问题:
- 如何进行数据权限的划分?按部门划分是一种方案,但是部门之间有可能会存在上下级,有些人可能属于多个部门(同时存在上下级部门)。有没有其它的划分方式?
- 复杂sql的处理?
案例
建表
用户表
CREATE TABLE `user` (
`id` bigint(20) NOT NULL COMMENT '主键ID',
`type` int(1) DEFAULT NULL COMMENT '用户类型',
`tenant_id` bigint(20) NOT NULL COMMENT '租户ID',
`name` varchar(30) DEFAULT NULL COMMENT '姓名',
`dept_id` int(11) DEFAULT NULL COMMENT '所属部门id',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (1, 0, 1, 'mp', NULL);
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (2, 1, 1, 'Jack', 1);
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (3, 1, 1, 'Sandy', 2);
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (4, 2, 1, 'Billie', 1);
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (5, 2, 1, 'Sally', 1);
INSERT INTO `test`.`user` (`id`, `type`, `tenant_id`, `name`, `dept_id`) VALUES (6, 2, 1, 'Kevin', 2);
订单表
CREATE TABLE `orders` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`orders_no` varchar(10) DEFAULT NULL,
`user_id` int(11) DEFAULT NULL,
`dept_id` int(11) DEFAULT NULL,
`tenant_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
INSERT INTO `test`.`orders` (`id`, `orders_no`, `user_id`, `dept_id`, `tenant_id`) VALUES (1, '001', 4, 1, 1);
INSERT INTO `test`.`orders` (`id`, `orders_no`, `user_id`, `dept_id`, `tenant_id`) VALUES (2, '002', 5, 1, 1);
INSERT INTO `test`.`orders` (`id`, `orders_no`, `user_id`, `dept_id`, `tenant_id`) VALUES (3, '003', 6, 2, 1);
环境准备
User
@Data
@Accessors(chain = true)
public class User {
/**
* 租户 ID
*/
private Long tenantId;
@TableId(type = IdType.AUTO)
private Long id;
private Integer type;
private Integer deptId;
private String name;
}
UserMapper
public interface UserMapper extends BaseMapper<User> {
}
UserMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.baomidou.mybatisplus.samples.dataPerm.mapper.UserMapper">
</mapper>
Orders
@Data
@Accessors(chain = true)
public class Orders {
private String id;
private String ordersNo;
private Integer userId;
private String deptId;
}
OrdersMapper
public interface OrdersMapper extends BaseMapper<Orders> {
List<Orders> selectOrdersList();
}
OrdersMapper.xml
这里起初并没有where条件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.baomidou.mybatisplus.samples.dataPerm.mapper.OrdersMapper">
<select id="selectOrdersList" resultType="com.baomidou.mybatisplus.samples.dataPerm.entity.Orders">
SELECT * FROM orders
</select>
</mapper>
配置
UserTypeEnum
@Getter
public enum UserTypeEnum {
BOSS(0,"老板"),
DEPT_MANAGER(1,"部门经理"),
CLERK(1,"普通职员"),
DEFAULT(1,"普通职员"),
;
Integer type;
String desc;
UserTypeEnum(Integer type, String desc) {
this.type = type;
this.desc = desc;
}
public static UserTypeEnum type(Integer type) {
for (UserTypeEnum value : UserTypeEnum.values()) {
if (Objects.equals(type, value.type)) {
return value;
}
}
return DEFAULT;
}
}
UserContextHolder
public class UserContextHolder {
private static final ThreadLocal<User> USER_THREAD_LOCAL = new ThreadLocal<>();
public static void bindUser(User user) {
USER_THREAD_LOCAL.set(user);
}
public static void unBindUser() {
USER_THREAD_LOCAL.remove();
}
public static User getUser() {
return USER_THREAD_LOCAL.get();
}
}
CustomizeDataPermissionHandler
@Component
public class CustomizeDataPermissionHandler implements DataPermissionHandler {
@Override
public Expression getSqlSegment(Expression where, String mappedStatementId) {
User user = UserContextHolder.getUser();
if (user == null) {
return where;
}
UserTypeEnum type = UserTypeEnum.type(user.getType());
if (UserTypeEnum.BOSS == type) {
return where;
} else if (UserTypeEnum.DEPT_MANAGER == type) {
// 部门下可能存在子部门(需要查询当前部门的所有子部门(包括当前部门),可以使用sql递归), 这里暂时就认为只有1个
StringValue deptIdStringValue = new StringValue(String.valueOf(user.getDeptId()));
ExpressionList expressionList = new ExpressionList(deptIdStringValue);
InExpression inExpression = new InExpression(new Column("orders.dept_id"), expressionList);
if (where == null) {
// 如果原来没有where条件, 就添加一个where条件
return inExpression;
} else {
return new AndExpression(where, inExpression);
}
} else {
EqualsTo equalsTo = new EqualsTo(new Column("orders.user_id"), new StringValue(String.valueOf(user.getUserId())));
if (where == null) {
// 如果原来没有where条件, 就添加一个where条件
return equalsTo;
} else {
return new AndExpression(where, equalsTo);
}
}
}
}
MybatisPlusConfig
@Configuration
@MapperScan("com.baomidou.mybatisplus.samples.dataPerm.mapper")
public class MybatisPlusConfig {
@Autowired
private CustomizeDataPermissionHandler dataPermissionHandler;
/**
* 新多租户插件配置,一缓和二缓遵循mybatis的规则,需要设置 MybatisConfiguration#useDeprecatedExecutor = false 避免缓存万一出现问题
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new DataPermissionInterceptor(dataPermissionHandler));
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
@Override
public Expression getTenantId() {
User user = UserContextHolder.getUser();
if (user != null) {
return new LongValue(user.getTenantId());
} else {
return null;
}
}
@Override
public String getTenantIdColumn() {
return "tenant_id";
}
@Override
public boolean ignoreTable(String tableName) {
return Objects.equals("user", tableName);
}
}));
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
paginationInnerInterceptor.setDialect(DialectFactory.getDialect(DbType.MYSQL));
interceptor.addInnerInterceptor(paginationInnerInterceptor);
// interceptor.addInnerInterceptor();
// 如果用了分页插件注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
// 用了分页插件必须设置 MybatisConfiguration#useDeprecatedExecutor = false
// interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
// @Bean
// public ConfigurationCustomizer configurationCustomizer() {
// return configuration -> configuration.setUseDeprecatedExecutor(false);
// }
}
测试
测试类
@Slf4j
@SpringBootTest(classes = TenantApplication.class)
public class TenantTest {
@Resource
private UserMapper userMapper;
@Resource
private OrdersMapper ordersMapper;
@Test
public void test001() {
// User user = userMapper.selectById(1);
// User user = userMapper.selectById(2);
User user = userMapper.selectById(4);
UserContextHolder.bindUser(user);
List<Orders> orders = ordersMapper.selectOrdersList();
for (Orders order : orders) {
log.info("{}", order);
}
Page<Orders> ordersPage = new Page<>(1, 1);
ordersMapper.selectPage(ordersPage, null);
log.info("total:{},pages:{},data:{}",ordersPage.getTotal(),ordersPage.getPages(), ordersPage.getRecords());
UserContextHolder.unBindUser();
}
}
boss
老板可以看到所有的数据。可以看到下面,仅拼接了租户id
SELECT id, tenant_id, type, dept_id, name FROM user WHERE id = 1
SELECT * FROM orders WHERE orders.tenant_id = 1
SELECT COUNT(*) AS total FROM orders WHERE orders.tenant_id = 1
SELECT id, orders_no, user_id, dept_id FROM orders WHERE orders.tenant_id = 1 LIMIT 1
deptManager
部门经理可以看到本部门及子部门的数据。可以看到下面拼接了in的条件
SELECT id, tenant_id, type, dept_id, name FROM user WHERE id = 2
SELECT * FROM orders WHERE orders.dept_id IN ('1') AND orders.tenant_id = 1
SELECT COUNT(*) AS total FROM orders WHERE orders.dept_id IN ('1') AND orders.tenant_id = 1
SELECT id, orders_no, user_id, dept_id FROM orders WHERE orders.dept_id IN ('1') AND orders.tenant_id = 1 LIMIT 1
clerk
普通职员只能看到自己的数据。可以看到拼接的条件使用user_id
SELECT id, tenant_id, type, dept_id, name FROM user WHERE id = 4
SELECT * FROM orders WHERE orders.user_id = '4' AND orders.tenant_id = 1
SELECT COUNT(*) AS total FROM orders WHERE orders.user_id = '4' AND orders.tenant_id = 1
SELECT id, orders_no, user_id, dept_id FROM orders WHERE orders.user_id = '4' AND orders.tenant_id = 1 LIMIT 1
动态表名更换插件
这个插件比较简单,就是在sql中遇到表名,会把这个表名传递给TableNameHandler处理器,然后在TableNameHandler处理器的dynamicTableName方法中获取到表名,然后返回一个新的表名替换掉sql中的原表名。
案例
代码
MyBatisplusConfig
@Slf4j
@Configuration
public class MyBatisplusConfig {
@Bean
public MybatisPlusInterceptor interceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
dynamicTableNameInnerInterceptor.setTableNameHandler(new TableNameHandler() {
@Override
public String dynamicTableName(String sql, String tableName) {
log.info("sql: {}", sql);
log.info("tableName: {}", tableName);
return tableName + "_1";
}
});
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
return interceptor;
}
}
AccountController
@RestController
@RequestMapping("/account")
public class AccountController {
@RequestMapping("getAccounts")
public List<Account> getAccounts() {
// 调用mybatisplus的方法
return accountService.list();
}
@RequestMapping("getAccounts2")
public List<Account> getAccounts2() {
// 调用自己写的mapper方法
return accountMapper.find();
}
}
AccountMapper
public interface AccountMapper extends BaseMapper<Account> {
List<Account> find();
}
AccountMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zzhua.mapper.AccountMapper">
<select id="find" resultMap="BaseResultMap">
SELECT * FROM account a INNER JOIN user u on a.user_id = u.id
</select>
</mapper>
测试
访问:http://localhost:8080/account/getAccounts
,可以看到表名替换了
访问:http://localhost:8080/account/getAccounts2
,可以看到2个表名都会经过TableNameHandler
防止全表更新与删除插件
这个插件可以防止全表更新与删除插件,就是如果不带where条件DML的sql会抛出异常,但是有的时候,就是需要全表删除呢?可以在mapper方法上使用@InterceptorIgnore(blockAttack = “true”)注解标注此方法即可。
案例
代码
MyBatisplusConfig
直接加就完了
@Slf4j
@Configuration
public class MyBatisplusConfig {
@Bean
public MybatisPlusInterceptor interceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
return interceptor;
}
}
AccountController
@RestController
@RequestMapping("/account")
public class AccountController {
@GetMapping("deleteAll")
public Object deleteAll() {
accountMapper.deleteAll();
return "删除成功";
}
@GetMapping("updateAll")
public Object updateAll() {
LambdaUpdateWrapper<Account> updateWrapper = new LambdaUpdateWrapper<Account>().set(Account::getNickName, "005");
accountMapper.update(null, updateWrapper);
return "修改成功";
}
}
AccountMapper
public interface AccountMapper extends BaseMapper<Account> {
@InterceptorIgnore(blockAttack = "true") // true表示不启用插件
void deleteAll();
}
AccountMapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zzhua.mapper.AccountMapper">
<delete id="deleteAll">
delete from account
</delete>
</mapper>
乐观锁插件
给需要加乐观锁的表,添加一个version字段,然后给这个version字段来个默认值1(如果这个version字段为null的话,不会拼接乐观锁的sql),然后给实体类的version属性添加@Version注解,即可
其实就是当版本一致时,才会更新到数据库中,根据数据库的返回结果(受影响的行数),就能知道有没有更新成功
案例
代码
MyBatisplusConfig
@Slf4j
@Configuration
public class MyBatisplusConfig {
@Bean
public MybatisPlusInterceptor interceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
}
Account
@Getter
@Setter
@ApiModel(value = "Account对象", description = "")
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String nickName;
@Version
private Long version;
}
AccountController
@RestController
@RequestMapping("/account")
public class AccountController {
@GetMapping("updateAccount")
public Object updateAccount() {
Account account = accountMapper.selectById(2);
account.setNickName("m2");
int update = accountMapper.update(account, null);
System.out.println("更新结果: " + update);
return "ok";
}
}
测试
访问:http://localhost:8080/account/updateAccount
逻辑删除
在表中添加一个字段,用来表示该条数据的删除状态。当添加时,需要自己手动设置未删除状态;当删除时,并不会执行delete语句,而是执行update语句,更改删除状态字段为已删除;当更新时,也会带上未删除状态作为条件;当查询时,也会带上未删除状态作为条件。
(当然,这只会在mybatisplus自动注入的方法中才会生效,自己手动写的不会这样搞)
注意事项
-
只对自动注入的 sql 起效:
- 插入: 不作限制
- 查找: 追加 where 条件过滤掉已删除数据,如果使用 wrapper.entity 生成的 where 条件也会自动追加该字段
- 更新: 追加 where 条件防止更新到已删除数据,如果使用 wrapper.entity 生成的 where 条件也会自动追加该字段
- 删除: 转变为 更新
-
例如:
-
删除: update user set deleted=1 where id = 1 and deleted=0
-
查找: select id,name,deleted from user where deleted=0
-
-
-
字段类型支持说明:
- 支持所有数据类型(推荐使用 Integer,Boolean,LocalDateTime)
- 如果数据库字段使用datetime,逻辑未删除值和已删除值支持配置为字符串null,另一个值支持配置为函数来获取值如now()
-
附录:
- 逻辑删除是为了方便数据恢复和保护数据本身价值等等的一种方案,但实际就是删除。
- 如果你需要频繁查出来看就不应使用逻辑删除,而是以一个状态去表示。
案例
application.yml
mybatis-plus:
global-config:
db-config:
logic-delete-field: isDeleted # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
Account
@Getter
@Setter
@ApiModel(value = "Account对象", description = "")
public class Account implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Integer id;
private String nickName;
@TableLogic
private Integer isDeleted;
}