# 框架横评第 3 期:数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解 > 第 2 期拆了多租户,很多人留言说:多租户解决的是“租户之间不能串”,那同一个租户内部,部门、角色、个人之间的数据权限怎么做?这一期继续拆源码。 做后台系统,权限最容易被低估。 很多项目把权限理解成:菜单隐藏、按钮隐藏、接口加个注解。结果上线后才发现,真正难的是**数据权限**: - 销售只能看自己的客户 - 部门主管能看本部门数据 - 区域经理能看本区域 + 下级区域数据 - 租户管理员能看本租户全部数据 - 总管理员能看全部数据 如果这些逻辑散落在 Service 里,最后一定变成一堆 `if role == xxx`、`if deptId in xxx`,越写越乱。更可怕的是,某个查询忘了加过滤条件,就直接数据越权。 这一期不讲概念,直接拆 Forge Admin 的数据权限实现:**它不是在 Service 层拼条件,而是在 MyBatis Mapper 层用 JSqlParser 改写 SQL。** 先给结论: | 方案 | 优点 | 问题 | |------|------|------| | Service 层手写条件 | 简单直接 | 容易漏、重复代码多、不可统一审计 | | 注解 + AOP 拼参数 | 侵入小 | 复杂 SQL、分页 count、JOIN 场景难处理 | | Mapper 层 SQL 改写 | 对业务透明、统一兜底 | 实现复杂,需要处理分页、别名、子查询 | Forge 选的是第三种。 --- ## 一、为什么数据权限不能只靠 Service 层? 很多项目一开始都这么写: ```java if (!user.isAdmin()) { query.eq("create_by", user.getId()); } ``` 或者: ```java 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 步: ```text 用户登录态 ↓ 计算当前用户数据范围(角色、组织、行政区划) ↓ 根据 mapperId 查 sys_data_scope_config 配置 ↓ JSqlParser 解析原始 SQL ↓ 在 WHERE 后追加数据权限条件 ``` 核心类是: - `DataScopeInterceptor`:MyBatis-Plus InnerInterceptor,负责拦截查询并改写 SQL。 - `DataScopeServiceImpl`:负责加载角色、组织、行政区划、Mapper 配置等元数据。 - `SysDataScopeConfig`:配置某个 Mapper 方法用哪个字段做权限过滤。 - `DataScopeType`:定义 7 种数据权限范围。 先看拦截入口(`DataScopeInterceptor.beforeQuery`): ```java 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` | 行政区划 | 政务/区域运营项目 | 源码里有一个兼容历史编码的映射方法: ```java 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`: ```java 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: ```sql SELECT COUNT(*) FROM (...) TOTAL WHERE t.dept_id IN (...) ``` 这时外层根本没有 `t` 这个别名,SQL 直接报错。 Forge 专门处理了这个坑: ```java if (mapperId.endsWith("_mpCount") || mapperId.endsWith("_COUNT")) { actualMapperId = mapperId.replaceAll("(_mpCount|_COUNT)$", ""); } ``` 然后用 `resolveDataScopeTarget()` 递归找到真正需要追加条件的内层查询: ```java 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`: ```java 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 (lc.current_handler_id = #{userId} OR lc.register_person_id = #{userId}) ``` 支持的占位符包括:`#{userId}`、`#{tenantId}`、`#{orgIds}`、`#{customOrgIds}`、`#{regionCode}`、`#{regionCodes}` 等。复杂业务不用硬塞成单字段模式。 --- ## 七、行政区划权限:政府/区域项目的刚需 很多后台框架的数据权限只做到“本人/部门/子部门”,但政务、能源、运营商、区域代理项目经常需要行政区划权限: - 省级账号:看全省 - 市级账号:看本市 + 下级区县 - 区县账号:看本区县 Forge 在 `DataScopeType` 里专门定义了 `REGION`,而且做了两个细节。 第一,省级直接视为全部权限: ```java if (scopeType == DataScopeType.REGION && Integer.valueOf(1).equals(context.getRegionLevel())) { return; } ``` 第二,市级及以下会把本级和下级区划编码都解析出来,再生成 IN 条件: ```java Set regionCodes = dataScopeService.getRegionAndChildCodes(regionCode); return buildStringInCondition(fullColumnName, regionCodes); ``` 也就是说,选择呼和浩特市时,不是只查 `150100`,而是查 `150100` + 它下面所有区县编码。这类能力在政府项目里非常常见,但很多框架要自己扩展。 --- ## 八、无范围时为什么返回 1=0? 源码里还有一个安全兜底:当用户没有组织、没有自定义组织、没有行政区划时,不是放行,而是返回恒假条件。 ```java private Expression buildAlwaysFalse() { EqualsTo eq = new EqualsTo(); eq.setLeftExpression(new LongValue(1)); eq.setRightExpression(new LongValue(0)); return eq; } ``` 翻译成 SQL 就是: ```sql AND 1 = 0 ``` 这点很关键。权限系统最怕“拿不到范围就不加条件”。正确做法应该是:**拿不到范围,就查不到数据。** 宁可误伤,也不能越权。 --- ## 九、它和多租户是什么关系? 上一期我们拆过多租户。多租户解决的是:A 公司不能看到 B 公司的数据。 数据权限解决的是:A 公司内部,销售、主管、租户管理员分别能看哪些数据。 两者不是替代关系,而是叠加关系: ```sql 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、配置表、行政区划权限结合到一起。 这不是最简单的方案,但它更适合长期演进的企业后台。 --- ## 下一期拆什么? 横评第 4 期我准备拆 **接口加解密**:RSA 协商、SM4/AES 会话密钥、nonce 防重放、前后端怎么配合。 如果你更想看代码生成,也可以评论区扣: - `1`:接口加解密 - `2`:AI 代码生成 - `3`:Flowable 工作流接入 --- **源码自取**: - Gitee:https://gitee.com/ForgeLab/forge-admin - GitHub:https://github.com/yaomindong1996/forge-admin - 在线演示:http://www.dlforgelab.com:8084/forge/login (admin / 123456) > 你们项目的数据权限是怎么做的?Service 层手写、注解拦截,还是 SQL 改写?评论区聊聊,横评系列继续更新。