如何在 ExtJs 单页应用中集成 payment gateway?
结论
使用独立的 browser pop-up window 完成支付跳转是可行的,也能保留 ExtJs 主页面的运行状态。不过,弹窗不能成为唯一方案。浏览器可能拦截弹窗,支付网关也可能出于安全策略切断 window.opener,用户还可能手动关闭弹窗,或者在移动端切换页面。
建议采用以下流程:
- 后端创建支付订单并返回支付地址。
- 用户点击支付按钮时,主页面立即打开一个空白窗口,以免被浏览器拦截。
- 用户在弹窗中完成支付。
- 支付网关跳转到同源返回页面后,该页面通过
postMessage通知 ExtJs 主页面。 - 主页面向后端查询订单状态。
- 后端以支付网关的服务端通知或主动查询结果作为最终依据。
采用这种设计,不需要序列化整个 ExtJs 应用状态。即使弹窗通信失败,主页面仍可轮询订单状态。若弹窗不可用,则降级为在当前窗口中跳转,并只保存必要的业务上下文。
推荐的支付流程
用户点击支付按钮后,先同步执行:
var paymentWindow = window.open(
'about:blank',
'payment_gateway',
'width=900,height=720,resizable=yes,scrollbars=yes'
);
接着调用后端接口创建订单:
Ext.Ajax.request({
url: '/api/payments',
method: 'POST',
jsonData: {
productId: selectedProductId
},
success: function (response) {
var result = Ext.decode(response.responseText);
if (!paymentWindow || paymentWindow.closed) {
handlePopupUnavailable(result);
return;
}
paymentWindow.location.replace(result.paymentUrl);
watchPayment(result.orderId, paymentWindow);
},
failure: function () {
if (paymentWindow && !paymentWindow.closed) {
paymentWindow.close();
}
Ext.Msg.alert('支付失败', '暂时无法创建支付订单,请稍后重试。');
}
});
空白窗口必须先于 Ajax 请求打开。如果等异步请求完成后才调用 window.open(),浏览器通常不会把它视为用户点击直接触发的操作,因而可能拦截弹窗。
paymentUrl 应由后端生成。签名密钥、商户密钥和支付网关凭据不能出现在 ExtJs 代码中,支付参数也不应由浏览器自行拼接。
支付返回页面
提交给支付网关的 return_url 可以指向一个轻量的同源页面,例如:
https://app.example.com/payment/return
这个页面不需要重新启动完整的 ExtJs 应用,只负责通知原窗口并显示处理结果:
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>支付处理中</title>
</head>
<body>
<p id="message">正在确认支付结果……</p>
<script>
(function () {
var params = new URLSearchParams(window.location.search);
var orderId = params.get('orderId');
if (window.opener && !window.opener.closed) {
window.opener.postMessage(
{
type: 'PAYMENT_RETURNED',
orderId: orderId
},
'https://app.example.com'
);
}
document.getElementById('message').textContent =
'支付结果正在确认,可以关闭此窗口。';
setTimeout(function () {
window.close();
}, 1000);
})();
</script>
</body>
</html>
主应用监听消息:
window.addEventListener('message', function (event) {
if (event.origin !== 'https://app.example.com') {
return;
}
var message = event.data;
if (!message || message.type !== 'PAYMENT_RETURNED') {
return;
}
refreshPaymentStatus(message.orderId);
});
不要使用:
window.opener.someGlobalFunction(...)
这种方式在同源条件下可能有效,但会让返回页面直接依赖主应用的全局变量和加载状态。postMessage 的通信边界更清楚,还可以严格验证 event.origin。
后端必须确认支付结果
浏览器返回只能说明用户进入了返回页面,不能证明订单已经付款。查询参数也可能被修改,因此前端不应仅根据下面这类地址把订单标记为成功:
/payment/return?status=success&orderId=123
支付网关应向后端发送 webhook,或者由后端调用网关提供的查询 API 核实订单状态。下面是一个简化示例:
app.post('/payment/webhook', rawBodyMiddleware, async function (req, res) {
if (!verifyGatewaySignature(req)) {
return res.sendStatus(401);
}
const notification = parseGatewayNotification(req);
await updateOrderIdempotently(
notification.orderId,
notification.transactionId,
notification.status
);
res.sendStatus(200);
});
ExtJs 主页面只查询自己的后端:
function refreshPaymentStatus(orderId) {
Ext.Ajax.request({
url: '/api/payments/' + encodeURIComponent(orderId),
method: 'GET',
success: function (response) {
var payment = Ext.decode(response.responseText);
if (payment.status === 'PAID') {
Ext.Msg.alert('支付成功', '订单已完成支付。');
reloadOrderData(orderId);
} else if (payment.status === 'FAILED') {
Ext.Msg.alert('支付失败', '支付未完成,请重新尝试。');
} else {
Ext.Msg.alert('支付处理中', '银行尚未返回最终结果,请稍后查看。');
}
}
});
}
支付网关可能重复发送通知,因此订单更新必须具备幂等性。后端还要校验商户号、订单号、金额、币种和网关交易号,不能只检查签名或状态字段。
处理弹窗关闭和通信失败
用户可能关闭弹窗。返回页面也可能因为网络、网关流程或安全响应头而无法访问 window.opener。主页面应通过订单状态查询来应对这些情况:
function watchPayment(orderId, paymentWindow) {
var attempts = 0;
var maxAttempts = 60;
var timer = setInterval(function () {
attempts += 1;
if (attempts >= maxAttempts) {
clearInterval(timer);
return;
}
checkPaymentStatus(orderId, function (status) {
if (status === 'PAID' || status === 'FAILED') {
clearInterval(timer);
if (paymentWindow && !paymentWindow.closed) {
paymentWindow.close();
}
refreshPaymentStatus(orderId);
return;
}
if (paymentWindow && paymentWindow.closed) {
// 弹窗关闭不代表支付失败,继续查询一段时间。
}
});
}, 3000);
}
轮询间隔和持续时间需要根据支付网关的实际响应速度以及后端承载能力来设置。较复杂的应用也可以使用 WebSocket 或 Server-Sent Events,在后端收到 webhook 后通知主页面。
弹窗不可用时的降级方案
如果弹窗被拦截,可以提示用户允许弹窗,也可以改为在当前页面中跳转:
function handlePopupUnavailable(result) {
sessionStorage.setItem('paymentContext', JSON.stringify({
orderId: result.orderId,
returnRoute: getCurrentApplicationRoute()
}));
window.location.assign(result.paymentUrl);
}
没有必要保存所有 ExtJs windows、组件实例和 JavaScript variables。只需保存能够重新构建业务界面的最小状态,例如:
- 当前路由或功能模块;
- 正在查看的记录 ID;
- 支付订单 ID;
- 必要的筛选条件;
- 尚未提交的表单草稿。
用户返回应用后,再根据这些业务数据重新加载页面。组件实例、Ajax 响应对象、函数和临时运行变量不适合直接持久化,也很难可靠地恢复。
安全和兼容性注意事项
弹窗本身不会决定支付流程是否安全,安全性主要取决于支付协议和服务端实现。实现时需要注意以下几点:
- 支付地址必须使用 HTTPS。
- 订单金额应由后端根据商品和订单数据计算,不能相信前端提交的金额。
- 支付密钥及签名操作必须保留在后端。
postMessage必须指定明确的目标 origin,并校验event.origin,不要使用"*"。- 不要把访问令牌、银行卡信息或敏感交易资料放进 URL。
- 使用不可预测的订单号,并校验订单属于当前登录用户。
- webhook 必须验证签名,并对重复通知进行幂等处理。
- 不要因为用户关闭弹窗就把订单判定为失败。
- 不要把银行返回页面显示的结果作为支付成功的唯一依据。
- 某些支付网关可能要求在顶层页面中跳转,也可能在 3-D Secure、移动银行 App 拉起等环节改变窗口行为,具体实现应遵循该网关当前的接入规范。
Cross-Origin-Opener-Policy等响应头可能导致window.opener不可用,因此必须保留后端状态查询机制。
总体而言,Option 2 可以作为主要的交互方式,但不能假设弹窗始终存在,也不能假设它一定能通知主页面。支付订单应由后端管理,并以 webhook 或网关查询结果为准。弹窗、postMessage 和轮询用于改善用户体验,这样既能保留 ExtJs 单页状态,也能保证支付流程的可靠性。
备注:内容仅供参考。
