Procházet zdrojové kódy

fix(乐收赢): 查单明细的 code_url 改从订单行取 —— 上游那条腿从不下发它

承接上一条(退款实退),这是同一个病的第 3、4 例:`detail.code_url` 与
`detail.code_url_expire_time` 一直从**上游查单应答**里读,而 `/v3/queryorder`
**从不下发**这两个键 —— 三次真实抓包键集一致(logs/_lsy-query-probe.txt 的 FAIL
全字段回显、_lsy-query-by-tradeno-probe.txt、_lsy-query-by-traceno-probe.txt 两次
SUCCESS 都是同一份 24 键),上游自己的「交易查询」字段表里也没有。⇒ 两格恒为 null,
而对外契约(collection-openapi.md §5.4)写着它们会给二维码内容与过期时间。

数据一直都在,只是取错了地方:与上游无关的那份拷贝在下单时就落库了
(`LsyOrderService` 的 urlPatch / stageCodeUrl,`code_url_expire_time` 由
`now + time_expire` 算出)。用户裁定:改从订单行取。

改动:

- `LsyQueryDetailAssembler#forOrder` 收第二个参数(订单行,可 null)。金额与 `pay_time`
  仍来自上游应答,`code_url` / `code_url_expire_time` 改从行取 —— 两者是**两个来源**,
  javadoc 里写明了判据。
- 过期时间按**北京时间**渲染成与紧邻的 `pay_time` 同款的 `yyyyMMddHHmmss`
  (旧实现是原样透上游字符串,而上游从没给过)。时区那半句是承重的:列是 timestamptz,
  `JacksonConfig` 对外固定北京时间;直接 format 那个 OffsetDateTime,一台 UTC 的服务器
  会吐出早 8 小时的墙钟时间,租户拿它跟「现在」比会把还有 8 小时的码判成已过期。
  向量:2026-09-30T18:15:00Z == 北京 2026-10-01 02:15:00 ⇒ "20261001021500"。
- 空白的 code_url 归一为 null(上游真实出现过空白串,`LsyReconcileScheduler` 为它留了
  EXPIRED 分支):空串交给租户只会渲染出一张空二维码。
- 控制器查单调用点传入订单行。★ 副作用是好的:上游那一跳失败时(pay_result=UNKNOWN,
  未扫的聚合码单就是这种),`code_url` **照样有值** —— 租户仍能渲染那张码。
- 契约文档 §5.4 两行补上格式与来源说明,并加一句「本次回查失败时这两个字段照样有值」。

验证:全量 958 绿。三个变异各自只红对应那条:
  改回从上游读 ⇒ orderDetail_codeUrl取自订单行_不是上游报文 + 控制器那条(端到端)
  去掉时区换算(直接 format)⇒ 跨时区向量那条(实测真吐出 20260930181500)
  去掉空白归一 ⇒ orderDetail_空白的codeUrl归一为null
反向无害变异(局部变量改名)实测仍绿,class 文件 mtime + 大小(5221 → 5258 B)证明
变异体真被重新编译;生产文件按 sha256 复核回到基线。

★ 文档改动要**前端重新构建**才会上线(`?raw` 在构建期内联,见 docSlicer/collectionDoc)。
alphaH před 2 týdny
rodič
revize
f6b5706407

+ 4 - 2
frontend/src/views/module_payment/apikey/docs/collection-openapi.md

@@ -469,8 +469,10 @@ POST /payment/openapi/pay/order/query
 | `receipt_amount` | 实收金额(分) |
 | `discount_amount` | 优惠金额(分) |
 | `pay_time` | 支付时间,格式 `yyyyMMddHHmmss` |
-| `code_url` | 二维码内容(主扫场景) |
-| `code_url_expire_time` | 二维码过期时间 |
+| `code_url` | 二维码内容(主扫场景;H5 场景是跳转链接) |
+| `code_url_expire_time` | 二维码过期时间,格式 `yyyyMMddHHmmss`(北京时间) |
+
+> `code_url` 与 `code_url_expire_time` 取自**平台已落库的订单**,不是本次实时回查的结果。所以本次回查失败(`pay_result` 为 `UNKNOWN`)时,这两个字段**照样有值** —— 你可以继续渲染那张二维码。
 
 > `pay_result` 是**本次实时查询**的结果,`local_status` 是**平台已记录**的状态。两者通常一致;不一致时以 `local_status` 为准(例如已经收款、但回调尚未落库的瞬间)。
 

+ 4 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenController.java

@@ -149,8 +149,9 @@ public class LeshouyingOpenController {
         //   回的是 FAIL「订单信息不存在!」,而文档 §5.4 正是把这个接口定位成
         //   「回调可能丢失时用它兜底确认」的入口。抛出去 ⇒ HTTP 500「服务器内部错误」,
         //   等于对它唯一的用途失灵(联调实测:下单成功拿到 code_url 后立刻查,得到 500)。
-        //   降级后 detailAssembler.forOrder(null) 按契约给出 pay_result=UNKNOWN、其余键为 null
-        //   —— UNKNOWN 的含义是「无法判定」,不是「失败」(见 LsyQueryDetailAssembler)。
+        //   降级后 detailAssembler.forOrder(null, order) 按契约给出 pay_result=UNKNOWN、
+        //   其余键为 null —— UNKNOWN 的含义是「无法判定」,不是「失败」(见 LsyQueryDetailAssembler)。
+        //   ★ 注意那一跳失败时 `code_url` 仍然有值:它的来源是订单行,与上游这一跳无关。
         Map<String, Object> upstream = queryUpstreamQuietly(() -> queryService.queryOrder(order),
                 "订单查询", order.getOutTradeNo());
         // 本地状态为准(回调可能比查询更早到达);实时明细按白名单投影后返回便于对账。
@@ -159,7 +160,7 @@ public class LeshouyingOpenController {
         Map<String, Object> data = new LinkedHashMap<>();
         data.put("local_status", order.getStatus());
         data.put("trade_no", order.getTradeNo());
-        data.put("detail", detailAssembler.forOrder(upstream));
+        data.put("detail", detailAssembler.forOrder(upstream, order));
         return Result.ok(data);
     }
 

+ 55 - 5
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyQueryDetailAssembler.java

@@ -1,9 +1,13 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Component;
 
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
 import java.util.LinkedHashMap;
 import java.util.Map;
 
@@ -19,7 +23,12 @@ import java.util.Map;
  * <ul>
  *   <li>金额三兄弟 `total_amount` / `receipt_amount` / `discount_amount` —— 列注释「(分,上游回填)」;</li>
  *   <li>`pay_time` —— 列注释「上游支付时间(yyyyMMddHHmmss)」,格式与我们发出去的入参一致;</li>
- *   <li>`code_url` / `code_url_expire_time` —— 聚合码内容与过期时间;</li>
+ *   <li>`code_url` / `code_url_expire_time` —— 聚合码内容与过期时间。
+ *       ⚠ 2026-09-30 订正:这两个键的**取值来源是订单行,不是上游查单应答** ——
+ *       `/v3/queryorder` 从不下发它们(三次真实抓包的键集一致,上游自己的「交易查询」
+ *       字段表里也没有),旧实现从应答里读 ⇒ 两格恒为 null。用户裁定改从订单行取,
+ *       见 {@link #forOrder};过期时间按北京时间渲染成与 `pay_time` 同款的
+ *       `yyyyMMddHHmmss`(旧实现是原样透上游字符串,而上游从没给过)。</li>
  *   <li>`refund_no` —— 列注释「上游退款单号(退款回调的定位键)」,是**对外叫「退款单号」的那个值**。
  *       ⚠ 该列注释里的括注**是假的**(实测:退款回调报文不带任何退款标识,故它不是定位键)。
  *       但**白名单结论不变** —— 它仍是上游同步响应里那笔退款自己的单号,
@@ -62,18 +71,32 @@ public class LsyQueryDetailAssembler {
     /** 三个谓词全不命中、或根本没有 result_code 时的取值。含义是「不知道」,不是「失败」。 */
     static final String UNKNOWN = "UNKNOWN";
 
+    /** 对外的墙钟口径:北京时间(同 `JacksonConfig` 的序列化时区)。 */
+    private static final ZoneId BEIJING = ZoneId.of("Asia/Shanghai");
+    /** 与 `pay_time` 同款的格式,好让同一个对象里的两个时间长得一样。 */
+    private static final DateTimeFormatter STAMP = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
     private final LeshouyingResponseParser parser;
 
-    /** 订单查询的 `data.detail`。**键恒为这几个**,没有的值为 null。 */
-    public Map<String, Object> forOrder(Map<String, Object> upstream) {
+    /**
+     * 订单查询的 `data.detail`。**键恒为这几个**,没有的值为 null。
+     *
+     * <p>【两个来源,不是一个】金额与 `pay_time` 来自**上游查单应答**;
+     * `code_url` 与 `code_url_expire_time` 来自**订单行**(`order` 参数,可 null)。
+     * 判据是实测:`/v3/queryorder` 的应答键集里**从来没有**这两个键
+     * (三次真实抓包一致 + 上游自己的「交易查询」字段表也没有),而与上游无关的那份拷贝
+     * 下单时就落库了(`LsyOrderService` 的 urlPatch / stageCodeUrl)—— 数据一直在,
+     * 旧实现只是取错了地方,于是这两格恒为 null。2026-09-30 用户裁定改从订单行取。
+     */
+    public Map<String, Object> forOrder(Map<String, Object> upstream, LsyOrderEntity order) {
         Map<String, Object> detail = new LinkedHashMap<>();
         detail.put("pay_result", resultOf(upstream));
         detail.put("total_amount", toLong(get(upstream, "total_amount")));
         detail.put("receipt_amount", toLong(get(upstream, "receipt_amount")));
         detail.put("discount_amount", toLong(get(upstream, "discount_amount")));
         detail.put("pay_time", toStr(get(upstream, "pay_time")));
-        detail.put("code_url", toStr(get(upstream, "code_url")));
-        detail.put("code_url_expire_time", toStr(get(upstream, "code_url_expire_time")));
+        detail.put("code_url", codeUrlOf(order));
+        detail.put("code_url_expire_time", beijingStamp(order == null ? null : order.getCodeUrlExpireTime()));
         return detail;
     }
 
@@ -127,6 +150,33 @@ public class LsyQueryDetailAssembler {
         return upstream == null ? null : upstream.get(key);
     }
 
+    /**
+     * 订单行上的聚合码内容;空白串归一为 null。
+     *
+     * <p>空白 `code_url` 是上游真实出现过的状态({@code LsyReconcileScheduler} 专门为它留了
+     * EXPIRED 兜底分支)。空串交给租户只会渲染出一张空二维码,而 null 的含义是明确的
+     * 「没有内容」—— 同本类「转不出来就给 null,不编」的一贯口径。
+     */
+    private static String codeUrlOf(LsyOrderEntity order) {
+        if (order == null) {
+            return null;
+        }
+        String url = order.getCodeUrl();
+        return url == null || url.isBlank() ? null : url;
+    }
+
+    /**
+     * 把订单行上的时间列渲染成与 `pay_time` 同款的 `yyyyMMddHHmmss`,**按北京时间**。
+     *
+     * <p>时区那半句是承重的:列类型是 timestamptz,本平台对外固定用北京时间
+     * (见 {@code JacksonConfig}:序列化时区 Asia/Shanghai)。直接 format 那个
+     * {@link OffsetDateTime},一台 UTC 的服务器就会吐出早 8 小时的墙钟时间,
+     * 而租户拿它跟「现在」比 —— 会把还有 8 小时的码判成已过期。
+     */
+    private static String beijingStamp(OffsetDateTime time) {
+        return time == null ? null : time.atZoneSameInstant(BEIJING).format(STAMP);
+    }
+
     /** 上游金额是字符串。转不出来返回 null —— 不编数字。 */
     private static Long toLong(Object value) {
         if (value == null) {

+ 10 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/open/LeshouyingOpenControllerTest.java

@@ -20,6 +20,8 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.mock.web.MockHttpServletRequest;
 
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
@@ -316,6 +318,11 @@ class LeshouyingOpenControllerTest {
         order.setOutTradeNo("OUT001");
         order.setStatus("SUCCESS");
         order.setTradeNo("T001");
+        // ★ 2026-09-30:这两个值的来源是**订单行**(下单时落库),不是上游查单应答 ——
+        //   `/v3/queryorder` 从不下发 `code_url`(三次真实抓包键集一致 + 上游字段表也没有)。
+        //   下面故意**不**往 upstream 里放它们:走的是真实控制器路径,取错了地方这里就红。
+        order.setCodeUrl("https://qr.example/from-row");
+        order.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 22, 10, 45, 0, 0, ZoneOffset.ofHours(8)));
         when(orderMapper.selectOne(any())).thenReturn(order);
 
         Map<String, Object> upstream = new LinkedHashMap<>();
@@ -342,6 +349,9 @@ class LeshouyingOpenControllerTest {
         }
         assertEquals("SUCCESS", detail.get("pay_result"));
         assertEquals(100L, detail.get("total_amount"));
+        // 上游这一跳没给、行里有的那两个键必须由行补上(改之前它们恒为 null)
+        assertEquals("https://qr.example/from-row", detail.get("code_url"));
+        assertEquals("20260922104500", detail.get("code_url_expire_time"));
 
         String whole = r.getData().toString();
         assertFalse(whole.contains("Y52211090004"), "上游商户号出现在响应里: " + whole);

+ 92 - 10
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyQueryDetailAssemblerTest.java

@@ -1,8 +1,11 @@
 package com.payment.platform.module.payment.leshouying.service;
 
 import com.payment.platform.module.payment.leshouying.client.LeshouyingResponseParser;
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
 import org.junit.jupiter.api.Test;
 
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -26,7 +29,14 @@ class LsyQueryDetailAssemblerTest {
     private final LsyQueryDetailAssembler assembler =
             new LsyQueryDetailAssembler(new LeshouyingResponseParser());
 
-    /** 一份「什么都有」的上游查单报文,含全部**不得外泄**的字段。 */
+    /**
+     * 一份「什么都有」的上游查单报文,含全部**不得外泄**的字段。
+     *
+     * <p>⚠ 2026-09-30:`code_url` / `code_url_expire_time` 两个键**从这里删掉了** ——
+     * 原先夹具塞了它们,而 `/v3/queryorder` **从不下发**这两个键(三次真实抓包 + 上游自己的
+     * 「交易查询」字段表都没有)。正是这个假夹具让「映射恒为 null」这一行永远绿。
+     * 现在它们的取值来源是**订单行**(见 {@link #orderRow()}),上游报文里没有它们才是真的。
+     */
     private Map<String, Object> fullUpstreamOrder() {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("return_code", "SUCCESS");
@@ -44,14 +54,20 @@ class LsyQueryDetailAssemblerTest {
         m.put("total_amount", "100");
         m.put("receipt_amount", "100");
         m.put("discount_amount", "0");
-        m.put("code_url", "https://qr.example/x");
-        m.put("code_url_expire_time", "20260922104500");
         return m;
     }
 
+    /** 订单行:`code_url` / `code_url_expire_time` 的唯一来源(下单时落库,见 LsyOrderService)。 */
+    private LsyOrderEntity orderRow() {
+        LsyOrderEntity o = new LsyOrderEntity();
+        o.setCodeUrl("https://qr.example/x");
+        o.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 22, 10, 45, 0, 0, ZoneOffset.ofHours(8)));
+        return o;
+    }
+
     @Test
     void orderDetail_exposesExactlyTheWhitelist() {
-        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
+        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
         assertEquals(Set.of("pay_result", "total_amount", "receipt_amount",
                         "discount_amount", "pay_time", "code_url", "code_url_expire_time"),
                 detail.keySet(),
@@ -67,7 +83,7 @@ class LsyQueryDetailAssemblerTest {
      */
     @Test
     void orderDetail_amountsAreNumbersNotUpstreamStrings() {
-        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
+        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
         assertEquals(100L, detail.get("total_amount"));
         assertEquals(100L, detail.get("receipt_amount"));
         assertEquals(0L, detail.get("discount_amount"));
@@ -75,10 +91,76 @@ class LsyQueryDetailAssemblerTest {
 
         Map<String, Object> broken = new LinkedHashMap<>();
         broken.put("total_amount", "一百块");
-        assertNull(assembler.forOrder(broken).get("total_amount"),
+        assertNull(assembler.forOrder(broken, null).get("total_amount"),
                 "转不出来的金额必须是 null,不得编造");
     }
 
+    /**
+     * ★ 2026-09-30 用户裁定:`detail.code_url` 改从**订单行**取。
+     *
+     * <p>【为什么原先恒为 null】旧实现从**上游查单应答**里读 `code_url`,而 `/v3/queryorder`
+     * 从不下发这个键(真实键集实证:logs/_lsy-query-probe.txt 的 FAIL 全字段回显、
+     * _lsy-query-by-tradeno-probe.txt、_lsy-query-by-traceno-probe.txt 两次 SUCCESS,
+     * 三份键集一致;上游自己的「交易查询」字段表里也没有)。而与上游无关的那份拷贝
+     * **下单时就落库了**(`LsyOrderService` 的 urlPatch / stageCodeUrl)—— 数据一直在,
+     * 只是取错了地方。
+     *
+     * <p>【两向】正:报文里没有、行里有 ⇒ 取到行里的值。
+     * 反:报文里**有**、行里没有 ⇒ 仍必须是 null —— 这一半才证明来源真的换了地方,
+     * 否则「从上游读」这个旧行为在报文恰好带该键时也能让上半条绿。
+     */
+    @Test
+    void orderDetail_codeUrl取自订单行_不是上游报文() {
+        Map<String, Object> fromRow = assembler.forOrder(fullUpstreamOrder(), orderRow());
+        assertEquals("https://qr.example/x", fromRow.get("code_url"));
+
+        Map<String, Object> upstreamPretends = new LinkedHashMap<>(fullUpstreamOrder());
+        upstreamPretends.put("code_url", "https://upstream-从不发这个键.example/x");
+        Map<String, Object> fromUpstream = assembler.forOrder(upstreamPretends, new LsyOrderEntity());
+        assertNull(fromUpstream.get("code_url"),
+                "上游报文里的 code_url 不得被采信 —— 这条腿不下发它,采信等于把这个键留在原处");
+    }
+
+    /**
+     * 过期时间:**同一对象内的格式一致性**优先 —— 与紧邻的 `pay_time` 一样是
+     * `yyyyMMddHHmmss`,且**按北京时间渲染**。
+     *
+     * <p>时区那一半是承重的:行里存的是 `OffsetDateTime`(列类型 timestamptz),
+     * 本平台固定用北京时间对外(见 JacksonConfig:序列化时区 Asia/Shanghai)。
+     * 若直接 `format` 那个 OffsetDateTime,一台 UTC 的服务器就会吐出早 8 小时的墙钟时间
+     * —— 而租户拿它跟「现在」比,正好会把还有 8 小时的码判成已过期。
+     * 向量是算得出来的:2026-09-30T18:15:00Z == 2026-10-01 02:15:00 +08:00。
+     */
+    @Test
+    void orderDetail_codeUrl过期时间按北京时间格式化为上游同款字符串() {
+        LsyOrderEntity utcRow = new LsyOrderEntity();
+        utcRow.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 30, 18, 15, 0, 0, ZoneOffset.UTC));
+
+        assertEquals("20261001021500", assembler.forOrder(Map.of(), utcRow).get("code_url_expire_time"),
+                "跨时区渲染:UTC 18:15 就是北京次日 02:15 —— 直接 format 会得到 20260930181500");
+
+        LsyOrderEntity cnRow = new LsyOrderEntity();
+        cnRow.setCodeUrlExpireTime(OffsetDateTime.of(2026, 9, 22, 10, 45, 0, 0, ZoneOffset.ofHours(8)));
+        assertEquals("20260922104500", assembler.forOrder(Map.of(), cnRow).get("code_url_expire_time"),
+                "本就在东八区的值不得被搬移");
+    }
+
+    /**
+     * 订单行没有这个值时给 null,**不给空串**。
+     *
+     * <p>空白 `code_url` 是上游真实出现过的状态({@code LsyReconcileScheduler} 的
+     * EXPIRED 兜底分支专门处理它)。租户拿空串去渲染二维码只会得到一张空图,
+     * 而 null 的含义是明确的「没有内容」—— 同本类「转不出来就给 null,不编」的一贯口径。
+     */
+    @Test
+    void orderDetail_空白的codeUrl归一为null() {
+        LsyOrderEntity blank = new LsyOrderEntity();
+        blank.setCodeUrl("   ");
+        assertNull(assembler.forOrder(fullUpstreamOrder(), blank).get("code_url"));
+        assertNull(assembler.forOrder(fullUpstreamOrder(), null).get("code_url"),
+                "订单行缺席时不得抛异常");
+    }
+
     /**
      * ★ 上游身份类字段一个都不能出现。
      *
@@ -88,7 +170,7 @@ class LsyQueryDetailAssemblerTest {
      */
     @Test
     void orderDetail_neverLeaksUpstreamIdentity() {
-        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder());
+        Map<String, Object> detail = assembler.forOrder(fullUpstreamOrder(), orderRow());
         for (String forbidden : new String[]{"mch_no", "shop_no", "inst_no", "pay_trace_no",
                 "qd_trade_no", "td_trade_no", "user_id", "return_code", "result_code",
                 "return_msg", "upstream"}) {
@@ -125,11 +207,11 @@ class LsyQueryDetailAssemblerTest {
 
     @Test
     void orderDetail_blankOrNullUpstreamYieldsUnknownWithoutThrowing() {
-        Map<String, Object> detail = assembler.forOrder(Map.of());
+        Map<String, Object> detail = assembler.forOrder(Map.of(), null);
         assertEquals("UNKNOWN", detail.get("pay_result"));
         assertNull(detail.get("total_amount"));
 
-        Map<String, Object> nullDetail = assembler.forOrder(null);
+        Map<String, Object> nullDetail = assembler.forOrder(null, null);
         assertEquals("UNKNOWN", nullDetail.get("pay_result"),
                 "上游查询失败时不得抛异常把整个查单接口带崩");
     }
@@ -183,6 +265,6 @@ class LsyQueryDetailAssemblerTest {
     private Object resultOf(String resultCode) {
         Map<String, Object> m = new LinkedHashMap<>();
         m.put("result_code", resultCode);
-        return assembler.forOrder(m).get("pay_result");
+        return assembler.forOrder(m, null).get("pay_result");
     }
 }