SpringCloud微服务实战——搭建企业级开发框架(二十八):扩展MybatisPlus插件DataPermissionInterceptor实现数据权限控制
最近在给团队私有框架做统一数据权限改造,发现很多模块还在用最原始的“查询后过滤”方式,Mapper 里查全量数据回来,再用代码循环判断当前用户能不能看这条记录。数据量小的时候看不出问题,一旦表里几十万上百万行,这种玩法直接把接口拖垮,而且权限逻辑散落在各个业务层里,改一处漏三处。所以这次下决心在 MybatisPlus 这一层统一收口,用 DataPermissionInterceptor 做行级数据拦截,效果立竿见影。这篇文章把整个思路、实现步骤和踩过的坑完整记录下来,也作为这套企业级微服务框架系列的第二十八篇。
如果你正在做 SpringCloud 类的微服务项目,后管系统里又恰好有“销售只能看自己的客户”、“部门主管能看全部下属数据”、“不同租户之间数据必须隔离”这类需求,那这篇文章大概率能帮你少走不少弯路。
1. 数据权限控制的核心问题:你需要的不是“能不能进”,而是“能看到哪些行”
很多开发同学会把数据权限和认证授权搞混。认证解决的是“你是谁”,Shiro、Spring Security 这类框架帮你确认登录用户身份。授权解决的是“你能干什么”,比如某个菜单、某个按钮、某个接口你能不能调。但用户进了订单列表之后,订单数据是全量的。也就是说,哪怕你把接口权限都控死了,只要他有权限点开这个页面,他就能看到所有订单,甚至包括别的部门下的单。
这就是数据权限要解决的问题:当用户有权限访问某个功能时,他到底能看哪些数据行。比如:
- 销售只能看自己名下的订单
- 销售主管能看整个团队的订单
- 财务能看所有部门产生的订单,但不能看提成明细
- 总部的运营人员能看所有区域的数据,但区域负责人只能看自己区域的
这类需求跟“能不能访问某个接口”完全是两码事,它是查询层面的强制过滤。你可以在 Service 层写条件判断,也可以在每个 Mapper 查询里手动拼 where 条件,但最优雅、最不容易遗漏的方式,是把它下沉到数据访问层,让所有查询在产生的过程中自动带上权限条件。
MybatisPlus 的 DataPermissionInterceptor 干的正是这件事。它拦截select、update、delete语句,在 SQL 解析阶段自动追加额外的条件片段,比如AND dept_id = 1001、AND create_by = 'zhangsan'。这样一来,业务代码里写selectList()或者自定义 SQL,都不用感知数据权限的存在,权限规则集中在拦截器里统一管理,后续调整规则只改一处。
2. DataPermissionInterceptor 的基本原理:拦截器里到底发生了什么
DataPermissionInterceptor 是 MybatisPlus 内置插件MybatisPlusInterceptor的一个子拦截器,它跟分页插件、乐观锁插件、租户插件这类组件一样,都实现了InnerInterceptor接口。MybatisPlus 在真正执行 SQL 之前,会按照注册顺序依次调用这些拦截器,每个拦截器都有机会修改即将执行的 SQL。
具体到 DataPermissionInterceptor,它的处理大致分成以下几步:
第一步,解析原始 SQL。拦截器拿到 Mapper 方法对应的 SQL 语句后,使用 JSqlParser 对 SQL 做语法解析,生成一个抽象语法树。解析成功后才能定位到它查询了哪些表、有没有别名、有没有 join、有没有子查询。
第二步,提取需要处理的表。MybatisPlus 会遍历 SQL 涉及的每一张数据表,并把它转换成Table对象,包含表名、别名等信息。你需要判断哪些表需要做数据权限过滤,哪些表可以跳过。
第三步,生成条件 SQL。拦截器内部回调你实现的DataPermissionHandler接口,让你针对特定表返回一条完整的 SQL 片段,例如dept_id IN (1001, 1002, 1003)。这个片段会被当作合法的条件子句拼接进原始 SQL。
第四步,拼接条件到原始 SQL。条件片段会通过“追加 where 条件”的方式注入进去。注入的位置和方式由拦截器内部逻辑控制,你不需要手动处理括号和优先级,但你必须理解一个事实:它最终是在原始 SQL 上做了一个“AND 你的条件”的拼接。
这个流程对使用者最大的启发是:数据权限本质上就是“改写 SQL”。它不是运行时内存过滤,也不是二次查询,而是让数据库在返回结果之前就完成过滤。这既能保证性能,也能避免漏数据或重复数据的问题。
有一个概念需要提前理解:MybatisPlus 的插件拦截器是有顺序的。比如你同时配置了分页插件和 DataPermissionInterceptor,分页插件在计算 total 时需要执行一个 count 查询,而 count 查询也必须带上数据权限条件,否则分页的总条数会出错。这类顺序问题在后面的实操部分会详细讲。
3. 动手配置:把 DataPermissionInterceptor 接入 SpringCloud 聚合服务
先看最基础的配置方式。正常情况下,你在一个微服务模块中添加一个配置类,把拦截器注册到 MybatisPlus 的拦截器链里即可。这里给出一份常见的配置模板:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 先添加数据权限拦截器 interceptor.addInnerInterceptor(new DataPermissionInterceptor(new CustomDataPermissionHandler())); // 再添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }有人会问:为什么数据权限拦截器要放在分页插件前面?因为 DataPermissionInterceptor 一旦先执行,后面的分页插件在生成 count 语句时,就已经涵盖了数据权限条件;反过来如果分页插件先执行,分页插件在解析 SQL 生成 count 时,拿到的还是原始 SQL,没有被追加数据权限条件,最后查出来的总量可能是错的。例如,某销售只能看自己的 50 条订单,但是全表有 1 万条订单,如果没有在 count 阶段拼接条件,分页插件会返回 total=10000,页面上显示“共 10000 条数据”,但实际一页只能翻出 50 条,体验非常差。
有了基础配置,下一个核心问题就是:DataPermissionHandler里到底怎么写。
4. 自定义 DataPermissionHandler:核心逻辑的完整实现
MybatisPlus 提供了DataPermissionHandler接口,你只需要实现其中的getSqlSegment方法。这个方法会在拦截器解析 SQL 时被调用,传入当前处理的表信息Table和表别名信息,你需要返回一个条件 SQL 片段。
下面给出一个实际项目里可用的实现示例。假设我们的系统里有sys_user、biz_order、biz_customer三张核心业务表,要求:
- 任何查询
biz_order的操作,只能看到当前用户所属部门下的数据 - 任何查询
biz_customer的操作,只能看到当前用户自己创建的数据 - 查询
sys_user时,不做数据权限限制 - 其他表如果有需要,可以继续扩展,默认放行
public class CustomDataPermissionHandler implements DataPermissionHandler { private static final String ORDER_TABLE = "biz_order"; private static final String CUSTOMER_TABLE = "biz_customer"; private static final String USER_TABLE = "sys_user"; @Override public String getSqlSegment(Table table, String whereSegment, String alias) { String tableName = table.getName(); String tableAlias = StringUtils.isNotBlank(alias) ? alias : tableName; CurrentUser currentUser = UserContextHolder.getCurrentUser(); if (currentUser == null) { // 内部系统调用或者定时任务场景,不给任何数据 return "1 = 0"; } // 超级管理员直接放行,避免给管理员也强行加条件 if (currentUser.isAdmin()) { return null; } if (ORDER_TABLE.equalsIgnoreCase(tableName)) { // 如果当前用户是部门主管,则可以看到整个部门的数据 if ("manager".equals(currentUser.getRoleCode())) { return String.format("%s.dept_id = %d", tableAlias, currentUser.getDeptId()); } // 普通业务员只能看自己部门内自己名下的数据,具体字段按实际业务调整 return String.format("%s.dept_id = %d AND %s.create_by = '%s'", tableAlias, currentUser.getDeptId(), tableAlias, currentUser.getUserId()); } if (CUSTOMER_TABLE.equalsIgnoreCase(tableName)) { return String.format("%s.create_by = '%s'", tableAlias, currentUser.getUserId()); } // 默认返回 null 表示不追加任何条件,也就是完全放行 return null; } }这段代码看起来简单,但里面藏着三个关键细节。
第一个细节是别名。当 SQL 里写了别名,例如SELECT * FROM biz_order o WHERE o.status = 1,那么注入条件时必须使用别名o,不能直接用表名。MybatisPlus 的getSqlSegment方法提供了一个 alias 参数,它就是当前表在具体 SQL 中的别名引用。如果那张表在 SQL 里没写别名,alias 的值和表名一致。为了保险起见,建议拼接条件时统一用tableAlias这个变量,而不是写死表名。
第二个细节是超管放行。企业系统里必然会有“超级管理员”这类角色,他们需要看全量数据。如果给管理员也强制拼接 dept_id 条件,就会出大问题,比如管理后台看不到其他部门的数据,误以为数据丢了。所以拿到当前用户后,第一步先判断角色,管理员直接返回 null。
第三个细节是兜底。对于没有配置规则的表,返回 null 就等于“不追加任何条件”,这实际上是一种放行策略。但如果将来有人新增了一张敏感表,忘记配规则,就会默认放行,造成越权。我在团队里定下了一个强制约定:核心业务表必须显式在配置类里声明权限规则;新增表时,评审阶段必须检查数据权限配置。宁可改成默认拒绝,也不允许默认放行。更严格的做法是,getSqlSegment里对未配置的表返回一个1 = 0,强制新表上线前必须有意识地去配置权限,否则查询直接返回空数据。这样做前期会觉得烦,但是能有效防止漏配。
5. 用户上下文传递:从 SpringCloud 网关到 MybatisPlus 拦截器
关键问题来了:拦截器里怎么拿到“当前用户”?在一个单体 Spring Boot 项目里,你可能习惯用SecurityContextHolder或者自定义的ThreadLocal存用户信息。但在 SpringCloud 微服务架构下,事情就没那么简单。服务之间会发生远程调用,A 服务通过 Feign 调用 B 服务时,B 服务里的线程不再是接收到用户请求的那个线程,ThreadLocal里的数据已经丢了。
常见的解决方案是结合网关和 Feign 拦截器一起做。网关在认证通过后,把用户信息写进请求头,例如X-User-Id、X-User-Dept-Id、X-User-Role。下游服务提供一个全局过滤器或者 Feign 拦截器,从请求头里取出这些字段,重新封装成CurrentUser对象,再放入该服务内部的ThreadLocal。这样 MybatisPlus 拦截器执行时,就能通过同一个线程上下文获取当前用户。
我给出一个简化版的 Feign 拦截器示例,方便你理解整体链路:
@Configuration public class FeignUserContextInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { // 从当前线程上下文取出用户对象 CurrentUser user = UserContextHolder.getCurrentUser(); if (user != null) { template.header("X-User-Id", user.getUserId()); template.header("X-User-Dept-Id", String.valueOf(user.getDeptId())); template.header("X-User-Role", user.getRoleCode()); } } }下游服务再通过一个过滤器或拦截器读取这些 Header:
@Component public class UserContextFilter implements Filter { @Override public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; try { CurrentUser user = new CurrentUser(); user.setUserId(request.getHeader("X-User-Id")); user.setDeptId(Long.valueOf(request.getHeader("X-User-Dept-Id"))); user.setRoleCode(request.getHeader("X-User-Role")); UserContextHolder.setCurrentUser(user); chain.doFilter(servletRequest, servletResponse); } finally { // 防止线程池复用导致用户串号 UserContextHolder.clear(); } } }这条链路一旦打通,后面的所有 Mapper 查询不用再手动传 user 参数,MybatisPlus 拦截器会自动感知当前用户并拼接条件。如果某天你接到一个需求,说“这个查询不受数据权限控制,能看到全量数据”,你不应该在业务代码里偷偷换个 Mapper 方法去绕过拦截器,而应该在业务层显式标记,比如单独设计一个@IgnoreDataPermission注解,内置查询仍然通过同一个 Mapper,只是在插件层根据注解跳过。这样既保留了统一管控能力,又给了极端场景一个受控的出口。
6. 复杂 SQL 场景:多表 Join、子查询、统计报表如何处理
上面的示例里,SQL 结构简单,单表查询占大多数。但企业后台系统里,报表查询、多表关联查询非常常见。DataPermissionInterceptor 的解析能力并不是无敌的,它基于 JSqlParser,对复杂 SQL 的解析存在边界。你在实操中最好先掌握几个典型的复杂场景。
6.1 多表 Join 场景
假设有这样一个查询:
SELECT o.id, o.order_no, u.user_name FROM biz_order o LEFT JOIN sys_user u ON o.create_by = u.user_id WHERE o.status = 1拦截器会解析出两张表:biz_order(别名 o)和sys_user(别名 u)。如果你在getSqlSegment里不加判断,两张表都会执行你的逻辑。假如你对sys_user返回了 null,而你的业务本意只是想过滤订单表,那这条 SQL 其实只会在 biz_order 上追加条件,sys_user 不过滤。这没问题。
但是要注意:如果你在getSqlSegment里对业务表判断用的是全局配置表名,而 SQL 里恰好给表起了很常见的别名,比如o、u,那么 alias 参数会变成o、u。如果你在拼接条件时决定用tableAlias.dept_id,结果是对的;但如果你的代码里忘了用别名,直接拼接biz_order.dept_id,最终生成的 SQL 会变成:
SELECT ... FROM biz_order o LEFT JOIN sys_user u ON ... WHERE o.status = 1 AND biz_order.dept_id = 1001MySQL 在某些情况下能容忍这种写法,但 SQL 语义已经不对了,一旦表名出现歧义就会报错。所以别名处理必须严格。
6.2 子查询场景
子查询是数据权限插件最容易出问题的地方。MybatisPlus 在处理WHERE id IN (SELECT ... FROM biz_order WHERE ...)这种结构时,能把子查询内部的 biz_order 找出来并追加条件。但如果你在子查询里用了多层嵌套、EXISTS、UNION这些结构,解析器可能会识别不完整,导致某个子表漏掉条件,或者重复拼接条件。一旦出现这类情况,建议先把 SQL 拆开,尽量用通用表表达式或者平铺的 join,降低解析负担。
6.3 统计报表场景
报表查询经常会用聚合函数,例如:
SELECT dept_id, COUNT(*) AS cnt FROM biz_order GROUP BY dept_id这种情况下,如果当前用户是普通销售,只能看自己的数据,拦截器会把条件拼成:
SELECT dept_id, COUNT(*) AS cnt FROM biz_order WHERE create_by = 'zhangsan' GROUP BY dept_id逻辑正确。但如果业务想要的是“先按部门统计,每个部门的总数里再分个人占比”,这类需求往往需要双层聚合,拦截器只能帮你把最基础的权限条件加上,剩下的业务过滤依然要写在 SQL 里。不要指望一个拦截器能替代所有的权限设计,它只负责“行级可见性”这个基本盘。
7. 实战经验:数据权限规则的配置化与防止绕过
在真实的微服务框架里,数据权限的需求不是写死几个 if 就能搞定的。不同角色、不同用户的数据范围可能不一样,常见的数据范围级别有:
- 仅本人数据
- 本人及下属数据
- 本部门数据
- 本部门及下级部门数据
- 自定义部门数据
- 全部数据
我实现过一个相对通用的数据权限模型,核心思路是让规则不再散落在代码里,而是存到配置表或者字典表中。每个数据权限规则包括:规则名称、对应的数据范围类型、关联的角色或部门、附加条件表达式。这样产品经理要调整某个角色的数据范围时,不需要改代码重新发布,只需要维护配置数据即可。
最简单的做法是在DataPermissionHandler里读取当前用户的数据范围类型,动态拼接不同的条件:
switch (user.getDataScope()) { case "SELF": return tableAlias + ".create_by = '" + user.getUserId() + "'"; case "DEPT": return tableAlias + ".dept_id = " + user.getDeptId(); case "DEPT_AND_CHILDREN": return tableAlias + ".dept_id IN (" + getDeptAndChildrenIds(user.getDeptId()) + ")"; case "ALL": return null; default: return "1 = 0"; }这里有一个很关键的部门层级处理问题。很多企业组织架构是树形的,比如总部下面有华东大区,华东大区下面有上海分公司。如果要求“部门及下级部门”,你需要把当前部门节点下的所有子部门 id 全部查出来,拼成一个IN条件。实现方式一般是用递归查询或者维护一个部门祖先关系表。性能上建议提前把部门树加载到 Redis,避免每次查询都走数据库递归。
还有一点容易被忽略:数据权限条件不能在 SQL 中简单地依赖字符串拼接,必须考虑 SQL 注入风险。虽然我们在权限条件里拼接的是从用户上下文取出的 userId、deptId 这类数值和枚举值,看似安全,但假设将来某些规则需要拼接一个可变的条件表达式,而这个表达式来自前端配置,那就极有可能被注入。我的经验是:前端配置的仅仅是规则类型,不直接拼 SQL,任何动态条件都只能来自服务端可靠数据源。
8. 性能陷阱与 SQL 优化:追加条件后查询变慢怎么办
给 SQL 追加了dept_id IN (...)或create_by = 'xxx'这类条件,绝大多数时候能显著减少返回数据量,查询会变快。但有些时候反而会变慢,最常见的原因是索引没建好。举个例子:业务表里create_by字段没有索引,拦截器给每条查询都追加了AND create_by = '用户id',在数据量大时,数据库被迫走全表扫描,性能反而下降。
所以引入数据权限插件后,一定要回头审视核心业务表的索引设计。特别是这几类字段:
- 数据归属人字段:
create_by、owner_id - 数据归属部门字段:
dept_id - 数据隔离租户字段:
tenant_id - 数据归属区域字段:
region_code
这几类字段往往需要与业务查询字段组成联合索引。例如,报表场景经常按查询条件status + dept_id过滤,那就应该建(status, dept_id)联合索引。数据权限条件本质上增加了两个等值查询条件,如果索引设计合理,查询效率只会更高,不会更差。
另一个常见性能问题是:拦截器会对每一条 SQL 做解析和改写,一旦接口 TPS 很高,解析开销会被放大。尤其是复杂 SQL,JSqlParser 需要花更多时间。针对这个问题,可以做两点优化:
第一,尽量让 Mapper 里的 SQL 保持简单规范,避免刻意书写超长动态拼接。第二,对高频接口做二级缓存或 Redis 缓存,减少对数据库的重复访问。数据权限插件毕竟是加了一层处理,不能把一个本应缓存的热点接口打到数据库上。
9. 常见问题与排查技巧
9.1 追加的条件没有生效
优先检查拦截器是否真的被注册进去,尤其注意 SpringBoot 中是否有多套 MybatisPlus 配置类冲突。微服务里如果引入了不同版本的 mybatis-plus-boot-starter,或者某个模块自己 new 了一个MybatisPlusInterceptor覆盖了全局配置,都会导致不生效。排查时可以在getSqlSegment方法里打断点,或者打印一段临时日志,看方法是否被调用。
9.2 数据权限条件重复追加
如果你既在 Mapper XML 里手动写了dept_id = ?,又在拦截器里追加了dept_id = ?,查询结果可能没问题,但 SQL 会变得冗余,而且一旦两边条件的值不一致,结果就是错的。制定规范时应该明确:业务 SQL 中一律不写数据权限条件,统一交给拦截器处理。这样才不会重复。
9.3 分页查询总数错误
分页总数错误的原因我已经在前面提过,通常是拦截器顺序问题。把 DataPermissionInterceptor 放在分页插件之前一般可以解决。如果已经有自定义分页逻辑,同样需要注意 count SQL 也要走拦截器。
9.4 更新、删除操作被拦截
DataPermissionInterceptor 不只拦截SELECT,它也会对UPDATE、DELETE追加条件,这是一把双刃剑。好处是防止用户通过接口批量删除其他部门的数据;坏处是,如果某些批量更新的场景确实需要跨部门操作,比如管理员手动修正一批数据,却被权限条件挡住,就会导致“条件没查到数据,更新 0 条”,而且不易察觉。遇到这种情况,建议对超级管理员直接放行,对定时任务之类的系统内部操作单独处理。
9.5 子查询漏处理
多层嵌套子查询最容易漏。出现疑似漏数据的权限问题时,先在数据库里开启 SQL 日志,打印出最终执行的真实 SQL,检查拦截器是否在预期的位置注入了条件。不要只凭日志里的 Mapper 方法名去猜,要看最终的 SQL。
10. 从 MybatisPlus 拦截器到全局数据权限中台
做完以上这些改造后,数据权限已经从“业务代码里的 if 判断”升级成了“数据访问层的强制过滤”。不过这只是企业级框架里的一步。如果你接下来要继续推进,有几个方向值得关注。
第一,把数据权限扩展到 Redis、Elasticsearch 等查询场景。很多系统会把热数据放到 Redis 或 ES 里,单纯在 MybatisPlus 这一层做拦截,其他存储还是会漏。这就需要设计统一的权限条件生成服务,让所有数据查询组件都调用同一套规则。
第二,把权限规则从代码配置迁移到规则引擎。有些头部企业的权限规则复杂到需要支持“动态脚本”,比如“当订单金额大于 X 时,销售只能看自己的;当金额小于 X 时,可以看本部门的”。这类规则放到代码里会非常难维护,可以用规则引擎去做。但在基础阶段,不建议一上来就上规则引擎,先把简单的角色-部门-人模型跑通。
第三,审计与日志。数据权限是敏感能力,所有涉及权限规则变化的操作必须留下审计日志。我在框架里增加了一个简单的切面,记录谁在什么时间改了哪个角色的数据权限范围。事后追溯时极其有用。
第四,测试策略。数据权限这类功能,最怕“开发环境没数据测不出来,一上生产就出事”。建议搭建一个专门的数据权限测试账户矩阵:一个超管账户,一个部门主管账户,一个普通员工账户,一个跨部门调动的账户,用这些固定账户跑一套核心链路用例,覆盖主要业务表的查询、更新、删除、分页、报表场景。每个版本迭代时跑一遍这个矩阵,能在早期发现绝大多数权限回归问题。
11. 总结一点个人实操体会
数据权限这块,表面上就是一个拦截器加一个 Handler,真正复杂的是背后的组织模型和业务规则。我在落地这套方案的过程中,踩过最深的一个坑,是后来发现不同微服务之间对“部门”这个概念的理解并不一致:订单服务里记录的 dept_id 是订单归属部门,用户服务里的 dept_id 是用户任职部门,两张表的部门体系不完全是一套。这种情况下,如果直接拿用户当前的 dept_id 去过滤订单表的 dept_id,就会漏数据。最终我们通过引入“数据归属部门”和“人员任职部门”两张映射关系,才把问题彻底解决。
所以如果有同学准备在项目里直接抄上面这段代码,我建议你先把业务模型梳理清楚:这张业务表归属于哪个组织维度,归属关系是在行记录里冗余一个字段,还是通过中间表关联。这部分想清楚了,代码只是时间问题。这也是我做了这么多微服务权限改造之后,最想分享的一句话:数据权限的本质不是 SQL 改写,而是业务归属关系的建模。建模正确,后面的拦截器、规则、性能问题都好说;建模错了,条件拼得再漂亮也只是在错误的基础上盖楼。
这套扩展方案已经在我们内部的几个业务线跑了大半年,目前所有核心表查询都强制走 DataPermissionInterceptor,权限漏配导致的事故基本归零。后续如果遇到更复杂的权限场景,比如多租户叠加多层级组织架构,我还会继续在这个系列里记录扩展思路。
(本文基于实际项目经验输出,所有代码示例均为简化演示,生产落地时请根据自身业务模型调整字段与规则。)