# 退款侧收敛腿 + 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` 解除,现不再成立。