代码 订单超时关单撞上支付回调:微信返回 ORDERPAID 时不要当关单失败

2026-10-15 09:01:43

订单超时关单撞上支付回调:微信返回 ORDERPAID 时不要当关单失败

适用前提与不适用场景

本地订单有明确状态机:PENDING → PAID / CLOSED,且微信支付走 v3 异步回调。定时任务会扫描超时未支付订单并调微信关单,同时支付回调可能在任何时刻到达。

付款码支付不适用本文的 Close Order 主线。付款码场景用撤销 Revoke API,官方建议在发起支付 30 秒到 1 分钟之间调用,一经调用即冲正:未支付则关闭,已支付则退款。收银台支付才用关单 Close Order。两条链路的查单设计也不同。

下面的接口路径、错误码与时间间隔来自微信支付官方文档;未在真实商户号实测,以官方文档为准。

竞态长什么样

一次典型的单边账不是“回调丢了”,而是关单和回调撞车:

  1. 用户实际支付成功。
  2. 微信异步通知在途,网络或系统波动导致商户暂未收到。
  3. 本地定时任务按 PENDING 判断已超时,发起关单。
  4. 若微信侧订单已支付,关单会返回 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。本地关闭不代表微信没收到钱。

处理顺序:

  1. 验签、校验 out_trade_no、金额、商户号。
  2. 落支付流水,transaction_id 做唯一约束,保证回调幂等。
  3. 订单状态按 PENDING → PAID 条件更新。
  4. 若 RowsAffected = 0 且当前是 CLOSED,记录冲突,转入退款/补单队列。

回调里不要同步调退款接口。退款是外部调用,慢且可能失败,放在异步队列里重试。否则回调超时,微信重试,本地又开一次退款,账更乱。

查单能力:事前规避

指引给出的事前规避是建立查单能力,付款码支付与收银台支付两套设计。

收银台支付:订单过期时触发一次查单,保证未支付订单关单、已支付订单退款,避免单边账。建议两条查单链路:

  • 前端回调触发一次查单;
  • 下单即入队,延迟到超时窗口后异步查一次。

两条链路互为兜底。回调没到,队列查单能发现微信已支付;队列延迟,前端回调也能提前收敛。

多节点部署时,超时任务用分布式锁防重复执行。锁的粒度按订单号,不要按任务批次,否则一个节点挂住会拖住整批。

T+1 对账:单边账与资金核对

事中发现靠 T+1 账单。拿交易账单里的商户订单号去匹配自身系统交易明细。

“微信有、本地无”就是单边账:用户已支付,且微信已结算或即将结算。确认已结算后,对该笔发起退款。

资金对账公式:

应结算金额 = Settlement currency amount - Fee - Refund settlement amount

逐行汇总后,匹配结算账单的 Net Settlement Amount,再匹配银行账单实收。账单覆盖中国时间 0 到 24 点,注意 cutoff 检查前后一天,否则跨日交易会被算到相邻账期。

参考文档

推荐文章

程序员茄子在线接单