用注解把数据权限做成统一能力之前,我维护的那套老管理系统正在被业务方疯狂吐槽:A部门的销售能看到B部门跟进的客户,财务页面能拉出所有事业部的成本明细,更别提不同子公司的采购订单互相串——每个问题单提过来,最后都发现是某条Mapper的SQL少写了一个dept_id过滤条件。最让人头疼的是,这种问题和普通Bug不一样,它不报错、不抛异常,业务数据流水一样过,只是“能看到的范围不对”,属于细思极恐型故障。
后来我花了两个版本迭代,用自定义注解 + AOP切面 + MyBatis拦截器动态拼接SQL这套组合把数据权限收敛成了平台级能力。业务代码里不再有任何手写的where dept_id in (...)逻辑,只需要在Mapper方法上标一个@DataScope,就能自动按照当前登录人的部门、角色、数据范围去过滤。这篇文章就完整拆解这套方案的实现思路、关键代码、定制点,以及上线后踩过的几个大坑。
1. 为什么数据和菜单权限必须分开处理:一个被忽略的真实痛点
很多项目的权限设计到了菜单和按钮级别就停了,觉得“他能点进这个页面,自然只能看到他有权限的数据”。这个想法在早期的内部工具类系统里勉强成立,但一旦涉及多部门协作、多组织架构、上下游数据隔离,立刻崩盘。
1.1 菜单权限管的是“入口”,数据权限管的是“能看到多少”
菜单权限的本质是路由和页面元素的准入控制,解决的是“你能不能进入某个功能模块”的问题。数据权限解决的是“你进入了这个模块之后,数据集里哪些行对你可见”的问题。
举一个我实际遇到的例子:一个项目管理系统里有“全部项目”这个菜单,按菜单权限控制,所有项目经理都能进来。但A项目组的人进来后应该只能看到自己项目组的信息,结果因为project_member表查询时没有带团队过滤,所有人进来看到的都是全量项目列表。这种问题靠加菜单是解决不了的,你总不能给每个项目组单独建一个页面。
1.2 早期“人肉拼接SQL”方案为什么让维护变成了灾难
最开始我们为了解决这个问题,采取的是最原始的方式:在每个Mapper接口方法上增加一个deptId参数,然后在XML里手动拼AND dept_id = #{deptId}。刚开始只有两三个模块,看起来没什么问题,但随着业务线扩张,问题越来越多:
- 条件遗漏:新来的同事写SQL时不知道哪些表需要做数据权限过滤,验收时也看不出来,上线后才被业务发现。
- 代码侵入严重:Controller层要传当前登录人的部门信息,Service层要负责透传,Mapper参数对象里必须预留字段,改一个查询就要动三层代码。
- 权限规则难统一:同样是“部门数据权限”,有的模块要求
dept_id精确匹配,有的部门表是树形结构要包含子部门,有的还要联合创建人判断“本人数据优先”。
这种状态下,与其继续打补丁,不如把“数据过滤”这一步从业务SQL中剥离出来,做成一个独立的基础能力。我当时的方案选型目标是:业务SQL保持纯净,规则配置不侵入接口签名,框架统一拦截处理。
2. 三种常见实现方案对比:为什么我选注解 + 动态SQL而不是其他
数据权限的实现路径其实有不少,但每种都各有取舍。当时我调研了近十种网上流传的做法,最后真正进对比名单的有三个方向。
| 方案 | 核心思路 | 优点 | 明显缺陷 |
|---|---|---|---|
| 手写过滤SQL | 每个查询手动加WHERE条件 | 实现简单直观,可控性强 | 侵入严重,维护成本高,容易漏 |
| 编写MyBatis拦截器硬编码 | 拦截所有SQL,通过自定义规则生成条件 | 对业务代码透明 | 规则写死,扩展性差,误拦截风险大 |
| 自定义注解 + AOP + 动态SQL拦截 | 注解声明过滤规则,AOP解析上下文,拦截器拼接SQL | 灵活性高,业务零侵入,规则可配置 | 需理解底层原理,调试有一定门槛 |
2.1 为什么不选“所有Mapper方法统一拦截”
很多开源框架给数据权限的方案是:自定义一个MyBatis拦截器,拦截所有的Executor.query方法,对所有SQL统一做处理。看起来一劳永逸,但实际落地时很难受——每个表的过滤字段命名不一致,有的叫dept_id,有的叫department_id,有的表压根没有部门字段。拦截器的代码会变成一堆if (tableName.equals(...))的判断,每加一个模块就要动一次框架代码。
还有一个隐患是误拦截:比如一张纯字典表、一张配置表,本身不需要数据权限过滤,但统一拦截器无法识别,要么全放行要么全过滤。
2.2 注解方案的优势集中在“按需声明”和“规则分离”
这套思路借鉴了@Transactional这种声明式事务的设计哲学:把横切关注点从业务代码中抽出来,通过注解和AOP让框架自动处理。
自定义@DataScope注解,标在需要做数据权限过滤的Mapper方法上。注解里声明好这个查询“过滤哪个表别名、哪个部门字段、是否包含子部门等规则”,然后由AOP切面在方法调用前解析当前登录人的数据权限范围,生成SQL片段丢进ThreadLocal,最后由MyBatis拦截器在执行前拼进SQL。
这样带来的改变是:
- 业务SQL不携带任何权限参数,Mapper接口签名不用改动。
- 是否需要过滤,过滤哪些维度,完全由注解声明,一眼就能看出来。
- 权限规则统一在AOP切面中计算,不会出现同一个模块三套写法的诡异情况。
提示:这个方案对工程结构有一定要求——项目必须用MyBatis作为ORM层,且对Mapper的调用链路不能太绕。如果项目用的是全自动JPA,动态拼接SQL就没那么灵活了。
3. 自定义注解设计:把权限规则声明到Mapper方法上
要实现一套能落地的方案,第一步是先设计注解本身。这里的核心逻辑是:注解要能表达“这张表的哪个字段归属哪个权限维度”,而不是把过滤条件写死,否则就和硬编码没有区别了。
3.1 注解字段如何定义
我最终的注解设计如下:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { // 需要过滤的数据库表别名,对应SQL里的 AS 别名,例如 u String tableAlias() default ""; // 部门字段名,不带别名,例如 dept_id String deptField() default ""; // 用户字段名,用于仅本人数据的场景,例如 create_by String userField() default ""; // 是否包含子部门数据,默认不包含 boolean includeChildren() default false; // 权限维度,可选 DEPT_ONLY / USER_ONLY / DEPT_AND_USER / ALL DataScopeType scopeType() default DataScopeType.DEPT_ONLY; // 仅当当前用户是超级管理员时放行 boolean ignoreAdmin() default true; }tableAlias字段是最容易被忽略但又特别关键的设置。因为SQL里经常出现多表连接,直接拼dept_id IN (...)会产生歧义,所以必须在注解中指定字段归属的表别名,拼接条件时生成u.dept_id IN (...)这种规范格式。
scopeType枚举标识当前查询按照哪种数据范围过滤:
public enum DataScopeType { // 仅本部门 DEPT_ONLY, // 仅本人 USER_ONLY, // 本人及本部门 DEPT_AND_USER, // 全部数据,不做限制 ALL }3.2 在Mapper方法上的使用示例
public interface OrderMapper { @DataScope(tableAlias = "o", deptField = "dept_id", scopeType = DataScopeType.DEPT_ONLY) List<OrderVO> selectOrderPage(PageQuery query); @DataScope(tableAlias = "u", userField = "create_by", scopeType = DataScopeType.USER_ONLY) List<UserVO> selectOperateLogList(PageQuery query); }这样一眼就能看出来:订单查询按订单表的部门字段过滤,操作日志按创建人过滤。无论是接手代码的新人,还是做代码审查的同事,都不用去XML里面翻半天SQL才能理解这个查询的权限边界。
3.3 这里藏着一个设计细节
为了兼容“本人且本部门”“含子部门”这样的复杂规则,建议在注解上穷举必要的维度即可,不要把所有过滤规则都塞进注解。我见过有人把“是否包含下级”“是否包含本人”“是否去除重复”全部塞进注解字段,导致一个注解十几个参数,阅读体验极差。
我最终在切面里引入了一个DataScopeRule的上下文对象,注解只负责告诉AOP“这个查询需要部门过滤”,具体的过滤规则由切面从当前登录人信息和组织架构服务中实时计算,这样注解的语义才清晰。如果有特殊的自定义规则(比如根据业务字段再细分),可以通过扩展DataScopeRule的构建器来实现,而不是让注解无限膨胀。
4. 核心链路实现:AOP切面、ThreadLocal上下文、MyBatis拦截器三段协作
整套方案的日常使用只是加一个注解,但你心里必须清楚背后的三段链路是怎么协作的,否则出了问题根本无从下手。
4.1 第一段:AOP切面解析当前登录人的数据权限,生成规则
切面的作用是拦截带@DataScope注解的Mapper方法调用,在方法真正进入MyBatis执行器之前,把数据权限规则准备好。
@Aspect @Component public class DataScopeAspect { @Around("@annotation(dataScope)") public Object around(ProceedingJoinPoint joinPoint, DataScope dataScope) throws Throwable { try { // 1. 获取当前登录用户信息 LoginUser loginUser = SecurityUtils.getLoginUser(); // 2. 如果是超级管理员且注解允许放行,则直接执行 if (loginUser.isAdmin() && dataScope.ignoreAdmin()) { return joinPoint.proceed(); } // 3. 根据注解配置生成数据权限规则,塞入上下文 DataScopeRule rule = new DataScopeRule(); rule.setTableAlias(dataScope.tableAlias()); rule.setDeptField(dataScope.deptField()); rule.setUserField(dataScope.userField()); rule.setScopeType(dataScope.scopeType()); rule.setIncludeChildren(dataScope.includeChildren()); rule.setUserId(loginUser.getUserId()); rule.setDeptId(loginUser.getDeptId()); rule.setDeptIds(dataScopeService.getAccessibleDeptIdList(loginUser)); DataScopeContext.set(rule); // 4. 执行原方法 return joinPoint.proceed(); } finally { // 5. 必须清理 ThreadLocal,否则线程池复用会污染下一次请求 DataScopeContext.clear(); } } }这里有几个点需要展开说。
为什么用ThreadLocal:MyBatis拦截器在执行SQL时并拿不到当前登录用户的信息(它不在Controller的调用链里),所以必须通过ThreadLocal把AOP切面里算好的规则传递到拦截器里。这是三段流程能够串起来的核心。
为什么要finally清理:如果线程被Tomcat线程池复用,上一次请求遗留的规则会被下一次请求读到,导致权限越界或者突然查不到数据。这个问题我在第6部分会专门展开讲,上线初期就因为这个吃过亏。
4.2 第二段:MyBatis拦截器拿到SQL后动态拼接
拦截器的实现是基于MyBatis的Interceptor接口,通过拦截StatementHandler.prepare方法,在SQL真正发送到数据库前修改SQL文本。
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class DataScopeInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { DataScopeRule rule = DataScopeContext.get(); if (rule == null || !rule.isValid()) { return invocation.proceed(); } StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = statementHandler.getBoundSql(); String originalSql = boundSql.getSql(); // 生成新的SQL,把权限条件加进去 String newSql = buildDataScopeSql(originalSql, rule); // 通过反射替换BoundSql中的sql字段 Field sqlField = BoundSql.class.getDeclaredField("sql"); sqlField.setAccessible(true); sqlField.set(boundSql, newSql); return invocation.proceed(); } }这里的反射替换是MyBatis拦截器里的一个常规操作,因为BoundSql没有提供公开的修改SQL入口,只能通过反射改内部字段。虽然不优雅,但胜在稳定,业界的主流用法基本都是这样。
4.3 第三段:条件SQL动态拼接的位置选择
动态拼接最核心的问题是:权限条件到底插在SQL的哪个位置?
网上一部分实现采用的是“包一层子查询”的方式,把原始SQL变成:
SELECT * FROM ( 原始SQL ) tmp WHERE tmp.dept_id IN (...)这种方式实现简单,但有两个隐患。一是MySQL对临时表的优化很有限,大表查询性能直线下降;二是会破坏原本的ORDER BY、LIMIT语义,尤其和PageHelper分页插件叠加时,可能出现分页失效或者LIMIT被包进去的严重问题。
我最终采用的是“无缝插入到WHERE条件后”的思路,代码逻辑如下:
private String buildDataScopeSql(String originalSql, DataScopeRule rule) { String condition = buildConditionSql(rule); String upperSql = originalSql.toUpperCase(); if (upperSql.contains(" GROUP BY ")) { // 如果存在GROUP BY,条件必须插入在GROUP BY之前 int idx = upperSql.indexOf(" GROUP BY "); return originalSql.substring(0, idx) + " WHERE " + condition + originalSql.substring(idx); } else if (upperSql.contains(" ORDER BY ")) { // 如果存在ORDER BY,插入在ORDER BY之前 int idx = upperSql.indexOf(" ORDER BY "); return originalSql.substring(0, idx) + " WHERE " + condition + originalSql.substring(idx); } else if (upperSql.contains(" LIMIT ")) { int idx = upperSql.indexOf(" LIMIT "); return originalSql.substring(0, idx) + " WHERE " + condition + originalSql.substring(idx); } else { // 绝大多数单表分页查询会走这里 return originalSql + " WHERE " + condition; } }说明一下为什么这么处理:如果你的查询本来就是SELECT * FROM orders WHERE type = 1,直接在末尾拼WHERE dept_id IN (...)显然是错的,会变成WHERE type = 1 WHERE dept_id...。所以必须先判断原始SQL里有没有WHERE。上面的代码没有判断WHERE,只是保证了插入点在GROUP BY/ORDER BY/LIMIT之前——这样确实会漏掉已有WHERE的场景。
更完整的版本应当先判断是否包含WHERE,如果已含WHERE则拼AND,否则拼WHERE。我用了一个不算完美但非常直观的中间方案:先统一判断是否已经有WHERE关键字,有则拼接AND,没有则在定位点前添加WHERE。逻辑如下:
private String buildDataScopeSql(String originalSql, DataScopeRule rule) { String condition = buildConditionSql(rule); String upperSql = originalSql.toUpperCase(); // 判断原始SQL是否已包含WHERE boolean hasWhere = upperSql.matches(".*\\sWHERE\\s+.*"); String operator = hasWhere ? " AND " : " WHERE "; int insertPos = findInsertPosition(upperSql); if (insertPos == -1) { return originalSql + operator + condition; } return originalSql.substring(0, insertPos) + operator + condition + originalSql.substring(insertPos); } private int findInsertPosition(String upperSql) { int groupBy = upperSql.indexOf(" GROUP BY "); int orderBy = upperSql.indexOf(" ORDER BY "); int limit = upperSql.indexOf(" LIMIT "); int pos = -1; if (groupBy != -1) pos = pos == -1 ? groupBy : Math.min(pos, groupBy); if (orderBy != -1) pos = pos == -1 ? orderBy : Math.min(pos, orderBy); if (limit != -1) pos = pos == -1 ? limit : Math.min(pos, limit); return pos; }这段代码里用的\\sWHERE\\s+正则是为了匹配大写的“WHERE”此外还需考虑SQL里可能带换行、注释等情况,这是上线后要特别注意的细节。
4.4 条件SQL片段的生成规则
private String buildConditionSql(DataScopeRule rule) { StringBuilder sql = new StringBuilder("1=1"); String alias = rule.getTableAlias(); String aliasPrefix = StringUtils.isNotBlank(alias) ? alias + "." : ""; switch (rule.getScopeType()) { case DEPT_ONLY: // 本部门数据 sql.append(" AND ").append(aliasPrefix).append(rule.getDeptField()) .append(" = ").append(rule.getDeptId()); break; case USER_ONLY: // 仅本人数据 sql.append(" AND ").append(aliasPrefix).append(rule.getUserField()) .append(" = ").append(rule.getUserId()); break; case DEPT_AND_USER: // 本人创建的数据 或 本部门的数据 sql.append(" AND (").append(aliasPrefix).append(rule.getUserField()) .append(" = ").append(rule.getUserId()) .append(" OR ").append(aliasPrefix).append(rule.getDeptField()) .append(" = ").append(rule.getDeptId()).append(")"); break; default: break; } return sql.toString(); }这里需要注意一个安全隐患:字段名和别名来自于注解,是开发人员硬编码的,没有被外部篡改的空间,所以拼接SQL不会引入SQL注入。要刻意避免的是“把客户端传来的值拼进条件”,比如下拉筛选框里的deptId千万不能走这条链路拼接。
5. 多表关联、LEFT JOIN等复杂SQL的处理:不跳出这几个坑就会翻车
数据权限拼接SQL,单表单表查询最简单,但真实的业务查询80%都是多表关联。处理多表时,上面的基本逻辑就会暴露出几个隐藏问题。
5.1 表别名不统一导致的dept_id歧义
这是最常踩的坑。SQL里出现两张表都有dept_id字段时,只拼dept_id IN (1,2,3)的话,数据库直接报“字段不明确”。所以注解里的tableAlias字段就是专门解决这个问题的。
但要注意,使用tableAlias的前提是SQL里必须写了别名且别名要和注解一致。如果一个Mapper的SQL是FROM sys_user u LEFT JOIN sys_dept d ON u.dept_id = d.id,注解就必须写tableAlias = "u"。如果哪次写SQL时忘了写AS u,拼接出来的SQL就会报“未知列”。解决方式是:在MyBatis拦截器里做一次粗校验,如果拼出来的条件字段在原始SQL中找不到对应的别名前缀,就打印一条明显的错误日志,而不是让数据库报一个语义模糊的异常。
5.2 LEFT JOIN场景:条件放在WHERE和放在JOIN条件里,语义天差地别
多表关联时,如果把权限条件放在WHERE子句里,会把LEFT JOIN变成INNER JOIN。
举个例子:
SELECT u.name, o.order_amount FROM sys_user u LEFT JOIN order o ON o.create_by = u.id WHERE u.dept_id = 10这种SQL查的是“部门的用户及其订单”,但其实你的本意可能是“所有人的订单,但仅展示本部门用户下的订单”?这里权限条件挂在不同的表上,业务语义完全不同。
我的经验是:数据权限条件应该过滤的是“查询结果的主体行”。如果主体是订单,那权限字段应该取订单表上的归属字段,条件放进WHERE无可厚非;如果主体是用户且只是关联展示订单,那过滤用户表也是合理的。关键是切面里计算rule时,要清楚当前查询的主体是谁,然后在注解上正确指定tableAlias。
为了减少认知负载,我最终强制要求团队遵循一个原则:如果查询主体表的过滤字段明确,一律在主体表上过滤;如果确实需要按关联表的维度过滤,必须在XML里显式处理好JOIN的语义,比如把过滤条件写在子查询里,而不是让拦截器盲目地往WHERE后面堆。
5.3 聚合查询和子查询:动态拼接的边界在哪里
聚合查询比如SELECT dept_id, COUNT(*) FROM order GROUP BY dept_id,数据权限条件是加在GROUP BY之前还是之后?按上面的插入逻辑,会插在GROUP BY之前,生成... WHERE o.dept_id IN (1,2) GROUP BY dept_id,这就正确过滤了参与聚合的行。
但遇到多层嵌套子查询,比如:
SELECT * FROM ( SELECT o.*, u.name FROM order o LEFT JOIN sys_user u ON o.create_by = u.id ) t如果给最外层拼上WHERE o.dept_id IN (...),数据库会直接报错——最外层根本没有o这个别名。所以在遇到复杂嵌套SQL时,一个最稳妥的做法是在注解的设计上避开这种场景:让SQL的最外层写明表别名,条件就加在最外层能识别的字段上。
注意:如果你的项目里有很多这种复杂嵌套SQL,强烈建议先梳理一下这些查询的主体表是什么,尽量让最外层查询直接可过滤。实在不好改的,再在XML里手动加
${@DataScopeContext.buildCondition()},手动指定插入位置。这也是动态SQL方案的一个补充逃生通道。
6. 上线之后踩过的真实大坑:从故障表象到根因定位
任何方案只看原型跑通是不够的,真正的问题永远在上线后的真实流量下浮出水面。这里记录三个我实际遇到过的故障,排查链路和根因都值得复盘。
6.1 故障一:ThreadLocal没清理,复用的线程带来了别人的权限
现象:系统上线一周后,有用户反馈“明明我只属于A部门,为什么偶尔能看到B部门的数据,刷新一下又没了”。这种偶发性问题最难受,因为完全无法稳定复现。
排查过程:先怀疑是缓存,把Redis、MyBatis二级缓存全清了,问题依旧。后来在拦截器里加了日志,把每次请求的DataScopeRule打印出来,发现一个诡异规律:同一个登录用户连续多次刷新,前几次请求的ThreadLocal规则是空的,后几次突然带上了别人的deptId。
这时候意识到是Tomcat线程池复用的问题。AOP切面里的finally块没有在每次请求都执行——其中有一个查询接口在Mapper方法的冒号里做了一次异步操作,导致joinPoint.proceed()抛出的异常被吞了,finally里明明写了DataScopeContext.clear(),但有些种类的异常在内部被捕获了,没有向上抛到AOP切面,于是ThreadLocal里的数据没有被清掉,被线程池复用后串到了下一个用户。
修复方案:一是把DataScopeContext.clear()从finally改成一个独立的拦截器或Filter,在请求真正结束时执行,确保无论发生什么异常都会清一次;二是给ThreadLocal加一个remove()的防御性调用,而不是仅仅set(null)。
6.2 故障二:分页插件和数据权限拦截器顺序导致总数不对
现象:加了数据权限后,列表页分页总数没有变化,但点击第二页数据时跳到了别的部门数据。
排查过程:翻看MyBatis插件栈调用顺序,发现PageHelper的分页拦截器和我们的权限拦截器都实现了Interceptor接口,但执行顺序不由代码决定,而由@Intercepts注解的加载顺序决定。如果分页拦截器先执行,它会先执行COUNT查询,此时权限条件还没有拼上去,总数自然就是全量数据的总数,然后才会执行第二条带LIMIT的查询——而这条带LIMIT的查询在权限拦截器执行时才拼上过滤条件,于是总数与明细对不上,第二页数据还可能错位。
修复方案:通过调整插件加载顺序,让数据权限拦截器先于分页插件执行。使用MyBatis配置时可以用@Bean方式显式控制顺序,或者干脆把权限拼接逻辑放到StatementHandler的prepare阶段,因为PageHelper实际上是在Executor层面实现分页的,我们比它更早拿到SQL就能避免这个问题。
6.3 故障三:嵌套子查询里的别名导致条件拼接报“Unknown column”
现象:某报表模块上线后接口直接报SQLSyntaxErrorException: Unknown column 'r.dept_id' in 'where clause'。
排查过程:查看SQL日志,发现权限条件拼在了最外层SELECT * FROM (SELECT ... ) r上,拼的是r.dept_id IN (...),但子查询内部并没有把dept_id字段透出到外层,所以报错。这条SQL的注解写的是@DataScope(tableAlias = "r", deptField = "dept_id"),但实际上“r”这个外层表结构里根本没有这个字段。
修复方案:从那之后我定了一条硬性规范——使用注解动态SQL的前提是查询主体表的过滤字段必须出现在最外层SELECT列中。如果连最外层都看不到这个字段,框架就不该强制过滤。另外在拦截器里增加一个开关:如果原始SQL中找不到注解声明的“表别名.字段名”,日志直接报出明确的配置错误信息,而不是等数据库抛错后再人肉排查。
7. 框架落地时的几个扩展思路:从能用到好用
7.1 数据权限范围的动态化
上面提到的AOP切面里,setDeptIds(dataScopeService.getAccessibleDeptIdList(loginUser))这一段是权限范围动态化的关键。
如果业务方的组织架构是树形的,并且要求“部门负责人能看到本部门及所有子部门的数据”,那么这个deptIds就不能仅仅是当前登录人的deptId,而必须从组织架构服务里递归查询出所有下级部门ID。这一步使用递归SQL或者在Java里做树遍历都可以,我建议直接查部门树然后DFS收集ID,避免在业务高峰期反复调用数据库。
public List<Long> getAccessibleDeptIdList(LoginUser loginUser) { if (loginUser.getDeptId() == null) { return Collections.emptyList(); } List<Long> deptIds = new ArrayList<>(); collectChildDeptIds(loginUser.getDeptId(), deptIds); return deptIds; } private void collectChildDeptIds(Long parentId, List<Long> result) { List<SysDept> children = deptMapper.selectByParentId(parentId); for (SysDept child : children) { result.add(child.getId()); collectChildDeptIds(child.getId(), result); } }7.2 从注解过滤扩展到“白名单表”
这个方案上线半年后,我越来越觉得“在Mapper方法上标注解”还是稍微有一点点依赖开发人员的自觉。于是我在拦截器里增加了一个白名单机制:如果某张表在配置中心里被声明为“数据权限白名单表”,即使Mapper方法上忘了写@DataScope,也会按照默认规则自动过滤。
这个扩展适合那些数据表明确、规则统一、不允许有例外的模块,比如审计日志、操作流水这类核心敏感数据。把“默认不过滤”变成“默认必须过滤”,可以挡住绝大多数“忘写注解”导致的数据越权。
实现上也不复杂:在切面里如果发现当前Mapper调用的方法没有@DataScope注解,就根据Mapper的全限定名和表名去查配置中心,如果命中白名单规则,则自动生成一个默认的DataScopeRule。
7.3 嵌套调用的支持与防重入
AOP切面在拦截的时候,如果方法A加了注解,方法A内部又调用了同一个拦截器拦截的方法B,会出现DataScopeContext被覆盖的问题。这种情况我建议在DataScopeContext里使用栈结构,而不是单一的ThreadLocal变量:
public class DataScopeContext { private static final ThreadLocal<Deque<DataScopeRule>> STACK = new ThreadLocal<>(); public static void push(DataScopeRule rule) { Deque<DataScopeRule> deque = STACK.get(); if (deque == null) { deque = new ArrayDeque<>(); STACK.set(deque); } deque.push(rule); } public static DataScopeRule peek() { Deque<DataScopeRule> deque = STACK.get(); return deque == null ? null : deque.peek(); } public static void pop() { Deque<DataScopeRule> deque = STACK.get(); if (deque != null && !deque.isEmpty()) { deque.pop(); } if (deque == null || deque.isEmpty()) { STACK.remove(); } } }这样嵌套调用时,内层规则只影响内层SQL,外层方法执行完回到外层后,peek()还能拿到外层规则,不会串规则。
7.4 性能开销和监控
这套方案在SQL上多了一段字符串拼接和反射操作,对于一个每秒钟几千次查询的系统来说,开销其实可以忽略——真正要注意的是条件本身有没有命中索引。
我上线后对慢SQL做了一个多维度监控,重点盯三类问题:
- 权限条件里的
dept_id字段没有索引,导致过滤时全表扫描。 - 部门ID数量过大,
IN (...)里面动辄几百上千个ID,直接触发了MySQL的优化器阈值,相当于全表扫描。 - 分页SQL的COUNT查询里,权限条件让优化器选错了执行计划,导致COUNT本身比数据查询还慢。
针对这三种情况,解决方案分别是:优化表结构增加联合索引、把IN列表改写成临时表JOIN、以及用EXPLAIN逐条验证核心接口的执行计划。
写在最后的一点心得
从最初的手写WHERE条件,到现在的注解+动态SQL拦截,这套方案落地已经跑了接近一年。回过头看,最值钱的其实不是那几段拦截器代码,而是彻底改变了团队对数据权限的思维方式:它不再是一个页面一个页面去补的漏洞,而是平台层面统一收口的安全能力。
如果你所在的项目正被数据权限问题搞得焦头烂额,我的建议是不要急着写一万个WHERE dept_id,先停下来想清楚权限规则,再用注解或类似方式抽离成公共能力。等到所有查询的统一过滤都收口到一个点上的时候,你会感受到那种“一个注解管住一条查询”的踏实感。
最后再分享一个小技巧:上线初期一定要在日志里打印拼接后的SQL,方便遇到权限相关问题时快速对比“原始SQL”和“修改后SQL”的差异。我这个习惯帮我在排查故障时省下了大量时间。