# OMG 支付流水生命周期重设计（bug 修复 + 账本重构调查）

> **废弃说明（2026-08-13）**：本文中的多支付尝试、延期支付和历史数据讨论只保留为调查记录，不再指导当前实现。当前每个订单只保留一条有效支付尝试，仅支持信用卡和 Apple Pay，不迁移或兼容开发测试阶段旧数据。

**日期**：2026-08-11
**状态**：追加式 S 已用户确认 / bugB 验签已修（commit `1a424c3`）/ 正在写实现 plan（见 §0 现状）
**关联**：`specs/016-omg-payment/spec.md`、`callback-reconcile.md`、`contracts/api.md`；记忆 `project-016-omg-payment.md`

> 本文档记录一次完整的问题调查 → 业界研究 → 候选方案 → 对抗挑刺 → 幸存方案的闭环。来源是一次多代理 workflow（runId `wf_e4577a10-1c6`，15 代理 / 13 成功 / 2 对抗代理中途断连；scriptPath 与 transcript 见文末附录）。

---

## 0. 2026-08-12 现状更新（bugB 已修，追加式 S 确认，准备落地）

**bugB 验签已修（前提满足）**：
- 根因坐实：OMG 服务端 CheckMacValue 按字母序**大小写不敏感**排序算（生产 4 候选诊断 `receivedCMV==ci_inc`），原 `TreeMap` naturalOrder（大小写敏感）对 QueryTradeInfo 响应混合大小写字段排序错位 → 验签失败。
- 修复 commit `1a424c3`：`generate` 默认改 `String.CASE_INSENSITIVE_ORDER` + 回归测试。**queryTrade 现可靠**（线上已验证：读到 TradeStatus=10200095 → markFail 链路通）。
- parseKvResponse 尾随 `&` 放宽（`7a8e248`）。

**单行方案对抗否决（用户改确认追加式 S）**：
- workflow `wf_06aa691f` 对抗验证：物理单行（每订单 1 条，in-place UPDATE 换号）3 视角（资金/并发/延期）全判 broken，核心根因 = **in-place UPDATE `merchant_trade_no` 是丢钱引擎**（pay_status=2 复活 MTN 被销毁 / 信用卡未取号也能付 / create vs notify 并发错配 TradeNo）。
- 用户确认改用**追加式 S（本文件 §6）**：is_active 逻辑单活跃（每订单 1 条有效），物理保留历史行接迟到 notify + 审计。

**collation 冲突（已写 sql.md 待执行）**：
- `pos_order`（utf8mb4_unicode_ci）vs OMG 3 表（utf8mb4_general_ci）JOIN 报 Illegal mix of collations。3 表 CONVERT 到 unicode_ci 已写 `updatesql/sql.md`（2026-08-12 节），待执行。

**§8 落地顺序调整**（P0-诊断/P0-修B 已完成）：
- ✅ P0-诊断 + P0-修B（bugB 验签）+ parseKvResponse 尾随&。
- ⏳ collation 待执行。
- ➡️ **接下来**：P0-补单（reconcile 两段式 + 两道节流闸）→ P0-防堆积（create 复用 + DDL is_active/expire_date）→ 读侧（7 处 getLatestByDdId 迁移）→ 退款（listPaidByDdId 遍历）。

**对抗发现的前置补强**（加进落地）：
- parseKvResponse **重复键放宽**（high）：S 依赖 queryTrade 做换号/补单安全网，但重复键仍抛（IllegalArgumentException）→ 大响应（ATM/超商 NeedExtraPaidInfo=Y 47 字段）若含重复键 → queryTrade 返 {0,0} → 安全网失效。落地前补（重复键 last-wins + 同 Map 验签避 kill-shot ⑥）+ 抓 stage 真实 fixture。
- 两道节流闸（防 OMG 403 自残，§6.4【C】）。
- expire_date 字段（§6.1，paymentInfo 回调落）。

**§9 待拍板项**：freshMin / MySQL 版本 / reuse MerchantTradeDate / 节流参数 / AFTEE 窗口等 → 实现/stage 确认（不阻塞 spec）。

---

## 1. 两个已暴露的问题

### 问题 A — 多次创建堆积
`POST /pay/omg/create`（`OmgPayController.java:122`）每次调用都走 `createPaymentAttempt`（L211-225）生成新 `MerchantTradeNo` 并 `INSERT` 一条新行到 `pos_order_omg_payment`（`pay_status=0`），**无复用、无去重**。`@RepeatSubmit(interval=1000)` 是按 token 的软限（`SameUrlDataInterceptor` GET-then-SET 非原子），挡不住连点。

**实测**：订单 `991786433092835` 被连点 9 次 → 9 条 `pay_status=0` 流水（id=18~26），每条不同 MTN。

### 问题 B — 已付订单被判未付
上述订单**最新一条 id=26** 的 MTN `OMGFE0494FB7AD1474A8`，在 OMG 后台**已确认授权扣款**（信用卡、授權單號 11065906、卡末 2222、2026-08-11 15:58:28、商店代號 1000031），但 `POST /pay/omg/query` 返回：

```json
{ "code": 200, "data": { "payStatus": 0, "reconciled": false } }
```

**根因待定（三选一，缺日志）**。`reconcileByQuery`（`OmgPayController.java:760-832`）在拿 id=26 查 OMG 前后有 3 处会静默返回 `{0,0}`：

| # | 分支 | 触发 | 日志关键字 |
|---|------|------|-----------|
| ① | `getCredentialByMerchantIdAndStoreId` 返回 null（L787-791） | (merchantId=1000031, storeId=168) 凭证查不到（概率低，与 create 同行） | `门店凭证不可用` |
| ② | `queryTrade` 抛异常被 `catch(Exception)` 吞（L797-800）(**概率最高**) | `parseKvResponse`（`OmgPay.java:175-195`）对尾随 `&`/空字段/重复字段/>100 字段一律抛 `IllegalArgumentException`；`OmgPayTest` 只测过干净向量 | `queryTrade 失败(下轮重试)` + err |
| ③ | OMG 回 `TradeStatus=0`（L830） | 与后台已付矛盾（信用卡已授权 = TradeStatus 1，可能性最低） | 无 warn，有 `postForm <<<` |

`omg.base-url=https://payment-stage.funpoint.com.tw`（create 与 query 同源，非跨环境）。

### 关键认知（推翻了初判）
OMG 后台截图证明付的正是 **id=26（最新那条）**，不是较早的——所以"查错 MTN"假设不成立，问题在 `queryTrade` 这次调用本身。但 `reconcileByQuery` 用 `getLatestByDdId`（`ORDER BY id DESC LIMIT 1`）取"最新一条"的假设，在"付的是较早 MTN"的多 MTN 场景仍是隐患（会漏补单）；退款 / paymentInfo 也用 `getLatestByDdId`。

---

## 2. 现有补单链路（现状基线）

- **notify 为主**：OMG `POST /pay/omg/notify`（`OmgPayController.java:241`）→ 验签 CheckMacValue → `trade_no` 幂等 → 金额校验 → `RtnCode==1 && SimulatePaid!=1` → `markSuccess`（`pay_status` 0→1，按 `trade_no` CAS）+ 订单状态流转 + 推送。
- **queryTrade 兜底**：方案A `/pay/omg/query`（前端轮询，L707）与方案B `OmgReconcileTask`（`@Scheduled` + Redisson 锁 `lock:omg:reconcile`）**共用** `reconcileByQuery(ddId, source)`（L760），其中 `getLatestByDdId` 取"最新一条"流水去查 OMG `QueryTradeInfo/V5`。
- **取号**：`/pay/omg/paymentInfo` 回调（L412）落虚帐/缴费码到 `callbackRaw`，不改 `pay_status`。
- **退款**：`refundOrderOutcome`（L543）/`confirmManualRefundOutcome`（L664），信用卡走 `DoAction(Action=R)`，ATM/超商无退款 API 走人工记录。

`getLatestByDdId` 共有 **7 个调用点**（对抗代理 grep，行号待实现时复核）：`OmgPayController` L492 / L573 / L615 / L669 / L769 + `OrderLifecycleService` L72 / L87 / L501。

---

## 3. 硬约束

1. **MerchantTradeNo 提交到收银台后永久绑定、不可复用**（OMG 官方明文）。
2. **延期支付方案已撤销**：当前只有开发测试数据，不兼容 ATM/超商/BarcodeATM/AFTEE 历史交易；新订单固定 `ChoosePayment=Credit`。
3. **notify 可能丢/迟到**（stage 尤甚），`queryTrade` 是兜底；但补单现依赖"最新一条=被付的那条"假设，多 MTN 时失效。
4. **换号有丢钱风险**：旧 MTN 实付但 notify 未到 → 把行 UPDATE 成新 MTN → 旧 MTN 迟到 notify `getByMerchantTradeNo` 查不到 → 钱付了订单永未支付。安全换号必须先 `queryTrade` 确认旧 MTN 未付；而 `queryTrade` 当前不可靠（问题 B）→ **问题 B 必须先修，换号才有安全网**。
5. **模块边界**：外部 HTTP 工具/锁在 `ruoyi-admin`；实体/Mapper/XML 在 `ruoyi-system`，不可反向依赖。
6. **官方查询节流**：下单后 40 分钟内不要查询、调过快收 HTTP 403 并罚等 30 分钟（按 MerchantID）。直接影响方案 A/B 轮询节奏。

---

## 4. 业界 / OMG 官方研究要点

来源 URL 见附录 B。核心：

- **MTN 生命周期**：一旦 AioCheckOut POST 被接收，MTN 即进入"已占用"，永久不能再用于建新交易（含平台商模式下全商家唯一）。重付唯一正解是应用层生成新 MTN + 维护"业务订单 1:N MTN"映射。Stripe(1 PaymentIntent : N Charges)、Adyen(merchantOrderReference)、Magento(sales_order_payment + sales_payment_transaction 链) 全部如此。**覆写外部 provider charge id 是反模式**（破坏对账、断退款、失审计）。
- **QueryTradeInfo/V5 真实响应**：`&` 分隔 KV 串（官方 YAML 标 JSON 但实际回 KV），含 `CheckMacValue`；**尾随 `&` 与 `PaymentDate=` 等空字段是常态**——正是 `parseKvResponse` 严拒的形态。值含 `=` 应用 `partition('=')` 只切首等号。
- **TradeStatus 三值**：`0`=订单已成立未付款；`1`=已成立且已付款；`10200095`=订单未成立（消费者未完成付款作业/交易失败）。信用卡"已授权未关帐"= **TradeStatus 1**（关帐是后端日批，不动 TradeStatus）。→ id=26 应返 1，问题 B 的 option3 可能性最低。
- **延期支付过期**：ECPay 把过期管理责任放在商户侧——QueryTradeInfo 无"已过期"态，过期只通过本地 `ExpireDate` 时钟或取号查询的 `102xxxxx` RtnCode 体现；ATM 付款到账最多延迟 2 天，超商/Barcode 默认 7 天，AFTEE 14~45 天。OMG QueryTradeInfo 对"ExpireDate 当下已付但银行未送达"无可观察性，结算客观上滞后几分钟~几小时（超商甚至跨日）。
- **重复点击去支付**：Square/Stripe/Plaid/Modern Treasury 一致——"同一业务意图 + 仍有效的待付交易 = 复用原 idempotency key/MTN"，重建仅限硬拒绝/已过期/金额方式变更。Square 官方："重试时生成新 key 是重复扣款的首要原因"。

---

## 5. 候选方案与对抗结论

三候选（C1/C2/C3）**全部被对抗代理判 `broken`**，但失败根因高度收敛。

### 共性致命伤（被反复命中的）

| # | 致命伤 | 命中 |
|---|--------|------|
| ① | **把"换号"语义塞进 `pay_status`**：C1 新增 `pay_status=5(superseded)`、C2 靠 `is_active` 隐式终结。`markSuccessIfUnpaid` 的 CAS 是 `pay_status IN (0,2)`（XML:79 已核实），任何被推到 {0,2} 之外的行，迟到 notify 与 ATM 边界 queryTrade 都无法复活 → 钱进 OMG 订单永未付，无告警。ATM 在 ExpireDate 临近被付但银行 T+1 结算滞后时 queryTrade 合法返 0，是高频触发路径。 | C1/C2/C3 |
| ② | **复用 MTN 重 POST 已占用 MTN**：`selectActiveByDdId` 只按 `expire_date/NOW()` 判可复用，不区分"form 未提交"与"用户已在收银台选 ATM 拿虚帐/trade_no 已落"。违反约束#1。 | 全部 |
| ③ | **遍历全量未付行 + queryTrade → OMG 403 自残 DoS**：`/query` 2s 轮询 × 每单 N 条未付行 = 单店高频打 OMG（按 MerchantID 限流）→ HTTP 403 → 该门店所有订单补单瘫痪 30 分钟。候选都把 cooldown 写在 risks 里、没写进落地步骤。 | 全部 |
| ④ | **`getLatestByDdId` 7 个调用点，只改了 2~3 个**：换号后退款/取号页/详情/校验读错行（退款被"状态不允许"拦截、取号页空 callbackRaw、详情状态=0、canRefundOmg=false）。 | 全部 |
| ⑤ | **L774-781 自愈变死代码**：`reconcileByQuery` 改 `listUnpaidByDdId`（只 pay_status=0）后，"pay_status==1 且 order.payStatus==0 → handlePaymentSuccess 自愈"分支丢失 → 跨事务残留订单永停未付。 | C1/C3 |
| ⑥ | **`parseKvResponse` 放宽与验签打架**：丢重复键后 `verifyResponse` 重算 CheckMacValue 与 OMG 原签名不一致 → 把"解析拒绝"换成"验签拒绝"，B 没修好反而更糟。 | 全部 |
| ⑦ | **id=26 在三候选下都会被永久钉死**：B 根因未修，`queryTrade` 仍返 0，定时任务按"过期未付"把 id=26 推到不可恢复态（5 / superseded / fail），连"将来 notify 到了还能补单"都砍断。 | C1/C2/C3 |
| ⑧ | **回填 `expire_date=create_time+3天` 系统性偏短**：历史已取号未付 ATM/CVS 行真实 ExpireDate 更晚，回填后误撤换客户手中仍有效的虚帐。 | C1/C3 |
| ⑨ | **锁释放在 TX commit 前**：`create` 标 `@Transactional`，若 Redisson 锁 `try/finally unlock` 在方法返回时释放，TX 在 AOP after-returning 才提交，被阻塞线程看不到未提交 INSERT → 又建一行（问题 A 复发）。 | C1/C3 |

### 最关键的认知
> **问题 B 必须最先修。在它修好之前，任何换号/过期/作废机制都给丢钱开新通道。** id=26 这笔会在结构改动下从"可恢复"变成"不可恢复"。

---

## 6. 幸存方案 S：双轴生命周期（is_active ⊥ pay_status）

**核心动作：把两个轴正交分离。**
- `pay_status` 只表达 OMG 侧支付终局（0/1/2/3/4 **不变、不加 5**），轮换绝不触碰。
- 换号只用新列 `is_active` 表达（1=当前活跃 / 0=已轮换历史）。

这样任何"已轮换但未终局"的行永远停在 `pay_status=0`，`markSuccess` CAS 永远命中，迟到 notify 与 queryTrade 补单对全量未付行始终有效——致命伤 ①/⑤/⑦/⑧ 整族消除。

吸收三方优点：C3 的 `reconcile-all-rows`（问题 B 真正解药）+ 诊断先行；C1 的 append-only + 退款走已付行；C2 的"单活跃不变量"用 MySQL 生成列唯一索引强制落地。

### 6.1 数据模型（DDL 写 `updatesql/sql.md`，不直接执行）

```sql
ALTER TABLE pos_order_omg_payment ADD COLUMN expire_date DATETIME NULL
  COMMENT 'OMG 延期支付真实 ExpireDate，仅由 paymentInfo 回调解析写入；NULL=未知(信用卡/未取号)，绝不由后台任务触发换号';
ALTER TABLE pos_order_omg_payment ADD COLUMN is_active TINYINT NOT NULL DEFAULT 1
  COMMENT '1=当前活跃尝试 0=已轮换历史(保留审计+迟到notify落地)';
ALTER TABLE pos_order_omg_payment ADD COLUMN active_dd_id BIGINT
  GENERATED ALWAYS AS (IF(is_active=1 AND pay_status=0, dd_id, NULL)) VIRTUAL
  COMMENT '单活跃不变量载体';
ALTER TABLE pos_order_omg_payment ADD UNIQUE KEY uk_omg_active_dd (active_dd_id);
-- 历史回填：每 ddId 保留最新一条 pay_status=0 行 is_active=1，其余 pay_status=0 置 0；
--          pay_status=1/2/3/4 的行 is_active=0（已终局无所谓活跃）。
--          绝不回填 expire_date（NULL 表示未知，按软窗口扫描）。
```

- **状态语义**：`pay_status` 沿用 0未付/1已付/2失败/3已退/4退款中，**不新增 5**。`is_active` 是与 `pay_status` 正交的轮换轴：轮换=只翻 `is_active` 1→0，`pay_status` 原地不动。
- **唯一索引语义**：`uk_omg_active_dd` 在 `(is_active=1 AND pay_status=0)` 时取 `dd_id` 否则 NULL，NULL 不参与唯一约束 → 每个 ddId 同时至多一条活跃未付行（DB 级强制单活跃，问题 A 兜底）。活跃行被付（0→1）或被轮换（is_active 1→0）时 `active_dd_id` 变 NULL，槽位释放；`create()` 的 `order.payStatus==1` 预检（L150）阻止为已付订单新建。**需 MySQL 5.7.6+（生产版本待确认）；不够则退化为普通索引 `idx_omg_dd_active(dd_id,is_active,pay_status)` + app 锁 + CAS，不变量降级为"尽量单活跃"。**
- **实体**：`PosOrderOmgPayment` + `expireDate` / `isActive` / `activeDdId`（`activeDdId` 为只读生成列，可不映射）。
- **Mapper 刻意不提供任何 `updateMerchantTradeNo`/`updateTradeNo` 方法**——从能力上杜绝约束#4 的丢钱路径。

### 6.2 create 流程（`OmgPayController.create`，保留 `@Transactional`）

1. **复用预检**（新增，在 `createPaymentAttempt` 之前）：Redisson `RLock lock:omg:create:{ddId}`，`tryLock(等 3s)`。**锁释放必须放 `TransactionSynchronizationManager.registerSynchronization` 的 `afterCommit` 回调**（仿 `WalletService.returnPoints`），**禁止直接 `try/finally unlock`**——否则 unlock 在方法返回、TX 在 AOP after-returning 才提交，被阻塞线程看不到未提交 INSERT → 重复活跃行（致命伤 ⑨）。
2. 拿锁后先跑现有预检 L140-158（订单存在/归属/state!=4/payStatus!=1/金额/门店/凭证）。
3. `selectActiveForReuse(ddId, freshMin)`：`WHERE dd_id AND is_active=1 AND pay_status=0 AND trade_no IS NULL AND create_time>=NOW()-INTERVAL #{freshMin} MINUTE ORDER BY id DESC LIMIT 1`。**命中 → 直接 return 该行 `merchantTradeNo`**（MTN 不变），`form` 由 `createAioForm` 用当前时刻 `MerchantTradeDate` 重算 CheckMacValue。**`trade_no IS NULL` 是关键守卫**——一旦 paymentInfo 回调落过 trade_no（已取号/已授权），该 MTN 在 OMG 侧已占用，绝不复用（致命伤 ②）。
4. **未命中 → 同一 TX 内**：先 `markActiveHistorical(ddId)`（CAS `UPDATE SET is_active=0 WHERE dd_id AND is_active=1 AND pay_status=0`，不动 pay_status），再 `createPayment` 新行（`is_active=1, expire_date=NULL` 兜底）。`uk_omg_active_dd` 在 DB 层兜底（旧行 `active_dd_id` 已变 NULL，新行可插入）。`createPaymentAttempt` 的 `DuplicateKeyException` 重试 3 次（L218）继续承担 MTN 唯一冲突。
5. 剩余组参/CheckMacValue/更新订单 payType/payUrl/返回 form（L169-204）不变。

**连点 9 次**：第 1 次建行；第 2-9 次在新鲜期内且 `trade_no IS NULL` → 复用第 1 条，不新建。新鲜期外或已 trade_no 非空 → 才换号（旧行 is_active=0 保留可补单）。问题 A 从源头收敛。

### 6.3 轮换策略（rotate_policy）

**核心原则：轮换只由用户主动触发（下次 create 命中复用未果），后台任务永不自动撤换。** 这杀死整族"后台任务按 expire_date/queryTrade=0 误作废 ATM 边界有效交易"的 kill-shot。

- 轮换动作 = `markActiveHistorical(ddId)`，只 `UPDATE is_active 1→0`，WHERE 带 `pay_status=0` CAS。`pay_status` 原地不动 → 被轮换旧行仍 `pay_status=0`，仍被 `listUnpaidByDdId` 扫到，迟到 notify 的 `markSuccess` CAS `IN(0,2)` 仍命中。
- **两序竞态均已验证安全**：(a) `markSuccess` 先到（0→1），`markActiveHistorical` 的 CAS `pay_status=0` 找 0 行 → 旧行保持 `is_active=1,pay_status=1`（已付活跃行，create 预检 order.payStatus==1 会拒新建）；(b) `markActiveHistorical` 先到（is_active 1→0, pay_status 仍 0），随后 `markSuccess`（0→1）→ 旧行 `is_active=0,pay_status=1`（已付历史行，退款链路可见）。两序都无资金孤立。
- **`expire_date` 角色降级为"信息+展示+软扫描优先级"，绝不作为自动撤换触发器**：(1) 信用卡无 ExpireDate 概念，恒 NULL，复用只受新鲜期+`trade_no IS NULL` 约束（致命伤"信用卡 3 天锁"消除）；(2) ATM/超商真实 ExpireDate 只由 paymentInfo 回调解析写入（`markPaymentInfoIfOpen` 扩展），回调丢失则 NULL，按 `create_time`+宽软窗口扫描但不撤换；(3) reconcile 遇 `TradeStatus=0` 一律 no-op（见 6.4），绝不 markFail/supersede。
- **"换号并同步更新相关字段"的安全边界**：只允许 UPDATE `expire_date`（回调覆盖）、`pay_type`/`pay_time`/`rtn_code` 等支付元数据；`merchant_trade_no`/`trade_no` 永不 UPDATE（mapper 无此能力）。

### 6.4 reconcile 修复（问题 B 根治 + 补可观测性）

**【A. 扫描入口】** `selectLeakOrderDdIds`（XML:55-70）改为：

```sql
SELECT DISTINCT p.dd_id FROM pos_order_omg_payment p
INNER JOIN pos_order o ON o.dd_id=p.dd_id
WHERE EXISTS(SELECT 1 FROM pos_order_omg_payment WHERE dd_id=p.dd_id AND pay_status=0
             AND create_time>=#{windowStart} AND create_time<=#{graceCutoff})
AND (o.state IS NULL OR o.state<>4)
ORDER BY p.id ASC LIMIT #{batchSize}
```

即"有任意 `pay_status=0` 行的 ddId"去重，不再 `MAX(id)` 取一条——多 MTN 订单的所有未付行都进扫描集。

**【B. 补单主体】** `reconcileByQuery`（L760）把 L769 `getLatestByDdId` 换成两段：
1. **先 `selectLatestPaidByDdId`**：若存在 `pay_status=1` 行且 `order.payStatus==0` → `handlePaymentSuccess` 自愈补推（保留原 L774-781，致命伤 ⑤ 消除）；若已付且订单已核销 → 幂等返回 `{1,0}`。
2. **否则 `listUnpaidByDdId` 循环**遍历全量 `pay_status=0` 行（is_active 不限，旧行也在内）。每行 `queryTrade` 前过两道节流闸，按 `TradeStatus` 分支：
   - `1` 且金额/字段校验过 → `applyPaidResult`；任何已付即 break。
   - `0` → **no-op**（不 markFail 不撤换，直接 continue，日志 info）。
   - `10200095` 且**该行 `trade_no IS NULL`**（从未与 OMG 建立交易）→ `markFail(0→2)`（CAS 允许 2→1 复活，安全）。
   - `10200095` 且 `trade_no` 非空（已取号，可能 OMG 延迟建案或结算滞后）→ no-op + log.warn，绝不 markFail。

**【C. 节流闸（防 OMG 403 自残 DoS，致命伤 ③）——两道，频率制】**

> ⚠ 原"闸1 首查延迟 40min"已于 2026-08-12 移除（commit fc46fed）：是对 OMG 节流的**误读**——OMG 的 403 是"短时间内反复查同一笔"（频率）触发，**不是**"下单后 N 分钟内不能查"。已付款交易可立即查状态（付完即查是行业标准）。原闸1 会让已付客户 notify 丢失时等 40min 才看到状态变更，不可接受。"刚创建未付"由定时任务扫描窗口的 2min 宽限（`create_time <= now-2min`）保护，无需 40min 黑名单。

1. **per-MerchantID 令牌桶**：Redis key `omg:qt:token:{merchantId}`，1 token / 3s，burst 1——`queryTrade` 前 acquire，空则 skip 本行本轮（不阻塞）。
2. **per-ddId 扫描间隔**：定时任务 Redis key `omg:reconcile:dd:{ddId}` TTL 180s（每 ddId 最少 3 分钟一轮）；`/query` 被动端点 Redis key `omg:query:dd:{ddId}` TTL 60s（窗口内直接返回上次结果或 202，挡用户 2s 轮询放大）。

**【D. 可观测性 + parseKvResponse（致命伤 ⑥）】**
- 三个 `{0,0}` 分支（L789 凭证 null / L797 queryTrade 异常 / L830 TradeStatus=0）拆成结构化 `log.error` 带 ddId/mtcn/source/异常类全名/响应前 500 字节；原始响应写 `ipn_log(type=omg_query_error)` 供事后追查 id=26 根因。
- `parseKvResponse`（`OmgPay.java:175`）放宽：`split('&')` 丢空段、`partition('=')` 只切首等号、空值(`PaymentDate=`) 忽略不抛；**重复键不静默丢弃**（避免与验签不一致），仅当整段无 `=` 或缺 `CheckMacValue` 才抛。
- `verifyResponse` 必须用 `parseKvResponse` 的同一份 Map 重算 CheckMacValue（已是现状，确认即可）。**落地前先抓真实 stage 响应 fixture 验签**（`OmgPayTest` 补尾随 `&` / 空 `PaymentDate` 用例），确认 OMG 不发重复键再上线。

**【E. `getLatestByDdId` 七处调用点全量迁移（致命伤 ④）】**

| 调用点 | 迁移到 |
|--------|--------|
| `getPaymentInfo`(L492) | `selectLatestRefundableByDdId`(优先取已付/退款中行 callbackRaw)，fallback `selectActiveForReuse` |
| `refundOrderOutcome`(L573) | `listPaidByDdId` 遍历退款（见 6.5） |
| L569 "订单未支付"早退 | 改为"若 `listPaidByDdId` 为空才 FAILED"，允许已取消订单(order.payStatus=2)但存在已付 OMG 行时退款 |
| L615(markRefunding==0 并发分支) | `selectLatestRefundableByDdId(pay_status IN(1,3,4))` |
| `confirmManualRefundOutcome`(L669) | `selectLatestRefundableByDdId` |
| `reconcileByQuery`(L769) | 见【B】两段式 |
| `OrderLifecycleService.buildContext`(L501) | 已付状态/退款记录读 `selectLatestRefundableByDdId`，canRefundOmg/canReconcileOmg 基于该行 |
| `validateOmgReconcile`(L72)/`validateOmgRefund`(L87) | `existsPaidByDdId` / `selectLatestRefundableByDdId` |

并修 `selectLatestPaidByDdId` 的 `pay_status=1` 硬过滤 → 扩展为 `IN(1,3,4)`（修退款中/已退返回 null 缺陷）。

### 6.5 幂等与退款（致命伤 ④ 多笔已付漏退）

**三层幂等：**
1. **流水级**（单 MTN 防重复核销）：`markSuccessIfUnpaid` 的 CAS `WHERE id AND pay_status IN(0,2) AND (trade_no IS NULL OR trade_no=#{tradeNo})`（XML:79,82）不变——对活跃行和历史(is_active=0)行一视同仁（轮换不动 pay_status）。
2. **订单级**（防双发货/双付核销）：`handlePaymentSuccess` 的 `posOrderService.update CAS eq pay_status=0`（L866-869）不变；第二笔已付行（跨 MTN 双付）CAS 失败 → 走 `recordPaidOrderWithoutFulfillment`，**增强为写 `ipn_log(type=omg_orphan_paid, 带双 tradeNo/金额)` + 运营告警**。
3. **退款级**（防双退/漏退）：`refundOrderOutcome` 改 `listPaidByDdId` 遍历所有 `pay_status=1 且 trade_no 非空` 的行；每行先查 `pos_order_omg_refund` 是否已有 `action=R 且 rtnCode=1` 成功记录 → 跳过；否则 `markRefundingIfPaid(1→4 CAS)` → `DoAction(Action=R)`，多笔逐笔退（每笔独立 tradeNo）。ATM/超商无退款 API 的行走 manual pending，孤立已付告警标"需 OMG 后台人工退"。

---

## 7. kill-shot → 缓解措施对照

| kill-shot | 缓解 |
|-----------|------|
| ① 换号塞进 pay_status → CAS 不命中 | 双轴：轮换只翻 is_active，永不碰 pay_status；TradeStatus=0 一律 no-op |
| ② 复用已占用 MTN | `selectActiveForReuse` 守卫 `trade_no IS NULL` + 新鲜期 |
| ③ OMG 403 自残 DoS | 两道频率闸（per-MerchantID 令牌桶 1/3s、per-ddId TTL）；原"首查≥40min"已移除（误读 OMG 节流，403 是频率触发） |
| ④ 7 处 getLatestByDdId 读错行 | 全量迁移表（6.4【E】） |
| ⑤ L774 自愈变死代码 | reconcile 两段式，先 selectLatestPaidByDdId 自愈 |
| ⑥ parseKvResponse 放宽与验签打架 | 重复键不静默丢；真实 fixture 验签；保留缺 CheckMacValue 即抛 |
| ⑦ id=26 被永久钉死 | TradeStatus=0 no-op + 回填不碰 expire_date → 不会被推到不可恢复态 |
| ⑧ 回填 expire_date 偏短 | create 不写 expire_date（NULL）；仅 paymentInfo 回调写入；回填只设 is_active |
| ⑨ 锁在 TX commit 前释放 | afterCommit 释放（仿 WalletService）+ uk_omg_active_dd DB 兜底 |

---

## 8. 落地顺序（诊断先行）

> **P0 必须最先做且 0 状态机改动**：先给 `reconcileByQuery` 三个 `{0,0}` 分支加结构化 `log.error` + 原始响应落 `ipn_log(type=omg_query_error)` + 查 `ipn_log type=omg` 看 id=26 MTN 有没有收到过 notify。定位 B 是 ①/②/③ 哪个，**再动结构**。

1. **P0-诊断**：三分支日志 + ipn_log（0 改动），复现定位 id=26。
2. **P0-修 B**：`parseKvResponse` 放宽（尾随&/空值，不丢重复键）+ `catch(Exception)` 改 log.error+UNKNOWN(3) + `OmgPayTest` 补真实 fixture。
3. **P0-补单**：`reconcileByQuery` 两段式 + `selectLeakOrderDdIds` 改 EXISTS + 两道节流闸。
4. **P0-防堆积**：create 复用预检（`selectActiveForReuse` + `trade_no IS NULL` + 新鲜期）+ afterCommit 锁 + DDL（is_active/expire_date/生成列唯一索引）+ 历史回填（只 is_active）。
5. **P0-读侧**：7 处 `getLatestByDdId` 全迁移 + `selectLatestPaidByDdId` 扩 `IN(1,3,4)`。
6. **退款**：`listPaidByDdId` 遍历逐笔退 + 孤立已付告警。

---

## 9. 待用户拍板（open questions）

1. **生产 MySQL 版本**：决定 `uk_omg_active_dd` 生成列唯一索引（5.7.6+）可行性，还是退化为 app 锁+CAS。pom 只有 JDBC 驱动 8.2.0、非服务端版本。
2. **freshMin（复用新鲜期）**：建议 3 分钟，需 stage 实测"同 MTN 在 trade_no 仍 NULL 时重 POST 到 AioCheckOut"的行为（是否回到同一收银台、是否允许换方式）。
3. **reuse 是否保留原 MerchantTradeDate**：重算 CheckMacValue 时用当前时刻还是沿用首创建时刻？OMG 以 MTN 为主键、MerchantTradeDate 为信息字段，倾向沿用原值（更稳），需 stage 验证。
4. **OMG QueryTradeInfo 节流精确参数**："首查≥40 分钟、同 MerchantID 连查≥3 秒、403 罚 30 分钟"是依 ECPay 共识推断，需 OMG/FunPoint 官方确认据实调整令牌桶。
5. **OMG 是否回传重复键/尾随&/空值**：parseKvResponse 放宽安全性依赖此；需先抓 stage 真实响应（尤其 ATM/超商/AFTEE 的 NeedExtraPaidInfo=Y 大响应）做 fixture。
6. **补单扫描窗口 windowHours**：现 168h(7 天) 不够 AFTEE（可达 21+ 天）；建议 720h(30 天) 或按 `expire_date`+缓冲动态计算。
7. **id=26 当前根因**：缺日志三选一，需诊断先行复现后再定结构改动范围。
8. **多笔已付的退款策略**：信用卡可逐笔 DoAction(Action=R) 自动退；ATM/超商/BarcodeATM 无退款 API 只能人工。需运营确认 SOP（告警通道、人工核对流程）。
9. **`/query` 被动端点轮询 cadence**：现前端 2s 过激进，配合 reconcile-all-rows 放大 queryTrade；本方案加 per-ddId 60s TTL 节流，但需与前端确认调整为 30-60s（客户 uni-app 不在工作区，T017/T024 阻塞中）。
10. **历史 9 条连点数据(id=18..26)**：方案不做物理清洗（保留供补单遍历），只做 is_active 回填。是否需要对运营/对账档提供"折叠展示当前活跃行+历史行计数"视图？属产品增强，非阻塞。
11. **spec 写入位置**：本设计是并进 `specs/016-omg-payment/`（本文档）还是另起 `docs/superpowers/specs/`；后续 plan/tasks 是顺延 016 还是新建。

---

## 10. 改动文件清单（实现时核对）

**ruoyi-system**
- `domain/PosOrderOmgPayment.java`：+ expireDate / isActive / activeDdId 字段
- `mapper/PosOrderOmgPaymentMapper.java` + `.xml`：+ selectActiveForReuse / markActiveHistorical / listUnpaidByDdId / listPaidByDdId / selectLatestRefundableByDdId；改 selectLeakOrderDdIds(EXISTS)；markPaymentInfoIfOpen 扩 expire_date；selectLatestPaidByDdId 扩 IN(1,3,4)
- `service/IPosOrderOmgPaymentService.java` + Impl：对应方法
- `domain/PosStoreOmg.java`（无改）

**ruoyi-admin**
- `app/pay/OmgPayController.java`：create 复用预检 + afterCommit 锁；reconcileByQuery 两段式 + 两道节流闸 + 三分支日志；refund 改 listPaidByDdId 遍历；7 处 getLatestByDdId 迁移
- `app/utils/omg/OmgPay.java`：parseKvResponse 放宽（重复键不丢）
- `app/task/OmgReconcileTask.java`：节流闸配合（如未在 controller 内全覆盖）
- 新增：queryTrade 节流组件（Redis 令牌桶，per-MerchantID）

**SQL**（`updatesql/sql.md`）
- 4 条 ALTER + UNIQUE + 历史回填（is_active only）

**测试**
- `OmgPayTest`：真实 stage QueryTradeInfo 响应 fixture（尾随&/空 PaymentDate）
- `PosOrderOmgPaymentServiceImplTest` / `OmgPayControllerTest`：复用预检、reconcile 两段式、多笔退款、双轴竞态

---

## 附录 A：workflow 产物路径

- scriptPath：`…/subagents/workflows/wf_e4577a10-1c6/../../workflows/scripts/omg-payment-ledger-design-wf_e4577a10-1c6.js`（可用 `Workflow({scriptPath, resumeFromRunId:"wf_e4577a10-1c6"})` 重跑/续跑）
- transcript dir：`…/subagents/workflows/wf_e4577a10-1c6/`
  - `journal.jsonl`（每代理结果）
  - `_extract.txt`（4 研究报告 + 3 候选详情）
  - `_verdicts.txt`（7/9 对抗评审结论）
  - `_final.txt`（Final 综合的结构化输出）
- 用量：15 代理，13 成功 / 2 对抗代理断连（C2-concurrency、C3-deferred），subagent_tokens ≈ 121 万

## 附录 B：研究来源（URL）

- MTN 唯一性：`developers.ecpay.com.tw/2862/`；`ithelp.ithome.com.tw/articles/10349746`；`blog.hoyo.idv.tw/?p=3970`
- ATM ExpireDate 1~60 天/默认 3：`developers.ecpay.com.tw/2872/`；`ecpay.com.tw/Content/files/gw_p120.pdf`
- QueryTradeInfo/V5 回 KV 串：`developers.ecpay.com.tw/16579/`、`/2890/`；`github.com/andy6804tw/ecpay-payment-demo/blob/master/Decode.md`
- TradeStatus 0/1/10200095 + 40 分钟/403/罚 30 分钟：`developers.ecpay.com.tw/16579/` Special Note
- 信用卡授权/关帐/退刷/放弃：`ecpay.com.tw/Content/files/gw_p110.pdf`；`developers.ecpay.com.tw/9242/`；`gctek.io/blog/ecpay-credit-card-close-cancel-refund-abandon-action-guide`
- Stripe PaymentIntent : N Charges：`docs.stripe.com/payments/payment-intents`
