POST API call 与 form submission 有什么区别?
明确结论
可以用 Node.js 的 HTTP 客户端向同一地址发送 POST 请求,但它未必能替代浏览器提交表单。
两者都可能发送 HTTP POST 请求,区别主要在于请求由谁发起,以及响应由谁处理:
- 浏览器提交表单后,会加载响应页面或跟随重定向,所以用户能看到页面跳转。
- Node.js 从服务器端发送请求,响应只会返回给 Node.js。用户的浏览器不知道这次请求,也就不会跳转。
接入支付网关前,需要先确认它提供的是浏览器收银台接口,还是服务端 API。如果网关要求用户进入其支付页面,通常仍需让浏览器提交表单,或访问网关返回的支付 URL。
form submission 实际做了什么
以下表单:
<form method="post" action="https://payment.example.com/checkout">
<input type="hidden" name="order_id" value="ORDER_123">
<input type="hidden" name="amount" value="100.00">
<button type="submit">支付</button>
</form>
提交后,浏览器会:
- 根据
action确定请求地址。 - 根据
method发送 POST 请求。 - 按照表单的
enctype编码字段。 - 接收支付网关返回的 HTML 或重定向响应。
- 在当前页面加载响应结果,或者跳转到新地址。
普通表单默认使用:
Content-Type: application/x-www-form-urlencoded
请求体大致如下:
order_id=ORDER_123&amount=100.00
这里的「跳转」是浏览器处理响应后产生的导航行为,不是 POST 请求本身具备跳转能力。
Node.js POST 请求有什么不同
Node.js 发出的 POST 请求发生在你的服务器与支付网关之间:
用户浏览器 -> 你的服务器 -> 支付网关
支付网关返回的 HTML、状态码或 Location 响应头,只会由 Node.js 程序接收。即使网关返回:
HTTP/1.1 302 Found
Location: https://payment.example.com/pay/session-123
用户的浏览器也不会自动进入该地址。你的程序需要读取响应,再决定如何通知浏览器跳转。
不同的 Node.js 客户端处理重定向的方式也不一样:
- 原生
http、https模块通常需要自行处理 3xx 响应。 fetch等客户端可以跟随重定向,但执行跳转的仍是服务器端请求,不是用户浏览器。
使用 Node.js 发送相同格式的 POST 请求
如果支付网关允许从服务端调用,并要求使用 application/x-www-form-urlencoded,可以这样发送:
import https from 'node:https';
import { URLSearchParams } from 'node:url';
const body = new URLSearchParams({
order_id: 'ORDER_123',
amount: '100.00'
}).toString();
const request = https.request(
{
hostname: 'payment.example.com',
path: '/checkout',
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body)
}
},
(response) => {
console.log('Status:', response.statusCode);
console.log('Location:', response.headers.location);
let responseBody = '';
response.setEncoding('utf8');
response.on('data', (chunk) => {
responseBody += chunk;
});
response.on('end', () => {
console.log(responseBody);
});
}
);
request.on('error', (error) => {
console.error(error);
});
request.write(body);
request.end();
这段代码能发送与普通表单格式相近的请求,但响应仍停留在 Node.js 服务端。
接口被称为 API,并不意味着请求体应该改成 JSON。只有支付网关明确支持时,才能使用下面这种格式:
const body = JSON.stringify({
order_id: 'ORDER_123',
amount: '100.00'
});
Content-Type: application/json
application/json 和 application/x-www-form-urlencoded 是两种不同的请求格式。网关支持其中一种,不代表它也支持另一种。
如何让用户进入支付页面
具体做法取决于支付网关的接入协议。
方式一:由浏览器直接提交表单
如果支付网关文档给出了表单字段,并要求将用户提交到其收银台,可以继续使用表单。敏感签名应由服务端生成,然后将支付参数返回给页面。
<form id="payment-form" method="post" action="https://payment.example.com/checkout">
<input type="hidden" name="order_id" value="ORDER_123">
<input type="hidden" name="amount" value="100.00">
<input type="hidden" name="signature" value="SERVER_GENERATED_SIGNATURE">
</form>
<script>
document.getElementById('payment-form').submit();
</script>
这样,浏览器会通过 POST 请求进入支付网关页面。
在真实项目中,不要把未经转义的用户输入直接拼接到 HTML 中,否则可能引发 XSS 风险。
方式二:服务端创建支付会话,再让浏览器跳转
很多现代支付网关要求服务端先调用 API 创建支付会话:
浏览器 -> 你的服务器 -> 支付 API
<- 返回 checkout_url
浏览器 <- checkout_url
浏览器 -> 支付页面
Express 示例:
app.post('/create-payment', async (req, res) => {
const gatewayResponse = await fetch(
'https://api.payment.example.com/payment-sessions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PAYMENT_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
order_id: 'ORDER_123',
amount: 10000,
currency: 'CNY'
})
}
);
if (!gatewayResponse.ok) {
return res.status(502).json({
error: 'Failed to create payment session'
});
}
const payment = await gatewayResponse.json();
res.json({
checkoutUrl: payment.checkout_url
});
});
浏览器拿到地址后再跳转:
const response = await fetch('/create-payment', {
method: 'POST'
});
const payment = await response.json();
window.location.assign(payment.checkoutUrl);
示例中的字段名、金额单位和返回结构只用于说明流程。实际参数必须以支付网关文档为准。
服务端也可以直接向浏览器返回重定向:
res.redirect(303, payment.checkout_url);
303 See Other 表示浏览器接下来会使用 GET 请求访问目标地址,适合 POST 创建资源后再跳转的场景。前提是目标支付页面支持通过 GET URL 打开。
方式三:返回自动提交的中转表单
如果网关必须接收浏览器发出的 POST,而服务端又需要提前准备签名参数,可以由服务端返回一个自动提交的 HTML 表单:
app.post('/start-payment', async (req, res) => {
const orderId = 'ORDER_123';
const amount = '100.00';
const signature = createPaymentSignature({ orderId, amount });
res.type('html').send(`
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>正在跳转到支付页面</title>
</head>
<body>
<form id="payment-form"
method="post"
action="https://payment.example.com/checkout">
<input type="hidden" name="order_id" value="${escapeHtml(orderId)}">
<input type="hidden" name="amount" value="${escapeHtml(amount)}">
<input type="hidden" name="signature" value="${escapeHtml(signature)}">
</form>
<script>
document.getElementById('payment-form').submit();
</script>
</body>
</html>
`);
});
最终向支付网关发送 POST 的仍是用户浏览器,因此网关返回的页面可以正常显示给用户。
为什么不能简单地把网关响应转发给浏览器
Node.js 理论上可以获取支付网关返回的 HTML,再把它发送给用户,但这种做法通常不可靠。
支付页面可能依赖:
- 网关域名下的 Cookie 和会话。
- 使用相对路径加载的 CSS、JavaScript 和图片资源。
- Content Security Policy。
- 防跨站请求伪造参数。
- 浏览器指纹或风控信息。
- 网关设置的安全响应头。
- 必须在支付网关自身域名下运行的后续页面。
除非支付网关明确支持这种集成方式,否则不要自行代理或改写托管支付页面。
接入支付网关时需要注意
严格按照网关文档选择接口
同一个支付平台可能同时提供:
- 浏览器表单接口。
- 服务端创建订单 API。
- 托管收银台 URL。
- 前端 JavaScript SDK。
- App SDK。
- 支付结果查询接口。
- 异步 webhook 通知。
这些接口的用途不同,不能仅凭它们都使用 POST 就相互替换。
不要把密钥放在浏览器中
API key、签名私钥和 webhook secret 应保存在服务端。浏览器只能获取完成支付所需的公开参数或一次性支付会话信息。
不要只依赖支付完成后的页面跳转
用户支付成功后可能直接关闭页面,跳转请求也可能因网络问题失败。订单状态应以经过签名验证的 webhook 为准,也可以根据服务端主动查询到的支付结果确认。
正确处理金额和重复请求
金额最好按照支付网关的要求使用最小货币单位,例如分,以免产生浮点数误差。创建支付订单时还应使用唯一订单号或 idempotency key,避免网络重试造成重复扣款。
校验签名和回调参数
不要因为浏览器传来了 success=true 就更新订单状态。服务端必须验证支付结果的签名,并核对订单号、金额、币种和商户身份。
总结
POST 只是 HTTP 请求方法,并不负责页面跳转。表单提交后能显示新页面,是因为请求由浏览器发起,响应也由浏览器加载。Node.js 发起的 POST 属于服务器之间的通信,响应只会回到 Node.js。
如果支付网关要求用户进入托管收银台,通常可以选择以下方式:
- 让浏览器直接提交网关要求的 POST 表单。
- 由服务端调用支付 API 创建会话,再把支付 URL 返回给浏览器。
- 由服务端生成参数和签名,再返回自动提交的中转表单。
具体实现应以支付网关的接入文档为准,尤其要确认请求格式、签名方式、浏览器跳转流程和异步通知机制。
备注:内容仅供参考。
