第 2 期拆了多租户,很多人留言说:多租户解决的是“租户之间不能串”,那同一个租户内部,部门、角色、个人之间的数据权限怎么做?这一期继续拆源码。
做后台系统,权限最容易被低估。
很多项目把权限理解成:菜单隐藏、按钮隐藏、接口加个注解。结果上线后才发现,真正难的是数据权限:
如果这些逻辑散落在 Service 里,最后一定变成一堆 if role == xxx、if deptId in xxx,越写越乱。更可怕的是,某个查询忘了加过滤条件,就直接数据越权。
这一期不讲概念,直接拆 Forge Admin 的数据权限实现:它不是在 Service 层拼条件,而是在 MyBatis Mapper 层用 JSqlParser 改写 SQL。
先给结论:
| 方案 | 优点 | 问题 |
|---|---|---|
| Service 层手写条件 | 简单直接 | 容易漏、重复代码多、不可统一审计 |
| 注解 + AOP 拼参数 | 侵入小 | 复杂 SQL、分页 count、JOIN 场景难处理 |
| Mapper 层 SQL 改写 | 对业务透明、统一兜底 | 实现复杂,需要处理分页、别名、子查询 |
Forge 选的是第三种。
很多项目一开始都这么写:
if (!user.isAdmin()) {
query.eq("create_by", user.getId());
}
或者:
if (scope == ORG) {
query.in("dept_id", user.getDeptIds());
}
短期看没问题,长期看全是坑:
LambdaQueryWrapper 很快撑不住。所以 Forge 立了一个很强的约束:查询类 SQL 写在 Mapper XML 中,数据权限按 mapperMethod 精确匹配配置,再统一改写 SQL。
这也是为什么项目规范里强调:查询类 SQL 禁止在 Service 层用 LambdaQueryWrapper 拼。不是为了教条,而是为了让 DataScopeInterceptor 能精确接管。
Forge 的数据权限链路可以简化成 5 步:
用户登录态
↓
计算当前用户数据范围(角色、组织、行政区划)
↓
根据 mapperId 查 sys_data_scope_config 配置
↓
JSqlParser 解析原始 SQL
↓
在 WHERE 后追加数据权限条件
核心类是:
DataScopeInterceptor:MyBatis-Plus InnerInterceptor,负责拦截查询并改写 SQL。DataScopeServiceImpl:负责加载角色、组织、行政区划、Mapper 配置等元数据。SysDataScopeConfig:配置某个 Mapper 方法用哪个字段做权限过滤。DataScopeType:定义 7 种数据权限范围。先看拦截入口(DataScopeInterceptor.beforeQuery):
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() 临时跳过。_mpCount 查询,Forge 会还原成原始 mapperId 查配置。mapperMethod。Forge 的 DataScopeType 定义了 7 种范围:
| 类型 | 含义 | 典型场景 |
|---|---|---|
ALL |
全部数据 | 超级管理员 |
SELF |
本人数据 | 普通销售、普通员工 |
ORG |
本组织数据 | 部门主管 |
ORG_AND_CHILD |
本组织及子组织 | 分公司负责人 |
CUSTOM |
自定义组织 | 临时授权、跨部门项目组 |
TENANT_ALL |
本租户全部 | 租户管理员 |
REGION |
行政区划 | 政务/区域运营项目 |
源码里有一个兼容历史编码的映射方法:
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 改写只消费这个上下文。
拿到配置和上下文后,Forge 开始改写 SQL。
核心方法是 buildDataScopeSql:
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();
这段代码做的事很直接:
SELECT * FROM customer WHERE status = 1
如果当前用户只能看本人数据,就会变成:
SELECT * FROM customer WHERE status = 1 AND create_by = 10001
如果当前用户能看本部门及子部门,就会变成:
SELECT * FROM customer WHERE status = 1 AND dept_id IN (10, 11, 12)
关键点在于:它不是字符串拼接,而是 SQL AST 改写。JSqlParser 解析 SQL 语法树,再把权限条件作为表达式追加进去,比手工拼字符串安全得多。
很多数据权限插件在分页场景会翻车。
MyBatis-Plus 分页通常会把原 SQL 包成:
SELECT COUNT(*) FROM (原始 SQL) TOTAL
如果你把数据权限条件追加到外层 count:
SELECT COUNT(*) FROM (...) TOTAL WHERE t.dept_id IN (...)
这时外层根本没有 t 这个别名,SQL 直接报错。
Forge 专门处理了这个坑:
if (mapperId.endsWith("_mpCount") || mapperId.endsWith("_COUNT")) {
actualMapperId = mapperId.replaceAll("(_mpCount|_COUNT)$", "");
}
然后用 resolveDataScopeTarget() 递归找到真正需要追加条件的内层查询:
if (plainSelect.getFromItem() instanceof ParenthesedSelect parenthesedSelect
&& (plainSelect.getJoins() == null || plainSelect.getJoins().isEmpty())) {
PlainSelect nestedSelect = resolveDataScopeTarget(parenthesedSelect.getSelect());
if (nestedSelect != null) {
return nestedSelect;
}
}
这就是源码文里最值得看的地方:不是“我支持数据权限”,而是分页 count、子查询、别名这些真实场景都处理了没有。
数据权限不是所有表都按同一个字段过滤。
create_by 过滤本人。dept_id 过滤部门。region_code 过滤行政区划。所以 Forge 把过滤字段放进 sys_data_scope_config,核心实体是 SysDataScopeConfig:
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> 开头时,可以用占位符:
<sql>(lc.current_handler_id = #{userId} OR lc.register_person_id = #{userId})
支持的占位符包括:#{userId}、#{tenantId}、#{orgIds}、#{customOrgIds}、#{regionCode}、#{regionCodes} 等。复杂业务不用硬塞成单字段模式。
很多后台框架的数据权限只做到“本人/部门/子部门”,但政务、能源、运营商、区域代理项目经常需要行政区划权限:
Forge 在 DataScopeType 里专门定义了 REGION,而且做了两个细节。
第一,省级直接视为全部权限:
if (scopeType == DataScopeType.REGION && Integer.valueOf(1).equals(context.getRegionLevel())) {
return;
}
第二,市级及以下会把本级和下级区划编码都解析出来,再生成 IN 条件:
Set<String> regionCodes = dataScopeService.getRegionAndChildCodes(regionCode);
return buildStringInCondition(fullColumnName, regionCodes);
也就是说,选择呼和浩特市时,不是只查 150100,而是查 150100 + 它下面所有区县编码。这类能力在政府项目里非常常见,但很多框架要自己扩展。
源码里还有一个安全兜底:当用户没有组织、没有自定义组织、没有行政区划时,不是放行,而是返回恒假条件。
private Expression buildAlwaysFalse() {
EqualsTo eq = new EqualsTo();
eq.setLeftExpression(new LongValue(1));
eq.setRightExpression(new LongValue(0));
return eq;
}
翻译成 SQL 就是:
AND 1 = 0
这点很关键。权限系统最怕“拿不到范围就不加条件”。正确做法应该是:拿不到范围,就查不到数据。 宁可误伤,也不能越权。
上一期我们拆过多租户。多租户解决的是:A 公司不能看到 B 公司的数据。
数据权限解决的是:A 公司内部,销售、主管、租户管理员分别能看哪些数据。
两者不是替代关系,而是叠加关系:
WHERE tenant_id = 1
AND dept_id IN (10, 11, 12)
租户拦截器先把外层边界框住,数据权限再做租户内部的精细过滤。这也是为什么企业后台不能只做 RBAC,更不能只做菜单权限。
总结一下,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 工作流接入源码自取:
你们项目的数据权限是怎么做的?Service 层手写、注解拦截,还是 SQL 改写?评论区聊聊,横评系列继续更新。