Mybatis Plus基于注解的数据权限

01 Nov 2022 » java

概述

在日常项目开发中,很多时候会用到一些数据权限,比如部门、营业点、地区等。这里以部门为例,假设存在以下表:

1. 部门表(department)

字段含义
id部门id
name部门名称
parent_id部门父id
full_id_path部门id全路径(用于查询下属部门)

2. 用户表(user)

字段含义
id用户id
code用户帐号
name用户姓名

3. 用户部门关系表(user_department)

字段含义
user_id用户id
department_id部门id

4. 业务订单表(order)

字段含义
id订单id
amount订单金额
department_id所属部门

忽略以上表字段的严谨性,假如用户需求为:根据登录用户所在部门(可能为多个)查询所在部门(或所有在部门及下级部门)的订单列表, 倘若只有一个这样的需求,你可能只需要用order关联user_department就可以了, 若要查询下级部门再关联department的full_id_path前缀匹配,若项目上的业务数据大多都与department相关,就需要写很多个这样的关联。

准备工作

笔者开发时基于mybatis-plus的3.4.3.2

mp本身提供了一个DataPermissionInterceptor数据权限拦截器

public class DataPermissionInterceptor extends JsqlParserSupport implements InnerInterceptor {
    // 数据权限过滤Where条件语句生成    
    private DataPermissionHandler dataPermissionHandler;

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
        // 只要没有@InterceptorIgnore注解中dataPermission为true都会执行数据权限
        if (InterceptorIgnoreHelper.willIgnoreDataPermission(ms.getId())) return;
        PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
        mpBs.sql(parserSingle(mpBs.sql(), ms.getId()));
    }

    @Override
    protected void processSelect(Select select, int index, String sql, Object obj) {
        SelectBody selectBody = select.getSelectBody();
        if (selectBody instanceof PlainSelect) {
            this.setWhere((PlainSelect) selectBody, (String) obj);
        } else if (selectBody instanceof SetOperationList) {
            SetOperationList setOperationList = (SetOperationList) selectBody;
            List<SelectBody> selectBodyList = setOperationList.getSelects();
            selectBodyList.forEach(s -> this.setWhere((PlainSelect) s, (String) obj));
        }
    }

    /**
     * 设置 where 条件
     *
     * @param plainSelect  查询对象
     * @param whereSegment 查询条件片段
     */
    protected void setWhere(PlainSelect plainSelect, String whereSegment) {
        // 数据权限的核心就在过滤条件 这里是根据msId的sql追加过滤条件
        Expression sqlSegment = dataPermissionHandler.getSqlSegment(plainSelect.getWhere(), whereSegment);
        if (null != sqlSegment) {
            plainSelect.setWhere(sqlSegment);
        }
    }
}

由源码可知,mp集成jsqlparser(继承JsqlParserSupport实现)运行时根据条件动态追加过滤条件

开发过程

笔者希望通过在mapper的方法上添加一个数据权限注解,来实现无感知的数据权限开发

1. 注解定义

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DepartmentDataPermission {
    /**
     * 查询主数据字段 若存在别名 自行拼接
     * @return 字段名称
     */
    String value();
    /**
     * 是否包含子部门数据 若用户所属上级部门 true则表示所有下级部门数据都能看 否则只能看所属部门数据
     * @return true/false
     */
    boolean includeChildren() default false;
}

2. 拦截器定义

public class DataPermissionInterceptor extends JsqlParserSupport implements InnerInterceptor {
    /**
    * 不存在数据权限注解标识
    */
    private static final Object NOT_EXISTS = Void.class;
    /**
    * 数据权限注解缓存
    */
    private static final Map<String, Object> CACHE = new HashMap<>(16);
    /**
    * 分页查询统计后缀
    */
    private static final String PAGE_COUNT_SUFFIX = "_COUNT";
    /**
     * 固定的SELECT 1元素
     */
    private static final List<SelectItem> SELECT_1_ITEM =
    Collections.singletonList(new SelectExpressionItem(new LongValue(1)));

    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
                            ResultHandler resultHandler, BoundSql boundSql) {
        String msId = ms.getId();
        if (InterceptorIgnoreHelper.willIgnoreDataPermission(msId)) {
            return;
        }

        // page helper分页查询统计sql
        if (msId.endsWith(PAGE_COUNT_SUFFIX)) {
            msId = msId.substring(0, msId.length() - PAGE_COUNT_SUFFIX.length());
        }

        // mybatis-plus-join查询 直接返回 若要使用这种查询 default方法重写
        if (msId.contains(StringConstants.UNDER_SCORE)) {
            return;
        }

        // 不存在注解 直接返回
        if (CACHE.computeIfAbsent(msId, DataPermissionInterceptor::touchAnnotation) == NOT_EXISTS) {
            return;
        }

        PluginUtils.MPBoundSql mpBs = PluginUtils.mpBoundSql(boundSql);
        mpBs.sql(parserSingle(mpBs.sql(), msId));
    }

    @Override
    protected void processSelect(Select select, int index, String sql, Object obj) {
        SelectBody selectBody = select.getSelectBody();
        if (!(selectBody instanceof PlainSelect)) {
            log.warn("数据权限查询类型为不支持的类型{}", selectBody.getClass().getName());
            throw new MaginaException("数据权限仅支持简单查询语句");
        }

        // 缓存的权限注解
        DepartmentDataPermission annotation = (DepartmentDataPermission) CACHE.get(String.valueOf(obj));

        PlainSelect plainSelect = (PlainSelect) selectBody;
        // where后面的条件
        List<Expression> conditions = new ArrayList<>();
        Optional.ofNullable(plainSelect.getWhere()).ifPresent(conditions::add);
        // 根据会话获取用户id 根据项目实际修改
        Long userId = 1L;
        // 关联列名
        String field = annotation.value();
        if(annotation.includeChildren()) {
            // 所在部门及子级部门
            conditions.add(resolveDepartmentIncludeChildren(userId, field));
        } else {
            // 只查询所在部门
            conditions.add(resolveDepartmentExcludeChildren(userId, field));
        }
        
        plainSelect.setWhere(new MultiAndExpression(conditions));
    }

    static Expression resolveDepartmentIncludeChildren(Long userId, String field) {
       // 业务数据关联部门表
       Table sourceDept = new Table().withName("department").withAlias(new Alias("d1", false));
       // 用户部门用户关系表
       Table targetDeptUsr = new Table().withName("user_department").withAlias(new Alias("ud", false));
       // 用户部门表
       Table targetDept = new Table().withName("department").withAlias(new Alias("d2", false));

       return
           // 使用连续的连个exist子句 这里未考虑效率问题
           // 生成的sql形如 
           // exists(select 1 from department d1 where d1.id = field 
           // and exists(select 1 from user_department ud inner join department d2 
           // on ud.department_id = d2.id and ud.user_id = 1 
           // where d1.full_id_path like concat(d2.full_id_path, '%')))
           new ExistsExpression().withRightExpression(
               new SubSelect().withSelectBody(
                   new PlainSelect().withSelectItems(SELECT_1_ITEM)
                       .withFromItem(sourceDept)
                       .withWhere(
                           new MultiAndExpression(
                               Arrays.asList(
                                   new EqualsTo(
                                       new Column(sourceDept, "id"),
                                       new Column(field)
                                   ),
                                   new ExistsExpression().withRightExpression(
                                       new SubSelect().withSelectBody(
                                           new PlainSelect().withSelectItems(SELECT_1_ITEM)
                                               // 查询登录用户的部门信息
                                               .withFromItem(targetDeptUsr)
                                               .addJoins(
                                                   new Join().withInner(true).withRightItem(targetDept)
                                                       .addOnExpression(
                                                           new MultiAndExpression(
                                                               Arrays.asList(
                                                                   new EqualsTo(
                                                                       new Column(targetDeptUsr, "department_id"),
                                                                       new Column(targetDept, "id")
                                                                   ),
                                                                   new EqualsTo(
                                                                       new Column(targetDeptUsr, "id"),
                                                                       new LongValue(userId)
                                                                   )
                                                               )
                                                           )
                                                       )
                                               )
                                               .withWhere(
                                                   // 根据使用数据库情况拼接like表达式
                                                   new LikeExpression()
                                                       .withLeftExpression(
                                                           new Column(sourceDept, "full_id_path")
                                                       )
                                                       .withRightExpression(
                                                           new Function().withName("CONCAT")
                                                                .withParameters(
                                                                    new ExpressionList(
                                                                        new Column(targetDept, "full_id_path"),
                                                                        new StringValue("%")
                                                                   )
                                                                )
                                                           )
                                                       )
                                               )
                                       )
                                   )
                               )
                           )
                       )
               );
    }
    
    // 只查询所在部门的条件生成
    static Expression resolveDepartmentExcludeChildren(Long userId, String field) {
        // 外层exist部门用户关系表
        Table outerDeptUsr = new Table().withName("user_department").withAlias(new Alias("ud", false));

        return
            // 登录用户部门关系中存在业务数据部门id即可
            // 生成sql形如 exists(select 1 from user_department ud where ud.user_id = 1 and ud.department_id = field)
            new ExistsExpression().withRightExpression(
                new SubSelect().withSelectBody(
                    new PlainSelect().withSelectItems(SELECT_1_ITEM)
                        // 查询登录用户的部门信息
                        .withFromItem(outerDeptUsr)
                        .withWhere(
                            new MultiAndExpression(
                                Arrays.asList(
                                    new EqualsTo(
                                        new Column(outerDeptUsr, "user_id"),
                                        new LongValue(userId)
                                    ),
                                    new EqualsTo(
                                        new Column(outerDeptUsr, "department_id"),
                                        new Column(field)
                                    )
                                )
                            )
                        )
                )
            );
    }

    /**
     * 获取方法或mapper接口上的注解
     * @param mappedStatementId 方法id
     * @return 注解
     */
    static Object touchAnnotation(Object mappedStatementId) {
        // 根据msId反射获取mapper的方法
        Method method = MappedStatementUtils.method(String.valueOf(mappedStatementId));
        
        return 
            Optional.ofNullable(AnnotationUtils.getMethodOrClassAnnotation(method, DepartmentDataPermission.class))
                .orElse(NOT_EXISTS);
    }
}   

3. 配置拦截器

@Configuration
public class MyBatisPlusConfigurer {
    @Bean
    public InnerInterceptor dataPermissionInnerInterceptor(){
        // 数据权限拦截器
        return new DataPermissionInterceptor();
    }

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(ObjectProvider<List<InnerInterceptor>> provider) {
        // 主拦截器配置
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        provider.getIfAvailable(ArrayList::new).forEach(interceptor::addInnerInterceptor);
        return interceptor;
    }
}

4. 代码使用

public interface OrderMapper extends BaseMapper<Order> {
    // 可以重写BaseMapper的方法 加DataPermission注解同样生效
    // 不包括子级部门
    @DepartmentDataPermission("department_id")
    List<Order> listStrict();
    // 包括子级部门
    @DepartmentDataPermission(value = "department_id", includeChildren = true)
    List<Order> listAll();
}

结语

多租户、动态的数据权限都可以通过这种方式实现,只要能拼sql,剩下的工作我相信你都会了,只是需要多熟悉下jsqlparser的API用法。

PS:在结合PageHelper做带有数据权限的分页时遇到过一个问题, jsqlparser版本过低导致分页的LIMITOFFSET两个关键字位置不一样导致分页不生效, 处理方法更新PageHelper使得和MP的jsqlparser的版本一致。