前端提交订单,后端日志里目标接口确实被调用了,但拿到的订单号是空的。请求发出时请求体里明明带着数据,中间只隔了一次 302 跳转。
先看这一跳到底发生了什么。起一个本地服务器,让它按查询参数返回不同的重定向状态码,再看跟随重定向之后服务器收到的方法是什么。
// 验证脚本:HTTP 重定向各状态码对请求方法与请求体的实际保留规则
import { createServer } from 'node:http';
const 记录 = [];
const 服务器 = createServer((请求, 响应) => {
记录.push({ 方法: 请求.method, 路径: 请求.url });
// 只取路径部分判断,查询串不参与比较
const 路径 = 请求.url.split('?')[0];
if (路径 === '/start') {
// 由查询参数决定回哪个重定向码
const 码 = Number(new URL(请求.url, 'http://x').searchParams.get('code') || 302);
响应.writeHead(码, { Location: '/target' });
响应.end();
return;
}
响应.writeHead(200, { 'Content-Type': 'application/json' });
响应.end(JSON.stringify({ 方法: 请求.method, 记录 }));
});
await new Promise((完成) => 服务器.listen(0, '127.0.0.1', 完成));
const 端口 = 服务器.address().port;
const 基址 = `http://127.0.0.1:${端口}`;
for (const 码 of [301, 302, 303, 307, 308]) {
记录.length = 0;
const 响应 = await fetch(`${基址}/start?code=${码}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 数据: 1 }),
redirect: 'follow',
});
const 体 = await 响应.json();
console.log(`状态码 ${码}:服务器收到的方法 = ${体.方法},请求链 = ${体.记录.map((条) => 条.方法 + ' ' + 条.路径).join(' -> ')}`);
}
服务器.close();
实际输出:
服务器端口 64337
状态码 301:服务器收到的方法 = GET,请求链 = POST /start?code=301 -> GET /target
状态码 302:服务器收到的方法 = GET,请求链 = POST /start?code=302 -> GET /target
状态码 303:服务器收到的方法 = GET,请求链 = POST /start?code=303 -> GET /target
状态码 307:服务器收到的方法 = POST,请求链 = POST /start?code=307 -> POST /target
状态码 308:服务器收到的方法 = POST,请求链 = POST /start?code=308 -> POST /target
五个状态码分成两组。301、302、303 这一组,第二跳的方法从 POST 变成了 GET;307、308 这一组保持 POST 不变。
方法被改写,请求体也就没有理由留下。把每一跳真正收到的请求体长度记下来:
// 验证脚本:重定向之后请求体是否还在
import { createServer } from 'node:http';
const 记录 = [];
const 服务器 = createServer((请求, 响应) => {
// 把请求体收完再记录,否则长度永远是 0
const 分片 = [];
请求.on('data', (片) => 分片.push(片));
请求.on('end', () => {
const 体 = Buffer.concat(分片).toString('utf8');
记录.push({ 方法: 请求.method, 路径: 请求.url, 体长度: 体.length, 体内容: 体 });
const 路径 = 请求.url.split('?')[0];
if (路径 === '/start') {
const 码 = Number(new URL(请求.url, 'http://x').searchParams.get('code') || 302);
响应.writeHead(码, { Location: '/target' });
响应.end();
return;
}
响应.writeHead(200, { 'Content-Type': 'application/json' });
响应.end(JSON.stringify({ 记录 }));
});
});
await new Promise((完成) => 服务器.listen(0, '127.0.0.1', 完成));
const 端口 = 服务器.address().port;
const 基址 = `http://127.0.0.1:${端口}`;
const 请求体 = JSON.stringify({ 订单号: 'A001', 金额: 100 });
for (const 码 of [301, 302, 303, 307, 308]) {
记录.length = 0;
const 响应 = await fetch(`${基址}/start?code=${码}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 请求体,
redirect: 'follow',
});
await 响应.text();
const 目标跳 = 记录.find((条) => 条.路径 === '/target');
console.log(
`状态码 ${码}:目标跳方法=${目标跳.方法},请求体长度=${目标跳.体长度},内容=${目标跳.体内容 || '(空)'}`
);
}
服务器.close();
实际输出:
状态码 301:目标跳方法=GET,请求体长度=0,内容=(空)
状态码 302:目标跳方法=GET,请求体长度=0,内容=(空)
状态码 303:目标跳方法=GET,请求体长度=0,内容=(空)
状态码 307:目标跳方法=POST,请求体长度=23,内容={"订单号":"A001","金额":100}
状态码 308:目标跳方法=POST,请求体长度=23,内容={"订单号":"A001","金额":100}
请求体长度那一列是 23 和 0 的差别。307 和 308 下订单号完整到达,301、302、303 下目标接口收到的是一个不带任何数据的 GET 请求。目标接口照常返回 200,日志里也照常留了一行记录,看起来像是「请求进来了但参数没带」,很容易往参数解析上排查。
这套行为不是浏览器特有的。301 和 302 在早期规范里对方法改写的态度比较含糊,后来为了让语义明确,才补了 307 和 308 两个码来保证方法与请求体不被改写。上面那次运行用的是 Node 的 fetch,得到的结论和浏览器一致,说明这是跟随重定向这一侧的统一口径。
落到实际链路上,几种典型场景的后果不一样。
登录后的门户跳转、旧域名整体跳新域名,这类跳转本身就是「换一个地址重新拿一次页面」,用 301 或 302 合适,被改写成 GET 正是想要的效果。
接口层面的跳转就危险。网关把请求转给内部服务、地址末尾补斜杠、HTTP 升级到 HTTPS 这类跳转如果挂了 POST,用 302 就会静默丢掉请求体。判断方法很简单:拿一个带请求体的请求打过去,看目标接口收到的体长度,是 0 就说明中间有 302 在改写。
HTTPS 升级那一跳尤其容易漏。很多服务器配置了 HTTP 到 HTTPS 的跳转,默认给的是 301。301 是永久跳转,会被浏览器缓存,之后连第一次请求都不再走 HTTP,直接发到 HTTPS 地址,这一跳反而不会发生。但如果客户端是脚本或 SDK,不缓存 301,每次都要先跳一次,请求体就在这一跳上没了。
要保住请求体,把跳转码换成 307 或 308 即可。307 对应临时跳转,308 对应永久跳转,选择依据和 302 对 301 一样。代价是这两个码在一些老的客户端上支持不完整,需要先确认调用方的范围。
如果跳转不由自己控制,比如是对方网关给的重定向,那就只能改在客户端:把 redirect 设成 manual,拿到 Location 之后自己用原来的方法和请求体重新发一次。这样做能把控制权拿回来,代价是重定向次数、跨域限制和循环跳转的防护都要自己处理,比交给运行时跟随麻烦不少。
至于该用哪种,取决于这个跳转是不是自己的服务器发的。可控就换码,不可控就在客户端接管,两种都能解决丢请求体的问题。