写在前面:做企业管理系统的同学一定遇到过这个需求——管理员看全部数据,部门经理看本部门,普通员工看自己的。很多人的做法是在每个接口里手写if判断,代码又臭又长。今天聊聊RuoYi、JeecgBoot都在用的方案:基于注解的数据权限控制,一行注解搞定数据隔离。

在这里插入图片描述


一、为什么需要数据权限控制?

1.1 业务场景

实际场景:你做了一个订单管理系统,销售经理要看团队所有订单,销售员只能看自己的订单,财务要看全部订单但只能看金额不能看客户信息。这种需求在每个企业管理系统里都会遇到。

常见的数据权限需求:

角色 数据范围 典型场景
系统管理员 全部数据 超级管理员,无限制
部门经理 本部门数据 销售经理看团队业绩
普通员工 个人数据 销售员看自己的订单
自定义 按规则过滤 财务看金额、客服看投诉

1.2 传统做法的痛点

踩坑提醒:我见过最糟糕的做法是在每个查询接口里写一堆if判断。代码重复、难以维护、容易漏掉。

// ❌ 糟糕的做法:每个接口都写判断逻辑
@GetMapping("/list")
public Result list() {
    Long userId = SecurityUtil.getUserId();
    String role = SecurityUtil.getRole();
    
    if ("admin".equals(role)) {
        return orderService.listAll();
    } else if ("manager".equals(role)) {
        Long deptId = userService.getDeptId(userId);
        return orderService.listByDept(deptId);
    } else {
        return orderService.listByUser(userId);
    }
}

// 另一个接口又要写一遍...
@GetMapping("/myOrders")
public Result myOrders() {
    // 又是一堆if判断...
}

痛点总结

  1. 代码重复(每个接口都要写)
  2. 容易漏掉(新增接口忘记加判断)
  3. 难以维护(权限规则变更要改所有接口)
  4. SQL耦合业务(业务逻辑和权限逻辑混在一起)

二、注解式数据权限的核心思路

2.1 一行注解搞定

// ✅ 优雅的做法:一行注解
@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u")
public Result list() {
    return orderService.list();
}

原理:通过AOP拦截方法,在SQL执行前动态拼接权限过滤条件。

2.2 整体架构

请求进入
    ↓
AOP拦截带@DataScope注解的方法
    ↓
获取当前用户的数据权限范围
    ↓
动态修改SQL(拼接WHERE条件)
    ↓
执行SQL返回过滤后的数据

核心组件

组件 作用
@DataScope注解 标记需要数据权限过滤的方法
DataScopeAspect切面 AOP拦截,解析注解,拼接SQL
DataScopeHandler处理器 根据用户角色生成过滤条件
MyBatis拦截器 在SQL执行前修改SQL语句

三、核心实现代码

3.1 定义@DataScope注解

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    
    /**
     * 部门表的别名,用于拼接部门过滤条件
     * 例如:SELECT * FROM order o LEFT JOIN dept d ON o.dept_id = d.id
     * 这里deptAlias = "d"
     */
    String deptAlias() default "";
    
    /**
     * 用户表的别名,用于拼接用户过滤条件
     * 例如:SELECT * FROM order o LEFT JOIN user u ON o.user_id = u.id
     * 这里userAlias = "u"
     */
    String userAlias() default "";
    
    /**
     * 权限字段名(可选)
     * 默认使用配置的字段名
     */
    String permissionField() default "";
}

3.2 数据权限范围定义

public enum DataScopeEnum {
    
    /** 全部数据权限 */
    ALL(1, "全部数据权限"),
    
    /** 自定义数据权限(按部门ID列表) */
    CUSTOM(2, "自定义数据权限"),
    
    /** 本部门数据权限 */
    DEPT(3, "本部门数据权限"),
    
    /** 本部门及以下数据权限 */
    DEPT_AND_CHILD(4, "本部门及以下数据权限"),
    
    /** 仅本人数据权限 */
    SELF(5, "仅本人数据权限");
    
    private final Integer code;
    private final String desc;
}

3.3 AOP切面实现

@Aspect
@Component
public class DataScopeAspect {
    
    @Before("@annotation(dataScope)")
    public void doBefore(JoinPoint point, DataScope dataScope) {
        // 1. 获取当前用户
        User user = SecurityUtil.getCurrentUser();
        if (user == null) {
            return;  // 未登录,不处理
        }
        
        // 2. 如果是管理员,跳过数据权限过滤
        if (user.isAdmin()) {
            return;
        }
        
        // 3. 获取用户的数据权限范围
        DataScopeEnum scope = user.getDataScope();
        
        // 4. 生成SQL过滤条件
        String sqlFilter = buildSqlFilter(user, scope, dataScope);
        
        // 5. 将过滤条件存入ThreadLocal(供MyBatis拦截器使用)
        DataScopeContext.setSqlFilter(sqlFilter);
    }
    
    private String buildSqlFilter(User user, DataScopeEnum scope, DataScope annotation) {
        StringBuilder filter = new StringBuilder();
        
        String deptAlias = annotation.deptAlias();
        String userAlias = annotation.userAlias();
        
        switch (scope) {
            case ALL:
                // 全部数据,不加过滤条件
                break;
                
            case CUSTOM:
                // 自定义部门ID列表
                if (StringUtils.isNotBlank(deptAlias)) {
                    filter.append(deptAlias).append(".dept_id IN (")
                          .append(user.getCustomDeptIds())
                          .append(")");
                }
                break;
                
            case DEPT:
                // 本部门数据
                if (StringUtils.isNotBlank(deptAlias)) {
                    filter.append(deptAlias).append(".dept_id = ")
                          .append(user.getDeptId());
                }
                break;
                
            case DEPT_AND_CHILD:
                // 本部门及子部门
                if (StringUtils.isNotBlank(deptAlias)) {
                    filter.append(deptAlias).append(".dept_id IN (")
                          .append("SELECT dept_id FROM dept WHERE find_in_set(")
                          .append(user.getDeptId())
                          .append(", ancestors)");
                }
                break;
                
            case SELF:
                // 仅本人数据
                if (StringUtils.isNotBlank(userAlias)) {
                    filter.append(userAlias).append(".user_id = ")
                          .append(user.getId());
                }
                break;
        }
        
        return filter.toString();
    }
    
    @After("@annotation(dataScope)")
    public void doAfter() {
        // 清理ThreadLocal
        DataScopeContext.clear();
    }
}

3.4 MyBatis拦截器拼接SQL

@Intercepts({
    @Signature(type = Executor.class, method = "query", 
               args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Component
public class DataScopeInterceptor implements Interceptor {
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 1. 获取ThreadLocal中的过滤条件
        String sqlFilter = DataScopeContext.getSqlFilter();
        if (StringUtils.isBlank(sqlFilter)) {
            return invocation.proceed();  // 无过滤条件,直接执行
        }
        
        // 2. 获取原始SQL
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];
        
        BoundSql boundSql = ms.getBoundSql(parameter);
        String originalSql = boundSql.getSql();
        
        // 3. 拼接过滤条件
        String newSql = originalSql + " AND " + sqlFilter;
        
        // 4. 重置SQL
        resetSql(ms, boundSql, newSql);
        
        return invocation.proceed();
    }
    
    private void resetSql(MappedStatement ms, BoundSql boundSql, String newSql) {
        // 通过反射修改SQL
        try {
            Field field = boundSql.getClass().getDeclaredField("sql");
            field.setAccessible(true);
            field.set(boundSql, newSql);
        } catch (Exception e) {
            throw new RuntimeException("修改SQL失败", e);
        }
    }
}

经验之谈:MyBatis拦截器是修改SQL的利器,但要注意性能。每次SQL执行都会触发拦截器,所以判断逻辑要尽量简单。ThreadLocal存过滤条件就是为了避免重复计算。


四、实际使用示例

4.1 订单列表查询

@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u")
public Result list(OrderQueryDTO query) {
    List<Order> orders = orderMapper.selectList(query);
    return Result.success(orders);
}

对应的Mapper SQL:

<select id="selectList" resultType="Order">
    SELECT o.*, u.username, d.dept_name
    FROM order o
    LEFT JOIN user u ON o.user_id = u.id
    LEFT JOIN dept d ON o.dept_id = d.id
    WHERE o.status = #{status}
    <!-- 数据权限条件会自动拼接在这里 -->
</select>

不同用户看到的SQL

用户角色 最终执行的SQL
管理员 SELECT ... WHERE o.status = 1
部门经理 SELECT ... WHERE o.status = 1 AND d.dept_id = 10
普通员工 SELECT ... WHERE o.status = 1 AND u.user_id = 100

4.2 多表关联查询

@GetMapping("/report")
@DataScope(deptAlias = "d")  // 只按部门过滤
public Result report() {
    List<Report> reports = reportMapper.selectReport();
    return Result.success(reports);
}
<select id="selectReport" resultType="Report">
    SELECT d.dept_name, COUNT(*) as order_count, SUM(amount) as total_amount
    FROM order o
    LEFT JOIN dept d ON o.dept_id = d.id
    GROUP BY d.dept_id
    <!-- 数据权限条件会自动拼接在GROUP BY之前 -->
</select>

踩坑提醒:GROUP BY查询时,数据权限条件要拼接在GROUP BY之前,否则过滤不生效。MyBatis拦截器需要处理这种情况。


五、预判问题与解答

Q1:如果SQL没有JOIN部门表,怎么办?

问题场景SELECT * FROM order WHERE status = 1,没有JOIN dept表,但注解写了deptAlias = "d"

解答

  1. 方案一:拦截器检测到别名不存在,自动忽略该条件
  2. 方案二:强制要求SQL必须JOIN相关表(编译期或运行时校验)
  3. 方案三:使用子查询替代JOIN
-- 方案三:子查询替代
SELECT * FROM order 
WHERE status = 1 
AND dept_id IN (SELECT dept_id FROM dept WHERE dept_id = #{currentDeptId})

Q2:数据权限和分页怎么配合?

解答:数据权限条件拼接在WHERE中,分页插件(如PageHelper)会自动处理。

原始SQL:SELECT * FROM order WHERE status = 1
数据权限后:SELECT * FROM order WHERE status = 1 AND d.dept_id = 10
分页后:SELECT * FROM order WHERE status = 1 AND d.dept_id = 10 LIMIT 0, 10

注意:MyBatis拦截器的执行顺序很重要。数据权限拦截器要在分页拦截器之前执行。

// 配置拦截器顺序
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    // 先加数据权限拦截器
    interceptor.addInnerInterceptor(new DataScopeInnerInterceptor());
    // 再加分页拦截器
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
    return interceptor;
}

Q3:一个用户有多个角色,数据权限怎么算?

解答:取最大范围(最宽松的权限)。

// 用户同时是部门经理(本部门)和项目负责人(自定义部门列表)
// 数据权限取两者的并集
private String buildSqlFilter(User user, DataScope annotation) {
    Set<String> conditions = new HashSet<>();
    
    for (Role role : user.getRoles()) {
        String condition = buildConditionByRole(role, annotation);
        if (StringUtils.isNotBlank(condition)) {
            conditions.add(condition);
        }
    }
    
    if (conditions.isEmpty()) {
        return "";
    }
    
    // 多个条件用OR连接(取并集)
    return "(" + conditions.stream().collect(Collectors.joining(" OR ")) + ")";
}

Q4:数据权限配置存哪里?

解答:通常存数据库,支持动态配置。

-- 角色数据权限配置表
CREATE TABLE role_data_scope (
    role_id BIGINT,
    data_scope TINYINT,      -- 权限范围类型
    custom_dept_ids VARCHAR(500),  -- 自定义部门ID列表
    PRIMARY KEY (role_id)
);

配置界面:在角色管理页面,增加数据权限配置选项。

权限范围 说明
全部数据 系统管理员级别
本部门数据 部门经理级别
本部门及子部门 大区经理级别
仅本人数据 普通员工级别
自定义 选择特定部门

Q5:性能会不会有问题?

解答

  1. ThreadLocal存储:过滤条件只计算一次,避免重复计算
  2. 简单字符串拼接:SQL修改是字符串操作,性能开销很小
  3. 索引优化:确保dept_id、user_id字段有索引

性能对比

方案 性能开销
每个接口手写if判断 无额外开销,但代码重复
注解+AOP 每次请求多一次AOP拦截(微秒级)
注解+MyBatis拦截器 每次SQL多一次字符串拼接(纳秒级)

总体性能开销可忽略,换来的是代码整洁和可维护性。


六、与RuoYi的实现对比

RuoYi的数据权限实现更完善,支持多表、子查询、权限字符等功能:

// RuoYi的@DataScope注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
    String deptAlias() default "d";
    String userAlias() default "u";
    String permission() default "";  // 权限字符,用于细粒度控制
}

// RuoYi的使用示例
@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u", permission = "system:user:list")
public Result list() {
    // ...
}

RuoYi还支持:

  • 权限字符控制(不同菜单配置不同数据权限)
  • 多表关联自动识别
  • 子查询自动转换

七、面试高频考点

面试官问:请介绍一下基于注解的数据权限控制方案

参考答案

基于注解的数据权限控制是一种优雅的数据隔离方案,核心思路是通过AOP拦截+MyBatis拦截器,在SQL执行前动态拼接权限过滤条件。

工作流程:

  1. 方法上添加@DataScope注解,指定部门表别名和用户表别名
  2. AOP切面拦截方法,获取当前用户的数据权限范围
  3. 根据权限范围生成SQL过滤条件,存入ThreadLocal
  4. MyBatis拦截器在SQL执行前,从ThreadLocal取出过滤条件拼接到SQL

优点:

  • 代码简洁(一行注解搞定)
  • 业务解耦(权限逻辑不侵入业务代码)
  • 易于维护(权限规则变更只改一处)

试官问:MyBatis拦截器是怎么修改SQL的?

参考答案

MyBatis拦截器通过反射修改BoundSql对象的sql字段。

核心步骤:

  1. 实现Interceptor接口,使用@Intercepts注解标记拦截的方法
  2. 从Invocation中获取MappedStatement和BoundSql
  3. 通过反射获取BoundSql的sql字段并修改
  4. 调用invocation.proceed()继续执行

注意点:

  • 拦截器执行顺序很重要(数据权限要在分页之前)
  • 反射修改sql字段有性能开销,但可忽略
  • 多表关联时要正确识别表别名

试官问:数据权限和功能权限有什么区别?

参考答案

权限类型 控制对象 实现方式
功能权限 能不能操作 RBAC(角色-菜单-按钮)
数据权限 能看到哪些数据 SQL过滤条件

功能权限解决"能不能"的问题:

  • 能不能看这个菜单
  • 能不能点这个按钮

数据权限解决"看多少"的问题:

  • 管理员看全部订单
  • 部门经理看本部门订单
  • 员工看自己的订单

两者配合使用:先判断功能权限(能不能访问),再判断数据权限(能看到什么)。


试官问:ThreadLocal在这里的作用是什么?

参考答案

ThreadLocal用于在AOP切面和MyBatis拦截器之间传递数据权限过滤条件。

为什么用ThreadLocal:

  1. 线程隔离:每个请求独立,不会串数据
  2. 避免重复计算:AOP计算一次,拦截器直接使用
  3. 跨组件传递:AOP和拦截器是不同组件,ThreadLocal作为桥梁

生命周期:

  • AOP切面@Before:计算过滤条件,存入ThreadLocal
  • MyBatis拦截器:从ThreadLocal取出,拼接SQL
  • AOP切面@After:清理ThreadLocal,防止内存泄漏

八、总结

基于注解的数据权限控制核心价值:

  1. 代码简洁:一行注解替代一堆if判断
  2. 业务解耦:权限逻辑和业务逻辑分离
  3. 易于维护:权限规则变更只改一处
  4. 灵活配置:支持多种权限范围,可动态配置

一句话总结:把数据权限过滤逻辑从业务代码中抽离,通过注解+AOP+MyBatis拦截器自动完成SQL拼接,让开发者专注于业务逻辑。


参考资料

  1. RuoYi官方文档 - 数据权限控制
  2. MyBatis拦截器官方文档
  3. JeecgBoot数据权限实现

互动话题:你在项目中是怎么实现数据权限的?是每个接口手写判断,还是用注解+AOP?有没有遇到过数据权限和分页冲突的问题?欢迎在评论区分享你的实践经验!

如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇


本文为【Java后端技术亮点】系列第2篇,持续更新中…

更多推荐