PAYATHON 2026

如何检测 Iframe 支付完成后的 POST redirect

支付小周

结论

不要根据 Iframe 的 load 次数判断支付是否完成。支付页面跳转、3-D Secure 验证、错误重试和资源刷新都可能触发 Iframe 加载,这些事件与最终支付状态没有稳定的对应关系。

可靠的实现需要两条独立链路:

  1. 支付结果链路:Payment provider 通过 webhook/server callback 通知 Backend。Backend 验签,并在必要时向 provider 查询支付状态,然后更新订单。这条链路是支付结果的权威来源。
  2. 页面通知链路: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) {
  // 无法确定这次加载代表支付完成
}

推荐的数据流

完整的支付流程可以这样设计:

  1. Frontend 请求 Backend 创建支付会话。
  2. Backend 创建本地订单,生成不可预测的 paymentAttemptId,并将初始状态设为 PENDING。
  3. Backend 调用 Payment provider 创建支付,同时传入:
    • returnUrl
    • webhookUrl
    • provider 支持的订单引用字段
  4. Backend 将支付页面 URL 和 paymentAttemptId 返回给 Frontend。
  5. Frontend 在 Iframe 中打开支付页面。
  6. 用户完成支付流程后,provider:
    • 将 Iframe POST 到 returnUrl;
    • 另行向 Backend 的 webhookUrl 发送支付通知。
  7. Backend 验证 webhook 签名,并在必要时通过 provider 的服务端 API 查询支付状态。
  8. Backend 以幂等方式更新本地订单。
  9. returnUrl 返回一个桥接页面,由该页面向父窗口发送 postMessage。
  10. 父页面收到消息后,向 Backend 查询本地订单状态。
  11. 如果订单仍为 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 携带的状态;
  • 未验签的 returnUrl POST 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 后再渲染结果页面。

备注:内容仅供参考。