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('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
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 方案,减少信用卡数据直接经过你的服务器。
备注:内容仅供参考。
