做后台系统,权限最容易被低估。
很多项目把权限理解成:菜单隐藏、按钮隐藏、接口加个注解。结果上线后才发现,真正难的是数据权限:
- 销售只能看自己的客户
- 部门主管能看本部门数据
- 区域经理能看本区域 + 下级区域数据
- 租户管理员能看本租户全部数据
- 总管理员能看全部数据
如果这些逻辑散落在 Service 里,最后一定变成一堆 if role == xxx、if 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());
}
短期看没问题,长期看全是坑:
- 每个 Service 都要写一遍:用户、订单、合同、工单、报表,每个业务都要判断。
- 新接口容易漏:某个开发写了个导出接口,忘了加过滤,直接越权。
- 复杂 SQL 不好处理 :JOIN、多表查询、子查询,
LambdaQueryWrapper很快撑不住。 - 无法统一配置:哪个 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
}
这个入口有几个细节很关键:
- 支持跳过开关 :后台任务、系统级操作可以用
DataScopeContextHolder.executeWithoutDataScope()临时跳过。 - 跳过自身 Mapper:数据权限自己的 Mapper 查询不能再套数据权限,否则会递归。
- 兼容分页 count :MyBatis-Plus 分页会生成
_mpCount查询,Forge 会还原成原始 mapperId 查配置。 - 只有配置过的方法才改写 :不是所有 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、配置表、行政区划权限结合到一起。
这不是最简单的方案,但它更适合长期演进的企业后台。
下一期拆什么?
横评第 4 期我准备拆 接口加解密:RSA 协商、SM4/AES 会话密钥、nonce 防重放、前后端怎么配合。
如果你更想看代码生成,也可以评论区扣:
1:接口加解密2:AI 代码生成3:Flowable 工作流接入
源码自取:
- Gitee:gitee.com/ForgeLab/fo...
- GitHub:github.com/yaomindong1...
- 在线演示:www.dlforgelab.com:8084/forge/login (admin / 123456)
你们项目的数据权限是怎么做的?Service 层手写、注解拦截,还是 SQL 改写?评论区聊聊,横评系列继续更新。