数据权限为何需要置于Mapper XML中:DataScope拦截器的架构权衡

作者:袖梨 2026-05-28

Forge Admin的数据权限模块通过forge-starter-datascope实现多维度的数据隔离,本文将深入解析其在Mapper XML层的技术实现与工程考量。


img_6a181bafe9e3130.webp

1. 这个问题在企业后台里为什么常见

企业后台系统常面临多角色数据隔离需求:

  1. 部门经理:仅可查看本部门数据
  2. 区域总监:可访问区域内所有部门数据
  3. 超级管理员:具备全公司数据访问权限
  4. 跨组织协作:需特殊授权才能访问其他部门项目数据

传统实现方式存在三大典型方案:

方案一:Controller 层硬编码(最差实践)

@GetMapping("/orders")
public Result> getOrders() {
    Long userId = getCurrentUserId();
    User user = userService.getById(userId);
    
    List orders;
    if (user.isSuperAdmin()) {
        orders = orderService.list();  // 全部数据
    } else if (user.isDeptManager()) {
        orders = orderService.listByDept(user.getDeptId());  // 本部门
    } else {
        orders = orderService.listByUser(userId);  // 个人数据
    }
    
    return Result.ok(orders);
}

缺陷:权限逻辑重复分散,接口维护成本高。

方案二:Service 层统一处理(中等实践)

@Service
public class OrderService {
    
    public List<Order> list() {
        QueryWrapper<Order> wrapper = new QueryWrapper<>();
        
        // 权限判断逻辑
        DataScopeContext context = getDataScopeContext();
        if (context.isDeptScope()) {
            wrapper.eq("dept_id", context.getDeptId());
        } else if (context.isUserScope()) {
            wrapper.eq("create_by", context.getUserId());
        }
        // ... 其他条件
        
        return orderMapper.selectList(wrapper);
    }
}

缺陷:SQL与业务代码强耦合,复杂查询处理困难。

方案三:AOP 或拦截器(当前方案)

// 只需一个注解
@GetMapping("/orders")
@DataScope(deptAlias = "o", deptField = "dept_id")
public Result> getOrders(PageParam param) {
    // 业务代码完全不用关心权限
    return Result.ok(orderService.page(param));
}

挑战:需解决SQL自动改写、分页兼容等技术难点。


2. Forge Admin 是怎么解决的

forge-starter-datascope采用MyBatis拦截器机制,在SQL执行前自动追加权限过滤条件。

2.1 整体架构

用户请求 → Controller → Service → Mapper
                            ↓
                   MybatisPlusInterceptor
                            ↓
              ┌─────────────┼──────────────┐
              │             │               │
        DataScope     TenantLine     Pagination
       Interceptor   Interceptor    Interceptor
              │
              ├→ 1. 检查是否需要跳过权限
              ├→ 2. 查询当前用户的数据权限上下文
              ├→ 3. 获取当前 Mapper 方法的权限配置
              ├→ 4. 根据权限类型构建 SQL 条件
              └→ 5. 使用 JSQLParser 改写原 SQL

2.2 七种数据权限范围

模块预置七种标准权限类型:

权限类型代码说明SQL 条件示例
ALL1全部数据无附加条件
SELF2个人数据user_id = 123
ORG3本组织org_id IN (101, 102)
ORG_AND_CHILD4本组织及子组织org_id IN (101, 102, 103, 104)
CUSTOM5自定义组织org_id IN (自定义组织ID列表)
TENANT_ALL6本租户tenant_id = 1001
REGION7本行政区划area_code = '440300' OR area_code IN (子区划)

2.3 核心模块组成

forge-starter-datascope/
├── config/
│   ├── DataScopeAutoConfiguration.java  # 自动配置
│   ├── DataScopeProperties.java         # 配置属性
│   └── DataScopeIgnore.java             # @DataScopeIgnore 注解
├── interceptor/
│   └── DataScopeInterceptor.java        # 核心拦截器
├── context/
│   ├── DataScopeContext.java            # 权限上下文
│   └── DataScopeContextHolder.java      # 上下文持有者
├── service/
│   ├── IDataScopeService.java           # 服务接口
│   └── impl/DataScopeServiceImpl.java   # 服务实现
├── enums/
│   └── DataScopeType.java               # 权限类型枚举
└── entity/                              # 数据库实体
    ├── SysDataScopeConfig.java          # Mapper 权限配置
    └── SysRoleDataScope.java            # 角色-自定义组织关联

3. 核心数据结构与配置协议

3.1 权限配置表(sys_data_scope_config)

采用Mapper方法级配置策略:

CREATE TABLE sys_data_scope_config (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    mapper_id VARCHAR(500) NOT NULL COMMENT 'Mapper方法全限定名',
    table_alias VARCHAR(50) COMMENT '表别名',
    user_id_column VARCHAR(50) COMMENT '用户ID字段名',
    org_id_column VARCHAR(50) COMMENT '组织ID字段名',
    tenant_id_column VARCHAR(50) COMMENT '租户ID字段名',
    region_code_column VARCHAR(50) COMMENT '行政区划代码字段名',
    enabled TINYINT DEFAULT 1 COMMENT '是否启用',
    UNIQUE KEY uk_mapper (mapper_id)
);

典型配置示例

mapper_idtable_aliasorg_id_column说明
com.example.mapper.OrderMapper.selectListodept_id订单列表按部门过滤
com.example.mapper.UserMapper.selectPageuorg_id用户列表按组织过滤
com.example.mapper.ReportMapper.getRegionStatsrarea_code报表按行政区划过滤

3.2 权限上下文(DataScopeContext)

拦截器通过该对象获取用户权限信息:

public class DataScopeContext {
    private Long userId;           // 当前用户ID
    private List<Long> orgIds;     // 用户所属组织ID列表
    private List<Long> roleIds;    // 用户角色ID列表
    private Integer minDataScope;  // 最小数据权限值(值越小权限越大)
    private Set<Long> customOrgIds; // 自定义组织ID集合
    private Long tenantId;         // 租户ID
    private String regionCode;     // 行政区划代码
    private Integer regionLevel;   // 行政区划级别
    private String regionAncestors; // 行政区划祖先路径
}

3.3 权限计算规则

核心规则:多角色时取最小data_scope值

-- 用户角色表 sys_user_role
user_id | role_id
--------|--------
1001    | 1      -- 角色1: data_scope = 3 (本组织)
1001    | 2      -- 角色2: data_scope = 4 (本组织及子组织)-- 最终权限: MIN(3, 4) = 3 (本组织)

特殊处理:data_scope=5时需检查自定义组织配置

// DataScopeType.getByRoleDataScope()
public static DataScopeType getByRoleDataScope(Integer code, boolean hasCustomOrgIds) {
    return switch (code) {
        case 5 -> hasCustomOrgIds ? CUSTOM : SELF;  // 关键兼容点
        // ... 其他 case
    };
}

4. 核心实现链路

4.1 第一步:拦截器入口

@Component
public class DataScopeInterceptor implements InnerInterceptor {
    
    @Override
    public void beforeQuery(Executor executor, MappedStatement ms, 
                          Object parameter, RowBounds rowBounds, 
                          ResultHandler resultHandler, BoundSql boundSql) {
        
        // 1. 检查跳过标记(后台任务等场景)
        if (DataScopeContextHolder.isSkip()) {
            return;
        }
        
        // 2. 获取当前 Mapper 方法ID
        String mapperId = ms.getId();
        
        // 3. 处理分页 Count 查询(去掉 _mpCount 后缀)
        if (mapperId.endsWith("_mpCount")) {
            mapperId = mapperId.replace("_mpCount", "");
        }
        
        // 4. 查询权限配置(带缓存)
        SysDataScopeConfig config = dataScopeService.getDataScopeConfig(mapperId);
        if (config == null || config.getEnabled() == 0) {
            return;  // 未配置或已禁用
        }
        
        // 5. 获取用户权限上下文
        DataScopeContext context = dataScopeService.getCurrentUserDataScope();
        if (context == null) {
            return;  // 未登录或后台任务
        }
        
        // 6. 确定权限类型
        DataScopeType scopeType = DataScopeType.getByRoleDataScope(
            context.getMinDataScope(),
            !CollectionUtils.isEmpty(context.getCustomOrgIds())
        );
        
        // 7. 根据权限类型改写 SQL
        String originalSql = boundSql.getSql();
        String modifiedSql = buildDataScopeSql(originalSql, config, context, scopeType);
        
        // 8. 替换 BoundSql 中的 SQL
        PLUGIN_UTILS.MPBoundSql mpBoundSql = PLUGIN_UTILS.mpBoundSql(boundSql);
        mpBoundSql.sql(modifiedSql);
    }
}

4.2 第二步:SQL 改写引擎

基于JSQLParser实现SQL解析与改写:

private String buildDataScopeSql(String originalSql, SysDataScopeConfig config,
                               DataScopeContext context, DataScopeType scopeType) {
    
    // 1. 解析 SQL 为抽象语法树(AST)
    Statement statement = CCJSqlParserUtil.parse(originalSql);
    if (!(statement instanceof Select)) {
        return originalSql;  // 只处理 SELECT 查询
    }
    
    // 2. 获取 SELECT 主体
    Select select = (Select) statement;
    PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
    
    // 3. 构建权限条件表达式
    Expression dataScopeCondition = buildDataScopeCondition(config, context, scopeType);
    
    // 4. 追加到 WHERE 子句
    Expression where = plainSelect.getWhere();
    if (where != null) {
        // 原 WHERE 条件 AND 权限条件
        plainSelect.setWhere(new AndExpression(where, dataScopeCondition));
    } else {
        plainSelect.setWhere(dataScopeCondition);
    }
    
    // 5. 序列化回 SQL 字符串
    return select.toString();
}

4.3 第三步:条件构建策略

按权限类型生成对应SQL条件:

private Expression buildDataScopeCondition(SysDataScopeConfig config,
                                         DataScopeContext context,
                                         DataScopeType scopeType) {
    switch (scopeType) {
        case SELF:
            // user_id = 123
            return buildSimpleCondition(config.getTableAlias(), 
                                      config.getUserIdColumn(), 
                                      context.getUserId());
            
        case ORG:
            // org_id IN (101, 102)
            return buildInCondition(config.getTableAlias(),
                                  config.getOrgIdColumn(),
                                  context.getOrgIds());
            
        case ORG_AND_CHILD:
            // org_id IN (101, 102, 103, 104...) 包含所有子孙组织
            List allOrgIds = expandOrgTree(context.getOrgIds());
            return buildInCondition(config.getTableAlias(),
                                  config.getOrgIdColumn(),
                                  allOrgIds);
            
        case CUSTOM:
            // org_id IN (自定义组织列表)
            return buildInCondition(config.getTableAlias(),
                                  config.getOrgIdColumn(),
                                  context.getCustomOrgIds());
            
        case TENANT_ALL:
            // tenant_id = 1001
            return buildSimpleCondition(config.getTableAlias(),
                                      config.getTenantIdColumn(),
                                      context.getTenantId());
            
        case REGION:
            // area_code = '440300' OR area_code IN (子区划)
            return buildRegionCondition(config, context);
            
        case ALL:
        default:
            return null;  // 无附加条件
    }
}

4.4 第四步:复杂场景处理

场景一:JOIN 查询

-- 原始 SQL
SELECT o.*, u.name 
FROM t_order o 
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 'ACTIVE'-- 配置: table_alias = "o", org_id_column = "dept_id"
-- 用户权限: 部门经理,只能看部门101的数据-- 改写后 SQL
SELECT o.*, u.name 
FROM t_order o 
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.status = 'ACTIVE' 
  AND o.dept_id = 101  -- 自动追加

场景二:子查询

-- 原始 SQL
SELECT * FROM t_order 
WHERE id IN (
    SELECT order_id FROM t_order_item WHERE price > 100
)-- 改写后 (SELF 权限)
SELECT * FROM t_order o
WHERE o.create_by = 12345  -- 自动追加
  AND id IN (
    SELECT order_id FROM t_order_item WHERE price > 100
  )

场景三:分页查询

自动处理MyBatis-Plus分页插件的Count查询:

// 原始方法: OrderMapper.selectPage
// 生成的 Count 方法: OrderMapper.selectPage_mpCount// 拦截器处理
if (mapperId.endsWith("_mpCount")) {
    actualMapperId = mapperId.replace("_mpCount", "");  // 还原为原始方法
}
// 使用相同的配置进行权限过滤,确保分页统计准确

5. 关键工程取舍

5.1 为什么选择 Mapper XML 层?

三大实现层面对比:

实现层优点缺点Forge Admin 的选择
Controller 层业务语义清晰1. 代码重复 2. 易遗漏 3. 难以统一维护 否决
Service 层逻辑集中,可复用1. SQL 与业务耦合 2. 复杂查询难处理 3. 分页统计困难 否决
Mapper XML 层1. SQL 透明化 2. 统一入口 3. 与业务解耦 4. 自动处理分页1. 配置复杂 2. 调试困难 3. SQL 兼容性问题 选择

选择依据

  1. 透明化:业务代码无需感知权限逻辑
  2. 统一性:确保所有数据访问都经过权限检查
  3. 兼容性:与MyBatis-Plus生态无缝集成
  4. 性能:通过优化使性能开销最小化

5.2 性能优化策略

两级缓存机制

// 一级缓存:权限配置(30分钟)
private final Cache configCache = 
    Caffeine.newBuilder()
        .expireAfterWrite(30, TimeUnit.MINUTES)
        .maximumSize(1000)
        .build();// 二级缓存:组织树展开结果(10分钟)
private final Cache> orgChildCache = 
    Caffeine.newBuilder()
        

相关文章

精彩推荐