行级数据权限控制@DataPermission

一、背景与现状问题

在日常业务系统中,采购、订单、合同等核心数据往往按部门、按用户严格隔离。随着业务扩张,管理者(上级)需要查看下级人员负责的数据,进行监督、审批与统计,由此产生两类突出痛点:

一是数据权限过滤逻辑散落各处。各查询接口需要手工拼接部门、用户等过滤条件,代码重复、口径不一,极易遗漏或写错,存在越权访问与数据泄露风险。

二是上级查看下级数据困难。多级部门体系下,需要按部门层级做前缀匹配才能覆盖下属范围,手工实现繁琐且容易遗漏层级,导致上级无法完整看到下属数据,影响管理与决策。

三是合规管控薄弱。缺乏统一的豁免与鉴权机制,特殊角色(如超级管理员、供应商管理员)与普通用户的过滤规则混在一起,权限边界不清晰。

二、改进方案

(一)总体思路

设计行级数据权限控制注解@DataPermission,标注该注解的查询方法,在执行时由切面自动根据当前登录用户的属性拼接数据过滤条件,达到按用户、部门隔离数据的目的,核心解决上级查看下级数据的问题。

(二)技术方案设计

组件定位为行级数据权限控制注解。生效方式采用AOP切面加ThreadLocal加MyBatis拦截器的组合:AOP切面负责拦截带注解的方法并获取当前登录用户信息存入ThreadLocal;MyBatis拦截器在执行查询时读取ThreadLocal中的用户上下文,自动为SQL拼接数据权限过滤条件。

组件通过fields配置实现字段与取值来源的映射,关键配置项说明如下表:

表1 注解配置项说明

(三)关键实现点

一是AOP切面拦截:标注@DataPermission的方法被调用时,切面获取当前登录用户信息,写入ThreadLocal,供后续MyBatis拦截器使用,保证线程内上下文正确隔离。

二是MyBatis拦截器拼装过滤条件:拦截查询SQL,读取ThreadLocal中的用户上下文,根据fields配置的字段映射,将用户属性值作为参数拼入WHERE条件,实现自动过滤。

三是多级部门前缀匹配:启用defaultLevelCode时,按当前用户的deptLevelCode做like前缀匹配,自动覆盖其下各级部门的全部数据,从而让上级完整查看下属数据。

四是角色豁免机制:命中notRoleId配置的角色时不加过滤条件,例如超级管理员可查看全量数据、供应商管理员只管理本供应商数据,实现权限边界清晰可控。

(四)使用示例

在需要数据权限控制的查询方法上标注注解,并配置对应的字段映射即可,例如:@DataPermission(fields = {"dept_id = deptLevelCode"}, defaultLevelCode = true)。方法执行时即自动完成数据隔离,业务代码无需再手工编写过滤逻辑。

三、预期收益

(一)质量收益

统一数据权限过滤口径,消除散落重复的过滤代码,降低越权访问与数据泄露风险,数据隔离更严谨、更可靠。

(二)效率收益

上级无需逐级手工查询即可完整查看下属数据,管理、审批与统计效率显著提升;开发人员新增查询接口时只需加一个注解,无需重复编写过滤逻辑,开发效率大幅提高。

(三)合规收益

通过统一的角色豁免机制与权限边界管控,实现按角色、按部门、按用户的精细化授权,满足数据合规与审计要求。

五、适用范围与限制

一是该组件适用于需要按用户、部门隔离数据的查询场景,如采购、订单、合同、台账等业务;

二是依赖当前登录用户上下文,接口调用链中必须能获取到CommonUserVO用户信息;

三是fields的key需与目标SQL或实体字段名保持一致,value需为CommonUserVO中存在的属性名,否则过滤条件无法正确拼装;

四是非查询类操作(如批量写入)不适用,需结合业务另行处理;

五是特殊角色的豁免配置需与业务权限体系保持一致,避免误配导致数据越权。

四 、相关代码

(1)DataPermission 注解

java 复制代码
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface DataPermission {
    /**
     * 使用 KeyValue 数组来表示多个字段和对应的值
     */
    KeyValue[] fields() default {};

    /**
     * 是否使用levelCode控权
     */
    boolean defaultLevelCode() default false;
}

(2)DataPermissionAspect 切面

java 复制代码
package com.mdgyl.data.mybatis.plus.anno;

import com.jxdinfo.hussar.common.security.BaseSecurityUtil;
import com.jxdinfo.hussar.common.security.SecurityUser;
import com.mdgyl.common.cache.redis.util.RedisUtils;
import com.mdgyl.common.constants.RedisConstants;
import com.mdgyl.common.helper.CollectionHelper;
import com.mdgyl.common.vo.CommonUserVO;
import com.mdgyl.data.mybatis.plus.permission.DataPermissionContext;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Aspect
@Component
public class DataPermissionAspect {

    @Value("${permission.levelCode.roleId}")
    private String levelCodeRoleId;
    @Value("${permission.not.roleId}")
    private String notRoleId;
    @Resource
    private RedisUtils redisUtils;

    @Around("@annotation(com.mdgyl.data.mybatis.plus.anno.DataPermission)")
    public Object addDataPermission(ProceedingJoinPoint joinPoint) throws Throwable {

        final Signature signature = joinPoint.getSignature();
        final Object[] args = joinPoint.getArgs();
        if (!(signature instanceof MethodSignature)) {
            return joinPoint.proceed(args);
        }
        final Method method = ((MethodSignature) signature).getMethod();
        DataPermission dataPermission = method.getAnnotation(DataPermission.class);
        SecurityUser securityUser = BaseSecurityUtil.getUser();
        Long userId = securityUser == null ? null : securityUser.getUserId();
        List<Long> rolesList = securityUser == null ? null : securityUser.getRolesList();
        boolean queryPermission = containsNotRoleId(rolesList, notRoleId);
        if (queryPermission) {
            log.info("user is supplier manager or super admin id is {}", userId);
            return joinPoint.proceed(args);
        }
        Object o = redisUtils.hget(RedisConstants.PURCHASE_CENTER_USER_HASH_KEY, String.valueOf(userId));
        if (o == null) {
            log.error("DataPermissionAspect query user redis is null userId is {}", userId);
            return joinPoint.proceed(args);
        }
        CommonUserVO commonUserVO = (CommonUserVO) o;
        // 获取 @DataPermission 注解中的字段和值
        KeyValue[] keyValues = dataPermission.fields();
        boolean defaultLevelCode = dataPermission.defaultLevelCode();
        boolean permissionDeptLevelCode = isPermissionDeptLevelCode(rolesList,defaultLevelCode);
        // 动态添加数据权限过滤条件
        Map<String, Object> fieldMapping = new HashMap<>();
        for (KeyValue keyValue : keyValues) {
            String fieldName = keyValue.key();
            String value = keyValue.value();
            Object propertyValue = getPropertyValue(commonUserVO, value);
            if (CommonUserVO.PERMISSION_DEPT_LEVEL_CODE.equals(value)) {
                if(permissionDeptLevelCode){
                    fieldMapping.put(fieldName + ";like", propertyValue);
                }
            } else {
                fieldMapping.put(fieldName + ";=", propertyValue);
            }
        }

        try {
            // 将用户信息绑定到当前线程上下文
            DataPermissionContext.set(userId, fieldMapping);
            // 执行目标方法
            return joinPoint.proceed();
        } finally {
            // 清理上下文,避免线程污染
            DataPermissionContext.clear();
        }
    }

    public static boolean containsNotRoleId(List<Long> rolesList, String roleIdStr) {
        boolean permission = false;
        if (StringUtils.isBlank(roleIdStr)) {
            return false;
        }
        String[] roleIds = roleIdStr.split(",");
        for (String roleId : roleIds) {
            if (CollectionUtils.isEmpty(rolesList)) {
                break;
            }
            if (rolesList.contains(Long.parseLong(roleId))) {
                permission = true;
            }
        }
        return permission;
    }

    private boolean isPermissionDeptLevelCode(List<Long> rolesList, boolean defaultLevelCode) {
        // 判断是否要增加 部门编码的权限条件
        boolean levelCode = false;
        // 判断是否默认允许levelCode控权
        if(defaultLevelCode){
            return true;
        }
        if (StringUtils.isBlank(levelCodeRoleId)) {
            return false;
        }
        String[] roleIds = levelCodeRoleId.split(",");
        for (String roleId : roleIds) {
            if (CollectionUtils.isEmpty(rolesList)) {
                break;
            }
            if (rolesList.contains(Long.parseLong(roleId))) {
                levelCode = true;
            }
        }
        return levelCode;
    }

    /**
     * 根据属性名获取对象的属性值
     *
     * @param object    目标对象
     * @param fieldName 属性名
     * @return 属性值
     * @throws NoSuchFieldException   如果找不到属性
     * @throws IllegalAccessException 如果无法访问属性
     */
    public static Object getPropertyValue(Object object, String fieldName) throws NoSuchFieldException, IllegalAccessException {
        // 获取对象的 Class 类型
        Class<?> clazz = object.getClass();

        // 获取目标属性的 Field 对象
        Field field = clazz.getDeclaredField(fieldName);

        // 设置属性可访问(即使它是私有的)
        field.setAccessible(true);

        // 返回字段值
        return field.get(object);
    }
}

(3)KeyValue 注解

java 复制代码
package com.mdgyl.data.mybatis.plus.anno;

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

@Target(ElementType.ANNOTATION_TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface KeyValue {
    String key();
    String value();
}

(4)用法

java 复制代码
    @DataPermission(fields = {
            //计划岗控权
            @KeyValue(key = MaterialDO.PERMISSION_PLAN_USER_ID, value = CommonUserVO.PERMISSION_USER_ID),
            @KeyValue(key = MaterialDO.PERMISSION_PLAN_USER_SUPERIOR_ID, value = CommonUserVO.PERMISSION_USER_ID),
            @KeyValue(key = MaterialDO.PERMISSION_PLAN_USER_DEPT_LEVEL_CODE, value = CommonUserVO.PERMISSION_DEPT_LEVEL_CODE),
            //人员岗控权
            @KeyValue(key = MaterialDO.PERMISSION_PRICE_USER_ID, value = CommonUserVO.PERMISSION_USER_ID),
            @KeyValue(key = MaterialDO.PERMISSION_PRICE_USER_SUPERIOR_ID, value = CommonUserVO.PERMISSION_USER_ID),
            @KeyValue(key = MaterialDO.PERMISSION_PRICE_USER_DEPT_LEVEL_CODE, value = CommonUserVO.PERMISSION_DEPT_LEVEL_CODE),
    })
    public List<MaterialOrgDO> listByPermissionPageParam(MaterialPageQueryParam pageQueryParam) {
        LambdaQueryWrapper<MaterialOrgDO> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(MaterialOrgDO::getTenantId, pageQueryParam.getTenantId());
        return materialOrgMapper.selectList(queryWrapper);
    }
java 复制代码
@Setter
@Getter
@ToString
@TableName(value = "t_material", autoResultMap = true)
public class MaterialDO implements BaseEntity {

    @TableField(exist = false)//计划岗人员id控权
    public static final String PERMISSION_PLAN_USER_ID = "plan_user_id";
    @TableField(exist = false)//计划岗人员部门编码控权
    public static final String PERMISSION_PLAN_USER_DEPT_LEVEL_CODE = "plan_user_dept_level_code";
    @TableField(exist = false)//计划岗人员上级用户id控权
    public static final String PERMISSION_PLAN_USER_SUPERIOR_ID = "plan_user_superior_id";

    @TableField(exist = false)
    public static final String PERMISSION_PRICE_USER_ID = "price_user_id";
    @TableField(exist = false)
    public static final String PERMISSION_PRICE_USER_DEPT_LEVEL_CODE = "price_user_dept_level_code";
    @TableField(exist = false)
    public static final String PERMISSION_PRICE_USER_SUPERIOR_ID = "price_user_superior_id";
    @TableField(exist=false)
    public  static final String PERMISSION_DEVELOP_USER_ID="develop_user_id";
相关推荐
ZoeLandia1 小时前
React05:useEffect 与副作用
开发语言·前端·react.js·typescript
ManageEngineITSM1 小时前
ITSM系统如何与企业微信、钉钉、飞书集成?提升工单处理效率的方法
java·大数据·开发语言·钉钉·企业微信·变更管理
Akamai中国1 小时前
PHP 爆出两大高风险漏洞!Akamai 即时部署防护机制
开发语言·人工智能·云计算·云服务
学逆向的1 小时前
C 悬空指针
c语言·开发语言·网络安全
weixin_417396831 小时前
酒店弱电怎么验收?四个阶段与逐项检查清单
开发语言·前端·php·综合布线·弱电
凤凰院凶涛QAQ11 小时前
《Java版数据结构 & 集合类剖析》Map & Set & 反射:“从哈希碰撞到红黑树转换,反射帮你扒开 HashMap 的桶数组看个究竟”
java·数据结构·算法
H.莓飛11 小时前
【数据结构】排序_OJ题(一):计数、桶与排序后的处理
java·开发语言·数据结构
wangchunyu11412 小时前
JAVA AQS与Lock实现原理(含CAS)
java·开发语言·word
爱喝水的鱼丶13 小时前
SAP-ABAP:ECC→S/4HANA ABAP 迁移升级指南——程序改造、语法兼容与性能优化 导语
运维·开发语言·性能优化·sap·abap·经验交流·开发交流