
做过几年后端的人应该都遇到过这种场景同一个接口不同角色登录进来能看到的数据完全不一样。老板要看全公司的经营数据部门主管只能看自己部门的普通员工只能看自己的单子。这其实就是典型的数据权限校验属于鉴权里面最容易让人头疼的一块。我自己的做法是沉淀了一个基于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 idselectProjectList resultTypeProject 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-webCRUD写法、拦截器注册方式大同小异。核心依赖清单dependency groupIdorg.springframework/groupId artifactIdspring-webmvc/artifactId version5.3.39/version /dependency dependency groupIdorg.mybatis/groupId artifactIdmybatis/artifactId version3.5.10/version /dependency dependency groupIdcommons-beanutils/groupId artifactIdcommons-beanutils/artifactId version1.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(); } MapString, Object params new HashMap(); params.put(userId, currentUser.getUserId()); if (scopeType ScopeType.DEPT_ALL) { ListLong 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 ResultPageResultProjectVO list(ProjectQuery query, PageQuery pageQuery) { return Result.ok(projectService.pageList(query, pageQuery)); } }projectService.pageList内部调用Mapper查询select idselectProjectPage resultTypecom.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 classcom.example.framework.interceptor.DataScopeInterceptor/ /mvc:interceptor /mvc:interceptorsJava 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 ListLong getChildDeptIds(Long deptId) { ListLong result new ArrayList(); SetLong visited new HashSet(); DequeLong queue new LinkedList(); queue.offer(deptId); while (!queue.isEmpty()) { Long id queue.poll(); if (!visited.add(id)) { continue; // 防止环 } ListLong children deptMapper.selectChildIds(id); result.addAll(children); queue.addAll(children); } return result; }有了visited防止循环引用同时在SQL层面对status1的部门做过滤避免停用的部门还挂在组织架构树下面。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 iddataScope ${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框架其余部分不用动。另外一个使用体会可以分享如果哪天框架嵌入到老旧系统里各种蛋疼的权限字段已经满天飞了就不要试图通过一个注解统一掉所有接口。先让新接口使用框架老接口慢慢迁移每迁移一个验证一个比一次大拆大改稳得多。代码重构这件事没人是靠一次性推倒重来赢下来的。