Kaynağa Gözat

docs(omg-pay): 账本重构 spec 定稿追加式 S + bugB 已修现状 + OMG 接口参考

payment-attempt-lifecycle.md:
- 状态改为「追加式 S 已确认 / bugB 已修(1a424c3)/ 写 plan 中」
- 加 §0 现状:bugB 坐实(OMG 大小写不敏感排序 receivedCMV==ci_inc)+修复;
  单行方案对抗否决(wf_06aa691f 3视角 broken,in-place UPDATE MTN=丢钱引擎),
  用户改确认追加式 S;collation 待执行;落地顺序调整(P0-诊断/修B 已完成,
  从 P0-补单开始);前置补强(parseKvResponse 重复键 + 三道节流闸 + expire_date)
- §6 追加式 S 设计(双轴 is_active ⊥ pay_status)不变,仍是落地依据

omg-official-api-ref.md: OMG 官方接口参考(01_order/04_order_query/检查码
机制/額外回傳參數/TradeStatus,2026-08-12 抓取整理),后续接入/排查依据

Co-Authored-By: Claude <noreply@anthropic.com>
qmj 2 hafta önce
ebeveyn
işleme
c00de37bb0

+ 268 - 0
specs/016-omg-payment/omg-official-api-ref.md

@@ -0,0 +1,268 @@
+# OMG(歐買尬/FunPoint)全方位金流 官方接口参考
+
+> **来源**:OMG 官方介接技术文件 https://developers.omg.com.tw/payment/aio/
+> **文档版本**:V 1.5.3(2026-07,首页标注,茂為歐買尬數位科技)
+> **抓取日期**:2026-08-12
+> **抓取页**:`01_order.html`(產生訂單)、`04_order_query.html`(查詢訂單)
+> **用途**:本项目 OMG 支付接入/排查参考。底层引擎与 ECPay(綠界)同源(FunPoint),但以 OMG 官方文档为准。
+
+---
+
+## §0 基础环境
+
+| 环境 | 根地址 |
+|------|--------|
+| 正式 | `https://payment.funpoint.com.tw` |
+| 测试 | `https://payment-stage.funpoint.com.tw` |
+
+本项目配置(`application.yml` → `omg.base-url`):测试期指向 stage。
+
+凭证:门店级 `pos_store_omg`(merchant_id / hash_key / hash_iv / store_id),CheckMacValue 用 HashKey/HashIV 签名。
+
+---
+
+## §1 產生訂單 AioCheckOut /V5(幕前下单)
+
+**介接路径**:`{base}/Cashier/AioCheckOut/V5`
+**Content Type**:`application/x-www-form-urlencoded`,POST
+**场景**:消费者下单后,特店 POST 订单到 OMG,跳转收银台选付款方式。
+
+### 1.1 特店传入参数
+
+| 参数 | 型态 | 必填 | 说明 |
+|------|------|------|------|
+| MerchantID | String(10) | * | 特店编号 |
+| MerchantTradeNo | String(20) | * | 特店交易编号(唯一不可重复;英数字大小写混合;PlatformID 模式下全平台不可重复) |
+| StoreID | String(20) | | 分店代号(英数字大小写混合) |
+| MerchantTradeDate | String(20) | * | `yyyy/MM/dd HH:mm:ss` |
+| PaymentType | String(20) | * | 固定 `aio` |
+| TotalAmount | Int | * | 交易金额(整数,仅 NTD) |
+| TradeDesc | String(200) | | 交易描述 |
+| ItemName | String(200) | | 商品名称(多笔以 `#` 分隔;中文60/英数字120 字内,超出自动截断) |
+| ReturnURL | String(200) | * | 付款完成 Server POST 通知网址(收到后须回传 `1|OK`) |
+| ChoosePayment | String(20) | * | `Credit`/`ATM`/`CVS`/`AFTEE`/`BarcodeATM`/`ApplePay`/`ALL` |
+| CheckMacValue | String | * | 检查码(见 §5) |
+| ClientBackURL | String(200) | | Client 端返回商店按钮 |
+| ItemURL | String(200) | | 商品销售网址 |
+| Remark | String(100) | | 备注 |
+| ChooseSubPayment | String(20) | | 付款子项目(指定后无法选其它) |
+| OrderResultURL | String(200) | | Client 端付款结果回传(幕前;银联卡/ATM/CVS/Barcode/BarcodeATM 不支援) |
+| NeedExtraPaidInfo | String(1) | | 预设 `N`;`Y`=回传额外付款信息(见 §2)。**本项目用 `Y`** |
+| DeviceSource | String(10) | | 带空值,系统自动判定 |
+| IgnorePayment | String(100) | | ChoosePayment=ALL 时隐藏付款方式(`#` 分隔):Credit/ATM/CVS/BarcodeATM |
+| PlatformID | String(10) | | 平台商代号;一般特店/平台商自身带空值,平台商的特店带绑定的 MerchantID |
+| InvoiceMark | String(1) | | **固定 `N`**(本项目发票走 ezPay) |
+| CustomField1~4 | String(50) | | 自定义字段(特殊符号仅支援 `,.#()$[];%{}:?/&@<>!`) |
+| EncryptType | Int | * | **固定 `1`**(SHA256) |
+| Language | String(3) | | 预设中文;`ENG`/`KOR`/`JPN`/`CHI` |
+| RiskMerchantMemberID | String(100) | | 风控监测会员识别码(申请风控时必填,目前仅 ATM) |
+
+### 1.2 ChoosePayment 子参数
+
+**ALL / ATM**:
+- `ExpireDate` Int:缴费有效天数 1~60(预设 1)。例:4/10 成立、有效期 1 天 → 截止 4/11 23:59。
+- `ExpireMinute` Int:有效分钟数 10~1440(10 的倍数;仅中信/一银/凯基虚拟账号)。与 ExpireDate 不可同传;ExpireMinute>0 以分钟为主。
+- `ATMFromBankID` String(3):转出银行代码(仅凯基 ATM,需申请)。
+- `ATMFromBankAcc` String(16):转出银行账号(16 码,不足左补 0;仅凯基)。
+- `PaymentInfoURL` String(200):Server 端回传付款信息(取号通知)。
+- `ClientRedirectURL` String(200):Client 端回传付款信息。
+
+**ALL / CVS**(超商代码):
+- `StoreExpireDate` Int:缴费截止分钟(预设 10080=7 天;**测试上限 3012 分=3 天**)。
+- `Desc_1~4` String(20):超商缴费平台显示的交易描述。
+- `PaymentInfoURL` / `ClientRedirectURL`:同上。
+
+**ALL / BarcodeATM**(超商快付):
+- `BarcodeATMExpireDate` int:天数(预设 7,上限 7;测试上限 7)。
+- `PaymentInfoURL` / `ClientRedirectURL`:同上。回传三段号码(非条码图,需自转 code39)。
+
+**ALL / Credit**(信用卡):
+- `BindingCard` Int:记忆卡号 `1`/`0`。
+- `MerchantMemberID` String(30):记忆卡号识别码(MerchantID + 厂商会员编号)。
+  - 记忆卡号须有会员系统;仅 Visa/Master/JCB,不支援银联。
+
+**Credit 专属**:
+- `UnionPay` Int 预设 0:银联卡 `0`=消费者可选 / `1`=只用银联(导到银联网站)/ `2`=隐藏银联。须申请;测试环境无银联;不支援分期/红利/记忆卡号。
+
+### 1.3 信用卡分期(CreditInstallment)
+- `CreditInstallment` String(20) *:期数 `3`/`6`/`12`/`18`/`24`/`30`(须先申请开通)。刷卡一次授权,后续银行分期;不与定期定额同传;银联不支援。
+
+### 1.4 信用卡定期定额
+- `PeriodAmount` Int *:每次授权金额(须 = TotalAmount)。
+- `PeriodType` String(1) *:`D`天 / `M`月 / `Y`年。
+- `Frequency` Int *:执行频率(D≤365 / M≤12 / Y≤1)。
+- `ExecTimes` Int *:执行次数(D≤999 / M≤99 / Y≤9)。
+- `PeriodReturnURL` String(200):每次授权结果回传 URL。
+- 不与分期同传;第一次授权失败不进排程(需重建订单);银联不支援。
+
+---
+
+## §2 額外回傳的參數(NeedExtraPaidInfo=Y)★重点
+
+> **官方注意事项(原文)**:「額外回傳的參數全部都需要加入檢查碼計算」
+>
+> 即这些额外字段**全部参与 CheckMacValue 计算**——本项目 `NeedExtraPaidInfo=Y`,故 QueryTradeInfo 响应与付款通知回调会带回这些字段,**验签时必须全部纳入**(我们 `OmgCheckMacValue` 已对响应全部字段签名,字段集一致,**不是验签分歧原因**)。
+
+应用场景:产生订单时 `NeedExtraPaidInfo=Y`,OMG 在「付款结果通知」与「查询订单」时额外回传下表参数。
+
+| 参数 | 型态 | 说明 |
+|------|------|------|
+| AlipayID | String(10) | 支付宝系统编号(**目前已无提供此付款方式**) |
+| AlipayTradeNo | String(20) | 支付宝交易编号(**目前已无**) |
+| TenpayTradeNo | String(20) | 财付通交易编号(**目前已无**) |
+| WebATMAccBank | String(3) | WebATM 付款人银行代码(未提供则空) |
+| WebATMAccNo | String(5) | WebATM 银行账号后五码 |
+| WebATMBankName | String(10) | 银行名称 |
+| ATMAccBank | String(3) | ATM 付款人银行代码(未提供则空) |
+| ATMAccNo | String(5) | ATM 银行账号后五码 |
+| PaymentNo | String(14) | 缴费代码 |
+| PayFrom | String(10) | 缴费超商 `family`(全家)/`ibon`(7-ELEVEN) |
+| gwsr | Int | 授权交易单号 |
+| process_date | String(20) | 处理时间 `yyyy/MM/dd HH:mm:ss` |
+| auth_code | String(6) | 授权码 |
+| amount | Int | 金额 |
+| stage | Int | 分期期数 |
+| stast | Int | 头期金额 |
+| staed | Int | 各期金额 |
+| eci | Int | 3D(VBV),`5`/`6`/`2`/`1` 代表 3D 交易 |
+| card4no | String(4) | 卡片末 4 码 |
+| card6no | String(6) | 卡片前 6 码 |
+| red_dan | Int | 红利扣点 |
+| red_de_amt | Int | 红利折抵金额 |
+| red_ok_amt | Int | 实际扣款金额 |
+| red_yet | Int | 红利剩余点数 |
+| PeriodType | String(1) | 订单建立时的周期种类 |
+| Frequency | Int | 订单建立时的执行频率 |
+| ExecTimes | Int | 订单建立时的执行次数 |
+| PeriodAmount | Int | 订单建立时的每次授权金额 |
+| TotalSuccessTimes | Int | 目前已成功授权次数 |
+| TotalSuccessAmount | Int | 目前已成功授权金额合计 |
+| CVSStoreID | String(20) | 超商代码缴款的店铺代号 |
+| CVSStoreName | String(20) | 超商代码缴款的店铺名称 |
+| UnionPay | String(1) | 银联回传:银联卡 `Y` / 一般信用卡 `N` |
+| ForeignCard | String(1) | 海外卡:海外卡 `Y` / 国内地 `N`;非信用卡/银联空值 |
+
+---
+
+## §3 查詢訂單 QueryTradeInfo /V5 ★重点(当前验签 bug 相关)
+
+**介接路径**:
+- 正式:`https://payment.funpoint.com.tw/Cashier/QueryTradeInfo/V5`
+- 测试:`https://payment-stage.funpoint.com.tw/Cashier/QueryTradeInfo/V5`
+
+**Content Type**:`application/x-www-form-urlencoded`,POST
+
+### 3.1 特店传入参数
+
+| 参数 | 型态 | 必填 | 说明 |
+|------|------|------|------|
+| MerchantID | String(10) | * | 特店编号 |
+| MerchantTradeNo | String(20) | * | 特店交易编号 |
+| TimeStamp | Int | * | 当下 UnixTimeStamp;**OMG 验证时间区间暂定 3 分钟内有效**,超过则本次介接无效 |
+| PlatformID | String(10) | | 平台商代号(同 §1.1) |
+| CheckMacValue | String | * | 检查码(见 §5) |
+
+### 3.2 OMG 回传参数(基础 17 个)
+
+**Content Type**:`text/html`,POST
+**格式**:订单结果以 form-data 参数直接回传页面,`参数=值`,参数间以 `&` 隔开。
+例:`MerchantID=123456789&MerchantTradeNo=123456abc&TradeNo=20120315174058256423`
+
+| 参数 | 型态 | 说明 |
+|------|------|------|
+| MerchantID | String(9) | 特店编号 |
+| MerchantTradeNo | String(20) | 特店交易编号 |
+| StoreID | String(20) | 分店代号 |
+| TradeNo | String(20) | **OMG 交易编号** |
+| TradeAmt | Int | 交易金额 |
+| PaymentDate | String(20) | 付款时间 `yyyy/MM/dd HH:mm:ss` |
+| PaymentType | String(20) | 付款方式 |
+| HandlingCharge | Int | 手续费合计(**履约结束后才计算,未计前为 0**) |
+| PaymentTypeChargeFee | Decimal | 通路费 |
+| TradeDate | String(20) | 订单成立时间 `yyyy/MM/dd HH:mm:ss` |
+| TradeStatus | String(8) | **交易状态(见下)** |
+| ItemName | String(200) | 商品名称 |
+| CustomField1~4 | String(50) | 自定义字段 |
+| CheckMacValue | String | 检查码(特店必须检查以验证,见 §5) |
+
+### 3.3 TradeStatus 交易状态值 ★
+
+| 值 | 含义 |
+|----|------|
+| `0` | 交易订单成立**未付款** |
+| `1` | 交易订单成立**已付款** |
+| `10200095` | 消费者**未选择付款方式**,故交易失败 |
+
+> 注:文档基础回传只列 17 个参数,但 `NeedExtraPaidInfo=Y` 时实际会回传 §2 的全部额外字段(本项目实测共 47 个字段)。这些额外字段同样参与 CheckMacValue(§2 注意事项)。
+>
+> **信用卡「已授权未关帐」= TradeStatus `1`**(关帐是后端日批,不动 TradeStatus)。
+
+---
+
+## §4 查詢信用卡單筆明細記錄 CreditDetail/QueryTrade /V2
+
+**介接路径**:
+- 正式:`https://payment.funpoint.com.tw/CreditDetail/QueryTrade/V2`
+- 测试:**因无法提供实际授权,无法使用此 API**
+
+**传入**:`*MerchantID` / `*CreditRefundId` Int(信用卡授权单号,= gwsr,须 NeedExtraPaidInfo=Y 取得)/ `*CreditAmount` Int / `*CreditCheckCode` Int(厂商后台→信用卡收单→信用卡授权信息查到)/ `*CheckMacValue`
+
+**回传**:**JSON** 格式(非 k=v)。
+- `RtnMsg`:成功空值;`error_Stop`(查无商家或到期)/`error_nopay`(查无该授权单号)/`error`(错误)。
+- `RtnValue`:`TradeID` / `amount` / `clsamt`(已关帐金额)/ `authtime` / `status`。
+- `status`(未有明细):已取消 / 未授权 / 已授权;(有明细)銀行拒絕 / 要關帳 / 關帳中 / 已關帳 / 要取消 / 取消中 / 已取消 / 銀行追回中 / 銀行已追回 / 批次失敗 / 不明 / 操作取消。
+- `close_data[]`:`status` / `sno`(关帐序号) / `amount` / `datetime`。
+
+> 本项目退款用 `CreditDetail/DoAction`(Action=R 退刷),非此查询接口。此接口仅供明细查询,**测试环境不可用**。
+
+---
+
+## §5 CheckMacValue 檢查碼機制 ★重点
+
+> 来源:OMG 正文多次指向「附錄檢查碼機制」;附录页未独立抓到,规则同 ECPay 标准引擎(WebSearch 旁证 + 官方「額外回傳參數全部加入檢查碼」明文)。
+
+**核心规则**:
+1. **除 CheckMacValue 本身外,其余所有传递参数皆需加入检查码计算**(含空值字段、含 §2 额外回传参数)。
+2. 参数名称按字母 **A-Z 排序**,**大小写不敏感**(实测坐实,见 §6.1;OMG/ECPay 同源引擎服务端按 `String.CASE_INSENSITIVE_ORDER` 排序算检查码)。
+3. 组合 `key=value`,参数间以 `&` 串接。
+4. 前后包夹:`HashKey={HashKey}&{串接串}&HashIV={HashIV}`。
+5. **.NET 风格 URLEncode**(各语言须确认 UrlEncode 转换结果符合附录规范——官方强调这是常见出错点)。
+6. 整串转**小写**。
+7. **SHA256** 摘要(EncryptType=1),hex **大写** = CheckMacValue。
+
+**验签**:对回传参数(含回传的 CheckMacValue)同算法重算,与回传 CheckMacValue 比对。
+
+**本项目实现**:`OmgCheckMacValue`(SHA256,独立工具,零蓝新依赖)。pre-digest pipeline(排序/包夹/.NET URLEncode/还原 `-.!*()`/小写)已用 ECPay 物流官方向量(MD5)验证正确;该向量字段全大写,**排序大小写分歧由生产 QueryTradeInfo 响应实测另行锁定(大小写不敏感,见 §6.1)**。
+
+---
+
+## §6 对本项目的关键注意点
+
+1. **QueryTradeInfo 响应验签分歧 — 已解决**(2026-08-12 坐实并修复):
+   - **根因**:`OmgCheckMacValue` 默认用 `Comparator.naturalOrder()`(ASCII 大小写敏感)排序算 CheckMacValue,而 OMG 服务端按**大小写不敏感**排序算。QueryTradeInfo 响应字段大小写混合(首字母大写 `AlipayID`/`MerchantID`/`TradeStatus` vs 首字母小写 `amount`/`auth_code`/`card4no`/`eci`/`gwsr`/`process_date`/`red_dan`),两种排序结果不同 → 验签必然 mismatch。出站请求参数全为首字母大写,两种排序相同,故请求签名一直被 OMG 接受,bug 仅在响应验签暴露。
+   - **坐实手段**:commit `abcd7d4` 的 4 候选 CMV 诊断日志(大小写敏感/不敏感 × 空值保留/排除),生产订单 `991786433092835` 实测 `receivedCMV == ci_inc`(大小写不敏感 + 保留空值),其余 3 候选均不等。
+   - **修复**:`OmgCheckMacValue.generate` 默认排序改 `String.CASE_INSENSITIVE_ORDER`(保留空值不变)。请求侧零回归(参数全首字母大写,两种排序等价)。回归测试 `OmgCheckMacValueTest#verifiesQueryTradeInfoResponseWithCaseInsensitiveKeyOrder` 锁定(修复前该用例在 `verify` 处失败)。
+
+2. **回传格式**:QueryTradeInfo 回传 `text/html`,k=v 用 `&` 分隔(本项目 `parseKvResponse` 解析;2026-08-12 已放宽对尾随 `&`/空值的容忍,commit `7a8e248`)。
+
+3. **TradeStatus 三值**:`0`/`1`/`10200095`,判断支付成功主要看它,但**必须验签通过后才可信**(防伪造免单攻击)。
+
+4. **TimeStamp 3 分钟有效期**:QueryTradeInfo 介接后须 3 分钟内有效(本项目用 `System.currentTimeMillis()/1000`)。
+
+5. **NeedExtraPaidInfo=Y 的影响**:QueryTradeInfo 与付款通知会回传 §2 全部额外字段(本项目实测 47 字段),验签须全纳入。`card4no`/`card6no` 为卡号片段,日志脱敏注意。
+
+6. **QueryTrade/V2 测试不可用**:信用卡明细查询测试环境无授权,无法用;退款走 `DoAction(Action=R)`。
+
+7. **MTN(MerchantTradeNo)永久绑定**:一旦 AioCheckOut 提交,MTN 永久占用不可复用(详见 `payment-attempt-lifecycle.md` 硬约束)。
+
+---
+
+## 附录:本项目当前 create 组参对照
+
+`OmgPayController.create` 实际组参(对照 §1):
+```
+MerchantID / MerchantTradeNo / MerchantTradeDate / PaymentType=aio /
+TotalAmount / TradeDesc / ItemName / ReturnURL / ChoosePayment=ALL /
+EncryptType=1 / InvoiceMark=N / NeedExtraPaidInfo=Y / PaymentInfoURL
+```
+未用:StoreID / ClientBackURL / ItemURL / Remark / ChooseSubPayment / OrderResultURL / DeviceSource / IgnorePayment / PlatformID / CustomField / Language / RiskMerchantMemberID / 分期 / 定期定额 / 银联 / 记忆卡号。

+ 317 - 0
specs/016-omg-payment/payment-attempt-lifecycle.md

@@ -0,0 +1,317 @@
+# OMG 支付流水生命周期重设计(bug 修复 + 账本重构调查)
+
+**日期**: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. **延期支付有效期长**:`ChoosePayment=ALL`,用户可选 ATM/超商/BarcodeATM/AFTEE,交易有效期到 `ExpireDate`(ATM 默认 3 天、最长 60 天;超商 3 天;条码 7 天;AFTEE 可达 21+ 天),期间随时可付。取号信息经 `/pay/omg/paymentInfo` 回调落 `callbackRaw`。
+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. **首查延迟**:跳过 `create_time < NOW()-INTERVAL 40 MINUTE` 的行。
+2. **per-MerchantID 令牌桶**:Redis key `omg:qt:token:{merchantId}`,1 token / 3s,burst 1——`queryTrade` 前 acquire,空则 skip 本行本轮(不阻塞)。
+3. **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 | 三道节流闸(首查≥40min、per-MerchantID 令牌桶 1/3s、per-ddId TTL) |
+| ④ 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`