callback-reconcile.md 7.8 KB

OMG 支付回调可靠性 / 漏单补单 — 设计文档(待实现)

Feature: specs/016-omg-payment | 状态: ✅ 方案A+B 后端已实现(2026-08-07,tasks T042–T048);测试(T049)/前端配合(T050)待后续 | 关联: api.md §A4/B5/B8

本文档解决一个核心隐患:异步回调不可靠导致漏单。当前后端是纯被动等回调,回调一旦丢失,用户付了钱订单却永远停在「未支付」。下面是问题分析 + 补单方案,留待后续实现。


1. 问题:两种「回调没成功」,影响完全不同

场景 触发原因 当前覆盖 影响
① 回调延迟 OMG 服务端 POST 晚到(网络抖动/排队) OMG 自动重试(5–15分/次,当天最多4次);后端处理完即回 `1 OK`
② 回调丢失(漏单) 后端宕机/重启/断网,OMG 重试4次仍未送达,之后不再重试 无任何兜底 🔴 用户钱扣了,订单永远 pay_status=0

漏单的真实触发条件:后端在 OMG 重试窗口(当天4次)内一直不可用。


2. 当前后端盲区(为什么必须补)

  1. 纯被动:OmgPayController.notify 只能等 OMG POST 过来,不主动核对。
  2. 查询能力已具备但未接入:OmgPay.queryTrade()(调 QueryTradeInfo/V5,自带验签+关联校验,返回 TradeStatus 0未付/1已付/10200095失败)已实现,目前只用于门店凭证验证,没暴露成补单接口。
  3. contracts 规划的 POST /pay/omg/query(§B5)标了「可选」,未实现。
  4. 原定时兜底 TestTask.java 已废弃(全量废弃,含超时退款/抽成返还等),不能往里加。

3. 顺带:notify「全部回 1|OK」的语义

notify 在所有分支(成功/验签失败/金额不符/异常)都返回纯串 1|OK。这是 OMG 协议要求(回非 1|OK 会触发重试,但重试对校验类失败无用)。需区分两种「回调失败」:

情况 后端行为 正确性
校验失败(假回调/金额对不上) 回 `1 OK` 吞掉,不改订单
送达失败(后端没收到) 无从回 `1 OK`,OMG 重试4次后放弃

另:若 markSuccess 成功后、推送前崩溃——订单状态已更新,无碍(推送漏发不影响,用户刷新订单即见已支付);若 markSuccess 之前崩溃,OMG 会重试时补上。死穴仅「重试窗口内后端持续不可用」。


4. 方案A:被动补单接口(推荐先做)

前端结果页轮询时,订单长时间仍 payStatus=0 即调用,后端去 OMG 查真实状态并补单。

接口契约: POST /pay/omg/query

内容
Method POST
Header token: <用户JWT>
入参 query:orderid = ddId
鉴权 登录用户 + 订单本人
复用 OmgPay.queryTrade() + IPosOrderOmgPaymentService.markSuccess() + handlePaymentSuccess()(同回调核销链路)

返回

{ "code": 200, "data": { "payStatus": 1, "reconciled": true } }
// payStatus: 0未付 / 1已付(含本次补单) / 2失败 ; reconciled: 本次是否触发了补单

核心逻辑(实现时照此)

1. 校验登录 + 订单归属(token → userId == order.userId)
2. order.payType 必须为 "7";若 order.payStatus 已为 1 → 直接返回成功(不重复处理)
3. payment = paymentService.getLatestByDdId(ddId)  // 最新一条 OMG 流水
4. 门店凭证 cred = storeOmgService.getEnabledCredential(order.mdId) → OmgPayConfig
5. resp = omgPay.queryTrade(baseUrl, cfg, payment.getMerchantTradeNo)
6. 按 resp.TradeStatus 分支:
     "1" 已付:
        - 先判断 payment.payStatus == 1 → 已被回调处理过,直接返回成功(幂等)
        - 否则 markSuccess(payment.id, resp.TradeNo, resp.PaymentType, 1, ...) + handlePaymentSuccess(order)
        - 返回 { payStatus:1, reconciled:true }
     "0" 未付:
        - 返回 { payStatus:0, reconciled:false }   // 前端继续轮询(延期支付会长期是0,正常)
     "10200095" / 其他:
        - paymentService.markFail(...) ; 返回 { payStatus:2, reconciled:false }

关键:幂等

补单与回调可能并发/先后到达。markSuccess 必须按 trade_no 幂等(回调侧已如此);补单前先查 payment.payStatus==1 则直接返回,避免重复 markSuccess + 重复推送。


5. 方案B:定时任务兜底(建议补,A+B 最稳)

防的是「用户付完就关 App、回调又丢了」的极端情况——用户不会再回来触发方案A。

新建独立定时任务(RuoYi quartz 或 @Scheduled,严禁改废弃的 TestTask.java):

  • 扫描:pos_order_omg_paymentpay_status=0create_time 在补单窗口内的流水,且对应订单未取消(state != 4)
  • 对每条:同方案A步骤 4–6(queryTrade → 补单/标失败)
  • 频率:每 2~3 分钟一轮
  • 补单窗口:需覆盖延期支付周期
    • 信用卡即时:发起后 30 分钟内未付基本可判定异常
    • ATM/超商(延期):虚帐/缴费码有 ExpireDate(1~3 天),TradeStatus 在用户实际缴款前一直是 0;窗口应覆盖到 ExpireDate 之后(如发起后 7 天),过期停止扫描避免无限轮询
  • 并发保护:多实例部署时需加分布式锁或 SELECT ... FOR UPDATE,避免同一流水被多节点同时补单(幂等能兜底,但减少无效调用)

6. 前端配合(方案A 触发点)

接入文档 frontend-integration.md §4.3 的结果页轮询,当轮询超过阈值(如 30s)仍 payStatus=0 时,改为主动调补单接口而非继续干等:

轮询 paymentInfo → payStatus=0 持续 > 30s
   → 调 POST /pay/omg/query?orderid=   // 后端查 OMG 真实状态补单
   → 据返回 payStatus 展示成功/失败/继续等

(实现方案A后,回头更新 frontend-integration.md 的轮询段)


7. 实现清单

  • 方案A:OmgPayControllerPOST /pay/omg/query(@Anonymous @Auth),复用 queryTrade + applyPaidResult(markSuccess+handlePaymentSuccess),严格幂等(T044)
  • 共享逻辑抽取:applyPaidResult()(notify 成功分支改调此) + reconcileByQuery()(/query 与定时任务共用,含跨事务中断自愈)(T043)
  • 方案B:新建 OmgReconcileTask(@Scheduled 每3分钟 + Redisson 分布式锁)扫漏单,复用 reconcileByQuery(T046);scan mapper+service(T045);@EnableScheduling(T046);omg.reconcile.* 配置(T047)
  • 补单窗口/延期 ExpireDate:默认 7 天窗口覆盖 ATM/超商 ExpireDate,宽限期 2 分钟让回调送达,窗口外停扫(T047)
  • 补单操作写订单日志:orderLogHelper.logSync(ddId, ..., "OMG查询补单成功/失败/金额不符"),便于对账
  • 方案A/B 测试(T049):测试卡付款成功但模拟不触发回调 → 调 /query 补单;回调+补单并发不重复;定时任务扫描补单 —— 待测试环境(stage+测试卡)就绪后随 T040 端到端验证
  • 前端配合(T050):更新 frontend-integration.md §4.3 轮询逻辑接入 /query —— ⏸️ 推迟:客户 uni-app 不在当前工作区(同 T017/T024)

附:已具备的可复用能力(不用重写)

能力 位置 说明
查询 OMG 交易 OmgPay.queryTrade(baseUrl, cfg, merchantTradeNo) 返回 Map(TradeStatus/TradeNo/TradeAmt/PaymentDate/PaymentType...),自带验签
核销成功 IPosOrderOmgPaymentService.markSuccess(...) notify 侧已用,按 trade_no 幂等
标记失败 IPosOrderOmgPaymentService.markFail(...) 同上
支付成功业务 OmgPayController.handlePaymentSuccess(order) 更新订单 state=0/payStatus=1 + 日志 + 推送(私有方法,补单可复用)
查流水 paymentService.getLatestByDdId(ddId) 拿最新一条 OMG 流水
TradeStatus 枚举 contracts/api.md §A4 0未付 / 1已付 / 10200095失败