2026-09-22-refund-reconciliation.md 7.1 KB

退款侧收敛腿 + queryRefund 补 mch_no

日期:2026-09-22 依据:docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §2.1–2.3 裁定 设计出处:design §6.6 PAY_IN_PROCESS → REFUNDING,由 Job 2 查 v3/queryrefund 推进

为什么做

设计 §6.6 写了这条腿,实现时掉了 —— LsyReconcileScheduler.reconcile() 四条腿全是订单, 退款侧零对账。后果是一条链:

  1. 停在 REFUNDING 的退款行永久卡住(§2.3),在途闸门 + 部分唯一索引双双锁死该单;
  2. 正因如此,异步回调 TRADE_REFUND_FAIL 必须写 FAIL 放闸 —— 那是唯一能让失败退款 离开在途状态的通道(§2.2 的承重权衡)。

补上对账腿后 §2.3 直接消除,且 §2.2 的守闸才有可能变安全(本增量不改编回调行为)。

上游文档依据(D:\project2\leshouying-api-docs.md)

事实 出处
退款查询 result_code ∈ {PAY_SUCCESS, PAY_IN_PROCESS, PAY_FAIL} ## 15. 退款查询 :603
退款回调是另一套词表(TRADE_REFUND*) ## 8. :441
「PAY_IN_PROCESS 需调用退款查询接口确认退款结果」= 查询是权威确认通道 :485
退款查询 mch_no 必填 :596

改动

1. LsyRefundService.queryRefund 补 mch_no

文档标必填;结构孪生 LsyQueryService.queryOrder 传了它、且联调实测查单走通;同类的 createRefund 也传。注入 LsyOrderMapper,按 refund.getOrderId() 取 order 的 mchNo。 取不到则不传(LeshouyingClient.post 过滤 null)⇒ 退化为当前行为,不会更糟。

⚠ 这会改两条钉住 keySet 的测试(LsyRefundServiceTest:343 / :381)。那是刻意的契约变更, 不是把测试改绿 —— 新集合要多一个 mch_no。

2. LsyRefundService.settleFromQuery(refund, resp)(新)

镜像 LsyOrderService.settleFromQuery(:412)的形状与纪律:

  • isPaySuccess → applyNotifyResult(..., "SUCCESS") ⇒ 命中则订单侧 settleOrderAfterRefund + callbackService.notifyOrder ⇒ 返回 "SUCCESS"
  • isPayFail → applyNotifyResult(..., "FAIL") ⇒ 返回 "FAIL" 或 null (依据 :485,查询是权威确认通道;这与 createRefund 同步分支守闸不矛盾 —— 同步分支手上没有权威答案,对账腿有)
  • 未知码 → 只 log.error,不落任何状态(与 settleFromQuery 支付侧同纪律)
  • PAY_IN_PROCESS → 不动,等下一轮

订单侧收敛放在这里而不是调度器:调度器 javadoc 明令「本调度器自己不解析任何上游报文」。

3. LsyReconcileScheduler.reconcilePendingRefunds()(新)

形状对齐 reconcilePendingOrders():扫 status IN LsyRefundStatus.PENDING 且 createdTime <= now - reconcileDelaySeconds,LIMIT 100;逐条 try/catch 隔离,单条失败不中断。 新增依赖 LsyRefundMapper + LsyRefundService,接进 reconcile() 第 5 条腿。

4. 文档一致性(不能省)

「退款侧没有兜底对账」在 6 个文件里被当成事实断言,加腿后即为假:

enums/LsyRefundStatus / scheduler/LsyOrderPollScheduler / scheduler/LsyReconcileScheduler (含那段靠 grep 计数的纪律)/ service/LsyNotifyService(×2) / service/LsyRefundServiceTest(×3) / db/migration/V1.8(×2)。

★ V1.8 那两处要先确认是 -- 注释还是 COMMENT ON:若是后者,改文件会让它与已执行的库 分叉(库里的列注释是逐字核对过的物证)。

不做的

  • 不改 LsyNotifyService.handleRefundNotify 对 TRADE_REFUND_FAIL 落 FAIL 的行为。 台帐 §2.2 裁定:它是承重权衡,本腿只是让「将来改成守闸」变安全。

⚠ 2026-09-22 补充:handleRefundNotify 的定位段恒落空(退款回调报文不带任何 退款标识,查单 refund != null 永不成立)⇒ 上面这条「承重权衡」以及整个退款回调的 三分支词表/幂等闸门,在真实流量下都是死区。本腿(对账)因此不只是「兜底」, 而是退款侧唯一真正在跑的收敛通道。见台账 §一「★★ 2026-09-22 裁定」。 本增量不改定位逻辑(挂起中),只订正了注释。

  • 不做 §2.1 的预插入(下一增量)。
  • 不做 FAIL 行的「迟到成功」自动探测(LATE_REFUND_AFTER_TERMINAL 目前只有事后探测器)。

⚠ 本增量引入的新缺口(未做,需裁定)

退款对账腿没有上界,可被老行饿死。 订单侧有 absoluteExpireHours=24 强制收敛 (expireStaleOrders),退款侧不可能有对应物 —— 退款行没有任何安全的本地终态: 写 FAIL 会释放 uk_lsy_refund_one_pending + 在途闸门(可重复出金),写 SUCCESS 是谎报出金。 所以一个永远拿不到权威答案的退款行会每 5 分钟被问一次上游,直到永远, 且它一直占着 LIMIT 100 的配额 ⇒ 老行堆积后新退款行轮不到(静默饿死)。

没做的理由:上界该取多少、超界后该怎么办(停查 + 告警?转人工池?),我没有任何上游依据 可推 —— 上游文档没说退款查询结果的有效期。本项目铁律是不得猜测,故不发明这个数字。 建议方向(需裁定,不是结论):只查 createdTime 在 [now-delay, now-上界] 的行, 超界的停查 + log.error(不写任何终态),把无界重试换成有界重试 + 响亮告警。

实施结果(2026-09-22)

  • 改动落点:LsyRefundService(+LsyOrderMapper 依赖、queryRefund 补 mch_no、 新增 settleFromQuery)、LsyReconcileScheduler(+LsyRefundMapper/LsyRefundService 依赖、 新增 reconcilePendingRefunds 并接为 reconcile() 第 ⑤ 条腿)。
  • 测试:LsySchedulerTest 18、LsyRefundServiceTest 29,全量 401 绿 / 0 失败(原 390)。
  • 变异验证(两向,实测读数):
    • 抽掉 queryRefund 的 mch_no ⇒ 两条钉 keySet 的用例红,负对照 queryRefund_订单查不到_不送mchNo 仍绿(它本就只该在实现送 null 键时红);
    • 把扫描条件从 LsyRefundStatus.PENDING 收窄成 Set.of("REFUNDING") ⇒ 扫描条件用例红,报出 expected: <[CREATED, REFUNDING]> but was: <[REFUNDING]>;
    • 把第 ⑤ 条腿从 reconcile() 摘掉 ⇒ 「已接入」用例红。
  • 文档一致性:旧断言句「退款侧…兜底对账」全仓 12 行已全部改写,现为 0 行 (正对照:「兜底」一词全仓 72 行,证明该 0 是真 0、不是检索式失效)。 另发现 4 处换了措辞的同一断言(「没有对账会来捞它」「不会有退款对账来捞」 「再无兜底」及 LsyRefundServiceTest 的「没有任何定时任务调它」),已一并订正。 顺带订正 LsyReconcileScheduler javadoc 里另一条已过期的断言: 「退款回调自锁、retryFailed() 一行都捞不到」—— 该自锁已由 LsyRefundService.pushRefundCallbackIfTerminal 解除,现不再成立。