ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot数据权限实战:注解+AOP+MyBatis插件实现行级数据隔离

SpringBoot数据权限实战:注解+AOP+MyBatis插件实现行级数据隔离

之前维护过的一个后台管理系统,上线第三周突然被运营负责人找上:订单列表,不同角色进去看到的数据必须不一样。运营专员只能看自己创建的工单,运营主管能看本小组的,部门负责人能看整个部门,财务要能看到全公司。菜单权限、按钮权限当时都做完了,唯独这个“行级数据可见性”是从零开始。当时我就意识到,SpringBoot 数据权限这件事,如果处理不好,后面每个查询接口都要背上巨大的维护成本。后来用“注解 + 动态SQL”的组合把这个问题收口了:业务代码里看不到任何权限判断,Mapper层依然写的是一句干干净净的查询,真正改SQL的工作全部由拦截器和切面接管。效果非常明显,代码量、维护难度、排查成本都降了一个量级,用东北话讲,真香。

这篇文章不打算从 RBAC、权限模型论文讲起,直接聊方案和落地。我会把为什么要动态拼接、注解里到底放什么信息、AOP怎么算权限范围、MyBatis 插件怎么改 SQL,以及我上线后踩过的几个坑全部写出来。适合那些项目里数据权限已经失控、或者正准备给系统加行级隔离的读者,尤其适合 SpringBoot + MyBatis 这个技术栈的团队。

1. 同样一个列表接口,不同账号看到的行为什么不一样

1.1 菜单权限管“能不能点”,数据权限管“能看到哪几行”

很多团队一开始会把数据权限和登录权限混在一起,总觉得把菜单一藏、按钮一灰,权限就“天然”隔离了。但用户永远是从业务角度理解权限的:他能看到一个页面,不代表他该看到这个页面里的所有数据。

举一个很常见的例子。订单列表这个接口,后端逻辑一模一样,但不同角色进去,SQL 里的 WHERE 条件必须不一样:销售只能看到create_by = 当前用户ID的订单,销售经理要看到部门下的所有订单,财务则不需要任何额外过滤。菜单权限解决的是“他能不能访问/order/list这个URL”,数据权限解决的是“同一个/order/list,SQL 执行时给他限多大的范围”。

这两者的实现层级完全不同。菜单权限挂在路由、Controller、按钮API上,是功能级别的;数据权限挂在查询执行的底层,是行级别的。你可以在 Controller 前做拦截,但绝对不能在每个 Service 方法里到处写if (user.getRole().equals("xx"))来判断该拼什么条件。那样等于把权限规则散落到业务代码的每一个角落,后面任何一个查询漏写了判断,就是一次实打实的数据越权。

这也是为什么后来大家倾向于做一个独立的权限注入层:所有数据权限相关的代码收敛到一个地方,业务方只负责在需要过滤的查询方法上声明一下,剩下的由框架统一处理。

1.2 硬编码权限条件的三种土办法,为什么后面都想删掉

我自己的项目里,最原始的写法是这样:Service 层拿到用户角色,然后分叉成两个 Mapper 方法。

if (user.hasRole("SALES")) { orderMapper.selectByUserId(user.getId(), page); } else if (user.hasRole("SALES_MANAGER")) { orderMapper.selectByDeptId(user.getDeptId(), page); } else { orderMapper.selectAll(page); }

这种写法最大的问题是“接口爆炸”。角色一变多,Mapper 方法成倍增长。今天多一个“区域经理”,你就要再加一个selectByRegionId;明天说销售也要看自己产品的数据,又要改。而且权限规则一旦和业务查询耦合在一起,代码Review的时候根本没人敢动,因为一个判断分支错了,数据就看到别人那里去了。

第二种土办法更离谱:查询不分权限,查出全量数据后在内存里用stream().filter()过滤。小数据量的时候看起来挺“优雅”,但表一超过十万行,接口直接被打回原形,数据库的压力全变成了应用内存的压力。这种方案在真实业务里撑不过三个月。

第三种是写在 MyBatis XML 里。每个查询下面挂一大段<if test="...">,代码里可能看着还算集中,但 XML 文件一旦膨胀起来,权限条件会和各种业务条件混在一起,缩进、嵌套、括号改错一处就是事故。更关键的是,你没有办法跨 Mapper 复用同一套权限规则,同一条“只看本部门”的逻辑,在每个 XML 里都要复制一遍。后来才发现,缺的不是更优雅的 if 写法,而是缺少一个能在 SQL 执行前统一改写 WHERE 的机制。

2. 方案分工:注解声明范围,AOP解析身份,拦截器改写SQL

2.1 一次带权限过滤的查询,经过了哪几个加工环节

先说清楚这套方案的完整链路。业务代码里写的是:

@GetMapping("/list") public PageResult<OrderVO> list(OrderQuery query) { return orderMapper.selectPage(query); }

Mapper 接口上标注一行注解:

@DataScope(type = DataScopeType.DEPT_AND_CHILD, alias = "o") Page<OrderVO> selectPage(OrderQuery query);

这里说的“动态SQL”,并不是 MyBatis XML 里那个<if>、<choose>的动态标签,而是指一条 SQL 在构造完成后、执行之前,被拦截器在运行期追加上了权限条件片段。整条加工链路大概是这样的:

  1. 请求进入 Service,调用被@DataScope标注的 Mapper 方法。
  2. Spring AOP 切面拦截到这次调用,从登录态拿到当前用户。
  3. 切面根据注解里的type,算出用户当前可见的数据范围,比如“本部门及子部门”,落到一个具体的部门ID集合。
  4. 切面把集合拼成一个 SQL 片段,例如o.dept_id IN (1001, 1002),写入一个临时上下文。
  5. Mapper 方法真正执行,MyBatis 拦截器或者 MyBatis-Plus 的插件在 SQL 执行前拦截它。
  6. 插件把上下文里的权限片段和原 SQL 的 WHERE 条件合并,最终得到一条带完整权限过滤的 SQL。

整个过程里,Service 方法本身不知道发生了什么,业务代码完全不用感知权限规则的存在。这也是我推荐它的核心原因:数据权限被抽象成了一条流水线,每个环节只干一件事。

2.2 数据范围抽象成四种枚举就够了,别在业务里到处写判断

聊权限范围时最怕的是业务方说“我们的权限很复杂,要支持任意组合”。真做了才知道,90% 的系统需要的范围就四种:

  • 全员可见,不需要过滤;
  • 仅本人,只看自己创建/负责的数据;
  • 本部门,部门内的人互相可见;
  • 本部门及子部门,适合组织架构有层级的管理者。

我把它们定义成一个枚举:

public enum DataScopeType { ALL, SELF_ONLY, DEPT_ONLY, DEPT_AND_CHILD }

为什么不要支持“任意树节点的可见范围”?因为用户能看到的组织树范围和数据表里的部门归属往往不是同一个模型。比如有个角色能看到“华东大区”的数据,而这个大区在组织架构里是“上海公司”“杭州公司”“南京公司”的父节点。你如果在每个业务查询里去判断“他属于哪个节点、这个节点下面挂哪些叶子”,那这个逻辑就得在几十个 Service 里复制。把它抽象成枚举以后,切面统一处理:根据当前用户所属部门,向上找角色配置的范围类型,向下收集子部门ID集合。组织架构变了,只改公共服务,业务代码一行不动。

面试的时候有人问我:“那如果就是有角色需要看华东大区加上苏州团队,怎么抽象?”我的答案是把“可见范围”本身做成可配置的数据,放在角色表里。AOP 解析的不是枚举值,而是从角色配置表里读出的一段可见范围描述,切换枚举只是默认实现。这个我放到后面第五节细说。

2.3 为什么是三件套,而不是直接在 Service 里拼 SQL

有人可能觉得,搞注解、搞切面、搞拦截器,是不是过度设计?直接在 Service 里手动拼一个queryWrapper或者拼接 SQL 不好吗?

不好。手拼 SQL 的问题在于“分散”。今天在订单查询里拼一段dept_id in (...),明天在客户查询里又拼一遍,权限规则就没有单一可信来源了,后面做审计、做权限回收都无从下手。而注解的特点是“声明式”,它把一个需求精确地表达在方法上:这个查询需要做行级过滤,范围类型是什么,主表别名是什么。IDE 能搜,代码评审能看,运行期也能通过反射拿到。

AOP 切面解决的是“身份解析”。数据权限最关键的一步是把当前用户变成一组可见的ID集合,这涉及角色、部门、组织架构的查询,逻辑不算短。放在切面里,它就只被处理一次;放在业务代码里,每个 Service 都要引用一遍。

拦截器才是真正“改写SQL”的地方。MyBatis 在执行前会生成 BoundSql,拦截器拿到它,把权限片段合并进 WHERE,再执行。这一步的好处是 Mapper XML 里不需要有任何权限判断,SQL 的主体逻辑保持原样,权限条件只是被“附加”进去的。天然适配所有的查询方法,包括 XML 里写死的复杂 SQL,只要条件里用到的字段存在于结果集查询中,都能覆盖。

3. 动手落地:枚举、注解、切面与MyBatis插件的完整实现

3.1 先定义清楚:范围枚举和注解参数的含义

结合上面的分析,我把注解设计成这样:

@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** * 查询的主表别名,多表关联时必须写,否则条件不知道挂在哪个表上 */ String alias() default ""; /** * 数据范围类型 */ DataScopeType type() default DataScopeType.DEPT_AND_CHILD; /** * 部门字段名,默认 dept_id */ String deptField() default "dept_id"; /** * 用户字段名,默认 user_id */ String userField() default "user_id"; }

为什么必须传alias?因为你不知道 Mapper 里写的 SQL 是什么结构。有可能这条查询只查一张表,字段名没有歧义;但真实项目里,几乎每个列表都是select o.* from t_order o left join t_order_item i ...。如果不带别名,拼出来的条件可能被数据库理解成用了别的表的字段,直接报“ambiguous column”,更危险的是没报错,但条件挂错了表,权限过滤形同虚设。

为了方便演示,我这里定一个简化的登录用户对象:

public class LoginUser { private Long userId; private Long deptId; private Set<Long> roleIds; // getter/setter 省略 }

3.2 AOP切面:把当前账号能看的数据算成一个SQL片段

切面是“权限计算”的核心。先说一个业务细节:即使范围是“本部门及子部门”,也一定要叠加“本人创建的数据永远可见”。这是我在真实项目里吃过亏的地方。一个人从A部门调到B部门之后,他以前创建的工单仍然归属A部门,如果新部门下看不到他本人创建的历史数据,业务上会认为“系统把工单弄丢了”。所以拼条件时,部门范围必须和本人条件用 OR 连接。

代码如下:

@Aspect @Component public class DataScopeAspect { @Around("@annotation(dataScope)") public Object handle(ProceedingJoinPoint point, DataScope dataScope) throws Throwable { try { LoginUser loginUser = LoginHelper.getCurrentUser(); if (loginUser == null) { return point.proceed(); } String segment = buildSegment(dataScope, loginUser); DataScopeContext.setSegment(segment); return point.proceed(); } finally { DataScopeContext.clear(); } } private String buildSegment(DataScope dataScope, LoginUser loginUser) { switch (dataScope.type()) { case ALL: return null; case SELF_ONLY: return "(" + dataScope.alias() + "." + dataScope.userField() + " = " + loginUser.getUserId() + ")"; case DEPT_ONLY: return "(" + dataScope.alias() + "." + dataScope.deptField() + " IN (" + String.join(",", VisibleDeptService.getVisibleDeptIds(loginUser, dataScope.type()).stream() .map(String::valueOf).toList()) + "))"; case DEPT_AND_CHILD: default: List<Long> deptIds = VisibleDeptService.getVisibleDeptIds(loginUser, DataScopeType.DEPT_AND_CHILD); String deptCondition = dataScope.alias() + "." + dataScope.deptField() + " IN (" + deptIds.stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"; String userCondition = dataScope.alias() + "." + dataScope.userField() + " = " + loginUser.getUserId(); return "(" + deptCondition + " OR " + userCondition + ")"; } } }

这里要注意几个点。第一,权限片段里的值都是根据登录用户算出来的后端数据,不是前端传入的字符串,所以拼接不会有 SQL 注入问题。第二,片段一定要整体加括号,后面讲到被 OR 支配的事故时你会理解为什么。第三,VisibleDeptService是专门负责组织架构查询的公共服务,它要做的事很简单:拿着当前用户的部门ID,递归或者用树表查询把所有子部门ID收集起来。部门层级深的话,这个查询本身要做缓存,否则每个接口请求都会多一次数据库IO。

ThreadLocal 上下文是我自己写的,非常轻:

public class DataScopeContext { private static final ThreadLocal<String> SEGMENT = new ThreadLocal<>(); private DataScopeContext() {} public static void setSegment(String segment) { SEGMENT.set(segment); } public static String getSegment() { return SEGMENT.get(); } public static void clear() { SEGMENT.remove(); } }

注意,finally里的clear()是必须的,不是可选项。Spring MVC 的请求线程会被线程池复用,ThreadLocal 的值不清掉,下一个请求就会拿到上一个用户的权限片段。这个坑我单独放在第四节讲。

3.3 MyBatis拦截器和MyBatis-Plus插件的落地写法

如果项目用的是原生 MyBatis,需要自己写一个 MyBatis 拦截器。拦截的目标是StatementHandler的prepare方法,在 SQL 真正发送到数据库前把BoundSql里的 SQL 字符串改掉。

@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) @Component public class DataScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String sql = boundSql.getSql(); String segment = DataScopeContext.getSegment(); if (StringUtils.hasText(segment) && sql.trim().toLowerCase().startsWith("select")) { String finalSql = InjectPermissionUtil.inject(sql, segment); Field sqlField = BoundSql.class.getDeclaredField("sql"); sqlField.setAccessible(true); sqlField.set(boundSql, finalSql); } return invocation.proceed(); } }

核心难点在inject方法。最简版本是这样:

public static String inject(String sql, String segment) { String lower = sql.toLowerCase(); int whereIndex = lower.lastIndexOf("where"); int orderByIndex = lower.lastIndexOf("order by"); String permission = " AND " + segment; if (whereIndex < 0) { if (orderByIndex < 0) { return sql + " WHERE " + segment; } return sql.substring(0, orderByIndex) + " WHERE " + segment + " " + sql.substring(orderByIndex); } return sql.substring(0, whereIndex + 5) + permission + " " + sql.substring(whereIndex + 5); }

这段代码只能演示原理。真实的 SQL 里可能有GROUP BY、HAVING、子查询、嵌套 JOIN,字符串替换稍不留心就会改坏位置。所以如果是生产项目,我强烈建议直接用 SQL 解析器来做,或者直接上 MyBatis-Plus 的DataPermissionInterceptor,它底层用的是 JSqlParser,能把 SQL 解析成语法树,在正确的位置挂载条件。

MyBatis-Plus 的写法要优雅得多。配置一个拦截器:

@Configuration public class DataScopeMybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new DataPermissionInterceptor(new DataScopeHandler())); return interceptor; } static class DataScopeHandler implements DataPermissionHandler { @Override public Expression getSqlSegment(Expression where, String mappedStatementId) { String segment = DataScopeContext.getSegment(); if (!StringUtils.hasText(segment)) { return where; } try { Expression permission = CCJSqlParserUtil.parseCondExpression(segment); if (where == null) { return permission; } return new AndExpression(where, permission); } catch (JSQLParserException e) { throw new RuntimeException("数据权限SQL解析失败: " + segment, e); } } } }

这段代码我有必要做几个补充说明。getSqlSegment接收两个参数,第一个是 MyBatis-Plus 自己解析好的 WHERE 条件,第二个是当前执行的 Mapper 方法ID。理论上你可以在方法ID上做更细粒度的控制,比如某个方法不想被数据权限干预,直接返回where原样放行。我项目里用过的版本是 3.5.4,DataPermissionHandler接口的方法签名就是这样。新版可能会有ignoreTable之类的默认方法,覆盖getSqlSegment依然是最核心的入口,具体以你依赖的版本为准。

只要执行到 Mapper 的这一条链路上存在DataScopeContext里的片段,拦截器就会把它和业务 WHERE 条件用AND连接起来。到这里,一个“注解 + 切面 + 拦截器”的数据权限组件就算完整了。

4. 上线后的坑:从多表别名到ThreadLocal串号的六次翻车

4.1 自调用失效:同一个类里this方法调用让切面失灵

最早踩的坑出现在 AOP 上。我在一个 Service 里写了两个方法,methodA调用methodB,@DataScope标在methodB上,结果那段时间线上接口疯狂越权,查了好久才发现切面根本没触发。

原因很简单:Spring AOP 用的是代理模式,外部调用methodA时,拿到的其实是一个代理对象;但methodA内部的this.methodB()是真实对象上的直接调用,根本没有经过代理,注解自然失效。

解决办法有几个。如果你用的是 Spring 4.3+,可以注入ObjectProvider<Service>让自己代理自己;或者把methodB挪到另一个 Bean 里面,通过跨 Bean 的调用触发 AOP;再或者用AopContext.currentProxy()显式取代理。总之记住一条:带数据权限注解的方法,绝对不要在同类的内部方法里直接this调用。这个坑在代码评审里非常隐蔽,因为它 “看起来” 很正常,只有切面和代理的底层机制才能解释。

4.2 括号缺失:OR条件让权限形同虚设的一次事故

权限条件拼出来以后,合并时括号特别容易出问题。当时我的权限片段长这样:

o.dept_id IN (1001, 1002) OR o.user_id = 3

业务 WHERE 长这样:

o.status = 1

拦截器用AND拼起来以后,整条 SQL 变成了:

SELECT ... FROM t_order o WHERE o.status = 1 AND o.dept_id IN (1001, 1002) OR o.user_id = 3

数据库的运算符优先级往往不是从左边按顺序读,而是AND先结合。整条条件解析成了:

(o.status = 1 AND o.dept_id IN (...)) OR o.user_id = 3

于是任何一个user_id等于 3 的用户,无论订单状态是不是 1,哪怕部门完全不在范围内,都能看到数据。权限直接被这个 OR 绕开了。当时是某报表金额对不上,追查了两天才定位到是拼接公式少了一对括号。

所以我在切面的buildSegment里整整给整个片区包了一层括号,这样无论AndExpression怎么合并,它永远是一个独立单元:

(o.dept_id IN (1001, 1002) OR o.user_id = 3)

再去和前一个条件做AND的连接,才真正安全。以后的评审里只要看到拼权限条件,第一件事就是检查括号的完整性和边界。

4.3 别名不一致:多表关联时条件落错表

第一次把@DataScope铺到全项目时,发现有些 Mapper 报“unknown column o.dept_id”,有些 Mapper 根本不报错,但查出来的数据过滤效果不对。逐个排查才发现问题出在别名。

有的 SQL 写的是select * from t_order o join ...,别名是o,注解里也写的是o,正常;有的 SQL 直接没写别名select * from t_order join ...,我拼的o.dept_id自然就报错;还有的 SQL 居然给订单表起了别名a,我注解里还是写的o,条件挂到了另一张表的字段上,权限彻底失效。

这个问题的根治方案是管好约定:所有需要做数据权限的表,在 SQL 里必须带一个固定的主表别名,并且与该表相关的查询统一用这个别名。注解里直接写死,故意不让它灵活。可能有人觉得这样限制太硬,但现实是“灵活”在权限场景里约等于事故。

如果表结构里根本没有dept_id这个字段,比如历史业务表,那要么在表设计上补列,要么干脆别给这个查询加注解。牢记:数据权限过滤的前提是数据模型里存在被过滤的字段。

4.4 分页Count不同步:总数比实际可见记录大

用 MyBatis-Plus 做分页时,框架会自动生成一条COUNT查询。最初用纯 MyBatis 拦截器版本时,我只改了原始查询 SQL,没管那条COUNT查询。结果列表数据是对的,但分页 total 永远是全表的总数,页面上的总页数比实际能翻的多出好几页,用户拼命往下翻,越到后面越空白。

这个坑的本质是“同一份权限条件没有应用于所有派生 SQL”。MyBatis-Plus 的DataPermissionInterceptor在设计时把 COUNT 查询和主查询放在同一条解析链路里处理,所以不会再出这个问题。这也是我后来果断把团队方案迁到 MyBatis-Plus 插件上的原因:不是懒,而是它把这些边角逻辑都兜住了。

如果你还在自己写拦截器,请一定记得:改写 SQL 的入口,不仅要处理主查询,还要处理同一次执行里所有通过BoundSql扩展生成的其他 SQL,比如 COUNT、批量操作中的关联语句。

4.5 ThreadLocal串号:线程池复用导致A看到了B的数据

这可能是所有数据权限方案里最要命的坑。最初我把DataScopeContext.clear()写在切面的afterReturning里,结果有一次接口抛了异常,afterReturning不执行,ThreadLocal 里的权限片段就一直留在当前线程上。

Spring Boot 应用的线程池会长期复用线程,下一个请求碰巧被分配到这条线程时,它读到的还是上一个用户留下的dept_id IN (...) OR user_id = ...条件,结果自然就是 A 用户看到了 B 用户的数据。这种问题非常难复现,因为不是每次请求都出问题,只有恰好走到那几条脏线程时才出现。

修复方案是立刻改成try/finally,在finally里clear();同时在 Web 层的Filter或拦截器最前端加一个兜底clear(),保证即使切面因为某种奇怪的原因没有走到,ThreadLocal 也不会残留。另外,如果你的代码里有异步线程、线程池任务,那就要特别注意:ThreadLocal本身不会跨线程传递,必须把片段作为参数显式传给异步任务,而不是在子线程里直接get。

4.6 大IN集合:几百个子部门拼出的SQL把数据库拖垮

“本部门及子部门”听着很合理,可现实是组织架构动不动十几层,子部门几百个,每次拼接出来的 IN 列表能有一千多个ID,SQL 字符串本身有几十 KB,数据库执行的时候可能直接放弃索引走全表扫描,接口响应时间从几十毫秒飙到两秒。

针对这个问题我分了两层处理。如果集合数量在可控范围内,比如几千以内,直接在IN列表上做,但要确保权限字段上有索引。如果组织规模特别大,就不要在 SQL 里写大IN了,改为“可见范围表”方案:把“用户 + 可见部门/可见业务范围”提前物化成一张表,查询时用EXISTS (SELECT 1 FROM visible_scope vs WHERE vs.user_id = 当前用户 AND vs.biz_id = o.id)来过滤。这样虽然多一次 EXISTS 判断,但走唯一索引反而更稳定,也避免了超长 SQL 对数据库解析器的压力。

普通后台系统一般不会遇到这个瓶颈,但如果你所在的业务是集团级组织架构,强烈建议第一次做权限范围核算时就评估好到底是 IN 还是 EXISTS。

5. 数据权限还能怎么扩展,以及这套方案的适用边界

5.1 把数据范围做成配置表,管理员自己在界面维护

上面用的枚举只是默认模型,业务复杂以后一定要把它升级成配置化。最经典的建模是四张表:

  • t_user:用户表
  • t_role:角色表
  • t_role_data_scope:角色数据范围表,存data_scope_type和可见部门集合
  • t_user_dept:用户部门关系,可能有多部门

管理员在后台给角色配置“数据范围”时,看到的选项就是“全部/本部门/本部门及子部门/仅本人”,另外还可以勾选可额外访问的部门。AOP 切面里解析身份时,先查角色配置,再查可见部门集合,最后拼出的片段和前面完全一样。这样做的好处是:业务方改权限不再需要发版,运营人员自己就能操作。

唯一的代价是要把角色数据范围查询加上缓存。切面是高频入口,如果每个请求都查一次角色配置表,数据库压力是肉眼可见的,我当时就用本地缓存加了个几分钟的过期时间,角色权限生效的即时性要求比较高的话,可以考虑 Redis 缓存加主动失效,不过这是另一个话题了。

5.2 同时按多个维度过滤,注解字段怎么设计

有的数据不止按部门隔离,还要叠加产品线。比如某个合同表,既要按部门过滤,又要按产品线过滤。这种场景最简单的是在注解里再加一个字段:

String bizField() default "";

AOP 拼接时,如果bizField不为空,再多拼一个条件进去。两个条件之间用AND连接。不过我建议不要搞得太多,实际项目里我见过有人在注解里加了五个字段,最后自己都忘了哪些查询会带哪些条件,维护成本非常高。数据权限的维度控制在两个以内比较合适,超过两个就应该考虑是不是把“可见性”从业务表里拆出来单独建模了。

5.3 边界:报表宽表、跨库与全量导出不该用这套

任何方案都有边界,数据权限这套也不例外。我踩过的反向教训是:报表系统里有一张几十个字段的大宽表,是数仓同步过来的,团队天真地想用同一套注解做行级过滤,结果拼接出来的条件严重干扰了执行计划,明明该走索引的查询变成了全表扫描。后来改成在报表的查询服务里单独处理权限,而不是让拦截器无脑注入。

跨库查询也一样。MyBatis 拦截器只能改当前数据源里执行的 SQL,如果你的查询是跨库联邦查询,后半段在另一个数据库上执行的逻辑,拦截器根本管不到。低频的管理员全量导出也不要加数据权限,导出本身就是管理者行为,过滤掉数据反而让管理者困惑“为什么导出来缺数据”。

这套方案最适合的就是:业务系统内部的普通列表查询、详情查询、分页查询,以及那些需要高频执行且数据模型里有明确部门ID/用户ID字段的场景。字段级脱敏(比如手机号中间四位打码、身份证号隐藏)是完全不同的维度,不要和数据权限混在一套拦截器里做,会变得非常难维护。

最后分享一个落地顺序上的体会。别一上来就全项目铺注解,先挑两三个高频查询接口试点,在切面里把改写前后的 SQL 打出来,拿到测试库EXPLAIN一遍,确认条件和索引能匹配上,再逐步推广。权限片段拼上去只是起点,真正要盯的是之后每个新写的 Mapper 有没有漏标注解。漏一个查询接口,可能就意味着一次安静的数据越权。我的做法是在代码评审里给“涉及多用户可见性数据的查询”加了一条硬性约定:必须显式标注@DataScope,或者确认不需要过滤后写清楚原因。这套机制跑了大半年,新增接口再没出过越权事故。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进