如何防止支付完成后浏览器返回 payment gateway
明确结论
前端无法可靠禁用浏览器的返回按钮,也无法删除第三方 payment gateway 创建的跨域历史记录。
更合适的处理方式是:
- 支付完成后使用
replace跳转,避免结果页继续增加历史记录。 - 服务端将支付订单标记为终态,禁止同一订单重复发起支付。
- 用户再次进入支付入口时,服务端检查订单状态,直接将其跳转到结果页。
- 通过支付网关的服务端回调或主动查询,确认最终支付状态。
- 为支付请求和后续业务处理增加幂等保护。
即使用户通过返回按钮重新打开网关页面,已经完成或关闭的订单也不会被重复处理。
为什么不能直接禁止浏览器返回
浏览器负责管理历史记录。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,也要确保这一行为不会造成重复扣款或重复执行业务。
备注:内容仅供参考。
