PAYATHON 2026

如何避免 Chase PaymentGateway iframe 重复支付?

支付小周

结论

要避免 Chase PaymentGateway iframe 引发重复支付,不能只靠前端禁用按钮。比较可靠的做法是:

  1. 用户首次提交后,前端立即锁定按钮和页面,防止连续点击。
  2. 服务端为每次业务支付生成唯一的 payment_attempt_id,再通过数据库唯一约束和事务拦截重复请求。
  3. 调用 Chase 支付接口时复用订单号或幂等键。具体使用哪个字段,取决于 Chase 产品和接口版本,应以相应文档为准。
  4. 支付结果尚未确认时,让订单保持 PROCESSING 状态。不要因为请求超时、用户返回上一页或刷新页面而创建第二笔支付。
  5. 通过服务端查询或网关回调确认最终状态,不能只看 iframe 是否完成页面跳转。

前端防重复提交只能减少误操作。要真正防止重复扣款,仍需由服务端的幂等机制兜底。

为什么会产生重复支付

iframe 响应较慢时,用户无法判断提交是否成功,很可能再次点击 Submit。如果每次点击都会创建新的支付请求,网关就可能将它们视为两笔独立交易。

用户点击浏览器的 Back 按钮后再次提交,也会产生同样的问题。页面恢复了旧表单,但服务端没有识别出该订单已经提交或仍在处理中。

请求超时也不等于支付失败。有时只是应用没有及时收到响应,而网关已经完成扣款。如果此时直接重试,就可能造成重复支付。

前端立即阻止重复提交

如果 Submit 按钮在自己的页面中,可以在第一次提交时立刻禁用按钮,并向用户显示明确的处理中状态。

<form id="payment-form">
  <button id="submit-payment" type="submit">Submit</button>
  <p id="payment-status" role="status" aria-live="polite"></p>
</form>

<script>
  const form = document.getElementById("payment-form");
  const submitButton = document.getElementById("submit-payment");
  const statusElement = document.getElementById("payment-status");

  let submitting = false;

  form.addEventListener("submit", (event) => {
    if (submitting) {
      event.preventDefault();
      return;
    }

    submitting = true;
    submitButton.disabled = true;
    submitButton.textContent = "Processing...";
    statusElement.textContent =
      "Payment is being processed. Do not refresh or go back.";
  });
</script>

如果按钮在 Chase 托管的跨域 iframe 中,父页面通常无法直接访问或修改 iframe 内的 DOM,这是浏览器同源策略的限制。此时应优先使用 Chase 集成接口提供的事件或回调。如果接口没有这类能力,可以在用户进入支付流程后锁定父页面的操作入口,但服务端仍需做好幂等控制。

还要处理浏览器从 Back/Forward Cache 恢复旧页面的情况,避免页面重新变为可提交状态:

window.addEventListener("pageshow", (event) => {
  if (event.persisted) {
    window.location.reload();
  }
});

页面重新加载后,应向服务端查询订单的真实支付状态,而不是直接显示一个新的空白支付表单。

服务端实现支付幂等

每个业务订单在同一时间只应有一个有效的支付尝试。客户端提交时,需要携带服务端生成的 payment_attempt_id,服务端则在事务中检查该支付尝试的状态。

下面是一个简化的 Node.js 示例,主要展示处理逻辑,不依赖特定框架:

app.post("/api/payments/submit", async (req, res) => {
  const { orderId, paymentAttemptId } = req.body;

  const result = await db.transaction(async (tx) => {
    const payment = await tx.queryOne(
      `
      SELECT *
      FROM payments
      WHERE order_id = ? AND payment_attempt_id = ?
      FOR UPDATE
      `,
      [orderId, paymentAttemptId]
    );

    if (!payment) {
      return { status: 404, body: { error: "Payment attempt not found" } };
    }

    if (payment.status === "SUCCEEDED") {
      return {
        status: 200,
        body: {
          status: "SUCCEEDED",
          transactionId: payment.gateway_transaction_id
        }
      };
    }

    if (payment.status === "PROCESSING") {
      return {
        status: 202,
        body: { status: "PROCESSING" }
      };
    }

    if (payment.status !== "CREATED") {
      return {
        status: 409,
        body: { error: "Payment attempt cannot be submitted" }
      };
    }

    await tx.execute(
      `
      UPDATE payments
      SET status = 'PROCESSING', updated_at = CURRENT_TIMESTAMP
      WHERE id = ?
      `,
      [payment.id]
    );

    return {
      status: 201,
      body: {
        status: "READY",
        paymentId: payment.id
      }
    };
  });

  if (result.body.status !== "READY") {
    return res.status(result.status).json(result.body);
  }

  // 在事务外调用支付网关,避免长时间占用数据库锁。
  // gatewayOrderId 必须稳定,重试时不能重新生成。
  const gatewayResponse = await submitToChase({
    gatewayOrderId: orderId,
    paymentAttemptId
  });

  await saveGatewayResult(result.body.paymentId, gatewayResponse);

  return res.status(200).json({
    status: gatewayResponse.status
  });
});

数据库中还应添加唯一约束,避免并发请求绕过应用层检查:

ALTER TABLE payments
ADD CONSTRAINT uq_payment_attempt
UNIQUE (payment_attempt_id);

如果业务规定每个订单只能有一笔支付,还需要为 order_id 设计相应约束。但在退款后重新支付、支付失败后换卡重试等场景中,通常需要保留多次支付尝试,因此可以采用下面的模型:

  • 一个 order_id 可以关联多个历史支付尝试。
  • 同一时间只能存在一个状态为 CREATED 或 PROCESSING 的有效尝试。
  • 每个支付尝试都有唯一且不可变的 payment_attempt_id。
  • 订单支付成功后,不得再次创建支付尝试,除非业务明确支持追加付款。

对于”同一时间只能存在一个有效尝试”这条规则,可以通过数据库事务、条件唯一索引或分布式锁实现。具体方案取决于数据库类型和部署架构。

使用网关支持的幂等或重复交易检查

如果当前接入的 Chase PaymentGateway 接口支持以下机制,应优先启用:

  • Idempotency-Key
  • 唯一 merchant reference
  • 唯一 order ID
  • duplicate transaction checking
  • 重复交易时间窗口

不同 Chase 支付产品、接入渠道和接口版本使用的字段名称可能不同,不能默认接口一定支持名为 Idempotency-Key 的请求头。需要确认当前接口能否保证:使用同一标识重试时返回原交易结果,而不是再次扣款。

重试时必须复用原来的标识:

const request = {
  orderId: "ORDER-20260901-10001",
  paymentAttemptId: "PAY-7f36d47e"
};

// 网络超时后重试时仍使用相同的 orderId 和 paymentAttemptId。
await submitToChase(request);

不要在用户重复点击、刷新页面或超时重试时重新生成订单号,否则网关无法识别这些请求属于同一次支付。

正确处理超时和未知状态

网关请求超时后,不要立刻把支付状态改为 FAILED,也不要马上创建一笔新交易。此时应将状态记为 UNKNOWN,或者继续保留为 PROCESSING,然后通过以下方式确认结果:

  1. 使用原订单号或交易参考号查询 Chase。
  2. 等待服务端回调或通知。
  3. 通过定时任务进行对账。
  4. 确认没有成功交易后,再允许创建新的支付尝试。

状态可以按下面的方式流转:

CREATED -> PROCESSING -> SUCCEEDED
                      -> FAILED
                      -> UNKNOWN -> SUCCEEDED
                                 -> FAILED

只有收到明确且允许安全重试的失败结果后,才能开放重新支付。请求超时、连接中断或用户关闭页面时,应默认将支付结果视为未知。

Back 按钮和刷新页面的处理

每次加载支付页时,都要先查询服务端状态:

  • SUCCEEDED:直接显示支付成功,不再渲染 iframe。
  • PROCESSING 或 UNKNOWN:显示”正在确认支付”,并轮询状态。
  • CREATED:允许用户继续当前支付尝试。
  • FAILED:根据网关返回的失败类型,判断是否允许重新支付。

支付完成后,可以使用 Post/Redirect/Get 模式跳转到结果页:

POST /payments/submit
        |
        v
303 See Other
Location: /orders/123/payment-result

结果页只展示支付状态,不能再次执行扣款。这样即使用户刷新结果页,也不会重新提交原支付请求。

回调也必须幂等

支付网关可能重复发送回调,所以回调处理也要保证幂等。可以为网关交易 ID 添加唯一约束:

ALTER TABLE gateway_transactions
ADD CONSTRAINT uq_gateway_transaction
UNIQUE (gateway_transaction_id);

处理回调时,应先验证签名,再检查该交易是否已经写入数据库。如果回调已经处理过,仍应返回成功响应,但不能再次更新库存、发货、积分或发送通知。

async function handlePaymentCallback(callback) {
  verifyCallbackSignature(callback);

  await db.transaction(async (tx) => {
    const existing = await tx.queryOne(
      `
      SELECT id
      FROM gateway_transactions
      WHERE gateway_transaction_id = ?
      `,
      [callback.transactionId]
    );

    if (existing) {
      return;
    }

    await tx.execute(
      `
      INSERT INTO gateway_transactions (
        gateway_transaction_id,
        order_id,
        status
      ) VALUES (?, ?, ?)
      `,
      [
        callback.transactionId,
        callback.orderId,
        callback.status
      ]
    );

    await tx.execute(
      `
      UPDATE payments
      SET status = ?
      WHERE order_id = ?
        AND status IN ('PROCESSING', 'UNKNOWN')
      `,
      [callback.status, callback.orderId]
    );
  });
}

注意事项

禁用按钮、显示加载动画和拦截 Back 按钮只能减少误操作。用户仍可能通过刷新页面、打开多个标签页、发送脚本请求或网络重试触发并发提交。

iframe 加载完成、前端跳转成功或浏览器收到响应,都不能直接视为支付成功。最终状态必须以服务端验证后的网关结果为准。

如果已经发生重复扣款,还需要建立检测和处置机制。可以根据订单号、金额、支付账户和短时间窗口识别疑似重复交易,再进入人工复核或退款流程。是否适合自动退款,取决于业务规则和 Chase 接口能力,不能只因前端发生重复点击就直接退款。

完整的防护方案包括前端一次性提交、稳定的支付尝试标识、服务端事务和唯一约束、网关幂等、未知状态查询,以及幂等的回调处理。这样即使 iframe 响应很慢或用户反复操作,请求也只会推进同一笔支付,不会产生新的扣款。

备注:内容仅供参考。