PAYATHON 2026

Django 电商网站支付完成后如何重定向用户?

支付阿杰

结论

不能通过支付网关的 webhook 直接让用户跳转页面。

webhook 是支付网关向 Django 服务器发送的后台 HTTP 请求。接收响应的是支付网关,不是用户的浏览器。因此,即使 webhook 返回 302,也只会影响支付网关发起的请求,用户当前的页面不会跳转。

通常应按以下流程处理:

  1. 支付网关通过 webhook 将支付结果通知 Django。
  2. Django 验证 webhook 的真实性,然后更新本地订单状态。
  3. 支付网关将用户浏览器带回商户的支付结果页。
  4. 结果页查询订单状态。确认支付成功后,再由 Django 或前端跳转到指定页面。

推荐流程

如果支付网关支持配置 return_url 或 callback_url,可以使用下面的流程:

用户发起支付
    ↓
跳转到支付网关
    ↓
支付网关完成扣款
    ├── 后台请求 webhook → Django 更新订单状态
    └── 浏览器返回 return_url → Django 展示支付结果页
                                      ↓
                               查询本地订单状态
                                      ↓
                               跳转到订单完成页

webhook 和浏览器返回的先后顺序并不固定。用户回到网站时,webhook 可能尚未处理完,所以结果页一般需要短暂轮询订单状态,不能立即认定支付已经成功。

保存订单支付状态

可以在订单模型中记录支付状态和支付网关的交易编号:

# models.py
from django.conf import settings
from django.db import models


class Order(models.Model):
    class PaymentStatus(models.TextChoices):
        PENDING = "pending", "Pending"
        PAID = "paid", "Paid"
        FAILED = "failed", "Failed"

    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.PROTECT,
    )
    amount = models.DecimalField(max_digits=10, decimal_places=2)
    payment_status = models.CharField(
        max_length=20,
        choices=PaymentStatus.choices,
        default=PaymentStatus.PENDING,
    )
    gateway_transaction_id = models.CharField(
        max_length=100,
        blank=True,
        null=True,
        unique=True,
    )
    paid_at = models.DateTimeField(blank=True, null=True)

订单应在用户跳转到支付网关之前创建,初始状态保持为 pending。不能因为用户访问了成功页面,就直接把订单改为 paid。

处理 webhook

各支付网关使用的签名算法和 JSON 字段并不相同。下面的代码只展示通用结构,verify_gateway_signature() 必须根据实际支付网关的文档实现。

# views.py
import json

from django.db import transaction
from django.http import HttpResponse, HttpResponseBadRequest
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

from .models import Order


def verify_gateway_signature(raw_body, signature):
    """
    按支付网关官方文档验证签名。
    此处不能简单返回 True。
    """
    raise NotImplementedError


@csrf_exempt
@require_POST
def payment_webhook(request):
    raw_body = request.body
    signature = request.headers.get("X-Gateway-Signature", "")

    if not verify_gateway_signature(raw_body, signature):
        return HttpResponse("invalid signature", status=401)

    try:
        payload = json.loads(raw_body)
        order_id = payload["metadata"]["order_id"]
        transaction_id = payload["transaction_id"]
        payment_status = payload["status"]
    except (json.JSONDecodeError, KeyError, TypeError):
        return HttpResponseBadRequest("invalid payload")

    with transaction.atomic():
        try:
            order = Order.objects.select_for_update().get(pk=order_id)
        except Order.DoesNotExist:
            return HttpResponse("order not found", status=404)

        # 建议同时向网关核验金额、币种和商户订单号。
        if payment_status == "paid":
            if order.payment_status != Order.PaymentStatus.PAID:
                order.payment_status = Order.PaymentStatus.PAID
                order.gateway_transaction_id = transaction_id
                order.paid_at = timezone.now()
                order.save(
                    update_fields=[
                        "payment_status",
                        "gateway_transaction_id",
                        "paid_at",
                    ]
                )

                # 发货、发券等业务应保证幂等,必要时交给异步任务处理。

        elif payment_status == "failed":
            if order.payment_status == Order.PaymentStatus.PENDING:
                order.payment_status = Order.PaymentStatus.FAILED
                order.save(update_fields=["payment_status"])

    return HttpResponse("ok")

这里使用 csrf_exempt,是因为第三方支付网关通常无法提交 Django 的 CSRF token。但 webhook 仍然必须经过认证。除了验证网关签名,还应在条件允许时校验时间戳、事件编号和请求来源,并防范重放攻击。

创建浏览器返回页面

发起支付时,将用户返回地址设置为网站中的结果页。参数名称由支付网关决定,常见的有 return_url、success_url 和 redirect_url。

结果页必须检查当前用户是否有权访问对应订单:

# views.py
from django.contrib.auth.decorators import login_required
from django.shortcuts import get_object_or_404, redirect, render

from .models import Order


@login_required
def payment_return(request, order_id):
    order = get_object_or_404(
        Order,
        pk=order_id,
        user=request.user,
    )

    if order.payment_status == Order.PaymentStatus.PAID:
        return redirect("orders:complete", order_id=order.pk)

    return render(
        request,
        "payments/waiting.html",
        {"order": order},
    )

配置 URL:

# urls.py
from django.urls import path

from . import views

app_name = "payments"

urlpatterns = [
    path("webhook/", views.payment_webhook, name="webhook"),
    path(
        "return/<int:order_id>/",
        views.payment_return,
        name="return",
    ),
]

如果 webhook 已经处理完成,用户会立即跳转到订单完成页。如果订单仍处于 pending 状态,则显示等待页面。

轮询支付状态

可以提供一个只返回本地订单状态的接口:

# views.py
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse
from django.shortcuts import get_object_or_404

from .models import Order


@login_required
def payment_status(request, order_id):
    order = get_object_or_404(
        Order,
        pk=order_id,
        user=request.user,
    )

    return JsonResponse({
        "status": order.payment_status,
    })

添加对应路由:

# urls.py
path(
    "status/<int:order_id>/",
    views.payment_status,
    name="status",
),

等待页面可以定期查询订单状态:

<p id="payment-message">正在确认支付结果,请稍候……</p>

<script>
  const statusUrl = "{% url 'payments:status' order.id %}";
  const completeUrl = "{% url 'orders:complete' order.id %}";
  let attempts = 0;
  const maxAttempts = 30;

  const timer = setInterval(async () => {
    attempts += 1;

    try {
      const response = await fetch(statusUrl, {
        credentials: "same-origin",
        cache: "no-store"
      });

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}`);
      }

      const data = await response.json();

      if (data.status === "paid") {
        clearInterval(timer);
        window.location.replace(completeUrl);
      } else if (data.status === "failed") {
        clearInterval(timer);
        document.getElementById("payment-message").textContent =
          "支付失败,请重新支付或联系客服。";
      } else if (attempts >= maxAttempts) {
        clearInterval(timer);
        document.getElementById("payment-message").textContent =
          "支付结果仍在确认中,请稍后刷新订单页面。";
      }
    } catch (error) {
      if (attempts >= maxAttempts) {
        clearInterval(timer);
        document.getElementById("payment-message").textContent =
          "暂时无法获取支付结果,请稍后查看订单。";
      }
    }
  }, 2000);
</script>

轮询的作用只是改善用户体验。最终支付状态仍应以经过签名验证的 webhook 为准,也可以由服务器主动调用支付网关的查询接口确认。

安全注意事项

不要信任浏览器传来的支付状态

下面这种做法不安全:

/payment/success/?status=paid

用户可以自行修改查询参数。返回地址中的 status=paid、交易编号或订单编号,都不能直接作为发货依据。

验证 webhook 签名

必须使用支付网关提供的方法验证签名,并对原始请求体 request.body 进行验签。部分网关要求使用未经重新序列化的原始字节,否则签名无法匹配。

如果官方提供 Python SDK,应优先调用 SDK 中的验签函数,不要自行猜测签名算法。

校验订单关键数据

收到成功事件后,至少要核对以下内容:

  • 商户订单号是否存在。
  • 交易是否属于当前商户。
  • 支付金额和币种是否与本地订单一致。
  • 交易编号是否已经处理。
  • 支付状态是否确实表示最终成功,而不是处理中或已授权。
  • 事件时间是否在合理范围内。

保证幂等性

支付网关通常会重试 webhook,因此同一事件可能被多次发送。更新订单、发货、发送优惠券或增加余额等操作,都必须保证重复执行不会产生重复结果。

可以保存网关的唯一事件 ID,并为该字段建立唯一约束。也可以结合订单状态、交易编号和数据库事务实现幂等处理。

不要在 webhook 中执行耗时任务

webhook 应尽快返回支付网关要求的成功响应。邮件、发货通知和库存同步等耗时操作,可以在事务提交后交给 Celery 等任务队列处理。这些任务同样需要保证幂等。

处理用户没有返回网站的情况

用户完成支付后可能直接关闭页面,因此业务处理不能依赖 return_url。即使用户没有返回网站,webhook 也应能独立更新订单状态。

如果 webhook 长时间没有到达,服务器可以调用支付网关提供的订单查询 API 主动核验。是否支持该功能以及具体的接口形式,应以所用支付网关的文档为准。

备注:内容仅供参考。