PAYATHON 2026

支付网关 checkout 后如何处理 browser history

支付阿杰

结论

当前页面无法删除浏览器历史记录中属于支付网关的某一条记录。浏览器只允许页面修改当前历史条目,或者新增同源历史条目,不能随意查看、删除或改写跨域页面留下的记录。

支付流程应这样处理:

  1. 支付结果必须由后端确认,不能依赖浏览器返回的参数。
  2. 从支付网关返回后,使用 location.replace() 或服务端重定向进入支付状态页,避免中间页反复出现在历史记录中。
  3. 将“处理支付回调”和“向用户展示结果”拆分为两个 URL。
  4. 用户按返回键再次进入支付网关时,系统仍要通过幂等校验返回最新状态,不能重复处理,也不能展示旧状态。
  5. 如果业务上需要尽量减少用户返回支付网关的情况,可以调整支付流程,例如使用新窗口、弹窗或网关提供的嵌入式组件。不过,这些方案也无法保证完全控制浏览器历史。

为什么不能删除支付网关的历史记录

浏览器历史记录并不是网页能够随意操作的列表。JavaScript 的 History API 主要提供以下能力:

history.pushState(...)
history.replaceState(...)
history.back()
history.forward()
history.go(...)

具体来说:

  • history.pushState() 可以新增当前站点的历史条目。
  • history.replaceState() 可以替换当前历史条目。
  • history.back() 和 history.go() 可以让浏览器在历史记录中移动。
  • 页面无法枚举完整的历史记录。
  • 页面无法根据 URL 找到并删除某条历史记录。
  • 页面无法改写支付网关等跨域页面留下的历史条目。

这些限制是出于安全和隐私考虑。否则,任意网站都可能读取或篡改用户访问其他网站时留下的记录。

所以,浏览器并不存在下面这样的 API:

// 不存在这样的浏览器 API
history.remove("https://payment-gateway.example/checkout");

即使调用 history.replaceState(),它替换的也只是当前页面,而不是前一条支付网关记录:

history.replaceState(null, "", "/payment/result");

这个方法可以清理当前地址中的查询参数,但无法删除上一页。

推荐的支付流程

可以把支付流程分成几个独立环节:

商户结账页
  → 支付网关
  → 支付返回处理地址
  → 稳定的支付结果页

例如:

/checkout
https://gateway.example/pay/...
/payment/return?payment_id=123&token=...
/payment/result/123

/payment/return 只接收支付网关返回的信息并校验必要参数,随后立即跳转到稳定的结果页,不直接展示最终支付结果。

服务端处理示例

下面用 Express 展示这一流程。实际采用哪种校验方式,应以支付网关的接口规范为准:

app.get("/payment/return", async (req, res) => {
  const paymentId = req.query.payment_id;

  if (!paymentId) {
    return res.redirect(303, "/payment/error");
  }

  // 不要仅相信浏览器携带的 status。
  // 应查询本地订单,并在必要时向支付网关核实。
  const payment = await getPaymentFromDatabase(paymentId);

  if (!payment) {
    return res.redirect(303, "/payment/error");
  }

  return res.redirect(
    303,
    `/payment/result/${encodeURIComponent(paymentId)}`
  );
});

结果页再从后端读取当前状态:

app.get("/payment/result/:paymentId", async (req, res) => {
  const payment = await getPaymentFromDatabase(req.params.paymentId);

  if (!payment) {
    return res.status(404).send("Payment not found");
  }

  return res.render("payment-result", {
    paymentId: payment.id,
    status: payment.status
  });
});

这样,即使用户反复进入返回地址,页面也不会再次消费旧的支付结果,而是根据订单编号读取当前状态。

使用 location.replace() 避免增加中间页面记录

如果从支付网关返回后必须先进入一个前端页面,可以通过 location.replace() 跳转到最终结果页:

<script>
  const paymentId = new URLSearchParams(location.search).get("payment_id");

  if (paymentId) {
    location.replace(
      `/payment/result/${encodeURIComponent(paymentId)}`
    );
  } else {
    location.replace("/payment/error");
  }
</script>

location.replace() 会替换当前历史条目。因此,用户按返回键时不会再次回到这个中间返回页。

不过,它仍然无法删除更早的支付网关条目。最终的历史记录可能是:

结账页 → 支付网关 → 结果页

而不是:

结账页 → 支付网关 → 返回处理页 → 结果页

这可以减少一层循环风险,但不能彻底阻止用户返回支付网关。

清理结果页 URL 中的敏感参数

如果支付网关把交易编号、签名或状态放在查询字符串中,处理完成后可以替换当前地址:

history.replaceState(
  { paymentId: "123" },
  "",
  "/payment/result/123"
);

也可以直接换成不含查询参数的地址:

history.replaceState(null, "", "/payment/result");

这种方式适合清理当前结果页的地址,避免用户复制或刷新带有一次性参数的 URL。但它不能算作安全措施。敏感信息原则上不应长期保存在前端 URL 中,支付结果也仍然需要由服务端验证。

防止旧状态和重复处理

浏览器历史无法被完全控制,所以后端需要把重复访问当作正常情况。支付流程至少要满足以下要求。

使用幂等处理

同一条支付通知、返回请求或状态查询执行多次时,不能重复扣款、重复发货或重复记账。

async function markPaymentSucceeded(paymentId, gatewayTransactionId) {
  return database.transaction(async (tx) => {
    const payment = await tx.getPaymentForUpdate(paymentId);

    if (payment.status === "paid") {
      return payment;
    }

    await tx.updatePayment(paymentId, {
      status: "paid",
      gatewayTransactionId
    });

    return tx.getPayment(paymentId);
  });
}

数据库层面最好也为支付网关的交易编号设置唯一约束,防止并发请求产生重复记录。

不信任浏览器传回的支付状态

下面这种处理方式并不安全:

const paid = req.query.status === "success";

用户可以修改查询参数,浏览器也可能重新提交旧参数。最终支付状态应来自以下来源之一:

  • 通过支付网关签名验证的服务器通知。
  • 商户服务器主动调用支付网关查询接口得到的结果。
  • 经过可靠校验并写入数据库的订单状态。

支付网关的浏览器返回页更适合改善用户体验,不应成为确认支付成功的唯一依据。

让结果页可重复访问

无论用户刷新、返回还是重新打开标签页,结果页都应读取同一笔订单的最新状态:

async function loadPaymentStatus(paymentId) {
  const response = await fetch(
    `/api/payments/${encodeURIComponent(paymentId)}`,
    {
      credentials: "same-origin",
      cache: "no-store"
    }
  );

  if (!response.ok) {
    throw new Error("Unable to load payment status");
  }

  return response.json();
}

如果支付通知还没有到达,可以暂时显示“正在确认”,并进行有限次数的轮询:

async function waitForFinalStatus(paymentId) {
  for (let attempt = 0; attempt < 10; attempt += 1) {
    const payment = await loadPaymentStatus(paymentId);

    if (["paid", "failed", "cancelled"].includes(payment.status)) {
      return payment;
    }

    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  return { status: "pending" };
}

轮询次数和间隔要根据支付网关的通知延迟、接口限流和业务要求来确定,不能无限请求。

pageshow 与浏览器缓存问题

有时页面出现“旧状态”,并不是支付网关再次返回了旧数据,而是浏览器通过 back-forward cache 恢复了之前保存的页面快照。支付结果页可以监听 pageshow 事件,并在页面从缓存恢复时重新获取状态:

window.addEventListener("pageshow", async (event) => {
  if (event.persisted) {
    await refreshPaymentStatus();
  }
});

更稳妥的处理方式是,无论页面是否来自缓存,只要结果页恢复显示就查询一次:

window.addEventListener("pageshow", () => {
  refreshPaymentStatus().catch((error) => {
    console.error("Failed to refresh payment status", error);
  });
});

服务端还可以为动态支付状态接口设置合适的缓存策略:

Cache-Control: no-store

这可以降低旧响应被缓存的概率,但不能替代后端的幂等处理和状态校验。

是否应该拦截返回按钮

通常不建议依赖 popstate、人为插入多条历史记录,或者在用户返回时强行把页面推回结果页。例如:

history.pushState(null, "", location.href);

window.addEventListener("popstate", () => {
  history.pushState(null, "", location.href);
});

这种做法会破坏用户对返回按钮的正常预期,还容易引发死循环、无障碍问题以及浏览器之间的行为差异。用户依然可以通过长按返回按钮、使用历史菜单、关闭标签页或直接输入地址离开。

如果只是担心用户误操作,可以在结果页提供清楚的下一步入口,例如“返回订单列表”或“继续购物”,但不能把它当作保证支付一致性的手段。

可选的流程调整

如果支付网关支持,还可以考虑以下方案:

  • 使用网关官方提供的嵌入式支付组件。
  • 在新标签页或弹窗中完成支付,成功后关闭支付窗口并刷新原页面。
  • 使用 postMessage 在支付窗口和商户页面之间传递完成信号。
  • 通过服务器通知更新订单,再让前端轮询状态或使用 WebSocket 获取状态。

新窗口方案同样存在限制。弹窗可能被浏览器拦截,移动端的体验也可能不同,跨域通信则需要严格校验 origin。具体能否采用,要看支付网关的接入方式和安全要求。

实际建议

不要尝试删除支付网关的历史记录,浏览器没有提供这种能力。应把支付网关可能被再次访问当作正常情况,让整个流程可以安全地重复执行:

  1. 支付网关返回到一次性处理地址。
  2. 后端校验订单,但不根据浏览器参数确认支付结果。
  3. 使用 303 重定向到稳定的结果页。
  4. 前端中间页需要跳转时,使用 location.replace()。
  5. 结果页每次打开时都从后端读取最新状态。
  6. 支付通知、状态更新和业务动作都要保持幂等。
  7. 必要时使用 pageshow 和 Cache-Control: no-store,减少缓存造成的旧状态。

这样,即使用户按返回键再次进入支付网关,最多也只是多执行一次跳转,不会造成重复支付处理,也不会长期显示过期状态。

备注:内容仅供参考。