摘要

在后台管理系统中,数据权限不是简单的“列表查询过滤”。真正的问题往往出现在按 ID 查询、编辑、删除、批量操作等场景中:用户在列表里看不到某条数据,但如果知道 ID,是否还能直接操作?

本文基于若依 RuoYi-VueGeek-XD 的源码,对两者的数据权限实现进行对比。若依采用 @DataScope + BaseEntity.params + XML 占位 的方式实现数据权限;Geek-XD 则基于 MyBatis-Flex 方言扩展,将数据权限条件注入到 QueryWrapper/SQL 生成阶段。

从工程可靠性角度看,Geek-XD 的方案更靠近 ORM 层统一治理,能够降低业务代码遗漏权限校验的概率。

目录

一、数据权限真正要解决什么问题

在后台系统里,菜单权限、按钮权限、接口权限通常比较容易理解:

  • 用户有没有进入某个菜单的权限;
  • 用户有没有点击新增、修改、删除按钮的权限;
  • 用户有没有访问某个接口的权限。

但数据权限更细。

它关注的是:同一个接口、同一张表、同一类业务数据,不同用户能看到和操作的数据范围是否不同。

例如:

  • 总部管理员可以查看所有部门用户;
  • 部门经理只能查看本部门及下级部门用户;
  • 普通员工只能查看自己相关的数据;
  • 自定义角色只能查看被授权的部门数据。

如果数据权限只在列表查询时生效,而删除、修改、详情接口没有同样的约束,就会出现一个典型风险:

页面列表里看不到的数据,可能通过 ID 直接操作。

所以,一个更可靠的数据权限方案,不应该只关注列表查询,还应该尽量覆盖按 ID 查询、编辑、删除、批量操作等场景。

二、若依的数据权限实现方式

若依的数据权限核心由两部分组成:

  • @DataScope 注解;
  • DataScopeAspect 切面。

典型用法如下:

@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user) {
    return userMapper.selectUserList(user);
}

切面会在方法执行前,根据当前登录用户、角色、部门范围拼接一段 SQL 条件。

简化后的切面入口如下:

@Before("@annotation(controllerDataScope)")
public void doBefore(JoinPoint point, DataScope controllerDataScope) {
    clearDataScope(point);
    handleDataScope(point, controllerDataScope);
}

真正写入数据权限 SQL 的地方,是方法第一个参数:

Object params = joinPoint.getArgs()[0];

if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
    BaseEntity baseEntity = (BaseEntity) params;
    baseEntity.getParams().put(
        DATA_SCOPE,
        " AND (" + sqlString.substring(4) + ")"
    );
}

也就是说,若依的数据权限 SQL 最终被放到了:

BaseEntity.params.dataScope

BaseEntity 中提供了 params 字段,用于承载这类扩展参数:

public class BaseEntity implements Serializable {
    private Map<String, Object> params;

    public Map<String, Object> getParams() {
        if (params == null) {
            params = new HashMap<>();
        }
        return params;
    }
}

随后,MyBatis XML 中需要手动写入 ${params.dataScope}

<select id="selectUserList" parameterType="SysUser" resultMap="SysUserResult">
    select u.user_id, u.dept_id, u.user_name, d.dept_name
    from sys_user u
    left join sys_dept d on u.dept_id = d.dept_id
    where u.del_flag = '0'

    <!-- 数据范围过滤 -->
    ${params.dataScope}
</select>

这个方案的链路可以概括为:

@DataScope
    -> DataScopeAspect 拼接 SQL
    -> 写入第一个参数的 BaseEntity.params.dataScope
    -> XML 中通过 ${params.dataScope} 拼接
    -> 最终 SQL 生效

它的优点是简单、直观,适合传统 MyBatis XML 项目。

但它也有明显的结构性限制。

三、若依方案的边界问题

若依的数据权限要自然生效,通常需要同时满足几个条件:

  1. 方法上加了 @DataScope
  2. 方法第一个参数是 BaseEntity 子类;
  3. XML SQL 中写了 ${params.dataScope}
  4. 业务调用路径确实经过了这个带注解的方法。

在列表查询场景中,这通常没有问题。

例如:

@DataScope(deptAlias = "d", userAlias = "u")
public List<SysUser> selectUserList(SysUser user) {
    return userMapper.selectUserList(user);
}

因为 SysUser 继承了 BaseEntity,可以承载 params.dataScope

但是对于按 ID 删除,问题就出现了。

例如:

public int deleteUserById(Long userId) {
    return userMapper.deleteUserById(userId);
}

这里的参数是 Long,不是 BaseEntity

即使方法上加了 @DataScope,切面也没有地方写入 params.dataScope

对应 XML 往往是这样的:

<delete id="deleteUserById" parameterType="Long">
    update sys_user
    set del_flag = '2'
    where user_id = #{userId}
</delete>

这条 SQL 本身没有数据权限条件。

当然,若依并不是完全不处理这类问题。它通常会在业务层增加额外校验,例如:

public void checkUserDataScope(Long userId) {
    if (!SecurityUtils.isAdmin()) {
        SysUser user = new SysUser();
        user.setUserId(userId);

        List<SysUser> users =
            SpringUtils.getAopProxy(this).selectUserList(user);

        if (StringUtils.isEmpty(users)) {
            throw new ServiceException("没有权限访问用户数据");
        }
    }
}

这个方法的思路是:

  1. 先构造一个 SysUser
  2. 设置需要校验的 userId
  3. 再调用带 @DataScope 的列表查询;
  4. 如果查询不到,说明当前用户无权访问该数据。

这个兜底方案是可用的,但它属于业务层主动补救。

也就是说,若依在按 ID 删除、按 ID 修改、批量操作等场景中,依赖开发者记得手动调用:

checkUserDataScope(userId);
checkRoleDataScope(roleId);
checkDeptDataScope(deptId);

如果新增模块、新接口或新删除逻辑忘了调用,数据权限就可能被绕过。

因此,若依方案的核心问题并不是“不能处理 ID 删除”,而是处理方式比较分散:

  • 注解负责触发;
  • BaseEntity.params 负责传递 SQL;
  • XML 负责拼接 SQL;
  • ID 场景依赖额外 checkXXXDataScope
  • 最终是否完整,依赖开发者是否记得所有约定。

对于大型项目来说,这种模式的遗漏风险会比较高。

四、Geek-XD 的实现思路

Geek-XD 保留了 @DataScope 注解的使用习惯,但真正关键的改进在于:

数据权限不再只依赖 BaseEntity.params 和 XML 手工拼接,而是通过 MyBatis-Flex 方言,在 SQL 生成阶段统一注入。

Geek-XD 中仍然可以这样使用数据权限注解:

@Override
@DataScope(deptAlias = "d", userAlias = "u")
public Page<SysUser> page(SysUser user, int pageNum, int pageSize) {
    Page<SysUser> pg = Page.of(pageNum, pageSize);
    pg.setOptimizeCountQuery(false);
    return selectUserList(user).page(pg);
}

查询则大量使用 MyBatis-Flex 的链式 API:

public QueryChain<SysUser> selectUserList(SysUser user) {
    QueryChain<SysUser> queryChain = this.queryChain()
        .select(SYS_USER.DEFAULT_COLUMNS, SYS_DEPT.DEPT_NAME, SYS_DEPT.LEADER)
        .from(SysUser.class).as("u")
        .leftJoin(SysDept.class).as("d")
        .on(SysUser::getDeptId, SysDept::getDeptId)
        .eq(SysUser::getUserId, user.getUserId())
        .like(SysUser::getUserName, user.getUserName())
        .eq(SysUser::getStatus, user.getStatus())
        .like(SysUser::getPhonenumber, user.getPhonenumber())
        .ge(SysUser::getCreateTime, user.getParams().get("beginTime"))
        .le(SysUser::getCreateTime, user.getParams().get("endTime"));

    return queryChain;
}

如果仍然依赖 XML 中的 ${params.dataScope},这种链式查询模式就很难优雅地统一处理数据权限。

Geek-XD 的做法是:切面仍然负责计算当前用户的数据范围 SQL,但不再只把它塞进实体参数中,而是放入当前线程上下文。

核心代码如下:

if (StringUtils.isNotBlank(sqlString.toString())) {
    if (joinPoint.getArgs().length != 0) {
        Object params = joinPoint.getArgs()[0];

        if (StringUtils.isNotNull(params) && params instanceof BaseEntity) {
            BaseEntity baseEntity = (BaseEntity) params;
            baseEntity.getParams().put(
                DATA_SCOPE,
                " AND (" + sqlString.substring(4) + ")"
            );
        }
    }

    DataScopeContextHolder.use("(" + sqlString.substring(4) + ")");
}

这里最关键的是:

DataScopeContextHolder.use(...)

DataScopeContextHolder 本质上是一个 ThreadLocal 栈:

public class DataScopeContextHolder {
    private static ThreadLocal<Deque<String>> lookup =
        ThreadLocal.withInitial(ArrayDeque::new);

    private DataScopeContextHolder() {
    }

    public static void use(String dataScope) {
        lookup.get().push(dataScope);
    }

    public static String get() {
        Deque<String> deque = lookup.get();
        return deque.peek();
    }

    public static void clear() {
        Deque<String> deque = lookup.get();
        if (!deque.isEmpty()) {
            deque.pop();
            if (deque.isEmpty()) {
                lookup.remove();
            }
        }
    }

    public static void forceClear() {
        lookup.remove();
    }
}

方法执行结束后,切面会清理上下文:

@After(value = "@annotation(controllerDataScope)")
public void doAfter(final JoinPoint point, DataScope controllerDataScope) {
    DataScopeContextHolder.clear();
}

这样,数据权限条件就不再必须挂在某个实体参数上,而是跟随当前业务调用线程存在。

五、核心改造:MyBatis-Flex 方言注入

Geek-XD 最核心的设计在 DataScopeDialectImpl

它扩展了 MyBatis-Flex 的方言能力,在 SQL 生成阶段读取 DataScopeContextHolder 中的数据权限 SQL,然后统一追加到 QueryWrapper 或 SQL 片段中。

针对 QueryWrapper 的处理如下:

private static void prepareAuth(
        QueryWrapper queryWrapper,
        OperateType operateType
) {
    String dataScopeSql = DataScopeContextHolder.get();

    if (StringUtils.isEmpty(dataScopeSql)) {
        return;
    }

    List<QueryTable> queryTables = CPI.getQueryTables(queryWrapper);

    if (queryTables == null || queryTables.isEmpty()) {
        return;
    }

    QueryTable t = queryTables.get(0);
    if (queryTables.size() == 1
            && "t".equals(t.getAlias())
            && t.getName() == null
            && t.getSchema() == null) {
        QueryWrapper child = CPI.getChildSelect(queryWrapper).get(0);
        child.and(dataScopeSql);
    } else {
        queryWrapper.and(dataScopeSql);
    }
}

针对普通 SQL 构建的处理如下:

private static void prepareAuth(
        String schema,
        String tableName,
        StringBuilder sql,
        OperateType operateType
) {
    String dataScopeSql = DataScopeContextHolder.get();

    if (StringUtils.isNotEmpty(dataScopeSql)) {
        sql.append(AND).append(dataScopeSql);
    }
}

针对表信息构建的处理如下:

private static void prepareAuth(
        TableInfo tableInfo,
        StringBuilder sql,
        OperateType operateType
) {
    String dataScopeSql = DataScopeContextHolder.get();

    if (StringUtils.isNotEmpty(dataScopeSql)) {
        sql.append(AND).append(dataScopeSql);
    }
}

也就是说,只要当前线程中存在数据权限上下文,MyBatis-Flex 在生成 SQL 时就可以自动把数据权限条件加进去。

Geek-XD 还在 MyBatis-Flex 配置中为所有数据库类型注册自定义方言:

@Bean
public MyBatisFlexCustomizer myBatisFlexCustomizer(KeyConfig keyConfig) {
    return new MyBatisFlexCustomizer() {
        @Override
        public void customize(FlexGlobalConfig flexGlobalConfig) {
            DbType.all().forEach(dbtype -> {
                DialectFactory.registerDialect(
                    dbtype,
                    DataScopeDialectImpl.createDialect(dbtype)
                );
            });

            BaseEntityListener baseEntityListener = new BaseEntityListener();
            flexGlobalConfig.registerInsertListener(baseEntityListener, BaseEntity.class);
            flexGlobalConfig.registerUpdateListener(baseEntityListener, BaseEntity.class);
            flexGlobalConfig.setLogicDeleteColumn("del_flag");
            flexGlobalConfig.setTenantColumn("tenant_id");
            flexGlobalConfig.setKeyConfig(keyConfig);
        }
    };
}

这一步非常关键。

若依的数据权限是在“业务参数 + XML 占位”层面完成的。

Geek-XD 的数据权限则进入了“ORM SQL 生成”层面。

六、为什么 Geek-XD 对按 ID 操作更友好

按 ID 查询、删除、批量删除这类操作,参数通常是:

Long id
List<Long> ids
Long[] ids

这些参数都不是 BaseEntity

在若依模式下,@DataScope 没有办法自然地把权限 SQL 放进这些参数里,所以必须额外写:

checkUserDataScope(userId);
checkRoleDataScope(roleId);
checkDeptDataScope(deptId);

这依赖业务开发者每次都记得调用。

而 Geek-XD 的优势是:权限 SQL 不再依赖方法参数承载,而是放入当前线程上下文,并在 MyBatis-Flex 生成 SQL 时统一注入。

例如 Geek-XD 中可以这样写:

@Override
@DataScope(deptAlias = "d", userAlias = "u")
public void checkUserDataScope(Long userId) {
    if (!SecurityUtils.isAdmin()) {
        SysUser user = new SysUser();
        user.setUserId(userId);

        List<SysUser> users = selectUserList(user).list();

        if (StringUtils.isEmpty(users)) {
            throw new ServiceException("没有权限访问用户数据");
        }
    }
}

注意,这个方法的参数是 Long userId

如果是传统若依模式,Long 本身无法承载 params.dataScope

但在 Geek-XD 中,@DataScope 建立的是 ThreadLocal 权限上下文。后续 selectUserList(user).list() 走 MyBatis-Flex QueryWrapper 时,方言层会自动追加数据权限条件。

这就把数据权限从:

参数能不能带上 SQL

提升到了:

当前 ORM SQL 是否处于权限上下文中

这也是 Geek-XD 方案在架构上更可靠的地方。

七、两种方案对比

对比项 若依方案 Geek-XD 方案
权限触发方式 @DataScope 注解 @DataScope 注解
权限 SQL 载体 BaseEntity.params.dataScope DataScopeContextHolder 线程上下文
SQL 生效方式 XML 中手写 ${params.dataScope} MyBatis-Flex 方言 prepareAuth 统一注入
对方法参数要求 第一个参数最好是 BaseEntity 子类 对参数形态更宽容
按 ID 删除 通常依赖额外 checkXXXDataScope 可通过上下文 + ORM SQL 生成链路统一处理
链式查询支持 不天然适配 适配 QueryWrapper、QueryChain
遗漏风险 注解、参数、XML、手工 check 都要记得 主要收敛到注解和 ORM 受控链路
框架闭环程度 偏业务层约定 偏 ORM 层统一治理

从表中可以看出,两者不是简单的“有没有数据权限”的区别,而是数据权限放在哪一层治理的问题。

若依更偏向业务层和 XML 层配合。

Geek-XD 更偏向 ORM 层统一注入。

八、Geek-XD 方案的边界

客观来说,Geek-XD 的方案也不是“所有 SQL 自动安全”。

它仍然有几个前提:

  1. 业务方法需要正确标注 @DataScope
  2. SQL 执行路径最好走 MyBatis-Flex 的 QueryWrapper、QueryChain、Service API 等受控链路;
  3. 如果绕过 MyBatis-Flex,直接使用原生 JDBC 或完全手写 SQL,仍然需要单独治理;
  4. 复杂 SQL 的表别名需要和 @DataScope(deptAlias = "d", userAlias = "u") 保持一致。

所以,Geek-XD 的优势不是“魔法式解决所有权限问题”,而是它把数据权限的默认生效位置下沉到了 ORM 层,减少了业务代码手工拼接和手工检查的比例。

这对后台管理框架非常有价值。

九、总结

若依的数据权限实现简单、清晰,适合传统 MyBatis XML 项目。

它通过 @DataScope 生成 SQL 片段,再通过 ${params.dataScope} 拼接到 XML 中。这个方案在列表查询场景中很直观,但它的限制也很明显:

  • 数据权限依赖 BaseEntity.params
  • SQL 需要手动写 ${params.dataScope}
  • 按 ID 删除、按 ID 修改、批量操作等场景需要额外业务校验;
  • 最终是否完整,依赖开发者是否遵守所有约定。

Geek-XD 在保留 @DataScope 使用习惯的基础上,结合 MyBatis-Flex 的方言扩展,把数据权限条件放入 ThreadLocal 上下文,并在 QueryWrapper/SQL 生成阶段统一注入。

这使得 Geek-XD 的数据权限方案具备更强的框架闭环能力:

  • 不再强依赖方法第一个参数是 BaseEntity
  • 不再强依赖 XML ${params.dataScope}
  • 更适合 QueryWrapper/QueryChain 链式查询;
  • 对按 ID 操作、批量操作等场景更友好;
  • 更能降低业务开发者遗漏权限校验的风险。

因此,从源码实现和工程可靠性角度看,Geek-XD 在数据权限这一块的设计,比传统若依模式更进一步。

它不是简单地“换了一个 ORM”,而是借助 MyBatis-Flex 的方言扩展,把数据权限从业务层约定推进到了 ORM 层统一治理。

对于后台管理系统中最容易出问题的“列表看不到,但可能通过 ID 操作越权”的场景,Geek-XD 的方案明显更接近完整闭环。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐