Angular/NodeJS 如何向第三方支付网关 POST 并获取响应
结论
需要重新梳理支付流程。Option 4.2 不适合作为最终方案,因为后端 fetch() 和浏览器跳转是两次彼此独立的请求。前一次 POST 使用的表单、会话状态和响应上下文,不会自动“转交”给浏览器。
支付流程应拆成两条链路:
- 浏览器通过标准 HTML form POST 进入支付页面。也可以由后端调用网关的“创建支付会话”API,再把支付 URL 返回给浏览器。
- 支付网关通过 webhook/server callback 直接把最终结果通知后端。用户返回网站后,Angular 再向后端查询订单状态。
确认支付结果应以 webhook 为准。success 和 cancel 页面只负责界面跳转,不能单独作为入账凭证。
为什么现有方案拿不到响应
标准 form POST 本质上是一次页面导航。浏览器收到支付网关返回的 HTML 后,会直接显示支付页面,不会把响应交给 Angular 的 HttpClient。
Angular 和 jQuery 发起的 AJAX 请求受浏览器同源策略限制。只有支付网关明确允许你的域名,并返回正确的 CORS 响应头,前端 JavaScript 才能读取响应。客户端无法靠设置 crossDomain 或请求头绕过 CORS。
Node.js 的 fetch() 不受浏览器 CORS 限制,但它发起的是服务器到服务器的请求,无法替用户建立完整的浏览器支付会话。网关可能依赖这些信息:
application/x-www-form-urlencoded表单格式- 网关设置的 Cookie
- 浏览器重定向过程
- CSRF token 或一次性支付 token
- JavaScript 在支付页面中初始化的状态
另外,fetch() 默认跟随重定向。newData.url 通常只是重定向后的最终地址,并不表示浏览器可以脱离原始 POST、Cookie 和 token,再单独访问这个地址。
window.location.replace()、location.href 和普通链接只能发起 GET,不能携带原来的 POST body。这就是 Option 4.3 最终打开空白页面的原因。
推荐的支付流程
方案一:使用网关的“创建支付会话”API
如果支付网关提供后端 API,优先使用这种方式:
- Angular 把商品或订单标识发送给后端。
- 后端重新读取商品价格,并在数据库中创建
pending订单。 - 后端使用密钥调用支付网关的创建支付接口。
- 网关返回
checkoutUrl、session ID 或 payment ID。 - 后端保存这些标识,把
checkoutUrl返回给 Angular。 - Angular 使用
window.location.assign(checkoutUrl)跳转。 - 网关通过 webhook 通知后端付款结果。
- 用户返回网站后,Angular 查询后端的订单状态。
// Angular
this.http.post<{ checkoutUrl: string }>('/api/payments/start', {
orderId: this.orderId
}).subscribe({
next: ({ checkoutUrl }) => {
window.location.assign(checkoutUrl);
},
error: () => {
this.message = '暂时无法发起支付,请稍后重试';
}
});
Node.js 应按照支付网关文档规定的格式调用接口。下面只是结构示例,具体 URL、字段和认证方式必须以网关文档为准:
app.post('/api/payments/start', async (req, res, next) => {
try {
const order = await loadOrder(req.body.orderId);
if (!order || order.userId !== req.user.id) {
return res.status(404).json({ message: 'Order not found' });
}
const gatewayResponse = await fetch(
'https://gateway.example.com/api/payment-sessions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.PAYMENT_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
reference: order.id,
amount: order.amount,
currency: order.currency,
returnUrl: `https://example.com/payment/return?orderId=${encodeURIComponent(order.id)}`,
cancelUrl: `https://example.com/payment/cancel?orderId=${encodeURIComponent(order.id)}`,
webhookUrl: 'https://api.example.com/webhooks/payment'
})
}
);
if (!gatewayResponse.ok) {
throw new Error(`Gateway returned ${gatewayResponse.status}`);
}
const payment = await gatewayResponse.json();
await saveGatewayPaymentId(order.id, payment.id);
res.json({
checkoutUrl: payment.checkoutUrl
});
} catch (error) {
next(error);
}
});
只有网关明确提供这类 API,才能假设响应中包含 id 或 checkoutUrl。
方案二:网关只接受 HTML form POST
如果网关文档要求浏览器向 checkout 地址提交表单,就应继续使用标准 form POST。跨域页面导航不受 AJAX CORS 限制。
后端可以先创建订单并计算签名,再返回一个自动提交的 HTML 页面:
app.post('/api/payments/start', async (req, res, next) => {
try {
const order = await createPendingOrder(req.user.id, req.body.productId);
const signature = createGatewaySignature(order);
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://test-wallet.example.com/checkout/">
<input type="hidden" name="order_id" value="${escapeHtml(order.id)}">
<input type="hidden" name="amount" value="${escapeHtml(order.amount)}">
<input type="hidden" name="currency" value="${escapeHtml(order.currency)}">
<input type="hidden" name="signature" value="${escapeHtml(signature)}">
<input type="hidden" name="return_url"
value="https://example.com/payment/return">
<input type="hidden" name="cancel_url"
value="https://example.com/payment/cancel">
</form>
<script>
document.getElementById('payment-form').submit();
</script>
</body>
</html>
`);
} catch (error) {
next(error);
}
});
这个接口需要通过普通 form 或页面导航访问。不能先用 Angular HttpClient 请求,再期待浏览器自动显示接口返回的页面。例如:
<form method="post" action="/api/payments/start">
<input type="hidden" name="productId" value="PRODUCT_ID">
<button type="submit">Pay with card</button>
</form>
隐藏字段名称、签名规则和回调地址都要采用支付网关规定的格式。API 密钥和签名密钥不能放进 Angular。
如何可靠接收支付结果
需要在支付网关后台或创建支付请求时配置 webhook。付款完成后,网关会从自己的服务器向你的后端发送请求:
app.post(
'/webhooks/payment',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const signature = req.get('X-Payment-Signature');
if (!verifyGatewaySignature(req.body, signature)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
if (event.type === 'payment.succeeded') {
await markOrderPaidIdempotently(
event.data.reference,
event.data.paymentId
);
} else if (event.type === 'payment.failed') {
await markOrderFailedIdempotently(event.data.reference);
}
res.sendStatus(200);
} catch (error) {
res.sendStatus(500);
}
}
);
X-Payment-Signature、事件结构和验签算法只是占位名称,必须换成网关实际使用的协议。部分网关发送 application/x-www-form-urlencoded 数据,此时也要调整解析方式。
很多验签算法需要原始请求体。因此,webhook 路由的 express.raw() 必须放在全局 express.json() 之前,或者单独为该路由保留 raw body。
返回页面应该做什么
用户回到 /payment/return 后,不能直接根据 URL 显示“付款成功”。返回地址中的参数可能被用户修改,而且用户可能在 webhook 到达前就已经返回。
Angular 应向后端查询订单状态:
this.http.get<{ status: string }>(
`/api/orders/${encodeURIComponent(orderId)}/payment-status`
).subscribe(({ status }) => {
if (status === 'paid') {
this.message = '支付成功';
} else if (status === 'failed') {
this.message = '支付失败';
} else {
this.message = '支付结果确认中,请稍候';
}
});
如果状态仍是 pending,可以短时间轮询,也可以让后端主动调用网关的支付查询 API。不能因为用户进入了 /success,就把订单标记为已支付。
关于 307 redirect
307 表示浏览器应在新地址继续使用原来的请求方法和请求体。它只适用于网关确实要求相同的 POST body,并且字段格式完全匹配的情况。
307 不能代替“创建支付会话”流程,也不会自动携带后端 fetch() 得到的 Cookie、token 或响应状态。尤其不要采用下面的流程:
- 后端先 POST 到网关。
fetch()跟随网关重定向。- 取出最终 URL。
- 再让浏览器以 POST 或 GET 访问最终 URL。
这种做法会让两个不同的客户端分别参与同一次支付流程,可能导致 token 已消费、Cookie 丢失或请求字段缺失。Option 4.2 出现 “Your request cannot be processed”,很可能就与这些问题有关,并非只是少了一次 FormData。
其他注意事项
- 金额、币种和商品价格必须由后端计算,不能信任 Angular 提交的金额。
- 每笔订单应使用唯一 reference。webhook 更新操作要具备幂等性,避免重复通知造成重复发货。
- 只有经过验签的 webhook 或网关查询 API 才能改变订单的最终支付状态。
- 不要在 URL 查询字符串中放置密钥、银行卡信息或其他敏感数据。
- 应对网关返回的跳转地址设置域名白名单,避免开放重定向漏洞。
- 用户可能关闭页面、遇到网络中断,或者一直没有返回网站。支付确认逻辑不能依赖浏览器返回。
- webhook 应使用公网可访问的 HTTPS 地址,并按网关要求及时返回成功状态码。
- 不要混用
async和subscribe()。Observable 可以直接订阅,也可以通过firstValueFrom()转成 Promise。
标准 form POST 可以保留,但支付结果必须通过 webhook 或后端支付查询接口获取。如果网关提供正式的“创建支付会话并返回 checkout URL”API,应优先使用该流程,不要继续围绕 Option 4.2 转发 fetch() 得到的最终 URL。
备注:内容仅供参考。
