SpringBoot后端重定向全解析:从用法原理到工程避坑实践
干后端这些年我发现自己最常跟新人解释的东西不是数据库索引也不是消息队列反而是 SpringBoot 后端服务重定向。一个return加个redirect:前缀看似简单但真遇到的问题一点不少参数带不过去、页面刷新就重复提交、浏览器提示重定向循环、前端说拿不到数据……这次我把 SpringBoot 后端服务重定向从用法到原理、从工程实践到安全红线完整捋了一遍希望能帮刚开始接触后端的人少踩几个坑也帮老同事查漏补缺一些平时不太注意的细节。先说明白这个内容能解决什么问题。重定向在后端不是一个高深概念但它几乎出现在每个 Web 项目里表单提交成功后跳转、用户未登录时跳登录页、老链接迁移到新地址、下单完成后跳到订单详情。这些场景背后都离不开 HTTP 的 3xx 状态码和浏览器行为。如果你对redirect:的理解还停留在“反正跳转一下就完了”那这篇文章值得看完。我会把每种写法、状态码差异、参数传递、跨域影响、安全校验、常见坑排查一次讲透。1. 重定向到底解决什么问题场景、选型和本质1.1 四个最常见的重定向场景我复盘做过的项目重定向基本逃不过这四类场景理解场景再动手比上来写代码重要得多。第一类是 POST-Redirect-GET 模式也就是表单提交后的防重复提交。用户提交订单、注册、发布文章这类写操作如果提交后直接返回页面模板用户按一下 F5 就会原样再提交一次后果可能是重复下单、重复扣款。重定向解决这个问题的方式很直接POST 请求处理完业务后返回 3xx浏览器自动发起一次新的 GET 请求此时刷新页面刷的是 GET 结果原 POST 已经结束表单不会重复提交。这也是所有下单流程跳订单详情页的标准做法。第二类是登录态校验。用户访问受保护资源时会话已经失效后端拦截器检测到未登录重定向到登录页。这个场景大家见得最多但实现上有个容易踩的细节对浏览器页面直接重定向没问题对 AJAX 接口直接重定向就很麻烦后面会专门展开讲。第三类是 URL 迁移和兼容。老系统升级后原路径被替换为了不让历史链接彻底失效在控制器层把旧地址重定向到新地址。这里要特别注意用 301 还是 302选错了可能引发缓存问题我推荐非必要不用 301。第四类是跨服务流程衔接。比如订单服务处理完支付回调后需要让用户回到前端页面查看结果此时后端可能直接重定向到网关或者前端应用的地址。这种跳出去的重定向最容易出现安全漏洞是开放重定向的重灾区第六部分我会详细说。1.2 重定向与转发一个比喻讲清本质很多新人容易把重定向和转发混为一谈但它们的作用机制完全不同。转发是服务器内部行为客户端发一次请求Servlet 容器直接在请求链里把处理权交给另一个资源浏览器地址栏不会变整个流程只有一次请求。重定向则是客户端行为服务器在响应里返回 302并告诉浏览器“你要找的资源在另一个地址”然后浏览器基于新的地址重新发出一次请求地址栏会变成新地址总共会有两次请求。用生活里的事类比。转发像是你去银行柜台办业务柜员看了你的需求后直接把你的材料递给隔壁同事帮你办完你没挪窝全程一个窗口。重定向则是柜员告诉你“你去 3号窗口找 xx经理带上号”。这个区别决定了实际应用上的几个关键差异重定向之后原始请求里携带的 request 参数、手动放入 request 的 attribute 全部丢失因为第二次请求跟第一次毫无关系重定向可以指向任意站点而转发只能在同一应用内生效重定向的响应里有明确的 Location 头利于浏览器和历史记录识别转发则对浏览器不可见。1.3 方案选型三种实现方式怎么选SpringBoot 里做重定向的主流方式有三种选型逻辑很简单。第一种是return redirect:/xxx这是最推荐的日常方案。字符串简洁直观Spring MVC 会帮你完成大部分工作包括 context-path 的处理和参数的编码。第二种是RedirectView对象适合需要精细控制状态码或者暴露模型属性的场景比如你要显式返回 301 永久重定向。第三种是直接调用HttpServletResponse.sendRedirect()它脱离了视图解析链路适合在拦截器、过滤器里做前置跳转或者在 Controller 里想完全自己控制响应对象时使用。我的选型习惯是默认情况一需要指定 301/307 等状态码时用情况二在非 MVC 组件过滤器里要跳转时用情况三。别一上来就new RedirectView别在 Controller 里见了response就想自己sendRedirect让框架处理能省掉很多边缘问题。2. 五种重定向写法从入门到进阶附完整代码2.1 redirect 字符串前缀90% 场景用它最常见的写法在 Controller 里直接返回带redirect:前缀的字符串视图名PostMapping(/order/save) public String saveOrder(Valid OrderSaveForm form) { // 业务处理 return redirect:/order/list; }这段代码的含义是告诉 Spring MVC 生成一个重定向响应目标是/order/list。Spring 底层会自动生成一个RedirectView然后再把响应提交给浏览器。默认状态码是 302。这里有个很多人踩过的坑redirect:/order/list是相对于当前应用的 context-path 解析的也就是说如果配置了server.servlet.context-path/app最终浏览器跳转的地址会是/app/order/list。但如果你不小心写成了redirect:order/list少了开头的斜杠它就是相对路径会基于当前 Controller 的 URL 去解析。比如你当前请求是/order/save相对路径会变成/order/order/list大概率直接 404。判断标准很简单重定向地址必须以/开头别省这个斜杠。2.2 RedirectView需要控制响应状态码时RedirectView的使用场景很固定你要控制重定向的状态码或者希望重定向时暴露模型属性供后续处理。示例代码如下GetMapping(/old/link) public RedirectView handleOldLink() { RedirectView redirectView new RedirectView(/new/link, true); redirectView.setStatusCode(HttpStatus.MOVED_PERMANENTLY); return redirectView; }构造参数里的true表示 URL 相对于当前 context-path 解析这个参数在带 context-path 部署时非常关键。setStatusCode可以指定 301、302、307 这些状态码。另外RedirectView还有个属性exposeModelAttributes控制是否把模型里的 attribute 作为查询参数拼到重定向路径上默认是开启的。这个特性既方便也危险如果你往 model 里放了密码、手机号之类的敏感数据开启后它们会出现在浏览器地址栏。我建议除非明确需要这个行为否则给它关掉。2.3 HttpServletResponse.sendRedirect当你想完全掌控响应在拦截器、过滤器或者Controller里注入HttpServletResponse直接跳转写法如下GetMapping(/privacy) public void handlePrivacy(HttpServletResponse response) throws IOException { response.setStatus(HttpStatus.SC_MOVED_TEMPORARILY); response.setHeader(Location, /privacy-policy); // 或者直接用响应对象提供的便捷方法 // response.sendRedirect(/privacy-policy); }用sendRedirect时有两个注意点。第一方法返回值要写void或返回null避免 Spring MVC 继续做视图解析否则可能出现“重定向已经发出却又去找一个视图模板”的奇怪报错。第二sendRedirect里如果传入/privacy-policy容器会自动拼接 context-path如果传入的是相对路径比如privacy-policy则会基于当前请求 URI 计算不同环境下结果可能不一致所以尽量用/开头。2.4 RedirectAttributes带参数与消息的重定向重定向场景下要传数据时RedirectAttributes是最正统的方案它同时解决两类需求URL 参数和一次性提示消息。PostMapping(/order/save) public String saveOrder(Valid OrderSaveForm form, RedirectAttributes redirectAttributes) { Long orderId orderService.create(form); // 拼到 URL 上的参数 redirectAttributes.addAttribute(orderId, orderId); // 一次性消息存在 session 中重定向后自动取出 redirectAttributes.addFlashAttribute(toast, 下单成功); return redirect:/order/detail; } GetMapping(/order/detail) public String orderDetail(RequestParam Long orderId, ModelAttribute(toast) String toast) { // toast 只是演示实际一般由前端统一处理 return order/detail; }addAttribute会把参数拼到重定向 URL 后面等价于/order/detail?orderId123。addFlashAttribute则把数据先存进 session重定向目标接口从 session 里取出后放到本次请求的 model 中然后立即从 session 清理整个生命周期只有一次。这种“一次性”特性特别适合放“操作成功”“保存失败”这类提示消息刷新页面后消息不会再出现天然解决了重复提示问题。要特别提醒的是不要把addAttribute和addFlashAttribute搞混。addAttribute最终会出现在 URL 上数据量大、敏感、含特殊字符的都不适合往里塞而 flash attribute 走 session数据量可以稍大但也不能塞对象列表这类大对象因为 SpringSession 存在 Redis 时序列化和反序列化开销很大。2.5 UriComponentsBuilder复杂 URL 的规范化拼接当重定向 URL 包含多个参数或者需要动态拼接路径变量时直接用字符串拼出 URL 是最容易出问题的写法。我之前遇到过用户昵称里有、有中文、有空格的情况字符串拼接出来的 URL 要么乱码要么参数变成两个。规范化做法是使用UriComponentsBuilderGetMapping(/redirect/to/profile) public String redirectToProfile(RequestParam Long userId) { String url UriComponentsBuilder .fromPath(/user/{userId}/profile) .queryParam(from, order) .queryParam(tab, history) .build(userId) .encode() .toUriString(); return redirect: url; }build(userId)会把{userId}替换成实际值encode()会处理 URL 编码queryParam多个调用自然追加。这么做有三个好处避免了手工 URL 编码遗漏、路径变量和查询参数各司其职、代码可读性高。如果你用RedirectAttributes.addAttributeSpring 内部也会走类似编码逻辑所以第二种场景下优先用RedirectAttributes而不是手动拼 URL。3. 源码视角return 一个字符串是怎么变成 302 的3.1 从 Controller 返回值到 Location 头我第一次看源码的时候挺震撼因为一个return redirect:/xxx背后涉及的组件比想象中多。我在排查一次诡异的重定向问题时打开org.springframework.web.servlet.view包下这几个类整个链路就清楚了。大致流程如下Controller 方法返回字符串后RequestMappingHandlerAdapter拿到返回值交给HandlerMethodReturnValueHandler链处理。ViewNameMethodReturnValueHandler识别到以redirect:开头的字符串就会创建一个View对象并起个特殊名字名字就是去掉前缀后的 URL。接下来DispatcherServlet把这个 view 名交给ViewResolver解析得到的实际是RedirectView。最后RedirectView.renderMergedOutputModel方法里调用response.sendRedirect()实际生成302状态码和Location响应头交给浏览器。这个链路说明一个关键结论重定向不是由某个注解或者返回值自动触发的而是由 HTTP 响应头的状态码和Location决定的。比如你在过滤器里看到有人手动写response.setStatus(302); response.setHeader(Location, xxx)这就是最原始的本体只是 Spring 把这个过程做得足够傻瓜化。3.2 301、302、307、308状态码不是随便选的很多团队写重定向永远只返回 302这在大多数场景没毛病但有几个场景选错状态码会出事。HTTP 重定向常用状态码对比状态码含义请求方法是否保留典型场景301永久移动不保留POST 会变 GET域名迁移、SEO 跳转302临时移动不保留POST 会变 GET日常表单提交后跳转307临时重定向保留原方法需要保持 POST 语义的临时跳转308永久重定向保留原方法需保持 POST 语义的永久迁移注意 301 的特殊问题浏览器会对 301 结果做缓存。如果某次跳转是因为“临时活动页”结果你用 301用户再次访问旧地址时浏览器可能直接使用缓存的跳转结果而不请求服务器后续改回来也无法立刻生效。我处理老系统时吃过这个亏明明后台把地址改回了原样用户那边过了好多天还在被跳到旧页面。非必要不用 301这点记牢。POST 场景下 302 的问题更隐蔽。你写了一个重定向接收 POST 请求浏览器拿到 302 后通常会转成 GET 去请求目标地址。如果你的业务依赖 POST 方法带 body目标接口按RequestBody接收数据就会直接拿不到。这时候必须显式返回 307 或 308让浏览器保留 POST body 重发。3.3 flash attribute 的会话机制与使用边界RedirectAttributes.addFlashAttribute为什么能带数据我以前以为是框架直接把数据放在 response 里看了源码才知道它走了 session。Spring MVC 使用FlashMapManager管理 flash attribute默认实现是基于 session 的SessionFlashMapManager。当你调用addFlashAttribute时数据被写进FlashMap这个 map 存在当前 session 里。重定向请求到达目标 Controller 前DispatcherServlet会从前一个请求的 session 中取出FlashMap将数据合并到当前请求的 model 中然后用完即丢。这个机制带来一个使用边界flash attribute 依赖 session所以禁用了 cookie 的客户端、跨域重定向场景flash 数据很可能带不过去。如果前后端完全分离后端重定向后前端拿的不是页面而是 JSONflash attribute 的实际意义也会大打折扣因为目标接口可能压根不渲染页面模板。另外一个常见坑是如果你的项目引入了 Spring Session 并把 session 存在 Redisflash attribute 会在 Redis 里经过一次序列化和反序列化。如果往里放了未实现Serializable的复杂对象重定向后取值时大概率报反序列化异常。所以 flash data 只放简单类型别放“大礼包”。3.4 context-path、反向代理与 Location 头这部分是最容易被忽略却又最常出问题的。你本地启动没问题部署到测试环境后所有重定向都跳丢了大概率是 context-path 或者反向代理造成的。Spring Boot 配置了server.servlet.context-path/api之后redirect:/order/list会被解析成/api/order/list这个由RedirectView的 contextRelative 行为保证。但很多团队的部署架构是 Nginx 反代到应用应用并不知道自己对外域名是什么。如果应用生成的Location是相对地址浏览器可以正常工作一旦你手动拼了绝对地址http://localhost:8080/xxx用户访问线上域名时就会跳到 localhost直接现场翻车。正确的做法是应用里不写死 host统一使用相对路径或借助UriComponentsBuilder从当前请求的Host头构建。如果必须返回绝对地址也要用ServletRequestAttributes里拿到的真实请求头来拼接。4. 前后端联调重定向最容易引发的前端“背锅”现场4.1 浏览器与请求库会自动跟随重定向很多前端同事第一次遇到 302 会很困惑明明后端返回了 302为什么我在浏览器 Network 里看到的却是 200答案是浏览器自动跟随了重定向。你在 Network 里看到的那条 200已经是重定向目标地址的响应原始 302 可能只在第一条记录里一闪而过。同理axios 在浏览器环境下默认也会自动跟随重定向除非目标跨域且被 CORS 拦截否则前端代码层面几乎感知不到这个过程。这就引出一个实际教训你不能指望前端通过“获取 302 状态码”来感知重定向因为大多时候前端拿到的其实是最终的 200 或 404。做后端接口设计时如果重定向的目的是让前端页面跳转那没问题如果重定向的目的是告诉前端“这里有问题需要处理”这个设计就是失败的。4.2 登录过期返回 302 的接口设计问题我实际见过的最典型问题是登录拦截器在用户会话过期后统一返回 302 到登录页。对直接访问页面的浏览器请求来说这很合理但当前端用 AJAX 请求接口时浏览器自动跟随 302 去了登录页接口响应回来的是登录页的 HTML 而不是 JSON。前端代码里response.data解析失败控制台报一个莫名其妙的 JSON parse error排查了半天才发现是登录态问题。更麻烦的是请求已经自动跟随了 302前端代码里根本看不到这次重定向的明细你很难从前端侧判断“这个响应来自接口还是登录页”。这个问题的根治方案是在拦截器里判断请求是否来自 AJAX比如检查X-Requested-With头如果是就返回 401 或 JSON 格式的“未登录”标识由前端拿到后自行跳转登录页。不要对 AJAX 请求返回 302这是接口设计和页面跳转要分开处理的核心逻辑。4.3 跨域重定向的 CORS 与 Header 丢失跨域重定向时特别容易踩两个坑。第一个是 CORS 头在跨域重定向过程中没有被正确保留。假设你的前端在a.example.com后端接口在b.example.com后端接口重定向到c.example.com。浏览器跟随跳转时会重新检查目标和响应是否符合 CORS 规则如果目标响应没有带Access-Control-Allow-Origin哪怕原始请求允许跨域这一步也会被浏览器拦截前端报错无法获取数据。第二个坑是自定义 Header 丢失。很多项目会在请求头里放Authorization或X-Token同域重定向时浏览器会自动带上但跨域重定向时浏览器对自定义 Header 的处理要受 CORS 预检约束。实际结果往往是目标接口收到的是不带任何自定义头的请求后端安全校验直接失败。遇到过好几次前端反馈“登录都成功了还是 401”根因都在这里。对这种场景我的建议很简单如果是内部系统之间的衔接优先走网关做统一认证由网关在后端完成跳转或聚合别让浏览器直接跨域重定向如果确实需要跨域重定向后端要确保每个可能跳转到的地址都在 CORS 白名单里并且做全链路联调验证别只看第一个请求的通畅程度。5. 重定向的坑与排查实录从现象到根因5.1 重定向循环最常见的隐藏问题重定向循环长什么样子浏览器显示“页面无法正常运作将您重定向的次数过多”。后端日志里可能什么都没有因为每次请求都是正常返回 302只是每个地址都在要求你跳走。我排查过的一个典型例子项目里配置了登录拦截器用户未登录访问受保护页面时重定向到登录页。登录页本身也需要加载静态资源而静态资源路径也被拦截器拦下来了重定向又指向了登录页形成死循环。另一个常见原因是代理层把http重定向到https应用内又无条件把https重定向回http两边互相踢皮球。排查方法很简单用curl -I带上请求 URL观察返回的Location序列。哪个地址在重复出现问题核心就在哪。如果本地无法复现重点看代理层配置和拦截器放行路径。5.2 参数丢失、中文乱码与特殊字符重定向后参数丢失是最常见的生产事故之一。我第一次遇到时很困惑明明addAttribute了跳转后RequestParam就是拿不到。后来发现参数名拼错了而且是表单字段名到查询参数名映射错了。中文乱码的场景更经典。手动拼接 URL 时写了类似redirect:/search?keyword keyword中文没编码浏览器得到的地址里出现一堆%XX%XX后端拿到后乱码。解决这个问题最简单的方式就是前面说的RedirectAttributes.addAttribute或UriComponentsBuilder.encode()它们会主动做 URL 编码。编码时会遇到细节差异空格编码成还是%20、中文用什么字符集Spring 在不同版本和容器环境有差异但只要你依赖框架处理而不是手动拼字符串绝大多数场景是安全的。还有一种参数丢失是路径变量场景。重定向地址是/user/{id}/profile但跳转时只传了 path 变量目标 Controller 方法签名里却还多了个RequestParam没传就是空。这类错误靠 IDEA 的断点都很难发现最好一开始就统一约定重定向参数必须全部显式列出不允许“可能为空”的参数不被处理。5.3 flash 数据带不过去flash attribute 带不过去我归纳过三种情形。一是目标 Controller 上配置了ResponseBody或接口返回 JSON根本不会渲染模型flash 里即使有数据也无法通过模型绑定拿到。二是 Spring Session 序列化异常前面说过往 flash 里塞了不可序列化的对象。三是重定向链路经过网关时网关把 session cookie 重写或丢弃了session 数据从源头丢失。排查时先确认 cookie 里JSESSIONID是否正常、是否变了再看目标方法签名里是否用了ModelAttribute接收最后看有没有跨服务调用的场景。这三步排查完基本能定位。顺带说一句flash 数据命名时别和正常 model attribute 重名不然重定向后会互相覆盖我曾经为了覆盖问题排查了大半天。5.4 自动化测试MockMvc 断言重定向重定向逻辑一定要写测试。我维护过的老项目里有太多重定向 bug 是被测试拦下来的。用 Spring Boot 测试里最常规的 MockMvc 写法SpringBootTest AutoConfigureMockMvc class RedirectTest { Autowired private MockMvc mockMvc; Test void saveOrderShouldRedirectToDetail() throws Exception { mockMvc.perform(post(/order/save) .param(name, demo) .param(amount, 100)) .andExpect(status().is3xxRedirection()) .andExpect(redirectedUrl(/order/list)); } }配合redirectedUrl(/order/list)能精确断言重定向目标。如果项目配置了 context-path断言里要带上 context-path这是很多刚用 MockMvc 的人容易踩的地方。另外测试里还有一种情况是重定向不经过 Controller直接在拦截器或过滤器里用response.sendRedirect完成这类逻辑要单独写自定义测试或用webTestClient覆盖。5.5 别忽略日志与调试开关做重定向排查时最忌讳只盯着业务代码。有些重定向是容器或安全框架在 Servlet 过滤器链里直接发起的业务 Controller 根本不会真正执行。这时候把logging.level.org.springframework.webDEBUG打开能看到视图解析、HandlerMapping、拦截器执行的路由记录对比两次请求的差异比瞎猜快得多。遇到特别邪门的跳转问题我还会用抓包工具看原始 HTTP 报文重点看Location头是容器生成的还是业务代码生成的这一个信息往往就能定位问题。6. 上线前必须检查的安全与缓存6.1 开放重定向漏洞别把你的用户送到钓鱼网站开放重定向Open Redirect是重定向里最需要重视的安全问题。它的攻击原理是服务端接口接收一个url或redirect参数直接作为重定向目标返回给浏览器。攻击者构造一个恶意外链比如https://your-site.com/login?nexthttps://fake-site.com用户看到的是你站点的域名点击后被悄悄带到了攻击者的钓鱼页面。这种漏洞在登录跳转、支付回跳、分享链接场景尤其常见。防护要抓三点。第一能不用外部 URL 参数就不用重定向目标尽量在服务端写死或从白名单配置里取。第二必须接收外部参数时做协议白名单校验只允许http和https禁止javascript:、data:这类危险协议。第三做域名白名单目标地址必须属于预期范围内。我一般会用一个工具方法统一过滤所有对外重定向都走这个口子public String safeRedirectTarget(String input) { if (input null || input.isBlank()) { return defaultTarget; } // 只允许相对路径或者预期白名单域名 if (input.startsWith(/) !input.startsWith(//)) { return input; } if (input.matches(https?://[\\w.-]\\.example\\.com(/.*)?)) { return input; } return defaultTarget; }注意校验里尖括号、反斜杠、控制字符都要警惕这些可能被用来做响应头注入。对外输出Location头前如果做不了充分校验宁可默认跳到首页也别把错误目标原样扔出去。6.2 永久重定向的缓存陷阱301 除了浏览器会缓存CDN、搜索引擎也会缓存。我见过一个运营需求是活动页做永久重定向到新页面结果活动结束后想回到旧页面展示其他内容CDN 上还缓存着 301用户连续几天访问旧地址都直接跳到新页面运维又排查了好久。这类问题唯一的稳办法是不打算永久变动的地址坚决不用 301如果已经发了 301 但必须撤掉需要主动去 CDN 控制台刷新缓存并且接受搜索引擎收录更新需要时间。反向层也有个常见坑很多 Nginx 配置会对不带结尾斜杠的目录做 301 跳转这本身没错但是如果你在后端同时返回了 302代理层又补了一个 301最终状态码完全不是你预期的。所以排查重定向问题时一定要看完整响应链路里是否有代理层参与别只盯着应用。6.3 Cookie、SameSite 与第三方上下文重定向虽然不直接参与 Cookie 管理但当你重定向到跨域站点时目标站点接收到的 Cookie 策略就变得非常微妙。现代浏览器普遍使用SameSiteLax或SameSiteNone跨域重定向场景中如果目标站点需要在Set-Cookie里种下会话标识而浏览器端第三方 Cookie 被禁用就会遇到“重定向过去了但登录态始终建立不起来”的诡异问题。这类问题在支付回调、SSO 单点登录、跨平台联动时特别常见。我的建议是不要把“跨域重定向 Cookie 会话”设计成主流程优先用带一次性 token 的 URL 参数完成跳转和身份确认如果只能靠 Cookie那就必须确认目标站点、代理层、浏览器策略三者对 SameSite 的配置一致并在真实环境多浏览器验证。工具类、中间件文档里写的东西不一定和你部署环境完全一致别拿本地 Chrome 的成功当作生产环境的通行证。7. 我的实操心得重定向不是一行字符串的事代码写久了你会发现return redirect:/xxx只是重定向的冰山一角真正的难点全都在 HTTP 语义、会话数据、部署架构和安全边界上。这么多年下来我自己最常用的组合是表单提交后用RedirectAttributes.addAttribute拼参数用addFlashAttribute放提示消息Controller 返回redirect:/业务路径。这种组合在绝大多数业务系统里稳得不能再稳也最容易让前后端理解整个流程。RedirectView我基本只在要显式控制 301/307 时才用response.sendRedirect则只会出现在过滤器里做顶层拦截。最后再分享一个冷门小技巧排查重定向问题时先确认浏览器地址栏里最终停留的地址长什么样再看 Network 面板里第一条 3xx 记录和最后的 200 记录差了几个请求。很多看似复杂的重定向问题其实只是你没有意识到浏览器会自动跟随重定向把中间那条 302 当成错误来看了。理解这个行为之后你跟它打交道会轻松很多。