Spring Boot + MyBatis-Plus 数据权限实战:从设计到落地
2026/8/26 10:12:19 网站建设 项目流程

1. 项目概述:从“一刀切”到“千人千面”的数据管控

在任何一个涉及多角色、多部门协作的业务系统中,数据权限都是一个绕不开的核心议题。想象一下,一个全国性的销售管理系统,大区经理能看到自己区域的所有订单,城市经理只能看到自己城市的,而普通销售代表只能看到自己名下的客户。这背后,就是数据权限在起作用。它要解决的,远不止“谁能登录系统”这么简单,而是深入到“登录后能看到什么数据、能操作哪些记录”的层面。我经历过不少项目,早期为了快速上线,往往采用最粗放的权限控制——要么全有,要么全无。结果就是,要么信息泄露风险巨大,要么一线员工抱怨系统难用,查询效率低下。这个项目,正是要系统性地解决这个问题,设计一套灵活、高效、可落地的数据权限技术实现方案,并验证其实际应用效果。

简单来说,数据权限就是一套规则引擎,它根据当前操作者的身份(谁)、所处的环境(何时何地)、以及要执行的动作(干什么),动态地对数据进行过滤和裁剪,确保“该看的人看到该看的数据”。它的核心价值在于,在保障数据安全与合规的前提下,最大化数据的可用性和业务效率。无论是金融行业的客户信息隔离、电商平台的店铺数据隔离,还是企业内部的人力资源数据分级查看,都离不开一套健壮的数据权限体系。接下来,我将结合多年的实战经验,拆解这套方案从设计到落地的全过程。

2. 核心设计思路与架构选型

数据权限的实现,本质上是在用户请求数据的路径上,增加一个动态的过滤器。这个过滤器的规则,需要能够灵活配置,并且高效执行。市面上常见的思路有几种,我们需要根据业务复杂度、性能要求和技术栈来做出选择。

2.1 主流实现模式对比

在动手之前,我们必须厘清几种核心的实现模式,它们各有优劣,适用于不同的场景。

基于数据行的权限控制:这是最常见也最直观的方式。它的核心思想是,在数据库查询时,自动附加与当前用户相关的过滤条件。例如,查询订单表时,自动加上where sales_id = ${currentUserId}或者where department_id in (${userDeptList})。这种方式对应用透明,实现相对简单,但规则复杂后,SQL 会变得异常臃肿,且难以处理跨表、跨层级的数据关系。

基于数据列的权限控制:控制用户能看到表的哪些字段。例如,普通员工查看客户表时,隐藏“身份证号”和“年收入”字段,而经理可以看到。这通常在数据返回给前端前,在后端进行字段的脱敏或裁剪。它常与行级权限结合使用,实现更细粒度的控制。

基于数据单元的权限控制:这是最精细的粒度,控制到具体某个单元格的值。比如,同一份报表中,A 部门看到的是实际金额,B 部门看到的是按一定规则模糊处理后的金额区间。这种实现成本最高,通常需要在前端或后端渲染时进行动态替换。

基于预计算视图的权限控制:为不同权限角色预先创建不同的数据库视图(View),应用层直接查询对应的视图。这种方式将复杂的权限规则固化在数据库层,查询性能好,但视图管理繁琐,权限变更时需要修改视图定义,不够灵活。

经过多个项目的权衡,对于大多数中小型业务系统,我推荐采用“行级为主,列级为辅,在应用层实现规则引擎”的混合模式。这样既能满足大部分业务隔离需求,又保持了足够的灵活性和可维护性。

2.2 架构设计核心:规则引擎与执行点

确定了模式,接下来要设计系统的核心——规则引擎。规则引擎负责解析和计算当前用户有权访问的数据范围。这里的关键决策在于:规则如何定义?在哪里执行?

规则定义:我们采用“主体-资源-条件”三元组模型。

  • 主体:谁。可以是用户ID、角色、职位、部门等,支持多属性组合。
  • 资源:对什么进行操作。通常对应数据库表或业务实体,如订单客户
  • 条件:具体的过滤规则。这是最灵活的部分,我们设计了一套简单的表达式语言,例如:
    • creator_id = #{currentUserId}(我创建的)
    • dept_id IN #{currentUserDeptIds}(我所在部门及下级部门)
    • region_code = #{currentUserRegion}(我负责的区域) 这些表达式中的占位符(如#{currentUserId})会在执行时被替换为当前用户的具体属性。

执行点选择:这是影响性能和代码侵入性的关键。

  1. DAO/ORM 层拦截:在数据访问层(如 MyBatis 的 Interceptor, JPA 的 Specification)统一注入过滤条件。这是最优雅的方式,对业务代码几乎无侵入。例如,在 MyBatis 中,可以编写一个插件,解析 Mapper 方法上的注解,动态修改 SQL。
  2. Service 层封装:在业务逻辑层,通过一个统一的DataScopeService来包装数据查询。业务方调用dataScopeService.filterQuery(Order.class, query)来获得过滤后的查询对象。这种方式更直观,但需要在每个查询点都调用。
  3. 数据库视图层:如前所述,将规则预置为视图。适合权限模型稳定、变化少的场景。

我们最终选择了DAO 层拦截为主的方案。因为它对开发人员最友好,他们只需要在编写查询方法时添加一个@DataScope注解,指定资源类型和需要的字段,剩下的权限过滤由框架自动完成,符合“约定优于配置”的原则。

注意:规则引擎的设计一定要避免过度复杂化。初期可以用硬编码或配置文件的方式支持几种常见规则(如本人、本部门、本部门及下属),等业务模式稳定后再考虑可视化配置。一开始就追求万能规则配置器,很容易导致项目失控。

3. 技术实现细节与核心代码解析

理论说完了,我们进入实战环节。我将以基于 Spring Boot + MyBatis-Plus 的技术栈为例,展示一个可落地的行级数据权限实现方案。选择 MyBatis-Plus 是因为它提供了强大的拦截器机制,方便我们切入 SQL 生成过程。

3.1 环境准备与核心依赖

首先,确保你的项目已经引入了 Spring Boot 和 MyBatis-Plus 的相关依赖。这里的关键是 MyBatis-Plus 的拦截器。

<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>最新稳定版</version> </dependency>

3.2 定义数据权限注解与上下文

我们需要一个注解来标记需要数据权限过滤的方法,并传递必要的参数。

/** * 数据权限注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** * 表的别名,用于多表关联查询时区分 */ String tableAlias() default ""; /** * 权限字段,默认为创建人字段 */ String deptField() default "dept_id"; /** * 用户字段,默认为创建人字段 */ String userField() default "create_by"; }

同时,需要一个线程安全的上下文来存储当前登录用户的权限信息(如用户ID、部门ID、数据权限范围字符串等)。这个信息通常在登录认证后,存入SecurityContext或自定义的ThreadLocal中。

public class DataScopeContextHolder { private static final ThreadLocal<DataScopeUser> CONTEXT = new ThreadLocal<>(); public static void setDataScopeUser(DataScopeUser user) { CONTEXT.set(user); } public static DataScopeUser getDataScopeUser() { return CONTEXT.get(); } public static void clear() { CONTEXT.remove(); } @Data public static class DataScopeUser { private Long userId; private Long deptId; private String roleKey; // 数据权限范围字符串,例如 "1,2,3" 或 "ALL" private String dataScope; } }

3.3 实现 MyBatis-Plus 数据权限拦截器

这是最核心的部分。我们将实现一个Interceptor,在 SQL 被执行前,动态添加WHERE条件。

@Slf4j @Component @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 { StatementHandler statementHandler = (StatementHandler) invocation.getTarget(); MetaObject metaObject = SystemMetaObject.forObject(statementHandler); MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement"); // 1. 判断是否需要进行数据权限过滤 // 获取方法上的 @DataScope 注解 String id = mappedStatement.getId(); String className = id.substring(0, id.lastIndexOf(".")); String methodName = id.substring(id.lastIndexOf(".") + 1); Class<?> clazz = Class.forName(className); Method method = Arrays.stream(clazz.getMethods()) .filter(m -> m.getName().equals(methodName)) .findFirst().orElse(null); if (method == null || !method.isAnnotationPresent(DataScope.class)) { // 没有注解,直接放行 return invocation.proceed(); } DataScope dataScope = method.getAnnotation(DataScope.class); DataScopeUser user = DataScopeContextHolder.getDataScopeUser(); // 2. 如果用户无权限信息或为超级管理员,直接放行 if (user == null || "admin".equals(user.getRoleKey()) || "ALL".equals(user.getDataScope())) { return invocation.proceed(); } // 3. 根据用户权限,构建 SQL 过滤条件 String originalSql = (String) metaObject.getValue("delegate.boundSql.sql"); String whereCondition = buildDataScopeWhere(user, dataScope); if (StringUtils.isNotBlank(whereCondition)) { // 4. 修改原始 SQL,插入 WHERE 条件 String newSql = appendWhereCondition(originalSql, whereCondition); metaObject.setValue("delegate.boundSql.sql", newSql); log.debug("数据权限过滤,原始SQL: {}, 新SQL: {}", originalSql, newSql); } return invocation.proceed(); } /** * 构建 WHERE 条件字符串 */ private String buildDataScopeWhere(DataScopeUser user, DataScope dataScope) { String tableAlias = StringUtils.isNotBlank(dataScope.tableAlias()) ? dataScope.tableAlias() + "." : ""; String deptField = tableAlias + dataScope.deptField(); String userField = tableAlias + dataScope.userField(); StringBuilder where = new StringBuilder(); // 规则1:仅本人数据 if ("SELF".equals(user.getDataScope())) { where.append(userField).append(" = ").append(user.getUserId()); } // 规则2:本部门数据 else if ("DEPT".equals(user.getDataScope())) { where.append(deptField).append(" = ").append(user.getDeptId()); } // 规则3:本部门及以下部门数据 (需要部门层级关系表支持) else if ("DEPT_AND_CHILD".equals(user.getDataScope())) { // 假设通过一个函数或子查询获取所有子部门ID where.append(deptField).append(" IN (SELECT id FROM sys_dept WHERE find_in_set(") .append(user.getDeptId()).append(", ancestors))"); } // 规则4:自定义数据范围 (如:'1,2,3') else if (StringUtils.isNumericSpace(user.getDataScope().replace(",", ""))) { where.append(deptField).append(" IN (").append(user.getDataScope()).append(")"); } // 其他规则... return where.length() > 0 ? where.toString() : null; } /** * 将条件追加到 SQL 的合适位置(处理原有 WHERE、GROUP BY、ORDER BY 等) */ private String appendWhereCondition(String originalSql, String whereCondition) { originalSql = originalSql.trim(); String upperCaseSql = originalSql.toUpperCase(); // 简单的处理逻辑:找到第一个 WHERE 的位置,在其后追加;如果没有 WHERE,则在表名后添加 WHERE if (upperCaseSql.contains("WHERE")) { int whereIndex = upperCaseSql.indexOf("WHERE"); // 在第一个 WHERE 后插入 AND (条件) return new StringBuilder(originalSql).insert(whereIndex + 5, " (" + whereCondition + ") AND ").toString(); } else { // 找到 ORDER BY 或 GROUP BY 或 LIMIT 的位置,在它们之前插入 WHERE int orderByIndex = upperCaseSql.indexOf("ORDER BY"); int groupByIndex = upperCaseSql.indexOf("GROUP BY"); int limitIndex = upperCaseSql.indexOf("LIMIT"); int insertIndex = originalSql.length(); if (orderByIndex != -1) insertIndex = Math.min(insertIndex, orderByIndex); if (groupByIndex != -1) insertIndex = Math.min(insertIndex, groupByIndex); if (limitIndex != -1) insertIndex = Math.min(insertIndex, limitIndex); return new StringBuilder(originalSql).insert(insertIndex, " WHERE " + whereCondition + " ").toString(); } } @Override public Object plugin(Object target) { return Plugin.wrap(target, this); } @Override public void setProperties(Properties properties) { // 可以读取配置 } }

3.4 业务层使用示例

在 Service 或 Mapper 层,使用变得非常简单:

@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Override @DataScope(deptField = "sales_dept_id", userField = "salesman_id") // 指定本业务表对应的部门字段和用户字段 public List<Order> selectOrderList(Order order) { // 这个方法内部可能会调用 orderMapper.selectList(queryWrapper) // 拦截器会自动在生成的SQL上加上数据权限条件 return orderMapper.selectOrderList(order); } }

这样,当城市经理调用selectOrderList时,他实际执行的 SQL 会是SELECT * FROM order WHERE salesman_id = 1001 AND ...;当大区经理调用时,可能是SELECT * FROM order WHERE sales_dept_id IN (10, 11, 12) AND ...。这一切对编写业务代码的开发人员是透明的。

实操心得:拦截器修改 SQL 时,一定要处理好 SQL 的原始格式和大小写问题。上面的appendWhereCondition方法是一个简化版,在生产环境中,强烈建议使用 SQL 解析器(如 jsqlparser)来精准地定位和修改 SQL 树,这样可以完美处理子查询、联合查询等复杂情况,避免因字符串替换导致语法错误。

4. 高级场景与性能优化策略

基础的行级权限实现后,我们会遇到更复杂的业务场景,同时也必须面对性能挑战。

4.1 处理多表关联与复杂查询

上面的例子是针对单表查询。但在实际业务中,大量的查询都是多表关联。例如,查询订单时需要关联客户表,而权限可能基于客户所属的区域。这时,我们的注解和拦截器需要升级。

方案一:在注解中支持多实体权限声明。

@DataScope({ @DataFilter(tableAlias = "o", deptField = "sales_dept_id"), @DataFilter(tableAlias = "c", deptField = "region_id") // 客户表的区域字段 }) public List<OrderVO> selectOrderWithCustomer(...)

拦截器需要解析多个@DataFilter,为每个表别名生成对应的条件,并用AND连接。

方案二:将权限规则提升到“业务实体”层面。定义一个“订单视图”实体,其数据权限规则是:“销售员所属部门 = 订单部门” AND “销售员所属区域 = 客户区域”。在拦截器中,我们不再机械地根据表别名添加条件,而是根据“视图实体”的定义,生成一个复杂的、可能涉及多表的 WHERE 子句。这种方式更贴近业务,但规则引擎的设计会复杂得多。

对于大多数项目,我建议从方案一开始,保持规则与表结构的相对直观对应。当关联逻辑固定且复杂时,再考虑抽象成方案二

4.2 列级权限与数据脱敏实现

行级权限控制“能看到哪些行”,列级权限则控制“能看到行的哪些字段”。实现通常在数据返回前端之前。

在后端实现:

  1. 注解标记敏感字段:在实体类的字段上使用@SensitiveField注解。
  2. 序列化时动态处理:利用 Jackson 的JsonSerializer或 Spring 的ResponseBodyAdvice,在序列化对象为 JSON 时,根据当前用户角色,判断是否需要对该字段进行脱敏(如替换为*,或返回空值)。
public class SensitiveFieldSerializer extends JsonSerializer<String> { @Override public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException { // 从安全上下文中获取当前用户 User user = SecurityContext.getCurrentUser(); if (user != null && user.hasPermission("view_sensitive_field")) { gen.writeString(value); // 有权限,显示原值 } else { gen.writeString("****"); // 无权限,脱敏 } } }

在前端实现(不推荐作为唯一手段):后端返回所有字段,前端根据用户权限元数据,动态隐藏或禁用某些表格列或表单控件。这种方式简单,但数据已在网络传输和浏览器内存中,存在安全风险,只能作为体验优化,不能替代后端权限校验。

4.3 性能瓶颈分析与优化

数据权限最大的性能风险在于:动态添加的 WHERE 条件可能导致索引失效,或产生大量低效的 IN 查询。

优化策略1:权限预计算与缓存对于“本部门及所有下级部门”这类需要递归查询的规则,不要在每次 SQL 执行时都去查部门树。可以在用户登录时或权限变更时,预计算好该用户能访问的所有部门ID列表,存入缓存(如 Redis)。拦截器中直接使用缓存的ID列表IN (...)。虽然IN子句过长也有性能问题,但比递归查询好得多。

优化策略2:分页查询的总数问题在使用MyBatis-PlusPage分页时,拦截器修改了查询数据的 SQL,但计算总数的COUNT语句可能没有被同样修改。这会导致分页总数不准。必须在拦截器中同时处理数据查询SQL和计数查询SQL。检查MappedStatement的 ID,如果是以_COUNT结尾,同样需要注入权限条件。

优化策略3:避免全表扫描确保权限过滤字段(如dept_id,create_by)上有合适的索引。与 DBA 协作,对核心业务表的查询模式进行分析,建立复合索引。例如,常见的查询是where dept_id = ? and status = ?,那么建立(dept_id, status)的联合索引就非常有效。

优化策略4:异步与批处理场景在导出报表、发送批量消息等异步任务中,执行任务的线程可能脱离了原始的 Web 请求上下文,导致DataScopeContextHolder获取不到用户信息。解决方案是在创建异步任务时,将当前用户的权限信息(如dataScope字符串)作为任务参数显式传递过去,在任务执行线程中手动设置上下文。

5. 应用效果评估与常见问题排查

一套系统上线,不能只关注技术实现,更要看它带来的业务价值。同时,上线初期肯定会遇到各种问题。

5.1 效果评估维度

我们可以从以下几个维度来评估数据权限方案的应用效果:

  1. 安全性提升

    • 量化指标:权限相关的事故(如数据越权访问、泄露)发生频率是否降为零或显著降低。
    • 审计能力:是否能够清晰追溯每条数据的访问和操作记录,满足合规性要求。
  2. 开发效率影响

    • 侵入性:新功能开发时,开发人员需要额外编写多少权限控制代码?使用注解后,是否做到了接近“零编码”?
    • 维护成本:当组织架构调整(部门拆分合并)或权限规则变更时,需要修改多少处代码?理想情况是只需在用户-角色-部门关系配置页面上操作。
  3. 系统性能影响

    • 接口响应时间:引入权限过滤后,核心列表查询接口的平均响应时间增加了多少?应控制在可接受的范围内(如增加不超过20%)。
    • 数据库负载:观察数据库服务器的 CPU 和 IO 使用率,确保没有因为复杂的权限 JOIN 或子查询导致负载激增。
  4. 业务灵活性

    • 规则覆盖度:现有的规则引擎是否能覆盖90%以上的业务权限场景?对于剩下的10%特殊场景,是否有应急或扩展机制?
    • 配置便捷性:业务管理员能否通过界面自行配置一些简单的数据权限规则,而无需开发介入?

5.2 常见问题排查实录

在实际落地过程中,我踩过不少坑,这里总结几个最典型的:

问题一:分页查询总数不对,总是返回全部数据量。

  • 现象:列表第一页显示10条数据,但分页组件显示总记录数是1000(全表数据)。
  • 排查:检查拦截器日志,发现数据查询的 SQL 被正确添加了WHERE dept_id = 10,但执行COUNT(1)的 SQL 语句没有被修改。
  • 解决:在拦截器中,需要判断当前执行的 SQL 是否是用于分页的计数查询。在 MyBatis-Plus 中,计数查询的MappedStatementID 通常会在原 ID 后附加_COUNT。拦截器需要识别并同样注入权限条件。

问题二:多表关联查询时,权限条件加错了表,导致查询结果为空或错误。

  • 现象:一个关联了订单和物流表的查询,本应根据物流网点过滤,结果条件加到了订单表上。
  • 排查@DataScope注解的tableAliasdeptField配置错误。在复杂的、有多个别名的查询中,必须明确指定每个权限条件应用于哪个表别名。
  • 解决:打印出拦截器修改后的完整 SQL,仔细核对每个条件前的表别名是否正确。建议为复杂的查询编写单元测试,固定输入用户权限,断言输出的 SQL 条件。

问题三:超级管理员账号登录,查询速度变慢。

  • 现象:普通员工查询很快,但 admin 账号查询同样页面却慢很多。
  • 排查:代码中判断如果是 admin 就不过滤数据(dataScope = ‘ALL’),直接返回全量数据。这导致了全表扫描,当数据量大时自然就慢。
  • 解决:超级管理员也需要高效查询。不能简单地取消条件,而应该为其提供更高效的过滤路径,例如,admin 默认看到最近三个月的数据(基于时间索引),或者通过其他必填搜索条件(如业务类型)来缩小范围。权限放开不代表不需要性能优化。

问题四:异步任务(如定时报表)执行报错,提示“无法获取用户数据权限”。

  • 现象:在 Spring 的@Async异步方法或 Quartz 定时任务中,获取不到DataScopeContextHolder中的用户信息。
  • 排查:异步任务运行在独立的线程池线程中,而ThreadLocal变量是线程隔离的,原始请求线程的上下文不会自动传递。
  • 解决:使用TaskDecoratorRunnable/Callable包装器,在提交异步任务前,将当前线程的权限上下文信息捕获,并在异步线程开始执行时重新设置。
@Component public class ContextCopyingTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { DataScopeUser originalUser = DataScopeContextHolder.getDataScopeUser(); return () -> { try { DataScopeContextHolder.setDataScopeUser(originalUser); // 在新线程中设置 runnable.run(); } finally { DataScopeContextHolder.clear(); } }; } }

然后在异步任务执行器配置中注入这个TaskDecorator

问题五:权限规则变更后,已登录用户依然使用旧规则。

  • 现象:管理员在后台修改了某个角色的数据权限范围,但已经在线的前端用户,操作时权限并未立即更新。
  • 排查:用户的权限信息(如dataScope字符串)通常在登录时生成并缓存(在 session 或 token 中),后续请求直接从缓存读取,不会实时查询数据库。
  • 解决:这是一个缓存一致性问题。有两种策略:
    1. 设置较短的缓存过期时间:例如,将用户权限缓存设置为5-10分钟过期,牺牲一点性能换取数据的相对及时性。
    2. 主动清除缓存:在权限管理后台,当修改了角色或用户的权限后,主动发送事件或调用接口,清除相应用户的权限缓存。下次该用户请求时,会重新加载最新权限。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询