PAYATHON 2026

ASP.NET MVC 如何阻止返回 Processing page

支付阿杰

结论

ASP.NET MVC 服务端无法真正禁用浏览器的 back button,也无法可靠地删除用户的历史记录。

更稳妥的做法是把 Processing page 设计成一个可以重复访问、但不会重复处理支付的中转页面。用户返回该页面时,服务端先检查订单状态。如果订单已经处理完成,就立即重定向到 Checkout page 或 Order confirmation page。还要配合禁用页面缓存、Post/Redirect/Get 和幂等控制,防止重复扣款或重复创建订单。

为什么会再次进入 Processing page

浏览器会在历史记录中保留 Processing page。用户点击返回按钮时,可能发生两种情况:

  • 浏览器直接从 back-forward cache 或普通缓存中恢复页面。
  • 浏览器重新向 Processing action 发起请求。

所以,无论是在页面中隐藏内容、用 JavaScript 拦截返回按钮,还是调用一次 RedirectToAction,都无法保证用户永远不会再次访问这个 URL。

需要解决的不是如何阻止用户访问 URL,而是如何确保再次访问时不会重新执行支付处理。

推荐处理方式

1. 在服务端保存支付状态

为每次支付创建唯一的支付尝试记录,例如:

Created
RedirectedToProvider
Processing
Succeeded
Failed

Processing action 收到请求后,应根据数据库中的实际状态决定下一步,不能只依赖 URL 参数、Session 或隐藏字段。

2. Processing action 只执行一次状态转换

下面使用经典 ASP.NET MVC 作为示例。实际项目应通过不可预测的支付尝试标识查询记录,并验证该记录属于当前订单或当前用户。

[HttpGet]
public ActionResult Processing(string paymentAttemptId)
{
    var payment = paymentService.Get(paymentAttemptId);

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

    if (payment.Status == PaymentStatus.Succeeded)
    {
        return RedirectToAction(
            "Confirmation",
            "Checkout",
            new { orderId = payment.OrderId });
    }

    if (payment.Status == PaymentStatus.Failed)
    {
        return RedirectToAction(
            "Payment",
            "Checkout",
            new { orderId = payment.OrderId });
    }

    return View(payment);
}

用户通过 back button 再次请求 Processing page 时,已经成功或失败的支付会被直接引导到对应页面。

防止浏览器恢复旧的 Processing 页面

可以为 Processing page 设置禁止缓存的响应头:

[HttpGet]
[OutputCache(NoStore = true, Duration = 0, VaryByParam = "*")]
public ActionResult Processing(string paymentAttemptId)
{
    Response.Cache.SetCacheability(HttpCacheability.NoCache);
    Response.Cache.SetNoStore();
    Response.Cache.SetExpires(DateTime.UtcNow.AddYears(-1));

    var payment = paymentService.Get(paymentAttemptId);

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

    if (payment.Status == PaymentStatus.Succeeded)
    {
        return RedirectToAction(
            "Confirmation",
            "Checkout",
            new { orderId = payment.OrderId });
    }

    if (payment.Status == PaymentStatus.Failed)
    {
        return RedirectToAction(
            "Payment",
            "Checkout",
            new { orderId = payment.OrderId });
    }

    return View(payment);
}

这些响应头可以降低浏览器直接显示旧页面的概率,但不能单独承担业务保护。部分浏览器的 back-forward cache 并不完全受普通 HTTP 缓存头控制,因此服务端状态检查仍然不可省略。

使用 Post/Redirect/Get 避免重复提交

如果 Processing action 接收 POST 请求,处理完成后不要直接返回 Checkout 或 Confirmation 视图,而要返回重定向响应:

[HttpPost]
[ValidateAntiForgeryToken]
public ActionResult ProcessPayment(ProcessPaymentViewModel model)
{
    if (!ModelState.IsValid)
    {
        return View("Processing", model);
    }

    var result = paymentService.ProcessOnce(model.PaymentAttemptId);

    if (result.Succeeded)
    {
        return RedirectToAction(
            "Confirmation",
            "Checkout",
            new { orderId = result.OrderId });
    }

    return RedirectToAction(
        "Payment",
        "Checkout",
        new { orderId = result.OrderId });
}

这样,用户最终看到的是一个新的 GET 页面,刷新时不会重新提交处理请求。

不过,Post/Redirect/Get 只能解决重复提交和刷新确认问题,无法自动从浏览器历史记录中移除 Processing page。

支付处理必须具备幂等性

即使页面跳转处理得当,用户仍然可能重复请求 URL,支付提供商也可能重复发送回调。因此,支付服务必须保证同一个支付尝试只会产生一次业务结果。

常见做法是在数据库中为支付尝试标识建立唯一约束,并通过事务或条件更新完成状态转换:

public PaymentResult ProcessOnce(string paymentAttemptId)
{
    var payment = repository.GetForUpdate(paymentAttemptId);

    if (payment.Status == PaymentStatus.Succeeded ||
        payment.Status == PaymentStatus.Failed)
    {
        return PaymentResult.FromExisting(payment);
    }

    // 调用支付状态查询或执行必要的处理。
    // 更新时应配合数据库事务、唯一约束或并发控制。
    var providerResult = provider.QueryPayment(payment.ProviderReference);

    payment.Status = providerResult.Succeeded
        ? PaymentStatus.Succeeded
        : PaymentStatus.Failed;

    repository.Save(payment);

    return PaymentResult.FromExisting(payment);
}

并发控制的具体实现取决于项目使用的 ORM 和数据库,可以选择事务、行锁、乐观并发字段或原子条件更新。不要只使用进程内 lock,因为网站可能同时运行在多个实例上。

可选:替换当前浏览器历史记录

如果 Processing page 通过 JavaScript 跳转,可以使用 location.replace,由目标页面替换当前历史记录项:

window.location.replace('/Checkout/Confirmation?orderId=123');

这样通常可以避免用户点击返回按钮后再次进入 Processing page,改善使用体验。但它不能作为安全机制,也无法覆盖以下情况:

  • 服务端通过 HTTP 重定向完成跳转;
  • 用户复制并再次打开 Processing URL;
  • 页面在执行 JavaScript 前被关闭;
  • 不同浏览器使用不同的历史记录或缓存策略。

因此,location.replace 只能作为辅助措施。

支付场景的额外注意事项

  • 不要根据查询字符串中的 success=true 判断支付成功。
  • 不要只依赖用户从支付提供商返回时携带的数据。
  • 应验证支付提供商的签名,并通过服务端回调或服务端状态查询确认支付结果。
  • 支付提供商的异步通知可能早于或晚于用户跳转到 Processing page,这两条路径必须共用同一套幂等状态更新逻辑。
  • Processing URL 不应包含可用于伪造订单状态的敏感信息。
  • 如果订单属于特定用户,应检查当前用户是否有权查看该支付尝试。
  • Checkout 和 Confirmation action 也要根据服务端保存的订单状态决定允许执行的操作,不能假设用户一定会按照页面顺序访问。

应该把 Processing page 当作一个根据当前支付状态进行路由的中转端点,而不是一个只能访问一次的页面。这样,即使用户返回、刷新或重复打开该地址,也只会被安全地重定向,不会再次发起支付或重复处理订单。

备注:内容仅供参考。