Django 电商网站支付完成后如何重定向用户?
结论
不能通过支付网关的 webhook 直接让用户跳转页面。
webhook 是支付网关向 Django 服务器发送的后台 HTTP 请求。接收响应的是支付网关,不是用户的浏览器。因此,即使 webhook 返回 302,也只会影响支付网关发起的请求,用户当前的页面不会跳转。
通常应按以下流程处理:
- 支付网关通过 webhook 将支付结果通知 Django。
- Django 验证 webhook 的真实性,然后更新本地订单状态。
- 支付网关将用户浏览器带回商户的支付结果页。
- 结果页查询订单状态。确认支付成功后,再由 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 主动核验。是否支持该功能以及具体的接口形式,应以所用支付网关的文档为准。
备注:内容仅供参考。
