1. 项目概述为什么数据权限是后台系统的“隐形守护者”做后台管理系统尤其是涉及多租户、多部门、多角色的系统数据权限几乎是绕不开的坎。它不像菜单权限那样直观一个用户能点开哪些页面一目了然。数据权限更像是一套隐形的过滤规则决定了同一个列表页面不同用户能看到哪些行数据。比如销售总监能看到全公司的订单而一个普通销售员只能看到自己名下的订单部门经理能查阅本部门所有员工的考勤但看不到隔壁部门的数据。这种需求光靠PreAuthorize(“hasRole(‘ADMIN’)”)这种接口级别的权限注解是搞不定的它必须深入到数据查询的层面。我见过不少项目初期为了赶进度把数据权限逻辑写在每个Service的方法里用一堆if-else判断当前用户角色然后拼接不同的查询条件。结果就是业务代码和权限代码高度耦合后期加一个“按项目组过滤”的新需求就得把几十个查询方法改个遍维护起来简直是噩梦。所以一个优雅、通用、对业务侵入性低的数据权限实现方案是保障中后台系统可扩展性和可维护性的关键。本文将基于SpringBoot MyBatis这套最主流的Java后端技术栈手把手带你实现一套从设计到落地的数据权限方案。我们会重点解决几个核心问题如何抽象和描述一条数据权限规则如何将用户上下文角色、部门等信息与规则动态绑定以及如何通过MyBatis插件在SQL执行前自动、无感地注入这些过滤条件整个过程会配合清晰的流程图和代码片段确保你能理解透彻并直接应用到自己的项目中。2. 数据权限的核心设计思路与抽象在动手写代码之前我们必须把设计思路理清楚。数据权限的本质是在执行数据查询时根据当前请求者的身份动态地为SQL语句追加额外的查询条件WHERE子句。因此整个方案的设计可以围绕以下几个核心抽象展开。2.1 权限规则的抽象从“谁能看什么”到“SQL片段”首先我们需要一种方式来描述一条权限规则。一条规则至少包含几个要素资源Resource规则作用于哪张表、哪个业务实体例如t_order订单表、t_project项目表。字段Field规则通过哪个字段进行过滤例如create_user_id创建人ID、dept_id部门ID。操作符Operator如何进行过滤例如等于、IN在…中、LIKE模糊匹配。值Value过滤的具体值是什么这个值通常是动态的来源于当前用户的上下文信息。我们可以用一个简单的Java类来定义这个规则模型Data public class DataPermissionRule { /** 规则ID */ private String id; /** 对应的表名或实体名 */ private String resource; /** 进行过滤的字段名 */ private String field; /** 操作符如 , IN, LIKE */ private String operator; /** 过滤值。可以是固定值也可以是表达式如 #{user.deptId} */ private String value; /** 规则类型如 USER本人、DEPT本部门、CUSTOM自定义等 */ private String type; }关键在于value字段。它不应该总是写死的。比如“查看本人数据”的规则其value应该是当前登录用户的ID。因此我们需要支持表达式例如用#{user.id}来表示从用户上下文中获取id属性。2.2 用户上下文的构建与传递权限规则需要依据当前用户的信息来动态计算。因此我们需要一个统一的地方来存放和获取用户上下文。通常的做法是使用ThreadLocal它在一次请求的线程内是共享的非常适合存放像UserId、DeptId、RoleIds这类信息。我们定义一个SecurityContextHolder工具类public class SecurityContextHolder { private static final ThreadLocalLoginUser THREAD_LOCAL new ThreadLocal(); public static void setUser(LoginUser user) { THREAD_LOCAL.set(user); } public static LoginUser getUser() { return THREAD_LOCAL.get(); } public static void clear() { THREAD_LOCAL.remove(); } Data public static class LoginUser { private Long userId; private String username; private Long deptId; private ListLong roleIds; // 其他扩展信息... } }在请求进入时例如通过Spring的拦截器HandlerInterceptor从JWT Token或Session中解析出用户信息并存入SecurityContextHolder。在请求结束时拦截器的afterCompletion方法务必调用clear()方法清理ThreadLocal防止内存泄漏。2.3 规则与角色的绑定策略用户直接关联的是角色而非具体的权限规则。因此我们需要建立“角色-规则”的映射关系。一个角色可以拥有多条数据权限规则。这些规则可以配置在数据库中项目启动时加载到缓存如Redis中以提高性能。当需要判断某个用户对t_order表有哪些数据权限时流程如下获取用户的所有角色。根据角色从缓存中取出所有关联的数据权限规则。过滤出resource为t_order的规则。将这些规则转换为对应的SQL WHERE片段。注意这里可能会遇到规则冲突或叠加的问题。例如一个用户同时拥有“查看本人数据”(create_user_id #{user.id})和“查看本部门数据”(dept_id #{user.deptId})两条规则。是取交集AND还是并集OR这需要根据业务来定义。通常更严格的规则交集更安全。我们的系统可以设计一个mergeType字段在规则组上指定多条规则间的逻辑关系。3. 基于MyBatis插件实现SQL自动改写这是整个方案的技术核心。MyBatis提供了强大的插件Interceptor机制允许我们在SQL语句被执行前Executor的query方法或后对语句进行修改。我们将利用这个机制在SQL执行前动态拼接上数据权限的过滤条件。3.1 编写MyBatis拦截器我们创建一个实现Interceptor接口的类DataPermissionInterceptor。Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}) }) Component Slf4j public class DataPermissionInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 1. 获取当前执行的MappedStatement和参数 MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; // 2. 判断是否需要数据权限过滤 // 可以通过方法名、注解等方式标识。这里假设使用自定义注解 DataPermission // 如果方法上没有该注解或者注解的enable为false则直接放行 String id ms.getId(); if (!needDataPermissionFilter(id, parameter)) { return invocation.proceed(); } // 3. 获取原始的BoundSql和SQL BoundSql boundSql ms.getBoundSql(parameter); String originalSql boundSql.getSql(); // 4. 获取当前登录用户信息 SecurityContextHolder.LoginUser loginUser SecurityContextHolder.getUser(); if (loginUser null) { log.warn(“数据权限拦截未获取到登录用户信息将跳过过滤。”); return invocation.proceed(); } // 5. 根据用户角色、当前Mapper方法等信息计算需要追加的数据权限SQL片段 String dataPermissionSqlFragment buildDataPermissionSqlFragment(ms, loginUser); // 如果无需追加条件直接放行 if (StringUtils.isEmpty(dataPermissionSqlFragment)) { return invocation.proceed(); } // 6. 改写SQL插入权限过滤条件 String newSql rewriteSql(originalSql, dataPermissionSqlFragment); log.debug(“原始SQL: {}”, originalSql); log.debug(“改写后SQL: {}”, newSql); // 7. 创建新的BoundSql对象替换掉原来的 BoundSql newBoundSql new BoundSql(ms.getConfiguration(), newSql, boundSql.getParameterMappings(), boundSql.getParameterObject()); // 重要将原BoundSql的附加参数如动态SQL生成的参数复制到新的BoundSql中 for (ParameterMapping mapping : boundSql.getParameterMappings()) { String prop mapping.getProperty(); if (boundSql.hasAdditionalParameter(prop)) { newBoundSql.setAdditionalParameter(prop, boundSql.getAdditionalParameter(prop)); } } // 8. 利用反射将invocation参数中的BoundSql替换为我们新的newBoundSql Field field boundSql.getClass().getDeclaredField(“sql”); field.setAccessible(true); field.set(boundSql, newSql); // 直接修改原boundSql的sql字段 // 9. 继续执行原流程 return invocation.proceed(); } // 其他辅助方法needDataPermissionFilter, buildDataPermissionSqlFragment, rewriteSql // ... }关键点解析Intercepts注解指定该插件拦截Executor的query方法。这是执行所有查询操作的地方。判断是否需要过滤不是所有查询都需要加数据权限。通常通过自定义注解如DataPermission在Mapper方法上标记或者在方法名/资源名上进行约定例如查询t_order表的方法才过滤。获取并改写SQL通过MappedStatement拿到原始的BoundSql和SQL字符串。调用buildDataPermissionSqlFragment方法生成如AND dept_id 100这样的片段。SQL改写逻辑rewriteSql方法是难点。它需要智能地将权限片段插入到原始SQL正确的WHERE子句中。如果原SQL没有WHERE需要添加WHERE如果已有WHERE则在末尾追加AND (权限条件)。必须小心处理子查询、连接查询等复杂情况。替换BoundSqlMyBatis后续执行依赖BoundSql对象。我们通过反射修改其内部的sql字符串是最直接的方式。注意要复制原有的附加参数防止参数丢失导致执行错误。3.2 构建权限SQL片段buildDataPermissionSqlFragment方法是规则引擎的核心。它的输入是用户信息和当前查询的上下文可以从MappedStatement中解析出表名输出是一个合法的SQL条件片段。private String buildDataPermissionSqlFragment(MappedStatement ms, LoginUser user) { // 1. 获取当前方法对应的资源表名。可以从ms.getId()解析或从自定义注解获取。 String resource parseResourceFromMappedStatement(ms); // 2. 根据用户角色从缓存如Redis中获取该资源对应的所有有效数据权限规则 ListDataPermissionRule rules dataPermissionService.getRulesByUserAndResource(user, resource); if (CollectionUtils.isEmpty(rules)) { return “”; } // 3. 将规则列表渲染成SQL片段 StringBuilder sqlFragment new StringBuilder(); for (int i 0; i rules.size(); i) { DataPermissionRule rule rules.get(i); // 3.1 解析规则中的表达式如将 #{user.deptId} 替换为实际值 100 String actualValue resolveRuleValue(rule.getValue(), user); // 3.2 构建单个条件如 “dept_id 100” String condition String.format(“ %s %s %s”, rule.getField(), rule.getOperator(), actualValue); if (i 0) { sqlFragment.append(condition); } else { // 多条规则间这里简单使用 AND 连接。更复杂的逻辑OR、分组需要更复杂的设计。 sqlFragment.append(“ AND ”).append(condition); } } return sqlFragment.toString(); } private String resolveRuleValue(String valueExpression, LoginUser user) { // 简单的表达式解析例如 #{user.deptId} if (valueExpression.startsWith(“#{user.”) valueExpression.endsWith(“}”)) { String fieldName valueExpression.substring(7, valueExpression.length() - 1); // 取出 “deptId” try { Field field LoginUser.class.getDeclaredField(fieldName); field.setAccessible(true); Object fieldValue field.get(user); // 根据字段类型返回合适的SQL字面量。如果是字符串需要加单引号。 return formatSqlValue(fieldValue); } catch (Exception e) { log.error(“解析用户属性表达式失败: {}”, valueExpression, e); return “NULL”; } } // 如果是固定值直接返回 return valueExpression; }3.3 智能SQL改写策略rewriteSql方法需要稳健地处理各种SQL语句。这里提供一个处理单表查询和简单多表查询的基础版本。private String rewriteSql(String originalSql, String permissionSqlFragment) { if (StringUtils.isEmpty(permissionSqlFragment)) { return originalSql; } // 转换为小写方便查找但注意保留原SQL的大小写格式用于最终拼接 String lowerCaseSql originalSql.toLowerCase(); // 1. 找到 WHERE 关键字的位置 int whereIndex lowerCaseSql.indexOf(“ where “); // 2. 找到 ORDER BY / GROUP BY / LIMIT 等子句的起始位置作为WHERE子句的结束如果存在WHERE int orderByIndex lowerCaseSql.indexOf(“ order by “); int groupByIndex lowerCaseSql.indexOf(“ group by “); int limitIndex lowerCaseSql.indexOf(“ limit “); int clauseEndIndex originalSql.length(); if (orderByIndex ! -1) clauseEndIndex Math.min(clauseEndIndex, orderByIndex); if (groupByIndex ! -1) clauseEndIndex Math.min(clauseEndIndex, groupByIndex); if (limitIndex ! -1) clauseEndIndex Math.min(clauseEndIndex, limitIndex); StringBuilder newSql new StringBuilder(originalSql); if (whereIndex -1) { // 原SQL没有WHERE子句 // 在 FROM ... 之后 ORDER BY 等子句之前插入 WHERE int insertPosition clauseEndIndex; // 需要回溯找到FROM子句后的位置这里简化处理在基本查询末尾插入 // 更严谨的做法是解析SQL AST但过于复杂。一个折中方案在第一个FROM后的表名之后插入。 // 这里为简化假设在ORDER BY等子句前插入 newSql.insert(insertPosition, “ WHERE ” permissionSqlFragment); } else { // 原SQL有WHERE子句 // 在WHERE子句的末尾但在ORDER BY等之前追加 AND (权限条件) int insertPosition clauseEndIndex; // 检查WHERE子句末尾是否有已有的条件需要加上AND String beforeInsert newSql.substring(whereIndex 7, insertPosition).trim(); // “ where “ 长度是7 if (!beforeInsert.endsWith(“and”) !beforeInsert.endsWith(“or”) !beforeInsert.endsWith(“(”)) { permissionSqlFragment “ AND ” permissionSqlFragment; } newSql.insert(insertPosition, permissionSqlFragment); } return newSql.toString(); }重要提示上述SQL解析和改写逻辑是高度简化的。对于生产环境尤其是涉及复杂嵌套查询、公用表表达式CTE、存储过程调用等情况简单的字符串匹配和插入极易出错。一个更稳健的做法是使用SQL解析器如jsqlparser将SQL字符串解析为抽象语法树AST然后在AST的Where节点上精确地添加条件最后再将AST还原为SQL字符串。虽然引入额外依赖但能极大提升方案的健壮性和准确性。4. 方案集成与业务层使用4.1 配置与启用插件在SpringBoot中将我们编写的拦截器配置为MyBatis的插件。可以通过Configuration类来配置Configuration public class MyBatisConfig { Bean public DataPermissionInterceptor dataPermissionInterceptor() { return new DataPermissionInterceptor(); } /** * 将拦截器添加到MyBatis的插件链中 */ Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { configuration.addInterceptor(dataPermissionInterceptor()); }; } }这样拦截器就会对所有通过MyBatisExecutor执行的查询生效。4.2 在业务层进行细粒度控制有时我们希望对数据权限进行更精细的控制例如完全跳过某些后台管理接口需要查看所有数据。动态规则根据本次请求的特定参数决定过滤条件。我们可以定义一個自定义注解DataPermissionTarget({ElementType.METHOD}) Retention(RetentionPolicy.RUNTIME) public interface DataPermission { /** 是否启用数据权限过滤默认true */ boolean enable() default true; /** 指定资源覆盖默认解析 */ String resource() default “”; /** 忽略某些默认规则 */ String[] ignoreRules() default {}; }然后在拦截器的needDataPermissionFilter方法中读取该注解进行判断。在Service或Mapper方法上使用此注解即可。Service public class OrderService { // 此查询会自动加上数据权限条件 public ListOrder queryUserOrders(OrderQuery query) { return orderMapper.selectList(query); } // 此查询跳过数据权限供管理员使用 DataPermission(enable false) public ListOrder queryAllOrdersForAdmin(OrderQuery query) { return orderMapper.selectList(query); } }4.3 处理连表查询与别名问题当SQL涉及多表连接时权限条件中的字段必须带上正确的表别名。例如SELECT o.*, u.name FROM t_order o LEFT JOIN t_user u ON o.user_id u.id WHERE o.dept_id ?我们的权限规则是针对t_order表的dept_id字段生成的片段应该是o.dept_id ?而不是dept_id ?。这要求我们在设计规则或解析SQL时需要感知别名。有几种思路规则配置别名在规则中直接配置带别名的字段如o.dept_id。但这要求开发人员对SQL别名有预知耦合度高。拦截器解析别名在拦截器中利用jsqlparser等工具解析SQL自动识别t_order表使用的别名然后将规则中的字段名dept_id重写为别名.dept_id。这是更通用和优雅的方案但实现复杂度较高。约定大于配置在项目规范中约定涉及数据权限的主表统一使用固定别名如main然后在生成条件时固定使用main.字段名。这种方法简单但不够灵活。实操心得对于大多数内部管理系统业务SQL相对规范采用“约定别名”结合“简单解析”的方式往往能快速落地。可以先实现一个基础版本只处理单表或简单连接通过查找FROM后的第一个表作为主表满足初期需求。随着复杂查询增多再逐步引入SQL解析器来完善。5. 常见问题排查与性能优化实录在实际落地过程中你肯定会遇到各种各样的问题。下面是我踩过的一些坑和解决方案。5.1 SQL注入风险问题在resolveRuleValue方法中如果直接将用户上下文的值如user.getDeptId()字符串拼接进SQL而该值又来自不可信的前端输入理论上不应该但需防范则存在SQL注入风险。解决永远不要直接拼接字符串来构建SQL条件应该使用预编译参数占位符?。MyBatis的BoundSql管理着参数映射。我们的拦截器在修改SQL后需要将权限条件对应的参数值也添加到BoundSql的参数列表中。修改buildDataPermissionSqlFragment和rewriteSql的逻辑// 在拦截器中不再构建完整的“dept_id 100”而是构建“dept_id ?” String condition String.format(“ %s %s ?”, rule.getField(), rule.getOperator()); // 将实际值 actualValue 存储到一个集合中 paramValues.add(actualValue); // 在改写SQL后需要将 paramValues 中的参数添加到 BoundSql 的参数映射中 MetaObject metaObject SystemMetaObject.forObject(boundSql); ListParameterMapping originalMappings (ListParameterMapping) metaObject.getValue(“parameterMappings”); ListParameterMapping newMappings new ArrayList(originalMappings); Configuration configuration ms.getConfiguration(); for (int i 0; i paramValues.size(); i) { // 为每个权限参数创建新的ParameterMapping ParameterMapping.Builder builder new ParameterMapping.Builder(configuration, “dataPermissionParam” i, Object.class); newMappings.add(builder.build()); // 将参数值设置为附加参数 metaObject.setValue(“additionalParameters.dataPermissionParam” i, paramValues.get(i)); } metaObject.setValue(“parameterMappings”, newMappings);这样权限条件就会和MyBatis其他参数一样以预编译的方式安全地传入数据库。5.2 分页插件如PageHelper的兼容性问题问题PageHelper等分页插件也是通过MyBbatis拦截器实现的它会先于我们的拦截器修改SQL添加COUNT查询和分页参数。如果我们的拦截器顺序不当可能导致分页SQL改写错误或权限条件丢失。解决明确拦截器的执行顺序。在Spring中可以通过Order注解或实现Ordered接口来指定顺序。通常数据权限过滤应该在分页之前进行。因为分页是基于过滤后的结果集进行的。所以我们的拦截器应该在PageHelper拦截器之后执行。Component Intercepts({...}) Slf4j public class DataPermissionInterceptor implements Interceptor, Ordered { Override public int getOrder() { // 设置一个比PageHelper更大的order值使其在PageHelper之后执行 return 1; } // ... intercept 方法 }PageHelper的PageInterceptor默认order是0。我们将自己的拦截器order设为1即可保证执行顺序。5.3 性能考量与缓存策略问题每次查询都要根据用户角色去查询数据库获取权限规则性能无法接受。解决使用多级缓存。应用内存缓存项目启动时或将规则变更时将“角色-规则”映射关系加载到本地内存如Guava Cache中。查询时直接内存命中速度极快。适用于规则不频繁变动的场景。分布式缓存如果服务是多实例部署规则变更需要通知所有实例。可以使用Redis作为中央缓存。在拦截器中先查本地内存没有则查Redis并回写到本地内存。规则变更时发布一个事件如通过Redis的Pub/Sub或MQ通知所有实例清空本地缓存。规则预编译对于每个用户-资源组合其最终的SQL权限片段在用户登录或规则变更时就可以计算好并缓存起来。拦截器只需做一次简单的缓存查找无需实时计算。一个简单的本地缓存示例Component public class DataPermissionRuleCache { private LoadingCacheString, ListDataPermissionRule userRuleCache CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterAccess(10, TimeUnit.MINUTES) .build(new CacheLoaderString, ListDataPermissionRule() { Override public ListDataPermissionRule load(String userKey) throws Exception { // 从数据库或Redis加载用户的所有数据权限规则 return dataPermissionService.loadRulesByUserKey(userKey); } }); public ListDataPermissionRule getRules(String userKey) { try { return userRuleCache.get(userKey); } catch (ExecutionException e) { log.error(“加载数据权限规则缓存失败”, e); return Collections.emptyList(); } } public void invalidate(String userKey) { userRuleCache.invalidate(userKey); } }5.4 多租户SaaS场景下的隔离在SaaS系统中数据权限通常还需要叠加一层“租户隔离”tenant_id ?。这个需求可以完美地融入本方案。将租户ID视为一种特殊的数据权限规则为所有需要隔离的表默认添加一条规则field为tenant_idoperator为value为#{user.tenantId}。在用户上下文中增加tenantId字段在登录时确定用户所属租户。规则引擎自动应用对于属于某个租户的用户其规则集合中会自动包含这条租户隔离规则。这样所有查询都会自动带上AND tenant_id xxx条件实现数据隔离。注意事项对于超级管理员或系统内部Job可能需要跨租户查询数据。这时可以通过DataPermission(enablefalse)临时关闭拦截或者在用户上下文中设置一个“忽略租户过滤”的标志让规则引擎跳过租户规则的添加。5.5 调试与日志输出数据权限拦截器在幕后工作一旦出错排查起来比较困难。因此完善的日志至关重要。在拦截器入口和出口打上DEBUG日志记录是否启用了过滤。将原始SQL和改写后的SQL在DEBUG级别打印出来。这是最有效的调试手段。记录计算出的权限规则列表和最终生成的SQL片段。可以考虑在开发环境将改写后的SQL以注释的形式追加到查询中如/* DataPermission: dept_id100 */方便在数据库监控工具中直接查看。实现这套数据权限方案后你会发现业务代码变得非常干净权限控制逻辑集中在一个地方维护和扩展。当产品经理提出“领导需要看下属部门的数据”这种新需求时你只需要在规则配置中心添加一条新的规则或者调整用户的角色业务代码一行都不用改。这种解耦带来的维护性提升在长期迭代的项目中价值巨大。