日期: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 见文末附录)。
bugB 验签已修(前提满足):
receivedCMV==ci_inc),原 TreeMap naturalOrder(大小写敏感)对 QueryTradeInfo 响应混合大小写字段排序错位 → 验签失败。1a424c3:generate 默认改 String.CASE_INSENSITIVE_ORDER + 回归测试。queryTrade 现可靠(线上已验证:读到 TradeStatus=10200095 → markFail 链路通)。& 放宽(7a8e248)。单行方案对抗否决(用户改确认追加式 S):
wf_06aa691f 对抗验证:物理单行(每订单 1 条,in-place UPDATE 换号)3 视角(资金/并发/延期)全判 broken,核心根因 = in-place UPDATE merchant_trade_no 是丢钱引擎(pay_status=2 复活 MTN 被销毁 / 信用卡未取号也能付 / create vs notify 并发错配 TradeNo)。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 已完成):
对抗发现的前置补强(加进落地):
§9 待拍板项:freshMin / MySQL 版本 / reuse MerchantTradeDate / 节流参数 / AFTEE 窗口等 → 实现/stage 确认(不阻塞 spec)。
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。
上述订单最新一条 id=26 的 MTN OMGFE0494FB7AD1474A8,在 OMG 后台已确认授权扣款(信用卡、授權單號 11065906、卡末 2222、2026-08-11 15:58:28、商店代號 1000031),但 POST /pay/omg/query 返回:
{ "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。
POST /pay/omg/notify(OmgPayController.java:241)→ 验签 CheckMacValue → trade_no 幂等 → 金额校验 → RtnCode==1 && SimulatePaid!=1 → markSuccess(pay_status 0→1,按 trade_no CAS)+ 订单状态流转 + 推送。/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。
ChoosePayment=ALL,用户可选 ATM/超商/BarcodeATM/AFTEE,交易有效期到 ExpireDate(ATM 默认 3 天、最长 60 天;超商 3 天;条码 7 天;AFTEE 可达 21+ 天),期间随时可付。取号信息经 /pay/omg/paymentInfo 回调落 callbackRaw。queryTrade 是兜底;但补单现依赖"最新一条=被付的那条"假设,多 MTN 时失效。getByMerchantTradeNo 查不到 → 钱付了订单永未支付。安全换号必须先 queryTrade 确认旧 MTN 未付;而 queryTrade 当前不可靠(问题 B)→ 问题 B 必须先修,换号才有安全网。ruoyi-admin;实体/Mapper/XML 在 ruoyi-system,不可反向依赖。来源 URL 见附录 B。核心:
& 分隔 KV 串(官方 YAML 标 JSON 但实际回 KV),含 CheckMacValue;尾随 & 与 PaymentDate= 等空字段是常态——正是 parseKvResponse 严拒的形态。值含 = 应用 partition('=') 只切首等号。0=订单已成立未付款;1=已成立且已付款;10200095=订单未成立(消费者未完成付款作业/交易失败)。信用卡"已授权未关帐"= TradeStatus 1(关帐是后端日批,不动 TradeStatus)。→ id=26 应返 1,问题 B 的 option3 可能性最低。ExpireDate 时钟或取号查询的 102xxxxx RtnCode 体现;ATM 付款到账最多延迟 2 天,超商/Barcode 默认 7 天,AFTEE 14~45 天。OMG QueryTradeInfo 对"ExpireDate 当下已付但银行未送达"无可观察性,结算客观上滞后几分钟~几小时(超商甚至跨日)。三候选(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 这笔会在结构改动下从"可恢复"变成"不可恢复"。
核心动作:把两个轴正交分离。
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 生成列唯一索引强制落地。
updatesql/sql.md,不直接执行)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 为只读生成列,可不映射)。updateMerchantTradeNo/updateTradeNo 方法——从能力上杜绝约束#4 的丢钱路径。OmgPayController.create,保留 @Transactional)createPaymentAttempt 之前):Redisson RLock lock:omg:create:{ddId},tryLock(等 3s)。锁释放必须放 TransactionSynchronizationManager.registerSynchronization 的 afterCommit 回调(仿 WalletService.returnPoints),禁止直接 try/finally unlock——否则 unlock 在方法返回、TX 在 AOP after-returning 才提交,被阻塞线程看不到未提交 INSERT → 重复活跃行(致命伤 ⑨)。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 侧已占用,绝不复用(致命伤 ②)。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 唯一冲突。连点 9 次:第 1 次建行;第 2-9 次在新鲜期内且 trade_no IS NULL → 复用第 1 条,不新建。新鲜期外或已 trade_no 非空 → 才换号(旧行 is_active=0 保留可补单)。问题 A 从源头收敛。
核心原则:轮换只由用户主动触发(下次 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) 仍命中。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。expire_date(回调覆盖)、pay_type/pay_time/rtn_code 等支付元数据;merchant_trade_no/trade_no 永不 UPDATE(mapper 无此能力)。【A. 扫描入口】 selectLeakOrderDdIds(XML:55-70)改为:
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 换成两段:
selectLatestPaidByDdId:若存在 pay_status=1 行且 order.payStatus==0 → handlePaymentSuccess 自愈补推(保留原 L774-781,致命伤 ⑤ 消除);若已付且订单已核销 → 幂等返回 {1,0}。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,致命伤 ③)】
create_time < NOW()-INTERVAL 40 MINUTE 的行。omg:qt:token:{merchantId},1 token / 3s,burst 1——queryTrade 前 acquire,空则 skip 本行本轮(不阻塞)。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 缺陷)。
三层幂等:
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)。handlePaymentSuccess 的 posOrderService.update CAS eq pay_status=0(L866-869)不变;第二笔已付行(跨 MTN 双付)CAS 失败 → 走 recordPaidOrderWithoutFulfillment,增强为写 ipn_log(type=omg_orphan_paid, 带双 tradeNo/金额) + 运营告警。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 后台人工退"。| 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 兜底 |
P0 必须最先做且 0 状态机改动:先给
reconcileByQuery三个{0,0}分支加结构化log.error+ 原始响应落ipn_log(type=omg_query_error)+ 查ipn_log type=omg看 id=26 MTN 有没有收到过 notify。定位 B 是 ①/②/③ 哪个,再动结构。
parseKvResponse 放宽(尾随&/空值,不丢重复键)+ catch(Exception) 改 log.error+UNKNOWN(3) + OmgPayTest 补真实 fixture。reconcileByQuery 两段式 + selectLeakOrderDdIds 改 EXISTS + 三道节流闸。selectActiveForReuse + trade_no IS NULL + 新鲜期)+ afterCommit 锁 + DDL(is_active/expire_date/生成列唯一索引)+ 历史回填(只 is_active)。getLatestByDdId 全迁移 + selectLatestPaidByDdId 扩 IN(1,3,4)。listPaidByDdId 遍历逐笔退 + 孤立已付告警。uk_omg_active_dd 生成列唯一索引(5.7.6+)可行性,还是退化为 app 锁+CAS。pom 只有 JDBC 驱动 8.2.0、非服务端版本。expire_date+缓冲动态计算。/query 被动端点轮询 cadence:现前端 2s 过激进,配合 reconcile-all-rows 放大 queryTrade;本方案加 per-ddId 60s TTL 节流,但需与前端确认调整为 30-60s(客户 uni-app 不在工作区,T017/T024 阻塞中)。specs/016-omg-payment/(本文档)还是另起 docs/superpowers/specs/;后续 plan/tasks 是顺延 016 还是新建。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 内全覆盖)SQL(updatesql/sql.md)
测试
OmgPayTest:真实 stage QueryTradeInfo 响应 fixture(尾随&/空 PaymentDate)PosOrderOmgPaymentServiceImplTest / OmgPayControllerTest:复用预检、reconcile 两段式、多笔退款、双轴竞态…/subagents/workflows/wf_e4577a10-1c6/../../workflows/scripts/omg-payment-ledger-design-wf_e4577a10-1c6.js(可用 Workflow({scriptPath, resumeFromRunId:"wf_e4577a10-1c6"}) 重跑/续跑)…/subagents/workflows/wf_e4577a10-1c6/
journal.jsonl(每代理结果)_extract.txt(4 研究报告 + 3 候选详情)_verdicts.txt(7/9 对抗评审结论)_final.txt(Final 综合的结构化输出)developers.ecpay.com.tw/2862/;ithelp.ithome.com.tw/articles/10349746;blog.hoyo.idv.tw/?p=3970developers.ecpay.com.tw/2872/;ecpay.com.tw/Content/files/gw_p120.pdfdevelopers.ecpay.com.tw/16579/、/2890/;github.com/andy6804tw/ecpay-payment-demo/blob/master/Decode.mddevelopers.ecpay.com.tw/16579/ Special Noteecpay.com.tw/Content/files/gw_p110.pdf;developers.ecpay.com.tw/9242/;gctek.io/blog/ecpay-credit-card-close-cancel-refund-abandon-action-guidedocs.stripe.com/payments/payment-intents