做了这么多年后台系统,功能权限说实话没太卡过我,最头疼的一直是数据权限:同一个订单列表,销售只能看自己名下那几十条,销售主管要能看整个小组,财务又要看全公司但看不到提成明细。这套“同页面不同数据范围”的规则如果靠到处写 if-else 硬凑,短期能上线,后面每一次组织调整都会把人逼疯。构建健壮的“数据权限控制系统”,核心就是把“谁能看什么数据”从业务代码中抽离出来,用分层设计去承载规则,再通过框架集成把规则无感注入到数据访问链路里。这篇文章会从方案选型、分层设计、框架落地到线上问题排查,完整梳理一遍我的最佳实践,给正在做权限体系的后端开发、架构师一个可以直接参考的路线。
1. 先把问题聊透:数据权限到底卡在哪
1.1 功能权限和数据权限是两码事
很多团队把权限系统做成了“菜单 + 按钮”,也就是给用户分配角色,角色关联一堆资源标识,登录后前端判断某个按钮显不显示。这套 RBAC 模型解决的是“你能不能点这个按钮”,但它完全管不到“你点了之后能看到哪些数据”。
我见过不少项目,功能权限做得挺漂亮,结果数据权限就在 Service 层里用代码写死了。比如查订单列表时先拿当前用户,再判断角色,如果是普通销售就强制拼一个 user_id = 当前用户的条件,如果是主管就再拼一个 department_id IN (子部门) 的条件。刚开始只有两三个角色还好,等业务跑起来,角色变多、组织层级变深、权限维度从“本人/本组/本部门”扩展到“本人及下级/本部门及下级/指定人/仅本人”之后,这些逻辑会蔓延到几十个 Mapper 方法里。
出问题的不只是代码写得丑。审批流、定时任务、报表导出、消息推送这些场景都有数据查询,如果每个入口都手写一套过滤逻辑,漏掉一个就相当于数据越权。很多时候线上“数据泄露”根本不是被攻击,而是某个新加的后台查询接口忘了加数据权限条件,导出的 Excel 把所有客户的合同金额全带出去了。所以功能权限管的是“门禁”,数据权限管的是“房间里的每一个抽屉”,不能用门禁代替锁抽屉。
1.2 为什么不用数据库视图和前端控制
数据权限的实现思路,大多数团队绕不开这么几条路,我先说结论,再解释为什么我会选择“框架层拦截 SQL”这套方案。
前端隐藏数据这种方案基本可以直接否定。前端隐藏只是 UI 层面的操作,接口返回的完整数据依然在网络里传输,用户抓个包或者直接调接口就能看到全部,这属于安全性上的自欺欺人。数据库视图方案倒是能把行级过滤下沉到数据库,比如针对不同角色建不同的视图,但实际维护很痛苦:一张订单表关联了十几张表之后,光视图就有几十个版本,业务字段一变更就要同步改视图,而且视图里执行计划一旦因为权限条件没走对索引,DBA 会找你拼命。
真正主流的做法就两种:一种是在持久层框架里做 SQL 拦截,自动改写用户要执行的 SQL,加上强制过滤条件;另一种是引入独立的数据权限中间件,统一代理所有数据源访问。对于绝大多数中大型业务系统,前者更现实,因为它能跟现有技术栈深度融合,团队学习成本低,改动范围也容易控制。我这次的项目就是在 Spring Boot + MyBatis-Plus 这条技术栈上,用拦截器的思路来实现行级数据权限控制。
1.3 先用一张表看清需求边界
在动手设计之前,我把业务方提出的数据权限需求整理成了一张矩阵表,这也是后面做规则模型的基础:
| 角色 | 数据范围 | 规则描述 | 典型场景 |
|---|---|---|---|
| 普通销售 | 仅本人 | 创建人 = 当前用户 | 查看自己的客户和跟单记录 |
| 销售主管 | 本部门及下级部门 | 部门ID在本人管辖部门树内 | 查看团队业绩 |
| 大区经理 | 指定区域 | 区域编码 IN (华东, 华南) | 跨区域销售分析 |
| 财务专员 | 全部订单,隐藏提成字段 | 无行级限制,但脱敏部分列 | 财务核算 |
| 系统管理员 | 全部数据 | 跳过所有行级规则 | 配置维护 |
这张表看起来简单,但隐藏了一个关键问题:同一个用户在不同页面、不同操作上的数据范围可能完全不同。比如销售主管在“销售看板”能看全部门数据,在“客户编辑”只能改自己名下客户,在“导出对账单”反而只能导出自己跟进的客户。如果规则只挂在角色上,根本覆盖不了。这也是为什么我需要一套能精确到“Mapper 方法”级别的数据权限控制方案,而不是用户角色级别的粗粒度方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分层设计:把复杂规则拆成能维护的四层架构
2.1 依赖倒置:业务代码不感知权限规则
分层设计听起来像套话,但数据权限这套逻辑如果不分层,最后一定会变成一锅粥。我之前见过一个案例,为了支持“不同的菜单入口看到不同范围的数据”,有人在 Controller 层根据菜单 ID 拼 SQL 条件,再传个 params 给 Mapper,Mapper 的 XML 里写满了 <if test="deptScope != null"> 这种动态判断。这种实现最大的问题是规则藏在业务调用链路的各个角落,想梳理清楚到底谁能看什么,得把代码从头翻到尾。
好的分层设计应该遵循依赖倒置的思路:业务代码只声明“我这个查询需要做数据权限控制”,但完全不关心规则细节;真正决定规则是“本人”还是“本部门”的逻辑,被收敛到独立的权限规则层;而把规则翻译成 SQL 条件并拼进查询的任务,交由框架拦截器去完成。业务、规则、注入三者之间通过明确定义的接口交互,谁都不依赖谁的具体实现。这套思路和嵌入式软件里的分层思想其实是相通的:上层逻辑不直接操作寄存器,而是通过驱动层接口访问硬件,目的都是降低耦合、提高可维护性。
2.2 四层职责划分
我最终把整个数据权限控制系统分成四层,每一层只解决一个问题:
第一层是用户上下文层。它负责回答“当前操作者是谁、属于哪些组织、拥有哪些角色”。这层通常在网关或登录拦截器里构造好,放到一个可跨线程传递的上下文中。没有这层,后面规则再复杂也无从谈起。这里最容易踩的坑是上下文只存了一个 userId,等真正执行权限判断时发现还需要部门 ID、角色编码,又得回表查一遍,性能损耗全浪费在无意义的重复查询上。
第二层是规则配置层。它负责把“销售主管只能看本部门及下级部门的客户”这种自然语言翻译成结构化的规则配置。规则不只包含具体条件,还包含绑定关系:哪个 Mapper 方法需要启用哪条规则,什么条件下跳过规则。我把这些规则存储到独立的规则表里,并通过注解把 Mapper 方法和规则编码关联起来。这样做的好处是,业务方调整某个角色的数据范围时,运营人员在后台改一下配置即可,不需要发版重启。
第三层是规则解析层。它读取配置的规则和水用户上下文,将结构化规则翻译成可执行的过滤条件。比如把“部门树包含”翻译成“当前用户管辖部门ID列表”,再进一步翻译成 SQL 里的 department_id IN (...)。这一层是整个系统的核心,设计时必须保证它输出的是一个干净且可组合的表达式,方便后续拼接 AND、OR 条件。
第四层才是持久层注入。它通过 MyBatis 的拦截器机制,在 SQL 真正执行前拿到原始 SQL,将第三层生成的表达式以强制条件的形式注入到 SQL 的 WHERE 子句中。选择在框架层注入,而不是在业务代码里手动追加条件,最大好处是:所有查询入口都被统一拦截,没有漏网之鱼。
2.3 规则模型设计案例
规则配置层不能存一句废话,必须转成可计算的模型。我没有直接存 SQL 片段,是因为 SQL 片段无法做细粒度的逻辑组合和操作审计,而且容易被注入脏条件。我用类似的 JSON 结构来描述规则:
json复制{
"ruleCode": "ORDER_VIEW",
"description": "订单查看范围",
"enable": true,
"conditions": [
{
"field": "dealer_id",
"operator": "IN_USER_DEPT_TREE",
"valueRef": "currentUser.deptId"
},
{
"field": "order_status",
"operator": "NOT_IN",
"value": ["CANCELED", "CLOSED"]
}
],
"logic": "AND"
}
这个结构里有几个关键字段值得展开说一下。field 不是直接写数据库列名,而是写业务字段名,解析层再根据映射关系翻译成真实表结构的列名,这样规则配置和数据库表结构解耦。operator 支持的不是简单等于不等于,而是扩展了一套语义丰富的算子:IN_USER_DEPT_TREE 表示筛选字段的值必须落在当前用户管辖的部门树范围内,EQ_USER 表示字段值必须等于当前登录用户,ALLOW_ALL 表示跳过控制。valueRef 用来标明值从哪里取,最常见的来源就是当前登录用户上下文。
这套模型能覆盖前面需求矩阵里的绝大多数场景。如果以后要加一个“只看本周创建的订单”的动态规则,只需要新增一个 DATE_RANGE 算子,在解析层做日期范围计算即可,不需要动任何业务代码。
3. 框架集成:Spring Boot + MyBatis-Plus 的落地实践
3.1 为什么推荐基于 DataPermissionInterceptor 扩展
用 MyBatis 的同学都知道,MyBatis-Plus 内置了一批拦截器,其中 DataPermissionInterceptor 就是专门给数据权限预留的扩展点。它和另一个多租户插件 TenantLineInnerInterceptor 不同,多租户插件是硬编码地给所有 SQL 追加租户 ID 条件,适合 SaaS 系统强隔离场景;而 DataPermissionInterceptor 更像是一个开放接口,允许你根据每个 Mapper 方法返回自定义的 SQL 条件表达式,正好匹配前面分层设计里“按方法级别控制”的诉求。
我曾经纠结过要不要自己从零写一个 MyBatis 拦截器,但对比之后放弃了,原因很简单:MyBatis-Plus 的拦截器已经在处理 SQL 解析、表名识别、JOIN 关联这些复杂问题上做得相当成熟。自己写拦截器就得处理各种 SQL 方言和边缘情况,比如子查询里的表怎么关联、UNION 查询怎么处理,没几个月的持续打磨很难保证不翻车。站在巨人的肩膀上,把精力集中在规则解析和业务适配,才是性价比最高的路径。
3.2 核心接口实现
DataPermissionInterceptor 需要配合一个 DataPermissionHandler 实现类来使用。接口的核心方法签名如下:当 MyBatis 执行 SQL 时,会调用 getSqlSegment 方法,传入已经解析出的原始 WHERE 表达式和当前执行语句的 MappedStatementId。我们在这个方法里完成规则匹配、上下文获取、条件拼接,就能在 SQL 执行前拿到最终带权限条件的表达式。
下面是我在项目里的核心实现逻辑:
java复制public class DataScopeHandler implements DataPermissionHandler {
private final DataScopeRuleService ruleService;
@Override
public Expression getSqlSegment(Expression where, String mappedStatementId) {
LoginUser user = UserContextHolder.get();
if (user == null || user.isSuperAdmin()) {
return where;
}
// 从 Mapper 方法上的注解解析规则编码
DataScopeRule rule = ruleService.getRuleByStatementId(mappedStatementId);
if (rule == null || !rule.isEnable()) {
return where;
}
// 将结构化规则翻译为 JSqlParser 的表达式
Expression dataScopeExpr = DataScopeTranslator.translate(rule, user);
if (dataScopeExpr == null) {
return where;
}
if (where == null) {
return dataScopeExpr;
}
return new AndExpression(where, dataScopeExpr);
}
}
这里有个容易被忽略的细节:方法返回的表达式最终会和业务 SQL 里原有的 WHERE 条件做 AND 组合。也就是说业务代码里写了 WHERE order_status = 'PAID',拦截器返回 dealer_id IN (...),最终执行条件是 WHERE order_status = 'PAID' AND dealer_id IN (...)。这种追加方式天然安全,不会改变原有查询意图,只会缩小数据范围。
要把规则和 Mapper 方法关联起来,我用的是自定义注解:
java复制@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
String rule() default "";
String tableAlias() default "";
}
这里必须设计 tableAlias 参数,因为实际项目中的 Mapper 方法几乎都会连表查询,而权限条件往往只作用在其中一张业务表上。如果 SQL 写的是 SELECT * FROM order o LEFT JOIN order_item i ON o.id = i.order_id,而规则要约束的是 order 表,那拼接出的条件必须是 o.dealer_id IN (...),不能是 dealer_id IN (...),否则数据库会因列名歧义直接报错。
3.3 配置注入与启动
配置这块实际上非常简单,你把 DataPermissionInterceptor 注册成 MyBatis-Plus 的内部拦截器即可:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor(DataScopeHandler dataScopeHandler) {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new DataPermissionInterceptor(dataScopeHandler));
return interceptor;
}
}
一个重要的顺序问题:数据权限拦截器必须放在分页插件之前。分页插件执行时会对原始 SQL 做 COUNT 统计,如果数据权限追加在分页之后,会出现 COUNT 语句统计了全表数据但分页结果只有部分数据的严重问题。更实际的表现是,列表总数始终比实际能看到的数据多一截,用户翻到最后一页发现只有几行,怎么查都查不明白。
注册好插件后,原有的业务代码完全不用改。比如 Mapper 里有一个 selectOrderList 方法,只要在这个方法上加上 @DataScope(rule = "ORDER_VIEW"),拦截器就会在运行时自动把对应的权限条件拼接到 SQL 上。对业务开发来说,感知几乎为零,这是框架集成方案最大的优势。
3.4 规则解析层的翻译细节
规则解析层真正的技术含量在于,如何把结构化规则翻译成带正确列名和值集合的 JSqlParser 表达式。我在项目里封装了一个 DataScopeTranslator,核心是一个策略工厂,不同的 operator 对应不同的翻译策略,例如:
EQ_USER翻译成column = 当前登录用户IDIN_USER_DEPT_TREE先从用户上下文取出“管辖部门ID集合”,再翻译成column IN (集合)IN直接翻译成column IN (配置的固定值集合)ALLOW_ALL直接返回 null,表示不加限制
java复制public class DeptTreeOperatorStrategy implements OperatorStrategy {
@Override
public Expression translate(RuleCondition condition, LoginUser user) {
Set<Long> deptIds = user.getManagedDeptIds();
if (deptIds == null || deptIds.isEmpty()) {
// 一个部门都没管的人,不允许看任何数据
return new EqualsTo(new Column("1"), new LongValue("0"));
}
String columnName = TableColumnMapper.map(condition.getField());
InExpression inExpression = new InExpression();
inExpression.setLeftExpression(new Column(columnName));
// 把值集合转换成 JSQLParser 的 ItemsList
ExpressionList valueList = new ExpressionList(
deptIds.stream().map(LongValue::new).collect(Collectors.toList())
);
inExpression.setRightItemsList(valueList);
return inExpression;
}
}
这段代码里有几个细节值得强调。第一,当用户管辖的部门集合为空时,不能直接返回 null,否则等于对该用户放行了全部数据,这是绝对的安全事故。我返回 1 = 0 这个恒假条件,让查询结果强制为空。第二,部门和用户的映射关系如果特别大,IN 条件里的值数量可能会非常多,超过一两千个值就要考虑把部门树翻译成“左值右值”的区间条件。树结构用 left/right 编码后,判断一个节点是否在子树下只需要 lft BETWEEN parent.lft AND parent.rgt,性能和可读性都远胜于塞一两千个ID的 IN 列表。
4. 性能、并发与安全边界:健壮性不只是功能正确
4.1 规则缓存与解析缓存
数据权限规则如果每次请求都查一遍数据库,那系统根本扛不住。而且规则解析本身也有计算成本。所以我在规则配置层和解析层各加了一层缓存:规则配置缓存放在 Caffeine 本地缓存里,按规则编码缓存;解析结果缓存按“规则编码 + 用户ID + 用户管辖部门版本号”组合缓存。用户部门变更时,通过版本号让历史缓存自动失效。
加缓存不能影响安全。这里最容易出问题的点是,如果规则配置改了但缓存没刷新,用户可能继续用旧规则看到范围之外的数据。我的做法是:规则配置表里加一个全局版本号字段,每次增删改规则时版本号加一,应用侧每隔几秒主动拉取版本号,发现变化后清空本地缓存。这个方案实现简单,效果非常稳,远比我见过的通过 Redis 订阅通知缓存刷新要容易维护。
4.2 用户上下文在线程间的传递
用户上下文存在 ThreadLocal 里,单请求线程内读起来非常方便。可一旦代码里用了线程池、异步任务或者消息消费,子线程就拿不到用户上下文了。更危险的情况是线程池的线程被复用,上一次请求的用户信息被下一个请求读到,导致权限判断串号,A 用户看到了 B 用户的数据范围。
解决线程池传递问题我推荐用阿里开源的 TransmittableThreadLocal,它能在提交任务时把父线程的上下文快照带到子线程,任务执行完再恢复。项目中所有异步订单通知、报表导出都用这种方式传递用户上下文。消息消费者这种场景根本不走用户请求链路,一般处理的是系统级任务,数据权限规则应该单独规划,比如统一指定“系统管理员”身份去执行,而不是依赖消息体里某个字段猜用户。
4.3 更新和删除操作同样不能放过
很多团队做数据权限,拦截器只管 SELECT 查询。后台管理页面上一个“批量删除”按钮,前端把选中的一批订单 ID 传到后端,Service 层直接调用 Mapper 的 delete 方法。如果 DELETE 语句没有走拦截器,一个普通销售就可以通过构造请求删除他权限范围之外的订单——这是系统里非常典型的数据越权漏洞。
MyBatis-Plus 的 DataPermissionInterceptor 不只作用于 SELECT,对于 UPDATE 和 DELETE 同样会做 SQL 改写。所以业务侧使用 UPDATE、DELETE 时必须同样加上 @DataScope 注解,把行级过滤规则应用到写操作上。同时我要求团队在代码评审里必须检查写操作的数据权限注解,绝不能出现“查询有权限控制,更新删除裸奔”的情况。
4.4 常见的安全边界总结
| 边界场景 | 风险 | 对策 |
|---|---|---|
| Mapper 方法没加 @DataScope | 用户可查全量数据 | 静态扫描 + 代码评审双关卡 |
| DBA 直接连库执行 SQL | 绕过应用规则 | 生产库账号最小化授权,从基础设施上收口 |
| 定时任务无用户上下文 | 规则跳过导致任务失败或数据越权 | 定时任务固定使用特权服务账号并显式声明 |
| 报表导出接口只隐藏了页面列 | 底层数据仍越权 | 导出服务必须通过同一套权限链路查询 |
这些边界如果不在设计阶段考虑清楚,系统上线之后再补漏洞成本非常高,有些甚至要返工到数据模型。
5. 高频问题与排查技巧实录
5.1 上线后最容易遇到的四个问题
把问题按出现频率排了个序,排名第一的是拼接条件没有带别名。连表查询的 SQL 只要有两个或以上表且存在同名字段,比如 id 字段订单表和客户表都有,拦截器生成的条件不带表名或别名,数据库直接抛 Column 'id' in where clause is ambiguous。解决思路是解析 SQL 时先解析出主表或指定业务表的别名,再通过别名构造条件。MyBatis-Plus 的解析上下文里能拿到相关表的信息,但逻辑还是自己写更可控。
排名第二的问题是统计总数不对。前面提到分页插件和拦截器顺序问题之外,还有一种情况是业务框架自己执行了 COUNT 语句,并且 COUNT 语句里带了业务的条件,权限拦截器在解析这种简化版 COUNT 语句时无法匹配到表的别名,导致条件被静默丢弃。排查时会发现列表模块显示总数远大于实际总数。我的建议是写一个集成测试用例,专门针对每一个加了 @DataScope 的分页查询方法校验 COUNT SQL 最终是否包含了权限条件。
排名第三的是子查询被漏掉。比如用户要查“最近有下单记录的客户列表”,SQL 里通常会出现 WHERE EXISTS (SELECT 1 FROM order WHERE customer_id = c.id)。如果规则要求只能看本部门的客户,但权限条件只拼到了外层客户表上,子查询里的 order 表却没加条件,用户可以通过子查询的关联把其他部门客户的订单也带出来。处理这类情况,需要让规则解析器感知主 SQL 和子查询中都存在的权限表,逐个追加条件。
排名第四的是权限字段值类型不匹配。订单 ID 在业务里用了字符串或者雪花 ID,部门 ID 是 Long,但 IN 条件里的值可能被序列化成字符串,数据库隐式转换性能奇差,甚至直接报错。遇到这类问题只能回到模型中把字段类型定义清楚,在翻译阶段就完成类型强转,而不是等到数据库里踩坑。
5.2 一个真实排查案例:查询超时变成了越权事故
我印象最深的一次线上事故,是报表中心上线一个“全公司销售漏斗”大屏接口。开发同学图方便,直接查了订单表和客户表,认为大屏数据本来就是要给管理层看的,没加任何数据权限注解,也没走统一的查询入口。结果接口被一个销售角色的人用浏览器请求到了,返回了全公司所有漏斗数据。幸好是内部系统,影响还不算太大,但这件事直接推动了团队推进“所有数据查询必须显式声明数据权限上下文”的规范,任何查询接口不允许裸奔访问数据表。
从那以后,我们在数据访问中间件上还加了一层兜底策略——对没有声明权限规则的查询,不允许跨越用户所属组织范围返回数据。对于确实无需控制的公共接口(比如系统字典、枚举配置),显式加入白名单。兜底策略虽然让部分开发要多写两行声明,但换来的是“默认安全,显式放开”的心智模型。
5.3 问题速查表
| 故障现象 | 可能原因 | 排查建议 |
|---|---|---|
| 列表总数远大于实际翻页数据总数 | 分页插件与拦截器顺序不对 | 检查 MybatisPlusInterceptor 的内部拦截器顺序 |
| SQL 报列名歧义 | 权限条件未绑定表别名 | 检查 @DataScope 的 tableAlias 与 SQL 实际别名是否一致 |
| 普通用户看到全量数据 | Mapper 方法漏加 @DataScope | 全局搜索新增 Mapper 方法,review 权限注解 |
| 同一用户在不同方法看到的数据范围不一致 | 规则配置错误,或语句 ID 绑定错方法 | 查看规则配置缓存,核对 statementId 与规则关联 |
| 部门调整后用户数据范围没变 | 用户上下文里的部门版本号未更新 | 检查部门树同步逻辑和解析缓存失效条件 |
排查这类问题的通用手段是开启 MyBatis 的 SQL 日志,把最终执行的 SQL 打出来看 Where 条件是否符合预期。这一步能定位八成问题。我还会在测试环境加一个 SQL 分析任务,把包含敏感业务表但缺少权限条件的 SQL 记录下来形成审计报告,强制推动研发整改。
6. 最后聊点实在的经验
分层设计不是把代码塞进几个包里就完事,而是要让每一层的变更能被独立完成和验证。我后来做数据权限时最深刻的体会是,把“规则是什么”和“规则怎么执行”分开,整个系统的复杂度瞬间就降下来了。业务方改数据范围,运营改配置;开发加新查询,加个注解;框架层优化注入逻辑,业务无感知。这套边界清晰的分层、闭环的缓存与兜底策略组合在一起,才配得上“健壮”两个字。
再分享一个小技巧:数据权限规则上线前,一定要让业务方给每个角色准备好“最小用例集”,比如普通销售只能看到本人数据这种用例,必须在测试环境写进自动化用例里反复跑。权限系统最怕的不是功能不会做,而是改着改着把某个边界条件改没了。这类回归测试虽然是偏运维的脏活累活,但它是守住数据安全底线最有效的一道防线。
