PAYATHON 2026

Payment 完成后如何阻止用户返回 IPGResponse

支付阿杰

结论

浏览器的后退按钮无法被可靠禁用,[NoCache] 也不能阻止用户再次请求 IPGResponse()。支付回调应该设计成幂等操作:无论同一笔支付经历多少次回调、刷新或重新访问,保存订单、生成编号、发送短信等业务逻辑都只能执行一次。

IPGResponse() 处理完请求后,可以继续重定向到结果页。服务端需要根据支付流水号或订单号判断该支付是否已经处理,并通过数据库唯一约束避免并发请求造成重复处理。

为什么 [NoCache] 没有效果

[NoCache] 控制的是浏览器和代理服务器能否缓存响应,并不会禁止访问某个 URL。

用户点击后退按钮时,浏览器可能:

  • 直接显示历史页面;
  • 重新发送对 IPGResponse() 的请求;
  • 提示重新提交表单;
  • 返回支付网关页面。

启用 NoCache 后,浏览器甚至可能更频繁地重新请求服务器。因此,不能用缓存控制来防止重复处理支付。

用 JavaScript 修改浏览器历史记录只能改善交互体验。用户仍然可以直接输入 URL、重复提交请求,支付网关也可能再次发送回调。

使用支付流水号实现幂等处理

支付网关通常会返回以下标识之一:

  • 商户订单号;
  • 网关交易号;
  • Payment ID;
  • Transaction ID。

选择一个能唯一标识该笔支付的字段作为幂等键。在执行任何业务操作前,先验证支付结果,再检查这笔交易是否已经处理。

[HttpPost]
public ActionResult IPGResponse(PaymentResponse response)
{
    if (!paymentGatewayService.Verify(response))
    {
        return new HttpStatusCodeResult(400, "Invalid payment response");
    }

    var transactionId = response.TransactionId;

    var payment = db.Payments
        .SingleOrDefault(x => x.TransactionId == transactionId);

    if (payment != null && payment.Status == "Completed")
    {
        return RedirectToAction(
            "ThankyouUploadDocument",
            new { orderId = payment.OrderId }
        );
    }

    // 首次处理该支付
    // 保存支付结果、更新订单状态等

    return RedirectToAction(
        "ThankyouUploadDocument",
        new { orderId = response.OrderId }
    );
}

单纯采用“先查询、后插入”仍有并发风险。两个请求可能同时查到记录不存在,然后同时继续处理,所以还要在数据库中为交易号建立唯一约束。

CREATE UNIQUE INDEX UX_Payments_TransactionId
ON Payments(TransactionId);

如果支付网关无法提供可靠的交易号,可以使用系统在发起支付前生成的商户订单号。该订单号必须全局唯一,回调时还要核对支付金额、币种以及对应的订单。

通过原子状态更新取得处理权

更稳妥的做法是在发起支付前创建订单,并维护清晰的支付状态,例如:

Pending -> Processing -> Completed

收到回调后,使用一条带条件的数据库更新,把状态从 Pending 改为 Processing。只有成功更新状态的请求才能继续执行业务逻辑。

[HttpPost]
public ActionResult IPGResponse(PaymentResponse response)
{
    if (!paymentGatewayService.Verify(response))
    {
        return new HttpStatusCodeResult(400, "Invalid payment response");
    }

    int affectedRows = db.Database.ExecuteSqlCommand(
        @"UPDATE Payments
          SET Status = @p0
          WHERE OrderId = @p1
            AND Status = @p2",
        "Processing",
        response.OrderId,
        "Pending"
    );

    if (affectedRows == 0)
    {
        // 已处理、正在处理,或者订单状态不允许再次处理
        return RedirectToAction(
            "ThankyouUploadDocument",
            new { orderId = response.OrderId }
        );
    }

    try
    {
        var payment = db.Payments.Single(x => x.OrderId == response.OrderId);

        payment.TransactionId = response.TransactionId;
        payment.Status = "Completed";
        payment.CompletedAt = DateTime.UtcNow;

        db.SaveChanges();

        notificationService.SendPaymentSms(payment);

        return RedirectToAction(
            "ThankyouUploadDocument",
            new { orderId = payment.OrderId }
        );
    }
    catch
    {
        // 应记录异常,并根据业务规则恢复状态或进入人工处理状态。
        throw;
    }
}

实际项目中还应为 TransactionId 添加唯一索引。这样即使网关重复通知、用户刷新或后退,或者多个请求同时到达,系统也不会把同一笔交易处理多次。

短信等外部操作也要避免重复

数据库事务无法撤销已经发出的短信。下面的过程可能造成短信重复发送:

  1. 短信发送成功;
  2. 应用在更新“已发送”状态前发生异常;
  3. 支付回调再次到达;
  4. 应用重新发送短信。

对可靠性要求较高的支付系统可以使用 Outbox 模式。支付事务只负责保存支付结果和一条待发送的通知,再由后台任务发送短信。

using (var transaction = db.Database.BeginTransaction())
{
    var payment = db.Payments.Single(x => x.OrderId == response.OrderId);

    payment.Status = "Completed";
    payment.TransactionId = response.TransactionId;
    payment.CompletedAt = DateTime.UtcNow;

    db.NotificationOutbox.Add(new NotificationOutbox
    {
        EventKey = "PaymentCompleted:" + response.TransactionId,
        Type = "PaymentSms",
        OrderId = payment.OrderId,
        Status = "Pending",
        CreatedAt = DateTime.UtcNow
    });

    db.SaveChanges();
    transaction.Commit();
}

EventKey 也要建立唯一约束。后台任务成功发送短信后,再把通知状态改为 Sent。如果短信服务支持客户端幂等键,也应该把这个键传给服务商。

结果页只负责展示

ThankyouUploadDocument() 不应依赖回调 Action 临时构造的内存状态。它应该根据订单号,从数据库读取已经保存的支付结果。

[HttpGet]
public ActionResult ThankyouUploadDocument(string orderId)
{
    var payment = db.Payments.SingleOrDefault(x => x.OrderId == orderId);

    if (payment == null)
    {
        return HttpNotFound();
    }

    var paymentViewModel = new PaymentViewModel
    {
        OrderId = payment.OrderId,
        IsPaid = payment.Status == "Completed"
    };

    return View("ThankyouUploadDocument", paymentViewModel);
}

订单号不能充当访问凭证。系统还要验证当前登录用户是否有权查看该订单,防止用户修改 URL 后看到他人的支付信息。

回调处理时还要注意

  • 优先使用 POST 接收支付结果,不要通过 GET 执行保存数据、发送短信等有副作用的操作。
  • 按照支付网关的规则验证签名、金额、币种、商户号、订单号和支付状态。
  • 不要直接相信浏览器返回的“支付成功”字段。如果网关提供服务端通知或交易查询接口,应以经过验证的服务端结果为准。
  • 支付网关发起的跨站 POST 通常无法使用普通页面表单的防伪令牌,但仍然需要验证请求。可以使用网关签名或服务端查询来确认请求真实性。
  • 不要只用 Session、TempData 或布尔变量记录“已经处理”。这些数据可能过期或丢失,也无法处理多实例部署和并发请求。
  • 不要把禁用后退按钮当作安全措施。前端可以使用 history.replaceState() 改善体验,服务端仍要保证幂等。
  • 支付网关可能主动重试通知。即使用户从未点击后退按钮,回调接口也必须能够安全地重复执行。

允许请求再次进入 IPGResponse()。重复请求只能读取并返回已有结果,不能再次保存支付记录、生成编号或发送短信。

备注:内容仅供参考。