# 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`失败 |
