☰
Day 11 | 支付集成:让钱到账
2026/10/19 1:54:18 网站建设 项目流程

摘要:上一篇订单系统搞定了,但订单状态停在"待支付"——钱没进来,一切都是空转。今天接支付。核心链路:下单 → 创建支付单 → 调微信/支付宝 → 回调确认 → 更新订单状态。重点讲三件事:支付流程怎么设计、签名验证怎么防篡改、幂等性怎么保证不重复扣款。代码全部可运行,沙箱环境零成本。


支付是电商的命门

订单系统做得再漂亮,钱收不进来就是白搭。

支付模块跟其他模块有一个本质区别:别的模块出 bug,最多挨骂;支付出 bug,直接赔钱。

用户付了两次钱、回调没收到导致订单一直 pending、金额被篡改从 100 变成 0.01——这些都不是段子,是真实出过事故的。

我在做商城支付的时候,光回调接口就改了四版。第一版没做幂等,微信重试了三次,给用户发了三件货;第二版没验签,被人用假回调白嫖了一台手机;第三版金额没校验,订单金额和支付金额对不上……

所以今天这篇文章,我会把每一个坑都标出来。你照着写,至少能少走半年弯路。


原理速览

支付全流程

一笔在线支付,从用户点"付款"到订单变成"已支付",中间经过了这些角色:

用户 ──→ 梅雅达商城 ──→ 微信支付/支付宝 ──→ 银行 │ │ │ ┌────────────────────────────────────┘ │ ▼ │ (银行扣款成功) │ │ │ ▼ └── 支付平台发回调 ──→ 商城回调接口 ──→ 更新订单状态

整个流程分 5 步:

  1. 用户下单:调用 Day10 的下单接口,订单状态 =pending
  2. 创建支付单:生成唯一payment_no,记录金额、订单号
  3. 调起支付:调微信/支付宝的统一下单 API,拿到预支付参数(prepay_id 或支付链接)
  4. 用户支付:前端拉起支付页面,用户输密码、指纹、刷脸
  5. 回调确认:支付平台异步通知回调接口,我们验签 → 校验金额 → 更新订单状态

最关键的是第 5 步——回调处理。因为用户的浏览器可能关掉、网络可能断开,只有支付平台的异步回调才是"可信的最终结果"。

签名验证:怎么知道回调是真的?

支付平台回调你的服务器时,会带一个签名(sign)。你需要用平台的公钥或 API 密钥验证这个签名。

原理很简单:

支付平台把回调参数按字典序排列,拼接成字符串,用密钥做哈希,得到签名。

你的服务器拿到同样的参数、同样的拼接规则、同样的密钥,自己算一遍哈希,对比结果。

一致 → 是真的回调;不一致 → 有人在伪造请求。

为什么必须验签?因为回调 URL 是公网可达的,任何人都可以构造一个假请求发过来。不验签,就等于你家大门没锁。

幂等性:同一笔支付只处理一次

微信的回调有个特点:如果没收到你的成功响应,它会重试,而且可能重试很多次。

重试间隔大概是:15s → 30s → 3m → 10m → 20m → 30m……

如果你不做幂等处理,同一个"支付成功"回调被处理了 3 次——恭喜你,给用户发了 3 次货。

幂等的核心思路就一句话:拿到回调,先查这个 payment_no 是不是已经处理过了。处理过了就直接返回成功,别再动了。


动手:从支付单模型到回调处理

支付单模型

先建支付单表。它和订单一对一——一笔订单对应一笔支付。核心字段设计要点:

classPayment(models.Model):payment_no=models.CharField(max_length=64,unique=True,db_index=True)# 支付单号,全局唯一order=models.OneToOneField('order.Order',on_delete=models.PROTECT)# 关联订单amount=models.DecimalField(max_digits=10,decimal_places=2)# 金额,必须 Decimal!channel=models.CharField(max_length=20)# wechat / alipaystatus=models.CharField(max_length=20,choices=STATUS_CHOICES,default='created')trade_no=models.CharField(max_length=128,blank=True,default='')# 平台交易号,退款要用callback_data=models.JSONField(default=dict,blank=True)# 回调原始数据,留证paid_at=models.DateTimeField(null=True,blank=True)

几个细节:

  • 金额用DecimalField,绝对不要用FloatField。浮点数精度在金融场景是灾难——0.1 + 0.2在 Python 里等于0.30000000000000004,你能接受用户多付 4 分钱吗?
  • callback_data用 JSONField 存原始回调:万一出了纠纷,可以把支付平台原始报文甩出来当证据。
  • trade_no存平台交易号:退款时要带着这个号去找平台。

创建支付单 & 调起微信支付

核心流程(关键片段):

defcreate_payment(order,channel='wechat'):# 1. 校验订单状态——只有 pending 才能创建支付单iforder.status!='pending':return{'code':400,'message':f'订单状态异常:{order.status},无法支付'}# 2. 防止重复创建——已存在且状态正常的直接返回existing=Payment.objects.filter(order=order).first()ifexistingandexisting.status==Payment.STATUS_CREATED:return{'code':0,'message':'success','data':_build_wechat_params(existing)}# 3. 金额校验:实付金额必须 > 0iforder.total_amount<=Decimal('0'):return{'code':400,'message':'订单金额为0,无需支付'}# 4. 创建支付单(事务中)withtransaction.atomic():payment=Payment.objects.create(payment_no=generate_payment_no(),order=order,amount=order.total_amount,channel=channel)# 5. 调微信统一下单,拿 prepay_id# 6. 构建前端拉起支付需要的参数(appId/timeStamp/nonceStr/package/paySign)return{'code':0,'message':'success','data':pay_params}

签名算法(这是防篡改的核心,思路必须清楚,实现可以靠 SDK):

1. 参数按 key 字典序排列 2. 拼成 URL 参数格式 stringA:k1=v1&k2=v2... 3. 末尾拼上 &key=API密钥 4. MD5(或 HMAC-SHA256 / RSA2)哈希,转大写

微信统一下单参数里有个容易踩的坑:total_fee单位是"分",要把元* 100转成整数。

回调处理——重中之重

这部分单独拎出来,因为这里踩坑最多。微信会在这个接口做多次重试,我们必须保证 4 件事(核心框架):

@csrf_exempt# 微信回调不走 CSRF@require_POSTdefwechat_payment_notify(request):# ① 解析 XML 回调数据(失败返回 FAIL,别让微信死等)# ② 验签:收到的 sign 和本地算出来的对比,不一致直接 FAIL# ⚠️ 签名不一致 = 有人在伪造回调,必须拒绝# ③ 检查 result_code == 'SUCCESS',非成功直接返回 SUCCESS 停止重试# ④ 按 payment_no 查支付单,查不到返回 FAIL# ⑤ 幂等检查(核心!):payment.status == PAID 直接返回 SUCCESS,别再处理# ⑥ 金额校验:回调金额(分→元)必须 == payment.amount,不一致返回 FAIL 让人工介入# ⑦ 事务 + select_for_update 行锁更新:payment → paid,order → paid# 拿到锁后再做一次幂等检查(双保险,防并发)# ⑧ 返回 SUCCESS 告诉微信"我收到了",停止重试

回调处理的每步都不能省,画个重点:

步骤做什么不做会怎样
验签确认是微信发的有人伪造回调,白嫖商品
幂等检查防止重复处理一笔钱发三次货
金额校验回调金额 == 订单金额金额被改成 0.01 元
select_for_update并发安全两个回调同时进来,状态更新冲突
返回 SUCCESS停止微信重试微信一直重试,服务器被打爆

一个细节:金额校验失败时不要返回 SUCCESS,让微信继续重试,人工介入排查。

支付宝和银联怎么接?

思路跟微信一模一样,只是 API 细节不同:

  • 支付宝:签名用 RSA2(比微信的 MD5 更安全),回调是 POST 表单(不是 XML),SDK 用官方alipay-sdk-python,沙箱环境更友好。
  • 银联:签名用 RSA-SHA256,需要申请银联商务商户号,接口风格偏传统。

核心流程都是:创建支付单 → 调统一下单 → 拿预支付参数 → 前端拉起 → 异步回调 → 验签 → 幂等 → 金额校验 → 更新状态。

建议抽象一个支付基类,加新渠道只新增子类,不改回调核心逻辑(策略模式):

classBasePaymentChannel:"""支付渠道基类,所有渠道实现这四个方法"""defcreate_order(self,payment):raiseNotImplementedError# 统一下单defverify_notify(self,raw_data):raiseNotImplementedError# 验签defquery_order(self,payment_no):raiseNotImplementedError# 主动查询(补偿)defrefund(self,payment,amount):raiseNotImplementedError# 退款classWechatPayChannel(BasePaymentChannel):pass# 实现上面四个方法classAlipayChannel(BasePaymentChannel):pass

支付安全清单

生产环境的支付安全,每一条都是用血泪教训换来的:

幂等性保障(三层)

第一层:数据库唯一索引(payment_no)→ 防止创建重复支付单 第二层:状态检查(if payment.status == PAID: return)→ 防止重复处理回调 第三层:select_for_update 行锁 → 防止并发更新

金额校验

# 永远不要信任回调里的金额,一定要和数据库订单金额对比# 注意三点:# 1. 单位转换:微信用"分",我们用"元"# 2. 精度:用 Decimal,不用 float# 3. 对比的是 order.total_amount(优惠后的实付金额)

其他安全要点

  • 回调 URL 必须用 HTTPS:明文传输 = 裸奔
  • API 密钥不要硬编码:放环境变量或配置中心
  • 退款接口要做权限校验:只有管理员能发起退款
  • 所有支付操作都要记日志:出了问题要能追溯到每一笔
  • 防重放:回调带 nonce + timestamp,超过 5 分钟的请求直接拒绝

用 AI 生成回调测试用例

支付回调的测试用例,手写很痛苦——要模拟各种正常和异常场景。AI 就很好用。

关键提示词方向:

请为微信支付回调接口生成测试用例,覆盖: 1. 正常支付成功(验签通过、金额匹配、订单更新为 paid) 2. 伪造签名(应拒绝,订单状态不变) 3. 金额篡改(回调 0.01 元,订单 99 元,应拒绝) 4. 重复回调(第二次直接返回 SUCCESS,不重复处理) 5. 支付单不存在(返回 FAIL)

AI 生成测试的关键价值在两点:

  1. 穷举场景:AI 擅长把正常流程 + 所有异常分支列全。手动写大概率漏掉"伪造签名"、"重复回调"这种边界。
  2. 模板代码快:构造 XML 回调、mock 签名函数这些样板,AI 几秒完成,你只需审查逻辑对不对。

学完来练练手

  1. 添加主动查询补偿:写个定时任务,每 5 分钟扫描status=created且创建超 30 分钟的支付单,主动调微信"查询订单"接口确认支付结果。(这是对付"回调丢了"的兜底方案)

  2. 接入支付宝:基于BasePaymentChannel基类实现AlipayChannel,让项目支持双渠道。

  3. 退款功能:实现全额退款和部分退款接口,注意退款也要做幂等处理。

做完以后,把你的代码发到评论区,我帮你 review。


配套文件day11_payment.py(models + services + views + tests,含微信/支付宝双渠道、退款、定时补偿)可参考学习。

本系列为梅雅达编程笔记原创,首发于 CSDN。有问题?评论区见 🍃

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询