
做后台系统的朋友一定对这句话不陌生“这个模块只要让用户看到自己部门的数据就行。”我第一次接到类似需求时第一反应是在每个Mapper的SQL里加where dept_id ?。十几个Mapper改完以后产品又说“经理要看到整个公司主管要看到本部门及下级”于是每个方法又多了一堆if判断。最可怕的是线上出过一次事故一个导出接口的SQL漏加了部门条件某个普通员工把全公司的订单数据拉走了。从那以后我就下定决心数据权限这种横切逻辑绝不能继续散落在业务SQL里。这个项目后来我用Spring Boot MyBatis重做了权限层做法是把数据权限收敛到MyBatis拦截器里业务侧只需要在Mapper方法上加一行注解或者干脆不加靠默认规则SQL会自动附加组织过滤条件。本文记录的就是这套无侵入数据权限方案的完整落地过程从MyBatis插件机制到底层SQL改写从拦截器代码到实际踩坑全部是我在真实项目里验证过的做法。适合正在做中后台管理系统、SaaS多租户隔离、报表数据隔离的Spring Boot开发者也适合想把MyBatis插件机制真正吃透的人。1. 数据权限为什么难做从逐接口修补到拦截器方案1.1 散落在业务代码里的权限条件最终都会变成事故隐患数据权限问题几乎不会在系统上线第一天出现因为早期数据量少、用户少一个超管账号能查全部数据也出不了大乱子。等到数据量上来、角色分工变细产品就会提各种“分权”需求。这时候你打开项目一看SQL已经散落在几十个XML文件里有的写在Service层用MyBatis-Plus的LambdaQueryWrapper拼条件有的直接在注解SQL里写死了部门ID有的干脆查全量到内存再过滤。这种做法的核心痛点是权限条件和业务条件完全耦合。业务条件变权限条件可能被误删权限规则变所有相关SQL要重新过一遍。更隐蔽的问题是“漏”而不是“错”——你没有意识到某个查询也应该带权限条件等数据泄露出去才反应过来。我经历的事故就是典型导出功能上线时只测了功能没人测试“A部门的账号导出B部门数据”这个场景因为代码里根本没有这个约束。1.2 无侵入方案的前提找到所有SQL的必经之路要解决“漏”和“散”的问题思路很直接把权限逻辑放到一个所有数据库操作都必须经过的位置。在MyBatis框架里这个位置就是Executor。不管你是用XML写SQL还是用注解写SQL还是MyBatis-Plus帮你生成SQL最终所有数据库操作都会经过Executor的query和update方法。如果在这两个方法上做拦截从源头改写SQL业务代码层面就完全不需要感知权限逻辑的存在。这也是标题里“无侵入”的真正含义不是业务代码一行都不用写而是权限规则和业务SQL解耦业务人员不需要关心自己的SQL是怎么被加上权限条件的。业务侧的“一行代码”只需要表达这条查询应该按什么维度过滤。我说下合理的期望边界这套方案解决的是“按组织架构/归属人自动过滤数据”这类横切问题适合行级权限。如果是字段级权限A角色看不到手机号、B角色能看到或者数据本身需要复杂的密级标签计算拦截器方案也能做但复杂度会上升很多我在第7节会单独聊。1.3 为什么不是AOP、不是改XML、不是过滤器很多人听到无侵入第一反应是Spring AOP。AOP确实可以在Service层做权限校验但AOP拿不到SQL也拿不到MyBatis的参数绑定信息。你可以在AOP里记录“谁调用了这个Service”然后自己去拼条件但拼出来的条件还是得传给Mapper本质还是在业务代码里穿针引线。改XML是另一个思路在XML中为每个SQL预埋一个${params.dataScope}占位符由统一逻辑填充。但这要求所有人写SQL时都记得留占位符漏写一个就是漏洞依然没有解决“默认安全”的问题。而拦截器方案的好处是即使开发在XML里只写了select * from order_info拦截器也会把它改写成select * from order_info where dept_id in (...)默认情况下就是安全的。Filter/InterceptorServlet层面的更不现实它们是HTTP层面的概念一个Mapper方法可能被多个Service调用也可能在定时任务、MQ消费里执行Servlet过滤器和这些场景完全不搭边。2. MyBatis插件体系里改SQL这件事到底发生在哪一层2.1 四大核心对象的分工MyBatis的插件机制基于四大核心对象Executor、StatementHandler、ParameterHandler、ResultSetHandler。它们是整个执行链路的主角Executor负责调度维护一级缓存、二级缓存把SQL交给StatementHandler执行是所有SQL的入口。StatementHandler负责创建JDBC的Statement处理参数、执行SQL。ParameterHandler负责把Java参数绑定到JDBC占位符上。ResultSetHandler负责把JDBC返回的结果集映射成Java对象。MyBatis允许你通过Interceptor对这四类对象做代理包装。代理的创建逻辑在Plugin.wrap里本质是JDK动态代理等到对应的方法被调用时进入你实现的intercept方法。这样我们就可以在真正执行SQL之前或之后插入自己的逻辑典型的应用就是数据权限、多租户隔离、分页、慢SQL日志。2.2 为什么选择拦截Executor而不是StatementHandler拦截SQL听起来拦截StatementHandler更“贴近底层”但我的建议是拦截Executor。原因有三点第一Executor的query和update方法入参里有MappedStatement和参数对象。MappedStatement里持有SQL、参数映射等完整信息参数对象也直接暴露在这里方便我们做条件追加和参数绑定。第二Executor同时覆盖查询和更新两类操作。数据权限不只影响selectupdate和delete同样要带条件。拦截一个Executor比分别拦截StatementHandler下的三种Statement更省事。第三业界成熟插件分页插件、MyBatis-Plus的多租户插件基本都挂在Executor层。和它们保持同层顺序协调起来更简单。如果有的插件在Executor、有的在StatementHandler那插件之间的互相影响会非常不好排查。2.3 必须理解的一个隐藏前提BoundSql已经完成参数解析很多教程教你在拦截器里拿BoundSql后改SQL然后往里加#{deptId}占位符我在这里把话说死这个做法基本是错的。原因在于拦截器拿到的BoundSql它的sql字段已经不是原始的XML文本了而是经过SqlSource解析、把#{}替换成JDBC预编译占位符?之后的SQL。同时BoundSql内部维护了一个parameterMappings列表记录每个?对应的参数位置、类型、属性名。如果你在拦截器里往SQL末尾追加dept_id #{dataScopeDeptId}MyBatis不会再对这段文本做二次解析#{dataScopeDeptId}会被当成普通字符串交给JDBC然后你对着一个没有对应参数值的?自然就报错。正确的做法有两种要么直接在SQL文本里拼上具体数值内部系统可用需要注意注入与转义要么用MetaObject同步修改parameterMappings并注入参数值。这两种方案我在第4节都给出了代码。这个细节是很多“看起来能用”的拦截器实现跑不起来的根因也是你打算动手前最值得先理解的一点。3. 落地实现注解规则、拦截器骨架与SQL改写3.1 用注解表达权限规则业务侧一行代码解决先设计权限维度。实际项目中典型的数据权限范围一般是这几种范围类型含义生成条件ALL查询全部数据不加条件DEPT本部门dept_id 当前部门IDDEPT_AND_CHILD本部门及下属部门dept_id in (本部门及子部门ID集合)SELF仅本人create_by 当前用户ID用一个注解来表达这条规则Target({ElementType.METHOD, ElementType.TYPE}) Retention(RetentionPolicy.RUNTIME) public interface DataScope { /** 需要附加权限条件的表别名比如 u 对应 sys_user u */ String alias() default ; /** 部门字段名默认 dept_id */ String deptColumn() default dept_id; /** 用户字段名仅 SELF 模式下使用 */ String userColumn() default create_by; /** 数据范围类型 */ DataScopeType type() default DataScopeType.DEPT_AND_CHILD; /** 跳过权限过滤适合登录、公开查询等场景 */ boolean ignore() default false; }用法就是在Mapper接口方法上Mapper public interface OrderMapper { DataScope(alias o, type DataScopeType.DEPT_AND_CHILD) ListOrderVO selectOrderList(Param(status) Integer status); }加了注解之后这条查询执行时拦截器会自动把SQL改写成类似select * from order_info o where o.status ? and o.dept_id in (?, ?, ...)顺序无所谓重要的是开发人员不需要在XML里写任何权限条件了。3.2 拦截器骨架拿到MappedStatement、找到注解、改SQL核心拦截器长这样Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class DataScopeInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; // 1. 判断这个Mapper方法是否需要走数据权限 DataScope dataScope DataScopeHelper.findDataScope(ms.getId()); if (dataScope null || dataScope.ignore()) { return invocation.proceed(); } // 2. 获取当前登录用户上下文没有用户上下文就跳过比如定时任务 UserContext user UserContextHolder.get(); if (user null || user.isSuperAdmin()) { return invocation.proceed(); } // 3. 获取并改写SQL BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql(); if (StringUtils.isBlank(sql) || sql.trim().toUpperCase().startsWith(INSERT)) { return invocation.proceed(); } try { SqlRewriter rewriter new SqlRewriter(sql, dataScope, user); String newSql rewriter.rewrite(); // 4. 用MetaObject修改BoundSql内部的sql字段 MetaObject metaObject SystemMetaObject.forObject(boundSql); metaObject.setValue(sql, newSql); // 5. 参数绑定相关处理见第4节 rebindDataScopeParams(boundSql, parameter, rewriter.getParams()); } catch (Throwable t) { // 安全兜底解析失败时宁可让查询报错也不允许不带权限条件执行 throw new DataScopeException(data scope rewrite failed: ms.getId(), t); } return invocation.proceed(); } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { } }四个步骤说清楚第一步通过ms.getId()拿到Mapper的全限定名。DataScopeHelper.findDataScope先按方法签名精确匹配注解如果方法上没配再向上找类级别的注解让“整个Mapper默认开启数据权限”成为可能个别方法用ignore退出。这种默认开启的策略比“每个方法都要手动加注解”安全得多。第二步取用户上下文。UserContextHolder是一个基于TransmittableThreadLocal的全局容器请求进来时由登录拦截器填充拦截器执行完后必须在finally里清理否则线程池复用线程会把上一个用户的数据权限带到下一个请求里这是线上“串数据”的头号原因。第三步判断SQL类型。INSERT不需要追加权限条件但UPDATE、DELETE必须处理。如果一个UPDATE/DELETE在改写后依然没有WHERE条件我建议直接拒绝执行后面会讲。第四步修改BoundSql。注意不是改原始SQL字符串而是用SystemMetaObject.forObject(boundSql)反射修改内部字段这也是MyBatis官方插件ExecutorInterceptor的标准用法。3.3 条件生成部门ID还是部门ID集合生产环境几乎没有“当前用户只有一个部门”的简单场景一个用户可能同时属于多个部门下级部门还可能继续嵌套。所以在生成条件前要先把部门树展开成ID集合public class UserContext { private Long userId; private Long deptId; private SetLong deptIds; // 包含本部门及所有下级部门 private SetLong roleIds; private boolean superAdmin; } public class ConditionBuilder { public static ListParamValue build(DataScope dataScope, UserContext user) { switch (dataScope.type()) { case ALL: return Collections.emptyList(); case DEPT: return List.of(new ParamValue(dataScope.alias() . dataScope.deptColumn(), , user.getDeptId())); case DEPT_AND_CHILD: return List.of(new ParamValue(dataScope.alias() . dataScope.deptColumn(), IN, user.getDeptIds())); case SELF: return List.of(new ParamValue(dataScope.alias() . dataScope.userColumn(), , user.getUserId())); default: throw new IllegalStateException(unsupported scope type); } } }我一直强调不要在拦截器里直接拼dept_id 1001这种条件因为部门ID集合来自前端的查询参数时会有注入风险。虽然改写SQL发生在权限层但生成条件的数据来源必须被认为是不可信的。4. SQL改写最容易翻车的细节JSqlParser解析与参数绑定4.1 引入JSqlParser并解析SQLSQL改写不是字符串拼接必须是真正的语法解析再生成。我用的方案是JSqlParser这是一个纯Java的SQL解析库可以把SQL解析成语法树改完树结构后再转回SQL文本。依赖如下dependency groupIdcom.github.jsqlparser/groupId artifactIdjsqlparser/artifactId version4.9/version /dependency核心操作public class SqlRewriter { private final Statement statement; public SqlRewriter(String sql) throws JSQLParserException { this.statement CCJSqlParserUtil.parse(sql); } public String rewrite(DataScope scope) throws JSQLParserException { if (statement instanceof Select select) { rewriteSelect(select, scope); } else if (statement instanceof Update update) { rewriteUpdate(update, scope); } else if (statement instanceof Delete delete) { rewriteDelete(delete, scope); } return statement.toString().replace(\r, ).replace(\n, ); } }用解析树而不是字符串拼接的最大好处是不管SQL里有多少换行、空格、注释解析后都是统一的结构。你不会因为某条SQL的格式不规范而写出错误的追加逻辑。4.2 别名问题85%的SQL改写Bug都出在表名上最典型的错误是SQL里写了select * from order_info o你生成的条件却是dept_id ?数据库会报dept_id列不明确。因为表有别名o所有字段引用都必须带o.前缀。正确的做法是先从语法树里取出主表private void rewriteSelect(Select select, DataScope scope) { SelectBody body select.getSelectBody(); if (!(body instanceof PlainSelect plainSelect)) { // 复杂查询UNION、子查询等这里先走兜底见4.3 return; } Table table (Table) plainSelect.getFromItem(); String alias scope.alias(); if (StringUtils.isBlank(alias) table.getAlias() ! null) { alias table.getAlias().getName(); } if (StringUtils.isBlank(alias)) { alias table.getName(); } Expression condition buildCondition(alias, scope); if (condition null) { return; } if (plainSelect.getWhere() null) { plainSelect.setWhere(condition); } else { plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), condition)); } }关于别名我要多说一句最好在注解里显式声明alias而不是完全依赖自动判断。原因是复杂的SQL里可能出现多表JOIN主表是哪个、每个表各自的别名是什么靠代码猜很容易错。开发人员写注解的时候顺手把主表别名写上反而比自动解析更可靠、更有可读性。这也是我这个方案和“全自动零配置”方案之间的取舍零配置意味着更多魔法遇到复杂查询容易失控。4.3 UNION、子查询、JOIN等复杂SQL怎么处理数据权限的实施过程中最麻烦的不是普通单表查询而是报表类和统计类的复杂SQL。我的处理原则是单表查询直接给主表追加条件最简单也最常用。JOIN查询如果权限条件能落在主表上直接给主表追加条件如果权限条件必须落在被驱动的子表上就需要递归去找目标表。JSqlParser本身支持遍历表达式树但代码会复杂很多。UNION/子查询/带括号的查询体尽量解析到最内层的PlainSelect如果结构太复杂无法安全定位主表我建议直接抛错而不是跳过权限。之前和一个朋友聊他们的实现他们在复杂SQL场景下选择了“跳过并告警”理由是复杂统计查询通常走独立的报表库或独立的Service。但我个人不推荐这个策略因为一旦允许跳过就存在跳过被滥用或遗忘的可能。宁可让这类SQL在开发阶段就暴露出来由人工确认是否需要权限也不要线上静默越权。4.4 UPDATE/DELETE必须有的兜底保护数据权限不只能加在SELECT上UPDATE和DELETE更要强迫带上条件。如果一个UPDATE语句本身没有WHERE拦截器改写后仍然没有WHERE那么执行结果就是全表更新这比数据泄露还严重。所以我的规则是UPDATE/DELETE在权限改写完成后如果WHERE仍然为空直接抛异常private void rewriteUpdate(Update update, DataScope scope) { Expression condition buildCondition(update.getTable(), scope); if (condition null) { return; } if (update.getWhere() null) { update.setWhere(condition); } else { update.setWhere(new AndExpression(update.getWhere(), condition)); } if (update.getWhere() null) { throw new IllegalStateException(update without where is forbidden by data scope); } }同样逻辑删除场景is_deleted 0也走这条链路拦截器会把逻辑删除条件和数据权限条件合并成and is_deleted 0 and dept_id in (...)。逻辑删除条件不该在拦截器里写死而应该由开发人员在XML里维护拦截器只负责叠加权限条件。4.5 参数绑定两种方案怎么选这是全文最硬核的一处值得多花点笔墨。方案A直接把值拼进SQL文本适合企业内部系统、权限条件全部由服务端计算生成、不存在用户输入的简单场景。优点是实现简单缺点是SQL文本里带了实际值一方面日志里会暴露部门ID另一方面如果条件值来源不可控就是注入隐患。代码大概是String paramSql String.format(%s.dept_id IN (%s), alias, user.getDeptIds().stream() .map(String::valueOf) .collect(Collectors.joining(,))); Expression expression CCJSqlParserUtil.parseExpression(paramSql);方案B用参数占位符同步绑定参数值推荐既然拦截器拿到的是已经解析号的BoundSql那我们就在改写SQL时使用?占位符并且手动把参数值挂到BoundSql上。先改写SQL把条件表达式里的值替换成?再往parameterMappings列表和参数对象里注入值private void rebindDataScopeParams(BoundSql boundSql, Object parameterObject, ListDataScopeParam params) { if (params.isEmpty()) { return; } // 1. 复制并扩展parameterMappings Configuration configuration boundSql.getConfiguration(); ListParameterMapping mappings new ArrayList(boundSql.getParameterMappings()); for (DataScopeParam param : params) { ParameterMapping mapping new ParameterMapping.Builder( configuration, param.getName(), param.getValue().getClass()).build(); mappings.add(mapping); } // 2. 把新的mappings写回BoundSql MetaObject metaObject SystemMetaObject.forObject(boundSql); metaObject.setValue(parameterMappings, mappings); // 3. 把参数值挂到parameterObject上 if (parameterObject instanceof Map map) { for (DataScopeParam param : params) { map.put(param.getName(), param.getValue()); } } else { // 如果parameterObject不是Map这里就要么用MetaObject给对象加临时字段 // 要么要求业务Mapper方法必须使用Param参数。 throw new IllegalStateException(data scope param binding requires Map or Param parameter); } }所以业务侧的Mapper方法参数我统一要求写成Param形式比如DataScope(alias o) ListOrderVO selectOrderList(Param(status) Integer status);这样parameterObject天然是Map拦截器往里放_dataScopeDeptIds之类的key就不会报错。这也是我在实际项目中总结出来的“约定优于魔法”既保持业务侧简洁又让底层机制可控。5. 用户上下文传递与多拦截器共存5.1 用户和部门信息从哪里拿数据权限的前提是知道“当前操作者是谁”。最常见的做法是在登录成功后把用户信息塞进ThreadLocal请求结束时清理。如果项目接了Spring Security最简单的方式是在拦截器里从SecurityContextHolder读取Authentication authentication SecurityContextHolder.getContext().getAuthentication(); LoginUser loginUser (LoginUser) authentication.getPrincipal();如果没有Spring Security就自己做一个登录拦截器在进入Controller之前把UserContext塞进ThreadLocal然后在afterCompletion清理。网关模式下用户信息通常以Header形式由网关传入可以在Spring MVC的HandlerInterceptor里解析Header并填充UserContext。无论哪种方式核心原则是UserContext的填充发生在业务方法执行之前并且保证在所有异步子任务里可见。线程池场景是个大坑。如果业务代码里用了ExecutorService异步处理子线程里的ThreadLocal直接丢失数据权限拦截器就会认为“无用户上下文”而跳过过滤这是严重越权。解决思路是要么在提交任务时把UserContext作为参数显式传进去要么换用TransmittableThreadLocal。传输线程变量库在异步场景下依然能正确传递线程上下文用起来和ThreadLocal一样。我的项目里直接换成了它之后再没遇到过异步丢用户的问题。5.2 和PageHelper分页插件、逻辑删除、多租户插件的共存顺序很多人以为自定义拦截器加上去就能和PageHelper“自动共存”其实这里有一个非常重要的顺序问题。MyBatis的多个拦截器按照注册顺序层层包装最外层先进入intercept。分页插件PageHelper的工作原理就是改写SQL加上limit并且会执行一遍count查询。正确的顺序是数据权限拦截器必须注册在分页插件之前也就是数据权限先改SQL分页插件拿到的已经是带权限条件的SQL。这样count查询得到的数量才是过滤后的数量而不是全量数量。反过来如果分页插件先加limit数据权限再去改写SQLlimit已经截断结果count统计也会错。注册方式用Spring Boot的话Configuration public class MybatisConfig { Bean public DataScopeInterceptor dataScopeInterceptor() { return new DataScopeInterceptor(); } Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 注意先把数据权限添加进MybatisPlusInterceptor再添加分页插件 interceptor.addInnerInterceptor(new DataPermissionInterceptor(new MyDataPermissionHandler())); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果用的是原生MyBatis而不是MyBatis-Plus在SqlSessionFactoryBean里配置插件顺序时同理数据权限在前分页在后。顺序错了效果不会立刻暴露往往是某个大列表的count数量不对翻到最后一页数据异常才开始怀疑。逻辑删除插件MyBatis-Plus的LogicDeleteInnerInterceptor通常也在Executor层它负责往SQL里追加is_deleted 0。它和数据权限的叠加是纯AND关系谁前谁后影响不大只要两个条件最终都在WHERE里就行。多租户插件同理理论上数据权限和租户隔离可以并存租户条件由租户插件处理部门条件由数据权限处理两者互不覆盖。5.3 多数据源场景怎么防止误伤如果项目里配置了多个数据源比如主库和报表库拦截器默认对所有数据源生效的话报表库的SQL也会被强行加上主库的部门条件这通常是错的。我用的方案是在数据源路由层给MyBatis设置不同的Configuration实例根据数据源名称决定是否注册数据权限拦截器。报表库的SQL不注册权限拦截器保持纯查询逻辑主库的业务Mapper注册拦截器强制生效。还有一种更轻量的做法在数据权限拦截器内部按MappedStatement的ID前缀判断if (ms.getId().startsWith(com.xxx.report)) { return invocation.proceed(); }用包名前缀区分哪些Mapper需要权限哪些不需要。这种方式虽然不如不同Configuration彻底但改造成本低适合简单场景。6. 上线之后踩过的坑与性能优化6.1 误伤公开接口、登录接口也被加上了权限条件有一类接口非常容易中招登录后获取用户信息的接口、用户自助注册接口、健康检查接口、公共字典查询。这些接口执行时ThreadLocal里其实已经有上下文因为你已经登录拦截器会认为要加权限条件但字典表和用户表的dept_id字段根本不存在SQL直接执行失败。我的处理方式是注解默认不开启只对需要数据权限的Mapper方法显式加DataScope并把类级别注解作为一个快速通道来用。没有注解拦截器直接放行有注解才进入过滤逻辑。这和前面的“默认开启”策略有着微妙差别实际落地时我建议两者结合Mapper接口类上默认加DataScope类级代表这个Mapper下所有方法默认要过滤。个别不需要过滤的方法如登录、字典查询加DataScope(ignore true)。这样既保证了默认安全又给特殊接口留出明确出口。比“全都不加注解只对有需求的方法加”更稳因为开发人员写新Mapper时忘了加注解默认还是安全的。6.2 性能损耗SQL解析不能每条都全量解析JSqlParser解析SQL是有开销的虽然单条SQL解析通常都在毫秒以内但高并发场景下每条查询都重复解析CPU占用会明显上涨。我在项目里做了一级缓存以MappedStatement.id SQL文本哈希作为Key缓存改写后的SQL。注意这里有一个坑同一个Mapper方法的动态SQL片段可能因为参数不同而产生不同的文本所以缓存Key必须带上完整的SQL文本而不能只带Mapper方法ID。public class SqlRewriteCache { private static final MapString, String CACHE new ConcurrentHashMap(); public static String getOrRewrite(String rawSql, FunctionString, String rewriter) { String key rawSql | rawSql.hashCode(); String rewritten CACHE.get(key); if (rewritten null) { rewritten rewriter.apply(rawSql); CACHE.put(key, rewritten); if (CACHE.size() 5000) { CACHE.clear(); // 简单防内存膨胀生产可换Caffeine } } return rewritten; } }这个缓存有一个非常重要的前置条件权限条件与具体用户无关。也就是说如果不同用户看到的部门ID集合不同你缓存改写后的SQL文本就必须用同一个占位符具体值通过参数绑定注入而不是把部门ID直接写死在SQL里。这就回到我第4节推荐的方案B改写后的SQL是稳定的dept_id IN (?,?,?)变化的部分在参数里。这样缓存命中率非常高SQL也不会因为用户不同而爆炸式增长。6.3 二级缓存和Caffeine缓存导致越权MyBatis自带一级缓存和二级缓存。一级缓存是同一个SqlSession内查询结果的缓存通常问题不大二级缓存是跨SqlSession的全局缓存如果开启了二级缓存第一次查询时把结果缓存了第二个用户进来时命中了缓存数据权限条件就没有重新执行直接拿到了别人的数据。这是非常隐蔽又严重的问题。我的结论很简单数据权限场景下不要开启MyBatis二级缓存。要用缓存就自己在Service层用Caffeine并且缓存Key里带上用户维度。比如cacheKey userId : queryCondition这样才能保证不同用户拿到的缓存数据都是经过自己权限过滤的。这个取舍我在项目里吃了亏之后才明白MyBatis的二级缓存听起来很美但在按用户过滤数据的系统里就是隐患。6.4 线上问题的排查手段数据权限这种“看不见的SQL改写”出问题之后最难受的是不知道SQL被改成了什么样。所以拦截器里一定要留可观测性改写前后SQL对比日志debug级别开关临时排查问题时打开。每次改写的MappedStatement.id方便定位是哪条Mapper方法。当前用户上下文记录userId、deptIds确认条件来源。我在拦截器里埋了一个简单的计数器记录“改写成功多少次、改写失败多少次、跳过多少次”。如果某个时段跳过次数特别多多半是ThreadLocal没传进去或者注解漏配了。上线后把这些指标接到监控告警里比出了事故再查日志高效得多。6.5 一个真实案例报表导出接口的越权最后分享一个让我后怕的真实案例。当时产品提了一个“销售排行TOP100”的报表需求开发在Mapper里写了一条比较复杂的统计SQL主查询带子查询权限注解加在了Mapper方法上。上线后测试发现子查询内部完全没有被权限条件约束因为拦截器只给最外层的主表加了条件子查询里仍然查了全量数据。虽然最终结果展示的TOP100可能只是多了几条不该出现的数据但底层数据已经泄露了。排查了很久才发现问题是SQL结构里存在两层查询体拦截器的简单改写只处理了外层。后来我在规则里加了一条强制约束如果SQL包含子查询、UNION、复杂JOIN且无法在所有查询体里都安全注入权限条件直接拒绝执行并邮件告警。虽然初期会误伤一些合法SQL但安全底线保住了。等团队习惯了把复杂统计SQL拆成独立视图这个问题就自然消退了。7. 如果要迈得更远列级权限、审计日志和方案选型7.1 行级权限和列级权限的差异到这里为止我们解决的是行级数据权限哪些行能看到条件追加在WHERE后面。列级权限用户A看不到手机号、用户B能看到是另一个维度。拦截器也能做但复杂度显著上升需要在SELECT语句里裁剪列或者在ResultSetHandler里对结果对象的字段做置空。我的建议是不要为了“无侵入”硬扛列级权限。SELECT语句裁剪列在单表简单查询里可行但遇到JOIN查询、动态列、ResultMap复杂映射很容易出现列名对不上、映射失败等问题。更稳妥的方案是在应用层做结果VO裁剪查询完成拿到VO后根据当前用户的字段可见性把不可见字段置空或脱敏。虽然每个接口都要调一下但逻辑简单明了出问题好排查线上脱敏也更容易验证。7.2 保留审计链路数据权限拦截器天然位于所有数据库操作的必经之路上这给了审计一个绝佳的落点。你在拦截器里可以记录谁、在什么时间、执行了哪条SQL、条件是什么。不需要侵入业务代码日志自动收集。这在合规审计、事故回溯时是救命稻草。我之前在拦截器里加了简单的操作日志异步写入记录MappedStatement ID、SQL模板注意不要记录真实参数值避免敏感信息落库、用户ID、执行耗时。后来有一次内部审计要求提供“某用户在一个月内查询了哪些表”的清单我直接从日志表里查出来五分钟交差。如果没有这层拦截器这个需求又得在各业务表里翻半天。7.3 自研拦截器还是直接上成熟框架最后说下方案选型。如果你在做一个从零开始的中小型系统团队没有太多精力维护底层SQL改写逻辑我更推荐直接用成熟框架的现成能力比如MyBatis-Plus的DataPermissionInterceptor或者基于若依框架自带的数据权限改造。这些方案经过大量项目验证复杂SQL处理比自研的要完善得多。自研拦截器的价值在于你完全掌控规则的表达方式可以按自己系统的组织模型来设计注解和条件生成逻辑不受框架假设限制。另外自研的过程会逼你真正理解MyBatis的Executor、BoundSql、ParameterMapping这些底层机制排查后续问题会顺很多。我的建议是先把原理吃透再用框架出了问题你才知道怎么救。我自己在这个项目里维护这套拦截器两年多最大的体会是权限方案的复杂度不在写拦截器那几百行代码而在于你的业务SQL有多复杂、组织模型有多折腾。拦截器把“权限条件该加在哪”这个问题从业务层收敛到了底层但新的问题变成了“哪些SQL需要被正确改写”。这需要开发规范、代码审查、监控告警一起配合缺一环安全就是碰运气。最后提醒所有准备动手的朋友一句话越权不是功能Bug是事故。上线前找几个不同角色的账号把主要列表、明细、导出、定时任务全测一遍别只看功能跑通。