支付成功重定向后如何防止返回页面再次发送 sms?
结论
商店页面无法直接阻止银行支付页发送短信。短信由银行或支付网关触发,是否重复发送,取决于支付平台怎样处理浏览器返回、表单重复提交和支付会话状态。
商店端应这样处理:
- 为每次支付创建唯一的支付会话或订单号。
- 使用支付网关支持的幂等键,防止同一支付请求被重复处理。
- 支付成功后,将会话设为终态。后续访问只查询结果,不再发起确认。
- 由银行或支付网关在支付成功后关闭原支付会话。
- 如果用户返回银行页面时仍会自动收到短信,需要联系支付服务商修复,商店端无法可靠规避。
禁止浏览器后退、清除历史记录,或者用 JavaScript 拦截返回,都无法从根本上解决这个问题。
为什么返回页面会再次发送短信
银行支付页可能把“发送短信”绑定到了以下操作:
- 重新加载支付确认页面;
- 再次提交支付表单;
- 恢复浏览器历史记录中的页面;
- 重新调用创建验证码的接口;
- 支付完成后未正确关闭支付会话。
如果银行页面通过 POST 提交,用户后退时还可能触发浏览器的表单重新提交流程。更严重的是,银行页面可能仅凭一次普通的 GET 请求就发送短信。这相当于让“读取页面”产生副作用,需要由支付平台解决。
success_url 只是支付完成后的跳转地址,不能修改银行页面的历史记录,也不能决定银行是否再次发送短信。
商店端应采用的支付流程
支付过程可以拆成“创建支付”和“查询支付结果”两个阶段:
用户点击购买
↓
商店后端创建唯一支付会话
↓
跳转到银行支付页
↓
银行完成验证和扣款
↓
银行服务端回调商店
↓
商店将支付会话标记为 paid
↓
浏览器跳转到结果页
↓
结果页只查询状态,不再次创建支付
银行的服务端回调通常比浏览器跳转更可信。用户可能关闭页面、断网,也可能修改跳转参数,因此不能仅凭用户访问了 success_url 就认定支付成功。
使用唯一支付会话和幂等键
每次支付尝试都应生成一个不可预测的唯一标识,并将其状态保存在数据库中。下面是简化的 Node.js 示例,具体参数需要替换为支付网关实际提供的 API。
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
app.post("/orders/:orderId/payment", async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: "Order not found" });
}
if (order.paymentStatus === "paid") {
return res.redirect(`/payment/result?order_id=${order.id}`);
}
const attemptId = crypto.randomUUID();
await db.paymentAttempts.create({
id: attemptId,
orderId: order.id,
status: "pending"
});
const payment = await paymentGateway.createPayment({
orderId: order.id,
amount: order.amount,
currency: order.currency,
successUrl: `https://shop.example.com/payment/return?attempt_id=${attemptId}`,
callbackUrl: "https://shop.example.com/webhooks/payment",
idempotencyKey: attemptId
});
await db.paymentAttempts.update(attemptId, {
providerPaymentId: payment.id
});
res.redirect(303, payment.redirectUrl);
});
这里的 idempotencyKey 只是示例名称。只有支付网关明确支持幂等请求时才能使用,实际的请求头或字段名称应以接口文档为准。
同一个 attemptId 只能对应一次支付尝试。如果后端发现该尝试已经成功、失败或过期,就不应再次调用创建支付或发送验证码的接口。
正确处理支付回调
支付成功状态应由支付网关的服务端通知确认,回调处理本身也要具备幂等性:
app.post("/webhooks/payment", async (req, res) => {
if (!paymentGateway.verifySignature(req)) {
return res.sendStatus(400);
}
const event = paymentGateway.parseEvent(req);
await db.transaction(async (tx) => {
const processed = await tx.paymentEvents.findById(event.id);
if (processed) {
return;
}
const attempt = await tx.paymentAttempts.findByProviderId(
event.paymentId
);
if (!attempt) {
throw new Error("Unknown payment");
}
if (event.type === "payment.succeeded") {
await tx.paymentAttempts.markPaid(attempt.id);
await tx.orders.markPaid(attempt.orderId);
}
await tx.paymentEvents.create({
id: event.id,
type: event.type
});
});
res.sendStatus(200);
});
数据库还应设置必要的唯一约束。例如,将支付平台的事件 ID、支付 ID 或订单支付记录设为唯一值,防止并发回调造成重复处理。
返回地址只负责展示结果
浏览器进入 success_url 后,不应再次创建支付或触发短信。该地址只需查询支付状态,再重定向到稳定的结果页:
app.get("/payment/return", async (req, res) => {
const attempt = await db.paymentAttempts.findById(
req.query.attempt_id
);
if (!attempt) {
return res.status(404).send("Payment attempt not found");
}
res.redirect(
303,
`/payment/result?order_id=${encodeURIComponent(attempt.orderId)}`
);
});
app.get("/payment/result", async (req, res) => {
const order = await db.orders.findById(req.query.order_id);
res.set("Cache-Control", "no-store");
res.render("payment-result", {
status: order.paymentStatus
});
});
如果服务端回调尚未到达,结果页可以显示“正在确认支付”,并通过只读接口轮询状态:
app.get("/api/orders/:orderId/payment-status", async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
res.set("Cache-Control", "no-store");
res.json({ status: order.paymentStatus });
});
这个接口只能查询状态,不能在查询时重新创建支付、重新提交扣款或请求发送短信。
支付平台需要承担的处理
银行或支付网关应做到:
- 已成功或已取消的支付会话不能再次进入短信确认流程;
- 刷新、后退或重复提交时,不会重复创建验证码;
- 发送短信的接口有幂等控制和频率限制;
- 支付成功后,旧页面只显示最终状态,或者提示会话已经结束;
- 收到重复的支付确认请求时返回原结果,而不是开始新的验证。
如果用户只是返回银行页面,没有再次点击提交按钮,银行却立即发送短信,应向支付服务商提供以下信息:
- 商户订单号;
- 支付平台交易号;
- 支付发生时间;
- 浏览器及版本;
- 返回页面前后的请求路径;
- 是否出现“确认重新提交表单”的浏览器提示;
- 短信发送次数及大致时间。
不要提交银行卡号、验证码、完整 Cookie、签名密钥或其他敏感凭据。
不建议使用的方案
用 JavaScript 禁止后退
修改 history、监听 popstate 或强制跳转,只能影响商店自己的页面,无法可靠控制银行域名下的页面。用户也可以通过长按返回按钮、重新打开历史记录或禁用 JavaScript 绕过这些限制。
依赖 Cache-Control 阻止短信
在商店结果页使用 Cache-Control: no-store,可以减少浏览器缓存敏感的支付结果。但它无法保证银行页面不会重新加载,也不能阻止银行后端发送短信。
在成功页调用 history.replaceState()
history.replaceState() 只能替换当前商店页面的历史记录项,不能删除或修改银行域名此前创建的历史记录。它可以改善部分导航体验,但不能作为安全措施。
根据访问 success_url 直接发货
浏览器跳转可能被伪造,也可能因网络问题没有发生。商店应校验支付网关签名,并以服务端回调或主动查询得到的支付状态作为发货依据。
最终判断
如果商店后端重复创建支付,进而导致短信重复发送,应通过唯一支付会话、幂等键和支付状态机修复。如果用户后退时,短信完全由银行页面触发,问题就在银行或支付网关端。支付平台需要关闭已经完成的会话,并为短信发送接口增加幂等控制。商店端只能改善跳转和结果页体验,无法可靠阻止第三方页面发送短信。
备注:内容仅供参考。
