form-based payment gateway 重定向前后如何处理订单
结论
不要在预览页面创建订单。用户点击“确认支付”后,服务端应在一次原子操作中重新核对价格和库存、冻结 basket、创建 pending_payment 订单,并建立库存预留与支付尝试记录。事务提交后,直接返回一个会自动提交到支付网关的 HTML form。
从协议角度看,这确实是一次中间响应,但不需要为此增加用户可见的操作页面。通常情况下,浏览器会直接进入支付网关。
库存不能被未支付订单无限期占用。每条预留都应有明确的过期时间,由定时任务负责释放。支付回调和超时任务可能同时执行,因此需要通过数据库锁和状态机处理竞态。支付结果应以网关的服务端通知或主动查询结果为准,不能只看用户是否从浏览器返回。
推荐的状态流转
订单、支付和库存预留可以分别建模:
Order:
pending_payment -> paid -> processing -> shipped
pending_payment -> payment_failed
pending_payment -> expired
pending_payment -> cancelled
PaymentAttempt:
created -> submitted -> succeeded
created -> submitted -> failed
created -> submitted -> unknown
Reservation:
active -> consumed
active -> released
典型流程如下:
- 预览页面只展示订单信息,不创建订单,也不清空 basket。
- 用户提交“确认支付”。
- 服务端重新计算金额、配送费、折扣和税费,同时再次检查库存。
- 在数据库事务中:
- 锁定相关库存记录;
- 冻结 basket;
- 创建状态为
pending_payment的订单; - 创建带过期时间的库存预留;
- 创建唯一的
PaymentAttempt。
- 事务提交后,返回自动提交到网关的 form。
- 网关通过 webhook/server callback 通知支付结果。
- 服务端验证签名、金额、币种和订单号,再以幂等方式把订单更新为
paid。 - 定时任务释放已经过期且仍未支付的预留。
- 浏览器返回页面只展示当前状态。如果 webhook 还没到,可以短暂轮询,也可以由服务端主动查询网关。
用户进入网关前,订单处于未支付状态很正常。需要解决的不是如何避免未支付订单,而是如何为它们设置清晰的状态、过期时间和清理机制。
如何把 POST form 提交给支付网关
普通的 HTTP 302 重定向不能让浏览器携带自定义 POST body 跳转到第三方。通常的处理方式是:确认请求完成后,由服务端返回一个包含隐藏字段的 HTML form,再通过 JavaScript 自动提交。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>正在前往支付页面</title>
</head>
<body>
<p>正在连接支付网关,请勿关闭页面……</p>
<form id="gateway-form"
method="post"
action="https://gateway.example.com/pay">
<input type="hidden" name="merchant_id" value="{{ merchant_id }}">
<input type="hidden" name="order_id" value="{{ order.number }}">
<input type="hidden" name="amount" value="{{ amount }}">
<input type="hidden" name="currency" value="{{ currency }}">
<input type="hidden" name="return_url" value="{{ return_url }}">
<input type="hidden" name="notify_url" value="{{ notify_url }}">
<input type="hidden" name="signature" value="{{ signature }}">
<noscript>
<button type="submit">继续前往支付</button>
</noscript>
</form>
<script>
document.getElementById("gateway-form").submit();
</script>
</body>
</html>
这是 form-based gateway 常用的衔接方式。页面可以很轻量,同时用 <noscript> 按钮为禁用 JavaScript 的情况提供降级入口。
商户密钥、内部风控字段以及任何可能被用于伪造签名的信息,都不能放进 form。发往网关的字段必须由服务端生成并签名。
Django 侧的确认处理
下面的示意代码主要说明事务边界和幂等性。Oscar 的具体模型、库存策略和下单 API 会因项目而异,不能直接照搬其中的模型名称。
from datetime import timedelta
from django.db import transaction
from django.shortcuts import render
from django.utils import timezone
@transaction.atomic
def confirm_payment(request):
basket = (
Basket.objects
.select_for_update()
.get(id=request.session["basket_id"])
)
if basket.status != Basket.Status.OPEN:
return resume_existing_payment(request, basket)
totals = recalculate_totals(basket, request.user)
stock_records = lock_and_validate_stock(basket)
order = create_order(
basket=basket,
user=request.user,
totals=totals,
status=Order.Status.PENDING_PAYMENT,
)
expires_at = timezone.now() + timedelta(minutes=15)
create_reservations(
order=order,
basket=basket,
stock_records=stock_records,
expires_at=expires_at,
)
basket.status = Basket.Status.FROZEN
basket.save(update_fields=["status"])
attempt = PaymentAttempt.objects.create(
order=order,
status=PaymentAttempt.Status.CREATED,
idempotency_key=f"order:{order.pk}:attempt:1",
)
gateway_fields = build_signed_gateway_fields(
order=order,
attempt=attempt,
return_url=request.build_absolute_uri(
f"/checkout/payment-return/{attempt.pk}/"
),
notify_url=request.build_absolute_uri(
"/payments/gateway/webhook/"
),
)
return render(
request,
"payments/gateway_form.html",
{
"order": order,
**gateway_fields,
},
)
这里要把握两个原则:
- 不能继续使用预览页面算出的金额。确认请求必须重新计算并校验,因为价格、优惠和库存都可能已经发生变化。
- 不要在数据库事务中调用远程支付接口。事务应尽快提交,避免库存行锁被长时间占用。只有本地记录和预留操作需要放进事务。
只有事务成功提交后,才能返回自动提交页面。如果项目通过 transaction.on_commit() 执行其他操作,也要保证操作失败时,浏览器不会拿到一个实际尚未建立的订单。
用户返回时如何处理
浏览器访问 return_url 不能证明付款成功。用户可能关闭页面、重复刷新或修改参数,也可能比 webhook 更早返回。
返回视图应读取本地状态,不能根据查询参数直接把订单标记为付款成功:
def payment_return(request, attempt_id):
attempt = get_object_or_404(
PaymentAttempt,
pk=attempt_id,
order__user=request.user,
)
return render(
request,
"payments/result.html",
{
"order": attempt.order,
"payment_status": attempt.status,
},
)
真正更新支付状态的逻辑应放在 webhook 中:
from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
@transaction.atomic
def gateway_webhook(request):
event = parse_and_verify_gateway_event(request)
if event is None:
return HttpResponseBadRequest("invalid signature")
attempt = (
PaymentAttempt.objects
.select_for_update()
.select_related("order")
.get(gateway_reference=event.reference)
)
order = attempt.order
if event.amount != order.total or event.currency != order.currency:
record_payment_mismatch(attempt, event)
return HttpResponseBadRequest("amount mismatch")
if attempt.status == PaymentAttempt.Status.SUCCEEDED:
return HttpResponse("ok")
if event.is_successful:
attempt.status = PaymentAttempt.Status.SUCCEEDED
attempt.save(update_fields=["status"])
order.status = Order.Status.PAID
order.paid_at = timezone.now()
order.save(update_fields=["status", "paid_at"])
consume_reservations(order)
else:
mark_attempt_failed(attempt, event)
return HttpResponse("ok")
实际项目还应保存网关事件 ID,并对它建立唯一约束,避免网关重复通知引起重复记账、重复扣减库存或重复发货。
何时释放库存
预留时长要根据网关特性和业务容忍度决定,例如 10~30 分钟,没有一个固定值适用于所有业务。银行转账、线下支付以及认证时间较长的支付方式,不能直接套用短时卡支付的策略。
每条预留至少应记录:
class Reservation(models.Model):
order = models.ForeignKey("Order", on_delete=models.PROTECT)
stockrecord = models.ForeignKey("StockRecord", on_delete=models.PROTECT)
quantity = models.PositiveIntegerField()
status = models.CharField(max_length=20)
expires_at = models.DateTimeField(db_index=True)
released_at = models.DateTimeField(null=True, blank=True)
定时任务可以每分钟扫描一次到期记录。这项工作可以交给 Celery,也可以使用 Django management command 配合 cron,或采用其他可靠的后台任务系统。
from django.db import transaction
from django.utils import timezone
def expire_unpaid_orders(batch_size=100):
order_ids = list(
Order.objects.filter(
status=Order.Status.PENDING_PAYMENT,
reservations__status=Reservation.Status.ACTIVE,
reservations__expires_at__lte=timezone.now(),
)
.values_list("id", flat=True)
.distinct()[:batch_size]
)
for order_id in order_ids:
expire_one_order(order_id)
@transaction.atomic
def expire_one_order(order_id):
order = (
Order.objects
.select_for_update()
.get(pk=order_id)
)
if order.status != Order.Status.PENDING_PAYMENT:
return
reservations = list(
Reservation.objects
.select_for_update()
.filter(
order=order,
status=Reservation.Status.ACTIVE,
)
)
if not reservations:
return
if any(r.expires_at > timezone.now() for r in reservations):
return
release_reservations(reservations)
order.status = Order.Status.EXPIRED
order.save(update_fields=["status"])
任务必须支持重复执行。如果某条记录已经释放,再次执行时不能继续增加库存。一般可以通过状态判断、行锁和唯一约束来保证这一点。
超时与支付成功同时发生怎么办
库存预留流程中很容易漏掉下面这个竞态:
定时任务准备释放库存
↕
支付 webhook 正好确认成功
这两条执行路径必须锁定同一个订单或同一组预留记录,并在获得锁后重新检查状态。最终只能发生以下一种合法转换:
pending_payment + active reservation
-> paid + consumed reservation
或者
pending_payment + active reservation
-> expired + released reservation
如果订单已经过期,库存也已经释放,此后网关才确认支付成功,就不能直接把订单改成 paid,因为商品可能已经卖给其他人。此时应按业务规则转入异常状态,例如:
expired -> paid_after_expiry
之后可以选择一种处理策略:
- 如果库存仍然充足,重新分配库存并接受订单;
- 如果库存不足,自动退款;
- 转入人工审核。
具体选择取决于网关能否可靠退款、商品是否可以补货以及业务作出的承诺。无论采用哪种策略,都要设置明确的监控和补偿任务。
用户取消支付或希望重新支付
订单确认后,不必允许用户通过浏览器“后退”去修改同一个已冻结 basket。更清楚的处理流程是:
- 支付取消后,返回订单支付状态页;
- 如果订单仍在有效期内,允许创建新的
PaymentAttempt; - 如果用户要修改商品、地址或优惠,取消当前待支付订单并释放预留;
- 必要时根据原订单行重新创建一个可编辑 basket,不要直接修改已经冻结的数据。
每次重试不应重新创建订单。一个订单可以关联多个支付尝试,但同一时间只能有一个有效的支付流程。网关的 merchant reference 也要能区分订单和支付尝试。
在 Oscar 中落地时需要注意
Oscar 提供了 basket freeze、订单、库存记录和 allocation 等基础机制,但在哪个阶段扣减 num_in_stock、增加 num_allocated,取消时又该如何回滚,取决于项目使用的 Oscar 版本,以及自定义的 strategy、partner 和 order pipeline。实施前需要检查当前项目实际调用的下单与库存方法,不能假定“创建订单”一定意味着“永久扣减实物库存”。
较稳妥的集成方式包括:
- 保留 Oscar 的预览页面;
- 覆盖或扩展确认提交处理;
- 创建明确的待支付订单状态;
- 让 gateway form response 成为确认提交的响应;
- 分别记录支付尝试、网关事件和预留期限;
- 在成功 webhook 中确认支付并消费 allocation;
- 在取消或超时任务中释放 allocation;
- 明确规定支付失败后如何恢复 basket。
还需要防范的细节
- 前端应防止用户重复点击确认按钮,但最终仍要靠服务端幂等性兜底。
- 金额必须使用
Decimal或以最小货币单位表示的整数,不能使用浮点数。 - webhook 必须校验签名、事件类型、商户号、币种、金额和订单引用。
- 不能假定 webhook 会按顺序到达,也不能假定它只会到达一次。
- 浏览器返回地址和服务端通知地址都要保留,两者用途不同。
- 对长期停留在
unknown、pending_payment或paid_after_expiry状态的订单,应执行主动查询和对账。 - 订单号、支付尝试 ID 和网关交易号要分别保存,不能混用。
- 只有确认支付成功后,才能触发发货、数字商品交付、积分发放或商家通知。
- 过期、释放、迟到付款、退款失败和签名校验失败都应记录日志并触发告警。
整个流程应把“确认订单”“发起支付”“确认付款”和“释放库存”拆成可恢复、可重试且幂等的状态转换。这样一来,即使用户关闭浏览器、网关延迟回调,或者后台任务与支付通知同时执行,订单和库存也能保持一致。
备注:内容仅供参考。
