Просмотр исходного кода

fix(乐收赢): 退款回调此前落在支付腿上 —— 按订单 trace 查必然查不到

上游只认**一个**回调地址(下单时配的 notify_url),退款通知也推它;而我方
`/notify/pay` 与 `/notify/refund` 是两个端点。`handlePayNotify` 此前不判类型,
拿**退款的** pay_trace_no 去查 `pay_lsy_order`,查不到,写一条 ORDER_NOT_FOUND
就结束了 —— 退款腿一行代码都没跑到。

2026-09-29 实弹实测到的正是这个形态(logs/_lsy-order-refunds-probe.txt):
订单 BC1790649777 的退款回调 11:04:12.903 进 RECEIVED、11:04:12.937 判
ORDER_NOT_FOUND,两条的 trade_type 都是 '0'。

两处改动,一处是分流、一处是定位键:

1. 分流判在**验签之后、任何业务查询之前**。顺序不是洁癖:放到「查订单 → 查不到」
   之后再分流,一次回调会写两条 RECEIVED(支付侧一条、退款侧一条),
   「回调轨迹」里凭空多一行。判据 result_code 以 TRADE_REFUND 开头**或**
   trade_type == "0" —— 前者更贴语义,后者是上游枚举里的硬定义,取并集。

2. 定位键:回调里**没有** refund_no / out_refund_no / refund_trace_no,
   只有 pay_trace_no,而实测它逐字符等于我方发出的 refund_trace_no
   (4e8ceec2b83b44a78efaacd73499b5a9)。改为先按 refund_trace_no 查,
   查不到再退回 refund_no;两个键各自非 null 才查(`eq(col, null)` 会渲染成
   `col = NULL`,恒不命中且不报错)。整条定位不到时仍写 REFUND_NOT_FOUND 并回 OK。

删掉的局部变量 `refundNo` 还有四处日志在引用,一并换成
`refund.getRefundNo()` —— 定位到的那一行里的真值,比请求参数更可信。

两向变异(还原后 grep -rn MUTANT 无残留):
- 分流处改成 `if (false && isRefundNotify(...))` ⇒ 路由用例红
- findByRefundTraceNo 里换成按 refundNo 查 ⇒ 定位用例红
- isRefundNotify 里交换两个谓词(无害)⇒ 全类仍绿

LsyNotifyServiceTest 24/24 绿;全量 916 绿。
alphaH 2 недель назад
Родитель
Сommit
92555d56ea

+ 113 - 27
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyService.java

@@ -90,6 +90,18 @@ public class LsyNotifyService {
             logNotify(params, rawBody, null, "FAIL", "REJECTED", "验签失败");
             return BAD;
         }
+
+        // ★★ 分流:上游只认**一个**回调地址(下单时配的 notify_url),退款通知也推它,
+        //   而我们的两个端点是分开的。必须在**任何业务查询之前**判,理由是顺序而非洁癖:
+        //   放到「查订单 → 查不到」之后再分流,一次回调就会写两条 RECEIVED 审计行
+        //   (支付侧一条、退款侧一条),「回调轨迹」里凭空多一行。
+        //   2026-09-29 实弹实测到的正是本分流缺失时的形态:退款回调打在 /notify/pay 上,
+        //   拿**退款的** trace 去查订单(必然查不到),写一条 ORDER_NOT_FOUND 就结束了。
+        //   详见 handleRefundNotify 的定位键注释与 logs/_lsy-refund-callback-raw.json。
+        if (isRefundNotify(params)) {
+            return handleRefundNotify(params, rawBody);
+        }
+
         // 此刻还没查订单,归属未知 → tenantId 传 null(审计行只对超管可见,见 logNotify 的 javadoc)
         logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
 
@@ -199,36 +211,49 @@ public class LsyNotifyService {
         // 此刻还没查退款单,归属未知 → tenantId 传 null
         logNotify(params, rawBody, null, "SUCCESS", "RECEIVED", null);
 
-        // ⚠⚠ 实测:refund_no **不是**本回调的定位键,这一段现在恒走 null 分支。
-        //   上游「退款异步回调」页的**完整字段表(24 行)与请求样例里没有任何退款标识**
-        //   —— refund_no / out_refund_no / refund_trace_no / qd_refund_no 一个都没有;
-        //   支付回调页同构,证明不是漏列。⇒ str(params.get("refund_no")) 恒为 null。
+        // ★★ 定位键 = 报文的 `pay_trace_no`,落在 `refund_trace_no` 列上(2026-09-29 实弹报文裁定)。
+        //
+        //   本段此前写的是「定位键挂起,不猜」,依据是上游 opendocs「退款异步回调」全页
+        //   (24 行字段表 + 请求示例)里 refund_no / out_refund_no / refund_trace_no /
+        //   qd_refund_no **一个都没有**。那个读数是**真的**,但由它推出的结论——「报文里
+        //   没有任何退款标识」——**是错的**:文档没有定义 `pay_trace_no` 在退款回调里指哪一笔,
+        //   我当时把「文档没写」当成了「不存在」,于是把唯一可能的键也一起排除掉了。
+        //
+        //   2026-09-29 一笔真实退款(1 分)的回调给出 25 个字段的报文,其中
+        //     pay_trace_no = 4e8ceec2b83b44a78efaacd73499b5a9
+        //   与我们随退款请求发出去的 `refund_trace_no`(LsyRefundService 里
+        //   IdUtil.fastSimpleUUID() 生成)**逐字符相同** —— 是我们选的值、由上游原样回传,
+        //   因果闭合,不是同形巧合。报文原文见 logs/_lsy-refund-callback-raw.json。
+        //
         //   ⚠ 不要以为「null 就当没这个条件」:`.eq(col, null)` 在 MP 里**照发谓词**,
         //   绑一个真正的 null(实测 getSqlSegment / getParamNameValuePairs),
-        //   而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行** ⇒ refund == null 每次必中。
+        //   而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行**。
         //   (好在不是退化成无 WHERE 的 LIMIT 1 —— 那会随机捞一行,更糟。)
-        //   后果:本方法的其余部分(三分支词表 / 幂等闸门 / LATE_REFUND_AFTER_TERMINAL)
-        //   在真实流量下是**死区**,且每次回调都误写一条 REFUND_NOT_FOUND 审计行。
-        //   影响范围**不是**「钱动了我们不知道」:异步收敛由
-        //   LsyReconcileScheduler.reconcilePendingRefunds 兜住(它按 status + created_time 扫,
-        //   不依赖任何上游字段)。它只是从未被点亮 —— 库里 pay_lsy_refund 0 行、
-        //   notify_log 0 行,日志里 /v3/refundorder 也是 0 命中。
-        //   ⇒ 重新定位键**挂起,不猜**(本项目铁律)。定夺依据:真实报文
-        //   (raw_body 已逐条落库)或上游答复。详见
-        //   docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md
-        //   §一「★★ 2026-09-22 裁定」与其下的「📌 待订正清单」。
-        String refundNo = str(params.get("refund_no"));
-        LsyRefundEntity refund = refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
-                .eq(LsyRefundEntity::getRefundNo, refundNo)
-                .last("LIMIT 1"));
+        //   ⇒ 每个键都只在**非 null 时**才发查询;两个都没有就一次库都不查。
+        //
+        //   兜底保留 `refund_no` 的理由:`pay_trace_no` 指「本笔退款」这一读法目前只有**一笔**
+        //   样本。若上游在别的场景改发原支付的 trace,主键会落空 —— 那时兜底救不回来
+        //   (真实报文里本就没有 refund_no),但**也不会更坏**:仍是 REFUND_NOT_FOUND。
+        //
+        //   影响面**不是**「钱动了我们不知道」:异步收敛由
+        //   LsyReconcileScheduler.reconcilePendingRefunds 兜住(按 status + created_time 扫,
+        //   不依赖任何上游字段)。本次改动是给那条从未通电的死腿接上线,不是补资金安全网。
+        //   原裁定全文见
+        //   docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §一。
+        LsyRefundEntity refund = findByRefundTraceNo(str(params.get("pay_trace_no")));
+        if (refund == null) {
+            refund = findByRefundNo(str(params.get("refund_no")));
+        }
         if (refund == null) {
-            log.error("乐收赢退款回调找不到退款单: refundNo={}",
-                    LsyLogSanitizer.flatten(refundNo));
+            log.error("乐收赢退款回调找不到退款单: refundTraceNo={}, refundNo={}",
+                    LsyLogSanitizer.flatten(str(params.get("pay_trace_no"))),
+                    LsyLogSanitizer.flatten(str(params.get("refund_no"))));
             // 与支付侧 ORDER_NOT_FOUND 对齐,补上审计行:
             // 「上游说退款回调来了,我们这边没有这笔退款单」是最需要留痕的一类事件,
             // 只写 log.error 会随日志轮转消失,事后无从查证。
-            // ⚠ 但在当前缺陷下这条**不是**「上游说有一笔我们不认识的退款」,
-            //   而是「定位键恒 null」的必然结果 —— 排查时别把它的出现当成异常信号。
+            // ★ 2026-09-29 之后这条**恢复**成异常信号了:在此之前它每次回调必中
+            //   (定位键恒 null),所以当时的注释写着「别把它的出现当成异常」——
+            //   那句话随本次修复**一并作废**,出现即说明键对不上或我们没落库。
             logNotify(params, rawBody, null, "SUCCESS", "REFUND_NOT_FOUND", "退款单不存在");
             return OK;
         }
@@ -281,7 +306,7 @@ public class LsyNotifyService {
                             + "outRefundNo={}, refundNo={}, return_msg={}",
                     refund.getStatus(),
                     refund.getOutRefundNo(),
-                    LsyLogSanitizer.flatten(refundNo),
+                    LsyLogSanitizer.flatten(refund.getRefundNo()),
                     LsyLogSanitizer.flatten(params.get("return_msg")));
             // ★ 审计词是新造的**我们自己的**词,不是上游词汇 —— 「不新造上游词汇」那条约束
             //   针对的是 `result_code` 这类上游字段值(必须有文档实证),审计词本来就是我们的词表。
@@ -300,7 +325,7 @@ public class LsyNotifyService {
         if (outcome == null) {
             log.error("乐收赢退款回调返回未知 result_code,不落终态、留在 REFUNDING 待人工核对: "
                             + "refundNo={}, return_code={}, result_code={}, return_msg={}",
-                    LsyLogSanitizer.flatten(refundNo),
+                    LsyLogSanitizer.flatten(refund.getRefundNo()),
                     LsyLogSanitizer.flatten(params.get("return_code")),
                     LsyLogSanitizer.flatten(resultCode),
                     LsyLogSanitizer.flatten(params.get("return_msg")));
@@ -343,9 +368,9 @@ public class LsyNotifyService {
                 log.error("★ 账实不符:上游回调称退款成功,但本行已落终态 {},本次回写被拒;"
                                 + "钱可能已退给用户,需人工核对!outRefundNo={}, refundNo={}",
                         refund.getStatus(), refund.getOutRefundNo(),
-                        LsyLogSanitizer.flatten(refundNo));
+                        LsyLogSanitizer.flatten(refund.getRefundNo()));
             } else {
-                log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refundNo));
+                log.info("退款回调重复,忽略: refundNo={}", LsyLogSanitizer.flatten(refund.getRefundNo()));
             }
             logNotify(params, rawBody, refund.getTenantId(), "SUCCESS",
                     lateRefundAfterTerminal ? "LATE_REFUND_AFTER_TERMINAL" : "DUPLICATE", null);
@@ -443,4 +468,65 @@ public class LsyNotifyService {
     private String str(Object o) {
         return o == null ? null : String.valueOf(o);
     }
+
+    /**
+     * 这笔回调是不是**退款**通知 —— 用于把打到支付端点上的退款通知分流给退款腿。
+     *
+     * <p>【为什么要分流】上游只认一个回调地址(下单时配的 `notify_url`),退款通知也推它;
+     * 我们的两个端点却是分开的。实测(2026-09-29):退款回调打在 `/notify/pay` 上,
+     * 支付腿拿退款的 trace 去查订单、必然查不到,写一条 `ORDER_NOT_FOUND` 就结束了。
+     *
+     * <p>【判据为什么取这两个,而不是「查不到订单就当退款」】后者是**从结果反推类别**:
+     * 一笔 trace 对不上的**支付**回调(我们没这笔单,或上游发错了)会被误判成退款,
+     * 于是最该报警的 `ORDER_NOT_FOUND` 反而写不出来。判据必须来自报文**自身的**语义:
+     *
+     * <ul>
+     *   <li>`result_code` 前缀 `TRADE_REFUND` —— 退款侧独有词表
+     *       (`TRADE_REFUND` / `TRADE_REFUND_FAIL`),支付侧是 `PAY_SUCCESS` / `PAY_FAIL`;</li>
+     *   <li>`trade_type = "0"` —— 我方设计文档 §4.2 记的上游枚举:0退款/1交易/2云订单关单,
+     *       与支付侧的 `"1"` 互斥。</li>
+     * </ul>
+     *
+     * <p>两条任一成立即分流。判错的最坏后果**有界**:分流到退款腿而退款腿也找不到单时,
+     * 写的是 `REFUND_NOT_FOUND` 审计行并应答成功 —— 不写任何业务状态、不丢钱,
+     * 与支付腿在同情形下写的 `ORDER_NOT_FOUND` 是同一量级的审计噪音。
+     * 反方向(漏判)的后果同样有界:退回今天的行为。
+     */
+    private boolean isRefundNotify(Map<String, Object> params) {
+        String resultCode = str(params.get("result_code"));
+        if (resultCode != null && resultCode.startsWith("TRADE_REFUND")) {
+            return true;
+        }
+        return "0".equals(str(params.get("trade_type")));
+    }
+
+    /**
+     * 按 `refund_trace_no` 定位退款单;键为 null 时**一次库都不查**(见 {@link #findByRefundNo})。
+     */
+    private LsyRefundEntity findByRefundTraceNo(String refundTraceNo) {
+        if (refundTraceNo == null) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getRefundTraceNo, refundTraceNo)
+                .last("LIMIT 1"));
+    }
+
+    /**
+     * 兜底:按上游给的 `refund_no` 定位退款单。
+     *
+     * <p>**键为 null 时直接返回 null,不发查询。** `.eq(col, null)` 在 MP 里照发谓词、
+     * 绑一个真正的 null,而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ 恒 0 行 ——
+     * 结果与不查完全一样,只是让日志里多一条看上去像「查过、没找到」的记录。
+     * 而「查过没有」与「没有可查的键」这两种情形的处置不同(前者要问上游是不是发了新字段,
+     * 后者要查我们自己的退款单为什么没落库),不能混成同一个读数。
+     */
+    private LsyRefundEntity findByRefundNo(String refundNo) {
+        if (refundNo == null) {
+            return null;
+        }
+        return refundMapper.selectOne(new LambdaQueryWrapper<LsyRefundEntity>()
+                .eq(LsyRefundEntity::getRefundNo, refundNo)
+                .last("LIMIT 1"));
+    }
 }

+ 142 - 52
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyNotifyServiceTest.java

@@ -156,6 +156,7 @@ class LsyNotifyServiceTest {
         r.setOrderId(1L);
         r.setRefundNo("R001");
         r.setOutRefundNo("RF001");
+        r.setRefundTraceNo("RT001");
         r.setRefundFee(30L);
         r.setStatus(status);
         return r;
@@ -164,24 +165,48 @@ class LsyNotifyServiceTest {
     /**
      * 退款回调报文 —— 词表与支付侧不同(TRADE_REFUND / TRADE_REFUND_FAIL),见实现类注释。
      *
-     * ★★ **本夹具刻意不含 `refund_no`,因为它在上游报文里不存在**(2026-09-22 实测上游
-     * opendocs「退款异步回调」全页:完整 24 行字段表与请求示例里 `refund_no` /
-     * `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**)。
-     * 此前这里塞过一个 `refund_no="R001"` —— 那是**凭空字段**(出自 Task 12 brief 的测试样例,
-     * 不是上游文档),它让报文看上去是对的,配合下面各用例的 `selectOne(any())` 打桩,
-     * 使「定位键恒为 null」这个缺陷**在测试里完全不可观测**。
-     * 摘掉它之后夹具才与真实报文同形;缺陷本身由
-     * {@link #refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull} 正面钉住。
+     * ★★ **本夹具的字段集取自 2026-09-29 那笔实弹退款回调(25 个键,报文原文见
+     * `logs/_lsy-refund-callback-raw.json`),值一律换成测试值。** 复刻的是**结构与语义**,
+     * 不是那笔交易的值。三条读数才是要照抄的部分:
      *
-     * <p>需要 `refund_no` 的用例(审计列宽那条)自行 `put` —— 它测的是**未认证入口的截断**,
-     * 攻击者本来就能塞任意字段,与上游发不发无关。
+     *   - `pay_trace_no` = **本笔退款的** trace,与我们随退款请求发出去的 `refund_trace_no`
+     *     (`LsyRefundService` 里 `IdUtil.fastSimpleUUID()` 生成)逐字符相同 ⇒ 它才是定位键;
+     *   - `trade_type` = `"0"`(我方设计文档 §4.2 记的枚举:0退款/1交易/2云订单关单),
+     *     与支付侧的 `"1"` 互斥;
+     *   - `refund_no` / `out_refund_no` / `refund_trace_no` / `qd_refund_no` **一个都没有**
+     *     —— 上游 opendocs「退款异步回调」全页(24 行字段表 + 请求示例)早有此结论,
+     *     实弹报文与它一致。此前夹具里塞过一个 `refund_no="R001"`,那是**凭空字段**
+     *     (出自 Task 12 brief 的测试样例,不是上游文档),使「定位键恒为 null」这个缺陷
+     *     在测试里完全不可观测。
+     *
+     * <p>需要 `refund_no` / 需要删字段的用例自行增删 —— 那几条测的正是**兜底腿**与
+     * **入口截断**,与上游发不发无关。
      */
     private Map<String, Object> refundBody(String resultCode) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("return_code", "SUCCESS");
+        m.put("return_msg", "退款成功");
         m.put("result_code", resultCode);
+        m.put("trade_type", "0");
         m.put("trade_no", "T001");
-        m.put("mch_no", "Y52211090004");
+        m.put("td_trade_no", "");
+        m.put("pay_trace_no", "RT001");
+        m.put("o_trade_no", "P20260929001");
+        m.put("qd_trade_no", "Q20260929001");
+        m.put("mch_name", "MCH_NAME");
+        m.put("inst_no", "INST001");
+        m.put("shop_no", "");
+        m.put("shop_name", "");
+        m.put("pay_type", "400");
+        m.put("total_amount", "30");
+        m.put("pay_amount", "-1");
+        m.put("receipt_amount", "-1");
+        m.put("coupon_amount", "0");
+        m.put("discount_amount", "0");
+        m.put("user_id", "U001");
+        m.put("pay_time", "2026-09-29 11:04:12");
+        m.put("end_time", "2026-09-29 11:04:13");
+        m.put("attach", "");
         return m;
     }
 
@@ -303,6 +328,13 @@ class LsyNotifyServiceTest {
         // 不写任何终态、不通知下游;但仍应答上游 SUCCESS(免无限重推)
         verify(orderService, never()).applyPayResult(any(), any(), any());
         verify(callbackService, never()).notifyOrder(any());
+
+        // ★ 顺带当**「入口分流」的阴性对照**:支付腿的未知码不许被分流到退款腿。
+        //   `payBody` 的 trade_type="1"(交易)、result_code 也不是 TRADE_REFUND* ——
+        //   两条判据都不成立。若哪天判据被放宽成「认不出来就当退款」,本条立刻红。
+        //   没有这一句时它照样绿:分流走错了也满足上面两条 never()(退款腿也不写订单终态),
+        //   于是缺陷在测试里不可见。
+        verify(refundMapper, never()).selectOne(any());
     }
 
     @Test
@@ -531,72 +563,130 @@ class LsyNotifyServiceTest {
         verify(callbackService).notifyOrder(any());
     }
 
-    // ================= 退款回调的定位键(2026-09-22,表征 + 阳性对照)=================
+    // ================= 退款回调的定位键(2026-09-29 由实弹报文裁定)=================
 
     /**
-     * ★★ **表征测试:钉住「退款回调定位键恒为 null」这个已知缺口。**
-     *
-     * <p>【缺口是什么】`handleRefundNotify` 按 `params.get("refund_no")` 查退款单,
-     * 而上游退款回调报文里**没有这个字段**(实测上游 opendocs「退款异步回调」全页:
-     * 完整 24 行字段表与请求示例里 `refund_no` / `out_refund_no` / `refund_trace_no` /
-     * `qd_refund_no` 一个都没有)。⇒ 定位**每一次都落空**,走 `REFUND_NOT_FOUND` 后返回,
-     * 该分支之后的一切(三分支词表、幂等闸门、`LATE_REFUND_AFTER_TERMINAL`)在生产上是死代码。
-     *
-     * <p>【为什么这里必须断言 wrapper 而不是断言"没找到"】断言结果没有信息量 ——
-     * `selectOne` 是 mock,返回什么由用例自己 stub。**唯一能证明「键是 null」的读数,
-     * 是去看 wrapper 里绑的值**。这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
-     *
-     * <p>【为什么断言的是缺陷而不是断言"应该找到"】改定位键需要先知道上游到底用哪个字段指代
-     * 一笔退款 —— 文档**没有定义** `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」
-     * 还是「本笔退款的」,两种读法都自洽且结论相反。按项目铁律**不得猜测**,故挂起。
-     * 本条的作用是让缺口**在测试里可见**:谁改定位键,谁就必须来改本条,
-     * 而不是让一个凭空字段继续把缺陷盖住。
+     * ★★ **主键:报文的 `pay_trace_no` → 表里的 `refund_trace_no`。**
      *
-     * <p>【影响面:为什么挂起是可以接受的】异步退款的**收敛**已由退款对账腿覆盖
-     * (`LsyReconcileScheduler.reconcilePendingRefunds` 按 `status` + `created_time` 扫描,
-     * **不依赖任何上游字段**),所以不存在「钱动了我们不知道」。回调从「唯一收敛通道」
-     * 降级为一条从未通电的死腿。
+     * <p>【在此之前这里钉的是什么】原先是
+     * `refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` —— 一条**表征测试**,
+     * 钉住「按 `refund_no` 查、而上游报文不含该字段 ⇒ 绑定值恒为 null ⇒ 恒 0 行」这个缺口,
+     * 并记着「重新定位键**挂起,不猜**」。挂起的理由是:文档**没有定义**
+     * `o_trade_no` / `pay_trace_no` 在退款回调里指「原支付的」还是「本笔退款的」,
+     * 两种读法都自洽且结论相反。**这个理由现在被实弹读数解除了**:
+     * 2026-09-29 那笔真实退款(1 分)的回调里 `pay_trace_no = 4e8ceec2b83b44a78efaacd73499b5a9`,
+     * 与我们随退款请求发出去的 `refund_trace_no` **逐字符相同** —— 是我们选的值、由上游原样回传,
+     * 因果闭合,不是同形巧合。报文原文见 `logs/_lsy-refund-callback-raw.json`。
      *
-     * <p>裁定与证据全文见
-     * `docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md` §一。
+     * <p>【为什么断言 wrapper 而不是断言「找到了」】`selectOne` 是 mock,返回什么由用例自己 stub,
+     * 断言结果没有信息量。**唯一能证明「用的是哪一列、绑的哪个值」的读数,是去看 wrapper**。
+     * 这也正是此前全绿的原因:`any()` 把 wrapper 吃掉了。
      */
     @Test
-    void refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull() {
+    void refundNotify_realPayload_locatesRefundByItsTraceNo() {
         when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
 
-        // refundBody 与真实报文同形 —— 刻意不含 refund_no
         service.handleRefundNotify(signed(refundBody("TRADE_REFUND")), "{}");
 
         AbstractWrapper<?, ?, ?> w = capturedRefundLookup();
-        assertTrue(w.getSqlSegment().contains("refund_no ="),
-                "定位仍走 refund_no 列(本断言的对照;改定位键时本条应红);实测: " + w.getSqlSegment());
-        assertNull(boundValue(w, "refund_no"),
-                "★ 已知缺口:真实报文不含 refund_no ⇒ 绑定值恒为 null ⇒ PG 里 `refund_no = NULL` "
-                        + "恒为 UNKNOWN ⇒ 0 行 ⇒ 退款回调永远定位不到自己的退款单。"
-                        + "若此断言变红,说明定位键已被改动 —— 请一并更新本类的 "
-                        + "refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey 与台账 §一。");
+        assertTrue(w.getSqlSegment().contains("refund_trace_no ="),
+                "定位必须走 refund_trace_no 列;实测: " + w.getSqlSegment());
+        assertEquals("RT001", boundValue(w, "refund_trace_no"),
+                "绑的必须是报文里的 pay_trace_no —— 它就是我们发出去的那个 refund_trace_no。"
+                        + "若本断言变红,说明定位键又被改了,请一并更新本类夹具与 "
+                        + "docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md §一");
     }
 
     /**
-     * **阳性对照**:证明上面那条断言的「看得见非 null」是成立的。
+     * **兜底腿**:报文里没有 `pay_trace_no` 时回落到 `refund_no`,且**只在它有值时才查**。
      *
-     * <p>没有这一条,`refundNotify_realPayloadHasNoRefundNo_soLookupKeyIsAlwaysNull` 读到 null
-     * 可能是**断言机制本身失灵**(正则没匹配上、取值取错括号)—— 那样它就是个假绿。
-     * 这里塞一个上游**不会发**的 `refund_no`,若定位键确实被读出来,值就该是它。
+     * <p>【这条腿为什么保留】`pay_trace_no` 在退款回调里指「本笔退款」这一读法目前只有**一笔**
+     * 实弹样本。若上游在某些场景改发原支付的 trace,主键会落空 —— 那时兜底救不回来
+     * (真实报文里本就没有 `refund_no`),但**也不会更坏**:仍是 `REFUND_NOT_FOUND`。
      *
-     * <p>两条合起来才是完整读数:**机制有效,且真实报文落进的是 null 那一侧。**
+     * <p>【为什么必须 `!= null` 才查】`.eq(col, null)` 在 MP 里**照发谓词**、绑一个真正的 null,
+     * 而 PG 里 `refund_no = NULL` 恒为 UNKNOWN ⇒ **恒 0 行**。拿一个已知查不到的条件去查库,
+     * 除了把意图藏起来没有任何作用。
      */
     @Test
-    void refundNotify_payloadCarryingRefundNo_wouldBeUsedAsLookupKey() {
+    void refundNotify_payloadWithoutPayTraceNo_fallsBackToRefundNo() {
         when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
 
         Map<String, Object> body = refundBody("TRADE_REFUND");
-        body.put("refund_no", "R001");     // 上游不发此字段,纯粹为了证明断言看得见非 null
+        body.remove("pay_trace_no");        // 真实报文不发这种形态,纯粹为了压兜底这条腿
+        body.put("refund_no", "R001");
 
         service.handleRefundNotify(signed(body), "{}");
 
+        // capturedRefundLookup 内含 verify(...)(等价 times(1))⇒ 主键落空后**只**发一次查询
         assertEquals("R001", boundValue(capturedRefundLookup(), "refund_no"),
-                "断言机制必须能读出非 null 的定位键,否则上面那条的 null 读数不可信");
+                "主键缺失时必须落到 refund_no 兜底,否则这条腿是死的");
+    }
+
+    /**
+     * **两个键都没有 ⇒ 一次库都不查**,直接记 `REFUND_NOT_FOUND`。
+     *
+     * <p>与上一条同源:`refund_no = NULL` 恒 0 行,所以「查了」与「没查」的**结果**完全一样,
+     * 区别只在读数上 —— 发出去的那次查询会让日志/追踪里出现一条看上去像「查过、没找到」的记录,
+     * 而真相是「根本没有可查的键」。这两种情形的处置不同(前者要查上游是不是发了新字段,
+     * 后者要查我们自己的退款单为什么没落库),所以不能混成同一个读数。
+     */
+    @Test
+    void refundNotify_withoutAnyLocatingKey_queriesNothingAndStillAcksSuccess() {
+        Map<String, Object> body = refundBody("TRADE_REFUND");
+        body.remove("pay_trace_no");        // 既无 pay_trace_no(→ refund_trace_no)也无 refund_no
+
+        String resp = service.handleRefundNotify(signed(body), "{}");
+
+        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
+        verify(refundMapper, never()).selectOne(any());
+
+        var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
+        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
+        assertTrue(captor.getAllValues().stream()
+                        .anyMatch(r -> "REFUND_NOT_FOUND".equals(r.getProcessStatus())),
+                "无键可查也必须留痕;实际=" + processStatuses(captor));
+    }
+
+    /**
+     * ★★ **分流**:退款回调打到**支付端点**上时,必须交给退款腿处理。
+     *
+     * <p>【缺陷是什么】上游只认**一个**回调地址(下单时配的 `notify_url`),退款通知也推它;
+     * 而我们的两个端点(`/notify/pay`、`/notify/refund`)是分开的。2026-09-29 实弹实测:
+     * 那笔退款的回调打在 `/notify/pay` 上,`handlePayNotify` 拿**退款的** trace 去查订单
+     * (必然查不到),写一条 `ORDER_NOT_FOUND` 就结束了 —— 退款腿一次都没通电。
+     * 后果不是「钱动了我们不知道」(收敛有对账腿兜底),而是:
+     * ① 退款回调这条**快通道**整条失效;② 每次退款都误写一条误导性的审计行
+     * (「上游推了一笔我们不认识的支付」),把真正该报警的那条信号淹掉。
+     *
+     * <p>【判据为什么是这两个】`result_code` 前缀 `TRADE_REFUND` 是退款侧独有的词表;
+     * `trade_type="0"` 是文档记的退款枚举(0退款/1交易/2云订单关单),与支付侧的 `"1"` 互斥。
+     * 两条都是上游语义、且**都不与支付侧重叠** —— 上面那条未知码用例是本条的阴性对照。
+     */
+    @Test
+    void refundPayloadArrivingAtThePayEndpoint_isRoutedToTheRefundLeg() {
+        when(refundMapper.selectOne(any())).thenReturn(refund("REFUNDING"));
+        when(refundService.applyNotifyResult(any(), any(), any(), any())).thenReturn(true);
+        when(orderMapper.selectById(1L)).thenReturn(order("SUCCESS"));
+        when(refundService.settleOrderAfterRefund(any(), anyLong())).thenReturn(true);
+
+        String resp = service.handlePayNotify(signed(refundBody("TRADE_REFUND")), "{}");
+
+        // ① 走的是退款腿(会去收敛订单),而不是支付腿的「查订单 → 找不到」
+        assertEquals("{\"return_code\":\"SUCCESS\",\"return_msg\":\"成功\"}", resp);
+        verify(orderMapper, never()).selectOne(any());
+        verify(refundService).settleOrderAfterRefund(any(), eq(30L));
+
+        // ② 审计行里不许出现 ORDER_NOT_FOUND,且 RECEIVED 只许一条
+        //    (分流必须在支付侧写 RECEIVED **之前**,否则一次回调写两条 RECEIVED)
+        var captor = ArgumentCaptor.forClass(LsyNotifyLogEntity.class);
+        verify(notifyLogMapper, atLeastOnce()).insert(captor.capture());
+        assertTrue(captor.getAllValues().stream()
+                        .noneMatch(r -> "ORDER_NOT_FOUND".equals(r.getProcessStatus())),
+                "退款回调不得被记成「订单不存在」;实际=" + processStatuses(captor));
+        assertEquals(1, captor.getAllValues().stream()
+                        .filter(r -> "RECEIVED".equals(r.getProcessStatus())).count(),
+                "一次回调只写一条 RECEIVED;实际=" + processStatuses(captor));
     }
 
     @Test