PAYATHON 2026

如何防止支付完成后浏览器返回 payment gateway

支付阿杰

明确结论

前端无法可靠禁用浏览器的返回按钮,也无法删除第三方 payment gateway 创建的跨域历史记录。

更合适的处理方式是:

  1. 支付完成后使用 replace 跳转,避免结果页继续增加历史记录。
  2. 服务端将支付订单标记为终态,禁止同一订单重复发起支付。
  3. 用户再次进入支付入口时,服务端检查订单状态,直接将其跳转到结果页。
  4. 通过支付网关的服务端回调或主动查询,确认最终支付状态。
  5. 为支付请求和后续业务处理增加幂等保护。

即使用户通过返回按钮重新打开网关页面,已经完成或关闭的订单也不会被重复处理。

为什么不能直接禁止浏览器返回

浏览器负责管理历史记录。React 只能操作当前站点创建的部分记录,无法安全清除第三方支付页面留下的跨域记录。

以下方法都不可靠:

  • 监听 popstate 后强制跳回当前页面;
  • 调用 history.forward();
  • 在页面中循环执行 history.pushState();
  • 隐藏浏览器按钮;
  • 只用 React 状态记录”是否支付完成”。

用户可能绕过这些限制,页面也可能因此出现循环跳转、返回按钮失效或无障碍体验问题。不同的移动端浏览器、内置 WebView 和支付网关还可能表现不一。

支付安全必须由服务端保证。即使用户重新打开支付链接,也不能再次付款或重复触发业务操作。

推荐的支付流程

支付流程可以设计为:

创建订单
  ↓
服务端创建支付会话
  ↓
浏览器跳转到 payment gateway
  ↓
支付网关通知服务端
  ↓
服务端验证通知并更新订单状态
  ↓
浏览器返回支付结果页
  ↓
结果页向服务端查询订单状态

订单状态可以根据业务需要设置为:

CREATED
PAYING
PAID
FAILED
CANCELLED
EXPIRED

PAID、CANCELLED 和 EXPIRED 通常是终态。FAILED 状态下是否允许重新支付,应根据支付渠道和业务规则决定。

React 中使用 replace 跳转

使用 React Router 时,可以在确认支付结果后设置 replace: true:

import { useNavigate } from "react-router-dom";

function PaymentCallback() {
  const navigate = useNavigate();

  async function confirmPayment(orderId) {
    const response = await fetch(`/api/orders/${orderId}/payment-status`, {
      credentials: "include",
    });

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

    const result = await response.json();

    navigate(`/orders/${orderId}/result`, {
      replace: true,
      state: {
        status: result.status,
      },
    });
  }

  // 省略调用逻辑
  return <div>正在确认支付结果...</div>;
}

replace: true 会替换当前的 React 路由记录,避免用户返回站内的支付回调页面。

它无法删除更早的第三方 payment gateway 历史记录,只能改善返回路径,不能充当支付安全机制。

没有使用 React Router 时,可以调用:

window.location.replace(`/orders/${orderId}/result`);

与 window.location.href 不同,location.replace() 不会为当前页面新增历史记录。

跳转到支付网关时尽量使用 replace

如果业务允许,离开站点并进入支付网关时也可以使用:

window.location.replace(paymentGatewayUrl);

这样可以避免用户从支付网关返回站内的”正在发起支付”页面。

这种方式仍然无法控制支付网关内部生成的历史记录,也不能保证所有网关跳转流程表现一致。具体行为取决于网关采用顶层跳转、表单提交、多次重定向、弹窗还是 SDK。

支付入口必须检查订单状态

无论用户正常进入支付页,还是通过浏览器历史记录再次访问,服务端都应先检查订单状态。

下面是一个简化的 Express 示例:

app.post("/api/orders/:orderId/pay", async (req, res) => {
  const order = await orderRepository.findById(req.params.orderId);

  if (!order) {
    return res.status(404).json({
      error: "ORDER_NOT_FOUND",
    });
  }

  if (order.userId !== req.user.id) {
    return res.status(403).json({
      error: "FORBIDDEN",
    });
  }

  if (order.status === "PAID") {
    return res.status(409).json({
      error: "ORDER_ALREADY_PAID",
      redirectUrl: `/orders/${order.id}/result`,
    });
  }

  if (order.status === "CANCELLED" || order.status === "EXPIRED") {
    return res.status(409).json({
      error: "ORDER_NOT_PAYABLE",
      redirectUrl: `/orders/${order.id}/result`,
    });
  }

  const paymentSession = await createOrReusePaymentSession(order);

  return res.json({
    paymentUrl: paymentSession.url,
  });
});

React 根据响应结果决定跳转位置:

async function startPayment(orderId) {
  const response = await fetch(`/api/orders/${orderId}/pay`, {
    method: "POST",
    credentials: "include",
    headers: {
      "Content-Type": "application/json",
    },
  });

  const result = await response.json();

  if (response.status === 409 && result.redirectUrl) {
    window.location.replace(result.redirectUrl);
    return;
  }

  if (!response.ok) {
    throw new Error(result.error || "Unable to start payment");
  }

  window.location.replace(result.paymentUrl);
}

不要只在 React 中判断订单是否可以支付。用户可以刷新页面、修改前端状态,甚至直接调用接口。

不要相信浏览器回跳参数

支付网关跳回成功页,不代表支付一定成功。用户可能修改 URL 参数,也可能在网关完成扣款后关闭页面,使浏览器没有机会执行回跳。

不能只根据以下 URL 判断支付成功:

/payment/result?status=success

结果页应使用订单编号查询自己的服务端:

import { useEffect, useState } from "react";

function PaymentResult({ orderId }) {
  const [status, setStatus] = useState("LOADING");

  useEffect(() => {
    let cancelled = false;

    async function loadStatus() {
      const response = await fetch(
        `/api/orders/${encodeURIComponent(orderId)}/payment-status`,
        {
          credentials: "include",
        }
      );

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

      const result = await response.json();

      if (!cancelled) {
        setStatus(result.status);
      }
    }

    loadStatus().catch(() => {
      if (!cancelled) {
        setStatus("UNKNOWN");
      }
    });

    return () => {
      cancelled = true;
    };
  }, [orderId]);

  if (status === "LOADING") {
    return <p>正在确认支付状态...</p>;
  }

  if (status === "PAID") {
    return <p>支付成功。</p>;
  }

  if (status === "FAILED") {
    return <p>支付失败,请重新尝试。</p>;
  }

  return <p>暂时无法确认支付结果,请稍后刷新订单页面。</p>;
}

最终状态应来自经过签名验证的网关回调,或者由服务端调用网关 API 主动查询。

使用 Webhook 更新订单状态

服务端通常可以按以下方式处理回调:

app.post(
  "/api/payment/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = verifyGatewayWebhook(req.body, req.headers);
    } catch {
      return res.status(400).send("Invalid signature");
    }

    await database.transaction(async (transaction) => {
      const processed = await transaction.paymentEvents.findByEventId(
        event.id
      );

      if (processed) {
        return;
      }

      if (event.type === "payment.succeeded") {
        await transaction.orders.markAsPaid({
          orderId: event.orderId,
          gatewayTransactionId: event.transactionId,
        });
      }

      if (event.type === "payment.failed") {
        await transaction.orders.markAsFailed({
          orderId: event.orderId,
        });
      }

      await transaction.paymentEvents.insert({
        eventId: event.id,
        type: event.type,
      });
    });

    res.sendStatus(200);
  }
);

verifyGatewayWebhook() 的签名算法、请求体格式和响应要求,必须按照实际支付网关的官方文档实现,不能直接用通用示例代替。

必须处理幂等性

浏览器返回不是重复支付的唯一来源。用户双击按钮、网络重试以及网关重复发送 Webhook,都可能触发重复请求。

至少应在以下环节增加幂等保护:

  • 创建支付会话时,以订单编号作为幂等依据;
  • 更新订单状态时,只允许合法的状态转换;
  • 处理 Webhook 时,记录网关事件 ID;
  • 发货、充值、开通会员等后续操作只能执行一次;
  • 在数据库中为网关交易编号设置唯一约束。

状态更新可以使用带条件的 SQL:

UPDATE orders
SET status = 'PAID',
    gateway_transaction_id = :transaction_id,
    paid_at = CURRENT_TIMESTAMP
WHERE id = :order_id
  AND status <> 'PAID';

还可以为交易编号建立唯一约束:

CREATE UNIQUE INDEX ux_orders_gateway_transaction_id
ON orders (gateway_transaction_id);

实际 SQL 需要根据所用数据库调整。执行更新后还应检查受影响的行数,并将订单更新与发货、充值等操作放入合适的事务或可靠消息流程。

如果用户确实返回了支付网关

第三方网关页面能否再次打开,由网关决定。应用端需要保证:

  • 支付会话只能使用一次,或者设置了有效期;
  • 已支付订单不能创建新的支付会话;
  • 再次访问旧支付链接时不会重复扣款;
  • 服务端重新验证网关返回的所有状态;
  • 用户回到站内支付入口时,立即查询订单状态并跳转到结果页。

如果支付网关支持配置 return_url、cancel_url、支付会话过期时间或一次性支付链接,应按照官方文档设置。不同网关的参数名称和行为可能不同。

注意事项

不要使用下面的代码”锁住”返回按钮:

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

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

这段代码会破坏正常导航,也无法可靠阻止跨域返回。

同样,不要只通过前端变量保存支付状态:

const [paid, setPaid] = useState(false);

页面刷新后,这个状态会丢失,也不能阻止接口被重复调用。

支付按钮被点击后,可以临时禁用按钮以减少误操作:

<button disabled={submitting} onClick={handlePayment}>
  {submitting ? "正在跳转..." : "立即支付"}
</button>

禁用按钮只是用户体验优化,不能代替服务端的幂等控制。

replace 用于改善浏览器导航,订单状态校验和幂等机制用于保证支付安全。即使无法阻止用户返回 payment gateway,也要确保这一行为不会造成重复扣款或重复执行业务。

备注:内容仅供参考。