需求场景:

业务要求对列表查询功能添加数据权限控制。

比如:给用户A针对某些订单模块设置一些查询条件,比如地址 = 山东,运输方式 = 空运,商品大类 = 家电。当用户访问订单模块时,只能访问满足这些条件的数据。

需求分析:

要实现动态条件拼接, 需要在执行查询前获取要执行的SQL,然后解析SQL,将现有的条件组装成SQL语句拼接到原有SQL中再执行新的SQL。

解决方案:

1.control层方法上添加自定义注解。目的是用来获取当前用户配置的权限数据。

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

/**
 * 
 *     
 * 项目名称:ff-flatcar    
 * 类名称:Qcym    
 * 类描述: 定制一个接口,用来获取当前用户对应的数据权限数据
 * 创建人:wuwenjin    
 * 创建时间:2022年5月16日 上午9:51:27    
 * 修改人:wuwenjin    
 * 修改时间:2022年5月16日 上午9:51:27    
 * 修改备注:    
 * @version     
 *
 */
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Qcym {
    String value() default "数据权限注解";
}



import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import javax.annotation.Resource;

import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.After;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;


/**
 * 类名称:QcymAspect    
 * 类描述: 接口Qcym的实现类
 * 创建人:wuwenjin    
 * 创建时间:2022年5月16日 上午9:55:05    
 * 修改人:wuwenjin    
 * 修改时间:2022年5月16日 上午9:55:05    
 * 修改备注:    
 * @version 1.0
 *
 */
@Aspect
@Component
public class QcymAspect {

    //获取用户数据权限service
    @Resource
    private DataAuthorityTestDataService dataAuthorityTestDataService;

    /**
     * cutMethod(设置切点)  
     * @return void
     * @author wuwenjin
     * @date 2022年5月16日 上午9:57:34  
     * @Exception 异常对象  
     * @version 1.0
     */
    @Pointcut("@annotation(com.dataauth.freight.utils.aspect.Qcym)")
    public void cutMethod() {
    }

    // @Before("cutMethod()&& args(qcym)")
    @Before("cutMethod()")
    public void before(JoinPoint joinPoint) {
        
        // 获取当前用户ID
        String userId = String.valueOf(((ServletRequestAttributes) RequestContextHolder.getRequestAttributes())
                .getRequest().getHeader("userId"));
        // 获取当前请求的方法路径
        String servletPath = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest()
                .getServletPath();
         
        // 模拟获取数据权限配置信息 --- 传入当前请求。
        // 此处时用来获取用户权限数据,根据具体情况自行修改
        List<JnTestDataauthorityData> list = dataAuthorityTestDataService.queryWithPath(servletPath);
        
        if (!CommonUtils.isEmpty(list)) {
            // 格式转换数据处理 。业务需要。
            List<JnTestDataauthorityVo> dataVoList = transData(list);
            // 将获取到的数据处理成自己需要的格式后,存到threadloacl中,后面mybatis拦截器中获取数据
            QueryInterceptorRegistry.setQueryInterceptor(dataVoList);
        }

        System.out.println("------------>@Before 前置通知之结束");
    }

    /**
     * transData(数据转换)  
     * @param list
     * @return List<JnTestDataauthorityVo>
     * @author wuwenjin
     * @date 2022年5月17日 上午10:42:06  
     * @Exception 异常对象  
     * @version 1.0
     */
    private List<JnTestDataauthorityVo> transData(List<JnTestDataauthorityData> list) {
        List<JnTestDataauthorityVo> result = new ArrayList<JnTestDataauthorityVo>();
        // 先将传入的数据根据表名+字段分组
        Map<String, List<JnTestDataauthorityData>> basicMap = list.stream()
                .collect(Collectors.groupingBy(e -> e.getTableName() + e.getCode()));
        JnTestDataauthorityVo datavo = null;
        for (Map.Entry<String, List<JnTestDataauthorityData>> entry : basicMap.entrySet()) {
            // String mapKey = entry.getKey();
            List<JnTestDataauthorityData> mapValueList = entry.getValue();
            datavo = new JnTestDataauthorityVo();
            // 获取当前分组中纬度值
            List<String> valueList = mapValueList.stream().map(s -> s.getCvalue()).distinct()
                    .collect(Collectors.toList());
            // 对象赋值
            datavo.setCode(mapValueList.get(0).getCode());
            datavo.setMapperMethod(mapValueList.get(0).getMapperMethod());
            datavo.setRequestPath(mapValueList.get(0).getRequestPath());
            datavo.setTableName(mapValueList.get(0).getTableName());
            datavo.setCvalueList(valueList);
            datavo.setExtFlag(mapValueList.get(0).getExtFlag());// 是否扩展表字段 1是 0否
            datavo.setMainid(mapValueList.get(0).getMainid());
            datavo.setMainTable(mapValueList.get(0).getMainTable());
            if (valueList.size() == 1) {
                datavo.setSingleFlag("0");
            } else {
                datavo.setSingleFlag("1");
            }
            result.add(datavo);

        }
        return result;
    }

    /**
     * 定制一个环绕通知
     * @param joinPoint
     * @throws Throwable 
     */
    @Around("cutMethod()")
    public Object advice(ProceedingJoinPoint joinPoint) throws Throwable {       
        return joinPoint.proceed();// 执行proceed方法的作用是让目标方法执行
    }

    @After("cutMethod()")
    public void after() {        
        System.out.println("------------>@After 已经记录下操作日志@After 方法执行后");        
    }

}

 说明:自定义注解的用途主要是用来获取当前用户的数据权限数据, 然后存到threadlocal中。为什么要存到threadlocal中呢? 因为后面再mytaits拦截器没法直接注入业务service来获取权限数据。 所以此处用了threadlocal来中转了一下。 其实此处也可以使用其它缓存来实现,此处就不再拓展。下面时threadlocal相关代码。


import java.util.List;

import com.freshport.freight.flatcar.entity.vo.JnTestDataauthorityVo;

public class QueryInterceptorRegistry {
    // ThreadLocal 存储的数据格式根据自己业务定义即可
    private static ThreadLocal<List<JnTestDataauthorityVo>> queryInterceptor = new ThreadLocal<List<JnTestDataauthorityVo>>();

    public static List<JnTestDataauthorityVo> getQueryInterceptor() {
        return queryInterceptor.get();
    }

    public static void setQueryInterceptor(List<JnTestDataauthorityVo> queryInterceptor) {
        QueryInterceptorRegistry.queryInterceptor.set(queryInterceptor);
    }

    public static void clear() {
        queryInterceptor.remove();
    }
}

到这一步,前期的获取数据已经准备完成, 接下来就是重点了,更改SQL。

// 自定义一个mybatis拦截器,用来获取执行SQL

import java.util.List;
import java.util.Properties;
import java.util.stream.Collectors;

import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.alibaba.fastjson.JSONObject;
import com.freshport.freight.common.util.CommonUtils;
import com.freshport.freight.flatcar.entity.vo.JnTestDataauthorityVo;

/**
 * 自定义 MyBatis 拦截器
 */
@Intercepts({ @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
        RowBounds.class, ResultHandler.class }) })
public class MySqlInterceptor implements Interceptor {

    private static final Logger logger = LoggerFactory.getLogger(MySqlInterceptor.class);

    /**
     * intercept 方法用来对拦截的sql进行具体的操作
     * @param invocation
     * @return
     * @throws Throwable
     */
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        
        // 获取用户自定义权限数据。
        List<JnTestDataauthorityVo> authDataList = QueryInterceptorRegistry.getQueryInterceptor();        
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameterObject = args[1];
       
        // sql语句类型 select、delete、insert、update
        String sqlCommandType = ms.getSqlCommandType().toString();
        
        // 仅拦截 select 查询 或者 传入的参数为空
        if (!sqlCommandType.equals(SqlCommandType.SELECT.toString()) || CommonUtils.isEmpty(authDataList)) {
            return invocation.proceed();
        }
        // id为执行的mapper方法的全路径名,如com.mapper.UserMapper
        String id = ms.getId();     
            
            BoundSql boundSql = ms.getBoundSql(parameterObject);
            String origSql = boundSql.getSql();
            System.out.println("======>原始SQL:  " + origSql);
            String newSql = "";
            // 这个方法主要是用来解析原来的SQL, 然后再将现有的数据权限拼装成SQL语句添加上,返回一个新SQL。
            newSql = handleSql(origSql, authDataList, sqlFlag);
            System.out.println("======>改写的SQL: " + newSql);
            // 重新new一个查询语句对象
            BoundSql newBoundSql = new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(),
                    boundSql.getParameterObject());
            // 把新的查询放到statement里
            MappedStatement newMs = newMappedStatement(ms, new BoundSqlSqlSource(newBoundSql));
            for (ParameterMapping mapping : boundSql.getParameterMappings()) {
                String prop = mapping.getProperty();
                if (boundSql.hasAdditionalParameter(prop)) {
                    newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop));
                }
            }
            // MappedStatement维护了⼀条<select|update|delete|insert>节点的封装
            Object[] queryArgs = invocation.getArgs();
            queryArgs[0] = newMs;           
        
        return invocation.proceed();
    } 

  

    /**
     * 定义一个内部辅助类,作用是包装 SQL
     */
    class BoundSqlSqlSource implements SqlSource {
        private BoundSql boundSql;

        public BoundSqlSqlSource(BoundSql boundSql) {
            this.boundSql = boundSql;
        }

        public BoundSql getBoundSql(Object parameterObject) {
            return boundSql;
        }

    }

    private MappedStatement newMappedStatement(MappedStatement ms, SqlSource newSqlSource) {
        MappedStatement.Builder builder = new MappedStatement.Builder(ms.getConfiguration(), ms.getId(), newSqlSource,
                ms.getSqlCommandType());
        builder.resource(ms.getResource());
        builder.fetchSize(ms.getFetchSize());
        builder.statementType(ms.getStatementType());
        builder.keyGenerator(ms.getKeyGenerator());
        if (ms.getKeyProperties() != null && ms.getKeyProperties().length > 0) {
            builder.keyProperty(ms.getKeyProperties()[0]);
        }
        builder.timeout(ms.getTimeout());
        builder.parameterMap(ms.getParameterMap());
        builder.resultMaps(ms.getResultMaps());
        builder.resultSetType(ms.getResultSetType());
        builder.cache(ms.getCache());
        builder.flushCacheRequired(ms.isFlushCacheRequired());
        builder.useCache(ms.isUseCache());
        return builder.build();
    }

    @Override
    public Object plugin(Object target) {
        logger.info("plugin方法:{}", target);

        if (target instanceof Executor) {
            return Plugin.wrap(target, this);
        }
        return target;

    }

    @Override
    public void setProperties(Properties properties) {
        // 获取属性
        // String value1 = properties.getProperty("prop1");
        logger.info("properties方法:{}", properties.toString());
    }

}

关于mybatis自定义拦截器说明:

1.需要在MybatisConfiguration.java中配置一下你的自定义拦截器。如果你的项目中使用的pagehelper分页,那么需要将你的自定义拦截器放到pagehelper前面, 这样才可以获取到pagehelper生成的SQL,否则无法获取SQL语句。

 2.上面mybatis拦截器中, handlesql()方法我没有放出来。 是因为此处我解析SQL的方法是自己根据SQL格式截取处理的,没有通用性。 有具体的解析SQL的sqlprease,druid等工具,但是由于我们的SQL过于复杂导致解析报错没法使用才用的截取方式。大家可以试试解析工具,简单的SQL是可以解析出来。


截至到这, 通过mybatis拦截器方式添加数据权限的方式就可以实现了。 但是仍然还有优化的空间,比如说微服务中跨系统调用时数据传值问题,缓存如何使用问题这些没有详细的来说。上面主要是一个解决思路,具体细节大家再搜一下差不多就能解决。 

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐