Spring Boot过滤器与拦截器实战:从登录校验到操作日志的完整指南

Spring Boot过滤器与拦截器实战:从登录校验到操作日志的完整指南 前阵子给easy网盘这个练习项目加功能接口越写越多但登录校验、操作日志、参数过滤这些事情全堆在每个Controller里代码看着又乱又不安全。后来我专门花了一整周把过滤器Filter和拦截器Interceptor从原理到落地完整梳理了一遍发现这两个东西虽然名字很像实际执行的时机、能拿到的信息、适用的场景完全不同。这篇文章就是这次“补充学习”的完整复盘包括过滤器怎么做登录态预检、拦截器怎么做业务鉴权和日志记录、两者如何配合、以及我踩过的几个坑给同样在Spring Boot Spring MVC里写Web项目的同学做个参考。1. easy网盘里为什么需要拦截器又为什么需要过滤器1.1 网盘项目的典型请求链路与安全痛点先说easy网盘的背景。这是一个模拟网盘的练习项目功能包括注册登录、文件上传下载、文件列表、文件重命名、创建分享链接等等。早期的实现方式比较原始每个Controller方法开头都手动判断“当前用户是否登录”判断完还要从Session里把用户信息取出来再拼到业务逻辑里。比如文件列表接口要判断登录重命名接口要判断登录上传接口也要判断登录。每个接口都写一遍代码重复率极高而且很容易漏掉某个新建的接口。有一次我加了一个“删除文件”接口忘了加登录校验测试的时候发现随便传一个文件ID就能删掉别人的文件这才意识到必须引入统一的请求预处理机制。网盘这类项目还有一个特点几乎所有业务操作都必须基于当前登录用户来执行。你不能只判断“登录了没有”还得知道“当前操作的人是谁”否则文件隔离就无从谈起。如果把这种“获取当前用户”的逻辑散落在各处不同接口拿用户的方式还可能有细微差异比如一个用Session、一个用请求头里的Token后患无穷。另外网盘项目里文件上传下载这类操作涉及请求体、响应流日志记录也比普通接口要复杂。你想统计“谁在什么时间上传了多大的文件”如果不在统一的位置做切面处理光靠业务代码里零散打印日志很难形成完整的操作链路。1.2 先想清楚职责边界再动手写代码在动手之前我先做了一轮“需求拆解”把我希望统一处理的事情列了一个清单避免重复登录校验所有/api/**接口除了登录、注册等白名单路径必须校验登录态注入当前用户信息校验通过后把当前用户对象放到一个统一的地方业务代码直接取用记录操作日志记录请求URL、请求方式、请求参数、处理耗时、操作人参数过滤统一处理XSS、非法字符过滤、编码处理跨域支持开发阶段前端单独起服务跨域问题要统一处理上传下载特殊处理大文件请求不要被无谓地拦截下载响应不能重复写日志接下来是第一步分工哪些交给Filter哪些交给Interceptor。我的划分原则很简单越靠近容器底层、与业务无关的事情交给Filter需要感知Spring MVC控制器细节、需要拿业务数据的事情交给Interceptor。举例来说字符编码设置、CORS跨域响应头、XSS参数清理这些和“业务规则”没关系放在Filter里登录校验、权限判定、用户上下文注入、操作日志这些需要知道“这个请求到底要调用哪个Controller方法”的事情放在Interceptor里。这个边界一开始想不清楚没关系但先勉强定一个方向后面踩坑了再调整。我后面就是经历了“Filter里做了登录校验Interceptor里又做了一遍”的重复才把边界重新划清晰的。2. 过滤器实战从请求编码到登录态预检2.1 基于OncePerRequestFilter实现登录态校验过滤器先写了我最想要的登录态预检过滤器。注意我用的不是直接实现javax.servlet.Filter接口而是继承OncePerRequestFilter。这个类能保证一次请求只执行一次过滤逻辑避免Servlet容器在某些情况下对同一个请求多次调用过滤器尤其在转发forward场景下非常有用。Component public class LoginCheckFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String uri request.getRequestURI(); // 白名单路径直接放行 if (uri.startsWith(/api/auth/) || uri.startsWith(/file/preview/)) { filterChain.doFilter(request, response); return; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); return; } // 这里仅做“有没有token”的预检真正的token解析放到拦截器去做 filterChain.doFilter(request, response); } }这段代码逻辑很简单白名单放行、无Token直接返回401。之所以叫“预检”是因为我只在过滤器阶段检查了“请求头里有没有Token”并没有解析Token、没有查库。原因有两点一是Filter阶段拿不到Spring MVC的HandlerMethod我只能做粗粒度的路径判断二是Token解析涉及数据库查询用户信息属于业务逻辑放在Interceptor里更合适分离关注点。2.2 过滤器注册方式与执行顺序的细节在Spring Boot里注册Filter有三种方式我建议直接用FilterRegistrationBean因为可以明确控制顺序。Configuration public class EasyPanFilterConfig { Bean public FilterRegistrationBeanLoginCheckFilter loginCheckFilter() { FilterRegistrationBeanLoginCheckFilter registration new FilterRegistrationBean(); registration.setFilter(new LoginCheckFilter()); registration.addUrlPatterns(/api/*); registration.setOrder(1); return registration; } Bean public FilterRegistrationBeanCharsetFilter charsetFilter() { FilterRegistrationBeanCharsetFilter registration new FilterRegistrationBean(); registration.setFilter(new CharsetFilter()); registration.addUrlPatterns(/*); registration.setOrder(0); return registration; } }这里有个容易踩的坑Component注解和FilterRegistrationBean同时存在会导致过滤器被注册两次。我第一次就是没注意这个LoginCheckFilter类上写着Component配置类里又注册了一遍结果每次请求过滤器执行了两遍。后来把Component去掉只保留FilterRegistrationBean里的注册才恢复正常。关于setOrder数值越小越先执行这里的CharsetFilter是0LoginCheckFilter是1所以请求先经过编码处理再进入登录预检。顺序的设计逻辑是尽量让底层的、无依赖的过滤器先执行有依赖的靠后执行。如果我把编码过滤器放在登录过滤器后面那么登录校验里response.getWriter()写中文时就有可能因为编码没设置好而产生乱码。addUrlPatterns(/api/*)和addUrlPatterns(/*)的区别也要留意。/api/*只会过滤以/api/开头的路径不会匹配/api本身/*过滤所有请求包括静态资源。easy网盘把前端静态资源也放在Spring Boot里运行时如果预览图片、下载图标这些静态资源都被过滤器拦下来就会导致页面样式丢失。解决方法是白名单放行静态资源路径或者更简单——把静态资源单独走一个前缀不在过滤范围内。2.3 文件上传场景中过滤器最容易踩的坑网盘项目最核心的操作是文件和下载这两种请求在Filter里都有特殊之处。先说文件上传。当请求的Content-Type是multipart/form-data时Spring MVC的MultipartResolver会解析请求体。如果我在Filter里调用了request.getParameter()或request.getInputStream()去读取请求内容那么当请求进入Controller时RequestParam(file)就拿不到文件了。因为Servlet请求体是InputStream本质上只能读一次。这在调试的时候非常隐蔽因为普通GET接口完全没有这个问题只有上传文件的接口报错。我当时的解决思路是不在Filter里读取任何请求体内容。如果确实有需求要对上传内容做安全扫描需要用ContentCachingRequestWrapper包装原始请求让后续组件还能读取。public class CachingRequestBodyFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingRequestWrapper wrapper new ContentCachingRequestWrapper(request); filterChain.doFilter(wrapper, response); } }但注意ContentCachingRequestWrapper也不是万能的它在读取请求体时才会缓存内容而且缓存的内容默认只在请求处理完成后才能通过getContentAsByteArray()拿到。如果你想要在Filter层面就拿到文件内容做扫描还需要配合getInputStream()主动消费一次。这会增加内存开销大文件上传尤其明显。所以最终我的选择是上传接口的请求一概不在Filter里做任何和请求体有关的处理安全扫描放到业务逻辑层去做。再说文件下载。下载接口通常返回ResponseEntityResource或直接往HttpServletResponse写流。如果我在Filter里对响应做包装、做日志统计就要注意不要缓存整个文件内容到内存。我最初想在Filter里统计响应大小用ContentCachingResponseWrapper包装了一下实际测试发现下载一个500MB的视频时内存直接飙升。后来我改成只统计请求耗时和状态码响应内容不缓存下载走下载专用的日志记录逻辑。3. 拦截器实战登录校验、操作日志与用户上下文3.1 HandlerInterceptor三个回调方法的执行时机Filter只是解决了一部分问题真正让我觉得“早该做”的是拦截器。Spring MVC的HandlerInterceptor接口里有三个方法分别对应请求处理的不同阶段preHandle在Controller方法调用之前执行返回值决定请求是否继续。返回false就不往下走了postHandle在Controller方法执行之后、视图渲染之前执行。这里能拿到ModelAndView很适合做视图层参数补充afterCompletion整个请求完成之后执行无论过程中有没有抛异常都会执行。非常适合做资源清理和日志收尾我记得第一次看完文档时觉得很简单但真正写代码才意识到这三个方法不一定会全部执行。如果preHandle返回false那postHandle和afterCompletion都不会执行如果postHandle阶段抛了异常afterCompletion依然会执行。这个特性决定了我不能把“日志收集”放在postHandle里因为一旦Controller抛异常这段日志就丢了。正确的做法是把日志记录的核心放在afterCompletion里用Exception ex参数判断是正常结束还是异常结束。3.2 基于HandlerInterceptor完成网盘登录拦截与用户上下文注入我重写了拦截器专门负责登录校验和用户注入。核心思路是用HandlerMethod判断当前请求是不是真的要调用某个Controller方法然后解析Token、查库、把用户信息放到ThreadLocal里业务代码通过静态方法直接获取当前用户。public class LoginInterceptor implements HandlerInterceptor { private UserService userService; public LoginInterceptor(UserService userService) { this.userService userService; } Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 非Controller请求静态资源、错误页等直接放行 if (!(handler instanceof HandlerMethod)) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); return false; } User user userService.getUserByToken(token); if (user null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return false; } UserContext.setUser(user); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 必须清理ThreadLocal防止线程复用时数据串号 UserContext.clear(); } }UserContext的实现非常简单本质就是一个ThreadLocal的封装public class UserContext { private static final ThreadLocalUser HOLDER new ThreadLocal(); public static void setUser(User user) { HOLDER.set(user); } public static User getUser() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); } }这里必须强调afterCompletion里清理ThreadLocal的重要性。Tomcat的工作线程是复用的如果不清除下一个请求在这个线程上运行时UserContext.getUser()拿到的就是上一个请求的用户这会造成严重的数据越权。我在测试时专门写了一个模拟一个用户登录后退出再以另一个用户登录在某一次处理里看到了前一个用户的文件列表排查半天才意识到就是ThreadLocal没清理导致的。在拦截器里能拿到HandlerMethod这一点比Filter强太多。比如我可以配合自定义注解做细粒度的权限控制某个接口加了RequireAdmin注解就在拦截器里判断当前用户是否是管理员。这个需求用Filter很难优雅实现因为Filter阶段拿不到方法级别的信息。3.3 注册拦截器WebMvcConfigurer中addInterceptors的细节拦截器写好了还要注册。Spring Boot里推荐的做法是实现WebMvcConfigurer接口重写addInterceptors方法。Configuration public class EasyPanWebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor(userService)) .addPathPatterns(/api/**) .excludePathPatterns( /api/auth/login, /api/auth/register, /file/preview/** ); } }这里有两个细节需要注意。第一addPathPatterns(/api/**)和Filter里的addUrlPatterns(/api/*)匹配规则不一样。拦截器的/api/**可以匹配/api的子路径以及更深层级比如/api/file/list和/api/user/info/detail都能匹配而Filter的/api/*按照Servlet的路径匹配规则/api/file/list也能匹配但两者在细节上还是有差异最直观的区分方法是拦截器用的是Spring的Ant路径匹配Filter用的是Servlet的URL Pattern规则。第二excludePathPatterns里放行的路径必须和addPathPatterns里的路径匹配规则兼容否则排不生效。如果接口路径写的是/api/auth/login但注册时addPathPatterns(/api/**)是能匹配这个路径的排出来没问题。但如果你注册的是addPathPatterns(/api/*)而接口是/api/auth/login这种二级路径那匹配规则要仔细测试。如果你需要多个拦截器按顺序执行addInterceptor的顺序就是它们执行的顺序。比如我可以加一个“登录拦截器”和一个“日志拦截器”登录拦截器在前日志拦截器在后。这样日志拦截器在afterCompletion里记录日志时UserContext.getUser()一定能拿到当前用户因为登录拦截器先执行了。4. 拦截器与过滤器的核心区别从执行时机到底层机制4.1 执行时机差异容器阶段与Spring MVC阶段的差距把两套代码都写完以后我专门画了一条完整的请求链路想搞清楚一次请求从进入Tomcat到返回Filter和Interceptor到底在哪个节点起作用。虽然我不能用流程图但用文字描述非常清晰客户端请求 → Tomcat容器分配工作线程 → 经过Servlet Filter链Filter 1 → Filter 2 → ...→ 进入DispatcherServlet → HandlerMapping找到对应Handler → 执行HandlerInterceptor.preHandle → 调用Controller方法 → 执行HandlerInterceptor.postHandle → 视图渲染 → 执行HandlerInterceptor.afterCompletion → DispatcherServlet返回响应 → 经过Filter链的返回路径 → 输出给客户端这个链路最重要的结论就是Filter先执行Interceptor后执行。Filter站在Servlet容器层面对所有进入Web应用的请求生效不管这个请求最终由谁来处理而Interceptor是Spring MVC内部的机制它只对DispatcherServlet分发到的请求生效。如果请求在Filter阶段就被拦截了比如我写的登录预检Filter直接返回401那请求根本不会到达Interceptor更不会进入Controller。理解了执行时机就能明白为什么“在Filter里做登录校验、又在Interceptor里做登录校验”是一种浪费。Filter已经拦住了一波无Token请求Interceptor又拦住了一波Token无效的请求虽然两波拦截的策略不同但明显有一部分工作是重复的。4.2 关键差异能不能拿到HandlerMethod在实战中我感受最深的区别是Interceptor能拿到HandlerMethodFilter拿不到。HandlerMethod包含了“即将被调用的Controller方法”这个对象的所有信息包括方法的类、方法名、参数注解、方法注解等等。这意味着我可以做很多精细化的操作在Interceptor里判断handlerMethod.hasMethodAnnotation(RequireAdmin.class)实现管理员权限校验在Interceptor里读取LogAnno(删除文件)注解的值拼装业务日志在Interceptor里根据方法的参数类型做定制化处理Filter阶段只能拿到原始的HttpServletRequest和HttpServletResponse你没有办法得知这个请求会被哪个Controller方法处理只能自己通过URL解析、甚至反射去猜非常不方便。举个easy网盘里的实际场景删除文件接口DeleteMapping(/api/file/{fileId})需要校验当前用户是否有权删除“这个fileId对应的文件”。如果在Filter里做你必须自己解析URL参数fileId再根据文件表查询归属人但如果在Interceptor里做你可以先通过HandlerMethod拿到方法参数解析器从请求里解析出fileId甚至可以配合Spring的参数解析机制优雅地取出参数。虽然单纯从功能上看Filter也并非完全不能做但Interceptor的表达力强太多。4.3 依赖注入与异常处理的差异还有一个历史遗留差异是依赖注入。早期如果直接new Filter()然后在web.xml里注册这个Filter对象不是Spring容器管理的你没法在里面Autowired注入UserService。而Interceptor只要是Spring容器里的Bean天然支持依赖注入。Spring Boot时代这个差异已经被抹平了。你用FilterRegistrationBean注册过滤器时可以把UserService作为构造参数传给Filter或者让Filter本身是Spring Bean然后在FilterRegistrationBean里引用它。所以如果你看到某些老文章说“Filter不能注入Spring Bean”在Spring Boot场景下已经不准确了但理解这个演变历史有助于排查一些莫名其妙的NPE。异常处理方面也有差异。在Filter里所有异常都必须在Filter链内部处理要么try-catch要么直接throw给容器。过滤器抛出的异常ControllerAdvice里的ExceptionHandler是不管的因为Filter在DispatcherServlet之前执行。而Interceptor的preHandle抛出的异常会进入HandlerExceptionResolver的处理流程所以ControllerAdvice里的全局异常处理器有可能会接住但也不是所有版本行为都完全一致。这个差异在网盘项目里体现得最明显的是如果我在Filter里做登录预检时userService查询数据库出现异常传统的RestControllerAdvice捕获不到必须在Filter里自己try-catch并返回JSON错误信息。4.4 选型参考一张对比表说清楚基于这次补充学习我把两者的核心区别整理成了一张对比表方便以后做选型对比维度过滤器 Filter拦截器 Interceptor所属规范Servlet规范Spring MVC框架生效层级Servlet容器层先执行Spring MVC分发层后执行是否经过DispatcherServlet是是能否拿到HandlerMethod否能依赖Spring容器Spring Boot下可以通过注册方式获得天然由Spring容器管理匹配规则Servlet URL Pattern如/api/*Ant路径匹配如/api/**典型场景字符编码、CORS、XSS过滤、登录预检、压缩登录态校验、权限控制、用户上下文、操作日志执行方式FilterChain链式HandlerInterceptor回调是否能拿到ModelAndView否能postHandle异常处理需单独try-catch不经过ExceptionHandler部分异常可被全局异常处理器处理这张表并不是说Filter比Interceptor弱或者反之而是强调它们处在不同的抽象层次。我的使用习惯是能用Interceptor解决的业务问题优先用Interceptor和Spring MVC无关的容器级需求才用Filter。5. 同时用上Filter和Interceptor之后的四个坑5.1 双登录校验Filter和Interceptor各校一遍的问题我在第1章提过这个坑这里仔细复盘一下。最初我的设计是Filter里做了“有Token就放行”、Interceptor里再做“Token有效才放行”看起来是分层校验实际跑起来发现有几个问题。第一性能浪费。每个需要认证的接口都要经过两次Token判断虽然第一次只判断非空但如果不小心在Filter里也查了用户表那一次请求等于查了两次库高峰时段数据库压力明显上升。第二两套校验逻辑不一致。我的Filter里写的白名单是/api/auth/和/file/preview/但Interceptor里的白名单是/api/auth/login和/api/auth/register。文件预览请求虽然Filter放行了但Interceptor没放行直接被拦截导致分享出来的预览链接打不开。后来我把职责彻底划清Filter只做编码、CORS、XSS过滤这类无业务语义的通用处理不碰登录校验登录态相关的所有判断全部收敛到Interceptor里白名单维护在一处。如果你确实需要Filter和Interceptor都感知登录态那也要确保两边的Token解析逻辑、白名单来源、放行规则完全一致最好抽成公共方法避免出现“我以为这个接口放行了结果没放行”的情况。5.2 请求体被提前消费文件上传下载的血泪教训这个坑我在Filter章节简单提过实际排查过程比我想象中更曲折。现象是上传接口偶尔报Required request part file is not present但同一个接口在本地调试时又不报错。后来发现是因为有一次我为了在Filter里做“上传文件类型审计”读取了request.getInputStream()之后MultipartResolver解析文件时就拿不到数据了。更糟糕的是这个坑不是必现的。只有当我请求头里的Content-Type是multipart/form-data且请求体会被Filter消费时才触发普通JSON请求完全不受影响。这让我一度以为是并发问题。排查链路是这样的第一步确认Controller里RequestParam(file)参数没问题第二步确认前端FormData构造没问题第三步往Filter里加日志打印request.getParameter(file)时才发现这里读了一下就为null了第四步去掉Filter里的读取操作问题消失解决方案前面提过用ContentCachingRequestWrapper包装或者在Filter里绝不能直接读取请求体。对于easy网盘这种需要记录上传文件信息的场景我最终选择在业务Service层做审计而不是在Filter层。5.3 拦截器抛出的异常全局异常处理器真的能接住吗这个坑和Spring版本关系比较大但值得知道。我的全局异常处理器是RestControllerAdvice里面定义了各种ExceptionHandler。正常情况下Controller方法抛异常全局异常处理器能接住。但Interceptor里的异常情况更微妙preHandle里抛出的异常一般会被DispatcherServlet的processHandlerException流程处理但如果你在抛出异常前已经response.getWriter().write()了内容则可能没有机会再去写JSON了抛出RuntimeException而未在ExceptionHandler里明确指定可能被兜底的异常处理器捕获afterCompletion里抛出的异常这个异常往往不会被全局异常处理器接住而是被日志记录或者被吞掉我遇到的实际问题是preHandle里userService.getUserByToken(token)发生数据库异常时我的全局异常处理器没接住接口直接返回了Tomcat的默认错误页。排查后发现Spring Boot 3.2中对于Interceptor抛出的异常处理路径和“Controller抛出的异常”处理路径不完全一致。稳妥的做法是在拦截器内部try-catch所有可能的异常转换为固定的JSON响应格式返回对于确实需要全局统一处理的异常场景不要依赖拦截器抛给全局异常处理器而是显式调用HandlerExceptionResolver。5.4 转发、异步请求和CORS预检下的执行差异这几个场景如果不测线上很容易出问题。转发forward场景假设Controller A里request.getRequestDispatcher(/api/file/list).forward(request, response)转发到Controller B。这个过程中Filter会执行一次还是两次如果用OncePerRequestFilter它能保证一次请求只执行一次但如果直接实现Filter接口转发时Filter可能再次被调用。这个差异很隐蔽也是一个使用OncePerRequestFilter的重要原因。异步请求AsyncDispatch场景Callable或DeferredResult异步处理请求时preHandle执行时机也可能有变化。Spring MVC对异步请求有一种“提前结束再恢复”的处理Interceptor的afterCompletion在异步请求完成后的行为也需要单独测试。easy网盘暂时没有用异步接口但我后来做过一个小测试发现HandlerInterceptor需要实现afterConcurrentHandlingStarted方法才能在异步处理开始前收到通知否则会出现日志顺序错乱。CORS预检OPTIONS场景前端跨域调用时浏览器会先发一个OPTIONS预检请求。如果Filter里配置了CORS响应头预检请求在Filter阶段就把响应头写好了然后正常返回不会进入业务接口。但如果你的CORS配置放在Interceptor里OPTIONS请求也会被拦截且由于没有实际业务方法来处理OPTIONS可能返回405。所以在easy网盘项目里CORS配置我坚持放在Filter层而且放在最前面这是经过这个坑验证出来的。6. 从easy网盘向外延伸布隆过滤器与前端请求拦截6.1 布隆过滤器在网盘秒传/去重场景中的应用搜索相关热词的时候我发现布隆过滤器经常和“拦截器、过滤器”一起出现。虽然布隆过滤器和Java Web里的Filter/Interceptor是完全不同的概念但在网盘项目中它确实有很好的应用场景——文件秒传和内容去重。网盘文件秒传的核心逻辑是用户上传文件时先计算文件内容指纹比如MD5或SHA-1如果服务端已经有相同指纹的文件就不需要真正上传文件内容直接给用户“秒传”就完成了。这个指纹集合可能非常庞大一个真实网盘可能有上亿条记录。如果每条记录都存在数据库里每次上传先查数据库数据库压力巨大。布隆过滤器非常适合做这个“前置判断”它能快速告诉你“这个文件指纹肯定不存在”或者“可能存在”。BloomFilterString fingerprintFilter BloomFilter.create( Funnels.stringFunnel(StandardCharsets.UTF_8), 10_000_000_000L, // 预期元素量 0.01); // 误判率 public boolean couldExist(String fileFingerprint) { if (!fingerprintFilter.mightContain(fileFingerprint)) { return false; // 肯定不存在可以走完整上传流程 } // 可能存在需要去数据库精确确认 return fileFingerprintDao.exists(fileFingerprint); }布隆过滤器的原理是一个很长的位数组配合多个哈希函数。新元素写入时对元素做多次哈希把位数组里多个位置置为1查询时如果多个哈希位置上有一个是0就说明肯定不存在。如果全部是1只能说“可能存在”因为不同元素的哈希结果可能相互覆盖产生误判。这也带来了一个特性布隆过滤器只能判断“肯定不存在”和“可能存在”不能判断“肯定存在”。所以通常会搭配一个精确数据库或缓存做二次确认。easy网盘虽然是练习项目但加上布隆过滤器作为“秒传前置判断”能明显减少对数据库的无效查询。我当时把文件指纹结果缓存起来模拟了一百万条数据布隆过滤器查询耗时在微秒级别误判率控制在1%以内效果非常理想。注意布隆过滤器不支持删除操作如果你需要支持“删除文件后重新上传”这种场景可以考虑换成布谷鸟布隆过滤器Cuckoo Filter它支持删除操作但实现复杂度更高。6.2 前端视角axios拦截器与原生fetch封装拦截器的取舍最后聊一下前端的热搜词Next.js中是用原生fetch封装拦截器还是用axios拦截器。这个对easy网盘来说也很实用因为项目前端需要统一在请求头里加Token、统一处理401跳转登录页。axios拦截器是我用得比较熟的方案axios.interceptors.request.use(config { config.headers.Authorization localStorage.getItem(token); return config; }); axios.interceptors.response.use( response response, error { if (error.response?.status 401) { window.location.href /login; } return Promise.reject(error); } );axios拦截器的好处是API设计完善request拦截器统一加Tokenresponse拦截器统一处理错误码代码非常直观。但axios的问题在于包体积相对较大而且如果你在Next.js这种SSR环境下使用服务端请求和客户端请求需要区分处理否则服务端也会带着客户端的Token去请求接口容易泄露。原生fetch封装拦截器的核心思路是把fetch再包一层让它具备“请求前统一加header”和“响应后统一处理错误”的能力async function request(url, options {}) { const res await fetch(url, { ...options, headers: { Content-Type: application/json, Authorization: localStorage.getItem(token), ...options.headers } }); if (res.status 401) { window.location.href /login; throw new Error(未登录); } return res.json(); }原生fetch封装的好处是零依赖适合轻量项目而且可以针对Next.js的server-side和client-side分别封装不同的逻辑。缺点是如果拦截器逻辑变复杂比如要支持请求重试、请求排队、取消重复请求这些原生封装写起来就比axios麻烦很多。我的建议很简单项目简单、不想多引入依赖用原生fetch封装项目复杂、需要处理各种边界情况选axios。easy网盘这种练习项目直接用axios拦截器就够了重点还是要理解“请求前拦截”和“响应后拦截”这两个时机的价值——这和后端Filter/Interceptor的思路本质上是一样的都是在请求的关键边界上做统一处理。折腾完这一轮easy网盘登录校验这块明显清爽多了Filter管编码、CORS和基础预检Interceptor管登录、权限和用户上下文前端再用axios统一处理Token。后面如果再往项目里加“管理员操作审计”“上传频率限制”我会优先在Interceptor和Filter链上做文章而不是再去Controller里堆代码。