资讯动态

用注解+AOP+MyBatis拦截器统一实现数据权限过滤

发布时间:2026/9/10 20:13:50 来源:尧图企业网站定制
用注解把数据权限做成统一能力之前我维护的那套老管理系统正在被业务方疯狂吐槽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) ListOrderVO selectOrderPage(PageQuery query); DataScope(tableAlias u, userField create_by, scopeType DataScopeType.USER_ONLY) ListUserVO 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(); } } }这里有几个点需要展开说。为什么用ThreadLocalMyBatis拦截器在执行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(11); 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 ListLong getAccessibleDeptIdList(LoginUser loginUser) { if (loginUser.getDeptId() null) { return Collections.emptyList(); } ListLong deptIds new ArrayList(); collectChildDeptIds(loginUser.getDeptId(), deptIds); return deptIds; } private void collectChildDeptIds(Long parentId, ListLong result) { ListSysDept 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 ThreadLocalDequeDataScopeRule STACK new ThreadLocal(); public static void push(DataScopeRule rule) { DequeDataScopeRule deque STACK.get(); if (deque null) { deque new ArrayDeque(); STACK.set(deque); } deque.push(rule); } public static DataScopeRule peek() { DequeDataScopeRule deque STACK.get(); return deque null ? null : deque.peek(); } public static void pop() { DequeDataScopeRule 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”的差异。我这个习惯帮我在排查故障时省下了大量时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价