Ver Fonte

fix(进件): Task 5 修复轮 1 - 订正「翻面成本」的失实依据 + 拆开行序相关的双桩

评审 5 条(F1/F2/F3/F5/F6),逐条落点与读数见 task-5-fix1-report.md。
F4(createShop(null) → NPE)按裁定**不做**:那是新增生产行为,且当前不可达。
生产代码改动**全是注释**(git diff 里新增的非注释行 0 行),无新增行为。

F1 改「为什么不改解析器」的依据。原文写「会动到批次一全部路径的返回形状」——
   实测为假。真实作用面三条(2026-09-22 实测):
   · toMap 逐键替换顶层值 ⇒ 递归化只改嵌套结构内部的值、**不改任何顶层键的值**,
     只读顶层键的既有消费方(payPatch、回调、白名单 get)读取结果不变;
   · 全模块读嵌套字段的只有 toAreaItems / toShopItems 两个方法:4 个 instanceof
     Map/List 守卫 + 6 个元素字段读取,全部过 asText;main 里出现 JSONObject /
     JSONArray 的地方只有解析器自己;
   · 递归化后那两个守卫照样成立(JSONObject 经 MapWrapper 实现 Map、JSONArray
     直接实现 java.util.List,javap hutool-all 5.8.34 实测);变成死代码的只有
     asText 的 JSONNull 分支。
   「翻面后只有一条测试会红」按评审要求**标注为推断(未实测)**并附可执行的测量
   命令,不写成读数。

F2 再评估条件改成**结构性触发**:下一次有人动 LeshouyingResponseParser 就一并把
   递归化做掉(同时删掉 asText 的 JSONNull 分叉)。不再数「消费方到 3 处」——
   那种计数只对**记得**过 asText 的消费方 +1,而危害来自**忘了**的,触发条件与
   要防的失败模式正交,永远不会响。

F3 shop_phone(创建出站)/ shop_tel(列表入站)这个同义异名的提示:两个 DTO 的
   字段 javadoc 各写一条、互相指向。依据逐字取自上游原文(两页表格这两行的说明列
   都是「门店电话」,2026-09-22 复核)。

F5 把 listShops_blankShopTel_doesNotBecomeStringNull 拆成两条用例(缺键 /
   显式 JSON null),各自一个桩、各自一条断言:原来第二个桩是**同 matcher 覆盖式**,
   正确性依赖桩的行序(挪一下就变成 IndexOutOfBounds + UnnecessaryStubbing,
   症状与 JSONNull 无关)。既有断言的断言语句逐字保留,红/绿性质未变。

F6 订正 LeshouyingResponseParserTest 里那句加工引文(源码只有「谁要开始读嵌套字段,
   先回来把这里改成递归」一条路,没有「或在这里补归一化」),并在同一处挂上 F2 的
   结构性触发提示。**未动 LeshouyingResponseParser.java 本身**。

本提交字节 = 跑过的字节:全量 483 / 0 / 0 / 0(上一轮 482,F5 拆分 +1)。
两向变异(全量跑,各带三条编译证据,读数见报告):
  M-4(asText 去掉 JSONNull 分支)    → 2 红(areaList_itemJsonNull、listShops_shopTelJsonNull)
  M-8(asText 换成裸 String.valueOf) → 5 红(含新增的 listShops_missingShopTelKey)
alphaH há 3 semanas atrás
pai
commit
0fe2b4e245

+ 9 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopCreateDTO.java

@@ -35,7 +35,15 @@ public class LsyShopCreateDTO {
     /** 门店名称(上游 {@code shop_name},必填)。 */
     private String shopName;
 
-    /** 门店电话(上游 {@code shop_phone},必填)。 */
+    /**
+     * 门店电话(上游 {@code shop_phone},必填)。
+     *
+     * <p>★ <b>同一语义在上游有<u>两个键名</u>,不要按「统一命名」去对齐</b>:**创建**腿出站是
+     * {@code shop_phone}(page 196236559 请求参数表),而**列表**腿入站是 {@code shop_tel}
+     * (page 196236560 返回参数表)—— 一个 {@code phone}、一个 {@code tel}。
+     * 两页表格里这两行的**说明列逐字都是「门店电话」**(2026-09-22 取原文复核),
+     * 所以这不是两种含义,是上游自己没统一。对侧见 {@link LsyShopItemDTO#getShopTel()}。
+     */
     private String shopPhone;
 
     /** 门店地址(上游 {@code shop_address},必填)。 */

+ 11 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyShopItemDTO.java

@@ -29,6 +29,16 @@ public class LsyShopItemDTO {
     /** 门店地址(上游 {@code shop_address})。 */
     private String shopAddress;
 
-    /** 门店电话(上游 {@code shop_tel});上游示例里就有门店不带这个键 ⇒ 可能为 {@code null}。 */
+    /**
+     * 门店电话(上游 {@code shop_tel});上游示例里就有门店不带这个键 ⇒ 可能为 {@code null}。
+     *
+     * <p>★ <b>别把它改成 {@code shop_phone}</b>:同一语义在上游有<u>两个键名</u> ——
+     * **创建**腿出站是 {@code shop_phone}(page 196236559 请求参数表),**列表**腿入站是
+     * {@code shop_tel}(page 196236560 返回参数表)。两页表格里这两行的**说明列逐字都是
+     * 「门店电话」**(2026-09-22 取原文复核),是上游自己没统一,不是两种含义。
+     * 改了不会抛异常,只会让门店列表的电话列**全为 null**(届时红的只有
+     * {@code LsyOnboardingServiceTest.listShops_mapsFourFields})。对侧见
+     * {@link LsyShopCreateDTO#getShopPhone()}。
+     */
     private String shopTel;
 }

+ 35 - 9
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -343,16 +343,42 @@ public class LsyOnboardingService {
      * 上游示例里第二项**就是没有 {@code shop_tel} 键**(另一条路径:{@code Map.get} 给 Java
      * {@code null},不过 {@code asText} 的话同样会变成字符串 {@code "null"})。
      *
-     * <p>★ <b>为什么不去把解析器改成递归</b>(另一条路):那会动到 Task 1-4 与批次一
-     * **全部**路径的返回形状,超出本 task 边界。
-     * <br>★ <b>这是对解析器那条注释的一次<u>有意偏离</u></b>,逐字记清楚免得后来人以为它同意了:
-     * {@code LeshouyingResponseParser.toMap} 的注释里写的是
+     * <p>★ <b>这是对解析器那条注释的一次<u>有意偏离</u></b>,逐字记清楚免得后来人以为它同意了:
+     * {@code LeshouyingResponseParser.toMap} 的注释写的是
      * 「<b>谁要开始读嵌套字段,先回来把这里改成递归</b>」—— 它给的路是**改它**;
-     * 本 task 按派发件的裁定走了另一条(在读取点归一),理由是改动面:改解析器要同时动
-     * 批次一(支付/回调/退款)全部消费方,而本批次一处都不碰。
-     * <br>代价与再评估条件一起记着:**每个**读嵌套字段的消费方都得自己记得过 {@code asText},
-     * 漏一处就是一条静默的字符串 {@code "null"}。**消费方到 3 处就该反过来做**——
-     * 那时把解析器改递归,并删掉这里的 {@code JSONNull} 分支(两处都留会变成两份口径)。
+     * 本 task 按派发件的裁定走了另一条(在读取点归一),因为本 task 的边界不允许动
+     * {@code client/} 里那条既有行为。
+     *
+     * <p>★ <b>那条路真实的作用面</b>(2026-09-22 实测,不是估的;将来重新评估「要不要翻面」
+     * 请用下面三条去算账,**不要**再沿用「翻面要动批次一全部消费方」这种没量过的说法):
+     * <ol>
+     *   <li>{@code toMap} 的实现是逐键替换顶层值
+     *       ({@code map.put(k, v instanceof JSONNull ? null : v)})⇒ 递归化**只改嵌套结构
+     *       内部的值,不改任何顶层键的值**,只读顶层键的既有消费方(payPatch、回调、
+     *       {@code LsyQueryDetailAssembler} 的白名单 {@code get})**读到的东西不变**。
+     *       (唯一会变的形态是把整张已解析的 map 原样序列化或打印 —— 本模块没有这种用法。)</li>
+     *   <li>全模块读嵌套字段的只有两个方法({@link #toAreaItems} 与本方法):4 个
+     *       {@code instanceof Map/List} 守卫 + 6 个元素字段读取,**全部**过 {@code asText};
+     *       main 里出现 {@code JSONObject}/{@code JSONArray} 的地方只有解析器自己。</li>
+     *   <li>递归化后这两处守卫照样成立:{@code JSONObject} 经 {@code MapWrapper} 实现 {@code Map}、
+     *       {@code JSONArray} 直接实现 {@code java.util.List}(javap hutool-all 5.8.34 实测)
+     *       ⇒ 容器仍是 List、元素仍是 Map;变成死代码的只有 {@link #asText} 的 {@code JSONNull} 分支。</li>
+     * </ol>
+     * <b>推断(未实测,别当读数用)</b>:按上面三条,翻面后全量应当只有一条测试会红 ——
+     * {@code LeshouyingResponseParserTest.parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives}
+     * (它的 {@code assertInstanceOf(JSONObject.class, nested)} 与「嵌套里的 null 没有被归一化」
+     * 两句钉的就是这条边界本身)。要把这个推断变成读数就跑:
+     * {@code C:\Users\1\tools\apache-maven-3.9.16\bin\mvn.cmd -f java\pom.xml test}
+     * (先把 {@code target/classes} 下 parser 的 class 删掉强制重编,再逐条比对失败清单)。
+     *
+     * <p>★ <b>再评估条件(结构性触发,不靠计数)</b>:**下一次有人动
+     * {@code LeshouyingResponseParser},就把递归化一并做掉**,同时删掉本类的 {@code JSONNull}
+     * 分叉({@link #asText} 的分支 + 两处读取点的说明)—— 两处都留会变成两份口径。
+     * 触发点挂在「动解析器」这个**必然会发生**的事件上。**不要**换成数「有几个消费方记得过
+     * {@code asText}」:那种计数只对**记得**的消费方 +1,而危害恰恰来自**忘了**的
+     * ⇒ 计数器的触发条件与它要防的失败模式正交,永远不会响。
+     * <br>在这之前,代价照旧记着:**每个**读嵌套字段的消费方都得自己记得过 {@code asText},
+     * 漏一处就是一条静默的字符串 {@code "null"}(长度 4)。
      */
     private static List<LsyShopItemDTO> toShopItems(Map<String, Object> resp) {
         Object raw = resp.get("shop_list");

+ 5 - 1
java/src/test/java/com/payment/platform/module/payment/leshouying/client/LeshouyingResponseParserTest.java

@@ -113,7 +113,11 @@ class LeshouyingResponseParserTest {
         //   (实现对 `obj.keySet()` 逐键替换,不递归)。
         //   实测(NullProbe):嵌套对象仍是 `JSONObject`,其内部取出的 null 仍是 `JSONNull`。
         //   当前所有消费方(Task 6 payPatch、Task 12 回调)只读顶层字段,所以够用;
-        //   但**别以为这是递归的** —— 谁要开始读嵌套字段,必须先把这里改成递归或在这里补归一化。
+        //   但**别以为这是递归的** —— 源码里那句是「谁要开始读嵌套字段,先回来把这里改成递归」
+        //   (LeshouyingResponseParser.java:117,逐字)。
+        //   ★ 谁下次动 toMap,就把递归化一并做掉:作用面(实测)与决定依据见
+        //   LsyOnboardingService#toShopItems 的 javadoc —— 那里正是被这条注释点名、
+        //   却先走了「读取点归一」的那处,它记着为什么以及什么时候翻回来。
         Map<String, Object> r = parser.parseV3(
                 "{\"return_code\":\"SUCCESS\",\"data\":{\"outer\":null},\"arr\":[null]}");
         Object nested = r.get("data");

+ 27 - 16
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -748,31 +748,42 @@ class LsyOnboardingServiceTest {
     }
 
     /**
-     * ★ 这条钉的是 **Hutool 的 {@code JSONNull}**:没有电话的门店,{@code shopTel} 必须是
-     * Java {@code null},**不是** 4 个字符的字符串 {@code "null"}。
+     * 没有电话的门店:{@code shopTel} 必须是 Java {@code null},**不是** 4 个字符的字符串
+     * {@code "null"} —— 后者会被页面当成一个真实的门店电话显示出来,并原样回传给上游。
      *
-     * <p>{@link LeshouyingResponseParser} 对 JSON null 的归一化**只走顶层、不递归**
-     * (由 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住),
-     * 而 {@code shop_list} 是**嵌套**数组 ⇒ 里面取出来的 null 仍是 {@code JSONNull} 单例,
-     * 裸 {@code String.valueOf} 会得到字符串 {@code "null"}。那会被页面当成一个真实的
-     * 门店电话显示出来,并原样回传给上游。
+     * <p>★ <b>本方法只钉两条路径中的一条:键不存在</b>(上游返回示例的第二项就是)。
+     * 这条路径全程是 Java {@code null}({@code Map.get} 给 null),
+     * <b>碰不到 Hutool 的 {@code JSONNull}</b> —— 另一条路径单独立一条用例
+     * ({@link #listShops_shopTelJsonNull_isJavaNull}),两条各自一个桩、各自一条断言,
+     * 谁也不覆盖谁,正确性与**行序无关**。
      *
-     * <p>两种输入**都要测,因为它们走的是两条不同的路径**,谁也不覆盖谁:
-     * <ol>
-     *   <li><b>键不存在</b>(上游示例第二项就是)⇒ {@code Map.get} 给 Java {@code null},
-     *       一路到 {@code String.valueOf(Object)} 同样会变成字符串 {@code "null"};</li>
-     *   <li><b>键在、值是 JSON null</b> ⇒ 取到的是 {@code JSONNull} <b>单例</b>,
-     *       与上一条**完全不是同一个对象**。只测第 1 种的话,「只判 Java null」的实现照样绿,
-     *       而它在真机上遇到显式 null 就漏 —— 变体 M-4 打的就是这一条。</li>
-     * </ol>
+     * <p>【为什么键不存在也要单独测】上游返回示例里就有门店**根本不带这个键**(第二项),
+     * 而两条路径的输入形态不同、谁也代表不了谁:本条的输入是「键不存在」,
+     * 另一条的输入是「键在、值是 JSON null」。少了本条,上游真实返回的那种形态就没有覆盖;
+     * 少了那条,显式 JSON null 的形态没有覆盖。
      */
     @Test
-    void listShops_blankShopTel_doesNotBecomeStringNull() {
+    void listShops_missingShopTelKey_isJavaNull() {
         stubUpstream(SHOP_LIST_EXAMPLE);
+
         assertNull(service.listShops(MCH_NO).get(1).getShopTel(),
                 "第二项没有 shop_tel 键 ⇒ 必须是 Java null,不是字符串 \"null\"");
+    }
 
+    /**
+     * 同上,但钉的是**另一条路径**:键在、值是 **JSON null**。
+     *
+     * <p>★ 这条路径取到的是 Hutool 的 {@code JSONNull} <b>单例</b>,与「键不存在」那条
+     * **完全不是同一个对象**:{@link LeshouyingResponseParser} 对 JSON null 的归一化
+     * **只走顶层、不递归**(由 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives}
+     * 钉住),而 {@code shop_list} 是**嵌套**数组 ⇒ 里面的 null 原样是 {@code JSONNull}。
+     * 只判 Java null 的实现在这条路径上会漏({@code String.valueOf(JSONNull)} 得到
+     * 字符串 {@code "null"}),而在「键不存在」那条上照样绿 —— 这就是两条用例必须都在的原因。
+     */
+    @Test
+    void listShops_shopTelJsonNull_isJavaNull() {
         stubUpstream(ok(",\"shop_list\":[{\"shop_no\":\"861811140412\",\"shop_tel\":null}]"));
+
         assertNull(service.listShops(MCH_NO).get(0).getShopTel(),
                 "JSON null ⇒ 必须是 Java null,不是字符串 \"null\"");
     }