Bladeren bron

fix(订单): SUCCESS 落库而 trade_no 为空时补 ERROR - 把静默丢字段改成可见(台账 §八 R-2)

payPatch 的 str(resp.get("trade_no")) 把「字段缺失」与「显式 JSON null」都归一成
Java null(归一化在 LeshouyingResponseParser.toMap),而 MyBatis-Plus 的 NOT_NULL
字段策略会把 null 从 UPDATE 列清单里剔掉 —— 于是我们记了一笔「收款成功」、库里
trade_no 却是 NULL:既对不了账、也退不了款。全程一条日志都不留。

改法:补一条 log.error,带 outTradeNo + 上游 result_code/return_msg + 运营的捞法
(status='SUCCESS' AND trade_no IS NULL)。不动状态机、不发明兜底值 —— 没有任何
依据表明上游会不回该字段,按铁律不发明。

★ 落点偏离裁定的字面(裁定写的是 payPatch),因为照字面做会误报:
/v3/qrpay 与 /v3/h5pay 的 PAY_SUCCESS 是「下单请求被受理」,订单落 PAYING、
收款尚未发生,那个响应本来就没有 trade_no ⇒ 每一笔主扫下单都响一次 = 告警疲劳,
真告警会被淹掉,这项可见化等于白做。
按调用点逐个量过(不是推断):全 main 里 isPaySuccess 为真的调用点恰好 4 个 ——
createQrOrder:220 / createH5Order:317 落 PAYING(都不该响),
settleFromQuery:414 / 回调 LsyNotifyService:144 落 SUCCESS(都该响)。

⇒ 实际挂在 applyPayResult 的 rows == 1 块内,判据
"SUCCESS".equals(newStatus) && StrUtil.isBlank(patch.getTradeNo()):
真阳性一个不少、假阳性归零;这里还拿得到 order(payPatch 拿不到)⇒ 日志带得上单号;
放 rows==1 里面 = 每单至多一次(同一单的回调会被上游重推)。

读数(两向都测):
- 改前 RED:applyPayResult_alarmsOnlyForTheOrderMissingTradeNo,expected <1> but was <0>
- 改后全量 416 绿 / 0 失败(改前 412,差 = 新增 4 条)
- 变异 M-A(把裁定字面原样加进 payPatch)⇒ 1 红,正是 qrpay 那条钉子
- 变异 M-B(判据整体挪到条件更新之前)⇒ 1 红,正是「每单至多一次」那条钉子
- 变异 M-C1(无害:整句换措辞)⇒ 全绿
- 变异 M-C2(isBlank → isEmpty)⇒ 全绿 ⇒ 一处未钉住的区分,只在「纯空白 trade_no」
  上分叉。不补测试:上游回 " " 不现实,为它写断言是对着不可能输入写测试。记为已知缺口。

新增 4 条里 2 条是负向断言(钉子),改代码前就是绿的 —— 所以靠 M-A/M-B 证明它们可红。
alarmsOnlyForTheOrderMissingTradeNo 把正负对照放进同一次日志捕获:捕获若失效会得
0 条而让第一条断言红,负读数因此不是空洞通过。
alphaH 3 weken geleden
bovenliggende
commit
b2e93a394f

+ 51 - 2
docs/superpowers/specs/2026-09-21-leshouying-payment-channel-open-questions.md

@@ -774,14 +774,63 @@ MyBatis-Plus 默认 NOT_NULL 策略会把 null 字段从 UPDATE 列清单里剔
 `LsyQueryDetailAssembler` 与下游通知会少一个字段。
 
 **结论**:**不改状态机、不发明兜底值**。没有任何依据表明上游会不回该字段
-(缺文档实证也缺联调实证),按铁律不发明。**但把静默改成可见**:在 `payPatch`(唯一的
-「上游字段 → 我们字段」映射点)里,当上游回了成功码而 `trade_no` 为空时补一条 `log.error`。
+(缺文档实证也缺联调实证),按铁律不发明。**但把静默改成可见**:当上游回了成功码而
+`trade_no` 为空时补一条 `log.error`。
 理由与本模块反复强调的一致:不记日志的后果是「运营一个字节都看不到」。
 **触发升级条件**:联调或生产只要出现一笔 SUCCESS 且 `trade_no` 为空,即升级为缺陷并去问上游。
 
 ⚠ **本条此前连登记都没有** —— 它只存在于我的记忆里,台账里 grep 不到。这本身是一处欠账,
 本裁定首先把它补进台账。
 
+#### ✅ 已实施(2026-09-22)—— 但**落点与下面的字面不同**
+
+> ⚠ **订正:本节原先写的落点是 `payPatch`,照字面做会误报。**
+> `/v3/qrpay` 与 `/v3/h5pay` 的 `PAY_SUCCESS` 描述的是**「下单请求被受理」**
+> (见 `LsyOrderService.createQrOrder:222-235` 的长注释),订单刻意落 `PAYING`、
+> **收款尚未发生**,那个响应**本来就没有 `trade_no`**。判据挂在 `payPatch` 上
+> ⇒ **每一笔主扫下单都响一次** = 本模块明确在防的**告警疲劳**(真告警会被淹掉,
+> 这项可见化等于白做)。
+>
+> - **按调用点逐个量过(不是推断)**:全 `main` 里 `isPaySuccess` 为真的调用点**恰好 4 个**
+>   —— `createQrOrder:220`、`createH5Order:317` 落 `PAYING`(都不该响);
+>   `settleFromQuery:414`、回调 `LsyNotifyService:144` 落 `SUCCESS`(都该响)。
+> - **实测佐证**:变异 M-A(把裁定字面原样加进 `payPatch`)⇒ `qr_paySuccessWithoutTradeNo_doesNotAlarm`
+>   **红**,捕获到的正是变异自己打的那行。
+> - ⇒ 真阳性**一个不少**,假阳性归零。
+
+**实际落点**:`LsyOrderService.applyPayResult` 的 `rows == 1` 块内,
+判据 `"SUCCESS".equals(newStatus) && StrUtil.isBlank(patch.getTradeNo())`。
+
+| 决定 | 为什么 |
+|---|---|
+| 挂「**真的要写 SUCCESS**」这一刻,不在「上游回了成功码」那一刻 | 异常的定义是**我们记了一笔收款成功却没有上游交易号**;只回了个成功码但订单落 `PAYING` 不是异常 |
+| 放 `applyPayResult` 而非 `payPatch` | 上一条的结果;且这里拿得到 `order`(`payPatch` 拿不到)⇒ 日志能带 `outTradeNo`,运营才定位得到 |
+| 放 `rows == 1` **里面** | 同一单的回调会被上游重推,放外面 = 每次重推响一次。里面 = **每单至多一次** |
+| `isBlank` 而非 `!= null` | 上游回 `"trade_no": ""` 时落的是非 null 空串,同样是「没给」;而空串连运营的捞法(`trade_no IS NULL`)都捞不到 |
+
+**两向变异读数(每条都实测)**:
+
+| 变异 | 改了什么 | 预期 | 实测 |
+|---|---|---|---|
+| M-A | 把裁定字面原样加进 `payPatch` | 1 红 | **1 红**(`qr_paySuccessWithoutTradeNo_doesNotAlarm`)|
+| M-B | 判据整体挪到条件更新**之前** | 1 红 | **1 红**(`applyPayResult_duplicateNotification_doesNotAlarmAgain`)|
+| M-C1 | 把日志措辞整句换掉(无害) | 全绿 | **全绿** —— 守卫不咬措辞 |
+| M-C2 | `isBlank` → `isEmpty` | 全绿 | **全绿** ⇒ **一处未钉住的区分** |
+
+**M-C2 是已知缺口,如实记下**:`isBlank` 与 `isEmpty` 只在「纯空白 `trade_no`」上分叉,
+无测试覆盖。**不补测试** —— 上游回 `" "` 不现实,为它补断言就是对着不可能输入写测试。
+选 `isBlank` 是取更保守的一侧,不是因为有实证。
+
+**新增的 4 条测试里,有 2 条是「钉子」不是「驱动」**:
+`applyPayResult_duplicateNotification_doesNotAlarmAgain` 与
+`qr_paySuccessWithoutTradeNo_doesNotAlarm` 是**负向断言**,改代码**之前**就是绿的
+(负向断言本就如此)—— 所以它们**必须靠变异证明可红**,上面 M-A / M-B 正是干这个的。
+`applyPayResult_alarmsOnlyForTheOrderMissingTradeNo` 把**正负对照放在同一次日志捕获里**
+(两条断言共用一个 appender):捕获若失效得到 0 条 ⇒ 第一条断言红,
+「缺 trade_no 才响」这个负读数因此不是空洞通过。
+
+**测试数**:412 → **416**(+4)。全量 **416 绿 / 0 失败**。
+
 ### R-3 §2.5 下游通知的崩溃窗口 → **担忧成立,但不修(修法在触发条件下不可用)**
 
 **本次核清了「异常路径是否一定落 FAIL」这个悬了很久的问题**:

+ 35 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java

@@ -70,6 +70,41 @@ public class LsyOrderService {
 
         int rows = orderMapper.update(patch, w);
         if (rows == 1) {
+            // ★★ 裁定 R-2:**上游回了成功码、trade_no 却是空的** —— 静默丢字段,必须响。
+            //
+            // 【机制】`payPatch` 的 `str(resp.get("trade_no"))` 对「字段缺失」和「显式 JSON null」
+            //   都返回 Java null(归一化在 LeshouyingResponseParser.toMap,见那里的注释),
+            //   而 MyBatis-Plus 的 NOT_NULL 字段策略会把 null 从 UPDATE 列清单里剔掉 ——
+            //   于是我们记了一笔「收款成功」,库里 trade_no 却是 NULL:既对不了账、也退不了款。
+            //   一条日志都不留,这就是「静默」。本行把它改成可见。
+            //
+            // 【判据为什么挂在这里,而不是裁定字面写的 payPatch】裁定当时的措辞是
+            //   「在 payPatch 里,当上游回了成功码而 trade_no 为空」。照字面放会**每笔主扫下单都误报**:
+            //   /v3/qrpay 与 /v3/h5pay 的 PAY_SUCCESS 描述的是「下单请求被受理」(完整机制见
+            //   createQrOrder 的注释),那两个路径传的 patch 本来就没有 trade_no,订单落 PAYING、
+            //   收款尚未发生 —— 没有 trade_no 是**正常**而非异常。响在这里就是告警疲劳,
+            //   真告警会被淹掉,这项可见化等于白做。
+            //   【按调用点逐个量过,不是推断】全 main 里 isPaySuccess 为真的调用点**恰好 4 个**:
+            //   createQrOrder:220 / createH5Order:317 落 PAYING(都不该响),
+            //   settleFromQuery:414 与回调 LsyNotifyService 落 SUCCESS(都该响)。
+            //   ⇒ 挂到「真的要写 SUCCESS」这一刻,**真阳性一个不少**,假阳性归零。
+            //     这里还额外拿得到 order(payPatch 拿不到),日志因此能带上单号。
+            //
+            // 【为什么在 rows == 1 里面】同一单的回调会被上游重推,放在外面就是每次重推响一次
+            //   —— 本模块明确怕告警疲劳。放进这里 = **每单至多一次**。
+            //
+            // 【为什么 isBlank 而不是 != null】上游回 `"trade_no": ""` 时落的是非 null 空串,
+            //   同样是「没给我们交易号」;而空串连下面的运营捞法(trade_no IS NULL)都捞不到。
+            if ("SUCCESS".equals(newStatus) && StrUtil.isBlank(patch.getTradeNo())) {
+                log.error("上游回成功码但未给 trade_no:本单已记收款成功,却拿不到上游交易号 —— "
+                                + "对不了账、退不了款,请按 outTradeNo 定位并向上游核对该笔交易"
+                                + "(库里可捞 status='SUCCESS' AND trade_no IS NULL): "
+                                + "outTradeNo={}, upstreamResultCode={}, upstreamResultMsg={}",
+                        LsyLogSanitizer.flatten(order.getOutTradeNo()),
+                        LsyLogSanitizer.flatten(patch.getUpstreamResultCode()),
+                        LsyLogSanitizer.flatten(patch.getUpstreamResultMsg()));
+            }
+
             // 同步内存对象:调用方紧接着要拿新状态去回调下游。
             // ⚠️ payPatch 每新增一个字段,都必须同步补进下面这段 —— 漏一个就等于让
             //    「从内存取」和「从库取」得到两个答案(F-9 就是这么发生的)。

+ 87 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderServiceStateTest.java

@@ -1,5 +1,9 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
@@ -12,8 +16,11 @@ import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
 
+import java.util.List;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.stream.Collectors;
 
 import static org.junit.jupiter.api.Assertions.*;
@@ -75,6 +82,62 @@ class LsyOrderServiceStateTest {
                 "重复通知必须返回 false,调用方据此不重复回调下游");
     }
 
+    @Test
+    void applyPayResult_alarmsOnlyForTheOrderMissingTradeNo() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
+        LsyOrderEntity withTradeNo = order("PAYING");
+        withTradeNo.setOutTradeNo("OT_HAS_TRADE_NO");
+        LsyOrderEntity withoutTradeNo = order("PAYING");
+        withoutTradeNo.setId(2L);
+        withoutTradeNo.setOutTradeNo("OT_NO_TRADE_NO");
+
+        // ★ 正负对照放在**同一次捕获**里:第二条断言要成立,第一条必须先在同一个 appender 上
+        //   留下记录。捕获若失效(appender 没挂上、类名写错),得到的是 0 条 → 第一条断言红,
+        //   而不是让「无 trade_no 不响」这个负读数空洞通过。
+        withLogCapture(LsyOrderService.class, events -> {
+            assertTrue(service.applyPayResult(withTradeNo, "SUCCESS", patch("T1")));
+            assertTrue(service.applyPayResult(withoutTradeNo, "SUCCESS", patch(null)));
+
+            List<String> errs = errors(events);
+            assertEquals(1, errs.size(),
+                    "SUCCESS 已落库、上游却没给 trade_no = 静默丢字段,必须响**且只响这一单**;"
+                            + "实际全部事件=" + messages(events));
+            assertTrue(errs.get(0).contains("OT_NO_TRADE_NO"),
+                    "响的必须是缺 trade_no 的那一单(日志要带得走单号,运营才定位得到);"
+                            + "实际=" + errs);
+        });
+    }
+
+    @Test
+    void applyPayResult_blankTradeNo_countsAsMissing() {
+        // 上游回 `"trade_no": ""` 时落的是**非 null 空串** —— 同样是「没给我们交易号」,
+        // 而它连运营的捞法(trade_no IS NULL)都捞不到。判据必须用 isBlank 而非 != null。
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
+        LsyOrderEntity o = order("PAYING");
+        o.setOutTradeNo("OT_BLANK_TRADE_NO");
+
+        withLogCapture(LsyOrderService.class, events -> {
+            assertTrue(service.applyPayResult(o, "SUCCESS", patch("")));
+
+            List<String> errs = errors(events);
+            assertEquals(1, errs.size(), "空串与缺字段同义,必须响;实际全部事件=" + messages(events));
+            assertTrue(errs.get(0).contains("OT_BLANK_TRADE_NO"), "实际=" + errs);
+        });
+    }
+
+    @Test
+    void applyPayResult_duplicateNotification_doesNotAlarmAgain() {
+        when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(0);   // 条件更新未命中 = 重复
+
+        withLogCapture(LsyOrderService.class, events -> {
+            assertFalse(service.applyPayResult(order("SUCCESS"), "SUCCESS", patch(null)));
+
+            assertEquals(List.of(), errors(events),
+                    "同一单被上游重推是常态,重复通知不得再响一次 —— 否则真告警会被淹掉;"
+                            + "实际全部事件=" + messages(events));
+        });
+    }
+
     @Test
     void applyPayResult_onlyAllowsCreatedAndPayingAsSource() {
         when(orderMapper.update(any(), any(UpdateWrapper.class))).thenReturn(1);
@@ -167,4 +230,28 @@ class LsyOrderServiceStateTest {
         assertFalse(service.isTerminal("PAYING"));
         assertFalse(service.isTerminal("PARTIAL_REFUNDED"));
     }
+
+    /** 与 LsyNotifyServiceTest 同款(本仓库按测试文件各自持有一份,不抽公共类)。 */
+    private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            body.accept(appender.list);
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+
+    /** 只看 ERROR —— 本类正常路径会打 INFO(如「支付回写未命中」),断言得把级别分开。 */
+    private static List<String> errors(List<ILoggingEvent> events) {
+        return events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                .map(ILoggingEvent::getFormattedMessage).toList();
+    }
+
+    /** 断言失败时把实际捕获到的日志正文全列出来 —— 否则只看到「没匹配上」,看不到实际打了什么。 */
+    private static List<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
+    }
 }

+ 54 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQrPayTest.java

@@ -1,5 +1,9 @@
 package com.payment.platform.module.payment.leshouying.service;
 
+import ch.qos.logback.classic.Level;
+import ch.qos.logback.classic.Logger;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
@@ -11,9 +15,12 @@ import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.slf4j.LoggerFactory;
 
 import java.util.LinkedHashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.function.Consumer;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.*;
@@ -139,6 +146,31 @@ class LsyQrPayTest {
         verify(queryService, never()).queryOrder(any());
     }
 
+    @Test
+    void qr_paySuccessWithoutTradeNo_doesNotAlarm() {
+        // ★ 本用例守的是 R-2 的**触发范围**,不是它本身。
+        //   「上游回成功码而 trade_no 为空就响」照字面实现,会在**每一笔主扫下单**上响:
+        //   qrpay 的 PAY_SUCCESS 描述的是「下单请求被受理」(见 qr_success_returnsCodeUrl),
+        //   此刻码还没交到用户手上、收款还没发生,没有 trade_no 是**正常**而非异常。
+        //   落到这里的后果就是告警疲劳 —— 真告警会被淹掉,等于这项可见化白做。
+        Map<String, Object> resp = new LinkedHashMap<>();
+        resp.put("return_code", "SUCCESS");
+        resp.put("result_code", "PAY_SUCCESS");
+        resp.put("code_url", "https://x");
+        when(client.post(eq("/v3/qrpay"), anyMap())).thenReturn(resp);
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        withLogCapture(LsyOrderService.class, events -> {
+            LsyPayResultDTO r = orderService.createQrOrder(order(), null);
+
+            // 正对照:确实走进了 isPaySuccess 那个分支(下单成功、落 PAYING)——
+            // 少了它,下面的负读数可能只是因为压根没走到判据而空洞通过。
+            assertEquals("PAYING", r.getStatus());
+            assertEquals(List.of(), errors(events),
+                    "下单路径的 PAY_SUCCESS 本来就没有 trade_no,不得响;实际全部事件=" + messages(events));
+        });
+    }
+
     @Test
     void qr_payFail_returnsFail() {
         Map<String, Object> resp = new LinkedHashMap<>();
@@ -172,4 +204,26 @@ class LsyQrPayTest {
         // 但库里**不能**落终态
         verify(orderMapper, never()).update(any(), any());
     }
+
+    /** 与 LsyNotifyServiceTest 同款(本仓库按测试文件各自持有一份,不抽公共类)。 */
+    private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
+        Logger logger = (Logger) LoggerFactory.getLogger(type);
+        ListAppender<ILoggingEvent> appender = new ListAppender<>();
+        appender.start();
+        logger.addAppender(appender);
+        try {
+            body.accept(appender.list);
+        } finally {
+            logger.detachAppender(appender);
+        }
+    }
+
+    private static List<String> errors(List<ILoggingEvent> events) {
+        return events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                .map(ILoggingEvent::getFormattedMessage).toList();
+    }
+
+    private static List<String> messages(List<ILoggingEvent> events) {
+        return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
+    }
 }