做过几年后端的人,应该都遇到过这种场景:同一个接口,不同角色登录进来,能看到的数据完全不一样。老板要看全公司的经营数据,部门主管只能看自己部门的,普通员工只能看自己的单子。这其实就是典型的数据权限校验,属于鉴权里面最容易让人头疼的一块。
我自己的做法是沉淀了一个基于Spring MVC的通用数据权限校验(鉴权)框架,日常业务代码只需要加一个注解,权限条件自动生效,不需要在每个Mapper里手写一堆where判断。这篇东西就围绕这个框架的使用示例来写,按照我实际跑过的项目来拆解:整个框架的设计思路、核心细节、具体怎么落地配置,以及我在广西移动互联网中心(虚拟环境)里调试时踩过的那些和网上教程不太一样的坑。
先说清楚定位:这套框架解决的是“登录用户能看哪些数据”的问题,不是“用户能不能登录”的问题。前者是数据权限,后者是身份认证,两个不是一个东西,别混。
1. 项目整体设计与思路拆解
刚开始想自己写数据权限的时候,我的第一反应是直接在Service层里写:
if ("admin".equals(roleCode)) { // 查全部 } else { // 只查部门 }三个角色还好,等角色变成七八个、业务又分成订单、客户、报表好几块的时候,代码就乱成一锅粥了。后面静下心复盘,才发现核心问题其实只有一个:业务代码不该关心当前用户能看哪些数据,它只负责把“约束条件”带上查询里就行。
整套框架的想法就很直白:把数据权限的解析过程抽出来,做成一个通用的组件。业务接口上标记一个注解,拦截器自动解析当前登录人,从ThreadLocal里拿用户上下文,找出他对应的数据范围(全部、本部门、本人、本部门及以下等),最后再把权限条件以SQL片段的形式塞给业务查询。业务方完全不用感知权限逻辑,整个过程中业务代码零侵入。
1.1 为什么用Spring MVC拦截器而不是过滤器
实话讲,我也试过用Filter来实现,毕竟Servlet Filter理论上是入口,管得更早。真正上手才发现两个问题:
第一个问题:Filter在Spring管理之外。你在Web.xml里注册的Filter默认拿不到Spring的Bean,非要在Filter里用ApplicationContext手工去getBean,写起来极其别扭,尤其到了Spring Boot集成阶段,配置也不够统一。
第二个问题:Filter拿不到HandlerMethod。过滤器的世界里只有路径,没有方法的概念。而Spring MVC的核心——HandlerInterceptor——是能拿到HandlerMethod的,进而能拿到方法上的注解、一次请求所在Controller的类信息、参数注解,这对鉴权框架来说是刚需。
你可以理解为:Filter管的是“请求是否合法”,HandlerInterceptor管的是“这个请求正在调用哪个方法”。后者天然更贴近业务语义。
所以框架选择了HandlerInterceptor:
- 在
preHandle里解析请求和当前用户; - 将权限上下文写入
ThreadLocal; - 在
afterCompletion里清理上下文,防止线程池复用导致数据串线。
1.2 为什么要有“通用”这两个字
我把很多项目里的数据权限实现翻了个遍,发现各自的写法千奇百怪但本质都差不多:要么直接在每个ServiceImpl里循环判断角色,要么把临时写好的SQL片段复制粘贴到好几个Mapper里。一旦权限规则变化,东改一句西改一句,改漏了老板那边又不满意。
通用的思路就是把权限规则抽象成几个维度:
- 权限类型:全部数据、本部门及以下、本部门、本人、自定义字段;
- 匹配结构:用户表、部门表、角色表之间的关联关系;
- 拼接策略:把权限范围最终翻译成一段
WHERE条件。
业务开发完全不用关心这些维度怎么实现,只需要在Controller接口上标记注解,通用框架内部完成维度解析和SQL拼接。这就是“通用”两个字的价值。
这个方案选型的另一个好处是,它可以无缝对接很多项目现有的账号体系。用户表是现成的,角色表是现成的,部门表也是现成的。框架只需读配置、做关联,不需要去改动用户的登录流程,落地成本极低。
1.3 框架的核心模块划分
整个框架被我拆成了四个模块,按生效顺序排列:
- 注解模块:定义
@DataScope注解,作用在Controller方法上,标记这个接口需要做数据权限校验; - 拦截模块:
DataScopeInterceptor,负责在请求进入时读取用户信息、解析注解,并初始化权限上下文; - 解析模块:根据用户数据权限类型生成SQL条件片段,替换掉语句里的占位符;
- 上下文模块:
ThreadLocal保存当前请求的权限数据,业务层通过静态方法获取并拼接到查询里。
模块之间用服务接口解耦,所有实现类都由Spring管理。后面你想把部门维度改成自定义维度,只需要替换解析器实现,不用动拦截逻辑。
2. 核心细节解析与实操要点
设计的思路定了,落地的时候有些细节必须抠清楚。这部分直接决定框架好不好用、会不会线上翻车。
2.1 注解怎么定:参数越少越好用
最早我在注解里设计了五六个别名参数,比如deptAlias、userAlias、roleAlias,实操下来发现很蠢。因为业务开发不会去记那么多字段,问一句“这注解里面到底填什么”就卡住了。
后来我换成极简方案:注解里只有一个type参数,用来声明数据权限的类型:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DataScope { /** * 数据权限类型 * ALL: 全部数据 * DEPT_ALL: 本部门及以下 * DEPT: 本部门 * SELF: 仅本人 */ ScopeType type() default ScopeType.DEPT_ALL; /** * 参与过滤的字段别名,默认是当前业务表的别名 */ String fieldAlias() default ""; }fieldAlias只在特殊场景下需要,比如你的查询主表不是用户表,而是project表时,需要指定p.id这样子的完整列名,其余情况走默认就行。用最少的参数解决绝大多数问题,方便业务方抄作业。
2.2 数据范围的定义与属性表设计
权限类型的定义需要考虑真实业务的复杂性。“全部数据”和“仅本人”都好理解,难办的是中间地带的“本部门及以下”。举个实例:一个集团平台,某大区的负责人既要看自己部门的数据,还要看下属几个子部门的数据。单纯通过数据字典配置很难支撑这种层级关系。
我这边在设计上参考了比较经典的“数据权限范围字典表”,核心字段包括:
- 权限标识(
ALL/DEPT_ALL/DEPT/SELF); - 数据范围编码;
- SQL拼接模板;
- 适用范围描述。
同一个用户可能同时出现在部门层级的不同位置,所以权限范围的匹配不只是一对一,还需要结合用户在组织机构中的位置来做递归匹配。
好在这套逻辑在工作流、OA系统、进销存系统里被验证过无数次,踩的坑也摆在那里。我把它的实现拆成两步:
- 根据用户ID查出所在部门ID;
- 根据部门ID递归查出子部门ID集合。
子部门集合会作为“本部门及以下”的过滤条件拼进SQL。为了性能考虑,第三步我建议直接把子部门ID集合做缓存,部门层级变动后手动刷新缓存。否则每次都递归查一遍部门表,组织架构稍微复杂一点就能把数据库打爆。
2.3 ThreadLocal 上下文传递的正确姿势
很多小白把ThreadLocal理解成“一个变量到处传”,这里必须正本清源:ThreadLocal是“当前线程的局部变量”。如果处理不当,线上出现的数据串线问题,十有八九就出在这一步。
小技巧:在拦截器的preHandle中初始化ThreadLocal,在afterCompletion中必须清理。无论业务方法是否抛异常,afterCompletion都会被执行,这一点比finally还好用,因为异常不会被吞掉。
@Component public class DataScopeInterceptor implements HandlerInterceptor { private final DataScopeContext context; public DataScopeInterceptor(DataScopeContext context) { this.context = context; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } DataScope dataScope = handlerMethod.getMethodAnnotation(DataScope.class); if (dataScope == null) { return true; } CurrentUser user = currentUser(); DataScopeContext.DataPermission permission = dataScopeService.resolve(user, dataScope.type()); context.set(permission); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { context.clear(); } }事实上有相当多的业务系统使用线程池异步处理子任务,例如一个线程把数据导出拆成多个子线程。你以为子线程的权限上下文还在吗?不在。因为ThreadLocal是线程局部的,子线程根本拿不到父线程的副本。
解决方式不复杂——如果你的耗时可接受,就把权限上下文显式传进去;如果不行,使用TransmittableThreadLocal这类增强方案。这一点我建议在框架设计阶段就确定好,不然后续要给所有异步代码擦屁股。
2.4 SQL拼接:这块最容易写崩
数据权限最终的表现形式是SQL条件拼接。我最开始自测的时候,直接拼了个and p.dept_id in (...)进去,但MyBatis的XML里使用${}和#{}完全不是一回事。
#{}是预编译参数占位符,对应?,无法拼接一整段条件;${}是字符串直接替换,有注入风险,但有时候必须用它来拼接动态条件片段。
所以我在框架里采用了占位符替换的思路:让业务SQL里显式留一个${dataScope}占位符,然后由框架统一替换成最终的SQL条件片段。
<select id="selectProjectList" resultType="Project"> select p.id, p.name, p.owner_id, p.dept_id, p.project_amount from project p where p.deleted = 0 ${dataScope} order by p.id desc </select>拦截器解析完用户权限后,把${dataScope}替换成比如:
and p.dept_id in (101, 102, 103)注意这套写法有前提:占位符必须由框架统一注入,业务代码不能直接用外部参数去拼${}。否则就是自己把自己弄成注入漏洞。框架内部在替换前需要做严格的字段白名单校验,只允许由解析模块生成的合法条件片段。
3. 实操过程与核心环节实现
现在进入手把手环节。我维护的这套框架示例项目比较简单,但五脏俱全:用户、部门、角色、项目四张表,外加数据权限配置表。场景设计是一个项目管理系统,不同角色看到的项目列表范围不同。
3.1 项目结构与依赖准备
我用的是Maven构建的Spring MVC 5 + MyBatis 3 项目,最外层的依赖就是标准的Spring MVC和MyBatis。如果你用Spring Boot,可以平移到spring-boot-starter-web,CRUD写法、拦截器注册方式大同小异。
核心依赖清单:
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-webmvc</artifactId> <version>5.3.39</version> </dependency> <dependency> <groupId>org.mybatis</groupId> <artifactId>mybatis</artifactId> <version>3.5.10</version> </dependency> <dependency> <groupId>commons-beanutils</groupId> <artifactId>commons-beanutils</artifactId> <version>1.9.4</version> </dependency>目录结构:
src/main/java/com/example/framework/ ├── annotation/DataScope.java ├── context/DataScopeContext.java ├── interceptor/DataScopeInterceptor.java ├── service/DataScopeService.java └── service/impl/DataScopeServiceImpl.java src/main/java/com/example/business/ ├── controller/ProjectController.java ├── service/ProjectService.java └── mapper/ProjectMapper.xml3.2 数据权限配置表
数据库里除业务表外,需要一张数据权限配置表,用来存储权限类型和SQL片段的映射关系。坦白讲,完全可以在代码里硬编码,但做成表的好处是将来运营可以改,不需要重新发版。
CREATE TABLE sys_data_scope ( id BIGINT PRIMARY KEY, scope_type VARCHAR(32) NOT NULL COMMENT '权限类型: ALL/DEPT_ALL/DEPT/SELF', scope_name VARCHAR(64), sql_template TEXT COMMENT '数据权限SQL片段模板,形如: and p.dept_id in (...)', status TINYINT DEFAULT 1, create_time DATETIME ); INSERT INTO sys_data_scope (scope_type, scope_name, sql_template) VALUES ('ALL', '全部数据', ''), ('DEPT_ALL', '本部门及以下', 'and p.dept_id in (${deptIds})'), ('DEPT', '本部门', 'and p.dept_id = ${deptId}'), ('SELF', '仅本人', 'and p.owner_id = ${userId}');这里面有两点要注意:
第一,${deptIds}是模板参数,不是外部SQL注入。框架解析时会把当前用户的部门ID集合填充进去,集合来自权限解析服务,不是来自请求参数。
第二,模板里使用的是项目表的别名p,这个是约定好的,业务方必须保证查询SQL的主表别名和模板里的别名一致。不一致的话,需要在@DataScope(fieldAlias = "x")里指定。
3.3 核心服务实现
权限解析服务是整个框架的大脑。它的职责就是输入一个当前用户,输出一个具体的SQL条件片段。
@Service public class DataScopeServiceImpl implements DataScopeService { @Resource private SysUserMapper sysUserMapper; @Resource private SysDeptMapper sysDeptMapper; @Resource private SysDataScopeMapper sysDataScopeMapper; @Override public DataScopeContext.DataPermission resolve(CurrentUser currentUser, ScopeType scopeType) { String template = sysDataScopeMapper.selectTemplateByType(scopeType.name()); if (template == null || template.isEmpty()) { return DataScopeContext.DataPermission.empty(); } Map<String, Object> params = new HashMap<>(); params.put("userId", currentUser.getUserId()); if (scopeType == ScopeType.DEPT_ALL) { List<Long> deptIds = getChildDeptIds(currentUser.getDeptId()); deptIds.add(currentUser.getDeptId()); params.put("deptIds", deptIds.stream() .map(String::valueOf) .collect(Collectors.joining(","))); } else if (scopeType == ScopeType.DEPT) { params.put("deptId", currentUser.getDeptId()); } // 基于简单模板的字符串替换 String finalSql = template.replace("${deptIds}", String.valueOf(params.get("deptIds"))) .replace("${deptId}", String.valueOf(params.get("deptId"))) .replace("${userId}", String.valueOf(params.get("userId"))); return new DataScopeContext.DataPermission(finalSql); } }快速说明几个设计决策:
- 使用模板替换而不是代码拼接,好处是权限字段改动只动表,不动代码。
getChildDeptIds方法递归查子部门,这里必须做缓存和递归深度的控制,防止部门层级太深导致性能问题。- 如果是
SELF类型,只填充userId就能完成过滤。
3.4 Controller 使用示例
Controller层就是这篇文章标题里说的“使用示例”,代码少到令人舒适:
@RestController @RequestMapping("/project") public class ProjectController { @Resource private ProjectService projectService; @GetMapping("/list") @DataScope(type = ScopeType.DEPT_ALL) public Result<PageResult<ProjectVO>> list(ProjectQuery query, PageQuery pageQuery) { return Result.ok(projectService.pageList(query, pageQuery)); } }projectService.pageList内部调用Mapper查询:
<select id="selectProjectPage" resultType="com.example.business.vo.ProjectVO"> select p.id, p.name, p.owner_id, p.dept_id, p.project_amount from project p where p.deleted = 0 ${dataScope} order by p.id desc </select>框架在请求进入时,已经把${dataScope}替换成了合法性很高的一段条件。效果如下:
- 超管角色:
${dataScope}为空,全表可见; - 部门经理:
and p.dept_id in (101, 102, 103),本部门及下级可见; - 普通员工:
and p.owner_id = 10086,只能看到自己的项目。
上面这个流程跑通之后,整个功能链就闭合了:注解 → 拦截器 → 权限解析服务 → SQL占位符 → 业务查询。
3.5 拦截器注册与Spring MVC配置
DataScopeInterceptor写好了还不算完,还必须注册到Spring MVC的拦截器链上。注意,Java Config和XML配置方式我强烈建议搞清楚一种就够。
XML方式:
<mvc:interceptors> <mvc:interceptor> <mvc:mapping path="/**"/> <mvc:exclude-mapping path="/login"/> <mvc:exclude-mapping path="/logout"/> <bean class="com.example.framework.interceptor.DataScopeInterceptor"/> </mvc:interceptor> </mvc:interceptors>Java Config方式:
@Configuration public class WebConfig implements WebMvcConfigurer { @Resource private DataScopeInterceptor dataScopeInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(dataScopeInterceptor) .addPathPatterns("/**") .excludePathPatterns("/login", "/logout"); } }拦截器顺序很重要。如果你同时有登录拦截器(校验token)、数据权限拦截器、审计日志拦截器,那数据权限拦截器必须排在登录拦截器之后。因为数据权限拦截器需要从用户上下文拿当前用户信息,用户还没确认就谈权限,全是空谈。
4. 常见问题与排查技巧实录
这部分才是实操的精华。网上很多框架介绍都写得天花乱坠,但一运行就是各种问题。我把在这个框架上实际调试时遇到的典型问题记录如下,按频率排序。
4.1 同线程数据串线,A用户看到了B用户的数据
这个坑我一年要提醒上百次。原因是Tomcat线程池复用了线程,上一次请求在ThreadLocal里残留的数据没有清掉。新请求进来时,preHandle还没执行,异步业务可能先读了上下文,拿到就是上一次请求的脏数据。
解决方案在框架设计时已经强制必须处理:
@Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { DataScopeContext.clear(); }另外我需要强调一个容易被忽视的细节:千万不能在postHandle里清理。postHandle只在正常返回时执行,异常发生时可能跳过,使用afterCompletion才是保险的。
如果进程里有用到线程池,推荐考虑TransmittableThreadLocal这种支持值传递的增强型线程变量。美团分享过一个Java线程池问题排查的经典案例,本质就是这里提到的场景。
4.2 框架注解不生效,拦截不到Controller方法
出现这个问题的常见原因有三个:
- 拦截器注册的路径不匹配,
/api/**写了但Controller实际在/rest/**下; - 注解放在
Controller上,而拦截器获取的是HandlerMethod,这没问题,但如果注解放在的类是被代理类,方法注解可能拿不到; - 在Spring Boot 2.6之后,路径匹配策略发生了变化,导致拦截器路径失效。
最直接的排查方式:在preHandle里打一下日志,输出handler是不是HandlerMethod的类型,以及当前请求URI。先确认拦截器有没有生效,再确认方法上有没有注解,两步就能定位。
4.3 多表关联时权限字段别名总对不上
有一类数据权限涉及多表join,比如项目关联客户表,权限在客户维度上,但查询的别名是p。最早版本的框架只允许一个默认别名,导致这种场景直接垮掉。
解决方案是在注解上加了一个fieldAlias属性,用完整列名描述过滤字段:
@DataScope(type = ScopeType.DEPT_ALL, fieldAlias = "c.dept_id") public Result<...> listByCustomer(ProjectQuery query) { }再打开SQL模板后,权限解析会把模板里的p.统一替换成c.,或者更优雅的方案:框架提供一个字段映射表,业务方只需要把列名映射好,权限条件自动基于映射后的字段生成。
排查这个问题时记住一个口诀:先看SQL,再看别名,先跑native,再去跑框架。确认原生SQL执行无误后,再检查框架生成的SQL片段是否和原生SQL冲突。
4.4 员工列表出现了子部门的用户,但部门查不到
这个问题的根源是部门数据不完整,子部门表数据缺失,或者用户的dept_id没有正确落到部门表的主键上。
出现这种跨部门漏数据的情况,多数是递归查询子部门的时候,递归没有闭合。例如有个部门的PID指向了自己,递归条件就永远退不出来。
在实现递归查询时要设置两层保护:
public List<Long> getChildDeptIds(Long deptId) { List<Long> result = new ArrayList<>(); Set<Long> visited = new HashSet<>(); Deque<Long> queue = new LinkedList<>(); queue.offer(deptId); while (!queue.isEmpty()) { Long id = queue.poll(); if (!visited.add(id)) { continue; // 防止环 } List<Long> children = deptMapper.selectChildIds(id); result.addAll(children); queue.addAll(children); } return result; }有了visited防止循环引用,同时在SQL层面对status=1的部门做过滤,避免停用的部门还挂在组织架构树下面。
4.5 高频缓存与权限实时性的取舍
数据权限有一个天然的矛盾:缓存能提升性能,但权限变化可能不能及时生效。比如把用户从A部门调到B部门,如果权限数据缓存不刷新,用户还能看到原来部门的数据,这是管理事故。
我的做法是采用“短缓存+主动失效”双保险:
- 部门层级缓存时长控制在30秒到1分钟;
- 用户组织和角色发生变更时,主动调用缓存刷新接口,清除相关key。
这种方案对权限系统来说是最安全稳妥的。千万不要把权限数据缓存一整天,权限场景优先保证准确性,性能优化排在后头。运气不好真出了问题,背锅的还是写代码的人。
4.6 数据权限和分页的配合问题
分页是多条SQL同时执行的,MyBatis分页插件在计算总条数时会改写SQL。如果框架只是简单地把${dataScope}占位符替换成条件,理论上分页插件会拿到完整的SQL再处理,这个没问题。
但有一种坑:PageHelper会先执行count查询,再执行page查询。如果count查询中包含了数据权限表中用到的${}替换字段,而Mapper参数没有传递该字段,就会报错。MyBatis的${}是从Mapper接口传参中取的,框架做不到把全局变量注入Mapper参数。
所以更稳妥的做法是:在MyBatis层面定义动态SQL片段:
<sql id="dataScope"> ${dataScope} </sql>然后业务SQL引入这段。这里${dataScope}是来自上下文或参数,不是框架占位符替换。我在实际生产中更推荐这种,因为能被分页插件正确处理,排查也容易。
不过这段是框架使用者约定俗成的内容,最终用哪种方式需要看业务场景。我建议保存一种主推方式,避免团队里有人用占位符,有人用参数传递,风格分裂。
常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 用户能看到其他人数据 | ThreadLocal未清理 | 确保在afterCompletion清空上下文 |
| 数据权限注解不生效 | 拦截器路径配错或未注册 | 先打印handler,确认HandlerMethod上的注解 |
| SQL报“无效的列名” | 权限字段别名与业务SQL别名不一致 | 检查fieldAlias,保证alias一致 |
| 部门数据查不全 | 递归查询部门时存在环或数据缺失 | 引入访问集合,限制递归深度 |
| 分页数据异常 | count和page两次SQL竞争条件不一致 | 将生成的SQL条件固化到XML片段中 |
| 缓存导致权限滞后 | 权限缓存时间过长 | 短缓存+主动失效 |
排查通用顺序:先看preHandle日志,再看生成的SQL片段,再审ThreadLocal上下文,最后才盯着返回结果去猜。按这个顺序,大多数问题十分钟内就能定到位。
5. 实际操作里的一点感受
框架从设计到示例落地,前后大概用了两周时间。真正让框架变得可用的,不是算法有多高明,而是把“脏活”全部揽到底:注解解析、上下文传递、SQL片段生成、异常处理。业务模块反而像没发生过事情一样只管自己的业务逻辑。
最初我实际使用这个示例时,最大的感受是“代码清爽了很多”。以前每个接口里都要传一堆部门ID、用户ID进去,Mapper.xml里也全是if判断权限的标签,现在全部收敛到框架层。新写一个列表接口,只要加上一个注解,SQL模板里放一个占位符,效果立刻就出来。
默认实现可能不适合你的业务,比如有些系统的部门有独立法人实体,有些系统的用户组织关系不在部门维度,而在“分组”维度。你需要做的不是推翻框架重写,而是扩展resolve方法:返回你自己的SQL条件片段,只要能把权限范围表达成合法SQL,框架其余部分不用动。
另外一个使用体会可以分享:如果哪天框架嵌入到老旧系统里,各种蛋疼的权限字段已经满天飞了,就不要试图通过一个注解统一掉所有接口。先让新接口使用框架,老接口慢慢迁移,每迁移一个验证一个,比一次大拆大改稳得多。代码重构这件事,没人是靠一次性推倒重来赢下来的。