同一个接口,为什么销售只看到自己的订单?我拆了 681 行数据权限拦截器的源码

本文是 ForgeAdmin 框架源码拆解系列的一篇,所有代码都来自 forge-starter-datascope 模块,文末有仓库地址。

一、从一个熟悉的需求说起

做过管理系统的都遇到过这个需求:

"订单列表页面,销售只能看自己的,销售主管能看整个部门的,大区总监能看本大区的,老板能看全部。"

你们项目是怎么实现的?我见过三种写法:

写法一:业务代码里 if-else

复制代码

写法二:每个 Mapper 写两份 SQL

复制代码

写法三:SQL 改写

业务代码一行不动,selectList 还是那个 selectList,SQL 在执行前被自动追加了 WHERE 条件------销售执行时多一句 user_id = 9527,主管执行时多一句 org_id IN (...)

ForgeAdmin 用的是写法三,落在一个独立的 starter 里(forge-starter-datascope,24 个类 2129 行,核心拦截器 681 行)。今天把这套东西完整拆开:怎么改写 SQL、怎么防住各种边角翻车、以及我在里面踩过的坑。

二、设计目标

动手拆之前先看设计者给自己定了什么规矩,这决定了后面所有代码的形态:

  1. 业务零侵入------Controller / Service / Mapper 一行不改,权限对业务代码不可见
  1. 按 mapperMethod 精确配置------不是全局一刀切,哪个查询要控、控什么字段,逐个方法配置
  1. 失败要安全------权限判断出问题时的兜底方向是"查不到数据"而不是"查到全部"
  1. 不影响启动------拦截器依赖 Service、Service 依赖 Mapper、Mapper 依赖 SqlSessionFactory、SqlSessionFactory 装拦截器......这是个天然循环依赖,必须破掉
  1. 多实例部署下配置实时生效------A 实例改了配置,B 实例的缓存要立刻失效

三、全景图

先上整体链路,后面逐段拆:

复制代码

四、数据模型:一张配置表定生死

整套机制的核心配置在 sys_data_scope_config 表,实体类我摘了关键字段:

复制代码

注意两个设计点:

第一,配置粒度是 mapperMethodcom.xxx.OrderMapper.selectPage 配一条、com.xxx.OrderMapper.export 配一条------列表要控、导出也要控,少配哪个哪个就越权,多控哪个查询也一目了然。

第二,字段值支持"简单/复杂"双模式 。简单模式填个字段名 user_id,拦截器生成 user_id = 9527;复杂模式用 <sql> 包一段模板,占位符运行时替换,专门应付"经手人 发起人都算我的"这种一个字段表达不了的条件。

再看权限类型的枚举,一共 7 种:

复制代码

前 6 种是标准 RBAC 套路,第 7 种 REGION 是政务/区域连锁场景:按行政区划编码过滤,省级用户不限制,市级用户看本市+下级区县。

五、拦截器主干:beforeQuery 的七步流水

核心类 DataScopeInterceptor 实现的是 MyBatis-Plus 的 InnerInterceptor(比原生 MyBatis Interceptor 好处:官方支持链式、和分页插件天然协同)。主干我做了精简注释:

复制代码

几个容易一眼扫过去、实际很讲究的点:

第 2 步的防递归。拦截器查配置要走 Mapper,这个查询本身也会触发拦截器。靠包名前缀判断"自己人"直接放行,不然第一次启动就栈溢出给你看。

第 3 步的 count 还原 。MyBatis-Plus 分页时实际发两条 SQL:SELECT ... LIMITSELECT COUNT(*) ...,后者 MappedStatement 的 id 会变成 selectPage_mpCount。不剥后缀,count 语句查不到配置 → count 不带权限条件、列表带 → 页码算出来 100 页,实际数据只有 3 页。这种 bug 线上特别隐蔽。

第 5 步的"未获取到用户"是失败而不是放行 。配置了权限的方法,拿不到用户信息说明出了问题(token 解析异常?匿名调用?),这时走 handleConfiguredFailure

六、SQL 改写:AST 而不是字符串拼接

buildDataScopeSql 用 JSqlParser 把 SQL 解析成语法树再改写:

复制代码

为什么用 AST 不用正则/字符串替换?两个原因:一是业务 SQL 里 WHERE 可能没有、可能有括号嵌套、可能带子查询,字符串拼接处理不了这些形态;二是 AST 改写天然知道该挂在哪个节点上------比如下面这个坑。

坑:分页 count 会把你的 SQL 包一层。MP 的 count 语句长这样:

复制代码

表别名在子查询内部 。如果条件追加到外层,o.org_id 直接报"列不存在"。所以有个 resolveDataScopeTarget 递归下钻:

复制代码

七、条件构建:7 种类型一条 switch

buildDataScopeCondition 按权限类型构建表达式,这段信息密度很高:

复制代码

重点看 buildAlwaysFalse()

复制代码

用户被配置了"本组织权限"但压根没有组织,怎么办?三种选择:抛异常(页面 500)、放行查全部(越权事故)、追加 1=0 查不到任何数据。这里选了第三种------权限系统的失败方向必须朝着"更少"而不是"更多",宁可让用户来报"我看不到数据",也不能让用户看到不该看的。

再看复杂模式 <sql> 的占位符替换 。当 userIdColumn 配的是:

复制代码

拦截器会剥掉标签、替换占位符、再用 CCJSqlParserUtil.parseCondExpression 解析成表达式挂到 WHERE 上。支持的占位符包括 #{userId}#{tenantId}#{orgIds}#{customOrgIds}#{regionCode}#{regionCodes} 等,其中 #{regionCodes} 是提前从平台库解析好的"本级+下级"区划编码集合------业务库的 SQL 里不引用平台库的表,跨库部署时才不会翻车。

八、启动循环依赖:一个 Supplier 破局

这是我认为整个模块最漂亮的一处设计。依赖链是这样的:

复制代码

SqlSessionFactory 创建时要实例化拦截器,拦截器要 Service,Service 要 Mapper,Mapper 又要 SqlSessionFactory。直接构造注入,Spring 起不来。

解法是延迟获取

复制代码

拦截器持有的是 Supplier<IDataScopeService>,构造时只存一个"以后怎么拿到 Service"的承诺,真正 get() 发生在第一次查询进来时------那时整条依赖链早就就绪了。比 @Lazy 干净,也不依赖 Spring 容器特性,纯 Java 手段。

九、多实例缓存同步:Redisson 广播 + 防抖

配置在本地缓存(避免每条 SQL 都查一次配置表),那多实例部署时 A 实例改配置、B 实例缓存失效怎么办?看 DataScopeCacheSynchronizer

复制代码

两个细节值得抄走:

订阅成功即补载 。发布订阅有天然缺陷:断线期间的消息永久丢失。所以监听 onSubscribe(首次订阅和重连都会触发)时无条件重载一次------宁可多查一次库,不给"错过失效通知后一直用旧配置"留任何机会。对权限系统,旧配置多放一会儿都是风险

刷新任务防抖

复制代码

管理员在界面上连续改 10 条配置 = 广播 10 次 = 每个实例要刷新 10 次本地缓存?容量为 1 的队列 + DiscardOldestPolicy:密集通知合并成"最多保留一个待执行刷新",而且刷新动作不占用订阅回调线程。

十、实战:给一个查询接上数据权限

拿订单列表举例,四步:

第 1 步 :确定要控的 Mapper 方法,比如 com.xxx.business.OrderMapper.selectPage

第 2 步:插入一条配置:

复制代码

意思是:这个查询按主表别名 o,本人权限用 o.create_by 匹配,组织权限用 o.org_id 匹配。

第 3 步:给角色配数据范围。销售角色 → "本人",主管角色 → "本组织及子组织"。

第 4 步:没有第 4 步。业务代码一行没改,销售登录查订单列表,执行的 SQL 自动变成了:

复制代码

主管登录,同一接口:

复制代码

上线前建议 :把 unconfigured-policy 切到 DENY(未配置数据权限的 Mapper 直接拒绝执行)扫一遍测试环境------所有没显式声明的查询全暴露出来,逐个确认"确实不需要控"再补配置或放行。默认的兼容策略(放行+告警去重)是为存量系统平滑迁移准备的,新项目别用默认值。

十一、还可以再挖的点

篇幅原因没展开、但源码里都有的:

  • 流程经手可见flowRelatedVisible):主管本部门 + "我审批过/被抄送的单子我也能看",通过 EXISTS (SELECT 1 FROM sys_flow_record_participant ...) OR 进主条件------数据权限和工作流的联动
  • 防注入白名单 :配置里的表别名、业务类型全部过正则 ^[A-Za-z_][A-Za-z0-9_]{0,63}$,字面量 '' 转义。配置是存在数据库里由管理员录入的,"内部来源"一样可能是注入入口
  • 双编码兼容getByRoleDataScope):新旧两套角色范围编码映射,switch 表达式写得挺干净
  • 告警去重ConcurrentHashMap.newKeySet() 记住告警过的 mapperId,防止每个请求刷一条重复 warn

十二、总结

拆完这 681 行,能带走的三条:

  1. SQL 改写是数据权限侵入度最低的落点------用 InnerInterceptor + JSqlParser AST 改写,业务代码零感知,换来的代价是要处理 count 包裹、防递归这些底层边角,但这些都是一次性成本
  1. 权限系统的失败方向必须朝"更少" ------1=0 兜底、未配置可配 DENY、拿不到用户按失败处理,宁可误伤不可漏放
  1. 循环依赖用延迟引用破,缓存一致性用"重连即补载"保------Supplier 一个参数解决启动死锁,onSubscribe 一次回调堵住发布订阅的最大漏洞,都是十几行代码的事

下一篇打算拆 forge-starter-tenant:多租户的 TenantLineInnerInterceptor 怎么和这套数据权限共存(两个都改 SQL,执行顺序、别名冲突、tenant_id 要不要参与数据权限),感兴趣的可以先关注。


系列仓库:ForgeAdmin 开源框架

类别 地址
Gitee gitee.com/ForgeLab/fo...
GitHub github.com/yaomindong1...
在线文档 www.dlforgelab.com:8084/forge-docs/
本篇源码位置 forge-starter-parent/forge-starter-datascope

如果觉得有收获,点赞收藏是对作者最大的鼓励;有不同实现思路的欢迎评论区交流------尤其你们项目的数据权限是怎么做的,我想看看还有没有第四种写法。

相关推荐
元界metalite1 小时前
SpringBoot接口通用响应对象设计 - 一个 Resp 管理五种责任
后端
海宇数据1 小时前
零信任架构实战:基于海宇运营商近3个月平均账单构建自动化分布式租赁网关
人工智能·分布式·架构·自动化
对象存储与RustFS1 小时前
把 RustFS 跑在 Kubernetes 上:官方 Helm Chart 从单机到分布式
后端·rust·开源
卓怡学长1 小时前
w157基于springboot北部湾地区助农平台
java·spring boot·spring·maven·intellij-idea
小白羊丨1 小时前
问数 Agent 整体架构、意图识别、历史上下文与 NL2SQL
java·面试·架构
Linux-lucky1 小时前
33-Linux学习之旅之MySQL用户管理
java·linux·运维·学习·mysql·ubuntu
SimonKing1 小时前
将cURL 命令直接变成 Java 代码?用JQuick-Curl搞起来
java·后端·程序员
IT大白鼠1 小时前
MySQL 分布式集群系列 · 第二篇:架构深度拆解--NDB 三大核心节点
分布式·mysql·架构
大牧师1 小时前
Nest.js 微服务入门教程
开发语言·javascript·后端·微服务·node.js·nest.js·nest