# OMG 支付回调可靠性 / 漏单补单 — 设计文档(待实现) **Feature**: specs/016-omg-payment | **状态**: ✅ 方案A+B 后端已实现(2026-08-07,tasks T042–T048);测试(T049)/前端配合(T050)待后续 | **关联**: [api.md](contracts/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()`(同回调核销链路) | **返回** ```json { "code": 200, "data": { "payStatus": 1, "reconciled": true } } // payStatus: 0未付 / 1已付(含本次补单) / 2失败 ; reconciled: 本次是否触发了补单 ``` **核心逻辑(实现时照此)** ``` 1. 校验登录 + 订单归属(token → userId == order.userId) 2. order.payType 必须为 "2";若 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_payment` 中 `pay_status=0` 且 `create_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](frontend-integration.md) §4.3 的结果页轮询,当轮询超过阈值(如 30s)仍 `payStatus=0` 时,**改为主动调补单接口**而非继续干等: ``` 轮询 paymentInfo → payStatus=0 持续 > 30s → 调 POST /pay/omg/query?orderid= // 后端查 OMG 真实状态补单 → 据返回 payStatus 展示成功/失败/继续等 ``` (实现方案A后,回头更新 frontend-integration.md 的轮询段) --- ## 7. 实现清单 - [x] **方案A**:`OmgPayController` 加 `POST /pay/omg/query`(@Anonymous @Auth),复用 queryTrade + applyPaidResult(markSuccess+handlePaymentSuccess),严格幂等(T044) - [x] **共享逻辑抽取**:`applyPaidResult()`(notify 成功分支改调此) + `reconcileByQuery()`(/query 与定时任务共用,含跨事务中断自愈)(T043) - [x] **方案B**:新建 `OmgReconcileTask`(@Scheduled 每3分钟 + Redisson 分布式锁)扫漏单,复用 reconcileByQuery(T046);scan mapper+service(T045);@EnableScheduling(T046);`omg.reconcile.*` 配置(T047) - [x] **补单窗口/延期 ExpireDate**:默认 7 天窗口覆盖 ATM/超商 ExpireDate,宽限期 2 分钟让回调送达,窗口外停扫(T047) - [x] **补单操作写订单日志**:`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`失败 |