Bladeren bron

fix(乐收赢): 退款查询取不到商户号时本地拒发,并补一条按 outTradeNo 的寻址

mch_no 是上游「退款查询」入参表的必填项,而 pay_lsy_refund 没有这一列,
只能回订单表取。原写法是「取不到就不送这个键」,而那是注定被拒的一跳:
上游对缺必填字段的应答是「有必传字段未传!」,一句把读日志的人引向
「参数拼错了」的话,而真因是本地取不到订单,是另一回事。
现在改成在本地就把话说清楚(异常码 LOCAL_NO_MCH_NO,消息里点名 orderId 与
outTradeNo),且不花那一次网络往返。两条调用腿的降级行为各自保持原样:
开放 API 那条腿照旧降级成「返回本地状态、detail 为 null」,对账调度器那条腿
逐行 try/catch,会把这条记成一次带栈的 ERROR(那正是它该有的响度)。

寻址多给一条路:pay_lsy_refund 的 order_id 与 out_trade_no 不是同一份信息的两种写法
(前者是本库订单行主键,后者是业务单号),历史行 —— 预插入那条腿上线之前建的 ——
order_id 可能是空的而 out_trade_no 一直在,只认前者会让这类行永远查不了退款状态。
按 out_trade_no 兜底查询时**显式带 tenant_id**:这条腿的调用方里有未经认证的
对账调度器,TenantInnerInterceptor 只对已认证请求追加谓词,
而 out_trade_no 在租户内唯一、跨租户不保证。

测试:LsyRefundServiceTest 新增两条 —— 按 orderId 查不到时回退 outTradeNo 取 mchNo、
两条路都定位不到订单时不发请求且消息点名定位键(41 条全绿)。
alphaH 2 weken geleden
bovenliggende
commit
50b61aba47

+ 52 - 8
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyRefundService.java

@@ -1,8 +1,10 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
 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.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
@@ -394,17 +396,27 @@ public class LsyRefundService {
     public Map<String, Object> queryRefund(LsyRefundEntity refund) {
         Map<String, Object> p = new LinkedHashMap<>();
         // ★ mch_no 是**必填**(上游文档「退款查询」入参表),而 pay_lsy_refund 没有这一列
-        //   ⇒ 只能顺着 order_id 回订单表取。判据不是「文档这么写」,是**结构孪生方法**:
+        //   ⇒ 只能回订单表取。判据不是「文档这么写」,是**结构孪生方法**:
         //   LsyQueryService.queryOrder 形状完全平行(同样的 inst_no/mch_no/query_trace_no/
         //   二选一定位键)且送了 mch_no、联调实测查单走通;createRefund 也送。缺它的只有本方法。
-        //   取不到就**不送这个键**(而不是送 null):LeshouyingClient.post 会过滤副本里的 null 值,
-        //   所以两者在上游看来一样,但「送了个 null 键」会让钉 keySet 的用例失去覆盖
-        //   ⇒ 反查失败这条路永远测不到。宁可退化为改动前的行为,也不静默留个空键。
-        LsyOrderEntity order = refund.getOrderId() == null ? null
-                : orderMapper.selectById(refund.getOrderId());
-        if (order != null && order.getMchNo() != null) {
-            p.put("mch_no", order.getMchNo());
+        //
+        // ★★ 2026-09-30 修:取不到时**不再把请求发出去**。原写法是「取不到就不送这个键」,
+        //   而那是**注定被拒**的一跳:上游对缺必填字段的应答是「有必传字段未传!」——
+        //   一句把读日志的人引向「参数拼错了」的话,而真因是**本地取不到订单**(另一回事)。
+        //   现在改成在本地就把话说清楚,且不花那一次网络往返。
+        //   两条腿的降级行为**各自保持原样**:开放 API 那条腿的
+        //   `queryUpstreamQuietly` 本来就只接 LeshouyingApiException ⇒ 照旧降级成
+        //   「返回本地状态、detail 为 null」(文档 §3.6 的 UNKNOWN 就是为这种情形写的);
+        //   对账调度器那条腿逐行 try/catch,会把这条记成一次带栈的 ERROR(那正是它该有的响度)。
+        String mchNo = resolveMchNo(refund);
+        if (StrUtil.isBlank(mchNo)) {
+            throw new LeshouyingApiException("LOCAL_NO_MCH_NO",
+                    "退款查询缺少商户号:按 orderId/outTradeNo 都找不到订单记录"
+                            + "(orderId=" + refund.getOrderId()
+                            + ", outTradeNo=" + LsyLogSanitizer.flatten(refund.getOutTradeNo()) + ")",
+                    null);
         }
+        p.put("mch_no", mchNo);
         p.put("query_trace_no", IdUtil.fastSimpleUUID());
         if (refund.getRefundNo() != null) {
             p.put("refund_no", refund.getRefundNo());
@@ -415,6 +427,38 @@ public class LsyRefundService {
         return client.post("/v3/queryrefund", p);
     }
 
+    /**
+     * 取这笔退款的商户号:先按 {@code order_id} 直取,取不到再按 {@code out_trade_no} 兜一次。
+     *
+     * <p>【为什么要第二条路】{@code pay_lsy_refund} 同时有 {@code order_id} 与
+     * {@code out_trade_no} 两列,而它们**不是同一份信息的两种写法**:前者是本库订单行的主键,
+     * 后者是业务单号。历史行(预插入那条腿上线之前建的)的 {@code order_id} 可能是空的,
+     * 而 {@code out_trade_no} 一直在 —— 只认前者会让这类行永远查不了退款状态,
+     * 且失败形态是**上游那句误导人的「有必传字段未传!」**(见 {@link #queryRefund} 的注释)。
+     *
+     * <p>【为什么带 tenant_id】这条腿的调用方里有**未经认证**的对账调度器
+     * ({@code LsyReconcileScheduler}),而 {@code TenantInnerInterceptor} 只对已认证请求
+     * 追加租户谓词 ⇒ 调度器跑的时候那条谓词不存在,只有本方法自己带上,
+     * 才不会跨租户取到一个同号订单。{@code out_trade_no} 在**租户内**唯一,
+     * 跨租户不保证,所以这一条不是可省的谨慎。
+     *
+     * @return 商户号;{@code null} = 两条路都没定位到订单(调用方据此拒发请求)
+     */
+    private String resolveMchNo(LsyRefundEntity refund) {
+        LsyOrderEntity order = refund.getOrderId() == null ? null
+                : orderMapper.selectById(refund.getOrderId());
+        if (order == null && StrUtil.isNotBlank(refund.getOutTradeNo())) {
+            LambdaQueryWrapper<LsyOrderEntity> w = new LambdaQueryWrapper<>();
+            w.eq(LsyOrderEntity::getOutTradeNo, refund.getOutTradeNo())
+                    .eq(refund.getTenantId() != null, LsyOrderEntity::getTenantId, refund.getTenantId())
+                    // 不加 LIMIT 时 selectOne 命中多行会抛异常;这里要的是「取一条来拿商户号」,
+                    // 多行本身就是不该出现的状态,用 LIMIT 1 让它退化成可用而不是崩掉。
+                    .last("LIMIT 1");
+            order = orderMapper.selectOne(w);
+        }
+        return order == null ? null : order.getMchNo();
+    }
+
     /**
      * 退款查询结果收敛 —— 退款对账腿({@code LsyReconcileScheduler.reconcilePendingRefunds})的解释器。
      *

+ 45 - 9
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyRefundServiceTest.java

@@ -585,19 +585,27 @@ class LsyRefundServiceTest {
     }
 
     /**
-     * 订单查不到时**不送** `mch_no` —— 退化为改动前的行为,而不是送一个 null 键。
+     * `order_id` 那条路断了时,**按 `out_trade_no` 再兜一次**。
      *
-     * <p>【为什么单独立一条】`LeshouyingClient.post` 会过滤 null 值,所以「送 null」与「不送」
-     * 在上游看到的是同一件事;但只有后者能被 keySet 断言区分。若实现写成
-     * `p.put("mch_no", null)`,上面两条「keySet 恰好相等」的用例**照样全绿**,
-     * 于是「反查失败时怎么办」这条路径永远不被覆盖。
+     * <p>【为什么需要第二条路】两列不是同一份信息的两种写法:`order_id` 是本库订单行的主键,
+     * `out_trade_no` 是业务单号。历史行(预插入那条腿上线之前建的)可能只有后者,
+     * 而 `mch_no` 是上游**必填** ⇒ 定位不到就等于这笔退款查不了状态。
+     *
+     * <p>【本用例替换掉了什么】它原先钉的是相反的行为:查不到就**不送** `mch_no`,
+     * 把请求照样发出去。那条路必然被上游以「有必传字段未传!」拒绝 ——
+     * 一句把人引向「参数拼错了」的话,而真因是本地取不到订单。
+     * 现在改成两条路都走不通时**不发请求**(见下一条用例)。
      */
     @Test
-    void queryRefund_订单查不到_不送mchNo() {
+    void queryRefund_按orderId查不到_回退outTradeNo取mchNo() {
         LsyRefundEntity refund = refund("REFUNDING");
         refund.setOrderId(404L);
+        refund.setOutTradeNo("OUT001");
         refund.setRefundNo("RF999");
         when(orderMapper.selectById(404L)).thenReturn(null);
+        // ★ 「兜底查询真的换了判据」由这条桩证明:它按 out_trade_no 返回订单,
+        //   而实现若仍只看 order_id,本用例会因为「不发请求」而红。
+        when(orderMapper.selectOne(any())).thenReturn(order());
         when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
 
         service.queryRefund(refund);
@@ -606,9 +614,37 @@ class LsyRefundServiceTest {
         verify(client).post(eq("/v3/queryrefund"), captor.capture());
         @SuppressWarnings("unchecked")
         Map<String, Object> sent = captor.getValue();
-        assertFalse(sent.containsKey("mch_no"),
-                "查不到订单时不得送 mch_no(送 null 键会让上两条 keySet 用例失去覆盖),实测="
-                        + sent.keySet());
+        assertEquals("Y52211090004", sent.get("mch_no"),
+                "order_id 断了也要能从 out_trade_no 取到商户号;实测入参=" + sent.keySet());
+    }
+
+    /**
+     * 两条路都定位不到订单 ⇒ **一个字都不出海**,在本地把话说清楚。
+     *
+     * <p>判据是那句异常消息**点名了两个定位键**:读日志的人要能一眼看出
+     * 「是本地数据对不上,不是参数拼错了」。
+     *
+     * <p>异常类型选 {@link LeshouyingApiException} 而不是 IllegalStateException:
+     * 开放 API 那条腿的 `queryUpstreamQuietly` 只接前者(照旧降级成「返回本地状态、
+     * detail 为 null」,那是文档 §3.6 为这种情形规定的 UNKNOWN),
+     * 对账调度器那条腿也只按它分流。换成别的类型会把这两条腿的既有降级行为一起改掉。
+     */
+    @Test
+    void queryRefund_两条路都定位不到订单_不发请求且消息点名定位键() {
+        LsyRefundEntity refund = refund("REFUNDING");
+        refund.setOrderId(404L);
+        refund.setOutTradeNo("OUT404");
+        refund.setRefundNo("RF999");
+        when(orderMapper.selectById(404L)).thenReturn(null);
+        when(orderMapper.selectOne(any())).thenReturn(null);
+
+        LeshouyingApiException e = assertThrows(LeshouyingApiException.class,
+                () -> service.queryRefund(refund));
+        assertTrue(e.getMessage().contains("OUT404"),
+                "消息要带上 out_trade_no,否则运维拿着日志还得回去翻库;实际=" + e.getMessage());
+        // 非空转的关键:旧实现在这个夹具下会把**缺 mch_no 的请求**发出去(被上游拒),
+        // 新实现必须在上游之前就停住。
+        verify(client, never()).post(anyString(), anyMap());
     }
 
     // ---- settleFromQuery:退款对账腿的解释点(Job 2 第五条腿,2026-09-22) ----