使用 Django 最容易实现的 Payment Gateway 是什么?
结论
对于只面向美国收款,同时需要支持一次性付款和按月订阅的 Django 网站,通常最省事的方案是使用 Stripe Checkout 和 Stripe Billing。
主要原因有:
- 支付页面由 Stripe 托管,无需自行处理银行卡输入。
- 一套 API 就能覆盖一次性付款和 recurring billing。
- 可以在 Dashboard 中查看交易、退款、订阅和失败付款。
- Django 后端只需创建 Checkout Session、接收 webhook,并维护账户流水。
- PCI 合规范围会小很多。
如果你已经熟悉 Django,而且业务规则比较简单,基础测试版本有可能在 10 小时内完成。生产环境还要处理 webhook 重试、幂等、退款同步、账务核对和安全测试,因此不能保证完整上线也能控制在 10 小时内。
Amazon FPS 已不适合新项目。PayPal 也能实现这些功能,但以减少集成代码为目标时,Stripe Checkout 通常更直接。
推荐的整体流程
一次性付款:
用户选择金额
→ Django 创建 Checkout Session
→ 跳转到 Stripe 托管支付页
→ Stripe 发送 checkout.session.completed webhook
→ Django 验证签名并增加用户余额
按月订阅:
用户选择月付金额
→ Django 创建 subscription 模式的 Checkout Session
→ Stripe 创建订阅并完成首期付款
→ Stripe 发送 invoice.paid webhook
→ Django 根据实际到账结果更新账户
不要根据浏览器是否跳转到 success 页面来确认付款。用户可能关闭页面、伪造请求,也可能在付款完成后没有成功跳转。只有经过签名验证的 Stripe webhook 才能作为入账依据。
安装与配置
安装 Stripe 的 Python SDK:
pip install stripe
将密钥放入环境变量,不要写进源码:
# settings.py
import os
STRIPE_SECRET_KEY = os.environ["STRIPE_SECRET_KEY"]
STRIPE_WEBHOOK_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"]
测试密钥和正式密钥必须分开管理。开发阶段应先使用 Stripe test mode。
创建一次性付款
下面的示例允许用户选择付款金额。实际项目必须在服务端验证金额,不能直接信任浏览器提交的数据。
# payments/views.py
from decimal import Decimal, InvalidOperation
import stripe
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.http import HttpResponseBadRequest
from django.shortcuts import redirect
from django.urls import reverse
from django.views.decorators.http import require_POST
stripe.api_key = settings.STRIPE_SECRET_KEY
@login_required
@require_POST
def create_deposit_checkout(request):
try:
amount = Decimal(request.POST["amount"])
except (KeyError, InvalidOperation):
return HttpResponseBadRequest("Invalid amount")
if amount < Decimal("1.00") or amount > Decimal("5000.00"):
return HttpResponseBadRequest("Amount is outside the allowed range")
amount_cents = int(amount * 100)
session = stripe.checkout.Session.create(
mode="payment",
payment_method_types=["card"],
line_items=[
{
"price_data": {
"currency": "usd",
"product_data": {
"name": "Account deposit",
},
"unit_amount": amount_cents,
},
"quantity": 1,
}
],
client_reference_id=str(request.user.pk),
metadata={
"user_id": str(request.user.pk),
"purpose": "account_deposit",
},
success_url=request.build_absolute_uri(
reverse("payments:success")
) + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=request.build_absolute_uri(
reverse("payments:cancel")
),
)
return redirect(session.url, permanent=False)
金额必须以整数美分传递。例如,$19.99 应转换为 1999。不要用 float 计算货币金额。
创建按月订阅
如果所有用户支付相同金额,最好提前在 Stripe Dashboard 中创建 Product 和 recurring Price,再在 Django 中保存对应的 price_... ID:
@login_required
@require_POST
def create_subscription_checkout(request):
price_id = settings.STRIPE_MONTHLY_PRICE_ID
session = stripe.checkout.Session.create(
mode="subscription",
payment_method_types=["card"],
line_items=[
{
"price": price_id,
"quantity": 1,
}
],
client_reference_id=str(request.user.pk),
metadata={
"user_id": str(request.user.pk),
},
subscription_data={
"metadata": {
"user_id": str(request.user.pk),
}
},
success_url=request.build_absolute_uri(
reverse("payments:success")
) + "?session_id={CHECKOUT_SESSION_ID}",
cancel_url=request.build_absolute_uri(
reverse("payments:cancel")
),
)
return redirect(session.url, permanent=False)
固定 Price 比每次动态创建价格更方便管理,也便于在 Dashboard 中统计收入。如果不同用户的月付金额不同,可以使用动态 price_data,但要先确定价格变更、退款、取消和续费规则。
接收并验证 webhook
Webhook 请求来自 Stripe,因此端点通常不使用 Django 的 CSRF 校验,但必须通过 webhook signing secret 验证签名:
# payments/views.py
import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
@csrf_exempt
@require_POST
def stripe_webhook(request):
payload = request.body
signature = request.headers.get("Stripe-Signature", "")
try:
event = stripe.Webhook.construct_event(
payload,
signature,
settings.STRIPE_WEBHOOK_SECRET,
)
except ValueError:
return HttpResponse(status=400)
except stripe.error.SignatureVerificationError:
return HttpResponse(status=400)
event_type = event["type"]
obj = event["data"]["object"]
if event_type == "checkout.session.completed":
handle_checkout_completed(
event_id=event["id"],
session=obj,
)
elif event_type == "invoice.paid":
handle_invoice_paid(
event_id=event["id"],
invoice=obj,
)
elif event_type == "invoice.payment_failed":
handle_invoice_payment_failed(
event_id=event["id"],
invoice=obj,
)
return HttpResponse(status=200)
URL 配置:
# payments/urls.py
from django.urls import path
from . import views
app_name = "payments"
urlpatterns = [
path(
"checkout/deposit/",
views.create_deposit_checkout,
name="deposit",
),
path(
"checkout/subscription/",
views.create_subscription_checkout,
name="subscription",
),
path(
"webhooks/stripe/",
views.stripe_webhook,
name="stripe-webhook",
),
]
Webhook 必须具备幂等性
Stripe 可能在超时或网络异常时重复发送同一个事件。如果每次收到 webhook 都直接增加余额,同一笔付款就可能被记账两次。
至少应保存以下信息:
class StripeEvent(models.Model):
event_id = models.CharField(max_length=255, unique=True)
event_type = models.CharField(max_length=100)
processed_at = models.DateTimeField(auto_now_add=True)
处理事件时,应在数据库事务中先创建事件记录,再修改余额:
from django.db import IntegrityError, transaction
def handle_checkout_completed(event_id, session):
if session.get("mode") != "payment":
return
if session.get("payment_status") != "paid":
return
user_id = session.get("metadata", {}).get("user_id")
amount_total = session.get("amount_total")
if not user_id or amount_total is None:
return
try:
with transaction.atomic():
StripeEvent.objects.create(
event_id=event_id,
event_type="checkout.session.completed",
)
add_account_credit(
user_id=user_id,
amount_cents=amount_total,
external_reference=session["id"],
)
except IntegrityError:
# 该事件已经处理过
return
add_account_credit() 应创建不可变的账务流水,而不是只执行:
user.balance += amount
更可靠的做法是保存每一笔 credit、debit、refund 和 adjustment,再根据流水计算或校验余额。
订阅不能只处理创建事件
订阅创建成功,不代表以后每个月都能付款成功。至少需要处理以下事件:
invoice.paid:某一期账单付款成功。invoice.payment_failed:付款失败。- 订阅取消或状态变化事件。
- 如果业务需要同步退款和争议状态,还要处理对应事件。
首次订阅可能同时产生 Checkout 和 invoice 相关事件,因此不能在两个事件中重复记入同一笔款项。可以采用下面这套简单规则:
checkout.session.completed只处理mode="payment"的一次性付款。- 所有订阅周期的到账都以
invoice.paid为准。 - 保存 Stripe Customer、Subscription 和 Invoice ID,用于关联本地用户并防止重复记账。
退款怎么做
简单场景可以直接在 Stripe Dashboard 中发起退款,也可以调用 API:
refund = stripe.Refund.create(
payment_intent=payment_intent_id,
amount=amount_cents,
)
调用成功只表示 Stripe 接受了退款请求。本地系统仍需记录退款流水,并通过相应 webhook 同步最终状态。不要删除原付款记录,也不要覆盖原交易金额。
“存入账户”需要特别注意
如果”账户余额”只能用于购买你自己提供的商品或服务,通常可以将其实现为站内 credit ledger。
如果用户可以进行以下操作:
- 提现;
- 向其他用户转账;
- 长期储存资金;
- 将余额兑换成现金;
- 代第三方收付款;
业务可能就不再是普通的 Payment Gateway 集成,而会涉及 stored value、money transmission、KYC、AML 等合规问题。Stripe Checkout 不会自动解决这些问题。此时应先确认业务模式和法律要求,再判断是否需要 Stripe Connect、专门的钱包基础设施或持牌支付合作方。
能否在 10 小时内完成
满足以下条件时,基础版本有机会在 10 小时内完成:
- 只收 USD;
- 只接受美国客户;
- 使用 Stripe 托管的 Checkout;
- 订阅价格固定;
- 不在网站中保存银行卡信息;
- 退款暂时由 Dashboard 操作;
- 已有用户、数据库和 HTTPS 环境;
- 只要求测试环境可用。
生产上线通常需要更多时间,还要完成:
- webhook 幂等和重试测试;
- 订阅取消与付款失败处理;
- 退款、争议和账务核对;
- 日志与告警;
- 密钥管理;
- 税务处理;
- 服务条款和合规审查。
实际选择时,与其寻找一个包办支付页面的 Django 项目,不如直接使用 Stripe Checkout。支付界面交给 Stripe,Django 负责创建会话、验证 webhook 和维护账务状态。
备注:内容仅供参考。
