PAYATHON 2026

支付成功重定向后如何防止返回页面再次发送 sms?

支付小周

结论

商店页面无法直接阻止银行支付页发送短信。短信由银行或支付网关触发,是否重复发送,取决于支付平台怎样处理浏览器返回、表单重复提交和支付会话状态。

商店端应这样处理:

  1. 为每次支付创建唯一的支付会话或订单号。
  2. 使用支付网关支持的幂等键,防止同一支付请求被重复处理。
  3. 支付成功后,将会话设为终态。后续访问只查询结果,不再发起确认。
  4. 由银行或支付网关在支付成功后关闭原支付会话。
  5. 如果用户返回银行页面时仍会自动收到短信,需要联系支付服务商修复,商店端无法可靠规避。

禁止浏览器后退、清除历史记录,或者用 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 直接发货

浏览器跳转可能被伪造,也可能因网络问题没有发生。商店应校验支付网关签名,并以服务端回调或主动查询得到的支付状态作为发货依据。

最终判断

如果商店后端重复创建支付,进而导致短信重复发送,应通过唯一支付会话、幂等键和支付状态机修复。如果用户后退时,短信完全由银行页面触发,问题就在银行或支付网关端。支付平台需要关闭已经完成的会话,并为短信发送接口增加幂等控制。商店端只能改善跳转和结果页体验,无法可靠阻止第三方页面发送短信。

备注:内容仅供参考。