Kaynağa Gözat

fix(乐收赢): 上游明确拒绝的下单就地收敛为 FAIL 并记下原因

下单是「先落库、再打上游」,上游那一跳一旦抛异常,本地就留下一行 CREATED:
在订单列表里像一笔在途单,要等 LsyReconcileScheduler.expireStaleOrders 的绝对过期
才被收敛成 EXPIRED,而 upstream_result_code / upstream_result_msg 两格一直空着。
2026-09-30 生产上那笔顾客页失败单(CS2105183092730679296)就是这样:上游的原话
(「门店不存在或已被关闭!」)只活在一条 WARN 日志里,页面上的空白让取证多绕了一圈。

判据是异常里的 rawReturnCode,不是「抛异常就落 FAIL」:
NETWORK_ERROR / PARSE_ERROR / EMPTY / HTTP_5xx 四种一律不动 —— 这几种根本不知道
上游建没建出这笔单,而落 FAIL 是不可逆的(PAY_WRITE_FROM 只放行 CREATED/PAYING,
见 applyPayResult 的条件更新),等于把一笔可能的真实交易判了死刑。
其余取值都是上游 v3 报文里它自己的 return_code,那是它明说没受理,收敛是陈述事实。

两条消息按列宽截断(DDL V1.8:VARCHAR(32) / VARCHAR(255))—— 不截断的话超长原文
会让这条 UPDATE 自己报错,把「上游拒绝了下单」换成一条数据库错误。

测试:LsyOrderUpstreamRejectTest 6 条 —— 三条下单腿各一条(barcode/qr/h5 收敛
FAIL 并落下原因)、传输层失败不动本地行、上游原文超长按列宽截断、
条件更新未命中时只记日志不改内存状态。
alphaH 2 hafta önce
ebeveyn
işleme
da6a0b0099

+ 81 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.leshouying.service;
 
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
 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;
@@ -210,7 +211,13 @@ public class LsyOrderService {
         }
         putUpstreamNotifyUrl(p);
 
-        Map<String, Object> resp = client.post("/v3/pay", p);
+        Map<String, Object> resp;
+        try {
+            resp = client.post("/v3/pay", p);
+        } catch (LeshouyingApiException e) {
+            closeIfUpstreamRejected(order, e);
+            throw e;
+        }
         return settleAfterPay(order, resp, true);
     }
 
@@ -245,7 +252,13 @@ public class LsyOrderService {
         putUpstreamNotifyUrl(p);
         // 注意:聚合码不传 pay_type,上游自动识别支付宝/微信
 
-        Map<String, Object> resp = client.post("/v3/qrpay", p);
+        Map<String, Object> resp;
+        try {
+            resp = client.post("/v3/qrpay", p);
+        } catch (LeshouyingApiException e) {
+            closeIfUpstreamRejected(order, e);
+            throw e;
+        }
 
         if (parser.isPaySuccess(resp)) {
             String codeUrl = str(resp.get("code_url"));
@@ -361,7 +374,13 @@ public class LsyOrderService {
         }
         putUpstreamNotifyUrl(p);
 
-        Map<String, Object> resp = client.post("/v3/h5pay", p);
+        Map<String, Object> resp;
+        try {
+            resp = client.post("/v3/h5pay", p);
+        } catch (LeshouyingApiException e) {
+            closeIfUpstreamRejected(order, e);
+            throw e;
+        }
 
         if (parser.isPaySuccess(resp)) {
             stageCodeUrl(order, resp, type.getCode());
@@ -511,6 +530,65 @@ public class LsyOrderService {
      * 包内可见(非 private):LsyNotifyService 在同一包下,回调报文与查询报文
      * 字段名完全一致,直接复用它,不要自己再写一份。
      */
+    /**
+     * 上游**明确拒绝**时,把这行就地收敛为 FAIL 并记下它给的原因。
+     *
+     * <p>【为什么必须有】下单是「先落库、再打上游」(顺序的理由见三个 {@code create*} 方法),
+     * 于是上游那一跳一旦抛异常,本地就留下一行 {@code CREATED}:在订单列表里看着像一笔在途单,
+     * 要等 {@code LsyReconcileScheduler.expireStaleOrders} 的绝对过期才被收敛成 EXPIRED,
+     * 而**失败原因一个字都没留**({@code upstream_result_code/msg} 两格皆空)。
+     * 2026-09-30 排查顾客页「门店不存在或已被关闭!」时,正是这两格的空白让取证多绕了一圈 ——
+     * 上游的原话只活在一条 WARN 日志里。
+     *
+     * <p>【只在上游**自己答了话**时才收敛】判据是异常里的 {@code rawReturnCode}:
+     * <ul>
+     *   <li>{@code NETWORK_ERROR} —— 连接/超时:**根本不知道上游建没建出这笔单**;</li>
+     *   <li>{@code HTTP_5xx} —— 同上(服务端错误,请求可能已经被处理了);</li>
+     *   <li>{@code PARSE_ERROR} / {@code EMPTY} —— 报文回来了但读不懂,同样不知道。</li>
+     * </ul>
+     * 这三种一律**不动**(维持改动前的行为:留在 CREATED,靠回调或绝对过期收尾)。
+     * 落 FAIL 是**不可逆**的({@code PAY_WRITE_FROM} 只放行 CREATED/PAYING,见
+     * {@link #applyPayResult} 的条件更新),在一个「上游可能已经建出单」的状态上落它,
+     * 等于把一笔可能的真实交易判了死刑。其余取值都是上游 v3 报文里它自己的
+     * {@code return_code}(实测形如 {@code FAIL})—— 那是它明说没受理,此时上游侧
+     * 不可能存在对应的单,收敛是**陈述事实**而不是猜测。
+     *
+     * <p>★ 与 {@code result_code=PAY_FAIL} 那条路的区别只是「消息从哪来」:
+     * 那条走正常应答({@code payPatch(resp, null)}),本条走异常对象,落库结果同形。
+     */
+    private void closeIfUpstreamRejected(LsyOrderEntity order, LeshouyingApiException e) {
+        if (!upstreamRejected(e)) {
+            return;
+        }
+        LsyOrderEntity patch = new LsyOrderEntity();
+        // 列宽:upstream_result_code VARCHAR(32) / upstream_result_msg VARCHAR(255)(V1.8)。
+        // 不截断的话超长原文会让这条 UPDATE 自己报错,把「上游拒绝了下单」换成一条数据库错误。
+        patch.setUpstreamResultCode(clip(e.getRawReturnCode(), 32));
+        patch.setUpstreamResultMsg(clip(e.getRawReturnMsg(), 255));
+        boolean applied = applyPayResult(order, "FAIL", patch);
+        log.warn("上游明确拒绝下单,本地行收敛为 FAIL: outTradeNo={}, return_code={}, applied={}",
+                order.getOutTradeNo(), LsyLogSanitizer.flatten(e.getRawReturnCode()), applied);
+    }
+
+    /** 「上游自己答了话,且是拒绝」—— 见 {@link #closeIfUpstreamRejected} 的判据表。 */
+    private static boolean upstreamRejected(LeshouyingApiException e) {
+        String code = e.getRawReturnCode();
+        if (StrUtil.isBlank(code)) {
+            return false;
+        }
+        if (TRANSPORT_CODES.contains(code)) {
+            return false;
+        }
+        return !code.startsWith("HTTP_");
+    }
+
+    private static String clip(String s, int max) {
+        return s == null || s.length() <= max ? s : s.substring(0, max);
+    }
+
+    /** 非「上游自己的话」的那几个码 —— 见 {@link #closeIfUpstreamRejected} 的判据表。 */
+    private static final Set<String> TRANSPORT_CODES = Set.of("NETWORK_ERROR", "PARSE_ERROR", "EMPTY");
+
     LsyOrderEntity payPatch(Map<String, Object> resp, Long receiptFallback) {
         LsyOrderEntity patch = new LsyOrderEntity();
         patch.setUpstreamResultCode(str(resp.get("result_code")));

+ 195 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOrderUpstreamRejectTest.java

@@ -0,0 +1,195 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
+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;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.Arrays;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 上游**明确拒绝**下单时,本地那一行不留幽灵单、也不留白原因。
+ *
+ * <p>【为什么要有这个类】下单是「先落库、再打上游」(顺序的理由见 {@code LsyOrderService}
+ * 三个 {@code create*} 方法的注释),于是上游那一跳一旦抛异常,本地就留下一行
+ * {@code CREATED}:在订单列表里像一笔在途单,要等绝对过期才被收敛,而
+ * {@code upstream_result_code/msg} 两格**皆空**。2026-09-30 生产上那笔顾客页失败单
+ * ({@code CS2105183092730679296})就是这样:上游的原话(「门店不存在或已被关闭!」)
+ * 只活在一条 WARN 里,页面上一个字都看不到,取证多绕了一圈。
+ *
+ * <p>【判据的核心是「上游到底答没答话」】只有它能区分「它明说没受理」与
+ * 「我们不知道它建没建出单」。后者(连接/超时/5xx/读不懂报文)落在本地行上写 FAIL
+ * 是**不可逆**的({@code PAY_WRITE_FROM} 只放行 CREATED/PAYING ⇒ 写进去就再改不了),
+ * 等于把一笔可能真实存在的交易判了死刑 ⇒ 那几种**一律不动**。本类的负向用例就是钉这条边界的。
+ */
+@ExtendWith(MockitoExtension.class)
+class LsyOrderUpstreamRejectTest {
+
+    @Mock private LeshouyingClient client;
+    @Mock private LsyOrderMapper orderMapper;
+    @Mock private LsyQueryService queryService;
+    private LsyOrderService orderService;
+    private LeshouyingProperties props;
+
+    @BeforeEach
+    void setUp() {
+        props = new LeshouyingProperties();
+        props.setDefaultQrExpireMinutes(15);
+        props.setSyncPollTimes(3);
+        props.setSyncPollIntervalMs(0);   // 测试里不真的睡
+        orderService = new LsyOrderService(orderMapper, client,
+                new LeshouyingResponseParser(), props, queryService);
+    }
+
+    private LsyOrderEntity order(long id, String outTradeNo, String payScene) {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(id);
+        o.setOutTradeNo(outTradeNo);
+        o.setMchNo("852609290114");
+        o.setPayScene(payScene);
+        o.setPayTraceNo("PT" + id);
+        o.setPayTime("20260930142856");
+        o.setTotalAmount(100L);
+        o.setStatus("CREATED");
+        return o;
+    }
+
+    // ────────────────────────── ① 上游明说拒绝:三条腿都收敛 ──────────────────────────
+
+    @Test
+    void barcode_上游明确拒绝_收敛FAIL并落下原因() {
+        assertConvergesOnReject(
+                () -> orderService.createBarcodeOrder(order(1L, "OUT001", "BARCODE"), "134567890123456789", "000"),
+                "被扫 /v3/pay");
+    }
+
+    @Test
+    void qr_上游明确拒绝_收敛FAIL并落下原因() {
+        assertConvergesOnReject(
+                () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null),
+                "主扫 /v3/qrpay");
+    }
+
+    @Test
+    void h5_上游明确拒绝_收敛FAIL并落下原因() {
+        assertConvergesOnReject(
+                () -> orderService.createH5Order(order(3L, "OUT003", "H5"), "400"),
+                "H5 /v3/h5pay");
+    }
+
+    /**
+     * 共用断言:一条腿调下去,本地行必须被就地收敛,且**原因就是上游那句话**。
+     *
+     * <p>三条腿各跑一遍而不是只测一条:这三处 {@code catch} 是**三个独立的**代码点
+     * (各自一行 {@code closeIfUpstreamRejected}),漏掉任何一处都不会有别的用例变红。
+     */
+    private void assertConvergesOnReject(Runnable leg, String legName) {
+        // 上游原话取自 2026-09-30 生产事故的日志:门店号被填成中文商户名,上游就是这么回的。
+        // 逐字照抄,好让这条用例与那次事故对得上。
+        LeshouyingApiException upstream = new LeshouyingApiException(
+                "FAIL", "门店不存在或已被关闭!", "{\"return_code\":\"FAIL\"}");
+        when(client.post(anyString(), anyMap())).thenThrow(upstream);
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LeshouyingApiException thrown = assertThrows(LeshouyingApiException.class, leg::run,
+                legName + ":上游拒绝的异常必须原样上抛(调用方靠它回错误给顾客)");
+        assertSame(upstream, thrown, legName + ":上抛的必须是同一个异常对象,不得被吞掉或包装");
+
+        ArgumentCaptor<LsyOrderEntity> patch = ArgumentCaptor.forClass(LsyOrderEntity.class);
+        verify(orderMapper).update(patch.capture(), any());
+        assertEquals("FAIL", patch.getValue().getStatus(),
+                legName + ":上游明确拒绝 ⇒ 本地行收敛为 FAIL,不能再挂着 CREATED 冒充在途单");
+        assertEquals("FAIL", patch.getValue().getUpstreamResultCode(),
+                legName + ":失败原因码要落库(页面上要看得到)");
+        assertEquals("门店不存在或已被关闭!", patch.getValue().getUpstreamResultMsg(),
+                legName + ":上游的原话要落库 —— 这一格空白正是 2026-09-30 那次多绕一圈的原因");
+    }
+
+    // ────────────────────── ② 不是「上游答了话」:一个字节都不许落 ──────────────────────
+
+    /**
+     * 传输层的四种码 + null:**不知道上游建没建出单**,一律维持改动前的行为(留在 CREATED)。
+     *
+     * <p>这一条是本改动的**边界**,比上面的正向断言更要紧:写 FAIL 是不可逆的。
+     * 四种码逐一跑,是因为它们**四条不同的构造路径**({@code LeshouyingClient} 的四个 catch
+     * 分支各造一个),漏掉任何一个都不会有别的用例变红。
+     */
+    @Test
+    void 传输层失败_不动本地行() {
+        List<String> transportCodes = Arrays.asList("NETWORK_ERROR", "PARSE_ERROR", "EMPTY", "HTTP_500", null);
+        for (String code : transportCodes) {
+            LeshouyingApiException e = new LeshouyingApiException(code, "连接超时", null);
+            when(client.post(eq("/v3/qrpay"), anyMap())).thenThrow(e);
+
+            assertThrows(LeshouyingApiException.class,
+                    () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null),
+                    "code=" + code + ":异常仍要上抛");
+
+            // 正对照:这一轮确实走到了那次调用(否则下面的 never 可能只是因为压根没调)。
+            verify(client).post(eq("/v3/qrpay"), anyMap());
+            reset(client);
+        }
+        verify(orderMapper, never()).update(any(), any());
+    }
+
+    // ────────────────────────────── ③ 列宽:超长原文要截断 ──────────────────────────────
+
+    /**
+     * 上游原文超长时按列宽截断({@code upstream_result_code VARCHAR(32)} /
+     * {@code upstream_result_msg VARCHAR(255)},V1.8 DDL)。
+     *
+     * <p>不截断的后果不是「少存几个字」,而是那条 UPDATE 自己报错 ——
+     * 「上游拒绝了下单」被换成一条数据库错误,页面上看到的又是另一回事。
+     */
+    @Test
+    void 上游原文超长_按列宽截断而不是让UPDATE报错() {
+        String longMsg = "x".repeat(300);
+        String longCode = "C".repeat(40);
+        when(client.post(anyString(), anyMap()))
+                .thenThrow(new LeshouyingApiException(longCode, longMsg, null));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        assertThrows(LeshouyingApiException.class,
+                () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null));
+
+        ArgumentCaptor<LsyOrderEntity> patch = ArgumentCaptor.forClass(LsyOrderEntity.class);
+        verify(orderMapper).update(patch.capture(), any());
+        assertEquals(32, patch.getValue().getUpstreamResultCode().length(), "code 列宽 32");
+        assertEquals(255, patch.getValue().getUpstreamResultMsg().length(), "msg 列宽 255");
+    }
+
+    // ───────────────────────── ④ 没被写进去的那条路:条件更新未命中 ─────────────────────────
+
+    /**
+     * 条件更新命中 0 行(回调已先把这单推走了)时**不得**再动内存里的状态。
+     *
+     * <p>这条守的是 {@code applyPayResult} 的返回值语义:{@code rows == 0} = 没真流转,
+     * 调用方(本改动)只是记一条 WARN,不能因为「我调了收敛」就把内存对象也改成 FAIL ——
+     * 那会让调用方在上抛前看到两个不一致的状态。
+     */
+    @Test
+    void 条件更新未命中_只记日志不改内存状态() {
+        when(client.post(anyString(), anyMap()))
+                .thenThrow(new LeshouyingApiException("FAIL", "门店不存在或已被关闭!", null));
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        LsyOrderEntity o = order(2L, "OUT002", "QRCODE");
+        assertThrows(LeshouyingApiException.class, () -> orderService.createQrOrder(o, null));
+
+        assertEquals("CREATED", o.getStatus(),
+                "库里没流转成功时,内存对象也必须留在 CREATED(两个答案不能打架)");
+    }
+}