RBAC 权限模型 4 种数据范围实现:MyBatis-Plus 拦截器自动注入 SQL

作者:忆笙智云官方 | 发布时间:2026-06-24 14:30 | 更新时间:2026-06-24 14:30

RBAC 权限模型四种数据范围实现

引言

在企业级应用中,权限控制不仅仅是"能不能访问"的问题,更核心的是"能看到哪些数据"。传统的 RBAC(Role-Based Access Control)模型解决了功能权限的问题,但在数据权限层面往往力不从心。

例如:销售总监能看到所有订单,区域经理只能看到本区域订单,普通销售只能看到自己的订单。这种数据范围控制是权限系统的进阶需求。

本文将分享 RBAC 权限模型中四种数据范围的实现方案,以及如何通过 MyBatis-Plus 拦截器自动注入 SQL 条件,实现无侵入的数据权限控制。

一、RBAC 模型与数据范围

1.1 RBAC 基础模型

erDiagram
    USER ||--o{ USER_ROLE : "拥有"
    ROLE ||--o{ USER_ROLE : "分配"
    ROLE ||--o{ ROLE_MENU : "关联"
    MENU ||--o{ ROLE_MENU : "被关联"
    ROLE ||--o{ ROLE_DEPT : "关联"
    DEPT ||--o{ ROLE_DEPT : "被关联"

    USER {
        bigint id PK
        varchar username
        bigint dept_id FK
    }
    ROLE {
        bigint id PK
        varchar role_name
        varchar data_scope
    }
    DEPT {
        bigint id PK
        varchar dept_name
        bigint parent_id
    }
    MENU {
        bigint id PK
        varchar menu_name
        varchar permission
    }

1.2 四种数据范围定义

数据范围 data_scope 值 说明 SQL 效果
全部数据 1 不做任何过滤 无额外条件
本部门数据 2 仅查看本部门 dept_id = 当前用户部门ID
本部门及下级 3 查看本部门及子部门 dept_id IN (本部门及子部门ID列表)
仅本人 4 仅查看自己创建的 create_by = 当前用户ID

二、数据范围实现方案

2.1 角色数据范围配置

在角色表中增加 data_scope 字段,标识该角色的数据范围类型:

/**
 * 角色实体
 */
public class Role {
    private Long id;
    private String roleName;
    private String roleKey;

    /** 数据范围:1-全部 2-本部门 3-本部门及下级 4-仅本人 */
    private Integer dataScope;
}

2.2 数据范围上下文

在请求处理过程中,需要获取当前用户的数据范围信息,通过 ThreadLocal 传递:

/**
 * 数据权限上下文
 */
public class DataScopeContext {

    private static final ThreadLocal<DataScope> CONTEXT = new ThreadLocal<>();

    /**
     * 设置当前用户数据范围
     */
    public static void set(DataScope scope) {
        CONTEXT.set(scope);
    }

    /**
     * 获取当前用户数据范围
     */
    public static DataScope get() {
        return CONTEXT.get();
    }

    /**
     * 清除上下文
     */
    public static void clear() {
        CONTEXT.remove();
    }
}

/**
 * 数据范围信息
 */
public record DataScope(
    Long userId,          // 当前用户ID
    Long deptId,          // 当前用户部门ID
    Integer dataScope,    // 数据范围类型
    List<Long> deptIds    // 部门ID列表(本部门及下级时使用)
) {}

2.3 登录时注入数据范围

用户登录后,根据其角色的数据范围设置上下文:

/**
 * 登录后设置数据权限上下文
 */
public void setupDataScope(Long userId) {
    // 获取用户角色(取最大权限的角色)
    Role role = roleMapper.selectMaxDataScopeRole(userId);

    DataScope scope;
    switch (role.getDataScope()) {
        case 1 -> scope = new DataScope(userId, null, 1, null);
        case 2 -> {
            Long deptId = userMapper.selectDeptIdByUserId(userId);
            scope = new DataScope(userId, deptId, 2, List.of(deptId));
        }
        case 3 -> {
            Long deptId = userMapper.selectDeptIdByUserId(userId);
            // 递归查询本部门及所有子部门ID
            List<Long> deptIds = deptMapper.selectDeptAndChildrenIds(deptId);
            scope = new DataScope(userId, deptId, 3, deptIds);
        }
        case 4 -> scope = new DataScope(userId, null, 4, null);
        default -> scope = new DataScope(userId, null, 4, null);
    }

    DataScopeContext.set(scope);
}

三、MyBatis-Plus 拦截器自动注入

3.1 核心思路

通过 MyBatis-Plus 的拦截器机制,在 SQL 执行前自动追加数据权限条件,业务代码无需关心数据过滤逻辑。

sequenceDiagram
    participant C as Controller
    participant S as Service
    participant MP as MyBatis-Plus拦截器
    participant DB as 数据库

    C->>S: 查询列表
    S->>MP: 执行SQL: SELECT * FROM order
    MP->>MP: 获取DataScopeContext
    MP->>MP: 根据dataScope追加WHERE条件
    MP->>DB: SELECT * FROM order WHERE dept_id IN (...)
    DB-->>C: 返回过滤后的数据

3.2 自定义数据权限拦截器

/**
 * 数据权限拦截器
 * 自动为SQL追加数据范围条件
 */
@Intercepts({
    @Signature(type = Executor.class, method = "query",
               args = {MappedStatement.class, Object.class,
                       RowBounds.class, ResultHandler.class})
})
public class DataScopeInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        DataScope scope = DataScopeContext.get();
        // 无数据范围信息或全部数据权限,直接放行
        if (scope == null || scope.dataScope() == 1) {
            return invocation.proceed();
        }

        // 获取原始SQL
        Object[] args = invocation.getArgs();
        MappedStatement ms = (MappedStatement) args[0];
        Object parameter = args[1];

        BoundSql boundSql = ms.getBoundSql(parameter);
        String originalSql = boundSql.getSql();

        // 追加数据权限条件
        String newSql = applyDataScope(originalSql, scope);

        // 替换SQL
        resetSql(ms, boundSql, newSql, parameter);

        return invocation.proceed();
    }

    /**
     * 根据数据范围追加SQL条件
     */
    private String applyDataScope(String sql, DataScope scope) {
        String condition = switch (scope.dataScope()) {
            case 2 -> "dept_id = " + scope.deptId();
            case 3 -> "dept_id IN (" +
                       scope.deptIds().stream()
                           .map(String::valueOf)
                           .collect(Collectors.joining(",")) + ")";
            case 4 -> "create_by = " + scope.userId();
            default -> "1 = 0"; // 未知范围,拒绝所有
        };

        // 简单的SQL拼接(生产环境建议使用JSqlParser)
        if (sql.toUpperCase().contains("WHERE")) {
            return sql + " AND " + condition;
        } else {
            return sql + " WHERE " + condition;
        }
    }
}

3.3 使用 JSqlParser 精确解析(推荐)

生产环境推荐使用 JSqlParser 库精确解析和修改 SQL,避免字符串拼接带来的问题:

/**
 * 使用JSqlParser解析并追加数据权限条件
 */
private String applyDataScopeWithParser(String sql, DataScope scope) {
    try {
        Statement statement = CCJSqlParserUtil.parse(sql);
        Select select = (Select) statement;
        PlainSelect plainSelect = (PlainSelect) select.getSelectBody();

        // 构建数据权限条件表达式
        Expression scopeCondition = buildScopeExpression(scope);

        // 获取已有WHERE条件
        Expression where = plainSelect.getWhere();
        if (where != null) {
            // AND连接
            plainSelect.setWhere(new AndExpression(
                new Parenthesis(where), scopeCondition));
        } else {
            plainSelect.setWhere(scopeCondition);
        }

        return select.toString();
    } catch (JSQLParserException e) {
        throw new RuntimeException("SQL解析失败", e);
    }
}

/**
 * 构建数据范围表达式
 */
private Expression buildScopeExpression(DataScope scope) {
    return switch (scope.dataScope()) {
        case 2 -> new EqualsTo(
            new Column("dept_id"),
            new LongValue(scope.deptId()));
        case 3 -> new InExpression(
            new Column("dept_id"),
            new ExpressionList<>(scope.deptIds().stream()
                .map(LongValue::new).collect(Collectors.toList())));
        case 4 -> new EqualsTo(
            new Column("create_by"),
            new LongValue(scope.userId()));
        default -> new LongValue(0).equals(new LongValue(1))
            ? null : null; // 1=0 拒绝
    };
}

3.4 注册拦截器

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor(
            DataScopeInterceptor dataScopeInterceptor) {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 数据权限拦截器(需在分页插件之前)
        interceptor.addInnerInterceptor(new DataScopeInnerInterceptor());
        // 分页插件
        interceptor.addInnerInterceptor(
            new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

四、部门树递归查询优化

4.1 部门表设计

CREATE TABLE sys_dept (
    id          BIGINT PRIMARY KEY,
    dept_name   VARCHAR(100) NOT NULL,
    parent_id   BIGINT DEFAULT 0,
    sort_order  INT DEFAULT 0,
    INDEX idx_parent_id (parent_id)
);

4.2 递归查询子部门(MySQL 8.0 CTE)

-- 使用CTE递归查询本部门及所有子部门
WITH RECURSIVE dept_tree AS (
    -- 锚点:本部门
    SELECT id FROM sys_dept WHERE id = #{deptId}
    UNION ALL
    -- 递归:子部门
    SELECT d.id FROM sys_dept d
    INNER JOIN dept_tree dt ON d.parent_id = dt.id
)
SELECT id FROM dept_tree;

对应的 MyBatis-Plus 实现:

/**
 * 递归查询部门及子部门ID列表
 */
default List<Long> selectDeptAndChildrenIds(Long deptId) {
    return selectList(new LambdaQueryWrapper<SysDept>()
            .apply("id IN (WITH RECURSIVE dept_tree AS " +
                   "(SELECT id FROM sys_dept WHERE id = {0} " +
                   "UNION ALL SELECT d.id FROM sys_dept d " +
                   "INNER JOIN dept_tree dt ON d.parent_id = dt.id) " +
                   "SELECT id FROM dept_tree)", deptId))
        .stream()
        .map(SysDept::getId)
        .collect(Collectors.toList());
}

4.3 部门树缓存优化

频繁递归查询数据库性能较差,建议将部门树缓存到 Redis:

/**
 * 获取部门及子部门ID(带缓存)
 */
public List<Long> getDeptAndChildrenIds(Long deptId) {
    String cacheKey = "dept:tree:" + deptId;

    // 先查缓存
    List<Long> cached = redisTemplate.opsForList().range(cacheKey, 0, -1);
    if (cached != null && !cached.isEmpty()) {
        return cached;
    }

    // 查数据库
    List<Long> deptIds = deptMapper.selectDeptAndChildrenIds(deptId);

    // 写入缓存,过期时间1小时
    redisTemplate.opsForList().rightPushAll(cacheKey, deptIds);
    redisTemplate.expire(cacheKey, 1, TimeUnit.HOURS);

    return deptIds;
}

五、数据范围处理流程总览

flowchart TD
    A[用户请求] --> B[获取当前用户角色]
    B --> C{角色dataScope?}
    C -->|1-全部数据| D[不追加条件]
    C -->|2-本部门| E[追加: dept_id = 用户部门ID]
    C -->|3-本部门及下级| F[递归查询子部门ID]
    F --> G[追加: dept_id IN 部门ID列表]
    C -->|4-仅本人| H[追加: create_by = 用户ID]
    D --> I[执行SQL返回结果]
    E --> I
    G --> I
    H --> I

    style D fill:#c8e6c9,stroke:#2e7d32
    style E fill:#fff9c4,stroke:#f9a825
    style G fill:#ffe0b2,stroke:#ef6c00
    style H fill:#ffcdd2,stroke:#c62828

结论与建议

核心要点

  1. 数据范围是 RBAC 的进阶能力:功能权限解决"能不能做",数据权限解决"能看到什么"
  2. 拦截器方式零侵入:业务代码无需关心数据过滤,由拦截器统一处理
  3. JSqlParser 优于字符串拼接:生产环境务必使用 SQL 解析库,避免注入风险和语法错误
  4. 部门树缓存必不可少:递归查询是性能瓶颈,Redis 缓存可大幅提升响应速度

扩展建议

相关资源