1. 项目概述:当数据权限遇上SpringBoot
最近在重构公司内部管理系统时,我又遇到了那个老生常谈的问题——数据权限控制。每次新功能开发,都要在Service层写一堆if-else来判断当前用户能看到哪些数据,不仅代码臃肿,还容易遗漏权限判断。直到尝试了注解+动态SQL的方案后,我才发现原来数据权限可以如此优雅地实现。
这个方案的核心思想是:通过自定义注解标记需要数据权限控制的方法,利用MyBatis拦截器动态修改SQL语句,自动注入权限过滤条件。实测下来,原先需要几十行判断逻辑的查询方法,现在只需要加个注解就能搞定,代码量减少了70%以上。更重要的是,权限规则统一维护在一个地方,再也不用担心不同开发人员实现不一致导致的权限漏洞。
2. 核心设计思路拆解
2.1 传统方案的痛点分析
在采用新方案前,我们项目中的数据权限实现大致是这样的:
public List<Order> queryOrders(OrderQuery query) { // 基础查询条件 List<Order> orders = orderMapper.selectByQuery(query); // 数据权限过滤 User currentUser = SecurityUtils.getCurrentUser(); if (!currentUser.isAdmin()) { if (currentUser.isDeptManager()) { orders = orders.stream() .filter(o -> o.getDeptId().equals(currentUser.getDeptId())) .collect(Collectors.toList()); } else { orders = orders.stream() .filter(o -> o.getCreateBy().equals(currentUser.getUserId())) .collect(Collectors.toList()); } } return orders; }这种实现方式存在几个明显问题:
- 业务代码和数据权限代码高度耦合,可读性差
- 同样的权限逻辑要在多个方法中重复编写
- 先查后过滤的方式性能低下,特别是数据量大时
- 权限规则变更需要修改多处代码,维护成本高
2.2 注解+动态SQL方案的优势
新方案通过以下方式解决了上述问题:
- 声明式编程:使用注解声明方法需要的数据权限类型,业务代码保持简洁
- 统一处理:通过MyBatis拦截器集中处理权限逻辑,避免代码重复
- SQL注入:在SQL执行前动态添加WHERE条件,实现真正的数据库层过滤
- 规则可配置:权限规则可集中配置,修改时只需调整一处
@DataPermission(deptAlias = "o", userAlias = "o") public List<Order> queryOrders(OrderQuery query) { // 无需手动处理权限,方法保持简洁 return orderMapper.selectByQuery(query); }3. 核心实现细节
3.1 自定义注解设计
首先定义数据权限注解,用于标记需要权限控制的方法:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataPermission { /** * 部门表别名 */ String deptAlias() default ""; /** * 用户表别名 */ String userAlias() default ""; /** * 权限类型 */ DataPermissionType type() default DataPermissionType.ALL; } public enum DataPermissionType { ALL, // 所有数据 DEPT, // 本部门数据 SELF, // 仅本人数据 CUSTOM // 自定义规则 }3.2 MyBatis拦截器实现
核心拦截器负责解析注解并修改SQL:
@Intercepts({ @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class}) }) public class DataPermissionInterceptor implements Interceptor { @Override public Object intercept(Invocation invocation) throws Throwable { // 1. 获取当前执行的Mapper方法 Method method = getMapperMethod(invocation); // 2. 检查是否有@DataPermission注解 DataPermission permission = method.getAnnotation(DataPermission.class); if (permission == null) { return invocation.proceed(); } // 3. 获取当前用户权限信息 User currentUser = SecurityUtils.getCurrentUser(); if (currentUser.isAdmin()) { return invocation.proceed(); // 管理员跳过权限过滤 } // 4. 解析原始SQL并添加权限条件 StatementHandler handler = (StatementHandler) invocation.getTarget(); BoundSql boundSql = handler.getBoundSql(); String originalSql = boundSql.getSql(); String permissionSql = buildPermissionSql(originalSql, permission, currentUser); resetSql(handler, boundSql, permissionSql); return invocation.proceed(); } private String buildPermissionSql(String originalSql, DataPermission permission, User user) { // 根据权限类型构建不同的WHERE条件 StringBuilder condition = new StringBuilder(); switch (permission.type()) { case DEPT: condition.append(permission.deptAlias()).append(".dept_id = ").append(user.getDeptId()); break; case SELF: condition.append(permission.userAlias()).append(".create_by = '").append(user.getUserId()).append("'"); break; case CUSTOM: condition.append(buildCustomCondition(user)); break; default: return originalSql; } // 将条件注入到SQL中 if (originalSql.toUpperCase().contains(" WHERE ")) { return originalSql.replaceFirst("(?i) WHERE ", " WHERE (" + condition + ") AND "); } else { int index = originalSql.toUpperCase().indexOf(" FROM "); String beforeFrom = originalSql.substring(0, index); String afterFrom = originalSql.substring(index); return beforeFrom + afterFrom.replaceFirst("(?i) FROM ", " WHERE " + condition + " FROM "); } } }3.3 权限上下文传递
为了在拦截器中获取当前用户信息,我们需要实现一个线程安全的权限上下文:
public class SecurityUtils { private static final ThreadLocal<User> userHolder = new ThreadLocal<>(); public static User getCurrentUser() { User user = userHolder.get(); if (user == null) { throw new IllegalStateException("No user in current context"); } return user; } public static void setCurrentUser(User user) { userHolder.set(user); } public static void clear() { userHolder.remove(); } }注意:记得在过滤器或拦截器中清理ThreadLocal,否则可能导致内存泄漏
4. 高级功能扩展
4.1 多表关联权限控制
对于需要关联多表的复杂查询,可以通过注解指定每个表的权限别名:
@DataPermission( deptAlias = {"o", "c"}, // 订单和客户表都需要部门权限 userAlias = "o" ) public List<OrderDTO> queryOrderDetails(OrderQuery query) { return orderMapper.selectOrderDetails(query); }对应的SQL修改逻辑需要处理多个表的权限条件:
private String buildMultiTablePermission(String originalSql, DataPermission permission, User user) { List<String> deptConditions = new ArrayList<>(); for (String alias : permission.deptAlias()) { if (!alias.isEmpty()) { deptConditions.add(alias + ".dept_id = " + user.getDeptId()); } } List<String> userConditions = new ArrayList<>(); for (String alias : permission.userAlias()) { if (!alias.isEmpty()) { userConditions.add(alias + ".create_by = '" + user.getUserId() + "'"); } } // 组合所有条件 String condition = Stream.concat(deptConditions.stream(), userConditions.stream()) .collect(Collectors.joining(" OR ")); return injectCondition(originalSql, "(" + condition + ")"); }4.2 权限规则动态配置
将硬编码的权限规则改为从数据库或配置中心读取:
@Service public class DataPermissionRuleService { @Cacheable(value = "permissionRules", key = "#roleId") public List<DataPermissionRule> getRulesByRole(String roleId) { // 从数据库查询该角色对应的数据权限规则 return dataPermissionRuleMapper.selectByRole(roleId); } } // 在拦截器中使用 List<DataPermissionRule> rules = ruleService.getRulesByRole(currentUser.getRoleId()); String condition = rules.stream() .map(rule -> rule.getTableAlias() + "." + rule.getColumn() + " " + rule.getOperator() + " " + rule.getValue()) .collect(Collectors.joining(" AND "));4.3 性能优化技巧
- SQL解析优化:使用JSqlParser等工具替代字符串操作,更可靠地修改SQL
Statement statement = CCJSqlParserUtil.parse(sql); Select select = (Select) statement; PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); // 添加权限条件 Expression where = plainSelect.getWhere(); if (where == null) { plainSelect.setWhere(new Parenthesis(new AndExpression(permissionCondition))); } else { plainSelect.setWhere(new Parenthesis(new AndExpression(where, permissionCondition))); } return select.toString();- 缓存权限SQL:对相同的SQL模板和权限组合进行缓存
private static final Cache<String, String> SQL_CACHE = Caffeine.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); String cacheKey = originalSql + "|" + currentUser.getRoleId(); String permissionSql = SQL_CACHE.get(cacheKey, k -> buildPermissionSql(originalSql, permission, currentUser));5. 常见问题与解决方案
5.1 分页总数问题
问题描述:当使用PageHelper等分页插件时,权限条件只应用到了分页查询SQL,没有应用到count查询SQL,导致分页总数不正确。
解决方案:修改拦截器,同时处理原始SQL和countSQL:
if (BoundSqlHelper.isCountSql(boundSql)) { String countSql = boundSql.getSql(); String permissionCountSql = buildPermissionSql(countSql, permission, currentUser); resetSql(handler, boundSql, permissionCountSql); } else { String permissionSql = buildPermissionSql(originalSql, permission, currentUser); resetSql(handler, boundSql, permissionSql); }5.2 多数据源支持
问题描述:项目中使用多个数据源时,拦截器需要对特定数据源生效。
解决方案:通过@ConditionalOnProperty或自定义条件装配拦截器:
@ConditionalOnProperty(name = "spring.datasource.primary.enable-data-permission", havingValue = "true") @Bean public DataPermissionInterceptor dataPermissionInterceptor() { return new DataPermissionInterceptor(); }5.3 权限条件冲突
问题描述:手动编写的WHERE条件可能与自动注入的权限条件产生逻辑冲突。
解决方案:使用括号明确条件分组,确保权限条件的独立性:
-- 原始SQL SELECT * FROM orders WHERE status = 'ACTIVE' AND create_time > '2023-01-01' -- 修改后SQL SELECT * FROM orders WHERE (status = 'ACTIVE' AND create_time > '2023-01-01') AND (create_by = 'user123' OR dept_id = 'dept456')6. 最佳实践建议
注解使用原则:
- 保持注解配置最小化,只声明必要的属性
- 为常用查询创建专门的权限注解,如@OrderPermission、@CustomerPermission等
- 避免在Controller层使用数据权限注解,保持权限控制靠近数据层
测试策略:
- 对拦截器进行单元测试,验证各种SQL场景下的修改正确性
- 编写集成测试,模拟不同权限用户查询数据的结果
- 使用AOP测试工具验证注解是否按预期生效
监控与日志:
- 记录SQL修改前后的对比日志(仅开发环境)
- 监控权限拦截器的执行时间,确保不会成为性能瓶颈
- 实现权限命中率统计,了解各权限规则的使用频率
灰度发布方案:
- 先在小范围功能中试点新权限方案
- 保留旧权限代码,通过开关控制新旧方案切换
- 对比新旧方案的查询结果,确保一致性
这套方案在我们生产环境运行半年多以来,数据权限相关的Bug减少了90%以上,新功能开发时也不再需要反复确认权限逻辑是否正确实现。特别是在应对组织架构调整时,只需修改权限规则配置,所有相关查询自动适应新的权限要求,真正实现了"一次编写,处处生效"的理想效果。