简介:在电商后端开发中,支付系统是连接用户、订单与资金的桥梁,也是考验开发者对业务流程和数据一致性理解深度的典型场景。本文基于Django框架,从订单状态机的数据建模出发,详解支付宝与微信支付两大主流渠道的接入流程,重点剖析异步回调验签、幂等控制、超时关单等生产级难题。通过沙箱环境调试、回调联调和内网穿透部署,完整演示从下单到支付成功的全链路实现。文章还梳理了答辩高频问题与常见踩坑记录,帮助开发者理解支付系统中“私钥签名公钥验签”的安全机制,以及如何保证订单状态在并发场景下不出现混乱。无论你是准备毕业设计还是构建电商项目,都能从中获得一套可落地的支付系统集成方案。
1. 为什么毕设选支付系统:一次把Django核心能力打满
每年毕业季都能看到大量"基于Django的XXX系统"这类题目,但订单支付系统始终是其中比较特殊的一个。原因很简单,其他管理系统做完通常只有一个后台和几张CRUD页面,而支付系统天然包含订单状态机、第三方接口对接、异步回调、签名验证、并发控制这些"真刀真枪"的生产级问题。换句话说,做完这个项目,你几乎把Django后端开发里最值钱的那部分技能都练了一遍。
这个项目的标准组合是Django + 支付宝 + 微信支付,核心业务就一句:用户在商城下单,跳转到支付宝或微信完成付款,支付成功后系统自动把订单状态改成已支付。听起来简单,真正动手之后才会发现,验收一个支付系统,老师不会只问页面长什么样,而是会追问"异步通知怎么保证不重复处理""回调频率高怎么办""订单状态什么时候改""退款怎么处理"。这篇博文就把我从选题到答辩的全过程拆开讲,包括数据库设计、两家支付渠道的接入步骤、回调处理的完整思路,以及那些不写到文档里的坑。无论你是正在做这个毕设,还是打算用Django做电商类项目,这篇都能当一份实操参考来用。
先给一个总览,整个项目大概分四块:Django商城基础(商品、购物车、订单)、支付宝网页支付、微信Native扫码支付、支付回调与订单状态流转。难度排序大概是回调处理 > 微信支付接入 > 支付宝接入 > 基础商城。后面我按这个顺序展开。
2. 订单与支付数据模型:状态机比CRUD重要得多
支付系统跟普通管理系统的本质区别在于:订单不是一个静态的表,而是一个会随着外部事件不断流转的状态机。这个设计一旦想明白,后面写代码就顺了。
2.1 订单表和支付记录表的关系
先说订单表。核心字段除了用户、商品、金额这些基本信息,务必单独存一个status字段,取值范围建议直接定义成枚举常量,不要散落在代码里:
# orders/models.py from django.db import models from django.conf import settings class Order(models.Model): class Status(models.TextChoices): PENDING = 'pending', '待支付' PAID = 'paid', '已支付' CLOSED = 'closed', '已关闭' REFUNDING = 'refunding', '退款中' REFUNDED = 'refunded', '已退款' order_no = models.CharField(max_length=64, unique=True, verbose_name='订单号') user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) total_amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='订单金额') status = models.CharField(max_length=16, choices=Status.choices, default=Status.PENDING) created_at = models.DateTimeField(auto_now_add=True) paid_at = models.DateTimeField(null=True, blank=True)再单独建一张支付记录表:
class Payment(models.Model): class Channel(models.TextChoices): ALIPAY = 'alipay', '支付宝' WECHAT = 'wechat', '微信支付' order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name='payments') channel = models.CharField(max_length=16, choices=Channel.choices) transaction_id = models.CharField(max_length=64, db_index=True, verbose_name='渠道交易号') raw_notify = models.JSONField(verbose_name='回调原始报文') status = models.CharField(max_length=16, default='notified') created_at = models.DateTimeField(auto_now_add=True)为什么订单和支付要分两张表?因为一个订单可能多次发起支付(比如用户扫码后一直没付,重试了几次),如果只在订单表里记一个支付单号,数据就乱了。支付记录表每发起一次支付就插入一条,回调来了按transaction_id去匹配记录,既方便对账,也方便排查"用户到底付到哪个单子上了"这种问题。
2.2 订单号生成规则:别用自增ID
订单号这里有个初学者很容易忽略的坑:绝对不能直接用数据库自增主键当订单号发给支付平台。原因是支付平台的流水号规则要求你这边尽量做到全局唯一、可追溯,而且订单号会出现在支付回调、对账单、用户退款申请里,纯自增ID既不安全也容易被枚举。
我用的方案是时间戳加随机数的组合:
import time import random def generate_order_no(): ts = time.strftime('%Y%m%d%H%M%S') rand = f'{random.randint(1000, 9999)}' return f'{ts}{rand}'字段定义里给order_no加了unique=True,生成后入库前再捕获一次IntegrityError做重试,双保险。实际项目中这个方案完全够用,不需要引入雪花算法之类的重型方案。
2.3 状态流转:只有三种合法路径
订单状态不能想改就改,必须限制合法流转路径。我画了一张流转图,自己写代码时反复对照:
- 待支付 -> 已支付(正常支付成功)
- 待支付 -> 已关闭(超时未支付,或用户主动取消)
- 已支付 -> 退款中 -> 已退款(售后处理)
任何其他跳转,比如"已支付再改回待支付""已关闭又改成已支付",都应该在业务层直接拒绝。实现方式不复杂,写一个change_status方法,里面判断当前状态和目标状态是否在允许的映射里,不在就抛异常。这个方法在支付回调和后台操作里统一调用,避免业务散落在各个视图函数里造成状态失控。
3. 支付宝接入:沙箱环境是调试神器,先跑通再谈证书
支付宝的接入难度在两家里算低的,文档清晰、SDK完善,而且有沙箱环境可以反复测试。我的建议是毕设阶段全部用沙箱完成,把支付链路跑通后再决定要不要上真实商户号。
3.1 开放平台配置和密钥准备
去支付宝开放平台创建应用,开通"电脑网站支付"产品能力。毕设阶段用沙箱环境就够了,沙箱的配置入口在开放平台的"开发服务-沙箱"页面,系统会给你一套专用的APPID、支付宝网关地址和两个测试账号。
重点说密钥。支付宝的签名方式有 RSA2(SHA256withRSA),推荐直接用它。需要生成一对应用私钥和公钥,然后把应用公钥上传到支付宝平台,换回一个支付宝公钥。这里要特别注意:验签用的是支付宝公钥,签名用的是应用私钥,两者不能搞混。我第一次联调时签名一直报错,查了半天发现是把支付宝公钥填到了应用私钥的位置上。平时常说的"私钥签名、公钥验签",在支付宝这个场景就是:你拿应用私钥对请求参数签名,支付宝拿你的应用公钥验签;支付宝返回的通知,它用支付宝私钥签名,你拿支付宝公钥验签。
3.2 用SDK构造支付请求
支付宝官方提供了python-alipay-sdk这个第三方SDK,虽然不是支付宝官方发布的,但社区使用非常广泛,毕设项目直接用它就够了。安装后按官方示例初始化客户端:
from alipay import AliPay, IS_ALIPAY alipay = AliPay( appid='你的沙箱APPID', app_notify_url='https://你的域名/api/payments/alipay/notify/', app_private_key_string=open('keys/app_private_key.pem').read(), alipay_public_key_string=open('keys/alipay_public_key.pem').read(), sign_type='RSA2', is_alipay=IS_ALIPAY, # 沙箱环境传这个 )构造下单页面时,直接调用SDK的api_alipay_trade_page_pay方法生成一个跳转URL,然后让用户浏览器重定向过去:
def create_alipay_order(request, order_id): order = get_object_or_404(Order, id=order_id, user=request.user) order.order_no = generate_order_no() order.save() pay_url = alipay.api_alipay_trade_page_pay( out_trade_no=order.order_no, total_amount=str(order.total_amount), subject='商城订单-{}'.format(order.order_no), return_url='https://你的域名/orders/return/', ) return redirect(pay_url)SDK会帮你把参数排序、拼接、签名这些脏活全部做完,返回的pay_url就是支付宝收银台的完整地址。注意total_amount必须传字符串,不要传浮点数,否则金额精度会出问题。我在测试时不小心传过Decimal对象,直接报参数格式错误。
3.3 同步跳转和异步通知:return是展示,notify才是真相
支付宝有两条结果返回通道:用户在收银台付完钱,浏览器会跳回return_url,这个叫同步通知;同时支付宝服务器会向app_notify_url发一个POST请求,这个叫异步通知。
毕设阶段最容易被问到的点就在这里:同步跳转不能作为支付成功的依据,真正的入账依据必须是异步通知。原因是用户完全可能在跳转前关掉浏览器,或者网络抖动导致同步跳转失败,但钱已经付了。只有异步通知是支付宝服务器主动发到你的服务器上的,可靠性完全不一样。
同步跳转的视图里只做一件事:展示"支付中,请稍候"的页面,然后让前端轮询订单状态接口,看看订单有没有变成已支付。异步通知的视图才是真正干活的:
import json from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse @csrf_exempt def alipay_notify(request): data = request.POST.dict() success = alipay.verify(data, data.get('sign')) if not success: return JsonResponse({'code': 'fail'}) out_trade_no = data.get('out_trade_no') trade_status = data.get('trade_status') if trade_status == 'TRADE_SUCCESS': order = Order.objects.select_for_update().get(order_no=out_trade_no) if order.status == Order.Status.PENDING: order.status = Order.Status.PAID order.paid_at = timezone.now() order.save() Payment.objects.create(...) return JsonResponse({'code': 'success'})这里有两个细节必须交代清楚。第一个是csrf_exempt,因为支付宝服务器发来的POST没有Django的CSRF Token,必须免除,否则回调直接被403挡掉。第二个是返回内容,支付宝要求你返回success这个纯文本才认为你处理成功了,如果你返回其他内容,支付宝会按失败处理并在几小时内持续重发通知。返回JsonResponse虽然你自己看着方便,但支付宝就会一直重试,导致订单被重复处理。所以一定要return HttpResponse('success')。
4. 微信支付接入:Native扫码模式的完整链路
微信支付的接入难度比支付宝高一个档次,主要是因为它没有沙箱环境,必须用真实的商户号调试,而且APIv3的证书体系、平台证书更新、回调解密这几件事都容易踩坑。毕设评审时老师通常对微信支付这部分兴趣最大,因为做出来的人比例不高。
4.1 商户号与APIv3配置
微信支付需要先有一个已认证的小程序或公众号,然后去微信商户平台申请商户号。整个申请流程在真实项目中可能要几天,但学生毕设也可以直接使用测试商户号(微信提供了小微商户测试入口),具体入口在微信商户平台的开发者文档里有指引。
APIv3的核心认证方式是商户API私钥 + 微信支付平台证书 + APIv3密钥三件套。商户API私钥是你在商户平台自己生成的,平台证书可以在商户平台下载,APIv3密钥是一个32字节的字符串,在商户平台设置。这三样东西分别承担:签名请求、验证微信返回、解密回调内容。
4.2 SDK选型和统一下单
微信官方的Python SDK更新比较慢,社区里用得多的是wechatpayv3这个包。它支持使用商户私钥自动签名请求,也封装了回调验签和解密。安装后配置客户端:
from wechatpayv3 import WeChatPay wxpay = WeChatPay( mchid='你的商户号', private_key=open('keys/merchant_private_key.pem').read(), cert_serial_no='商户API证书序列号', apiv3_key='APIv3密钥' )创建Native订单的接口调用如下:
def create_wechat_order(request, order_id): order = get_object_or_404(Order, id=order_id, user=request.user) order_no = generate_order_no() order.save() result = wxpay.pay( description='商城订单-{}'.format(order_no), out_trade_no=order_no, amount={'total': int(order.total_amount * 100)}, notify_url='https://你的域名/api/payments/wechat/notify/', ) code_url = result.get('code_url')这里有一个毕设新手必踩的坑:微信支付的金额单位是分,必须传整数,支付宝传的是元(字符串)。也就是说订单金额是129.99元,微信要传12999,支付宝要传"129.99"。我见过不止一个同学因为忘了乘100,结果测试时支付金额变成1.29元。在真实企业项目里这个问题会导致资金损失,在毕设里至少会被当场问住。
code_url是一个weixin://wxpay/bizpayurl?pr=xxx的链接,把它生成二维码展示给用户,用户用微信扫一扫就能弹出支付页面。生成二维码用Python的qrcode库,输出成base64编码给前端:
import qrcode import qrcode.image.base from io import BytesIO import base64 img = qrcode.make(code_url) buf = BytesIO() img.save(buf, format='PNG') qr_base64 = base64.b64encode(buf.getvalue()).decode()4.3 微信回调的验签与解密
微信的异步通知比支付宝复杂很多。支付宝的通知是普通表单参数直接POST过来,微信的通知是一个加密的JSON结构,里面resource字段的密文需要用APIv3密钥解密后才能拿到真正的交易数据。
wechatpayv3这个包封装了decrypt_callback方法,可以自动完成验签+解密。使用时要先调用callback解析请求头里的微信签名信息,然后传入请求体:
import json from django.views.decorators.csrf import csrf_exempt from django.http import JsonResponse, HttpResponse @csrf_exempt def wechat_notify(request): body = json.loads(request.body) try: result = wxpay.callback(headers=request.headers, body=body) except Exception: return JsonResponse({'code': 'FAIL'}, status=500) if result['trade_state'] == 'SUCCESS': out_trade_no = result['out_trade_no'] transaction_id = result['transaction_id'] # 更新订单状态,处理逻辑与支付宝一致 ... return JsonResponse({'code': 'SUCCESS'})微信平台的Wechatpay-Signature请求头使用的是平台证书做验签,wechatpayv3会自动从微信服务器下载最新平台证书。如果报证书过期或找不到证书,多半是平台证书自动更新逻辑没配好,可以检查一下定时刷新证书的任务是否在执行。
另外,微信同样要求收到通知后返回{"code": "SUCCESS"},如果返回其他内容,微信也会按失败重试。和支付宝不同的是,我们需要返回SUCCESS而不是success。
5. 回调处理:整个支付系统最容易翻车的环节
如果说两家支付平台的接口接入是"照文档就能做",那回调处理就是真正考验设计水平的地方。这一章所有内容基本是我在项目里实际踩过坑之后总结出来的,建议提前消化,不要等联调的时候再受苦。
5.1 幂等性:同一个通知绝不能把订单改两次
支付宝和微信的重试机制决定了回调可能到达多次。支付宝对未正确响应的通知会在24小时内重发8次,微信的规则也类似。即便你的业务逻辑看起来没问题,也应该假设"同一个成功通知至少会被处理两次"。
我把订单状态更新放在了select_for_update()的事务里,并在更新前判断order.status是不是pending。也就是说,只有待支付订单可以被置为已支付,其他状态直接跳过。这样即使两条回调几乎同时到达,事务锁会保证后到的进程看到的状态已经是paid,从而放弃重复更新。
from django.db import transaction with transaction.atomic(): order = Order.objects.select_for_update().get(order_no=out_trade_no) if order.status == Order.Status.PENDING: order.status = Order.Status.PAID order.paid_at = timezone.now() order.save(update_fields=['status', 'paid_at'])5.2 回调验签失败的第一排查点:公钥和私钥混用
回调验签失败是高频问题,我排查过的案例里九成都是密钥配置错误。最常见的三种情况:
- 支付宝签名时用了应用私钥,验签时却传了同一个私钥,应该换成支付宝公钥。
- 微信平台证书类型搞错,应该用
wechatpay_platform_cert.pem而不是商户证书。 - 回调数据在传输中被存储或打印时编码变了,导致验签用的原始报文和收到的报文不一致。
调试技巧:支付宝SDK的verify方法只返回布尔值,失败时不告诉你为什么。我的做法是把回调参数按字典打印出来比对文档,重点看sign字段是否存在、charset是否utf-8、sign_type是否RSA2。微信那边wechatpayv3的异常信息会稍微详细一些,优先看WechatPayException里的err_code。
5.3 支付成功后的业务联动:不只是改个状态
支付完成后通常要做三件事:改订单状态、扣减库存、给用户发通知。毕设一般不需要太复杂,但扣减库存是一个容易忽略的点。正确顺序应该是:先扣库存再改订单状态,放在同一个事务里。否则用户支付成功却发现商品库存不足,就会出现"订单已支付但无法发货"的数据不一致。
这里我用的是乐观锁方案,更新库存时带上stock >= 0的过滤条件:
updated = Product.objects.filter(id=product_id, stock__gte=num).update(stock=F('stock') - num) if not updated: raise ValidationError('库存不足')5.4 超时关单:待支付订单不能一直挂着
支付宝的未支付订单默认在一定时间后自动失效,但你的本地订单状态也需要同步处理。我的做法是Django后台写了一个自定义命令close_expired_orders,每分钟跑一次,把创建时间超过30分钟且仍为pending的订单置为closed。同时调用支付宝的alipay.trade.close接口在渠道侧主动关单,避免用户扫码时发现订单已经过期。
# orders/management/commands/close_expired_orders.py from django.core.management.base import BaseCommand from django.utils import timezone from datetime import timedelta from orders.models import Order class Command(BaseCommand): def handle(self, *args, **options): deadline = timezone.now() - timedelta(minutes=30) expired = Order.objects.filter(status=Order.Status.PENDING, created_at__lt=deadline) count = expired.update(status=Order.Status.CLOSED) print(f'已关闭 {count} 个超时订单')定时任务在Windows开发机上用python manage.py close_expired_orders手动执行,在Linux服务器上配crontab即可。
6. 联调测试、演示准备和答辩高频问题
写完代码只是完成了大概一半工作量,接下来是漫长的联调和测试阶段。这部分做好,答辩基本就稳了。
6.1 完整的测试路线图
我在项目里整理了一套可复现的测试清单,每改一次代码就按这个过一遍:
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 支付宝支付成功 | 沙箱收银台登录买家账号支付 | 订单变已支付,页面跳转成功提示 |
| 支付宝支付取消 | 在沙箱收银台直接关闭页面 | 订单保持待支付,可重新发起 |
| 微信扫码支付成功 | 真机微信扫Native二维码并付款 | 订单变已支付,二维码页面刷新状态 |
| 回调重复通知 | 用Postman手动重发支付宝通知报文 | 订单状态保持已支付,不产生重复操作 |
| 超时未支付 | 修改数据库订单创建时间为1小时前,运行关单命令 | 订单变已关闭,发起支付被拒绝 |
| 金额校验 | 构造一个金额和订单不一致的回调 | 验签失败或业务校验拦截 |
注意支付宝沙箱有自带的"模拟器"工具,可以模拟退款、账单下载这些能力,对毕设演示很有用。沙箱买家和卖家账号在开放平台沙箱页面直接查看,登录密码也可以通过短信重置。
6.2 演示环境准备:不要现场翻车
答辩演示最容易翻车的三个地方我都遇到过:
第一,支付宝沙箱和微信扫码在同一个演示环境里没法同时完美展示,因为微信Native扫码需要真机,如果教室网络不稳,二维码页面半天刷不出来。我的做法是准备两条录制好的演示视频作为后备,同时在演示时先用支付宝沙箱走一遍全流程,再用微信扫码展示一次。
第二,回调地址必须是外网能访问的地址。毕设期间我用的是内网穿透工具把本地的Django服务映射到公网,这样支付宝和微信的回调才能打到本地。这里强调一句:不要在宿舍局域网里直接配回调地址,回调根本进不来。如果不想用内网穿透,就租一台最低配的云服务器部署测试。
第三,数据库里准备好测试数据,不要现场注册账号、现场下单。把商品、库存、用户全部提前造好,演示时只走支付链路,省去无关等待。
6.3 答辩时老师爱问的问题清单
根据我自己的答辩经历和帮同学模拟答辩的经验,以下问题出现频率极高,提前准备:
- 为什么订单号和支付记录要分两张表?答:订单可能多次发起支付,支付记录是流水,订单是当前状态。
- 同步通知和异步通知的区别?答:同步通知是浏览器跳转,可能丢失;异步通知是服务器对服务器,必须作为入账依据。
- 回调重复通知怎么处理?答:事务锁加状态判断,保证幂等。
- 微信和支付宝的金额单位有什么不同?答:支付宝元(字符串),微信分(整数)。
- 验签的原理是什么?答:私钥签名,公钥验签,双向验证身份和数据完整性。
- 如何保证订单在支付成功后库存扣减不超卖?答:乐观锁或事务加锁。
这些问题基本都是围绕支付安全、数据一致性、幂等性展开的。只要项目是自己写的,把回调处理逻辑讲清楚,老师基本不会为难。
7. 个人体会:做完这个项目,我踩过最深的几个坑
分享几个印象深刻的教训,希望能帮你少走弯路。
第一个是关于调试日志的。支付回调这种外部系统触发的请求,出了问题最痛苦的就是"不知道它到底有没有来过"。我前期在本地调试时,每次回调都心里没底,后来在回调视图里加了一段中间件日志,把请求头、请求体、验签结果全部记录到日志文件。不需要什么高级日志系统,logging模块就够。这一招帮我解决了至少三次疑难问题,强烈建议你从一开始就加上。
第二个是关于虚拟环境。Django项目最怕环境不一致,本地跑得好好的,云服务器上一跑报错。我在requirements.txt里固化了所有依赖版本,并且把支付宝、微信支付两个SDK的版本也锁死。尤其wechatpayv3这个包升级比较频繁,接口签名方式会有变化,锁版本能避免很多诡异问题。
第三个是关于数据安全。毕设项目虽然是学习用途,但密钥文件绝对不要提交到Git仓库。我在.gitignore里把keys/目录和.env文件全部忽略掉,密钥通过环境变量加载。答辩演示时也注意不要把APIv3密钥明文贴到PPT里,有同学被老师现场指出这个隐患,场面比较尴尬。
第四个是关于金额计算的精度。整个项目里所有金额字段都用DecimalField,绝不用FloatField。Python的浮点数在涉及分和元的转换时会出现类似0.1 + 0.2 = 0.30000000000000004的问题,一旦金额算错,支付对不上账,排查起来极其痛苦。
最后一个建议:哪怕不打算走开发方向,也值得把支付回调这两套逻辑吃透。它是后端开发里"外部接口对接"这个能力的最典型代表,其他凡是涉及第三方开放平台(短信、对象存储、开放登录)的项目,核心思路都是同一套:构造请求、签名、发起调用、处理异步回调、验签、保证幂等。做完这个毕设,你等于用最少的代码量把这条链路完整走了一遍,后面找实习或者工作时遇到类似的对接需求,基本都能直接上手。
本文还有配套的精品资源,点击获取