【Java后端技术亮点】基于注解的数据权限控制,一行代码实现数据隔离
写在前面:做企业管理系统的同学一定遇到过这个需求——管理员看全部数据,部门经理看本部门,普通员工看自己的。很多人的做法是在每个接口里手写if判断,代码又臭又长。今天聊聊RuoYi、JeecgBoot都在用的方案:基于注解的数据权限控制,一行注解搞定数据隔离。

一、为什么需要数据权限控制?
1.1 业务场景
实际场景:你做了一个订单管理系统,销售经理要看团队所有订单,销售员只能看自己的订单,财务要看全部订单但只能看金额不能看客户信息。这种需求在每个企业管理系统里都会遇到。
常见的数据权限需求:
| 角色 | 数据范围 | 典型场景 |
|---|---|---|
| 系统管理员 | 全部数据 | 超级管理员,无限制 |
| 部门经理 | 本部门数据 | 销售经理看团队业绩 |
| 普通员工 | 个人数据 | 销售员看自己的订单 |
| 自定义 | 按规则过滤 | 财务看金额、客服看投诉 |
1.2 传统做法的痛点
踩坑提醒:我见过最糟糕的做法是在每个查询接口里写一堆if判断。代码重复、难以维护、容易漏掉。
// ❌ 糟糕的做法:每个接口都写判断逻辑
@GetMapping("/list")
public Result list() {
Long userId = SecurityUtil.getUserId();
String role = SecurityUtil.getRole();
if ("admin".equals(role)) {
return orderService.listAll();
} else if ("manager".equals(role)) {
Long deptId = userService.getDeptId(userId);
return orderService.listByDept(deptId);
} else {
return orderService.listByUser(userId);
}
}
// 另一个接口又要写一遍...
@GetMapping("/myOrders")
public Result myOrders() {
// 又是一堆if判断...
}
痛点总结:
- 代码重复(每个接口都要写)
- 容易漏掉(新增接口忘记加判断)
- 难以维护(权限规则变更要改所有接口)
- SQL耦合业务(业务逻辑和权限逻辑混在一起)
二、注解式数据权限的核心思路
2.1 一行注解搞定
// ✅ 优雅的做法:一行注解
@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u")
public Result list() {
return orderService.list();
}
原理:通过AOP拦截方法,在SQL执行前动态拼接权限过滤条件。
2.2 整体架构
请求进入
↓
AOP拦截带@DataScope注解的方法
↓
获取当前用户的数据权限范围
↓
动态修改SQL(拼接WHERE条件)
↓
执行SQL返回过滤后的数据
核心组件:
| 组件 | 作用 |
|---|---|
@DataScope注解 |
标记需要数据权限过滤的方法 |
DataScopeAspect切面 |
AOP拦截,解析注解,拼接SQL |
DataScopeHandler处理器 |
根据用户角色生成过滤条件 |
| MyBatis拦截器 | 在SQL执行前修改SQL语句 |
三、核心实现代码
3.1 定义@DataScope注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
/**
* 部门表的别名,用于拼接部门过滤条件
* 例如:SELECT * FROM order o LEFT JOIN dept d ON o.dept_id = d.id
* 这里deptAlias = "d"
*/
String deptAlias() default "";
/**
* 用户表的别名,用于拼接用户过滤条件
* 例如:SELECT * FROM order o LEFT JOIN user u ON o.user_id = u.id
* 这里userAlias = "u"
*/
String userAlias() default "";
/**
* 权限字段名(可选)
* 默认使用配置的字段名
*/
String permissionField() default "";
}
3.2 数据权限范围定义
public enum DataScopeEnum {
/** 全部数据权限 */
ALL(1, "全部数据权限"),
/** 自定义数据权限(按部门ID列表) */
CUSTOM(2, "自定义数据权限"),
/** 本部门数据权限 */
DEPT(3, "本部门数据权限"),
/** 本部门及以下数据权限 */
DEPT_AND_CHILD(4, "本部门及以下数据权限"),
/** 仅本人数据权限 */
SELF(5, "仅本人数据权限");
private final Integer code;
private final String desc;
}
3.3 AOP切面实现
@Aspect
@Component
public class DataScopeAspect {
@Before("@annotation(dataScope)")
public void doBefore(JoinPoint point, DataScope dataScope) {
// 1. 获取当前用户
User user = SecurityUtil.getCurrentUser();
if (user == null) {
return; // 未登录,不处理
}
// 2. 如果是管理员,跳过数据权限过滤
if (user.isAdmin()) {
return;
}
// 3. 获取用户的数据权限范围
DataScopeEnum scope = user.getDataScope();
// 4. 生成SQL过滤条件
String sqlFilter = buildSqlFilter(user, scope, dataScope);
// 5. 将过滤条件存入ThreadLocal(供MyBatis拦截器使用)
DataScopeContext.setSqlFilter(sqlFilter);
}
private String buildSqlFilter(User user, DataScopeEnum scope, DataScope annotation) {
StringBuilder filter = new StringBuilder();
String deptAlias = annotation.deptAlias();
String userAlias = annotation.userAlias();
switch (scope) {
case ALL:
// 全部数据,不加过滤条件
break;
case CUSTOM:
// 自定义部门ID列表
if (StringUtils.isNotBlank(deptAlias)) {
filter.append(deptAlias).append(".dept_id IN (")
.append(user.getCustomDeptIds())
.append(")");
}
break;
case DEPT:
// 本部门数据
if (StringUtils.isNotBlank(deptAlias)) {
filter.append(deptAlias).append(".dept_id = ")
.append(user.getDeptId());
}
break;
case DEPT_AND_CHILD:
// 本部门及子部门
if (StringUtils.isNotBlank(deptAlias)) {
filter.append(deptAlias).append(".dept_id IN (")
.append("SELECT dept_id FROM dept WHERE find_in_set(")
.append(user.getDeptId())
.append(", ancestors)");
}
break;
case SELF:
// 仅本人数据
if (StringUtils.isNotBlank(userAlias)) {
filter.append(userAlias).append(".user_id = ")
.append(user.getId());
}
break;
}
return filter.toString();
}
@After("@annotation(dataScope)")
public void doAfter() {
// 清理ThreadLocal
DataScopeContext.clear();
}
}
3.4 MyBatis拦截器拼接SQL
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
@Component
public class DataScopeInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 获取ThreadLocal中的过滤条件
String sqlFilter = DataScopeContext.getSqlFilter();
if (StringUtils.isBlank(sqlFilter)) {
return invocation.proceed(); // 无过滤条件,直接执行
}
// 2. 获取原始SQL
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String originalSql = boundSql.getSql();
// 3. 拼接过滤条件
String newSql = originalSql + " AND " + sqlFilter;
// 4. 重置SQL
resetSql(ms, boundSql, newSql);
return invocation.proceed();
}
private void resetSql(MappedStatement ms, BoundSql boundSql, String newSql) {
// 通过反射修改SQL
try {
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, newSql);
} catch (Exception e) {
throw new RuntimeException("修改SQL失败", e);
}
}
}
经验之谈:MyBatis拦截器是修改SQL的利器,但要注意性能。每次SQL执行都会触发拦截器,所以判断逻辑要尽量简单。ThreadLocal存过滤条件就是为了避免重复计算。
四、实际使用示例
4.1 订单列表查询
@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u")
public Result list(OrderQueryDTO query) {
List<Order> orders = orderMapper.selectList(query);
return Result.success(orders);
}
对应的Mapper SQL:
<select id="selectList" resultType="Order">
SELECT o.*, u.username, d.dept_name
FROM order o
LEFT JOIN user u ON o.user_id = u.id
LEFT JOIN dept d ON o.dept_id = d.id
WHERE o.status = #{status}
<!-- 数据权限条件会自动拼接在这里 -->
</select>
不同用户看到的SQL:
| 用户角色 | 最终执行的SQL |
|---|---|
| 管理员 | SELECT ... WHERE o.status = 1 |
| 部门经理 | SELECT ... WHERE o.status = 1 AND d.dept_id = 10 |
| 普通员工 | SELECT ... WHERE o.status = 1 AND u.user_id = 100 |
4.2 多表关联查询
@GetMapping("/report")
@DataScope(deptAlias = "d") // 只按部门过滤
public Result report() {
List<Report> reports = reportMapper.selectReport();
return Result.success(reports);
}
<select id="selectReport" resultType="Report">
SELECT d.dept_name, COUNT(*) as order_count, SUM(amount) as total_amount
FROM order o
LEFT JOIN dept d ON o.dept_id = d.id
GROUP BY d.dept_id
<!-- 数据权限条件会自动拼接在GROUP BY之前 -->
</select>
踩坑提醒:GROUP BY查询时,数据权限条件要拼接在GROUP BY之前,否则过滤不生效。MyBatis拦截器需要处理这种情况。
五、预判问题与解答
Q1:如果SQL没有JOIN部门表,怎么办?
问题场景:SELECT * FROM order WHERE status = 1,没有JOIN dept表,但注解写了deptAlias = "d"。
解答:
- 方案一:拦截器检测到别名不存在,自动忽略该条件
- 方案二:强制要求SQL必须JOIN相关表(编译期或运行时校验)
- 方案三:使用子查询替代JOIN
-- 方案三:子查询替代
SELECT * FROM order
WHERE status = 1
AND dept_id IN (SELECT dept_id FROM dept WHERE dept_id = #{currentDeptId})
Q2:数据权限和分页怎么配合?
解答:数据权限条件拼接在WHERE中,分页插件(如PageHelper)会自动处理。
原始SQL:SELECT * FROM order WHERE status = 1
数据权限后:SELECT * FROM order WHERE status = 1 AND d.dept_id = 10
分页后:SELECT * FROM order WHERE status = 1 AND d.dept_id = 10 LIMIT 0, 10
注意:MyBatis拦截器的执行顺序很重要。数据权限拦截器要在分页拦截器之前执行。
// 配置拦截器顺序
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 先加数据权限拦截器
interceptor.addInnerInterceptor(new DataScopeInnerInterceptor());
// 再加分页拦截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
Q3:一个用户有多个角色,数据权限怎么算?
解答:取最大范围(最宽松的权限)。
// 用户同时是部门经理(本部门)和项目负责人(自定义部门列表)
// 数据权限取两者的并集
private String buildSqlFilter(User user, DataScope annotation) {
Set<String> conditions = new HashSet<>();
for (Role role : user.getRoles()) {
String condition = buildConditionByRole(role, annotation);
if (StringUtils.isNotBlank(condition)) {
conditions.add(condition);
}
}
if (conditions.isEmpty()) {
return "";
}
// 多个条件用OR连接(取并集)
return "(" + conditions.stream().collect(Collectors.joining(" OR ")) + ")";
}
Q4:数据权限配置存哪里?
解答:通常存数据库,支持动态配置。
-- 角色数据权限配置表
CREATE TABLE role_data_scope (
role_id BIGINT,
data_scope TINYINT, -- 权限范围类型
custom_dept_ids VARCHAR(500), -- 自定义部门ID列表
PRIMARY KEY (role_id)
);
配置界面:在角色管理页面,增加数据权限配置选项。
| 权限范围 | 说明 |
|---|---|
| 全部数据 | 系统管理员级别 |
| 本部门数据 | 部门经理级别 |
| 本部门及子部门 | 大区经理级别 |
| 仅本人数据 | 普通员工级别 |
| 自定义 | 选择特定部门 |
Q5:性能会不会有问题?
解答:
- ThreadLocal存储:过滤条件只计算一次,避免重复计算
- 简单字符串拼接:SQL修改是字符串操作,性能开销很小
- 索引优化:确保dept_id、user_id字段有索引
性能对比:
| 方案 | 性能开销 |
|---|---|
| 每个接口手写if判断 | 无额外开销,但代码重复 |
| 注解+AOP | 每次请求多一次AOP拦截(微秒级) |
| 注解+MyBatis拦截器 | 每次SQL多一次字符串拼接(纳秒级) |
总体性能开销可忽略,换来的是代码整洁和可维护性。
六、与RuoYi的实现对比
RuoYi的数据权限实现更完善,支持多表、子查询、权限字符等功能:
// RuoYi的@DataScope注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DataScope {
String deptAlias() default "d";
String userAlias() default "u";
String permission() default ""; // 权限字符,用于细粒度控制
}
// RuoYi的使用示例
@GetMapping("/list")
@DataScope(deptAlias = "d", userAlias = "u", permission = "system:user:list")
public Result list() {
// ...
}
RuoYi还支持:
- 权限字符控制(不同菜单配置不同数据权限)
- 多表关联自动识别
- 子查询自动转换
七、面试高频考点
面试官问:请介绍一下基于注解的数据权限控制方案
参考答案:
基于注解的数据权限控制是一种优雅的数据隔离方案,核心思路是通过AOP拦截+MyBatis拦截器,在SQL执行前动态拼接权限过滤条件。
工作流程:
- 方法上添加
@DataScope注解,指定部门表别名和用户表别名 - AOP切面拦截方法,获取当前用户的数据权限范围
- 根据权限范围生成SQL过滤条件,存入ThreadLocal
- MyBatis拦截器在SQL执行前,从ThreadLocal取出过滤条件拼接到SQL
优点:
- 代码简洁(一行注解搞定)
- 业务解耦(权限逻辑不侵入业务代码)
- 易于维护(权限规则变更只改一处)
试官问:MyBatis拦截器是怎么修改SQL的?
参考答案:
MyBatis拦截器通过反射修改BoundSql对象的sql字段。
核心步骤:
- 实现Interceptor接口,使用
@Intercepts注解标记拦截的方法 - 从Invocation中获取MappedStatement和BoundSql
- 通过反射获取BoundSql的sql字段并修改
- 调用
invocation.proceed()继续执行
注意点:
- 拦截器执行顺序很重要(数据权限要在分页之前)
- 反射修改sql字段有性能开销,但可忽略
- 多表关联时要正确识别表别名
试官问:数据权限和功能权限有什么区别?
参考答案:
| 权限类型 | 控制对象 | 实现方式 |
|---|---|---|
| 功能权限 | 能不能操作 | RBAC(角色-菜单-按钮) |
| 数据权限 | 能看到哪些数据 | SQL过滤条件 |
功能权限解决"能不能"的问题:
- 能不能看这个菜单
- 能不能点这个按钮
数据权限解决"看多少"的问题:
- 管理员看全部订单
- 部门经理看本部门订单
- 员工看自己的订单
两者配合使用:先判断功能权限(能不能访问),再判断数据权限(能看到什么)。
试官问:ThreadLocal在这里的作用是什么?
参考答案:
ThreadLocal用于在AOP切面和MyBatis拦截器之间传递数据权限过滤条件。
为什么用ThreadLocal:
- 线程隔离:每个请求独立,不会串数据
- 避免重复计算:AOP计算一次,拦截器直接使用
- 跨组件传递:AOP和拦截器是不同组件,ThreadLocal作为桥梁
生命周期:
- AOP切面
@Before:计算过滤条件,存入ThreadLocal - MyBatis拦截器:从ThreadLocal取出,拼接SQL
- AOP切面
@After:清理ThreadLocal,防止内存泄漏
八、总结
基于注解的数据权限控制核心价值:
- 代码简洁:一行注解替代一堆if判断
- 业务解耦:权限逻辑和业务逻辑分离
- 易于维护:权限规则变更只改一处
- 灵活配置:支持多种权限范围,可动态配置
一句话总结:把数据权限过滤逻辑从业务代码中抽离,通过注解+AOP+MyBatis拦截器自动完成SQL拼接,让开发者专注于业务逻辑。
参考资料
互动话题:你在项目中是怎么实现数据权限的?是每个接口手写判断,还是用注解+AOP?有没有遇到过数据权限和分页冲突的问题?欢迎在评论区分享你的实践经验!
如果这篇文章对你有帮助,欢迎点赞、收藏!关注我,后续会继续分享更多Java后端技术亮点 👇
本文为【Java后端技术亮点】系列第2篇,持续更新中…
更多推荐



所有评论(0)