本文目录导读:

- 为什么要做数据权限?— 不只是“登录”那么简单
- 核心概念拆解:数据权限 vs 功能权限
- 主流实现方案对比:SQL拼接、拦截器、注解驱动
- 案例实战:基于Spring Boot + MyBatis的行级数据权限
- 常见坑与性能优化建议
- 高频面试问答(Q&A)
- 总结与扩展阅读方向
从零到一:Java实现数据权限的实战指南(RBAC + 行级过滤 + 拦截器详解)**
目录导读
- 为什么要做数据权限?— 不只是“登录”那么简单
- 核心概念拆解:数据权限 vs 功能权限
- 主流实现方案对比:SQL拼接、拦截器、注解驱动
- 案例实战:基于Spring Boot + MyBatis的行级数据权限
- 1 数据模型与表设计
- 2 自定义注解 + AOP拦截器
- 3 动态SQL拼接的核心逻辑
- 常见坑与性能优化建议
- 高频面试问答(Q&A)
- 总结与扩展阅读方向
为什么要做数据权限?— 不只是“登录”那么简单
在很多企业级系统中,功能权限(“你能不能访问这个页面”或“你能不能按这个按钮”)只解决了“能不能用”的问题,但真正的业务痛点在于数据权限:
同样是“查看订单”菜单,销售A只能看到自己的订单,销售经理能看到整个团队的订单,而财务总监能看到公司所有订单。
如果只做功能权限,那么所有登录用户看到的都是同一份数据,这在多租户、多组织、多层级的企业系统中是致命的设计缺陷。
数据权限的本质:在SQL执行前或执行时,动态地给查询语句加上“行级过滤条件”,确保用户只能操作和查看自己被授权的数据行。
核心概念拆解:数据权限 vs 功能权限
| 维度 | 功能权限(菜单/按钮) | 数据权限(行级别) |
|---|---|---|
| 控制粒度 | 操作(如增删改查) | 数据行(如订单记录) |
| 实现方式 | RBAC模型(角色-权限) | 数据范围规则(本人、本部门、全部) |
| 常见框架 | Spring Security, Shiro | 自定义拦截器 + SQL解析 |
| 冲突场景 | 有按钮但看不到数据 | 看得到菜单但数据为空 |
核心误区:很多新手以为用了Shiro或者Spring Security就自动有了数据权限,其实这两个框架默认只处理认证和授权,不处理行级数据过滤,数据权限必须由开发者在DAO层或Service层自己实现。
主流实现方案对比:SQL拼接、拦截器、注解驱动
在Java生态中,实现数据权限主要有三种路径,各有优劣:
-
硬编码SQL拼接
在每次查询时手动加WHERE user_id = ?。
✅ 简单直观;❌ 代码冗余,容易遗漏,维护成本极高。 -
MyBatis拦截器(Interceptor)
通过拦截Executor的query方法,解析SQL,动态追加条件。
✅ 统一、透明,改造量小;❌ 需要处理SQL解析(推荐JSqlParser),复杂SQL容易出错。 -
自定义注解 + AOP
在Mapper或Service方法上打上@DataScope注解,通过AOP切面获取当前用户,通过线程变量传递参数,再注入到SQL中。
✅ 业务侵入性小,灵活;❌ 注解定义需要规范,且要处理方法嵌套。
本文实战采用方案2+3结合:用注解标记需要数据权限的方法,用拦截器统一处理。
案例实战:基于Spring Boot + MyBatis的行级数据权限
1 数据模型与表设计
假设我们有如下表:
sys_user(用户表):user_id,dept_id,user_namesys_dept(部门表):dept_id,parent_id,dept_namebiz_order(业务订单表):order_id,order_no,creator_id,dept_id
数据权限规则定义(存入字典表或枚举):
- 本人数据:
creator_id = 当前用户ID - 本部门数据:
dept_id = 当前用户部门ID - 本部门及以下数据:
dept_id IN (当前部门及所有子部门) - 全部数据:不追加条件
2 自定义注解 + AOP拦截器
定义一个注解,作用在Mapper或Service方法上:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
// 指定表别名(如果SQL中有别名,必须对应)
String tableAlias() default "";
// 指定属于当前用户的列名,默认creator_id
String userColumn() default "creator_id";
// 指定部门列名,默认dept_id
String deptColumn() default "dept_id";
}
使用MyBatis的Interceptor拦截器,在Executor执行前处理:
@Component
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.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. 判断当前方法是否拥有@DataScope注解
// 2. 如果有,获取当前登录用户的上下文(从SecurityContext或ThreadLocal)
// 3. 根据用户的角色(如普通员工、部门经理)计算出数据范围(如本人、本部门)
// 4. 使用JSqlParser解析 BoundSql,拼接 where 条件
// 5. 替换BoundSql后继续执行
return invocation.proceed();
}
}
核心代码逻辑(伪代码):
// 从用户上下文获取用户和部门
Long userId = SecurityUtils.getUserId();
Long deptId = SecurityUtils.getDeptId();
String roleKey = SecurityUtils.getRoleKey(); // 如 "manager"
// 解析原SQL
Select select = (Select) CCJSqlParserUtil.parse(boundSql.getSql());
PlainSelect plainSelect = (PlainSelect) select.getSelectBody();
// 构建权限条件
StringBuilder condition = new StringBuilder();
if ("staff".equals(roleKey)) {
condition.append(" creator_id = ").append(userId);
} else if ("manager".equals(roleKey)) {
// 查询所有子部门ID
List<Long> subDeptIds = deptService.getChildDeptIds(deptId);
condition.append(" dept_id IN (").append(
subDeptIds.stream().map(String::valueOf).collect(Collectors.joining(","))
).append(")");
}
// 如果原SQL已有where,则加上AND
plainSelect.setWhere(new AndExpression(plainSelect.getWhere(), new CustomExpression(condition.toString())));
3 动态SQL拼接的核心细节
- 注意表别名:如果SQL用了别名
SELECT * FROM biz_order o,则拼接条件必须用o.creator_id。 - 避免全表扫描:数据权限条件尽量使用索引列(通常用
dept_id或user_id)。 - 多表关联:如果SQL是
JOIN,要确保条件作用在正确的表上,防止误过滤。
常见坑与性能优化建议
坑1:SQL解析失败
复杂SQL(含子查询、UNION、窗口函数)可能被JSqlParser解析异常,建议:
- 对所有涉及数据权限的SQL进行单元测试。
- 若解析失败,降级为“全部数据”并记录告警日志,不要直接报错崩溃。
坑2:超级管理员绕过权限
一般做法是:如果当前用户是admin或具有数据全部角色,则跳过权限拦截,避免性能损耗。
坑3:分页插件和拦截器冲突
分页插件(如PageHelper)也实现Interceptor,注意拦截器执行顺序,建议让数据权限拦截器优先于分页插件执行(通过@Order或interceptor.order),否则分页统计SQL可能漏掉数据集。
性能优化建议:
- 数据权限条件尽量写成IN (子查询),而不是先查子部门再拼接字符串(避免N+1问题)。
- 对频繁查询的用户部门层级建立缓存(如Redis或本地Caffeine)。
- 考虑在数据库层面建立
部门层级表(递归CTE),交由SQL完成子部门递归。
高频面试问答(Q&A)
Q1:你们项目的数据权限具体是怎么实现的?
A:我们基于Spring Boot + MyBatis,定义了一个@DataScope注解,标记在需要拦截的Mapper方法上,通过MyBatis的Executor拦截器,在查询执行前,用JSqlParser解析SQL,根据当前登录用户的角色动态拼接WHERE条件,例如普通员工本人数据,部门经理本部门及以下数据,管理员则不加条件。
Q2:如果有一次查询关联了多张表,数据权限加在哪张表上?
A:这取决于业务语义,如果主表是订单,过滤条件加在主表;如果主表是客户,但要求只能看本部门的客户,则加在客户表,我们在注解上支持指定表别名和列名,以应对不同场景。
Q3:数据权限和Shiro/Spring Security有什么区别?
A:Shiro和Spring Security是功能权限框架,解决“用户能执行哪些操作”(比如能否访问/order/list接口),数据权限是SQL行级过滤,是业务逻辑层的数据隔离,两者互补,很多项目只用了框架,但没有实现行级数据权限,导致越权查看数据。
Q4:如果SQL特别复杂,JSqlParser解析失败怎么办?
A:这是常见风险,我们做了两件事:
- 数据库只允许规范的SQL写法,不写特别花哨的语句。
- 拦截器中catch解析异常,记录日志并返回全量数据(但会发送告警邮件给开发人员),这比直接500错误要安全。
Q5:数据权限会影响性能吗?如何优化?
A:会,因为多了一个WHERE条件,且可能涉及子查询(查部门层级),优化方案:
- 部门表使用物化路径或通过Redis缓存层级关系。
- 使用EXPLAIN分析执行计划,确保权限字段有索引。
- 如果数据量极大,可采用“数据权限白名单”预先计算好用户可见的ID集合,但更推荐用数据库递归查询。
总结与扩展阅读方向
数据权限是Java后端开发中的高级需求,不是仅靠框架就能自动完成的,本文通过一个基于Spring Boot + MyBatis的实战案例,展示了注解标记 + 拦截器解析 + 动态SQL拼接的完整链路。
扩展方向:
- 集成
spring-security,将角色与数据范围绑定。 - 支持多租户场景(租户ID作为全局过滤)。
- 使用
MyBatis-Plus的DataPermissionInterceptor,它是官方插件,但只支持简单的配置,复杂场景仍需自定义。 - 探索基于
Apache Calcite或ShardingSphere的更强大的SQL改写方案。
阅读建议:结合你自己的业务,先从“本部门数据”开始实践,因为这类条件最简单且最实用,不要一开始就搞复杂递归,否则容易陷入SQL解析的泥潭。