若依框架数据权限实战:基于MyBatis-Plus插件实现行级数据隔离

若依框架数据权限实战:基于MyBatis-Plus插件实现行级数据隔离 1. 项目概述为什么若依的权限与数据隔离是项目基石如果你正在用若依RuoYi框架开发一个企业级应用无论是OA、CRM还是巡检系统那么“权限管理”和“数据隔离”这两个词一定是你绕不开的核心议题。这不仅仅是后台管理系统的标配功能更是项目能否安全、稳定上线的生命线。我见过太多项目初期功能跑得飞快一到权限细化和数据安全层面就漏洞百出要么是超级管理员能看到所有人的敏感数据要么是普通用户越权操作了不该碰的功能最终只能推倒重来耗时耗力。若依框架本身提供了一个非常经典的RBAC基于角色的访问控制权限模型开箱即用这省去了我们从头造轮子的麻烦。但是框架给的往往是“通用解”而我们的业务场景千差万别。比如一个多租户的SaaS平台如何确保A公司的数据绝对不会泄露给B公司一个大型集团内部如何让上海分公司的人只能看到上海的数据而北京分公司的人只能看到北京的数据这就是“数据隔离”要解决的问题它比单纯的“菜单权限”控制要深入得多直接关系到数据层面的安全。所以今天这篇内容我想从一个多年一线开发者的角度带你彻底吃透若依框架下的权限管理与数据隔离。我不会只讲配置文件怎么改而是会深入到底层SQL是如何拼接的、权限注解PreAuthorize背后的逻辑、以及如何根据你的业务场景设计出既灵活又安全的数据隔离方案。目标很明确让你不仅能配置出来更能理解为什么这么配置遇到奇葩需求时知道如何灵活变通。2. 核心思路拆解从RBAC到数据行级过滤若依的权限体系是一个典型的多层结构理解这个结构是进行一切定制开发的前提。很多人卡在数据隔离上根本原因是对权限体系的流转链路不清晰。2.1 若依RBAC权限模型深度解析若依的权限控制可以概括为“用户-角色-菜单-权限”四层模型。这听起来简单但里面的门道很多。用户与角色一个用户可以拥有多个角色这是多对多的关系。角色是一组权限的集合比如“部门经理”、“项目专员”。角色与菜单角色关联到具体的菜单或按钮。在若依里菜单不仅指导航栏上的条目一个前端的路由、一个按钮都可以抽象为一个“权限标识符”通常对应perms字段。例如system:user:query代表“查询用户”的权限。权限标识符与后端接口这是关键的一环。前端的按钮是否显示由perms控制。而后端接口Controller的方法是否允许访问则由Spring Security的PreAuthorize注解控制其值通常为PreAuthorize(“hasPermi(‘system:user:list’)”)。前后端的权限标识需要严格对应。这个模型的优势在于灵活。通过给用户分配不同的角色组合可以快速构建出复杂的权限矩阵。但它的默认能力止步于“功能权限”即“你能访问哪个菜单、点击哪个按钮”。至于你点击后查出来的数据是所有数据还是部分数据框架默认是不管的。这就是我们需要介入的地方——将权限控制延伸到数据层面。2.2 数据隔离的常见场景与设计思路数据隔离不是一种技术而是一种设计模式。根据业务场景主要有以下几种思路基于数据创建者Creator-Based最简单也最常用。用户只能操作增删改查自己创建的数据。实现方式是在每张业务表添加一个create_by字段若依的代码生成器默认会加然后在查询时自动追加where create_by #{当前用户ID}。这适用于任务、日志、个人笔记等场景。基于组织架构Organization-Based这是企业应用中最普遍的需求。用户只能看到其所属部门及子部门下的数据。这需要你的业务表中有dept_id部门ID字段。查询时需要计算当前用户所属部门的所有子部门ID列表然后拼接where dept_id in (子部门ID列表)。若依的部门表设计通常支持父子层级parent_id。多租户隔离Tenant-BasedSaaS系统的核心。每个租户公司的数据完全物理或逻辑隔离。通常会在每张表加一个tenant_id字段。所有查询都必须带上where tenant_id #{当前租户ID}。这里的关键是如何在用户登录时优雅地确定并传递tenant_id。混合模式现实项目往往是混合的。例如一个集团系统先按租户子公司隔离子公司内部再按部门隔离部门内的某些数据又需要按创建者隔离。我们的核心任务就是要在若依框架的权限校验链路中找到一个合适的切面Aspect在SQL执行前自动、无感地为我们拼接上这些数据过滤条件。MyBatis-Plus的数据权限插件正是为此而生也是若依官方推荐和集成的方式。注意选择哪种隔离方案必须在项目设计初期就确定下来并达成团队共识。中途修改数据隔离维度相当于对数据库查询进行了一次全局重构成本和风险极高。3. 核心工具与前置准备MyBatis-Plus数据权限插件在开始动手前我们需要确保若依项目已经正确集成了MyBatis-Plus并且理解其数据权限插件的工作原理。若依前后端分离版本默认已集成。3.1 插件原理与核心接口MyBatis-Plus的数据权限插件DataPermissionInterceptor是一个拦截器。它会在你执行SQL查询时进行拦截动态地向SQL语句的WHERE条件后追加额外的过滤条件。它的核心是DataPermissionHandler接口。我们需要自定义一个实现类在其中编写逻辑来判断当前这次查询是否需要加数据权限如果需要加什么条件这个条件如何拼接到SQL中若依框架已经提供了一个默认的实现PlusDataPermissionHandler但它通常只处理基于部门的简单隔离。对于复杂的业务场景我们必须进行自定义扩展。3.2 环境确认与依赖检查首先打开你的若依项目这里以Spring Boot单体版为例检查关键配置检查pom.xml确认已有MyBatis-Plus的依赖。dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version !-- 请确认版本与你的若依版本匹配 -- /dependency检查配置类通常在com.ruoyi.framework.config包下会有MybatisPlusConfig类里面已经配置了分页插件和数据权限插件。Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 数据权限插件 interceptor.addInnerInterceptor(dataPermissionInterceptor()); // 分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } Bean public DataPermissionInterceptor dataPermissionInterceptor() { return new DataPermissionInterceptor(new PlusDataPermissionHandler() { // 这里通常可以看到若依默认的数据权限处理逻辑 }); }理解默认逻辑查看PlusDataPermissionHandler你会发现它主要依赖一个DATA_SCOPE枚举和当前用户的“数据权限范围”。这个范围是在用户登录时根据其角色计算好的存储在LoginUser对象中。默认的数据范围包括全部数据、本部门数据、本部门及以下数据、仅本人数据等。实操心得若依默认的数据权限是基于角色的静态范围它和部门强绑定。如果你的数据隔离维度不仅仅是部门比如还有租户、项目等或者你的角色数据范围需要动态计算比如根据用户所属的项目组动态变化那么完全依赖这套默认机制就不够了必须自定义DataPermissionHandler。4. 实战实现基于部门与创建者的混合数据隔离假设我们有一个“巡检任务”模块inspection_task表。需求是普通员工只能查看和操作自己创建的巡检任务。部门经理可以查看本部门及所有子部门下所有员工创建的巡检任务。系统管理员可以看到所有任务。我们的表结构除了业务字段至少需要包含id主键task_name任务名称create_by创建者ID若依默认审计字段dept_id任务所属部门ID需要自己加4.1 第一步自定义DataPermissionHandler这是最核心的一步。我们在com.ruoyi.framework.handler包下创建自定义处理器。package com.ruoyi.framework.handler; import com.baomidou.mybatisplus.extension.plugins.handler.DataPermissionHandler; import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.utils.SecurityUtils; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.LongValue; import net.sf.jsqlparser.expression.operators.relational.EqualsTo; import net.sf.jsqlparser.expression.operators.relational.InExpression; import net.sf.jsqlparser.schema.Column; import org.springframework.stereotype.Component; import java.util.List; /** * 自定义数据权限处理器 * 实现本人数据、本部门及子部门数据、全部数据的过滤 */ Component // 确保能被Spring管理 public class CustomDataPermissionHandler implements DataPermissionHandler { Override public Expression getSqlSegment(Expression where, String mappedStatementId) { // 1. 获取当前登录用户 LoginUser loginUser; try { loginUser SecurityUtils.getLoginUser(); } catch (Exception e) { // 如果获取不到用户例如定时任务、匿名接口则直接返回原条件不进行数据过滤 return where; } // 2. 判断是否需要数据权限过滤 // 通常我们可以通过 mappedStatementId即Mapper方法全限定名来判断哪些查询需要过滤 // 这里为了简化我们假设所有对 inspection_task 表的查询都需要过滤。 // 更精细的控制可以通过注解或配置中心来实现。 if (!mappedStatementId.contains(“InspectionTaskMapper”)) { return where; // 非巡检任务Mapper不处理 } // 3. 判断用户角色/权限决定数据范围 // 这里模拟判断逻辑如果是admin角色返回全部数据即不加额外条件 if (loginUser.getRoles().stream().anyMatch(role - “admin”.equals(role.getRoleKey()))) { return where; } // 4. 构建数据过滤表达式 Expression dataFilterExpression null; // 获取用户的数据权限范围这里从LoginUser中获取该值应在登录时根据角色计算好 // 假设 loginUser 中有个 dataScope 属性其值来自角色关联的数据范围 // 数据范围枚举1全部2本部门及以下3本部门4仅本人 Integer dataScope loginUser.getDataScope(); if (dataScope ! null) { switch (dataScope) { case 1: // 全部数据 dataFilterExpression null; // 为null表示不加条件 break; case 2: // 本部门及以下 // 获取用户所属部门ID Long deptId loginUser.getDeptId(); if (deptId ! null) { // 这里需要查询出该部门及其所有子部门的ID列表 // 假设有一个工具方法 getChildDeptIds(Long deptId) ListLong childDeptIds getChildDeptIds(deptId); childDeptIds.add(deptId); // 包含自身 // 构建 IN 表达式dept_id IN (xxx, yyy, zzz) Column deptColumn new Column(“dept_id”); InExpression inExpression new InExpression(); inExpression.setLeftExpression(deptColumn); inExpression.setRightItemsList(new LongValueList(childDeptIds)); dataFilterExpression inExpression; } break; case 3: // 本部门 Long currentDeptId loginUser.getDeptId(); if (currentDeptId ! null) { // 构建等值表达式dept_id #{currentDeptId} Column deptColumn new Column(“dept_id”); dataFilterExpression new EqualsTo(deptColumn, new LongValue(currentDeptId)); } break; case 4: // 仅本人 default: // 默认按仅本人处理最安全 Long userId loginUser.getUserId(); // 构建等值表达式create_by #{userId} Column createByColumn new Column(“create_by”); dataFilterExpression new EqualsTo(createByColumn, new LongValue(userId)); break; } } else { // 如果没有设置数据范围默认按仅本人处理 Long userId loginUser.getUserId(); Column createByColumn new Column(“create_by”); dataFilterExpression new EqualsTo(createByColumn, new LongValue(userId)); } // 5. 将数据过滤表达式与原始where条件合并AND关系 if (dataFilterExpression ! null) { return where null ? dataFilterExpression : new AndExpression(where, dataFilterExpression); } return where; } // 模拟获取子部门ID列表的方法实际应从数据库或缓存中查询 private ListLong getChildDeptIds(Long deptId) { // 这里需要你实现递归查询逻辑或调用已有的部门服务 // 例如deptService.selectChildrenDeptIds(deptId); // 返回子部门ID列表 return new ArrayList(); // 示例返回空 } }关键点解析mappedStatementId可以用来精确控制哪些Mapper的哪些方法需要被拦截。例如你可以约定所有以select开头的方法需要过滤而selectById可能不需要。Expression操作我们使用jsqlparser这个库来动态构建SQL表达式片段如、IN。这是插件工作的核心。安全兜底在无法获取用户信息或未匹配到规则时最安全的做法是返回“仅本人数据”或一个无法查询到数据的条件如10防止数据泄露。4.2 第二步替换配置类中的处理器修改之前的MybatisPlusConfig使用我们自定义的处理器。Bean public DataPermissionInterceptor dataPermissionInterceptor(CustomDataPermissionHandler customHandler) { // 将自定义的处理器注入到插件中 return new DataPermissionInterceptor(customHandler); }4.3 第三步确保用户登录时计算并缓存数据范围数据范围dataScope需要在用户登录时确定。这通常发生在UserDetailsServiceImpl的loadUserByUsername方法中或者在登录成功后生成LoginUser对象时。你需要根据用户的角色集合计算出他最终的数据权限范围。计算规则通常是取所有角色中数据范围最小的即最严格的。例如用户同时拥有“部门经理”范围本部门及以下和“普通员工”范围仅本人两个角色那么他的有效数据范围应该是更严格的“仅本人”。// 在加载用户信息的服务中 LoginUser loginUser new LoginUser(); // ... 设置用户基本信息、角色、权限等 // 计算数据范围 Integer dataScope calculateDataScope(loginUser.getRoles()); loginUser.setDataScope(dataScope); // 计算逻辑示例 private Integer calculateDataScope(SetRole roles) { if (roles null || roles.isEmpty()) { return 4; // 默认仅本人 } // 假设数据范围值越小权限越大1全部 2本部门及以下 3本部门 4仅本人 // 取数值最小的那个即权限最大的 Integer minScope roles.stream() .map(Role::getDataScope) // 假设Role实体有dataScope字段 .filter(Objects::nonNull) .min(Integer::compareTo) .orElse(4); // 如果没有默认仅本人 return minScope; }4.4 第四步在业务查询中验证效果现在你在任何InspectionTaskMapper的查询方法中都不需要显式地写where create_by xxx或者where dept_id in (...)了。插件会自动追加。例如你的Mapper里只有一个简单的查询Select(“select * from inspection_task where task_status ‘PENDING’”) ListInspectionTask selectPendingTasks();当用户A普通员工dataScope4执行时插件会自动将SQL改写为select * from inspection_task where task_status ‘PENDING’ AND create_by 123当用户B部门经理dataScope2部门ID为5子部门包括6,7执行时SQL会被改写为select * from inspection_task where task_status ‘PENDING’ AND dept_id in (5, 6, 7)实操心得这种方式实现了数据过滤与业务代码的解耦。业务开发人员只需要关心核心业务逻辑无需在每个查询里重复编写权限过滤代码极大减少了出错的可能也使得权限规则变更更加集中和方便。5. 高级进阶实现多租户(Tenant)数据隔离对于SaaS系统多租户隔离是刚需。实现思路与上述类似但更强调全局性和一致性。5.1 表结构改造每张需要隔离的业务表都需要增加一个tenant_id字段VARCHAR或BIGINT类型。通常租户ID会在用户注册或创建公司时生成。5.2 获取当前租户上下文难点在于如何优雅地获取当前请求所属的tenant_id。常见方案有基于子域名从请求的Host头中解析子域名映射到租户ID。例如companyA.your-app.com对应tenant_id1。基于请求头/Token用户登录后在JWT Token中携带tenant_id。后端从Token中解析。基于用户信息在用户表sys_user中增加tenant_id字段登录后从LoginUser中获取。推荐方案2或3与若依的认证体系结合更紧密。我们采用方案3。5.3 自定义多租户处理器创建一个新的TenantDataPermissionHandler或者扩展之前的CustomDataPermissionHandler。Component public class TenantDataPermissionHandler implements DataPermissionHandler { Override public Expression getSqlSegment(Expression where, String mappedStatementId) { // 1. 排除不需要租户过滤的表 // 例如租户表(sys_tenant)、系统配置表等本身就不属于任何租户 if (isIgnoreTable(mappedStatementId)) { return where; } // 2. 获取当前租户ID String tenantId getCurrentTenantId(); if (tenantId null) { // 如果获取不到租户ID可以抛出异常或返回一个永假条件防止数据泄露 // return new EqualsTo(new LongValue(1), new LongValue(0)); // 10 // 更友好的方式是记录日志并返回空数据这里返回原条件有风险需评估 log.warn(“未获取到租户ID跳过数据过滤可能存在风险。MappedStatementId: {}”, mappedStatementId); return where; } // 3. 构建租户过滤条件tenant_id ‘xxx’ Column tenantColumn new Column(“tenant_id”); Expression tenantFilter new EqualsTo(tenantColumn, new StringValue(tenantId)); // 4. 合并条件 return where null ? tenantFilter : new AndExpression(where, tenantFilter); } private boolean isIgnoreTable(String mappedStatementId) { // 判断逻辑可以通过注解、配置列表等方式 ListString ignoreTables Arrays.asList(“sys_tenant”, “sys_config”); return ignoreTables.stream().anyMatch(mappedStatementId::contains); } private String getCurrentTenantId() { try { LoginUser loginUser SecurityUtils.getLoginUser(); // 假设LoginUser中已扩展了tenantId字段 return loginUser.getTenantId(); } catch (Exception e) { return null; } } }然后在配置类中将此处理器也添加到拦截器链中。注意如果有多个数据权限处理器需要明确它们的执行顺序通常租户过滤应该在最外层。5.4 租户ID的注入与维护用户注册/创建创建用户时必须关联一个tenant_id。登录在loadUserByUsername中查询用户信息时连同tenant_id一起查出并设置到LoginUser对象中。数据创建在插入任何业务数据时必须自动填充tenant_id字段。可以通过MyBatis-Plus的MetaObjectHandler元对象处理器来实现自动填充。Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { // 获取当前租户ID String tenantId getCurrentTenantId(); if (tenantId ! null) { this.strictInsertFill(metaObject, “tenantId”, String.class, tenantId); } // 同时也可以自动填充创建人、创建时间等 this.strictInsertFill(metaObject, “createBy”, String.class, SecurityUtils.getUsername()); this.strictInsertFill(metaObject, “createTime”, Date.class, new Date()); } Override public void updateFill(MetaObject metaObject) { // 更新时填充更新人、更新时间 this.strictUpdateFill(metaObject, “updateBy”, String.class, SecurityUtils.getUsername()); this.strictUpdateFill(metaObject, “updateTime”, Date.class, new Date()); } }重要提示多租户系统要特别注意缓存和静态数据。如果使用了Redis等缓存缓存Key必须包含tenant_id否则会导致租户间缓存数据错乱。同样的任何基于内存的静态配置或字典如果需要按租户区分也必须考虑隔离。6. 避坑指南与性能优化数据权限功能强大但用不好也会带来性能和维护上的噩梦。6.1 常见问题与排查SQL拼接错误导致查询结果为空或全量症状该看到的数据看不到或者看到了不该看的数据。排查开启MyBatis-Plus的SQL日志mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl查看最终执行的SQL语句检查自动追加的AND条件是否正确。重点检查getSqlSegment方法中的逻辑分支是否都覆盖到了。多表关联查询时数据权限失效症状单表查询正常但一到多表JOIN查询数据隔离就乱了。原因插件默认只处理主表From后的第一张表的条件追加。如果你的JOIN查询需要过滤的是子表的数据或者需要根据主表和子表的关系进行过滤默认插件可能无法处理。解决方案在自定义的DataPermissionHandler中需要解析SQL语句的Join部分并针对每张需要过滤的表动态添加条件。这非常复杂。一个更务实的做法是尽量避免复杂的多表JOIN将其拆分为多次单表查询或者在业务层通过代码控制数据权限。或者使用数据库视图View将多表关联预先定义好然后对视图应用数据权限插件。分页查询总数count语句权限不一致症状列表查询数据正确但分页的总数不对。原因MyBatis-Plus分页插件会先执行一次count(1)的查询。你需要确保你的数据权限处理器对count查询同样生效。检查mappedStatementId通常count查询的id会包含“_COUNT”后缀确保你的判断逻辑也包含了这些查询。6.2 性能优化建议减少数据范围计算的数据库查询在getChildDeptIds这类方法中不要每次执行SQL都去递归查询部门树。部门结构是低频变动的数据应该将其缓存在Redis中。登录时根据角色计算出的最终数据范围如部门ID列表也可以缓存在LoginUser或单独的缓存中避免每次查询都重新计算。为过滤字段建立索引这是最重要的优化手段。create_by,dept_id,tenant_id这些用于数据过滤的字段必须建立合适的索引。否则当数据量增大时每条查询都会变成全表扫描性能会急剧下降。控制拦截范围不是所有查询都需要数据过滤。像根据主键ID查询详情selectById、一些用于下拉框的公共数据查询等可以跳过数据权限插件。可以通过在mappedStatementId判断时设置白名单或者使用自定义注解如IgnoreDataPermission标记Mapper方法在处理器中检查该注解并跳过。复杂场景降级为代码控制对于极其复杂、动态的数据权限规则例如数据权限不仅取决于用户角色和部门还取决于某个动态的项目成员关系强行用SQL拦截器实现会使得逻辑难以维护和调试。此时不如在Service层显式地编写数据权限查询逻辑虽然代码量多了但清晰度和可控性更高。7. 扩展思考更细粒度的数据权限控制我们目前实现的是“行级”数据过滤。但在某些场景下可能还需要“列级”权限控制即用户只能看到表中的部分字段。这通常不在SQL层面解决而是在返回给前端的DTOData Transfer Object层面进行字段过滤。可以使用Jackson的JsonView注解或者自定义序列化器根据当前用户的角色决定哪些字段需要序列化返回。例如员工看不到工资字段而经理可以看到。另外对于“操作权限”的校验除了若依自带的PreAuthorize注解控制接口访问在Service内部的关键业务逻辑处也应该再次进行数据归属校验例如在修改一条数据前先检查这条数据的create_by或dept_id是否属于当前用户有权操作的范围这被称为“纵深防御”。数据权限和功能权限一样是一个持续迭代和加固的过程。在项目初期就搭建好一个清晰、可扩展的框架远比后期修修补补要轻松得多。希望这篇内容能帮你建立起若依框架下数据安全控制的完整图景让你在开发中更加游刃有余。