Ver Fonte

fix(乐收赢): H5 关单改打上游 /v3/closeorder(原为「不动上游、本地收敛」)

原实现凭一句「上游 H5 目录下无独立关单接口」,把 H5 分支做成只改本地状态、一个上游
调用都不发。那句话**字面仍真**(142 页文档里标题含「关单」的只有三页:196236529
/v3/closeorder、196236547 /v3/qrclose、196236550 云订单 /v3/trade/closePayTrade,
H5 目录下一页都没有),但由它推出的结论是**假的**:/v3/closeorder 落在「支付接口」
目录下,正文只写了可供「二维码支付,JSAPI支付」,实测对 H5 一样受理。

取证不是「关单成功」四个字 —— 那只能证明它应了一声。做的是**差分对照**:同一时刻
建两笔同参 H5,只关其中一笔。
    被关的 A:PAY_IN_PROCESS → PAY_FAIL
    没动的 B:PAY_IN_PROCESS → PAY_IN_PROCESS
读数 logs/_lsy-close-control.txt,原始应答字节在同目录 .bin。

为什么必须改:只本地收敛 = 关单按钮对操作员**说谎** —— 页面写「已撤销」,而上游那条
支付链接**还活着**。顾客真付了的话,EXPIRED 不在 LsyOrderStatus.PAY_WRITE_FROM 里,
applyPayResult 命中 0 行,只能由 LsyNotifyService 报一条 LATE_PAYMENT_AFTER_TERMINAL:
钱收了、单是「已过期」,账实不符**能被发现,但已经发生了**。

本地收敛那一半没删:上游没给过订单号(下单那一跳就失败了)时**没有句柄可关**。
本方法**故意**不先查一次 —— H5 单在顾客未付款时的正常态就是 PAY_IN_PROCESS(实测四次
全是「用户支付中」),照抄 cancelBarcodeOrder 的「先查后撤」会让它在最该关单的那一刻
返回 null,按钮永远点不动,比原来那个假的「本地收敛」还糟。
(cancelBarcodeOrder 必须先查是因为撤销撤到已付款的单会**退钱给用户**,是资金动作;
196236529 关单正文没有这层语义,且实测对 PAY_FAIL 的单直接拒绝。)

顺带把两处注释从「留到联调定案」改成实测结论:
  * `PAY_SUCEESS` 是**文档笔误** —— 五条关单应答回来的全是 `PAY_SUCCESS`,一次都没
    出现过错拼。⇒ isPaySuccess 的判据是对的,parser 什么都不用加(当初「不要加」的
    决定是对的,理由从「无法判定」升级成「已实测不是」)。result_code 仍留在失败日志里。
  * `/v3/cancelorder` 在本商户被上游一律拒绝(四次「暂不支持撤销!」,sign 有效)。
    那是**上游的商户配置**,不是我们的缺陷,本轮**不动**;要改善的只是操作员看到的
    文案,属另一个决定。

两向变异(跑 LsyCancelServiceTest):
  A 入参名写错(trade_no → pay_trace_no)⇒ **红**,精确落在入参断言那一行
    (h5_closesUpstreamByTradeNo:300 expected: <…0011> but was: <null>)
  B 退回旧的「本地收敛」(条件里加 || true)⇒ **红**,三条新 H5 用例全红
    (2 failures + 1 UnnecessaryStubbing),与 TDD RED 那轮读数同形
  C 无害改动(closeH5Order 里四个 put 顺序倒过来)⇒ **仍绿** 16/0/0/0
    变异体指纹 449300f1…(≠ 基线 5f567370…),确认变异真的写进去了才认这个「绿」
  三次还原后均复量 sha256 回到基线 5f567370…,逐字节相同

测试:LsyCancelServiceTest 16 条绿;全量 906 / 0 / 0 / 0(观测于与本提交这两个文件
逐字节相同的工作区 —— 提交前复量 sha256:生产码 5f567370…、测试 7a9d12d3…)。
alphaH há 2 semanas atrás
pai
commit
91280dd359

+ 91 - 15
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyCancelService.java

@@ -44,10 +44,7 @@ public class LsyCancelService {
     public String closeOrder(LsyOrderEntity order) {
         String scene = order.getPayScene();
         if ("H5".equals(scene)) {
-            // 上游 H5 目录下无独立关单接口,closeorder 正文也未提 H5。
-            // v1 只做本地收敛,不动上游(见 spec §2.3 / §14 待确认项)。
-            log.info("H5 订单无已知关单接口,本地收敛为 EXPIRED: outTradeNo={}", order.getOutTradeNo());
-            return "EXPIRED";
+            return closeH5Order(order);
         }
         if ("QRCODE".equals(scene)) {
             return closeQrOrder(order);
@@ -55,6 +52,72 @@ public class LsyCancelService {
         return cancelBarcodeOrder(order);
     }
 
+    /**
+     * H5 关单:打上游 {@code /v3/closeorder}({@code trade_no} 寻址)。
+     *
+     * <p>★ <b>2026-09-28 联调改判 —— 这里原先是「不动上游、本地收敛为 EXPIRED」。</b>
+     * 原依据是「上游 H5 目录下无独立关单接口」。那句话**字面仍真**
+     * (142 页文档里标题含「关单」的只有三页:196236529 {@code /v3/closeorder}、
+     * 196236547 {@code /v3/qrclose}、196236550 云订单 {@code /v3/trade/closePayTrade},
+     * H5 目录下确实一页都没有),但由它推出的结论是**假的**:
+     * {@code /v3/closeorder} 落在「支付接口」目录下,正文只写了可供「二维码支付,JSAPI支付」,
+     * <b>实测对 H5 一样受理</b>。
+     *
+     * <p>取证不是「关单成功」四个字(那只能证明它应了一声),而是**差分对照**:
+     * 同一时刻建两笔同参 H5,只关其中一笔 ——
+     * 被关的 A {@code PAY_IN_PROCESS → PAY_FAIL},没动的 B {@code PAY_IN_PROCESS → PAY_IN_PROCESS}。
+     * 读数在 {@code logs/_lsy-close-control.txt}。
+     *
+     * <p>【为什么这件事必须改】只本地收敛 = 关单按钮对操作员**说谎**:
+     * 页面上写「已撤销」,而上游那条支付链接**还活着**,顾客随时能付。
+     * 顾客真付了的话,{@code EXPIRED} 不在 {@code LsyOrderStatus.PAY_WRITE_FROM} 里,
+     * {@code applyPayResult} 命中 0 行,只能由 {@code LsyNotifyService} 报一条
+     * LATE_PAYMENT_AFTER_TERMINAL —— 钱收了、单是「已过期」,账实不符它**能被发现,但已经发生了**。
+     * 打了上游这一跳,这件事根本不会发生。
+     *
+     * <p>【本地收敛没删】上游没给过订单号(下单那一跳就失败了)时**没有句柄可关**,
+     * 仍旧只能本地收敛 —— 见下面的空判分支。
+     *
+     * <p>【本方法**故意**不先查一次】与 {@link #cancelBarcodeOrder} 的「先查后撤」不同:
+     * 那边的查询是用来分辨「撤销会不会变成退款」的,撤销一旦撤到已付款的单,
+     * 上游会把钱退给用户(196236528 撤销接口正文明写),属**资金动作**,必须查。
+     * 而 196236529 关单接口正文没有这层语义,实测对一笔 PAY_FAIL 的单它直接拒绝
+     * (「关单失败:该订单交易失败,不支持关单」)。反过来,H5 单在顾客还没付款时的
+     * **正常态就是 {@code PAY_IN_PROCESS}**(实测四次全是「用户支付中」)——
+     * 照抄「先查后撤」会让这一支在最该关单的那一刻返回 null,按钮永远点不动,
+     * 比原来那个假的「本地收敛」还糟。
+     *
+     * <p>【仍未测到的】顾客**正在**付款的那一瞬间调关单会怎样(可能两边同时进行)。
+     * 上游与我都无法在无真实付款人的环境下造出这个时刻。本地这一侧有兜底:
+     * 控制器落库是 {@code in("status", LsyOrderStatus.PENDING)} 的条件更新,
+     * 回调若已抢先写 SUCCESS,这次更新命中 0 行,操作员会看到「这一单的状态刚刚变了」。
+     */
+    private String closeH5Order(LsyOrderEntity order) {
+        if (order.getTradeNo() == null || order.getTradeNo().isBlank()) {
+            log.warn("H5 订单没有上游订单号(下单未成功),无句柄可关,本地收敛: outTradeNo={}",
+                    order.getOutTradeNo());
+            return "EXPIRED";
+        }
+        // 入参逐字照 196236529 的请求参数表:只发 mch_no / close_trace_no / close_time / trade_no,
+        // 不发 shop_no(该表里没有这一项)。这套形状就是联调实测被受理的那一套。
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("mch_no", order.getMchNo());
+        p.put("close_trace_no", IdUtil.fastSimpleUUID());
+        p.put("close_time", LocalDateTime.now().format(FMT));
+        p.put("trade_no", order.getTradeNo());
+
+        Map<String, Object> resp = client.post("/v3/closeorder", p);
+        if (parser.isPaySuccess(resp)) {
+            return "CLOSED";
+        }
+        // 实测这一支的原文是「关单失败:该订单交易失败,不支持关单」(对已关/已失败的单)。
+        // 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
+        log.warn("H5 关单失败: outTradeNo={}, result_code={}, return_msg={}",
+                order.getOutTradeNo(), LsyLogSanitizer.flatten(resp.get("result_code")),
+                LsyLogSanitizer.flatten(resp.get("return_msg")));
+        return null;
+    }
+
     /** 聚合码关单:入参是 code_url,不是 trade_no */
     private String closeQrOrder(LsyOrderEntity order) {
         if (order.getCodeUrl() == null || order.getCodeUrl().isBlank()) {
@@ -73,14 +136,17 @@ public class LsyCancelService {
             return "CLOSED";
         }
         // ★ 必须把 result_code 一起打出来,不能只打 return_msg。
-        //   上游文档里 `/v3/qrclose`(以及 closeorder / cancelorder)**正文把成功写成 `PAY_SUCEESS`**
-        //   —— 少一个 S。若那不是笔误而是真实返回值,`isPaySuccess` 对它为 false,
-        //   于是关单**实际上成功了**,我们却在这里记一条「失败」,
-        //   而 `return_msg` 大概率是「成功」二字 —— 运维看到「关单失败: msg=成功」将无从判断。
-        //   带上 result_code,这一条日志自己就回答了「是不是那个拼写」。
-        //   ★ 不要为了这个去给 parser 加 `PAY_SUCEESS`:我们无法判定它是笔误还是真实值,
-        //     而两种误判不对称 —— 猜错成真实值会让一个可疑拼写进入正常路径;
-        //     猜成笔误只是让关单多走人工核对。**留到联调第一笔关单看日志定案。**
+        //
+        //   【那个 `PAY_SUCEESS` 拼写疑问 —— 2026-09-28 联调已定案:是**文档笔误**。】
+        //   文档有 5 页正文把成功写成 `PAY_SUCEESS`(少一个 S):关单 196236529、撤销 196236528、
+        //   云订单 196236548/196236549/196236550。而联调实测,五条关单应答回来的**全是
+        //   `PAY_SUCCESS`**(正确拼写):`/v3/qrclose` 与 `/v3/closeorder` 各两次成功、
+        //   外加一次 PAY_FAIL —— 一次 `PAY_SUCEESS` 都没出现过。读数见 `logs/_lsy-lifecycle.txt`、
+        //   `logs/_lsy-round3.txt`、`logs/_lsy-close-control.txt` 的原始 .bin。
+        //   ⇒ 所以 `isPaySuccess` 的判据是对的,**parser 什么都不用加**(当初「不要加」的决定是对的,
+        //     而且理由现在从「无法判定」升级成了「已实测不是」)。
+        //   result_code 仍留在日志里:它让这一条自己就能回答「是不是那个拼写」,
+        //   而这个价值不依赖那个疑问是否已解 —— 上游再冒出别的码时它照样是第一线索。
         //
         // 【两个上游实参过 LsyLogSanitizer.flatten】它们来自 HTTP 响应体,不经过任何 DTO 校验
         // (入口控制覆盖不到),必须在汇点压平(CWE-117)。
@@ -134,9 +200,19 @@ public class LsyCancelService {
         if (parser.isPaySuccess(resp)) {
             return "CANCELED";
         }
-        // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)。
-        //   不带 result_code 的话,「撤销实际成功、我们记成失败」将表现为
-        //   「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
+        // ★ 同 closeQrOrder:`/v3/cancelorder` 的文档正文把成功写成 `PAY_SUCEESS`(少一个 S)——
+        //   2026-09-28 联调已定案为**文档笔误**(该页正文那句的「参数示例」列自己写的就是
+        //   正确拼写 `PAY_SUCCESS`)。不带 result_code 的话,「撤销实际成功、我们记成失败」
+        //   将表现为「撤销失败: outTradeNo=..., msg=成功」—— 一句自相矛盾、无法定位的日志。
+        //
+        //   【这条腿在本商户上目前是死的 —— 但**不改**,见下】
+        //   实测四次(两笔真实被扫失败单 × trade_no / pay_trace_no+pay_time 两种寻址),
+        //   上游一律回 `return_code=FAIL` + 「暂不支持撤销!」(sign 有效,是我方收到的真应答)。
+        //   即:**本商户(测试环境 Y52211090004)没有开通撤销**。
+        //   不改的理由:这是**上游的商户配置**,不是我们的缺陷 —— 我们这边该发的都发了、
+        //   该守的都守了;它随时可能被上游开通。真要改善的只是**操作员看到的文案**
+        //   (现在他会看到控制器那句笼统的「撤销失败」,拿不到「暂不支持撤销」这个真实原因),
+        //   那是 `LeshouyingCashierController.close` 的文案问题,**属另一个决定,本批不动**。
         //
         // 【两个上游实参过 LsyLogSanitizer.flatten】同 closeQrOrder:报文原文不经 DTO 校验。
         // outTradeNo 不套:它由入口 @Pattern 保证不含控制字符。

+ 85 - 8
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyCancelServiceTest.java

@@ -50,6 +50,17 @@ class LsyCancelServiceTest {
         return o;
     }
 
+    private LsyOrderEntity h5() {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setId(3L);
+        o.setMchNo("Y52211090004");
+        o.setPayScene("H5");
+        o.setPayTraceNo("PT-H5-1");
+        o.setPayTime("20260928162914");
+        o.setStatus("PAYING");
+        return o;
+    }
+
     private Map<String, Object> resp(String resultCode) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("return_code", "SUCCESS");
@@ -257,20 +268,86 @@ class LsyCancelServiceTest {
         verify(queryService, never()).queryOrder(any());
     }
 
+    /**
+     * H5 单**要**打上游 `/v3/closeorder` —— 原用例
+     * {@code h5_hasNoCloseInterface_returnsExpiredLocally} 断言「一个上游调用都不发」,
+     * 其依据是「上游 H5 目录下无独立关单接口」。
+     *
+     * ★ 2026-09-28 联调证伪。差分对照(同刻两笔同参 H5,只关其中一笔):
+     *     被关的 A:PAY_IN_PROCESS → PAY_FAIL;没动的 B:PAY_IN_PROCESS → PAY_IN_PROCESS。
+     *   即 `/v3/closeorder`(`trade_no` 寻址)对 H5 **确实生效**。
+     *   原文那句「H5 目录下无独立关单接口」字面仍真(142 页文档里 §关单 只有三页:
+     *   196236529 `/v3/closeorder`、196236547 `/v3/qrclose`、196236550 云订单,H5 目录下确实没有),
+     *   但由它推出的「所以只能本地收敛」是**假的** —— 该接口在「支付接口」目录下,正文只写了
+     *   可供「二维码支付、JSAPI支付」,实测对 H5 一样受理。取证读数见
+     *   `logs/_lsy-close-control.txt`。
+     *
+     * 本地收敛那一半没删:上游没给过订单号(下单就没成功)时**没有句柄可关**,仍只能本地收敛。
+     */
     @Test
-    void h5_hasNoCloseInterface_returnsExpiredLocally() {
-        LsyOrderEntity o = new LsyOrderEntity();
-        o.setId(3L);
-        o.setPayScene("H5");
-        o.setStatus("PAYING");
+    void h5_closesUpstreamByTradeNo() {
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
 
-        String target = service.closeOrder(o);
+        LsyOrderEntity o = h5();
+        o.setTradeNo("200001070X4326092816291510011");
+
+        assertEquals("CLOSED", service.closeOrder(o));
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/closeorder"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("200001070X4326092816291510011", sent.get("trade_no"));
+        assertNotNull(sent.get("close_trace_no"), "close_trace_no 是必填(联调实测缺它回「有必传字段未传」)");
+        assertNotNull(sent.get("close_time"));
+    }
+
+    /**
+     * H5 分支**不得**先查一次。
+     *
+     * 这与被扫分支({@code cancelBarcodeOrder} 的「先查后撤」)**故意不同**,不是漏了:
+     * 那边的查询是为了分辨「撤销会不会变成退款」—— 撤销一旦撤到已付款的单,上游会把钱退给用户,
+     * 是**资金动作**,所以必须查。而 `/v3/closeorder` 的正文没有这层语义
+     * (对比 196236528 撤销接口正文明写「会将此订单资金退还给用户」,196236529 关单接口没有),
+     * 且实测对一笔 PAY_FAIL 的单它直接拒绝(「该订单交易失败,不支持关单」)。
+     *
+     * 反过来,H5 单在**顾客还没付款**时的正常态就是 `PAY_IN_PROCESS`(实测 4 次全是
+     * 「用户支付中」)。若照抄「先查后撤」,这一支会在最该关单的那一刻返回 null ——
+     * 关单按钮变成永远点不动,比原来那个「本地收敛」还糟。
+     */
+    @Test
+    void h5_neverQueriesFirst_PAYINGisTheNormalStateForH5() {
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+
+        LsyOrderEntity o = h5();
+        o.setTradeNo("T-H5-1");
+
+        service.closeOrder(o);
 
-        assertEquals("EXPIRED", target);
-        verify(client, never()).post(anyString(), anyMap());
         verify(queryService, never()).queryOrder(any());
     }
 
+    @Test
+    void h5_withoutTradeNo_convergesLocallyExpired() {
+        // 上游没受理过这一单(下单那一跳就失败了)⇒ 没有句柄可关,只能本地收敛。
+        LsyOrderEntity o = h5();
+
+        assertEquals("EXPIRED", service.closeOrder(o));
+        verify(client, never()).post(anyString(), anyMap());
+    }
+
+    @Test
+    void h5_closeRefusedByUpstream_returnsNull_noStateChange() {
+        // 实测:对已关/交易失败的单,上游回 return_code=SUCCESS + result_code=PAY_FAIL
+        // + 「关单失败:该订单交易失败,不支持关单」。关不成 ⇒ 不改状态,留给下次。
+        when(client.post(eq("/v3/closeorder"), anyMap())).thenReturn(resp("PAY_FAIL"));
+
+        LsyOrderEntity o = h5();
+        o.setTradeNo("T-H5-1");
+
+        assertNull(service.closeOrder(o), "关单失败时不应改状态");
+    }
+
     @Test
     void qrcode_missingCodeUrl_returnsExpiredWithoutCallingUpstream() {
         LsyOrderEntity o = qrcode();