news 2026/6/30 1:38:50

数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解

做后台系统,权限最容易被低估。

很多项目把权限理解成:菜单隐藏、按钮隐藏、接口加个注解。结果上线后才发现,真正难的是数据权限

  • 销售只能看自己的客户
  • 部门主管能看本部门数据
  • 区域经理能看本区域 + 下级区域数据
  • 租户管理员能看本租户全部数据
  • 总管理员能看全部数据

如果这些逻辑散落在 Service 里,最后一定变成一堆if role == xxxif deptId in xxx,越写越乱。更可怕的是,某个查询忘了加过滤条件,就直接数据越权。

这一期不讲概念,直接拆 Forge Admin 的数据权限实现:它不是在 Service 层拼条件,而是在 MyBatis Mapper 层用 JSqlParser 改写 SQL。

先给结论:

方案优点问题
Service 层手写条件简单直接容易漏、重复代码多、不可统一审计
注解 + AOP 拼参数侵入小复杂 SQL、分页 count、JOIN 场景难处理
Mapper 层 SQL 改写对业务透明、统一兜底实现复杂,需要处理分页、别名、子查询

Forge 选的是第三种。


一、为什么数据权限不能只靠 Service 层?

很多项目一开始都这么写:

perl

代码解读

复制代码

if (!user.isAdmin()) { query.eq("create_by", user.getId()); }

或者:

ini

代码解读

复制代码

if (scope == ORG) { query.in("dept_id", user.getDeptIds()); }

短期看没问题,长期看全是坑:

  1. 每个 Service 都要写一遍:用户、订单、合同、工单、报表,每个业务都要判断。
  2. 新接口容易漏:某个开发写了个导出接口,忘了加过滤,直接越权。
  3. 复杂 SQL 不好处理:JOIN、多表查询、子查询,LambdaQueryWrapper很快撑不住。
  4. 无法统一配置:哪个 Mapper 方法应该套哪个字段,很难集中管理。

所以 Forge 立了一个很强的约束:查询类 SQL 写在 Mapper XML 中,数据权限按 mapperMethod 精确匹配配置,再统一改写 SQL。

这也是为什么项目规范里强调:查询类 SQL 禁止在 Service 层用LambdaQueryWrapper拼。不是为了教条,而是为了让 DataScopeInterceptor 能精确接管。


二、Forge 数据权限的整体链路

Forge 的数据权限链路可以简化成 5 步:

sql

代码解读

复制代码

用户登录态 ↓ 计算当前用户数据范围(角色、组织、行政区划) ↓ 根据 mapperId 查 sys_data_scope_config 配置 ↓ JSqlParser 解析原始 SQL ↓ 在 WHERE 后追加数据权限条件

核心类是:

  • DataScopeInterceptor:MyBatis-Plus InnerInterceptor,负责拦截查询并改写 SQL。
  • DataScopeServiceImpl:负责加载角色、组织、行政区划、Mapper 配置等元数据。
  • SysDataScopeConfig:配置某个 Mapper 方法用哪个字段做权限过滤。
  • DataScopeType:定义 7 种数据权限范围。

先看拦截入口(DataScopeInterceptor.beforeQuery):

ini

代码解读

复制代码

public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) { if (DataScopeContextHolder.isSkip()) { return; } String mapperId = ms.getId(); if (mapperId.startsWith(DATA_SCOPE_MAPPER_PACKAGE)) { return; } String actualMapperId = mapperId; if (mapperId.endsWith("_mpCount") || mapperId.endsWith("_COUNT")) { actualMapperId = mapperId.replaceAll("(_mpCount|_COUNT)$", ""); } SysDataScopeConfig config = dataScopeService.getDataScopeConfig(actualMapperId); if (config == null || config.getEnabled() == 0) { return; } DataScopeContext context = dataScopeService.getCurrentUserDataScope(); // ... 后面根据 scopeType 改写 SQL }

这个入口有几个细节很关键:

  1. 支持跳过开关:后台任务、系统级操作可以用DataScopeContextHolder.executeWithoutDataScope()临时跳过。
  2. 跳过自身 Mapper:数据权限自己的 Mapper 查询不能再套数据权限,否则会递归。
  3. 兼容分页 count:MyBatis-Plus 分页会生成_mpCount查询,Forge 会还原成原始 mapperId 查配置。
  4. 只有配置过的方法才改写:不是所有 SQL 都乱加条件,而是精确到mapperMethod

三、7 种数据权限范围,不只是“本人/部门”

Forge 的DataScopeType定义了 7 种范围:

类型含义典型场景
ALL全部数据超级管理员
SELF本人数据普通销售、普通员工
ORG本组织数据部门主管
ORG_AND_CHILD本组织及子组织分公司负责人
CUSTOM自定义组织临时授权、跨部门项目组
TENANT_ALL本租户全部租户管理员
REGION行政区划政务/区域运营项目

源码里有一个兼容历史编码的映射方法:

typescript

代码解读

复制代码

public static DataScopeType getByRoleDataScope(Integer code, boolean hasCustomOrgIds) { return switch (code) { case 1 -> ALL; case 2 -> TENANT_ALL; case 3 -> ORG; case 4 -> ORG_AND_CHILD; case 5 -> hasCustomOrgIds ? CUSTOM : SELF; case 6 -> TENANT_ALL; case 7 -> REGION; default -> getByCode(code); }; }

当前用户的数据范围由DataScopeServiceImpl.getCurrentUserDataScope()计算:超级管理员直接ALL,租户管理员直接TENANT_ALL,无角色用户默认SELF,普通用户按角色取最小权限范围,并加载组织、行政区划、自定义组织集合。

这就把“用户是谁、有哪些角色、属于哪些组织、属于哪个行政区划”统一封装成DataScopeContext,后面的 SQL 改写只消费这个上下文。


四、真正的核心:JSqlParser 改写 WHERE 条件

拿到配置和上下文后,Forge 开始改写 SQL。

核心方法是buildDataScopeSql

ini

代码解读

复制代码

Statement statement = CCJSqlParserUtil.parse(originalSql); Select select = (Select) statement; PlainSelect plainSelect = resolveDataScopeTarget(select.getSelectBody()); Expression where = plainSelect.getWhere(); Expression dataScopeCondition = buildDataScopeCondition(config, context, scopeType); if (dataScopeCondition != null) { if (where != null) { plainSelect.setWhere(new AndExpression(where, dataScopeCondition)); } else { plainSelect.setWhere(dataScopeCondition); } } return select.toString();

这段代码做的事很直接:

sql

代码解读

复制代码

SELECT * FROM customer WHERE status = 1

如果当前用户只能看本人数据,就会变成:

sql

代码解读

复制代码

SELECT * FROM customer WHERE status = 1 AND create_by = 10001

如果当前用户能看本部门及子部门,就会变成:

sql

代码解读

复制代码

SELECT * FROM customer WHERE status = 1 AND dept_id IN (10, 11, 12)

关键点在于:它不是字符串拼接,而是 SQL AST 改写。JSqlParser 解析 SQL 语法树,再把权限条件作为表达式追加进去,比手工拼字符串安全得多。


五、分页 count 是数据权限最容易漏的坑

很多数据权限插件在分页场景会翻车。

MyBatis-Plus 分页通常会把原 SQL 包成:

sql

代码解读

复制代码

SELECT COUNT(*) FROM (原始 SQL) TOTAL

如果你把数据权限条件追加到外层 count:

scss

代码解读

复制代码

SELECT COUNT(*) FROM (...) TOTAL WHERE t.dept_id IN (...)

这时外层根本没有t这个别名,SQL 直接报错。

Forge 专门处理了这个坑:

less

代码解读

复制代码

if (mapperId.endsWith("_mpCount") || mapperId.endsWith("_COUNT")) { actualMapperId = mapperId.replaceAll("(_mpCount|_COUNT)$", ""); }

然后用resolveDataScopeTarget()递归找到真正需要追加条件的内层查询:

scss

代码解读

复制代码

if (plainSelect.getFromItem() instanceof ParenthesedSelect parenthesedSelect && (plainSelect.getJoins() == null || plainSelect.getJoins().isEmpty())) { PlainSelect nestedSelect = resolveDataScopeTarget(parenthesedSelect.getSelect()); if (nestedSelect != null) { return nestedSelect; } }

这就是源码文里最值得看的地方:不是“我支持数据权限”,而是分页 count、子查询、别名这些真实场景都处理了没有


六、配置化:为什么按 mapperMethod 精确匹配?

数据权限不是所有表都按同一个字段过滤。

  • 客户表可能按create_by过滤本人。
  • 订单表可能按dept_id过滤部门。
  • 工单表可能既看当前处理人,又看登记人。
  • 政务表可能按region_code过滤行政区划。

所以 Forge 把过滤字段放进sys_data_scope_config,核心实体是SysDataScopeConfig

arduino

代码解读

复制代码

private String mapperMethod; private String tableAlias; private String userIdColumn; private String orgIdColumn; private String tenantIdColumn; private String regionCodeColumn; private String userRegionColumn; private String userTableAlias;

意思是:某个 Mapper 方法,应该用哪个表别名、哪个字段来做数据权限。

这比注解写死在代码里灵活得多。一个业务查询可以按用户过滤,另一个业务查询可以按部门过滤,另一个还可以按行政区划过滤。规则集中在配置表里,能查、能改、能审计。

更重要的是,它支持复杂 SQL 模板:字段配置以<sql>开头时,可以用占位符:

bash

代码解读

复制代码

<sql>(lc.current_handler_id = #{userId} OR lc.register_person_id = #{userId})

支持的占位符包括:#{userId}#{tenantId}#{orgIds}#{customOrgIds}#{regionCode}#{regionCodes}等。复杂业务不用硬塞成单字段模式。


七、行政区划权限:政府/区域项目的刚需

很多后台框架的数据权限只做到“本人/部门/子部门”,但政务、能源、运营商、区域代理项目经常需要行政区划权限:

  • 省级账号:看全省
  • 市级账号:看本市 + 下级区县
  • 区县账号:看本区县

Forge 在DataScopeType里专门定义了REGION,而且做了两个细节。

第一,省级直接视为全部权限:

ini

代码解读

复制代码

if (scopeType == DataScopeType.REGION && Integer.valueOf(1).equals(context.getRegionLevel())) { return; }

第二,市级及以下会把本级和下级区划编码都解析出来,再生成 IN 条件:

ini

代码解读

复制代码

Set<String> regionCodes = dataScopeService.getRegionAndChildCodes(regionCode); return buildStringInCondition(fullColumnName, regionCodes);

也就是说,选择呼和浩特市时,不是只查150100,而是查150100+ 它下面所有区县编码。这类能力在政府项目里非常常见,但很多框架要自己扩展。


八、无范围时为什么返回 1=0?

源码里还有一个安全兜底:当用户没有组织、没有自定义组织、没有行政区划时,不是放行,而是返回恒假条件。

csharp

代码解读

复制代码

private Expression buildAlwaysFalse() { EqualsTo eq = new EqualsTo(); eq.setLeftExpression(new LongValue(1)); eq.setRightExpression(new LongValue(0)); return eq; }

翻译成 SQL 就是:

ini

代码解读

复制代码

AND 1 = 0

这点很关键。权限系统最怕“拿不到范围就不加条件”。正确做法应该是:拿不到范围,就查不到数据。宁可误伤,也不能越权。


九、它和多租户是什么关系?

上一期我们拆过多租户。多租户解决的是:A 公司不能看到 B 公司的数据。

数据权限解决的是:A 公司内部,销售、主管、租户管理员分别能看哪些数据。

两者不是替代关系,而是叠加关系:

ini

代码解读

复制代码

WHERE tenant_id = 1 AND dept_id IN (10, 11, 12)

租户拦截器先把外层边界框住,数据权限再做租户内部的精细过滤。这也是为什么企业后台不能只做 RBAC,更不能只做菜单权限。


十、总结:Forge 数据权限强在哪?

总结一下,Forge 的数据权限不是“加个注解”这么简单,而是一套完整链路:

能力说明
Mapper 层拦截统一改写 SQL,减少 Service 层重复判断
mapperMethod 精确匹配哪个查询套哪个规则,可配置、可审计
7 种数据范围全部、本人、组织、组织及子组织、自定义、租户全部、行政区划
JSqlParser AST 改写不是字符串拼接,能处理复杂 SQL
分页 count 兼容识别_mpCount,把条件加到内层查询
恒假兜底无可用范围时1=0,防止越权
元数据缓存平台配置预热到内存,业务查询不反复打 sys_* 表

所以我才说:数据权限不是权限系统的附属品,而是企业后台的核心基础设施。

若依这类框架,更多是把数据权限交给开发者自己处理;Jeecg、芋道做了更完整的权限体系;Forge 的特点是把它下沉到 Mapper 层,和 XML SQL、JSqlParser、配置表、行政区划权限结合到一起。

这不是最简单的方案,但它更适合长期演进的企业后台。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/30 1:37:16

计算机组成原理计算机组成原理计算机组成原理

核心概念与背景介绍离线暂停更新的定义&#xff1a;解释在前端应用中&#xff0c;用户处于离线状态时如何暂停数据同步或更新请求&#xff0c;并在恢复网络后重新处理。应用场景&#xff1a;列举典型场景&#xff08;如PWA、表单提交、实时协作工具等&#xff09;。技术挑战&am…

作者头像 李华
网站建设 2026/6/30 1:34:44

【一文看懂申根国家】

第一次去欧洲旅行的朋友&#xff0c;会发现一个神奇的现象&#xff1a; 有人今天还在法国喝咖啡&#xff0c;几个小时后已经到了德国&#xff1b;第二天又去了荷兰&#xff0c;全程没有边境检查&#xff0c;也不用再次出示护照。 你可能会疑惑&#xff1a;“欧洲国家之间难道没…

作者头像 李华
网站建设 2026/6/30 1:32:52

位置参数、关键字参数和默认参数的规则

先定义一个简单的函数&#xff1a;def introduce(name, age, city广州):print(f{name}&#xff0c;{age}岁&#xff0c;来自{city})1. 位置参数&#xff08;Positional Arguments&#xff09;规则&#xff1a;按位置顺序一一对应传入&#xff0c;缺一不可&#xff0c;多一不可。…

作者头像 李华
网站建设 2026/6/30 1:25:03

【单片机毕业设计】基于 STM32 的超重声光报警电子秤设计与实现,基于 STM32 的阈值式重量监测报警系统设计(013701)

文章目录20 个相关毕业设计备选题目项目研究背景摘要总体方案核心功能一、基础数据处理功能二、数据可视化功能三、参数设置功能四、模式切换功能五、超限报警核心功能技术路线项目演示关于我们项目案例源码获取博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目…

作者头像 李华
网站建设 2026/6/30 1:22:24

[特殊字符] RPA + AI,成年人不做选择 [特殊字符]

先说个真事。去年11月的一个周三&#xff0c;凌晨两点&#xff0c;手机炸了。客户——深圳一家跨境电商&#xff0c;日订单 3000。我给他们搭了一套RPA&#xff1a;Shopify自动抓订单 → 录ERP → 生成发货单。上线第一周稳如老狗&#xff0c;客户群里连发三天红包。&#x1f9…

作者头像 李华