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 当作一个根据当前支付状态进行路由的中转端点,而不是一个只能访问一次的页面。这样,即使用户返回、刷新或重复打开该地址,也只会被安全地重定向,不会再次发起支付或重复处理订单。
备注:内容仅供参考。
