给会员功能接了支付之后,我经历了大概两周的"睡不踏实"期。每天早上的第一件事是打开后台看:有没有"付了钱没开通"的订单?
为什么焦虑?因为支付这件事有三个特性:
- 出错的代价是用户直接损失钱,客诉是最难处理的那种;
- 链路是异步的,你不知道哪一步会掉;
- 出问题的时候你往往是最后一个知道的(用户发现没开通才会来找你)。
跑了半年、处理了几千笔订单之后,我终于能睡踏实了——不是因为没出过问题,而是因为每个可能出问题的环节都有兜底。这篇把这套兜底写下来:异步通知的处理、幂等、掉单补偿、每日对账。
TL;DR:四个必须做对的事——只信异步通知,不信同步返回(同步返回只用于页面跳转);回调必须验签,并且要校验金额和订单号;处理必须幂等(用订单号做唯一键 + 状态机条件更新,重复通知直接返回成功);必须有掉单补偿(定时任务主动查单)和每日对账(本地订单 vs 支付平台账单)。另外:回调里不要用"查询订单状态再更新"的写法,要用条件更新(
UPDATE ... WHERE status='PENDING')避免并发重复入账。
目录
- 一、支付的完整链路与"权威数据源"
- 二、同步返回 vs 异步通知:只信后者
- 三、验签:必须做的第一件事
- 四、幂等:同一笔钱不能入账两次
- 五、掉单补偿:主动查单
- 六、金额与订单号校验:防篡改
- 七、并发:别让两条回调同时改一个订单
- 八、对账:每天一次的兜底
- 九、退款:同样的三件事再来一遍
- 十、日志与告警
- 十一、坑清单
一、支付的完整链路与"权威数据源"
一次支付涉及这些步骤:
1. 用户点「开通会员」
↓
2. 服务端创建订单(状态 PENDING,生成 out_trade_no)
↓
3. 调用支付平台「下单」接口,拿到支付链接/二维码
↓
4. 用户完成支付(在支付平台的页面/App 里)
↓
5a. 同步返回:支付平台把浏览器跳回你的 return_url(带参数)
5b. 异步通知:支付平台服务器 POST 你的 notify_url(可能多次)
↓
6. 服务端确认收款 → 发货(开通会员)
↓
7. 用户看到"开通成功"
关键点:支付平台的"权威数据源"是它自己那边。你的本地订单只是"待确认"。所以第 6 步的依据只能是:
- 异步通知(第 5b);或者
- 你主动去查(第 5a 之后或者定时补偿时调用查询接口)。
绝不能依据同步返回的参数——那个参数是 GET 请求带回来的,任何人都能构造。
二、同步返回 vs 异步通知:只信后者
| 对比项 | 同步返回(return_url) | 异步通知(notify_url) |
|---|---|---|
| 谁发起 | 浏览器跳转 | 支付平台服务器主动 POST |
| 可靠性 | 不可靠(用户可能关页面、网络断) | 可靠(平台会重试多次直到你返回成功) |
| 可伪造 | 容易(URL 参数) | 难(有签名,但仍必须验签) |
| 用途 | 只用于页面跳转和展示 | 发货的唯一依据 |
我的处理:
def pay_return(request):
"""同步返回:只做展示,不改订单状态!"""
out_trade_no = request.GET.get('out_trade_no')
# 查一下本地状态,告诉用户"正在确认支付结果"
order = Order.objects.filter(out_trade_no=out_trade_no).first()
return render(request, 'pay_return.html', {'order': order})
页面上写"支付结果确认中,请稍候刷新"——因为异步通知可能还没到。前端过几秒再查一次订单状态(轮询),通常 1~3 秒内就能确认。
用户体验上的关键:不要在同步返回页写"支付失败",因为它不可靠。写"确认中",然后靠轮询给结果。
三、验签:必须做的第一件事
不验签的回调接口等于公开的后门——任何人 POST 一个 trade_status=TRADE_SUCCESS 就能白嫖。
支付宝(RSA2)的验签流程:
import base64
import urllib.parse
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
def verify_sign(params: dict, sign: str, alipay_public_key: str) -> bool:
"""验证支付宝回调签名。
params: 回调的所有参数(dict),sign: 签名值(base64)
"""
# 1. 剔除 sign 和 sign_type
data = {k: v for k, v in params.items() if k not in ('sign', 'sign_type')}
# 2. 按 key 的 ASCII 升序排序
items = sorted(data.items())
# 3. 拼成 key=value&key=value(值要 URL 解码后的原值)
content = '&'.join(f'{k}={urllib.parse.unquote(str(v))}' for k, v in items)
# 4. 用支付宝公钥验证
pub_key = serialization.load_pem_public_key(
f'-----BEGIN PUBLIC KEY-----\n{alipay_public_key}\n-----END PUBLIC KEY-----'.encode()
)
try:
pub_key.verify(
base64.b64decode(sign),
content.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception:
return False
四个容易错的地方:
- 参数要剔除
sign和sign_type,只对其余参数排序; - 值要 URL 解码(Django 的
request.POST已经解过一次了,注意别解两次); - 排序是按 key 的 ASCII 升序,不是字典序的中文排序;
- 公钥格式:支付宝给的是一行 base64 字符串,要补上 PEM 头尾(或者每 64 字符换行)。
用官方 SDK 更省事(python-alipay-sdk / alipay-sdk-python),它封装了验签。但要知道它在做什么,否则出了问题没法排查(比如参数顺序不对导致验签失败,你根本不知道为什么)。
验签失败怎么处理:返回 fail(让平台重试)+ 记日志并告警。正常情况下验签不会失败,失败要么是配置错了(公钥用错),要么是有人在试探。
四、幂等:同一笔钱不能入账两次
支付平台的异步通知会重复发。原因很多:你响应慢了、网络抖动、你返回了非成功状态码。平台的策略通常是"一直重试到成功为止"(可能重试几十次、持续几天)。
所以回调处理必须幂等:
from django.db import transaction
from django.db.models import F
@csrf_exempt
@require_POST
def alipay_notify(request):
params = request.POST.dict()
sign = params.get('sign', '')
# 1. 验签
if not verify_sign(params, sign, settings.ALIPAY_PUBLIC_KEY):
logger.warning('alipay notify sign verify failed: %s', params.get('out_trade_no'))
return HttpResponse('fail')
out_trade_no = params.get('out_trade_no')
trade_status = params.get('trade_status')
trade_no = params.get('trade_no') # 平台流水号
total_amount = params.get('total_amount')
# 2. 只处理"成功"状态(还有 TRADE_CLOSED / WAIT_BUYER_PAY 等)
if trade_status not in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
return HttpResponse('success') # 非成功状态也要返回 success,否则平台一直重试
try:
with transaction.atomic():
# 3. 加锁取订单
order = Order.objects.select_for_update().get(out_trade_no=out_trade_no)
# 4. 金额校验(关键!)
if Decimal(total_amount) != order.amount:
logger.error('amount mismatch: %s vs %s', total_amount, order.amount)
return HttpResponse('fail')
# 5. 幂等:已经处理过就直接返回
if order.status == 'PAID':
return HttpResponse('success')
# 6. 状态推进 + 发货
order.status = 'PAID'
order.trade_no = trade_no
order.paid_at = timezone.now()
order.save(update_fields=['status', 'trade_no', 'paid_at', 'updated_at'])
fulfill_order(order) # 开通会员等
except Order.DoesNotExist:
logger.error('order not found: %s', out_trade_no)
return HttpResponse('fail') # 订单不存在,让平台重试(可能是我们的主从延迟)
return HttpResponse('success')
几个要点:
- 非成功状态也要返回
success。否则WAIT_BUYER_PAY(等待付款)这种状态会让平台一直重试,而你永远不该给它发货。 select_for_update()加行锁(在事务内),防并发。- 幂等判断在锁内做(先查再改,不能先查后改)。
- 订单不存在时返回
fail让它重试——这种情况通常是主从延迟(写主库读从库),重试一次就好了。但要注意:如果订单真的不存在,平台会一直重试,所以要有重试次数上限的告警。
更稳的幂等写法(条件更新):
# 用条件更新代替"查询 + 判断 + 更新",避免任何并发窗口
updated = Order.objects.filter(
out_trade_no=out_trade_no,
status='PENDING', # ← 只有待支付才能改成已支付
).update(status='PAID', trade_no=trade_no, paid_at=timezone.now())
if updated == 0:
# 没更新到 → 要么订单不存在,要么已经处理过了
logger.info('order %s already processed or not found', out_trade_no)
return HttpResponse('success')
# 更新成功了才发货
order = Order.objects.get(out_trade_no=out_trade_no)
fulfill_order(order)
这个写法是我最后采用的:UPDATE ... WHERE status='PENDING' 是原子的,数据库保证只有一次能成功。比"select_for_update + if 判断"更难写错。
五、掉单补偿:主动查单
异步通知可能永远不到(你的服务当时挂了、notify_url 配错了、平台通知失败且重试次数用尽)。这时候用户付了钱但没开通——最严重的客诉。
必须有一个主动查单的定时任务:
@shared_task
def reconcile_pending_orders():
"""扫描超过 N 分钟仍未支付的订单,主动向支付平台查询真实状态。"""
cutoff = timezone.now() - timedelta(minutes=5)
# 只处理"创建超过 5 分钟、还处于 PENDING"的订单
qs = Order.objects.filter(
status='PENDING',
created_at__lt=cutoff,
created_at__gt=timezone.now() - timedelta(days=1), # 只补最近一天
).values_list('id', 'out_trade_no')[:200]
for order_id, out_trade_no in qs:
try:
result = alipay_query(out_trade_no) # 调用 alipay.trade.query
except Exception as e:
logger.warning('query failed %s: %s', out_trade_no, e)
continue
if result.get('trade_status') in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
# 走和回调一样的处理逻辑(同一个函数!)
handle_paid(out_trade_no, result.get('trade_no'), result.get('total_amount'))
elif result.get('trade_status') == 'TRADE_CLOSED':
Order.objects.filter(id=order_id, status='PENDING').update(status='CLOSED')
# WAIT_BUYER_PAY:用户还没付款,跳过
关键点:
- 补偿逻辑和回调逻辑调用同一个函数(
handle_paid),保证行为一致——不要写两份,两份一定会不一致; - 只扫最近一天(老订单不补偿,交给对账);
- 要有频率控制(每 5 分钟跑一次,每次最多 200 条);
- 查单接口也可能失败,失败了下次再试。
用户侧的兜底:在"我的订单"页面放一个"我付了款但没到账?点击刷新状态"的按钮,用户点了就主动查一次单。这个按钮能消掉大部分客诉(不用等定时任务)。
六、金额与订单号校验:防篡改
即使验签通过,也要校验业务字段:
if Decimal(params['total_amount']) != order.amount:
logger.error('amount mismatch')
return HttpResponse('fail')
if params.get('seller_id') and params['seller_id'] != settings.ALIPAY_SELLER_ID:
logger.error('seller mismatch')
return HttpResponse('fail')
为什么验签通过了还要校验金额?
场景:攻击者用自己的账号发起一笔真实的小额支付(比如 0.01 元),拿到一个合法的签名回调,然后把 out_trade_no 改成别人的大额订单号。验签依然通过(因为参数是他自己那笔交易的),如果只看 out_trade_no 就给他发货了。
加了金额校验之后:0.01 ≠ 999,拒绝。
seller_id 校验同理:确认这笔钱是打给你的,不是打给别人的(早期接口有这种风险)。
七、并发:别让两条回调同时改一个订单
前面讲过用条件更新(UPDATE ... WHERE status='PENDING')。这里补充"发货"环节的并发问题:
def fulfill_order(order):
"""发货:开通会员。也要幂等。"""
user = order.user
# 用 get_or_create + 幂等键(订单号)保证不重复发放
obj, created = Membership.objects.get_or_create(
user=user,
source_order=order,
defaults={
'level': order.product.level,
'expires_at': timezone.now() + timedelta(days=order.product.days),
},
)
if not created:
logger.info('membership already granted for order %s', order.out_trade_no)
return
# 续期逻辑(如果已有会员,延长而不是覆盖)
extend_membership(user, order.product)
发货也要幂等:用订单号做唯一标识(source_order 唯一),重复调用不会重复发放。
更复杂的场景是"续期":用户已有会员,再买一年,应该是"延长到期时间"而不是"重置为一年"。这里要用数据库层面的累加,不能"读出来 + 加天数 + 写回去"(并发下会丢一次):
from django.db.models import F
from django.utils import timezone
# 安全:数据库层累加
Membership.objects.filter(user=user).update(
expires_at=F('expires_at') + timedelta(days=365) # 注意:Django 对 timedelta 的支持
)
# 或者更明确地用 SQL 函数
Django 的 F() 表达式对 timedelta 的支持取决于数据库后端(PG 支持 + interval)。不确定时可以用 Func 或者干脆在事务里用 select_for_update。
八、对账:每天一次的兜底
对账是最后一道防线:不管你的回调、补偿写得多么正确,每天都要把"本地订单"和"平台的账单"对一遍。
流程:
1. 每天凌晨下载前一天的平台账单(对账单 API)
2. 逐条比对:平台有的 / 本地有的 / 金额是否一致 / 状态是否一致
3. 差异分类处理:
- 平台有、本地没有 → 漏单(补发货)
- 本地已支付、平台没有 → 可能的假支付(重点查!)
- 金额不一致 → 人工处理
4. 输出对账报告(邮件/群消息)
下载对账单(支付宝 alipay.data.dataservice.bill.downloadurl.query):
@shared_task
def daily_reconcile(date_str=None):
"""每日对账。date_str: '2026-03-11',默认昨天。"""
date_str = date_str or (timezone.localdate() - timedelta(days=1)).isoformat()
# 1. 拿账单下载链接
url = alipay_bill_download_url(date_str)
# 2. 下载并解析(支付宝账单是 csv,gbk 编码,最后一行有汇总)
rows = download_and_parse_bill(url)
# 3. 本地订单
local = {o.out_trade_no: o for o in Order.objects.filter(
created_at__date=date_str, status='PAID')}
diff_missing = [] # 平台有、本地没有(漏单)
diff_extra = [] # 本地有、平台没有(可疑)
diff_amount = [] # 金额不一致
for row in rows:
if row['trade_status'] not in ('TRADE_SUCCESS', 'TRADE_FINISHED'):
continue
no = row['out_trade_no']
if no not in local:
diff_missing.append(row)
elif Decimal(row['total_amount']) != local[no].amount:
diff_amount.append((row, local[no].amount))
for no, order in local.items():
if no not in {r['out_trade_no'] for r in rows}:
diff_extra.append(order)
# 4. 报告
report = {
'date': date_str,
'platform_count': len(rows),
'local_count': len(local),
'missing': diff_missing,
'extra': diff_extra,
'amount_mismatch': diff_amount,
}
send_report(report) # 发邮件/群
if diff_missing:
logger.error('reconcile: %d orders missing locally!', len(diff_missing))
# 自动补发货(谨慎:先人工确认,或者只对明确的漏单自动补)
return report
"本地有、平台没有"是最危险的一类——说明有人伪造了支付(或者你的回调验签有问题)。这种必须人工查。
我们的策略:
missing(漏单):自动补发货(因为钱确实收到了,不发货是违约);extra和amount_mismatch:只告警,人工处理。
对账报告每天都看,哪怕全是零。它的价值在于"有异常立刻发现",而不是"等用户投诉"。
九、退款:同样的三件事再来一遍
退款和支付是镜像的,同样要注意:
- 幂等:退款请求要有唯一的
out_request_no(退款单号),重复提交同一单号不会重复退; - 异步:退款也有异步通知(
refund_status),要处理; - 金额校验:部分退款时要算剩余可退金额,不能超退:
def request_refund(order, amount):
with transaction.atomic():
locked = Order.objects.select_for_update().get(id=order.id)
refunded = locked.refunded_amount or Decimal('0')
refundable = locked.amount - refunded
if amount > refundable:
raise ValueError(f'可退金额不足:{refundable}')
# 记录退款单(幂等键:out_request_no)
Refund.objects.create(order=locked, amount=amount, out_request_no=uuid4().hex)
locked.refunded_amount = refunded + amount
locked.save(update_fields=['refunded_amount'])
# 调用退款 API(在事务外,避免长事务)
alipay_refund(order.out_trade_no, amount, out_request_no)
"超退"是退款最常见的 bug:并发下两个退款请求都读到"可退 100",各退 100,实际退了 200。用 select_for_update + 事务内校验避免。
十、日志与告警
支付链路的日志要完整但不泄密:
logger.info('pay notify received', extra={
'out_trade_no': out_trade_no,
'trade_status': trade_status,
'amount': total_amount,
'sign_ok': sign_ok,
})
不要记:用户的完整手机号、身份证、支付账号、完整的回调参数(可能含敏感信息)。
必须告警的指标:
| 指标 | 阈值 | 说明 |
|---|---|---|
| 验签失败次数 | > 0 | 有人在试探或者配置错了 |
| 掉单率(补偿任务补成功的订单数) | > 0 | 回调链路有问题 |
| PENDING 超过 30 分钟的订单数 | > 5 | 补偿没生效 |
| 对账差异(extra) | > 0 | 最高优先级,可能是假支付 |
| 回调处理失败次数 | > 3/小时 | 代码有 bug |
"对账差异 extra > 0"是 P1 告警,因为它意味着可能有经济损失。
十一、坑清单
- 以同步返回为准发货 → 可伪造,白嫖。只信异步通知。
- 回调不验签 → 任何人能构造"支付成功"。必须验签。
- 验签时没剔除 sign/sign_type → 验签永远失败。
- 验签时值解了两次 URL 编码 → 验签失败(难排查)。
- 非成功状态返回 fail → 平台无限重试
WAIT_BUYER_PAY,日志爆炸。返回 success。 - 回调不幂等 → 平台重发一次就重复发货。条件更新。
- 不校验金额 → 小额签名改订单号白嫖大额商品。
- 不校验 seller_id → 钱可能不是打给你的。
- 订单不存在返回 success → 平台不再重试,这笔钱永远掉单。返回 fail(但要防无限重试)。
- 没有掉单补偿 → 回调丢了用户就白付钱。必须有定时查单。
- 补偿逻辑和回调逻辑写了两份 → 行为不一致。抽成同一个函数。
- 没有对账 → 所有错误都靠用户发现。每天对账。
- 对账差异不告警 → 对账白做了。
- 退款不做幂等/不校验可退金额 → 超退、重复退。
- 支付日志记了敏感信息 → 泄露风险。
- 沙箱环境和生产用同一套配置 → 沙箱的"支付成功"打到生产库(我见过,很惨)。环境隔离。
- 回调接口被 CSRF 中间件拦截 → 平台 POST 被拒(要
@csrf_exempt),但必须验签来补偿。 - notify_url 配成了内网地址/带端口 → 平台访问不到,全部掉单。上线前用小金额真实支付测一遍。
最后说说心态上的变化。
刚接支付的时候,我把注意力都放在"怎么让支付成功"上。跑了半年之后我明白:支付系统的质量不取决于"正常流程多顺畅",而取决于"异常流程有多完善"。
正常流程谁都能写对——调个接口、处理个回调、发货,半天搞定。但真正决定用户体验的是:
- 回调丢了能不能补?(掉单补偿)
- 回调重发了会不会重复发货?(幂等)
- 有人伪造能不能挡住?(验签 + 金额校验)
- 系统算错了能不能发现?(每日对账)
这四个"兜底"加起来大概是正常流程工作量的三倍。但这三倍是值得的——它换来的是"出了问题我能自动发现并自动修复",而不是"等用户在群里骂我"。
还有一条实操建议:上线前一定要用小金额做真实的端到端测试(比如 0.01 元),并且要覆盖这几种情况:
- 正常支付成功;
- 支付后立刻关掉页面(不同步返回,只靠异步通知);
- 让你的 notify_url 故意返回 500,看平台会不会重试、重试几次、间隔多久;
- 手动构造一条重复的回调,看会不会重复发货。
第 3 和第 4 条最重要——它们是真实会发生的异常,而且你只有在测试环境故意触发过,生产上遇到时才不会慌。