支付回调设计:幂等、状态机与补偿兜底
支付回调是最容易翻车的接口,没有之一。渠道会重试、会迟到、会带伪造报文来试探。硬扛扛不住,只能拆层,把每一类问题关在自己的笼子里。
1. 安全校验层:进业务之前,先挡掉大部分脏数据
这层只做校验。校验不过直接返回失败,不进业务逻辑。
- 验签:防伪造,签名不对直接拒绝
- 订单号合法性:订单号格式、存在性必须校验
- 金额强校验:回调金额与订单原金额不一致,直接拒绝,不做任何模糊匹配
- 时间戳防重放:超过容忍时间窗口的回调直接丢弃
def pre_check(request):
if not verify_sign(request):
return FAIL
if not order_exists(request.order_no):
return FAIL
if request.amount != order.amount:
return FAIL
if is_replay(request.timestamp):
return FAIL
return PASS
2. 幂等防重层:终态不处理,中间态可更新
幂等 Key 用商户订单号 + 渠道交易号,二者组合作为唯一标识。
流程:
- 先查订单状态
- 是终态 → 直接返回成功,不重复处理
- 是中间态 → 加分布式锁(或数据库唯一索引)后继续走状态流转
核心记住八个字:终态不处理,中间态可更新。
key = f"{request.merchant_order_no}:{request.channel_txn_id}"
with distributed_lock(key):
order = get_order(key)
if order.status in TERMINAL_STATES:
return SUCCESS # 幂等命中,直接返回
# 中间态,继续往下走
3. 状态机防护层:只允许正向流转,禁止回写
严卡状态机,禁止逆向回写,禁止旧状态覆盖新状态。以下规则是硬约束:
- 支付成功后,不再接收关闭、支付失败这类逆向终态回调
- 订单关闭后,不再接收支付成功回调
- 退款完成后,拒绝重复退款回调
# 状态名仅为示意,按各系统实际状态机替换
ALLOWED_TRANSITIONS = {
"UNPAID": {"PAID", "CLOSED"},
"PAID": {"REFUNDING", "REFUNDED"},
"CLOSED": set(),
}
def transition(from_state, to_state):
if to_state not in ALLOWED_TRANSITIONS.get(from_state, set()):
return REJECT # 旧状态覆盖新状态,拒绝
return ACCEPT
4. 异步解耦层:接口只做轻动作,重活全走 MQ
回调接口绝不同步执行 heavy 业务。标准流程:
接收 → 校验 → 幂等判断 → 快速落库/落日志 → 发 MQ → 立即返回渠道成功
先存报文,后执行业务。原始报文必须永久留存,出问题随时可溯源。
def handle_notify(payload):
if pre_check(payload) != PASS:
return FAIL
if idempotent_hit(payload):
return SUCCESS
if transition(payload) != ACCEPT:
return FAIL
save_raw_message(payload) # 先落库,永久留存
mq.send(payload) # 异步消费,执行后续业务
return SUCCESS # 立即返回,不拖通道
5. 补偿兜底层:永远不要信回调 100% 可达
回调可能丢、可能迟到、可能被渠道重试机制搞乱顺序,所以必须有一套补偿链路兜底:
- 定时主动查询:扫描超时未变更状态的订单,主动向渠道发起查单
- 日终三方对账:订单、支付流水、渠道账单三方比对,修复不一致
补偿不是拿来兜低频事故的,是拿来兜“回调不可靠”这个常态的。
回调响应规范
响应策略直接决定渠道重试行为。规则很简单:
- 业务成功 → 返回渠道指定的成功字符串,渠道停止重试
- 业务失败 → 返回失败标识,渠道继续重试
- 幂等命中 → 同样返回成功,渠道不再重试
严禁随意返回、返回 500、返回空响应——渠道会把一切非成功响应当成失败,然后无限重试,直到把你打挂。
六条铁律
- 回调必验签、必校验金额、必校验订单状态
- 必做幂等,终态不重复处理
- 业务异步化,接口只做接收、落库、校验、发消息
- 严格状态机,禁止旧状态覆盖新状态
- 绝不只依赖回调,必须有主动轮询补偿
- 全链路日志留存,每一笔回调变更可追溯