分享一种灵活的数据权限思路(AOP、反射、MyBatis拦截器)

3,877 阅读11分钟

前言

我一年java,在小公司,当前公司权限这块都没有成熟的方案,目前我知道权限分为功能权限和数据权限,我不知道数据权限这块大家是怎么解决的,但在实际项目中我遇到数据权限真的复杂,你永远不知道业主在这方面的需求是什么。我也有去搜索在这方面是怎么做,但是我在gitee、github搜到的权限管理系统他们都是这么实现的:查看全部数据自定义数据权限本部门数据权限本部门及以下数据仅本人数据权限,但是这种控制粒度完全不够的,所以就想自己实现一下。

需求

  • 需求一 有一个单位企业的树,企业都是挂在某个单位下面的,企业是分类型的(餐饮企业经营企业生产企业),业主需要单位的人限定某些单位只能看一个或他指定的某个类型的企业。现在指定角色A只能查看餐饮经营企业,那就只能使用查看自定义部门数据这个,然后在10000家企业里面慢慢勾选符合的企业,这样可以是可以,但是我觉得这样做不太妥。估计有人说:那你把三种类型的企业分组,餐饮企业挂在餐饮分组下,其他同理。然后用自定义数据权限选中那两个不就可以了吗? 可以是可以,但是我不是业主,业主要求了那些企业必须挂在哪些单位下,在页面显示的树也不能显示什么餐饮企业分组生产企业... 说到底,除非你有办法改变业主的想法。

  • 需求二 类似订单吧,角色A只能查看未支付的订单,角色B只能看交易金额在100~1000元的订单。

用通用的那5种权限对这两个需求已经是束手无策了。

设计思路

后来我看到一篇文章【数据权限就该这么实现(设计篇) - 掘金 (juejin.cn)】,对我有很大的启发,从数据库字段下手,用规则来处理 image.png 我以这个文章的思路为基础,设计了这么一个关系 image.png

主要还是这张规则表,通过在页面配置好相关的规则来实现对某个字段的控制

CREATE TABLE `sys_rule` (
   `id` int NOT NULL AUTO_INCREMENT,
   `remark` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '备注',
   `mark_id` int DEFAULT NULL,
   `table_alias` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '表别名',
   `column_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '数据库字段名',
   `splice_type` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '拼接类型 SpliceTypeEnum',
   `expression` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '表达式 ExpressionEnum',
   `provide_type` tinyint DEFAULT NULL COMMENT 'ProvideTypeEnum 值提供类型,1-值,2-方法',
   `value1` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '值1',
   `value2` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '值2',
   `full_method_name` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '全限定类名#方法名',
   `formal_param` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '形参,分号隔开',
   `actual_param` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '实参,分号隔开',
   `create_time` datetime DEFAULT NULL,
   `create_by` int DEFAULT NULL,
   `update_time` datetime DEFAULT NULL,
   `update_by` int DEFAULT NULL,
   `deleted` bit(1) DEFAULT NULL,
   PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='规则表';

整体思路就是通过页面来对特定的接口设置规则,如果提供类型是@DataScope注解用在方法上,那么默认机会在执行SQL前去拼接对应的数据权限。如果提供类型是方法@DataScope注解用在方法上,那么会根据你配置的方法名参数类型去反射执行对应的方法,得到该规则能查看的所有idList,然后在执行SQL前去拼接对应的数据权限,这是默认的处理方式。如果@DataScope注解使用在形参上或者使用Service提供的方法接口,那么需要开发者手动处理,返回什么那么是开发者自定义了。所以字段你自己定,联表也没问题、反射执行什么方法、参数是什么、过程怎么样也是你自己定,灵活性很高(至少我是这么认为的,哈哈哈哈哈哈)

新建 DataScopeHandler

/**
 * 数据权限处理器
 */
@Slf4j
@Component
public class DataScopeHandler implements DataPermissionHandler {

   @Autowired
   Map<String, ExpressStrategy> expressStrategyMap;

   private final Expression noDataExpression = getNoDataExpression();

   @Override
   public Expression getSqlSegment(Expression oldWhere, String mappedStatementId) {
      DataScopeInfo dataScopeInfo = DataScopeAspect.getDataScopeInfo();
      // 没有规则 或 或者管理员 就不限制
      // 这个有待考虑实际情况修改
      // 情况1: 没有配置规则的话就看不到任何数据
      // 情况2: 没有配置规则的话就不限制
      // 当前是 情况2
      if (dataScopeInfo == null
              || CollectionUtil.isEmpty(dataScopeInfo.getRuleList())
              || SecurityUtil.isAdmin()
      ) {
         return oldWhere;
      }

      log.debug("----------------------------------数据权限处理器 开始处理SQL----------------------------------");
      log.debug("处理前的 WHERE 条件: {}", oldWhere.toString());
      Expression newWhere = null;

      List<DataScopeRule> ruleList = dataScopeInfo.getRuleList();

      // 当规则只有一条且需要根据规则构造的条件为空时, 让这条sql无法查询出数据
      if (ruleList.size() == 1) {
         Expression apply = process(ruleList.get(0), newWhere);
         newWhere = Objects.isNull(apply) ? this.noDataExpression : apply;

      }else {
         for (DataScopeRule rule : ruleList) {
            Expression apply = process(rule, newWhere);
            if (!Objects.isNull(apply)) {
               newWhere = apply;
            }
         }
      }

      log.debug("数据限制的 WHERE 条件: {}", newWhere);

      newWhere = new AndExpression(oldWhere, new Parenthesis(newWhere));

      log.debug("处理后的 WHERE 条件: {}", newWhere);
      log.debug("----------------------------------数据权限处理器 处理SQL结束----------------------------------");

      return newWhere;
   }

   private Expression process(DataScopeRule rule, Expression expression) {
      ExpressStrategy strategy = expressStrategyMap.get(rule.getExpression());
      if (strategy == null)
         throw new IllegalArgumentException("错误的表达式:" + rule.getExpression());

      return strategy.apply(rule, expression);
   }

   private Expression getNoDataExpression() {
      // 构造 1 != 1
      LongValue value = new LongValue(1);
      NotEqualsTo notEqualsTo = new NotEqualsTo();
      notEqualsTo.setLeftExpression(value);
      notEqualsTo.setRightExpression(value);
      return notEqualsTo;
   }
}

使用策略模式 ExpressStrategy

public interface ExpressStrategy {

    Expression apply(DataScopeRule rule, Expression where);

    /**
     * 获取条件的值
     * @param rule 某个规则
     * @return 条件的值
     */
    default Object getValue(DataScopeRule rule) {
        if (rule.getProvideType().equals(ProvideTypeEnum.METHOD.getCode())) {
            return rule.getResult();
        } else if (rule.getProvideType().equals(ProvideTypeEnum.VALUE.getCode())) {
            return rule.getValue1();
        } else {
            throw new IllegalArgumentException("错误的提供类型");
        }
    }

    /**
     * 获取字段
     * @param rule 某个规则
     * @return 字段
     */
    default Column getColumn(DataScopeRule rule) {
        String sql = "".equals(rule.getTableAlias()) || rule.getTableAlias() == null ? rule.getColumnName() : rule.getTableAlias() + "." + rule.getColumnName();
        return new Column(sql);
    }

    /**
     * 获取是否为 OR 拼接
     * @param spliceType 拼接方式
     * @return 是否为 OR 拼接
     */
    default boolean isOr(String spliceType) {
        if (!spliceType.equals(SpliceTypeEnum.AND.toString()) && !spliceType.equals(SpliceTypeEnum.OR.toString())) {
            throw new IllegalArgumentException("错误的拼接类型:" + spliceType);
        }
        return spliceType.equals(SpliceTypeEnum.OR.toString());
    }

    /**
     * 组装条件
     * @param spliceType 拼接方式
     * @param where 原来的查询条件
     * @param newExpression 新的查询条件
     * @return 组装后的查询条件
     */
    default Expression assemble(String spliceType, Expression where, Expression newExpression) {
        if (isOr(spliceType)) {
            where = where == null ? newExpression : new OrExpression(where, newExpression);
        } else {
            where = where == null ? newExpression : new AndExpression(where, newExpression);
        }
        return where;
    }

}

其中一种策略 EqStrategyImpl

这里只列举其中一种情况,我们演示处理 = 操作

public class EqStrategyImpl implements ExpressStrategy{

    @Override
    public Expression apply(RuleDto rule, Expression where) {
        StringValue valueExpression = new StringValue(String.valueOf(getValue(rule)));
        EqualsTo equalsTo = new EqualsTo(getColumn(rule), valueExpression);
        return assemble(rule.getSpliceType(), where, equalsTo);
    }
}

注册 DataScopeHandler

@Configuration
public class MyBatisPlusConfig {

    @Autowired
    private DataScopeHandler dataScopeHandler;

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

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

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

自定义注解@DataScope

/**
 * 使用数据权限。
 * 建议使用在mapper层接口上,使用在其他层会出现同一个类中直接调用了另一个方法,从而导致无法代理,
 * 如果使用在其他层方法,那么这个方法内的所有sql查询都会被数据权限限制!!!
 * @author Create by whz at 2023/6/8
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DataScope {

   /**
    * 规则name
    */
   String value();

}

切面处理

@Aspect
@Slf4j
@Component
public class DataScopeAspect {

   @Autowired
   private MarkService dataScopeService;

   // 通过ThreadLocal记录权限相关的属性值
   private static ThreadLocal<DataScopeInfo> threadLocal = new ThreadLocal<>();
   private static ThreadLocal<Boolean> methodProcessed = new ThreadLocal<>();

   public static DataScopeInfo getDataScopeInfo() {
      return threadLocal.get();
   }

   // 方法切点
   @Pointcut("@annotation(com.gitee.whzzone.annotation.DataScope)")
   public void methodPointCut() {
   }

   @After("methodPointCut()")
   public void clearThreadLocal() {
      if (methodProcessed.get() != null) {
         return;
      }
      threadLocal.remove();
      methodProcessed.remove();
      log.debug("----------------数据权限信息清除----------------");
   }

   @Before("methodPointCut()")
   public void doBefore(JoinPoint point) {
      if (methodProcessed.get() != null && methodProcessed.get()) {
         return;
      } else {
         methodProcessed.set(true);
      }
      Signature signature = point.getSignature();
      MethodSignature methodSignature = (MethodSignature) signature;
      Method method = methodSignature.getMethod();
      // 获得注解
      DataScope dataScope = method.getAnnotation(DataScope.class);

      try {
         if (dataScope != null && !SecurityUtil.isAdmin()) {
            String scopeName = dataScope.value();
            DataScopeInfo dataScopeInfo = dataScopeService.execRuleByName(scopeName);
            threadLocal.set(dataScopeInfo);
            printDebug(method ,dataScopeInfo);
         }
      } catch (Exception e) {
         e.printStackTrace();
         throw new RuntimeException("数据权限切面错误:" + e.getMessage());
      }
   }

   private void printDebug(Method method, DataScopeInfo dataScopeInfo) {
      if (dataScopeInfo != null && CollectionUtil.isNotEmpty(dataScopeInfo.getRuleList())) {
         log.debug("-----{}#{} 当前绑定规则开始-----", method.getDeclaringClass(), method.getName());
         for (DataScopeRule rule : dataScopeInfo.getRuleList()) {
            log.debug("- markId: {}, ruleId:{}, ruleName:{}, expression: {}", rule.getMarkId(), rule.getId(), rule.getRemark(), rule.getExpression());
         }
         log.debug("-----{}#{} 当前绑定规则结束-----", method.getDeclaringClass(), method.getName());
      }
   }

}

解析

根据注解的值,能拿到一个mark,根据这个标记可以查询到对应的rules,则可以开始进行解析

@Service
public class MarkServiceImpl extends EntityServiceImpl<MarkMapper, Mark, MarkDTO, MarkQuery> implements MarkService {
   private DataScopeInfo execRuleHandler(List<Rule> rules) {
      if (CollectionUtil.isEmpty(rules))
         return null;

      List<DataScopeRule> ruleList = new ArrayList<>();

      for (Rule rule : rules) {
         DataScopeRule dataScopeRule = new DataScopeRule();
         BeanUtil.copyProperties(rule, dataScopeRule);

         if (rule.getProvideType().equals(ProvideTypeEnum.VALUE.getCode())) {
            ruleList.add(dataScopeRule);

         } else if (rule.getProvideType().equals(ProvideTypeEnum.METHOD.getCode())) {
            try {
               Class<?>[] paramsTypes = null;
               Object[] argValues = null;

               if (StrUtil.isNotBlank(rule.getFormalParam()) && StrUtil.isNotBlank(rule.getActualParam())) {
                  // 获取形参数组
                  String[] formalArray = rule.getFormalParam().split(";");
                  // 获取实参数组
                  String[] actualArray = rule.getActualParam().split(";");

                  if (formalArray.length != actualArray.length)
                     throw new RuntimeException("形参数量与实参数量不符合");

                  // 转换形参为Class数组
                  paramsTypes = new Class<?>[formalArray.length];
                  for (int i = 0; i < formalArray.length; i++) {
                     paramsTypes[i] = Class.forName(formalArray[i].trim());
                  }

                  // 转换实参为Object数组
                  argValues = new Object[actualArray.length];
                  for (int i = 0; i < actualArray.length; i++) {
                     argValues[i] = JSONObject.parseObject(actualArray[i], paramsTypes[i]);
                  }
               }

               String[] parts = rule.getFullMethodName().split("#");
               String className = parts[0];
               String methodName = parts[1];

               Class<?> clazz = Class.forName(className);
               Object result;

               Method targetMethod = clazz.getDeclaredMethod(methodName, paramsTypes);
               if (Modifier.isStatic(targetMethod.getModifiers())) {
                  // 设置静态方法可访问
                  targetMethod.setAccessible(true);
                  // 执行静态方法
                  result = targetMethod.invoke(null, argValues);
               } else {
                  try {
                     // 尝试从容器中获取实例
                     Object instance = context.getBean(Class.forName(className));
                     Class<?> beanClazz = instance.getClass();
                     Method beanClazzMethod = beanClazz.getDeclaredMethod(methodName, paramsTypes);

                     // 执行方法
                     result = beanClazzMethod.invoke(instance, argValues);

                  } catch (NoSuchBeanDefinitionException e) {
                     // 创建类实例
                     Object obj = clazz.newInstance();
                     // 执行方法
                     result = targetMethod.invoke(obj, argValues);
                  }
               }

               dataScopeRule.setResult(result);
               ruleList.add(dataScopeRule);

            } catch (NoSuchMethodException e) {
               throw new RuntimeException("配置了不存在的方法");
            } catch (ClassNotFoundException e) {
               throw new RuntimeException("配置了不存在的类");
            } catch (Exception e) {
               e.printStackTrace();
               throw new RuntimeException("其他错误:" + e.getMessage());
            }

         } else
            throw new RuntimeException("错误的提供类型");
      }
      DataScopeInfo dataScopeInfo = new DataScopeInfo();
      dataScopeInfo.setRuleList(ruleList);
      return dataScopeInfo;
   }
}

例子1 查看订单金额大于100且小于500的订单

规则配置

  1. 新增一个标记,可以理解成一个接口标识 image.png

  2. 这个接口下所有的规则 image.png

  3. 查看订单金额大于100且小于500的订单的需求的具体配置,这个配置的目的是通过反射执行com.gitee.whzzone.admin.business.service.impl.OrderServiceImpl这个类下的limitAmountBetween(BigDecimal, BigDecimal)的方法,也就是执行limitAmountBetween(100, 500),返回符合条件的orderIds,然后会在执行sql前去拼接 select ... from order where ... and id in ({这里是返回的orderIds}),从而实现这个权限控制 image.png

  4. 给角色的这个订单列表接口配置查看订单金额大于100且小于500的订单这个规则,那么这个角色只能查看范围内的订单数据了。 image.png

代码

controller

@Api(tags = "订单相关")
@RestController
@RequestMapping("order")
public class OrderController extends EntityController<Order, OrderService, OrderDto, OrderQuery> {
    // 通用的增删改查不用写,父类已实现
}

service

public interface OrderService extends EntityService<Order, OrderDto, OrderQuery> {
    // 通用的增删改查不用写,父类已实现
    
    /**
     * 查询订单范围内的 orderIds
     * @param begin 订单金额开始
     * @param end 订单金额结束
     * @return
     */
    List<Long> limitAmountBetween(BigDecimal begin, BigDecimal end);
}

impl

@Service
public class OrderServiceImpl extends EntityServiceImpl<OrderMapper, Order, OrderDto, OrderQuery> implements OrderService {
    
    @DataScope("order-list") // 使用在方法上,交给AOP默认处理,标记这个方法为订单列表查询
    @Override // 重写父类列表查询
    public List<OrderDto> list(OrderQuery query) {
        LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(StrUtil.isNotBlank(query.getReceiverName()), Order::getReceiverName, query.getReceiverName());
        queryWrapper.eq(StrUtil.isNotBlank(query.getReceiverPhone()), Order::getReceiverPhone, query.getReceiverPhone());
        queryWrapper.eq(StrUtil.isNotBlank(query.getReceiverAddress()), Order::getReceiverAddress, query.getReceiverAddress());
        queryWrapper.eq(query.getOrderStatus() != null, Order::getOrderStatus, query.getOrderStatus());
        return afterQueryHandler(list(queryWrapper));
    }

    // 具体实现
    @Override
    public List<Long> limitAmountBetween(BigDecimal begin, BigDecimal end) {
        LambdaQueryWrapper<Order> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.between(Order::getOrderAmount, begin, end);
        List<Order> list = list(queryWrapper);
        if (CollectionUtil.isEmpty(list))
            return new ArrayList<>();

        return list.stream().map(BaseEntity::getId).collect(Collectors.toList());
    }
}

这样就实现了查看订单金额大于100且小于500的订单的需求,其实这个需求用不着这么麻烦,被我复杂化了(演示一下),其实用例子2的方式来实现。配两条规则:分别是order_amount > 100order_amount < 500的规则,然后选择AND连接就可以了。

例子2 查看收货人地址模糊查询钦南区的订单

规则配置

  1. 新增一个规则,提供类型,单表查询可以不设置表别名,看图吧 image.png

  2. 配置角色在订单列表查询接口使用的规则 image.png

代码

例子1的基础上不用做任何改动,因为这个需求无需编写代码

这样就实现了这个简单的需求,这样处理后,就可以在sql执行前拼接对应的查询条件,从而实现数据权限

到这里以上前面说的两个例子就可以搞定了,这查看全部数据自定义数据权限本部门数据权限本部门及以下数据仅本人数据权限五种权限在无形中实现了,针对你的用户id字段、部门id字段配几条对应的规则就可以。

还有很多可以完善的地方,忽略我的垃圾技术,本文主要是描述一下思路。有兴趣的可以移步到仓库看看,有没有感兴趣的来一起维护维护,非常欢迎~

项目地址 wonder-server: 一个有意思的权限管理系统 (gitee.com)