本文是 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、怎么防住各种边角翻车、以及我在里面踩过的坑。
二、设计目标
动手拆之前先看设计者给自己定了什么规矩,这决定了后面所有代码的形态:
- 业务零侵入------Controller / Service / Mapper 一行不改,权限对业务代码不可见
- 按 mapperMethod 精确配置------不是全局一刀切,哪个查询要控、控什么字段,逐个方法配置
- 失败要安全------权限判断出问题时的兜底方向是"查不到数据"而不是"查到全部"
- 不影响启动------拦截器依赖 Service、Service 依赖 Mapper、Mapper 依赖 SqlSessionFactory、SqlSessionFactory 装拦截器......这是个天然循环依赖,必须破掉
- 多实例部署下配置实时生效------A 实例改了配置,B 实例的缓存要立刻失效
三、全景图
先上整体链路,后面逐段拆:
四、数据模型:一张配置表定生死
整套机制的核心配置在 sys_data_scope_config 表,实体类我摘了关键字段:
注意两个设计点:
第一,配置粒度是 mapperMethod 。com.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 ... LIMIT 和 SELECT 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 行,能带走的三条:
- SQL 改写是数据权限侵入度最低的落点------用 InnerInterceptor + JSqlParser AST 改写,业务代码零感知,换来的代价是要处理 count 包裹、防递归这些底层边角,但这些都是一次性成本
- 权限系统的失败方向必须朝"更少" ------
1=0兜底、未配置可配 DENY、拿不到用户按失败处理,宁可误伤不可漏放
- 循环依赖用延迟引用破,缓存一致性用"重连即补载"保------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 |
如果觉得有收获,点赞收藏是对作者最大的鼓励;有不同实现思路的欢迎评论区交流------尤其你们项目的数据权限是怎么做的,我想看看还有没有第四种写法。