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 添加唯一索引。这样即使网关重复通知、用户刷新或后退,或者多个请求同时到达,系统也不会把同一笔交易处理多次。
短信等外部操作也要避免重复
数据库事务无法撤销已经发出的短信。下面的过程可能造成短信重复发送:
- 短信发送成功;
- 应用在更新“已发送”状态前发生异常;
- 支付回调再次到达;
- 应用重新发送短信。
对可靠性要求较高的支付系统可以使用 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()。重复请求只能读取并返回已有结果,不能再次保存支付记录、生成编号或发送短信。
备注:内容仅供参考。
