PAYATHON 2026

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

典型流程如下:

  1. 预览页面只展示订单信息,不创建订单,也不清空 basket。
  2. 用户提交“确认支付”。
  3. 服务端重新计算金额、配送费、折扣和税费,同时再次检查库存。
  4. 在数据库事务中:
    • 锁定相关库存记录;
    • 冻结 basket;
    • 创建状态为 pending_payment 的订单;
    • 创建带过期时间的库存预留;
    • 创建唯一的 PaymentAttempt。
  5. 事务提交后,返回自动提交到网关的 form。
  6. 网关通过 webhook/server callback 通知支付结果。
  7. 服务端验证签名、金额、币种和订单号,再以幂等方式把订单更新为 paid。
  8. 定时任务释放已经过期且仍未支付的预留。
  9. 浏览器返回页面只展示当前状态。如果 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 和网关交易号要分别保存,不能混用。
  • 只有确认支付成功后,才能触发发货、数字商品交付、积分发放或商家通知。
  • 过期、释放、迟到付款、退款失败和签名校验失败都应记录日志并触发告警。

整个流程应把“确认订单”“发起支付”“确认付款”和“释放库存”拆成可恢复、可重试且幂等的状态转换。这样一来,即使用户关闭浏览器、网关延迟回调,或者后台任务与支付通知同时执行,订单和库存也能保持一致。

备注:内容仅供参考。