侧边栏壁纸
博主头像
一笑痕

人生若只如初见,
是可喜亦或者是可悲?

  • 累计撰写 117 篇文章
  • 累计收到 7 条评论

302 跳转之后 POST 变成 GET,订单号在中间那跳丢了

2026-9-24 / 0 评论 / 14 阅读

前端提交订单,后端日志里目标接口确实被调用了,但拿到的订单号是空的。请求发出时请求体里明明带着数据,中间只隔了一次 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 之后自己用原来的方法和请求体重新发一次。这样做能把控制权拿回来,代价是重定向次数、跨域限制和循环跳转的防护都要自己处理,比交给运行时跟随麻烦不少。

至于该用哪种,取决于这个跳转是不是自己的服务器发的。可控就换码,不可控就在客户端接管,两种都能解决丢请求体的问题。

    🤞 分享