PAYATHON 2026

Node.js / Express 在 POST 请求后重定向用户

支付小周

结论

Node.js 通过 request 发出的服务端 POST 请求,不能直接“转交”给用户浏览器。这个请求发生在你的服务器和支付网关之间,浏览器没有参与。即使网关返回 200 OK,浏览器也不会自动跳转。

具体处理方式要看支付网关的接口设计:

  • 如果网关返回支付页面 URL,使用 res.redirect(url),让浏览器重新发起 GET 请求。
  • 如果网关要求浏览器通过 POST 进入支付页面,向浏览器返回一个自动提交的 HTML <form>。
  • 如果网关返回 token、session ID 或 transaction ID,按照接口文档将其加入跳转 URL,或作为表单字段提交。
  • 如果网关只返回 HTML,可以把 HTML 返回给浏览器,但这并不等于跳转,而且可能遇到资源路径、Cookie 和安全策略等问题。

followAllRedirects 为什么没有用

followAllRedirects 只控制 request 模块是否跟随第三方服务器返回的 3xx 响应。例如:

你的服务器 --POST--> 支付网关
你的服务器 <--302--- 支付网关
你的服务器 --GET/POST--> Location 指向的地址

这些请求都发生在服务器端。即使 request 跟随了重定向,用户浏览器仍会停留在原页面。

如果支付网关返回的是 200 OK,就没有 HTTP 重定向可以跟随。此时需要检查响应正文和响应头,确认网关是否返回了支付 URL、token 或可提交的表单数据。

方案一:网关返回支付页面 URL

如果响应正文中包含用户可以访问的 URL,可以在请求完成后调用 Express 的 res.redirect():

function handler(req, res, next) {
  request({
    url: 'https://urlopaymentgateway',
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    form: data,
    followAllRedirects: true
  }, function (error, response, body) {
    if (error) {
      return next(error);
    }

    if (response.statusCode !== 200) {
      return res.status(502).send('Payment gateway request failed');
    }

    let result;

    try {
      result = JSON.parse(body);
    } catch (error) {
      return res.status(502).send('Invalid payment gateway response');
    }

    if (!result.paymentUrl) {
      return res.status(502).send('Payment URL was not returned');
    }

    return res.redirect(303, result.paymentUrl);
  });
}

上面的代码假设网关返回以下数据:

{
  "paymentUrl": "https://payment.example.com/checkout/session-id"
}

303 See Other 会明确要求浏览器使用 GET 请求访问目标地址,适合服务器处理完 POST 请求后跳转到支付页面的场景。实际字段名和跳转方式仍应以支付网关文档为准。

不要直接信任响应中的任意 URL。至少要验证协议和域名,防止产生开放重定向漏洞:

function isAllowedPaymentUrl(value) {
  try {
    const url = new URL(value);

    return (
      url.protocol === 'https:' &&
      url.hostname === 'payment.example.com'
    );
  } catch {
    return false;
  }
}

方案二:让浏览器向网关提交 POST

常规的 HTTP 302 或 303 重定向无法携带一组新的 POST 表单字段。如果网关要求用户浏览器通过 POST 进入支付页面,可以返回一个自动提交的表单:

function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

function handler(req, res) {
  const paymentData = {
    merchantId: 'merchant-123',
    orderId: 'order-456',
    amount: '99.00',
    token: 'token-returned-by-gateway'
  };

  const fields = Object.entries(paymentData)
    .map(([name, value]) => (
      `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`
    ))
    .join('\n');

  res.type('html').send(`
    <!doctype html>
    <html lang="zh-CN">
      <head>
        <meta charset="utf-8">
        <title>正在前往支付页面</title>
      </head>
      <body>
        <p>正在跳转到支付页面,请稍候……</p>

        <form
          id="payment-form"
          method="post"
          action="https://payment.example.com/checkout"
        >
          ${fields}
          <noscript>
            <button type="submit">继续支付</button>
          </noscript>
        </form>

        <script>
          document.getElementById('payment-form').submit();
        </script>
      </body>
    </html>
  `);
}

请求过程如下:

浏览器 -> 你的 Express 路由
Express -> 返回带隐藏字段的 HTML 表单
浏览器 -> 自动向支付网关提交 POST
支付网关 -> 向用户显示支付页面

这种方式与传统 HTML 表单直接提交到支付网关的效果更接近。

用户仍然可以查看和修改隐藏字段,因此金额、订单号等重要数据必须由服务端校验。更合适的做法是让网关返回一次性且短时有效的支付 token,浏览器只负责提交这个 token。

不建议直接使用 307 或 308

Express 可以发送保留原请求方法的重定向:

res.redirect(307, 'https://payment.example.com/checkout');

307 Temporary Redirect 会要求浏览器使用原请求的方法和请求体再次发送请求。但它发送的是用户提交到当前 Express 路由的原始请求,不是服务器后来为支付网关构造的 form: data。

因此,307 通常不适合支付流程,还可能将登录字段、内部参数或敏感数据意外发送给第三方。只有当请求体格式完全匹配、数据转发已经通过安全评估,并且支付网关明确要求这种流程时,才可以考虑使用。

网关返回 HTML 时

如果 body 本身就是完整的支付页面,可以直接返回:

res.status(200).type('html').send(body);

这种方式只是把第三方 HTML 作为你的网站响应发送给浏览器,地址栏中仍然显示你的域名。页面里的相对链接、Cookie、表单目标、脚本、CSP 和跨域逻辑可能无法正常工作,安全风险也会增加。

除非支付网关明确要求商户代理该页面,否则应优先使用网关提供的 hosted checkout URL、session token 或官方表单提交方案。

支付场景的注意事项

不要只根据 statusCode === 200 判断支付是否成功。200 只能说明 HTTP 请求已经正常完成,响应正文仍可能表明业务处理失败。还需要验证以下内容:

  • 网关返回的业务状态码;
  • 订单号、金额和币种;
  • 支付 session 或 token;
  • 回调签名;
  • token 的有效期和使用次数;
  • 跳转 URL 是否属于允许的 HTTPS 域名。

不要在日志、URL 查询参数或普通隐藏字段中保存完整卡号、CVV 等敏感信息。优先采用支付网关提供的托管支付页面、Hosted Fields 或 tokenization 方案,减少信用卡数据直接经过你的服务器。

备注:内容仅供参考。