소스 검색

fix(进件): category 的线上契约 —— 把 of() 接给 Jackson(Task 9b-②)

LsyMerchantCreateDTO.category 是枚举类型字段、字段上没有任何注解,而 JacksonConfig
里**零枚举定制**(全文 27 行,没有 READ_UNKNOWN_ENUM_VALUES_AS_NULL 之类的开关)⇒
Jackson 走默认的**常量名**匹配,于是 LsyMerchantCategory.of()(唯一认中文标签的那个)
在生产代码里**零调用方**。而它的 javadoc 逐字承诺「调用方(前端七选一)给的是七个名字之一」
且两种写法都认 —— 那段承诺此前**没有任何实现支撑**:写页面的人读到它,会理所当然地送标签
(那也正是他要显示在选项里的字)⇒ 绑定失败 ⇒ 落 GlobalExceptionHandler 的兜底分支
⇒ HTTP 500「服务器内部错误」。

改动就两行:of() 的声明行正上方加 @JsonCreator,文件头加一个 import。
★ **方法体一字不改**:它「空白与未知一律返回 null、不抛」的语义是必须的 ——
抛出去只会被 Jackson 包成 JsonMappingException ⇒ 又是 500;返回 null 才落到既有的 400
必填路径(类别为 null ⇒ 矩阵报两个派生字段 merchant_type/account_type ⇒ 400,不是 500)。
★ **不加 @JsonValue**:出站形状本来就是常量名,加了反而把给人看的显示文案塞进协议。
★ 不改 LsyMerchantRequiredFields 的任何东西。

测试(+3 用例)—— 关键是**必须走真的 JSON 绑定**:
既有的 category_of_acceptsTheTwoDocumentedNames_elseNull 是**直接调 of()** 的,它绿着而线上是坏的;
直调 of() 钉不住「Jackson 会不会来调它」。所以三条都 POST /create + 真报文:
- 常量名正对照(ENTERPRISE_PUBLIC 绑得上)—— 它绿**不能**证明标签也能绑,它的作用是定性:
  若它也红,说明坏的是「整份报文压根没走到绑定」。
- 中文标签绑上,且用 ArgumentCaptor<LsyMerchantCreateDTO> 抓 service 的**实参**断言
  assertSame 到那个枚举常量(只断言「被调用过」的话,绑成 null 也照样绿)。
  ★ 标签**逐字**取 getLabel(),不在用例里手打中文(手打抄错一个字就变成一条假钉子)。
  ★ 内含**反例先行**:同一份报文形状、只把 category 换成一个谁都不是的值,断言它落到 null
  —— 否则「标签解成了 ENTERPRISE_PUBLIC」可能只是因为这条路径对任何输入都给这个常量。
- 垃圾值 ⇒ 400 + 文案含「商户类型」,**不是 500**。这条要一个**真** service:400 是服务层
  必填矩阵给的(merchant_type → LABELS 里的「商户类型」),把 service mock 掉这条路径整条消失。

★ **与 brief ②-step2 实测不符(已在报告里记明)**:brief 说「红的那一条只能是第 2 条中文标签,
第 1/3/5 条今天就该是绿的」。实测**两条红** —— 标签与垃圾值今天**都是** HTTP 500。
成因是 JacksonConfig 没有开 READ_UNKNOWN_ENUM_VALUES_AS_NULL ⇒ 任何没绑上的值
(标签也好、垃圾也好)走的都是同一条 InvalidFormatException 路径。
brief 那张探针表用的是**裸 ObjectMapper**,与端点实际用的这个不是同一把量具。

两向变异(5 个):该红的 3 个全红 —— 摘 @JsonCreator(标签与垃圾值双双 500,证明这一个注解
是两条路径共同的承重件)/ of() 改成抛异常(双双 500,证明「不抛」也是承重件)/
of() 改成未知默认到 MICRO(反例先行那条 assertNull 以 expected:<null> but was:<MICRO> 红);
无害的 2 个实测仍绿 —— 调换 of() 里 `||` 两侧的顺序、把 creator 的形参名 name 改成 value,
且都用 javap 当场证明变异体进了被执行的 class(绿向要 class 级证据:前者按 of() 字节码里
两条指令的**先后**判定,变异体 SWAPPED / 还原后 ORIGINAL;后者按 of() 自己的
LocalVariableTable 判定,值为 name↔value)。★ 量的时候发现:javap -l 里 `name` 会命中**两处**
(of 的形参 + javac 合成的 valueOf(String name))⇒ 必须把范围限定在 of 那一节,
否则「还原后 name 还在」这条正对照是恒真的。

实测:该类 31 绿(28 → 31);全量 634 绿(上一提交 631,BUILD SUCCESS)。
alphaH 3 주 전
부모
커밋
7c94b9d801

+ 21 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyMerchantCategory.java

@@ -1,6 +1,7 @@
 package com.payment.platform.module.payment.leshouying.enums;
 
 import cn.hutool.core.util.StrUtil;
+import com.fasterxml.jackson.annotation.JsonCreator;
 
 /**
  * 「**创建商户**」页末附表那七类商户 —— 七个 {@code (merchant_type, account_type)} 组合。
@@ -102,7 +103,27 @@ public enum LsyMerchantCategory {
      *
      * <p>★ <b>码值不是入参</b>:{@code "1"} 解不出任何类别 —— {@code merchant_type=1}
      * 同时是三类商户,用它当类别名会静默丢信息。码值对({@code "1-1"})也不是本方法认的格式。
+     *
+     * <p>★★ <b>{@code @JsonCreator}:上面那段承诺(「两种写法都认」)本来在线上是空的。</b>
+     * {@code LsyMerchantCreateDTO.category} 是枚举类型字段、字段上没有任何注解,而
+     * {@code JacksonConfig} 里**零枚举定制** ⇒ Jackson 走默认的**常量名**匹配,
+     * 于是本方法(唯一认中文标签的那个)在生产代码里**零调用方**。写页面的人读到上面那段
+     * javadoc,会理所当然地送标签(那也正是他要显示在选项里的字)⇒ 绑定失败 ⇒
+     * 落 {@code GlobalExceptionHandler} 的兜底分支 ⇒ HTTP 500「服务器内部错误」。
+     * 加上这个注解,那段承诺才第一次成立。
+     *
+     * <p>★ <b>方法体一字不改是有意的</b>:这里「空白与未知一律返回 {@code null}、**不抛**」
+     * 是必须的语义。抛出去只会被 Jackson 包成 {@code JsonMappingException} ⇒ 又是 500;
+     * 返回 {@code null} 才落到既有的 400 必填路径(类别为 null ⇒ 矩阵报两个派生字段
+     * {@code merchant_type}/{@code account_type} ⇒ 400,**不是** 500)。
+     *
+     * <p>★ <b>不要顺手加 {@code @JsonValue}</b>:出站形状**本来就**是常量名,
+     * 加了反而把「给人看的显示文案」塞进协议里。
+     * <p>★ 这条契约由 {@code LeshouyingOnboardingControllerTest} 的三条用例钉着
+     * (常量名正对照 / 中文标签绑上 / 垃圾值 400),它们走的是**真的 JSON 绑定**,
+     * 不是直调本方法 —— 直调钉不住「Jackson 会不会来调它」。
      */
+    @JsonCreator
     public static LsyMerchantCategory of(String name) {
         if (StrUtil.isBlank(name)) {
             return null;

+ 142 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -15,7 +15,9 @@ import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
 import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
+import com.payment.platform.module.payment.leshouying.enums.LsyMerchantCategory;
 import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
@@ -456,6 +458,116 @@ class LeshouyingOnboardingControllerTest {
         gateFirst.verify(onboardingService).commitMerchantInfo(any());
     }
 
+    // ──────────────── category 的线上契约(加记 34) ────────────────
+
+    /**
+     * ★ 正对照:枚举**常量名**从 JSON 绑得上 —— 这一条**今天就是绿的**。
+     *
+     * <p>它的作用是给下面两条定性:如果这一条也红,说明坏的是「整份报文压根没走到绑定」,
+     * 而不是「标签没被认」。反过来说,这一条绿**不能**证明标签也能绑 ——
+     * Jackson 默认只认常量名,而 {@code of()}(唯一认中文标签的那个)在改动前
+     * 在生产代码里**零调用方**。
+     */
+    @Test
+    void category_fromJson_byConstantName_isAccepted() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(createBodyWithCategory(LsyMerchantCategory.ENTERPRISE_PUBLIC.name())))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+    }
+
+    /**
+     * ★★ 本节的要害:**中文标签**必须能从 JSON 绑上,且到达 service 的是那个枚举常量。
+     *
+     * <p>【为什么必须走 JSON 绑定这条路】既有的
+     * {@code LsyMerchantRequiredFieldsTest.category_of_acceptsTheTwoDocumentedNames_elseNull}
+     * 是**直接调 {@code of()}** 的 —— 它**绿着**,而线上是坏的。直调 {@code of()} 钉不住任何东西:
+     * 它证明的是「这个方法认得标签」,而 defects 恰恰在「Jackson 会不会去调这个方法」。
+     * 所以这里必须 {@code POST /create} + 真报文,且用
+     * {@code ArgumentCaptor<LsyMerchantCreateDTO>} 抓 service 的**实参** ——
+     * 只断言「被调用过」的话,绑成 {@code null} 也照样绿。
+     *
+     * <p>★ <b>反例先行(同一份报文形状,只换 category 的值)</b>:先跑一个谁都不是的值,
+     * 断言它落到 {@code null}、**不是**落到某个类别上 —— 否则「标签解成了 ENTERPRISE_PUBLIC」
+     * 有可能只是因为这条绑定路径对**任何**输入都给这个常量(那样下面那条断言就是恒真的)。
+     * 两条一起才说明「标签 → 那个常量」是一次真实的映射。
+     *
+     * <p>★ 标签**逐字**取 {@code getLabel()},不在用例里手打中文:手打的话抄错一个字就变成
+     * 一条测不到东西的假钉子(而且它会以「红的」形态出现,最容易被顺手改对)。
+     *
+     * <p>★ {@code assertSame} 而不是 {@code assertEquals}:要断的就是「**那个**枚举常量」,
+     * 枚举是单例,{@code assertSame} 才是这句话的字面意思。
+     */
+    @Test
+    void category_fromJson_byChineseLabel_reachesServiceAsThatEnum() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        // 反例先行:连谁都不是的值
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(createBodyWithCategory("这不是任何一个类别")))
+                .andExpect(status().isOk());
+
+        // 正例:文档给这七类起的中文名(与上游 account_type 说明栏里的名字逐字同名)
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(createBodyWithCategory(LsyMerchantCategory.ENTERPRISE_PUBLIC.getLabel())))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(0));
+
+        ArgumentCaptor<LsyMerchantCreateDTO> captor = ArgumentCaptor.forClass(LsyMerchantCreateDTO.class);
+        verify(onboardingService, times(2)).createMerchant(captor.capture());
+        List<LsyMerchantCreateDTO> reached = captor.getAllValues();
+        assertNull(reached.get(0).getCategory(),
+                "绑不上的类别值必须落到 null(不是抛、也不是默认到某一个类别上)");
+        assertSame(LsyMerchantCategory.ENTERPRISE_PUBLIC, reached.get(1).getCategory(),
+                "中文标签「" + LsyMerchantCategory.ENTERPRISE_PUBLIC.getLabel()
+                        + "」必须解成 ENTERPRISE_PUBLIC。实测: " + reached.get(1).getCategory());
+    }
+
+    /**
+     * ★ 绑不上的类别值 ⇒ **400 + 中文标签**,不是 500「服务器内部错误」。
+     *
+     * <p>【为什么这条要一个**真** service】400 不是端点给的,是服务层**必填矩阵**给的:
+     * 类别为 {@code null} 时矩阵报的是两个派生字段({@code merchant_type} / {@code account_type}),
+     * 渲染前逐名过 {@code LsyMerchantRequiredFields.LABELS}({@code merchant_type} → 「商户类型」)
+     * ⇒ 400。把 service mock 掉,这条路径整条消失:mock 会直接回一个商户号,
+     * 于是「类别没绑上」这件事在应答里**完全不可见**。
+     *
+     * <p>★ <b>判据是「不落 500」而不是「恰好 400」</b>:本仓 {@code BusinessException} 的既有出口是
+     * HTTP 200 + 业务码,而绑定失败会落 {@code GlobalExceptionHandler} 的兜底分支 ⇒ HTTP 500。
+     * 所以这里同时钉 HTTP 码与业务码 —— 只钉一个的话,「500 也可以是 200 吗」这种错法看不出来。
+     *
+     * <p>★ 报文里**只给**三个键(企业号 / 商户名 / 类别),必填矩阵因此会报出很多缺项;
+     * 断言只取「商户类型」这一个标签,它证明的是**类别这一格**被矩阵看见了。
+     * ★ 零接触下游:矩阵排在凭据闸之后、上送之前,所以这一枪一个上游字节都没出海。
+     */
+    @Test
+    void category_garbageValue_is400WithTheChineseLabel_notServerError() throws Exception {
+        login(TENANT, false);
+        LeshouyingClient client = mock(LeshouyingClient.class);
+        LeshouyingProperties props = new LeshouyingProperties();
+        withOnboardingCredentials(props);
+        MockMvc mvc = standaloneMvc(new LeshouyingOnboardingController(merchantService,
+                new LsyOnboardingService(client, props)));
+
+        mvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content(createBodyWithCategory("这不是任何一个类别")))
+                .andExpect(status().isOk())                      // 本仓 BusinessException 的既有出口
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg", containsString("商户类型")));
+
+        verifyNoInteractions(client);
+    }
+
     // ──────────────── 省市区只读端点(加记 29 §二 裁定 R-T9a) ────────────────
 
     /**
@@ -1262,6 +1374,36 @@ class LeshouyingOnboardingControllerTest {
         return b;
     }
 
+    /**
+     * 一份**真的走 JSON 绑定**的创建报文,只给三个键(企业号 / 商户名 / 类别)。
+     *
+     * <p>★ 为什么不用 {@link #createBody(String, String)} 那个 DTO 夹具:那条路是**直调**
+     * {@code controller.create(dto)},压根不经过 Jackson —— 而本节要钉的正是「Jackson 怎么解
+     * 这个字段」。所以这里只能拼字符串、走 MockMvc。
+     */
+    private static String createBodyWithCategory(String categoryValue) {
+        return "{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\",\"category\":\""
+                + categoryValue + "\"}";
+    }
+
+    /**
+     * 让 {@code createMerchant} 过得了第一步的凭据闸 ——
+     * {@code LeshouyingProperties.missingOnboardingCredentials()} 的**五项**,缺一个都过不去。
+     *
+     * <p>★ 这里给的是**编出来的占位值**(与 {@code LsyOnboardingServiceTest} 的 {@code CFG_*} 同形),
+     * 不是任何真实凭据;凭据不进仓库(Global Constraint 3)。
+     * <p>★ 为什么需要它:不设这五项的话,{@code createMerchant} 在**凭据闸**就抛 400 了,
+     * 根本走不到必填矩阵 —— 那样 {@code category_garbageValue_is400WithTheChineseLabel_notServerError}
+     * 里那个「商户类型」标签永远出不来,用例会以一条与它要钉的东西无关的理由红。
+     */
+    private static void withOnboardingCredentials(LeshouyingProperties props) {
+        props.setChannelId("1001");
+        props.setBdId("2002");
+        props.setChannelType("3");
+        props.setRateCode("RT0045");
+        props.setOnboardingNotifyUrl("https://example.com/xjz/notify/lsy/onboarding");
+    }
+
     private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
         LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
         d.setMchNo(mchNo);