数据权限为什么不能只靠注解?Forge 的 Mapper 层 SQL 改写源码拆解
做后台系统权限最容易被低估。很多项目把权限理解成菜单隐藏、按钮隐藏、接口加个注解。结果上线后才发现真正难的是数据权限销售只能看自己的客户部门主管能看本部门数据区域经理能看本区域 下级区域数据租户管理员能看本租户全部数据总管理员能看全部数据如果这些逻辑散落在 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 后追加数据权限条件核心类是DataScopeInterceptorMyBatis-Plus InnerInterceptor负责拦截查询并改写 SQL。DataScopeServiceImpl负责加载角色、组织、行政区划、Mapper 配置等元数据。SysDataScopeConfig配置某个 Mapper 方法用哪个字段做权限过滤。DataScopeType定义 7 种数据权限范围。先看拦截入口DataScopeInterceptor.beforeQueryini代码解读复制代码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 查询不能再套数据权限否则会递归。兼容分页 countMyBatis-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。核心方法是buildDataScopeSqlini代码解读复制代码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如果你把数据权限条件追加到外层 countscss代码解读复制代码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核心实体是SysDataScopeConfigarduino代码解读复制代码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代码解读复制代码SetString regionCodes dataScopeService.getRegionAndChildCodes(regionCode); return buildStringInCondition(fullColumnName, regionCodes);也就是说选择呼和浩特市时不是只查150100而是查150100 它下面所有区县编码。这类能力在政府项目里非常常见但很多框架要自己扩展。八、无范围时为什么返回 10源码里还有一个安全兜底当用户没有组织、没有自定义组织、没有行政区划时不是放行而是返回恒假条件。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把条件加到内层查询恒假兜底无可用范围时10防止越权元数据缓存平台配置预热到内存业务查询不反复打 sys_* 表所以我才说数据权限不是权限系统的附属品而是企业后台的核心基础设施。若依这类框架更多是把数据权限交给开发者自己处理Jeecg、芋道做了更完整的权限体系Forge 的特点是把它下沉到 Mapper 层和 XML SQL、JSqlParser、配置表、行政区划权限结合到一起。这不是最简单的方案但它更适合长期演进的企业后台。