订单超时关单撞上支付回调:微信返回 ORDERPAID 时不要当关单失败
适用前提与不适用场景
本地订单有明确状态机:PENDING → PAID / CLOSED,且微信支付走 v3 异步回调。定时任务会扫描超时未支付订单并调微信关单,同时支付回调可能在任何时刻到达。
付款码支付不适用本文的 Close Order 主线。付款码场景用撤销 Revoke API,官方建议在发起支付 30 秒到 1 分钟之间调用,一经调用即冲正:未支付则关闭,已支付则退款。收银台支付才用关单 Close Order。两条链路的查单设计也不同。
下面的接口路径、错误码与时间间隔来自微信支付官方文档;未在真实商户号实测,以官方文档为准。
竞态长什么样
一次典型的单边账不是“回调丢了”,而是关单和回调撞车:
- 用户实际支付成功。
- 微信异步通知在途,网络或系统波动导致商户暂未收到。
- 本地定时任务按
PENDING判断已超时,发起关单。 - 若微信侧订单已支付,关单会返回
ORDERPAID;若本地先落CLOSED,随后回调才到,就会出现“微信已扣款、本地已关闭”的幽灵订单。
微信支付《单边账防控指引》描述的正是这个结果:用户支付成功但商户未收到支付结果通知,本地显示未支付,可能引发投诉甚至重复支付。
反方向也一样:本地已置 PAID,定时任务拿着旧快照去关单,微信返回 ORDERPAID。这个错误码不是“关单失败”,而是“订单已支付”。
状态更新:把前置状态写进 WHERE
关单任务不要先 SELECT 判断状态再 UPDATE。检查后执行之间存在 TOCTOU 窗口,并发下必然漏。把前置状态直接写进 WHERE,用影响行数判断结果:
UPDATE orders
SET status = 'CLOSED', closed_at = NOW()
WHERE order_no = ?
AND status = 'PENDING';
RowsAffected = 1:本地抢占关闭成功。再去调微信关单。RowsAffected = 0:并发下已被支付或被别处关闭,必须二次查库判定,不能直接当失败。
二次查库的分支:
- 查到
status = 'PAID':按已支付处理,幂等返回成功,不要再调微信关单。已支付订单无法关闭。 - 查到
status = 'CLOSED':已被别处关闭,直接返回。 - 查不到或状态异常:走告警/人工。
支付回调的落库同理:
UPDATE orders
SET status = 'PAID', transaction_id = ?, paid_at = NOW()
WHERE order_no = ?
AND status = 'PENDING';
RowsAffected = 0 时查库:
- 已是
PAID:幂等返回成功,微信不会因为重复通知反复纠缠。 - 是
CLOSED:本地先关了,但微信实际已支付。不能把回调拒掉,要进入退款或补单分支。
状态机保持单向,禁止从 CLOSED 回退到 PAID 再回退。回退会让对账、库存、履约全部失去确定状态。
调微信关单:ORDERPAID 的正确读法
v3 关单接口:
POST /v3/pay/transactions/out-trade-no/{out_trade_no}/close
官方约束里有一条容易踩:订单生成后不能马上关单,最短调用间隔 5 分钟。超时任务的重试节奏要避开这个窗口,否则会拿到无意义的失败。
常见错误码的处理方式:
ORDERPAID:订单已支付。按订单支付成功处理,不是关单失败,不要重试关单,不要继续把本地订单置为关闭。ORDER_CLOSED:订单已关闭,无需再调。ORDER_NOT_EXIST:订单不存在,按本地数据异常处理。
关键点在于:ORDERPAID 是微信给你的状态纠正。收到它,应当把本地订单当作已支付交易继续走后续流程。若 POS 或前置链路已按撤销处理、无法更新为已支付,则对该订单发起退款。
迟到回调不能拒
本地已经 CLOSED,随后收到验签通过、金额与商户号匹配的支付成功通知,仍然要受理。原因很直接:关单只对未支付成功的订单生效;微信侧一旦支付成功,关单本身就会报 ORDERPAID。本地关闭不代表微信没收到钱。
处理顺序:
- 验签、校验
out_trade_no、金额、商户号。 - 落支付流水,
transaction_id做唯一约束,保证回调幂等。 - 订单状态按
PENDING → PAID条件更新。 - 若
RowsAffected = 0且当前是CLOSED,记录冲突,转入退款/补单队列。
回调里不要同步调退款接口。退款是外部调用,慢且可能失败,放在异步队列里重试。否则回调超时,微信重试,本地又开一次退款,账更乱。
查单能力:事前规避
指引给出的事前规避是建立查单能力,付款码支付与收银台支付两套设计。
收银台支付:订单过期时触发一次查单,保证未支付订单关单、已支付订单退款,避免单边账。建议两条查单链路:
- 前端回调触发一次查单;
- 下单即入队,延迟到超时窗口后异步查一次。
两条链路互为兜底。回调没到,队列查单能发现微信已支付;队列延迟,前端回调也能提前收敛。
多节点部署时,超时任务用分布式锁防重复执行。锁的粒度按订单号,不要按任务批次,否则一个节点挂住会拖住整批。
T+1 对账:单边账与资金核对
事中发现靠 T+1 账单。拿交易账单里的商户订单号去匹配自身系统交易明细。
“微信有、本地无”就是单边账:用户已支付,且微信已结算或即将结算。确认已结算后,对该笔发起退款。
资金对账公式:
应结算金额 = Settlement currency amount - Fee - Refund settlement amount
逐行汇总后,匹配结算账单的 Net Settlement Amount,再匹配银行账单实收。账单覆盖中国时间 0 到 24 点,注意 cutoff 检查前后一天,否则跨日交易会被算到相邻账期。