
接口联调时最让人血压升高的一幕大概就是控制台甩出一行TypeError: Failed to fetch切到 Network 面板一看那条请求红得发亮Status 明明白白写着404 Not Found。刷新三次、重启服务、清缓存、换浏览器一通操作下来还是同样的结果。我前后端都写过不少年这类问题踩过的次数两只手数不过来从最早的看到红色就慌到现在的五分钟定位中间交的学费基本都记在了排查笔记里。Failed to fetch 和 404 Not Found 这两个词看起来是同一件事实际上它们来自两个完全不同的层面一个是浏览器在客户端抛出的异常一个是服务端或中间层返回的状态码。把它们混为一谈是绝大多数人浪费半小时以上的根本原因。这篇内容就是把这个排查过程完整拆开浏览器为什么会隐藏真实原因、Network 面板里哪几个字段才是关键证据、反向代理和路由匹配的坑具体长什么样、拿到 404 之后怎么在五分钟内判断到底是路径错了、网关没命中还是资源名本身不存在。不管你是刚接手前后端联调的新人还是被线上偶发 404 折腾过的老手这里面的判断顺序和验证手段都可以直接拿去用。1. 别急着改代码先把两个报错的语义掰开1.1 Failed to fetch 是浏览器抛的不是服务器回的先纠正一个非常普遍的误解Failed to fetch这行字服务端永远看不到也永远不会有人给你返回它。它是 fetch API 在请求 Promise 被 reject 时由浏览器自己构造的一个TypeError消息文本固定就是这句。你用 XMLHttpRequest 的话看到的是NetworkError when attempting to fetch resource.用 axios 的话看到的是Network Error。措辞不同指向的是同一类情况请求没有拿到一个浏览器认可的完整响应。这里的关键在于浏览器认可。浏览器出于安全考虑对跨域场景下的错误信息做了刻意模糊处理。如果服务端返回了 500你能看到状态码和响应体但如果请求被 CORS 拦了、被混合内容策略拦了、被 CSP 拦了、DNS 解析失败了、TCP 连接被拒了、证书校验没过浏览器一律只告诉你fetch 失败了具体哪一条它藏起来只在 Network 面板的 Status 那一栏留一个不太起眼的括号备注。我在实际排查中的体会是只看 Console 就等于闭着眼睛看病。Console 给的是结论Network 给的才是证据。Console 里出现Failed to fetch之后第一反应应该是切面板而不是去翻自己刚改的那几行代码。1.2 404 Not Found 的三张面孔404 看上去说话很直白——找不到但它其实有三种完全不同的成因处理方式差得很远。第一种真·路径不存在。前端请求/api/user/list后端只注册了/api/user/listAll路由表里没有这条记录框架直接返回 404。这种最诚实改路径或者补路由就完事。第二种被中间层吃掉的 404。请求路径本身没错但它没走到后端应用而是停在了反向代理、网关或者静态资源服务器上。这时候你看到的 404 页面往往不是后端框架的 JSON 格式而是 nginx 的默认 HTML 页或者 CDN 的一整块错误页。这一类的比例很高而且因为它看起来跟第一种一模一样特别容易让人误判。第三种业务层语义上的伪 404。HTTP 状态码是 404但语义不是这个接口不存在而是你请求的那个对象不存在。比如响应体里返回{detail:not found}、unexpected status 404 not found: the model xxx does not exist这种路径完全正确、路由也命中了只是你传的 ID、模型名、资源标识在系统里查不到。这类 404 是最容易骗人的——很多人一看状态码就开始查路由查了半天发现路由一点问题没有。404 类型典型特征排查入口修复方向真·路径不存在后端框架格式的错误响应后端路由表改路径或补路由中间层吃掉nginx/CDN 的 HTML 错误页代理与网关配置修 location 或转发规则伪 404资源不存在响应体是规范 JSON路径是对的请求参数与业务数据校验 ID/名称/权限1.3 为什么这两个词总是一起出现它们成对出现是因为它们描述的是同一条请求的两个视角。浏览器从网络层看这次请求没成功于是抛Failed to fetchNetwork 面板从协议层看服务器回了 404于是 Status 显示404 Not Found。一个说我没拿到东西一个说对方说没有指的是同一件事。但有一个情况必须单独拎出来如果 Network 面板里压根没有这条请求只有 Console 里有Failed to fetch那就说明请求根本没发出去。这时候 404 是不存在的问题百分之百在客户端方向是 CSP、CORS 预检、混合内容、请求被 AbortController 取消、Service Worker 拦截这几条。反过来如果 Network 里有请求、有 404那问题百分之百不在你的 fetch 写法上往服务端和中间层找。这一个判断就能砍掉一半的无效排查。2. 四层定位法把半小时的瞎猜压缩到五分钟2.1 第一层Network 面板里四个必须看的字段打开 Network 面板之后很多人的操作是扫一眼红色的行就关掉。其实那行记录的细节里藏着几乎所有答案。我固定会看这四个位置Request URL看完整绝对地址。不是看你在代码里写的那个相对路径而是看浏览器实际拼出来的、冒号斜杠一条不落的全地址。/api/user在开发环境可能被 dev server 代理成了http://localhost:3000/api/user在生产环境可能变成了https://api.example.com//api/user——注意中间那个双斜杠很多 404 就是这么来的。Request Method确认是不是 OPTIONS。如果你看到一条 OPTIONS 请求返回 404 或 405而真正的那条 POST 根本没出现那这就是跨域预检失败了。浏览器不会告诉你预检失败它只会说 fetch 失败。Status 那一栏的括号内容。这是信息量最大的地方。(failed) net::ERR_CONNECTION_REFUSED说明端口没人监听(blocked:mixed-content)说明 HTTPS 页面请求了 HTTP 资源(blocked:by-csp)说明被内容安全策略拦了(canceled)说明请求被主动中断常见于组件卸载时没清掉的请求、或者路由跳转导致的取消。Response Headers 里的server和x-request-id。server: nginx加一个 HTML 错误页基本可以断定 404 是代理层给的server是你后端框架的标识、响应体是 JSON那才是应用层给的。x-request-id如果存在直接拿着它去日志系统里搜能一秒定位到具体是哪台机器、哪次调用。提示排查前先把 Network 面板的Preserve log勾上。页面一跳转记录就清空这是很多人明明刚才还看到 404一刷新就找不到了的原因。2.2 第二层判断请求到底有没有到达服务端这一步是分水岭。做法很简单去服务端翻访问日志搜这条路径。如果日志里有这条请求记录说明请求成功穿过了所有中间层到达了应用404 是应用自己返回的。接下来的方向就是路由注册、参数校验、业务逻辑。如果日志里什么都没有但 Network 里明明有 404那说明 404 是中间层代理、网关、CDN直接生成的请求根本没进来。我见过太多人跳过这一步对着后端代码改了俩小时最后发现请求压根没到后端。访问日志是唯一不会骗人的东西因为它是服务端自己写的不经过浏览器的任何修饰。Nginx 的 access.log、Spring Boot 的请求日志、网关的调用链随便哪一个都行关键是要有。2.3 第三层用 curl 把浏览器的变量全部剥离curl 的价值在于它什么都不做不执行 JS、不检查 CORS、不带浏览器缓存、不自动跟随重定向除非你加-L、不发送任何你没明确指定的头。所以它能帮你把问题一刀切成两半。# 1. 最基础的探测只看状态码和响应头 curl -i -X GET https://api.example.com/api/v1/profile # 2. 带上真实的前端请求头模拟浏览器行为 curl -i -X GET https://api.example.com/api/v1/profile \ -H Origin: https://www.example.com \ -H Referer: https://www.example.com/settings \ -H Content-Type: application/json # 3. 单独验证跨域预检这一步经常能抓到真凶 curl -i -X OPTIONS https://api.example.com/api/v1/profile \ -H Origin: https://www.example.com \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: content-type,authorization # 4. 绕过 DNS直接打某一台后端排除负载均衡干扰 curl -i --resolve api.example.com:443:10.0.1.23 \ https://api.example.com/api/v1/profile判读逻辑很清楚如果 curl 返回 200 而浏览器返回 404问题在前端路径拼接、代理转发或跨域环节如果 curl 也返回 404问题在服务端路由或网关配置跟浏览器无关。第 3 条命令尤其值得单独跑一次我遇到的跨域类 404 里有相当一部分是预检请求打到了没注册 OPTIONS 的路由导致浏览器认为整个跨域失败报出来却是 Failed to fetch。2.4 第四层把路由表和网关配置摊开看到了这一步就别再靠猜了。让框架把它实际注册的路由打印出来。Spring Boot打开management.endpoints.web.exposure.includemappings访问/actuator/mappings能拿到全部映射关系包括 context-path 叠加后的真实路径。Flaskapp.url_map直接打印能看到所有规则和 endpoint。Express遍历 router 的 stack 打印路径或者干脆加一个app._router.stack的调试端点。FastAPI访问/openapi.json所有路径一目了然。同时把网关配置文件打开对着看。很多时候后端路由是对的但server.servlet.context-path/api加上控制器上的RequestMapping(/api/v1)实际路径就变成了/api/api/v1/xxx前端按/api/v1/xxx请求自然 404。这种多一层前缀的坑在项目从单体拆成微服务的过程中出现频率极高。3. 高频场景逐一拆解你的接口到底是怎么 404 的3.1 路径拼接baseURL 和 path 之间那道斜杠这是最朴素也最常见的一类。看起来是细节实际能占到前端侧 404 成因的很大一块。// 问题版本 const api axios.create({ baseURL: https://api.example.com/api/, // 注意结尾有斜杠 }); api.get(/v1/profile); // 实际发出https://api.example.com/api//v1/profile // 双斜杠。有些网关会把 // 规范化成 /有些不规范化直接 404 // 修正版本一baseURL 不带尾斜杠path 带前斜杠 const api1 axios.create({ baseURL: https://api.example.com/api }); api1.get(/v1/profile); // - https://api.example.com/api/v1/profile // 修正版本二baseURL 带尾斜杠path 不带前斜杠 const api2 axios.create({ baseURL: https://api.example.com/api/ }); api2.get(v1/profile); // - https://api.example.com/api/v1/profile两种约定都行但一个项目里只能选一种并且要写进规范。混着用的结果就是有人写/v1/profile、有人写v1/profile、有人直接写全路径最终在某个特定组合下冒出双斜杠或者丢前缀的 404。还有两个容易被忽略的点。一是大小写Linux 环境下路由匹配严格区分大小写/api/User和/api/user是两个不同的路径本地 macOS 或者 Windows 上开发时因为文件系统不敏感可能测不出来一上线就 404。二是尾部斜杠FastAPI 默认redirect_slashesTrue请求/api/user/会返回 307 重定向到/api/user但在反向代理后面这个 307 的 Location 头如果没被正确重写浏览器拿到的是一个内网地址跳过去就是 404。环境变量注入也是个雷区。VITE_API_BASE或者REACT_APP_API_BASE没配、配成了空字符串、末尾多了个空格都会让请求路径悄悄变样。构建产物一旦进了容器变量就固定了改配置需要重新构建——所以我在项目里会强制在启动时打印一次实际使用的 baseURL出了问题一眼就能看见。3.2 反向代理404 其实是 nginx 给你的这一类最容易被误判因为 nginx 默认的 404 页面是一个很朴素的 HTML很多人根本没仔细看直接当成后端返回的 404。先讲一个必须刻进肌肉记忆的规则proxy_pass结尾有没有斜杠转发的路径完全不同。# 情况 Aproxy_pass 带尾斜杠 —— 会剥掉 location 匹配的部分 location /api/ { proxy_pass http://backend:8080/; } # 请求 /api/v1/user - 转发给后端的是 /v1/user # 注意/api/ 被替换成了 / # 情况 Bproxy_pass 不带尾斜杠 —— 原样透传 location /api/ { proxy_pass http://backend:8080; } # 请求 /api/v1/user - 转发给后端的是 /api/v1/user如果你的后端路由是/api/v1/user配了情况 A那就是必然 404而且 curl 直接打后端能通、走代理就 404现象非常典型。反过来如果后端上下文路径设了/api路由是/v1/user配了情况 B后端收到的就是/api/v1/user同样 404。这一对组合我见过的比例高到离谱。配置前端请求后端收到适用场景location /api/proxy_pass http://b:8080/;/api/v1/user/v1/user后端无 context-path路径不含 /apilocation /api/proxy_pass http://b:8080;/api/v1/user/api/v1/user后端 context-path/api 或路由自带 /apilocation /api/v1/user/api/v1/user/api/v1/user精确匹配单条接口接着是location 匹配优先级。nginx 的规则是精确匹配优先级最高其次是^~前缀匹配然后是正则~和~*最后才是普通前缀匹配。很多项目里同时存在location /api/和一条早期留下的location ~* \.(json|yaml)$之类的正则结果某些带后缀的 API 路径被正则抢先匹配转到了一个静态资源目录返回 404。排查的时候用nginx -T把完整生效配置打出来看不要只看你改的那个文件include 进来的配置经常会打架。第三个坑是SPA 的 history 路由回退。前端用 history 模式做路由配置里必须有一条location / { try_files $uri $uri/ /index.html; }少了这一句用户在/settings/profile页面按 F5nginx 会去找settings/profile这个文件或目录找不到就 404。而这类 404 的响应体是 nginx 的 HTML不是后端的 JSON API 响应两者一对比就能分辨。3.3 后端路由注册的顺序与规则陷阱后端这边的问题通常集中在顺序和叠加两个词上。顺序问题Express 最典型app.use(/api/users, userRouter); app.use(/api/orders, orderRouter); app.get(*, (req, res) res.sendFile(indexHtml)); // 兜底这个写法本身是对的因为兜底在最下面。但如果有人图方便把它挪到了最上面所有 API 请求都会被兜底吃掉返回的是一个 HTML 文件——前端拿到 HTML 解析 JSON 失败或者干脆因为Content-Type: text/html触发别的问题而 Network 里看到的可能是 200也可能是 404取决于兜底文件存不存在。我在真实项目里见过一次排查了一个下午。叠加问题Spring Boot 最典型。server.servlet.context-path加类上的RequestMapping加方法上的GetMapping三段拼起来才是最终路径。任何一段被改动比如运维为了统一网关前缀把 context-path 从/改成了/service-a所有前端请求就集体 404。所以我在服务启动时一定会打一行日志把 context-path 和当前生效的 profile 打出来这行日志在故障时的价值极高。版本前缀不一致也是老问题。网关转发规则写的是/api/v1/*前端新加的接口写成了/api/v2/xxx网关没配 v2 的规则直接 404。这类问题多发在版本升级或者多人协作的情况下解决办法是在网关侧统一做一次路径清单核对而不是等到报错再一个个试。3.4 资源标识不存在造成的伪 404这一类值得单独拿出来讲因为它的表现和路径错误一模一样但根因完全不同。热词里出现的unexpected status 404 not found: the model xxx does not exist、{detail:not found}、import profile failed: failed to fetch remote profile with status 403这些都属于同一家族HTTP 状态码承载的是请求成功但对象不存在而不是接口不存在。判断方法只有一个看响应体不要只看状态码。响应体是后端框架风格的 JSON里面有明确的字段说明比如detail、message、error_code且路径在上一步 curl 里验证过是通的那就是伪 404。响应体是 nginx 或 CDN 的 HTML 错误页那就不是伪 404是前面的路径或代理问题。伪 404 的排查方向是请求参数不是路由。要去看的是传的 ID 是不是真的存在于数据库、传的模型名或资源名拼写是不是和注册表一致、当前账号有没有权限访问这个对象有些系统出于安全考虑把无权限也统一返回成 404 而不是 403避免泄露资源是否存在、租户或者工作空间 ID 有没有带上。前端在处理这类错误时建议把 404 从网络错误里单独拆出来做差异化提示。用户看到网络异常请稍后重试和看到该资源不存在或已被删除体验差别很大。async function requestProfile(id) { const res await fetch(/api/v1/profile/${id}, { headers: { Content-Type: application/json }, }); if (res.ok) { return await res.json(); } if (res.status 404) { // 区分伪 404先看响应体能不能解析成 JSON const ct res.headers.get(content-type) || ; if (ct.includes(application/json)) { const body await res.json().catch(() null); // 后端明确说是资源不存在属于业务语义 const err new Error(body?.detail || 目标资源不存在); err.code RESOURCE_NOT_FOUND; throw err; } // 响应体是 HTML说明请求根本没到后端是网关/代理返回的 const err new Error(请求未能到达服务端请检查接口路径或网关配置); err.code GATEWAY_NOT_FOUND; throw err; } if (res.status 403) { const err new Error(没有访问该资源的权限); err.code FORBIDDEN; throw err; } throw new Error(请求失败${res.status}); }这段代码的核心价值在于把接口不存在和资源不存在在代码层面区分开。前者是开发问题要在监控里报警后者是业务问题应该正常展示给用户。混在一起的话监控告警会被大量正常业务噪音淹没真出问题的时候反而看不出来。3.5 跨域预检失败明明是 404却报 Failed to fetch这是最名不副实的一类。浏览器在发跨域的非简单请求前会先发一个 OPTIONS 预检。如果这个 OPTIONS 打到了后端没注册 OPTIONS 方法的路由后端回 404 或 405浏览器就直接判定跨域失败拒绝发送真正的请求然后在 Console 里报一个跟跨域相关的 Failed to fetchNetwork 里那条 POST 请求根本不出现。排查特征非常明确Network 里出现了一条OPTIONS请求状态是 404 或 405。或者 Network 里连 OPTIONS 都没有只有 Console 里的报错。curl 带Origin头请求同一个路径返回 200但浏览器就是不行。解决办法有两条路。一是后端或网关统一处理 OPTIONS比如 nginx 层直接返回if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Content-Type, Authorization, X-Requested-With always; add_header Access-Control-Max-Age 86400 always; return 204; }二是在后端框架里开启 CORS 中间件并确保它注册在所有业务路由之前。这里有个顺序坑如果 CORS 中间件注册在路由之后预检请求可能先被某条业务路由匹配到并返回 404压根轮不到 CORS 中间件处理。注意Access-Control-Allow-Origin和Access-Control-Allow-Credentials: true不能同时用通配符*必须回显具体的 Origin。这个组合错误会让浏览器静默拒绝响应表现同样是 Failed to fetch但 Network 里状态码是 200极具迷惑性。3.6 混合内容与协议降级还有一种情况页面是 HTTPS接口地址写成了http://。浏览器会直接拦截Network 里标注(blocked:mixed-content)Console 报 Failed to fetch。这类问题在从测试环境切到生产环境、或者前端硬编码了某个 HTTP 地址时特别常见。值得区分的是前端直连 http 会被拦但前端请求同源的 https由反向代理在服务端转发到内网的 http 后端是完全正常的。前者是浏览器行为后者是服务端行为。很多人把这两件事搞混因为降级这个词一样实际影响完全不同。判断方法还是看 Network如果请求 URL 本身就是http://开头那就是混合内容如果请求 URL 是 https 而 404那就是代理配置问题。4. 完整实操一次真实的 404 排障全过程4.1 现场一个看起来很简单的接口场景是这样前端设置页调用/api/v1/profile拉取用户资料Console 报TypeError: Failed to fetchNetwork 里那条请求显示 404响应体是一段 HTML。本地开发环境一切正常部署到测试环境才出现。本地正常、线上不正常这个信息本身就很有价值——说明代码逻辑没问题差异出在环境配置上。这时候千万不要去改代码先收集信息。我固定会收集这几项完整请求 URL、请求方法、Status 后面的括号内容、响应头里的server字段、响应体前 200 个字符、以及这条请求对应的服务端访问日志有没有记录。4.2 分层验证用 curl 把范围缩小第一步直接打后端服务的内网地址绕过所有中间层curl -i http://10.0.1.23:8080/api/v1/profile # 返回 200JSON 响应体正常后端本身是好的。第二步走公网域名curl -i https://api.example.com/api/v1/profile # 返回 404Content-Type: text/html # 响应体是 nginx 的默认 404 页面到这里结论已经很明确了404 是 nginx 返回的请求没到后端。第三步确认一下是不是路径问题试着去掉前缀curl -i https://api.example.com/v1/profile # 返回 200请求/v1/profile通了/api/v1/profile不通。这说明 nginx 把/api/这个前缀剥掉了但后端期望的其实是带/api的完整路径。回头看配置文件问题一目了然# 问题配置 location /api/ { proxy_pass http://backend:8080/; # 尾斜杠把 /api/ 替换成了 / }后端服务的 context-path 就是/api所有路由都在它下面。而 nginx 配了尾斜杠把/api/v1/profile变成了/v1/profile转发过去后端自然找不到。4.3 修复与验证修法很简单去掉尾斜杠# 修复后 location /api/ { proxy_pass http://backend:8080; # 原样透传/api/v1/profile 保持不变 }改完之后有几个步骤不能省。首先是配置语法检查nginx -t必须先跑通过了再nginx -s reload否则一个语法错误会让整个服务挂掉。其次是 reload 之后立刻用 curl 验证一遍确认 200。然后回到浏览器一定要勾上 Disable cache 再刷新因为浏览器可能缓存了之前那个 404 的响应不清缓存会看到改了配置还是 404的假象白白再排查一轮。最后一步是回归验证把所有依赖/api/前缀的接口都跑一遍别只测刚刚那一个。因为改的是proxy_pass的尾斜杠所有经过这条 location 的路径都受影响。如果有接口原本就是靠被剥掉前缀才能正常工作的这一改反而会把它们弄坏。这也是为什么在改代理配置之前最好先把当前所有接口的调用情况过一遍心里有个清单。# 批量回归把关键接口跑一遍只看状态码 for p in /api/v1/profile /api/v1/orders /api/v1/settings; do code$(curl -s -o /dev/null -w %{http_code} https://api.example.com${p}) echo ${p} - ${code} done4.4 加一道保险让下次不用再查问题修完之后我习惯做两件事防止复发。一件是在网关或者监控里加一条针对 404 比例的告警。正常的业务 404 会有一个稳定的基线一旦某个接口的 404 突然翻倍或者整体 404 比例在部署后突增立刻就能发现。这比等用户来报障快得多。另一件是在 CI 里加一个冒烟测试部署完成后自动跑一遍关键接口状态码不是 2xx 或者 3xx 就直接让发布流程失败。代理配置这种问题一旦上线影响面是全站级别的用自动化卡住它比靠人眼检查靠谱得多。5. 排查速查表与避坑经验5.1 故障速查表现象大概率原因验证方式修复方向Console 报 Failed to fetchNetwork 无请求CSP、混合内容、预检被拦看 Status 括号内容调整 CSP / 统一 HTTPS / 处理 OPTIONSConsole 报 Failed to fetchNetwork 有 404代理或路由问题curl 直连后端对比修 location 或 proxy_passStatus 404响应体是 HTML中间层返回请求未到后端查服务端访问日志修网关或代理配置Status 404响应体是 JSON伪 404资源不存在检查请求参数校验 ID/名称/权限本地正常线上 404环境变量或代理差异对比两份配置统一 baseURL 与代理规则刷新页面 404点击跳转正常SPA history 回退缺失直接访问深层 URL加 try_files 回退OPTIONS 请求 404/405预检未处理curl 发 OPTIONS网关统一处理或开启 CORS改了配置还是 404浏览器缓存了旧响应开 Disable cache 重试清缓存后复验路径多了一层前缀context-path 与路由叠加打印实际路由表调整前缀配置请求路径出现双斜杠baseURL 与 path 拼接冲突看 Request URL 全地址统一拼接约定5.2 我踩过的几个坑第一个坑把 nginx 的 404 页面当成后端返回的。早期做项目时我看到 404 就直奔后端代码改了两小时路由最后同事提醒我看一眼响应体——是一整段 nginx 的 HTML。从那之后我养成习惯拿到任何 404先看Content-Type和响应体前 100 个字符两秒钟的事能省掉大量无效排查。第二个坑只改配置不看 include。nginx 配了include conf.d/*.conf我以为自己改的是唯一的文件实际上还有另一个文件里定义了更具体的location优先级更高抢先匹配了。nginx -T会把所有 include 展开后完整输出来排查配置问题的时候一定要用这个命令不要只盯着自己改的那个文件看。第三个坑忽略了请求方法。有一次排查一个 404路径完全正确折腾半天才发现前端用的是 PUT而后端只注册了 POST。同一个路径不同方法返回 404 还是一些框架的行为有些返回 405但看到 404 的第一反应往往不会想到方法上去。在 Network 面板里多看一眼 Method 那一列成本极低。第四个坑以为 curl 通了浏览器就一定通。curl 不执行 CORS 检查也不管混合内容。所以 curl 200 但浏览器 Failed to fetch是完全可能的这时候问题就在跨域或协议上跟接口本身没关系。第五个坑改了配置不重启、不 reload。改完 nginx 配置忘了reload然后死活测不出效果这种低级错误我干过不止一次。后来我给自己定了个规矩所有配置类修改改完立刻执行 reload 并 curl 验证中间不插入任何其他动作。5.3 上线前的自检清单[ ] 打印一次前端实际使用的 baseURL确认和生产环境一致[ ] 用nginx -T检查完整生效配置确认 location 优先级无冲突[ ] 确认proxy_pass尾斜杠与后端 context-path 匹配[ ] 逐个 curl 关键接口确认状态码全部符合预期[ ] 用Origin头单独验证一次跨域预检[ ] 确认所有接口地址协议与页面一致同为 HTTPS[ ] SPA 项目确认try_files回退配置存在[ ] 部署完成后跑一遍自动化冒烟测试最后分享一个我自己一直在用的小技巧给项目准备一个debug.sh把常用的几条 curl 命令固定下来包括带 Origin 头的、带 OPTIONS 的、绕过 DNS 的三种写法。真出问题的时候不用现查语法改个域名和路径就能跑从发现问题到定位到层基本能压在一分钟以内。这套东西我用了两三年覆盖了绝大多数 404 和 Failed to fetch 的场景剩下那一小部分基本都是业务参数写错造成的伪 404那个只能靠仔细看响应体解决。