springboot注解 + mybatisplus拦截器实现数据权限拦截(兼容分页插件)

需求

要求在同一个数据请求方法中,根据不同的权限返回不同的数据集,而且无需并且不能由研发编码控制。

设计思路

竟然要实现查询语句与权限解耦,第一想法联想到的就是AOP,拦截所有的底层sql,加入过滤条件。
1、在数据库中新建一张权限表,记录权限点以及其对应的sql
springboot注解 + mybatisplus拦截器实现数据权限拦截(兼容分页插件)_第1张图片

2、编写一个自定义注解,在需要被拦截的mapper方法上标记并指定其权限过滤方式。
springboot注解 + mybatisplus拦截器实现数据权限拦截(兼容分页插件)_第2张图片
3、利用mybatis拦截器(插件)拦截mapper方法的sql,并根据对应的权限点对原有的sql进行修改。
在这里插入图片描述

代码部分

数据库设计

权限表sql

@TableName(value = "data_auth")
public class DataAuthEntity {

    /**
     * id
     */
    @TableId(value = "id",type = IdType.ASSIGN_UUID)
    String id;

    /**
     * 权限点名称
     */
    @TableField(value = "auth_name")
    String authName;

    /**
     * sql
     */
    @TableField(value = "auth_sql")
    String authSql;
}
CREATE TABLE "public"."data_auth" (
  "id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
  "auth_name" varchar(255) COLLATE "pg_catalog"."default",
  "auth_sql" text COLLATE "pg_catalog"."default"
)

模拟数据表

@TableName(value = "resource")
public class ResourceEntity {

    /**
     * id
     */
    @TableId(value = "id")
    String id;

    /**
     * 用户id
     */
    @TableField(value = "userid")
    String userid;

    /**
     * 部门id
     */
    @TableField(value = "deptid")
    String deptid;
}
CREATE TABLE "public"."resource" (
  "id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
  "name" varchar(255) COLLATE "pg_catalog"."default",
  "userid" varchar(255) COLLATE "pg_catalog"."default",
  "deptid" varchar(255) COLLATE "pg_catalog"."default"
)

代码设计

声明权限注解

@Documented
@Target(METHOD)
@Retention(RUNTIME)
public @interface DataAuth{

    /**
     * 资源点名称
     */
    String name();

}

编写数据权限拦截器

//mybatis 拦截顺序Executor -> StatementHandler->ParameterHandler->ResultSetHandler
//要在分页插件之前完成sql语句的修改 应拦截Executor
@Intercepts(
        {
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
                @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
        }
)
public class DataAuthInterceptor implements Interceptor {

    @Resource
    DataAuthMapper dataAuthMapper;

    /**
     * 模拟从request中取出userid
     */
    private static final String MOCK_USERID = "123";

    /**
     * 模拟从request中取出部门id
     */
    private static final String MOCK_DEPT_ID = "123";

    /**
     * 表名占位符
     */
    private static final String TABLE_NAME = "#{TABLE_NAME}";

    /**
     * 用户名占位符
     */
    private static final String USER_ID = "#{USER_ID}";

    /**
     * 部门id占位符
     */
    private static final String DEPT_ID = "#{DEPT_ID}";

    @Override
    public Object intercept(Invocation invocation) throws Throwable {

        //获取拦截下的mapper
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        DataAuth dataAuth = getDataAuth(mappedStatement);

        //没有注解直接放行,可根据情况补充更多的业务逻辑
        if (dataAuth == null){
            return invocation.proceed();
        }

        //获取上一个拦截器传过来的sql
        BoundSql boundSql = mappedStatement.getBoundSql(invocation.getArgs()[1]);
        String orgSql = boundSql.getSql(); //获取到当前需要被执行的SQL
        //根据指定权限点对原有sql进行修改
        String sql = modifyOrgSql(orgSql, dataAuth);

        //利用反射修改原本的sql
        Field sqlSourceField = mappedStatement.getClass().getDeclaredField("sqlSource");
        sqlSourceField.setAccessible(true);
        Object sqlSource = sqlSourceField.get(mappedStatement);
        Field boundSqlField = sqlSource.getClass().getDeclaredField("sqlSource");
        boundSqlField.setAccessible(true);
        Object boundSqlObj = boundSqlField.get(sqlSource);
        Field sqlField = boundSqlObj.getClass().getDeclaredField("sql");
        sqlField.setAccessible(true);
        sqlField.set(boundSqlObj,sql);

        return invocation.proceed();
    }

    /**
     * 通过反射获取mapper方法是否加了数据拦截注解
     */
    private DataAuth getDataAuth(MappedStatement mappedStatement) throws ClassNotFoundException {
        DataAuth dataAuth = null;
        String id = mappedStatement.getId();
        String className = id.substring(0, id.lastIndexOf("."));
        String methodName = id.substring(id.lastIndexOf(".") + 1);
        final Class<?> cls = Class.forName(className);
        final Method[] methods = cls.getMethods();
        for (Method method : methods) {
            if (method.getName().equals(methodName) && method.isAnnotationPresent(DataAuth.class)) {
                dataAuth = method.getAnnotation(DataAuth.class);
                break;
            }
        }
        return dataAuth;
    }

    /**
     * 根据权限点拼装对应sql
     * @return 拼装后的sql
     */
    private String modifyOrgSql(String orgSQql,DataAuth dataAuth) throws JSQLParserException {
        String authSql = dataAuthMapper.getAuthSql(dataAuth.name());
        if (authSql == null||authSql.isEmpty()){
            return orgSQql;
        }
        //使用CCJSqlParserUtil对sql进行解析
        //参考mybatis-plus源码{@link com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor#autoCountSql}
        CCJSqlParserManager parserManager = new CCJSqlParserManager();
        Select select = (Select) parserManager.parse(new StringReader(orgSQql));
        PlainSelect plain = (PlainSelect) select.getSelectBody();
        Table fromItem = (Table) plain.getFromItem();
        //有别名用别名,无别名用表名,防止字段冲突报错
        String aliasTableName = fromItem.getAlias() == null ? fromItem.getName() : fromItem.getAlias().getName();
        //对authSql中的占位符进行替换
        authSql = authSql.replace(TABLE_NAME, aliasTableName).replace(USER_ID, MOCK_USERID).replace(DEPT_ID, MOCK_DEPT_ID);
        if (plain.getWhere() == null) {
            plain.setWhere(CCJSqlParserUtil.parseCondExpression(authSql));
        } else {
            plain.setWhere(new AndExpression(plain.getWhere(), CCJSqlParserUtil.parseCondExpression(authSql)));
        }
        return select.toString();
    }
}

注入拦截器的bean

@Configuration
public class MybatisPlusConfig {

    //注意Mybatis执行拦截器的顺序是bean注入的倒序,所以我们为了让我们自定义的拦截器在分页拦截器之前执行必须先注入分页拦截器
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
        return interceptor;
    }

    @Bean
    public DataAuthInterceptor dataAuthInterceptor(){
        return new DataAuthInterceptor();
    }

}

测试结果

可以看到数据拦截的sql已经被注入进去了
springboot注解 + mybatisplus拦截器实现数据权限拦截(兼容分页插件)_第3张图片

遇到的问题

Q:mybatisplus分页插件在自定义拦截插件之前执行,导致sql语句拼接有误
A:注入的插件会在MybatisSqlSessionFactoryBean这个类的plugins属性中,我们可以看到plugins的顺序是跟Bean的顺序一样的
springboot注解 + mybatisplus拦截器实现数据权限拦截(兼容分页插件)_第4张图片
然而在运行时,则是排在前面的插件被后执行。故需要在配置bean时,将分页插件配置在自定义拦截插件前面。
如果有多个@Configuation,可以用@AutoConfigureAfter或者@AutoConfigureBefore来控制@Configuation的执行顺序从而控制Bean的注入顺序
Q:如何对sql语句进行解析
A:可以使用CCJSqlParserUtil工具包,在引入mybatis-plus相关包时该工具类也一样被引入,为啥?因为mybatis-plus分页插件就是用他来做sql语句解析的!再次感叹阅读大佬的源码给我涨知识了。
具体参考mybatis-plus源码{@link com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor#autoCountSql}

你可能感兴趣的:(javaWeb,spring,boot,java,数据库)