从零搭建开发脚手架 基于Mybatis-Plus的数据权限实现


权限管理是我们日常开发中很重要的一个点, 功能权限的实现大部分比较重而且业界也比较统一了,我这边就不详细介绍了,分享下一个基于Mybatis-plus实现的一套 数据权限方案。

权限分类

  • 功能权限:能看到什么功能模块,能操作什么功能,增删改查实体等。(常见
  • 数据权限:能看到哪些数据,例如管理员能看到所有人的数据,普通员工只能看到自己的数据,领导能看到本部门的数据。(常见
  • 字段权限:能看到哪些字段,例如普通员工看不到自己剩余的请假天数,领导和管理员可以。(不常见

功能权限:管理类系统常用的权限控制方式基于角色为基础设计(RBAC,Role-Based Access Control),主要由用户,角色,权限(资源)三个实体;用户角色关系,角色权限关系两个中间关联控制,有很多轮子,基本都是基于Spring Security 、Shiro或自定义AOP实现的,我这里仅介绍常用的数据权限的实现。

数据权限:我看了下目前的主流实现有2中方式:

  • 方案1.基于AOP+自定义注解,在service层进行拦截处理

  • 方案2.基于mybatis拦截器+自定义注解,在mapper层处理

    我这里的持久层框架选型为:mybatis-plus

针对这2种实现方式,其内部原理都是在原始sql上拼接 create_by= xxx And create_dept_id in xxx ,然后替换待执行的原始sql。

我个人更偏向于第二种方案,因为就像分页插件一样,数据权限过滤本来就是针对某一个sql进行过滤,这是在持久层应该做的事情。后面的实现逻辑也都是参照分页插件,利用mybatis拦截器的插件机制实现。

数据权限实现

MyBatis的拦截器简介

在Mybatis框架中,已经给我们提供了拦截器接口org.apache.ibatis.plugin.Interceptor,防止我们改动源码来添加行为实现拦截。说到拦截器,不得不提一下,拦截器是通过动态代理对Mybatis加入一些自己的行为。

Interceptor接口中包含3个方法如下:

public interface Interceptor {
  Object intercept(Invocation invocation) throws Throwable;
  default Object plugin(Object target) {
    return Plugin.wrap(target, this);
  }
  default void setProperties(Properties properties) {
    // NOP
  }
}
  • intercepter方法是拦截的核心方法,方法参数invocation可以对拦截对象进行修改。
  • plugin方法用于生成被拦截的对象的代理,方法参数target是原始对象。
  • setProperties方法用于获取配置intercepter时的参数。

MyBatis插件的实现必须继承Interceptor接口并实现其中的intercept拦截方法,除此之外,拦截器还需要定义拦截的对象以及方法,如下所示。

img

  • @Intercepts定义它是个拦截器
  • @Signature标注拦截的对象(type属性),方法名(method属性)及方法参数(args属性)。
    • type属性可以是MyBatis执行过程的四种对象。
    • method用于标注对象中的某个具体方法。
    • args是方法的参数类型。

MyBatis执行过程的四种对象

type类型作用
Executor调度执行StatementHandler、ParmmeterHandler、ResultHandler执行相应的SQL语句
StatementHandler执行SQL的过程,作为最常用的插件拦截对象
ParameterHandler设置预编译参数用的
ResultHandler处理结果集

对数据权限的拦截实际也是对执行的SQL进行修改,所以拦截的方法签名可等同于分页拦截器。此处需注意分页拦截器需要在数据权限拦截器之后执行。

具体实现

本来准备完全参照分页插件PaginationInnerInterceptor.java性能分析插件PerformanceInterceptor.java去实现的,但在阅读源码时发现了一个插件DataPermissionInterceptor.java,有趣的是在官网并没有发现对其的任何介绍,从注释来看,其实现的功能就是数据权限过滤。后面就直接使用它来实现了。

数据库设计

在需要进行权限过滤的表,新增2个字段create_by(该记录由谁创建),create_dept_id(该记录由哪个部门创建)。

暂不考虑人员换部门或者假定人员换部门但是数据归属还是之前的部门

权限类型设计

  • 查看全部数据 - where 1=1
  • 查看本人所在组织机构数据 - where create_dept_id = userDeptId
  • 查看本人数据 - where create_by = userId and create_dept_id = userDeptId
  • 查看自定义组织机构数据 - where create_dept_id in (deptIds)
  • 查看自定义sql过滤 - where 1=1 and 自定义过滤sql
@Getter
@AllArgsConstructor
public enum DataFilterTypeEnum {
    ALL(1, "全部"),
    DEPT(2, "本人所在组织机构"),
    SELF(3, "本人"),
    DEPT_SETS(4, "自定义组织机构"),
    DIY(5, "自定义sql过滤");
    int type;
    String desc;
}

拦截器实现

DataPermissionInterceptor.java中留了个扩展的口子DataPermissionHandler.java,我们只需要实现DataPermissionHandler接口,并按照业务规则处理SQL,就可以实现数据权限的功能。

其中用到了JSqlParser,JSqlParser是一个SQL语句解析器,它将SQL转换为Java类的可遍历层次结构。mybatis-plus中也引入了JSqlParser包。

@Slf4j
public class LakerDataPermissionHandler implements DataPermissionHandler {

    /**
     * @param where             原SQL Where 条件表达式
     * @param mappedStatementId Mapper接口方法ID
     * @return
     */
    @SneakyThrows
    @Override
    public Expression getSqlSegment(Expression where, String mappedStatementId) {

        // 1. 获取权限过滤相关信息
        DataFilterMetaData dataFilterMetaData = DataFilterThreadLocal.get();
        try {
            log.debug("开始进行权限过滤,dataFilterMetaData:{} , where: {},mappedStatementId: {}", dataFilterMetaData, where, mappedStatementId);
            if (dataFilterMetaData == null) {
                return where;
            }
            Expression expression = new HexValue(" 1 = 1 ");
            if (where == null) {
                where = expression;
            }
            switch (dataFilterMetaData.filterType) {
                // 查看全部
                case ALL:
                    return where;
                // 查看本人所在组织机构以及下属机构
                case DEPT_SETS:
                    // 创建IN 表达式
                    // 创建IN范围的元素集合
                    Set<Long> deptIds = dataFilterMetaData.getDeptIds();
                    // 把集合转变为JSQLParser需要的元素列表
                    ItemsList itemsList = new ExpressionList(deptIds.stream().map(LongValue::new).collect(Collectors.toList()));
                    InExpression inExpression = new InExpression(new Column("create_dept_id"), itemsList);
                    return new AndExpression(where, inExpression);
                // 查看当前部门的数据
                case DEPT:
                    //  = 表达式
                    // dept_id = deptId
                    EqualsTo equalsTo = new EqualsTo();
                    equalsTo.setLeftExpression(new Column("create_dept_id"));
                    equalsTo.setRightExpression(new LongValue(dataFilterMetaData.getDeptId()));
                    // 创建 AND 表达式 拼接Where 和 = 表达式
                    // WHERE xxx AND dept_id = 3
                    return new AndExpression(where, equalsTo);
                // 查看自己的数据
                case SELF:
                    // create_by = userId
                    EqualsTo selfEqualsTo = new EqualsTo();
                    selfEqualsTo.setLeftExpression(new Column("create_by"));
                    selfEqualsTo.setRightExpression(new LongValue(dataFilterMetaData.getUserId()));
                    return new AndExpression(where, selfEqualsTo);
                case DIY:
                    return new AndExpression(where, new StringValue(dataFilterMetaData.getSql()));
                default:
                    break;
            }
        } catch (Exception e) {
            log.error("LakerDataPermissionHandler.err", e);
        } finally {
            DataFilterThreadLocal.clear();
        }
        return where;
    }
}
参数传递

我们这里肯定要配合用户权限去做过滤的,但是方法参数中没办法传递参数,这里我们借助ThreadLocal去实现,注意使用完毕后要调用clear

@Slf4j
public class DataFilterThreadLocal {
    private static final ThreadLocal<DataFilterMetaData> ThreadLocalDataFilter = new ThreadLocal<>();
    public static void clear() {
        ThreadLocalDataFilter.remove();
    }
    public static void set(DataFilterMetaData metaData) {
        ThreadLocalDataFilter.set(metaData);
    }
    public static DataFilterMetaData get() {
        return ThreadLocalDataFilter.get();
    }
}

加载拦截器

@Configuration
@MapperScan("com.laker.map.*.mapper")
public class MybatisConfig {
    @Bean
	public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 添加数据权限插件
        DataPermissionInterceptor dataPermissionInterceptor = new DataPermissionInterceptor();
        LakerDataPermissionHandler lakerDataPermissionHandler = new LakerDataPermissionHandler();
        // 添加自定义的数据权限处理器
        dataPermissionInterceptor.setDataPermissionHandler(lakerDataPermissionHandler);
        interceptor.addInnerInterceptor(dataPermissionInterceptor);

        // 分页插件
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }

注意要在分页插件之前添加。

忽略拦截

mybatis-plus内置了@InterceptorIgnore注解。其内置插件的一些过滤规则

  • 支持注解在 Mapper 上以及 Mapper.Method 上 同时存在则 Mapper.method 比 Mapper 优先级高
  • 支持: true 和 false , 1 和 0 , on 和 off
  • 各属性返回 true 表示不走插件(在配置了插件的情况下,不填则默认表示 false)
@InterceptorIgnore(dataPermission = "1")
@Select("select  * from resource_service_area ")
Page<ServiceArea> selectAll(Page page);

使用示例

  @Test
    public void testDataFilter() {
        // 模拟设置当前用户的id是123,其拥有的数据权限为:本人
        DataFilterThreadLocal.set(DataFilterMetaData.builder().filterType(DataFilterTypeEnum.SELF).userId(123L).build());
        Page<ServiceArea> page = new Page<>();
        Page<ServiceArea> page1 = serviceAreaMapper.selectAll(page);
        System.out.println(JSONUtil.toJsonStr(page1));
    }

这里验证了与分页插件一起使用,这也是我们非常常用的功能了。执行过程如下:

sql1:

ID:com.laker.map.resource.mapper.ServiceAreaMapper.selectAll_mpCount
Execute SQLSELECT
        COUNT(*) 
    FROM
        resource_service_area 
    WHERE
        1 = 1 
        AND create_by = 123

自动拼接了 WHERE 1 = 1 AND create_by = 123

sql2:

ID:com.laker.map.resource.mapper.ServiceAreaMapper.selectAll
Execute SQLSELECT
        * 
    FROM
        resource_service_area 
    WHERE
        1 = 1 
        AND create_by = 123 LIMIT 10

自动拼接了 WHERE 1 = 1 AND create_by = 123

结果

{
    "hitCount": false,
    "optimizeCountSql": true,
    "records": [
        {
            "address": "怀远县",
            "areaName": "君王服务区",
            "id": 26
        },
        {
            "address": "肥西县",
            "areaName": "丰乐服务区",
            "id": 27
        }
        xxx
    ],
    "total": 529,
    "current": 1,
    "size": 10,
    "orders": [
    ],
    "isSearchCount": true
}

参考:

  • https://blog.csdn.net/qq_29653517/article/details/86299928

  • https://blog.csdn.net/qq_43437874/article/details/114691376

  • https://pagehelper.github.io/docs/interceptor/

  • 动态数据权限的设计


🍎QQ群【837324215】
🍎关注我的公众号【Java大厂面试官】,一起学习呗🍎🍎🍎

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lakernote

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值