如何检测 Iframe 支付完成后的 POST redirect
结论
不要根据 Iframe 的 load 次数判断支付是否完成。支付页面跳转、3-D Secure 验证、错误重试和资源刷新都可能触发 Iframe 加载,这些事件与最终支付状态没有稳定的对应关系。
可靠的实现需要两条独立链路:
- 支付结果链路:Payment provider 通过 webhook/server callback 通知 Backend。Backend 验签,并在必要时向 provider 查询支付状态,然后更新订单。这条链路是支付结果的权威来源。
- 页面通知链路:Iframe 中的
returnUrl页面通过window.parent.postMessage()告诉父页面支付流程已经返回。Frontend 收到消息后,再调用自己的 Backend 查询订单状态。
postMessage 只用于唤醒 Frontend,不能证明支付成功。最终结果必须以 Backend 保存并验证过的支付状态为准。
为什么不能监听 Iframe 加载次数
第三方支付页面通常与业务页面跨域。受浏览器同源策略限制,父页面无法可靠读取:
iframe.contentWindow.location.href
即使监听 load 事件,也只能知道 Iframe 加载了某个页面,无法判断:
- 当前是否仍在第三方支付页面;
- 是否进入了 3-D Secure 页面;
- 用户是否执行了刷新或返回操作;
- 支付是否已经成功;
- provider 的异步处理是否已经完成;
returnUrl是否因失败、取消或超时而触发。
因此,下面这类代码很容易误判:
iframeLoad += 1;
if (iframeLoad > 1) {
// 无法确定这次加载代表支付完成
}
推荐的数据流
完整的支付流程可以这样设计:
- Frontend 请求 Backend 创建支付会话。
- Backend 创建本地订单,生成不可预测的
paymentAttemptId,并将初始状态设为PENDING。 - Backend 调用 Payment provider 创建支付,同时传入:
returnUrlwebhookUrl- provider 支持的订单引用字段
- Backend 将支付页面 URL 和
paymentAttemptId返回给 Frontend。 - Frontend 在 Iframe 中打开支付页面。
- 用户完成支付流程后,provider:
- 将 Iframe POST 到
returnUrl; - 另行向 Backend 的
webhookUrl发送支付通知。
- 将 Iframe POST 到
- Backend 验证 webhook 签名,并在必要时通过 provider 的服务端 API 查询支付状态。
- Backend 以幂等方式更新本地订单。
returnUrl返回一个桥接页面,由该页面向父窗口发送postMessage。- 父页面收到消息后,向 Backend 查询本地订单状态。
- 如果订单仍为
PENDING,Frontend 可短暂轮询,直到获得终态或超时。
简化后的数据流如下:
Frontend ──创建支付──> Backend ──创建会话──> Payment provider
Frontend <──paymentPageUrl── Backend
Frontend iframe ───────────> Payment provider
Payment provider ──webhook──> Backend ──验签/查询──> Payment provider
Payment provider ──POST returnUrl──> Backend return controller
Backend return controller ──桥接页面──> iframe
iframe ──postMessage──> Parent Frontend
Parent Frontend ──查询状态──> Backend
Backend 的 returnUrl 处理
returnUrl 不负责直接判断支付是否成功。它的作用是结束 Iframe 中的交互流程,并通知父页面重新查询状态。
Backend 收到 provider 的 POST 后,可以返回一个很小的 HTML 页面:
app.post("/payments/return", async (req, res) => {
// 如果 provider 会对 return POST 签名,应在这里验证。
// 不要因为 req.body 中出现 "success" 就直接把订单标记为已支付。
const paymentAttemptId = resolvePaymentAttemptId(req.body);
res
.status(200)
.type("html")
.send(`
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Payment processing</title>
</head>
<body>
<p>Payment is being confirmed…</p>
<script>
window.parent.postMessage(
{
type: "PAYMENT_FLOW_RETURNED",
paymentAttemptId: ${JSON.stringify(paymentAttemptId)}
},
"https://checkout.example.com"
);
</script>
</body>
</html>
`);
});
必须将 "https://checkout.example.com" 替换为父页面真实且固定的 origin,不能使用 "*"。
如果不想让 Backend 拼接 HTML,也可以让 POST controller 使用 303 See Other 跳转到同源的桥接页面:
app.post("/payments/return", (req, res) => {
const paymentAttemptId = resolvePaymentAttemptId(req.body);
res.redirect(
303,
`/payment/iframe-return?attempt=${encodeURIComponent(paymentAttemptId)}`
);
});
桥接页面再调用 window.parent.postMessage()。303 会明确要求浏览器随后使用 GET 请求打开桥接页面,从而避免重复提交 provider 的 POST。
React 父页面监听消息
父页面需要同时验证 origin、消息结构和当前支付尝试的标识:
import { useEffect, useState } from "react";
type PaymentStatus =
| "PENDING"
| "SUCCEEDED"
| "FAILED"
| "CANCELLED"
| "EXPIRED";
type PaymentMessage = {
type: "PAYMENT_FLOW_RETURNED";
paymentAttemptId: string;
};
export function PaymentFrame({
paymentPageUrl,
paymentAttemptId,
}: {
paymentPageUrl: string;
paymentAttemptId: string;
}) {
const [status, setStatus] = useState<PaymentStatus>("PENDING");
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== "https://checkout.example.com") {
return;
}
const message = event.data as Partial<PaymentMessage>;
if (
message.type !== "PAYMENT_FLOW_RETURNED" ||
message.paymentAttemptId !== paymentAttemptId
) {
return;
}
const finalStatus = await waitForPaymentStatus(paymentAttemptId);
setStatus(finalStatus);
};
window.addEventListener("message", handleMessage);
return () => {
window.removeEventListener("message", handleMessage);
};
}, [paymentAttemptId]);
if (status === "SUCCEEDED") {
return <ThankYouPage />;
}
if (["FAILED", "CANCELLED", "EXPIRED"].includes(status)) {
return <PaymentError status={status} />;
}
return (
<iframe
src={paymentPageUrl}
title="Secure payment"
allow="payment"
/>
);
}
示例中的消息由自己的桥接页面发出,因此 event.origin 应是桥接页面所在的 origin,而不是第三方支付页面的 origin。
如果要进一步确认消息确实来自指定的 Iframe,还可以验证 event.source:
const iframeRef = useRef<HTMLIFrameElement>(null);
if (event.source !== iframeRef.current?.contentWindow) {
return;
}
然后将引用设置到 Iframe:
<iframe
ref={iframeRef}
src={paymentPageUrl}
title="Secure payment"
allow="payment"
/>
查询和短暂轮询支付状态
Webhook 和浏览器回跳之间可能出现竞态。Iframe 可能已经到达 returnUrl,但 webhook 还没有处理完,因此第一次查询返回 PENDING 很正常。
可以进行次数有限的轮询:
type PaymentStatus =
| "PENDING"
| "SUCCEEDED"
| "FAILED"
| "CANCELLED"
| "EXPIRED";
const TERMINAL_STATUSES: PaymentStatus[] = [
"SUCCEEDED",
"FAILED",
"CANCELLED",
"EXPIRED",
];
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
async function getPaymentStatus(
paymentAttemptId: string,
): Promise<PaymentStatus> {
const response = await fetch(
`/api/payment-attempts/${encodeURIComponent(paymentAttemptId)}`,
{
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
},
},
);
if (!response.ok) {
throw new Error(`Payment status request failed: ${response.status}`);
}
const result = (await response.json()) as { status: PaymentStatus };
return result.status;
}
async function waitForPaymentStatus(
paymentAttemptId: string,
): Promise<PaymentStatus> {
const delays = [0, 500, 1000, 2000, 3000, 5000];
for (const waitTime of delays) {
if (waitTime > 0) {
await delay(waitTime);
}
const status = await getPaymentStatus(paymentAttemptId);
if (TERMINAL_STATUSES.includes(status)) {
return status;
}
}
return "PENDING";
}
轮询超时后,不要直接显示“支付失败”。更合适的提示是“支付结果仍在确认中”,同时允许用户稍后刷新页面,或前往订单页查看。
如果系统已经使用 WebSocket 或 Server-Sent Events,也可以由 Backend 主动推送状态变化,但仍要保留状态查询接口,以便在连接中断或页面恢复时重新获取状态。
Backend 如何确认支付结果
Backend 不应根据以下信息确认付款:
- Frontend 发送的“支付成功”参数;
postMessage携带的状态;- 未验签的
returnUrlPOST body; - URL query string 中的
success=true; - 用户到达 Thank you 页面这一事实。
更可靠的处理方式如下:
app.post("/payments/webhook", async (req, res) => {
const signature = req.get("Payment-Signature");
if (!verifyProviderSignature(req.rawBody, signature)) {
return res.sendStatus(401);
}
const event = parseProviderEvent(req.rawBody);
// 具体是否必须再次查询 provider API,取决于 provider 的协议。
const remotePayment = await paymentProvider.getPayment(event.paymentId);
await updatePaymentAttemptIdempotently({
providerPaymentId: remotePayment.id,
status: mapProviderStatus(remotePayment.status),
amount: remotePayment.amount,
currency: remotePayment.currency,
});
return res.sendStatus(200);
});
签名字段、原始请求体要求和状态名称因 Payment provider 而异,必须以对应 provider 的接口文档为准。
更新订单前,至少需要核对:
- provider payment ID;
- 本地订单或支付尝试 ID;
- 商户账户;
- 金额;
- 币种;
- 支付状态;
- webhook 签名;
- webhook event ID 是否已经处理。
数据库更新必须具备幂等性,因为 provider 可能重复发送 webhook。同一个事件即使被处理多次,也不能导致重复发货、重复记账或重复发放权益。
状态模型建议
不要只保存一个简单的 paid: true/false。支付流程通常至少需要以下状态:
PENDING
SUCCEEDED
FAILED
CANCELLED
EXPIRED
根据 provider 的支付模型,还可能需要 AUTHORIZED、CAPTURED、REFUNDED 等状态。
Frontend 只能通过经过身份验证的 Backend 接口查询状态。Backend 还要确认当前登录用户有权访问对应订单,不能因为请求方知道 paymentAttemptId 就允许其查询任意订单。
页面刷新和消息丢失的处理
postMessage 是一次性通知。如果父页面正在刷新、浏览器暂停了标签页,或者监听器还没有注册,这条消息就可能丢失。因此,不能将它作为查询状态的唯一触发方式。
父页面还应在以下时机查询一次状态:
- 支付组件首次挂载;
- 页面重新获得焦点;
- 页面从后台恢复;
- 用户点击“我已完成支付”;
- 收到
postMessage。
例如:
useEffect(() => {
const refreshStatus = async () => {
setStatus(await getPaymentStatus(paymentAttemptId));
};
void refreshStatus();
const handleFocus = () => {
void refreshStatus();
};
window.addEventListener("focus", handleFocus);
return () => {
window.removeEventListener("focus", handleFocus);
};
}, [paymentAttemptId]);
必须确认 provider 是否真正支持 Iframe
支付页面能够在 Iframe 中显示,不代表完整的支付流程也支持这种方式。接入前需要确认 provider 明确支持嵌入式支付,并核实:
- 支付页面是否允许被目标站点嵌入;
Content-Security-Policy: frame-ancestors是否允许;X-Frame-Options是否阻止嵌入;- 3-D Secure 或银行验证页面是否能在 Iframe 中运行;
- 第三方 Cookie、Storage Access 和浏览器隐私策略是否会影响会话;
- 手机银行或支付 App 跳转后能否正常返回;
- provider 是否提供官方 JavaScript SDK、Hosted Fields 或弹窗模式。
如果 provider 没有正式支持 Iframe,或者认证流程需要顶层页面跳转,就应继续使用完整页面 redirect,或改用 provider 官方提供的嵌入式组件。支付页面处于高风险边界,不能只为了让视觉体验更连贯,就自行将它嵌入 Iframe。
最终原则是:由 webhook 或服务端查询决定支付状态,用 postMessage 协调页面退出 Iframe,Frontend 查询 Backend 后再渲染结果页面。
备注:内容仅供参考。
