Kaynağa Gözat

fix(乐收赢): 对账腿把上游「查无此单」从带栈 ERROR 降成不带栈 WARN

问题不是「日志太吵」,是**一条恒假的 ERROR 会训练运维忽略 ERROR**。

根因(读代码 + 联调实测量出,不是推断):
LeshouyingResponseParser.parseV3 对 return_code != SUCCESS 一律抛异常 —— 那是出站腿
要的语义(通信层失败必须炸,别把「没连上」当成「付款失败了」)。但上游把**业务性的
「查无此单」也放在 return_code=FAIL 里回**,于是这一跳穿过 service 落进调度器的
catch (Exception),被记成带完整栈的 ERROR,每 5 分钟一条。而这条栈零信息量:
每次都是同一条 postForRawJson → parseV3 → throw。

两条腿各自量到了「这是正常态」:

* 订单腿:**未扫的聚合码单**在 /v3/queryorder 上回的就是这句话,且与关单动作无关 ——
  差分对照实测,同刻建的两笔同参聚合码单,在关单**前后**都查不到
  (logs/_lsy-close-control.txt)。该状态每 5 分钟刷一条 ERROR,直到 expireStaleOrders
  把它收走。
* 退款腿:LsyRefundService.createRefund 把退款行**预插入**成 CREATED,那一刻的语义就是
  「我们记下了,但**还不知道上游有没有收到**」(该方法的注释明写)。预插入之后
  POST 超时/连接重置的行,上游根本没见过这笔退款。探针:拿一个上游不认识的
  refund_trace_no 打 /v3/queryrefund,实测回 return_code=FAIL / result_code=null /
  return_msg=「订单信息不存在!」(原始字节 logs/_lsy-lc4/qrefund-bogus-trace.bin,422 B,sign=null)。
  ⇒ 把它记成 ERROR,等于把设计里明写的正常态报成故障。

改法:新增私有判据 upstreamSaysNotFound(Throwable),命中则记**不带栈的 WARN**,
其余一切维持 ERROR + 栈。级别是唯一变的东西 —— 订单/退款状态一个字都不动
(用例里用 verify(orderService, never()).settleFromQuery(...) 与
verify(refundMapper).update(...) 各钉一条)。

★ 失败方向是**故意不对称**的:判据是「上游原文**包含**某句话」,不是
「凡是 LeshouyingApiException 就降级」。上游改措辞 ⇒ 退回 noisy(ERROR 带栈),
**不会**退回 silent。宁可再吵一次,也不要静音掉「验签失败」「商户未配置」这类
每笔都会失败的真故障。
判据读 getRawReturnMsg()(上游原文),写日志用 getMessage()(已 sanitize + 封顶)——
两者不可互换,直接把 raw 文本写进日志是 CWE-117。

【closeExpiredQrOrders 为什么**不**跟着改 —— 有读数,但没到判据】
同一个探针实测:拿一个上游不认的 code_url 打 /v3/qrclose,同样回
return_code=FAIL +「该聚合码链接不存在或已过期!」并抛异常。形状与上面两条腿一样,
但**形状不是判据**:本腿扫描的是「过期 + 仍 PAYING + code_url 非空」,而 code_url 是
qrpay **成功**之后才落库的 ⇒ 上游不认它属反常,不是设计内状态。我没有它复发的读数,
维持 ERROR + 栈,把这段读数写进注释里备查。

TDD 与两向变异(跑 LsySchedulerTest):
  RED   加 4 条用例、未改实现 ⇒ **2 红**,都停在「不得记 ERROR」那一行,
        实际读数 [对账查询异常: outTradeNo=OUT001] / [退款对账查询异常: outRefundNo=RF001];
        另 2 条反向守卫本来就绿(25 run / 2 failures)
  GREEN 改完 ⇒ 25 / 0 / 0 / 0
  A 无条件降级(判据换成 `instanceof LeshouyingApiException`)⇒ **红**,
        恰好是那两条反向守卫,且降级的正是「验签失败」这一格
        (读到的 WARN upstream=乐收赢接口失败[FAIL]: 验签失败)
  B 无害重构(if/else 改守卫 + continue)⇒ **仍绿** 25/0/0/0
  两个变异体指纹均 ≠ 基线(6e340869… / 1534d3cf… vs a91e47b3…),确认变异真的写进去了
  两次还原后 sha256 均回到基线 a91e47b3…,逐字节相同

两条反向守卫在加实现**之前**就是绿的(它们钉的是既有行为),所以它们的价值只能由
变异 A 证明 —— 没有 A 这一轮,它们看起来只是两条凑数的用例。

测试:LsySchedulerTest 25 条绿;全量 **910 / 0 / 0 / 0**(本改动前 906,新增 4 条)。
提交前复量 sha256:生产码 a91e47b3…(30926 B)、测试 ed02b75e…(43597 B),
两文件均 i/lf w/crlf。
alphaH 2 hafta önce
ebeveyn
işleme
674b8143c1

+ 71 - 2
java/src/main/java/com/payment/platform/module/payment/leshouying/scheduler/LsyReconcileScheduler.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.leshouying.scheduler;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -121,6 +122,51 @@ public class LsyReconcileScheduler {
     private final LsyRefundService refundService;
     private final LeshouyingProperties props;
 
+    /**
+     * 「上游说它那儿没有这笔单/这笔退款」的原文片段。
+     *
+     * <p>判据是**这一句原文**,不是异常类型 —— 见 {@link #upstreamSaysNotFound} 的失败方向说明。
+     */
+    private static final String NOT_FOUND_MSG = "订单信息不存在";
+
+    /**
+     * 这个异常是不是「上游查无此单」这种**正常业务回答**(是则返回 true,调用方降成 WARN)。
+     *
+     * <p>【为什么需要它】{@code LeshouyingResponseParser.parseV3} 对
+     * {@code return_code != SUCCESS} 一律**抛异常**(那是出站腿要的语义:通信层失败必须炸,
+     * 别把「没连上」当成「付款失败了」)。但上游把**业务性的「查无此单」也放在
+     * {@code return_code=FAIL}` 里回 —— 于是这一跳穿过 service 落进下面两个 catch,
+     * 被记成**带完整栈的 ERROR**。
+     *
+     * <p>而这个状态是**正常的、会自愈的**:
+     * <ul>
+     *   <li>订单腿:**未扫的聚合码单**在 {@code /v3/queryorder} 上回的就是这句话,
+     *       实测该状态与关单动作无关(差分对照:同刻两笔同参聚合码单,在关单前后都查不到),
+     *       读数 {@code logs/_lsy-close-control.txt}。每 5 分钟一条 ERROR,
+     *       直到 {@link #expireStaleOrders} 把它收走。</li>
+     *   <li>退款腿:{@code LsyRefundService.createRefund} 把退款行**预插入**成 CREATED,
+     *       那一刻的语义就是「我们记下了,但**还不知道上游有没有收到**」——
+     *       预插入之后 POST 超时/连接重置的行,上游根本没见过这笔退款。</li>
+     * </ul>
+     * 栈在这里零信息量(每次都是同一条 {@code postForRawJson → parseV3 → throw}),
+     * 而一条恒假的 ERROR 会训练运维忽略 ERROR —— 真正的故障就被它淹掉了。
+     *
+     * <p>【失败方向:故意不对称】判据是「上游原文**包含**某句话」而不是
+     * 「凡是 {@link LeshouyingApiException} 就降级」:
+     * 上游改了措辞 ⇒ 我们退回 <b>noisy</b>(ERROR 带栈),**不会**退回 silent。
+     * 宁可再吵一次,也不要静默掉「验签失败」「商户未配置」这类**每笔都会失败**的真故障。
+     * 这条不对称由 {@code LsySchedulerTest} 的两条反向守卫钉住(无条件降级 ⇒ 它们必须红)。
+     *
+     * <p>【用哪个 getter】判据读 {@code getRawReturnMsg()}(上游原文,未截断);
+     * 写日志一律用 {@code getMessage()}(已 sanitize + 封顶,见 {@link LeshouyingApiException})。
+     * 两者不可互换 —— 直接把 raw 文本写进日志是 CWE-117。
+     */
+    private static boolean upstreamSaysNotFound(Throwable e) {
+        return e instanceof LeshouyingApiException api
+                && api.getRawReturnMsg() != null
+                && api.getRawReturnMsg().contains(NOT_FOUND_MSG);
+    }
+
     @Scheduled(fixedDelay = 5 * 60 * 1000)
     public void reconcile() {
         try {
@@ -171,7 +217,15 @@ public class LsyReconcileScheduler {
                     callbackService.notifyOrder(order);
                 }
             } catch (Exception e) {
-                log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e);
+                // ★ 上游「查无此单」是**正常业务回答**(未扫的聚合码单恒回这句),降成不带栈的 WARN。
+                //   失败方向见 upstreamSaysNotFound 的 javadoc:措辞变了就退回吵闹的 ERROR。
+                //   级别是这里唯一变的东西 —— 订单状态一个字都不动,下面什么都不做正是原行为。
+                if (upstreamSaysNotFound(e)) {
+                    log.warn("对账查询:上游暂无此单,留待下轮: outTradeNo={}, upstream={}",
+                            order.getOutTradeNo(), e.getMessage());
+                } else {
+                    log.error("对账查询异常: outTradeNo={}", order.getOutTradeNo(), e);
+                }
             }
         }
     }
@@ -222,7 +276,14 @@ public class LsyReconcileScheduler {
                             refund.getOutRefundNo(), settled);
                 }
             } catch (Exception e) {
-                log.error("退款对账查询异常: outRefundNo={}", refund.getOutRefundNo(), e);
+                // ★ 同订单腿:预插入成 CREATED 的退款行,上游「没见过这笔」是设计内状态
+                //   (见 upstreamSaysNotFound 的 javadoc),降成不带栈的 WARN。
+                if (upstreamSaysNotFound(e)) {
+                    log.warn("退款对账:上游暂无此退款,留待下轮: outRefundNo={}, upstream={}",
+                            refund.getOutRefundNo(), e.getMessage());
+                } else {
+                    log.error("退款对账查询异常: outRefundNo={}", refund.getOutRefundNo(), e);
+                }
             }
             // ★ 三种情形都必须推:
             //   (a) 未收敛(上游回 PAY_IN_PROCESS / 未知码)—— 这类行**一次库都不写**,
@@ -268,6 +329,14 @@ public class LsyReconcileScheduler {
             try {
                 applyCloseResult(order);
             } catch (Exception e) {
+                // 【这条腿为什么**不**跟着降级 —— 有读数,但没到判据】
+                //   同一个探针实测:拿一个上游不认的 code_url 打 /v3/qrclose,回
+                //   return_code=FAIL +「该聚合码链接不存在或已过期!」,也会抛异常走到这里
+                //   (原始字节 logs/_lsy-lc4/qrclose-bogus-url.bin)。
+                //   形状与上面两条腿一样,但**形状不是判据**:本腿的扫描条件是
+                //   「过期 + 仍 PAYING + code_url 非空」,而 code_url 是 qrpay **成功**之后
+                //   才落库的 ⇒ 上游不认这个 code_url 属反常,不是设计内状态。
+                //   我没有它复发的读数,所以维持 ERROR + 栈不变。
                 log.error("聚合码关单异常: outTradeNo={}", order.getOutTradeNo(), e);
             }
         }

+ 155 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/scheduler/LsySchedulerTest.java

@@ -1,9 +1,14 @@
 package com.payment.platform.module.payment.leshouying.scheduler;
 
+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.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
@@ -18,6 +23,7 @@ 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.time.OffsetDateTime;
 import java.util.LinkedHashMap;
@@ -25,6 +31,7 @@ import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -32,6 +39,7 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -613,6 +621,153 @@ class LsySchedulerTest {
      * <p>返回值刻意区分「谓词不在」({@code null}) 与「值不对」(非 null 的别的串):
      * 断言失败消息里会一并打出 {@code getSqlSegment()},两种成因当场可分。
      */
+    // ============ 上游「这笔单/这笔退款我这儿没有」是**正常业务回答**,不是异常 ============
+
+    /**
+     * 未扫的聚合码单在 `/v3/queryorder` 上回的**就是** `return_code=FAIL` +「订单信息不存在!」,
+     * 而这是该单种类的**正常态**:差分对照实测,同一刻建的两笔同参聚合码单,
+     * 在关单动作**前后**都查不到(读数 `logs/_lsy-close-control.txt`)。⇒ 与关单无关,
+     * 是「未扫的聚合码单」这一类单在查询接口上的固有表现。
+     *
+     * <p>`LeshouyingResponseParser.parseV3` 对 `return_code != SUCCESS` 一律**抛异常**
+     * (那是出站腿要的语义:通信层失败必须炸),于是这一跳穿过 `LsyQueryService.queryOrder`
+     * 落进本调度器的 `catch (Exception)`,被记成**带完整栈的 ERROR** —— 每 5 分钟一条,
+     * 直到该单被绝对过期收走。栈本身零信息量(每次都是同一条
+     * `postForRawJson → parseV3 → throw`),而一条恒假的 ERROR 会训练运维忽略 ERROR。
+     *
+     * <p>【失败方向】判据是「上游原文**包含**某句话」,不是「凡是 LeshouyingApiException 就降级」:
+     * 上游改了措辞 ⇒ 我们退回 **noisy**(ERROR),**不会**退回 silent。这条不对称是故意的。
+     */
+    @Test
+    void reconcilePendingOrders_上游说订单不存在_记WARN不带栈() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(queryService.queryOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
+
+            assertTrue(errors(events).isEmpty(),
+                    "未扫聚合码单是正常态,不得记 ERROR,实测: " + errors(events));
+            List<ILoggingEvent> warns = warns(events);
+            assertEquals(1, warns.size(), "应有一条 WARN,实测: " + messages(events));
+            assertNull(warns.get(0).getThrowableProxy(), "正常业务回答不挂栈");
+            assertTrue(warns.get(0).getFormattedMessage().contains("OUT001"),
+                    "WARN 必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
+            assertTrue(warns.get(0).getFormattedMessage().contains("订单信息不存在"),
+                    "上游原文必须留下 —— 否则这一条只剩「失败了」,排障时无从判断,实测: "
+                            + warns.get(0).getFormattedMessage());
+        });
+        // 只改日志级别,**不改行为**:订单状态一个字都不许动
+        verify(orderService, never()).settleFromQuery(any(), any());
+    }
+
+    /**
+     * 反向守卫:**别的**上游失败必须仍是带栈 ERROR。
+     *
+     * <p>没有这一条,一个「把所有 LeshouyingApiException 都降成 WARN」的实现
+     * (更省事,也更像「顺手放宽」)会让上面那条全绿 —— 而验签失败、商户未配置、
+     * 参数被拒这类**每笔都会失败**的真故障会一起被静音。
+     *
+     * <p>本条在加实现**之前**就是绿的(它钉的是既有行为),所以它的价值只能由变异证明:
+     * 把实现改成无条件降级 ⇒ 本条必须红。读数见提交信息。
+     */
+    @Test
+    void reconcilePendingOrders_其他上游失败_仍是带栈ERROR() {
+        LsyOrderEntity o = paying(0);
+        o.setPayScene("QRCODE");
+        when(orderMapper.selectList(any())).thenReturn(List.of(o));
+        when(queryService.queryOrder(o)).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
+
+            assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
+            assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
+                            .findFirst().orElseThrow().getThrowableProxy(),
+                    "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
+        });
+    }
+
+    /**
+     * 退款腿同一形状 —— 依据是**实测量出来的**,不是「看着像所以顺手也改」:
+     * 拿一笔上游不认识的 `refund_trace_no` 打 `/v3/queryrefund`,实测回
+     * `return_code=FAIL` / `result_code=null` / `return_msg=订单信息不存在!`
+     * (原始字节 `logs/_lsy-lc4/qrefund-bogus-trace.bin`,422 B,sign=null)。
+     *
+     * <p>而这正是本腿存在的理由:`LsyRefundService.createRefund` 把退款行**预插入**成 `CREATED`,
+     * 那一刻的语义是「我们记下了,但**还不知道上游有没有收到**」(见该方法 :175-181 的注释)。
+     * 预插入之后 POST 超时/连接重置的那些行,上游根本没见过这笔退款 —— 每轮对账问到的
+     * 就是这句话。把它记成带栈 ERROR,等于把**设计里明写的正常态**报成故障。
+     *
+     * <p>(同一个探针也测了 `/v3/qrclose`:回 `FAIL` +「该聚合码链接不存在或已过期!」。
+     * 但 `closeExpiredQrOrders` **没有**改成 WARN —— 扫描条件是「过期且仍 PAYING 且有 code_url」,
+     * 那种行是 qrpay 成功之后才有的,**我没有它复发的读数**,不凭形状推断。)
+     */
+    @Test
+    void reconcilePendingRefunds_上游说订单不存在_记WARN不带栈() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenThrow(
+                new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
+
+            assertTrue(errors(events).isEmpty(),
+                    "实测这是正常业务回答,不得记 ERROR,实测: " + errors(events));
+            List<ILoggingEvent> warns = warns(events);
+            assertEquals(1, warns.size(), "实测: " + messages(events));
+            assertNull(warns.get(0).getThrowableProxy());
+            assertTrue(warns.get(0).getFormattedMessage().contains("RF001"),
+                    "必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
+        });
+        // ★ 饿死防线不得被本改动碰到:抛异常的行走的仍是同一条推游标的路。
+        verify(refundMapper).update(any(), any());
+    }
+
+    @Test
+    void reconcilePendingRefunds_其他上游失败_仍是带栈ERROR() {
+        LsyRefundEntity r = pendingRefund();
+        when(refundMapper.selectList(any())).thenReturn(List.of(r));
+        when(refundService.queryRefund(r)).thenThrow(
+                new LeshouyingApiException("FAIL", "验签失败", "{}"));
+
+        withLogCapture(LsyReconcileScheduler.class, events -> {
+            assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
+            assertEquals(1, errors(events).size(), "实测: " + messages(events));
+        });
+    }
+
+    /** 与 LsyQrPayTest / 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<ILoggingEvent> warns(List<ILoggingEvent> events) {
+        return events.stream().filter(e -> e.getLevel() == Level.WARN).toList();
+    }
+
+    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(e -> e.getLevel() + " " + e.getFormattedMessage()).toList();
+    }
+
     private static String equalityStatusGuardValue(AbstractWrapper<?, ?, ?> w) {
         Matcher m = Pattern.compile("status = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
                 .matcher(w.getSqlSegment());