Эх сурвалжийг харах

fix(乐收赢): 上送上游的 notify_url 改用平台配置,不再转发调用方地址

## 缺陷

三条下单腿(被扫 /v3/pay、聚合码 /v3/qrpay、H5 /v3/h5pay)此前发的是

    p.put("notify_url", order.getNotifyUrl());

而 `pay_lsy_order.notify_url` 这一列被两种**方向相反**的语义共用:
出站是「上游回调谁」,下游是「我们推给调用方」的目标
(`LsyCallbackService.resolveUrl` 的第一优先级,open 那三个 DTO 的 notifyUrl 落在这一列)。
于是开放 API 的调用方传进来的**下游**地址被原样上送,等于告诉上游
「支付结果请直接回调调用方」。两条后果都不是偏好问题:

1. 调用方收到的是**上游原文格式**,与我们对外文档写的下游格式
   (`LsyCallbackService.notifyOrder` 的 out_trade_no / status / …)对不上,
   按文档对接的调用方一条都解析不出来;
2. 我们那三个 `/payment/collection/notify/**` 端点(免登录 + 验签)**永远收不到支付回调**
   ⇒ `LsyNotifyService.handlePayNotify` 事实上是死代码,下单腿只剩轮询兜底。

同时 `leshouying.notify-url`(`LeshouyingProperties:35`,注释写着「后端回调地址
(我们暴露给乐收赢的入口)」)此前**全 main 树零消费** —— 它在 @PostConstruct 的
validate() 里被强制非空(缺了起不来),却一个字节都没用上。

## 改法

新增私有 `putUpstreamNotifyUrl(Map)`,三条腿共用;上送一律取 `props.getNotifyUrl()`。
空值**不发这个键**,而**不是**退回订单快照里的地址 —— 上游文档对这两个接口写明
「不传取机构默认配置回调」(付款码 196236534、聚合码 196236546),不发是合法形态;
退回快照等于把缺陷原样装回去,所以刻意没有 fallback。

不写库、不动快照列 ⇒ `resolveUrl` 的后两级(api_key.return_url → open_conf.return_url)
行为与改前一致,不会造出「自己回调自己」的环(环的成因是快照列存了我们自己的入口,
见 `LeshouyingCashierController` 类注释)。

配置值(application.yml 默认,`LESHOUYING_NOTIFY_URL` 可覆盖,读自
logs/_lsy-config-values.txt):
`https://qcsj88888.com/api/v1/payment/collection/notify/pay`

## 验证

TDD,先写用例再改实现,4 条新用例:

    barcode_sendsPlatformNotifyUrl_upstream_notTheCallers
    barcode_blankPlatformNotifyUrl_omitsTheKey        (反向守卫)
    qr_sendsPlatformNotifyUrl_upstream_notTheCallers
    h5_sendsPlatformNotifyUrl_upstream_notTheCallers

  RED   未改实现 ⇒ 30 run / 4 failures,全部停在「上送的是调用方地址」这一格
        (实读 https://caller.example/their-own-callback)
  GREEN 改完     ⇒ 30 / 0 / 0 / 0
  全量           ⇒ 914 / 0 / 0 / 0,BUILD SUCCESS
                  两把量具互印:Maven 汇总行 914,surefire XML 逐文件求和 914
                  (求和前已 rm -rf target/surefire-reports)

两向变异(LsyOrderService sha256 基线 4eb2ba8b3590b4831282bdcf36828bf7f5c2449e1e3405d56b188e478028b2f0):
  A 摘掉 isNotBlank 守卫(语义变异)⇒ 红,且**只红**那一条反向守卫(30/1/0/0);
    变异体 sha256 2a3e4b8e…(见 logs/_lsy-notify-mutants.txt)
  B 抽个局部变量(无害重构)      ⇒ 仍绿 30/0/0/0
  两次还原后 sha256 均回到基线,逐字节相同

## 一处副作用,写在这里免得被当成没发生

收银台的单此前**上送不带 notify_url**,改后开始带上 ⇒ 上游会真的回调我们,
`LsyNotifyService.handlePayNotify` 开始跑。收银单快照的 notify_url 仍是 null,
`resolveUrl` 走到 open_conf.return_url;租户若没配,会记 `notify_status='FAIL'`
(`LsyCallbackService` 的既有行为,最多被 retryFailed 重试 downstreamNotifyMaxRetries=8 次)。
收益是页关掉之后订单也能靠回调收敛,不再只靠轮询。
alphaH 2 долоо хоног өмнө
parent
commit
9a32cecca0

+ 42 - 9
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOrderService.java

@@ -208,9 +208,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
 
         Map<String, Object> resp = client.post("/v3/pay", p);
         return settleAfterPay(order, resp, true);
@@ -244,9 +242,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
         // 注意:聚合码不传 pay_type,上游自动识别支付宝/微信
 
         Map<String, Object> resp = client.post("/v3/qrpay", p);
@@ -359,9 +355,7 @@ public class LsyOrderService {
         if (order.getAttach() != null) {
             p.put("attach", order.getAttach());
         }
-        if (order.getNotifyUrl() != null) {
-            p.put("notify_url", order.getNotifyUrl());
-        }
+        putUpstreamNotifyUrl(p);
 
         Map<String, Object> resp = client.post("/v3/h5pay", p);
 
@@ -566,6 +560,45 @@ public class LsyOrderService {
         return o == null ? null : String.valueOf(o);
     }
 
+    /**
+     * 把**平台自己的**回调入口填进上送报文 —— 三条下单腿(被扫 / 聚合码 / H5)共用这一处。
+     *
+     * <h2>★ 为什么不是 {@code order.getNotifyUrl()}</h2>
+     * {@code pay_lsy_order.notify_url} 这一列在库里只有一列,却被两种语义共用,方向正好相反:
+     * <ul>
+     *   <li><b>出站</b>(本方法):上送给上游,语义是「**上游**支付完成后回调谁」;</li>
+     *   <li><b>下游</b>:{@code LsyCallbackService.resolveUrl} 把它当作「**我们**推给调用方」的
+     *       目标,且是**第一优先级**({@code open} 那三个 DTO 的 {@code notifyUrl} 就落在这一列)。</li>
+     * </ul>
+     * 改前实现把这一列原样上送 ⇒ 上游会**绕过我们直连调用方**,后果两条:
+     * <ol>
+     *   <li>调用方收到的是上游原文格式,而不是我们对外文档写的下游格式
+     *       ({@code LsyCallbackService.notifyOrder} 的 {@code out_trade_no}/{@code status}/…);</li>
+     *   <li>我们那三个 {@code /payment/collection/notify/**} 端点永远收不到支付回调 ⇒
+     *       {@code LsyNotifyService.handlePayNotify} 事实上是死代码,下单腿只剩轮询兜底
+     *       (而回收银台的设计恰恰<b>只</b>有轮询 —— 见 {@code LeshouyingCashierController} 的类注释)。</li>
+     * </ol>
+     *
+     * <h2>★ 为什么空值就**不发这个键**,而不是退回订单快照里的地址</h2>
+     * 上游文档对这两个接口各写了一句:付款码支付「回调地址,对于轮询超时订单,系统自动对单后会推送
+     * 订单支付结果」、聚合码支付「后端回调地址(<b>不传取机构默认配置回调</b>)」⇒ 不发是**合法**形态,
+     * 上游会走机构默认配置。而「退回订单快照」等于把这个缺陷原样装回去,
+     * 所以这里刻意**没有** fallback。<br>
+     * 另:{@code leshouying.notify-url} 由 {@code LeshouyingProperties.validate()} 在启动时校验非空
+     * (开关关掉时两道闸都不跑),所以正常运行中这个分支不会走到。
+     *
+     * <h2>★ 它不会造出「自己回调自己」的环</h2>
+     * 环的成因是**快照列**里存了我们自己的入口({@code resolveUrl} 第一优先级取它)。
+     * 本方法一个字节都不写库,快照列仍由 {@code LeshouyingOpenController} 从 DTO 落、
+     * 收银台仍留 null ⇒ {@code resolveUrl} 的后两级(api_key.return_url → open_conf.return_url)
+     * 不变,行为与改前一致。
+     */
+    private void putUpstreamNotifyUrl(Map<String, Object> p) {
+        if (StrUtil.isNotBlank(props.getNotifyUrl())) {
+            p.put("notify_url", props.getNotifyUrl());
+        }
+    }
+
     private Long parseAmount(Object v, Long fallback) {
         if (v == null) {
             return fallback;

+ 60 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyBarcodePayTest.java

@@ -143,6 +143,66 @@ class LsyBarcodePayTest {
         assertEquals("20260921120000", sent.get("pay_time"));
     }
 
+    /**
+     * 上送上游的 {@code notify_url} 必须是**平台自己的回调入口**({@code leshouying.notify-url}),
+     * <b>不是</b>订单快照里那一列。
+     *
+     * <p>【为什么这条是硬要求】{@code pay_lsy_order.notify_url} 在库里只有一列,却被两种语义共用:
+     * 它同时是 {@code LsyCallbackService.resolveUrl} 的**第一优先级**「我们推给下游」的目标
+     * (开放 API 调用方在 DTO 里传进来的那个地址)。修前实现把这个地址原样上送,等于告诉上游
+     * 「支付结果请直接回调调用方」—— 后果有两层:
+     * <ol>
+     *   <li>调用方收到的是**上游原文格式**,而我们对外文档写的是我们自己的下游回调格式
+     *       ({@code out_trade_no}/{@code status}/…,见 {@code LsyCallbackService.notifyOrder})
+     *       ⇒ 按文档对接的调用方一条都对不上;</li>
+     *   <li>我们自己那三个 {@code /payment/collection/notify/**} 端点(免登录 + 验签)
+     *       <b>永远收不到支付回调</b> ⇒ {@code LsyNotifyService.handlePayNotify} 成了死代码,
+     *       下单腿只剩轮询兜底。</li>
+     * </ol>
+     * 两条都不是「行为偏好」,是与对外契约相矛盾。
+     *
+     * <p>反向守卫见下一条:平台地址没配全时**不发这个键**(上游文档写明「不传取机构默认配置回调」),
+     * 而<b>不是</b>退回调用方的地址 —— 退回就等于把这个缺陷又装回去。
+     */
+    @Test
+    void barcode_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createBarcodeOrder(o, "134567890123456789", "000");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口");
+        assertNotEquals("https://caller.example/their-own-callback", sent.get("notify_url"),
+                "调用方的下游地址绝不能上送(那会让上游绕过我们直连调用方)");
+    }
+
+    /** 反向守卫:平台地址为空 ⇒ **不发这个键**,而不是退回调用方的地址。 */
+    @Test
+    void barcode_blankPlatformNotifyUrl_omitsTheKey() {
+        props.setNotifyUrl("   ");
+        when(client.post(eq("/v3/pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createBarcodeOrder(o, "134567890123456789", "000");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertFalse(sent.containsKey("notify_url"),
+                "空白的平台地址一律不发该键(上游会自动取机构默认配置回调)");
+    }
+
     // ---- settleFromQuery 的契约(Task 16 两个调度器都依赖它) ----
 
     @Test

+ 21 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyH5PayTest.java

@@ -26,10 +26,11 @@ class LsyH5PayTest {
     @Mock private LsyOrderMapper orderMapper;
     @Mock private LsyQueryService queryService;
     private LsyOrderService orderService;
+    private LeshouyingProperties props;
 
     @BeforeEach
     void setUp() {
-        LeshouyingProperties props = new LeshouyingProperties();
+        props = new LeshouyingProperties();
         props.setSyncPollTimes(3);
         props.setSyncPollIntervalMs(0);
         // 构造器签名与 Task 5 一致(5 参)
@@ -108,6 +109,25 @@ class LsyH5PayTest {
         assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象");
     }
 
+    /** 同 {@code LsyBarcodePayTest#barcode_sendsPlatformNotifyUrl_upstream_notTheCallers}:三条下单腿一条规则。 */
+    @Test
+    void h5_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        when(client.post(eq("/v3/h5pay"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
+        when(orderMapper.update(any(), any())).thenReturn(1);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createH5Order(o, "300");
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/h5pay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口,不是调用方的下游地址");
+    }
+
     @Test
     void h5_unknownResultCode_doesNotWriteTerminalFail() {
         // H5 的未知码比聚合码更危险:用户可能**已经**被跳到支付页并完成了付款,

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

@@ -94,6 +94,29 @@ class LsyQrPayTest {
         assertEquals("20260921120000", sent.get("pay_time"), "必须是 14 位 yyyyMMddHHmmss 串,不是时间对象");
     }
 
+    /** 同 {@code LsyBarcodePayTest#barcode_sendsPlatformNotifyUrl_upstream_notTheCallers}:三条下单腿一条规则。 */
+    @Test
+    void qr_sendsPlatformNotifyUrl_upstream_notTheCallers() {
+        props.setNotifyUrl("https://platform.example/api/v1/payment/collection/notify/pay");
+        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);
+
+        LsyOrderEntity o = order();
+        o.setNotifyUrl("https://caller.example/their-own-callback");
+        orderService.createQrOrder(o, null);
+
+        var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
+        verify(client).post(eq("/v3/qrpay"), captor.capture());
+        @SuppressWarnings("unchecked")
+        Map<String, Object> sent = captor.getValue();
+        assertEquals("https://platform.example/api/v1/payment/collection/notify/pay",
+                sent.get("notify_url"), "上送的必须是平台回调入口,不是调用方的下游地址");
+    }
+
     @Test
     void qr_defaultExpireApplied_whenNotSpecified() {
         Map<String, Object> resp = new LinkedHashMap<>();