Browse Source

feat(进件): 省市区三级联动 - area_code 必须当字符串

服务层第一个接口。上游 page 196236568,路径 /api/info/area(进件走 /api/* 信封,
与支付类 /v3/* 不是同一套:非 SUCCESS 一律抛)。

三处契约各有一条钉住它的用例:
  1. level=1 **不上送** parent_code —— 判据是 level,不是「parentCode 是不是 null」。
     签名规则是 null 不参与、**空串参与**(LeshouyingSigner),所以填 "" 会让签名原文
     多出一项 parent_code=,与文档示例对不上,而失败只会表现为上游一句笼统的「签名错误」。
     用例两种输入都测:三联动第一步的现实调用(null)+ 调用方误传了上级 code。
  2. area_code 必须原样当字符串:返回示例里有 100A…100H,其中 100G/100H 不是合法十六进制,
     任何 parseInt / 数字型 DTO 字段都会在三级联动第一屏炸;而它还会被当作下一跳的
     parent_code 回传(顺手转数字会同时改掉出站报文)。
  3. area_list 缺键 / 为 null / 不是数组 ⇒ 空列表(不返回 null、不抛)。

★ 补了一条 brief 未列、但实现要点明写的分支:数组元素不是对象时跳过,而不是映射成一行
「两个字段都空」的区划(空行会出现在省市区下拉里被用户选中)。
★ 补了一条本模块的已知陷阱:LeshouyingResponseParser 对 JSON null 的归一化**只走顶层、
不递归**(其 javadoc 与该边界用例 parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives
钉住,那里写着「谁要开始读嵌套字段,必须先把这里改成递归或在这里补归一化」)。
本类是全模块第一处读**嵌套**字段,故在读取处补归一化:area_list[i].area_code 的 JSON null
归成 Java null,而不是字符串 "null"(后者会被当成一个真实的区划编码继续回传上游)。

TDD:先写 8 条用例 → 跑出红(COMPILATION ERROR:找不到 LsyAreaItemDTO / LsyOnboardingService,
是「类不存在」不是打错字)→ 实现 → 8/8 绿。
本提交字节 = 跑过的字节:全量 459 / 0 / 0 / 0(基线 451 + 本类 8),
PaymentApplicationContextTest 2 绿 ⇒ 新增的 @Service 不破坏容器装配。
alphaH 3 weeks ago
parent
commit
e7ab1ba3ff

+ 28 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyAreaItemDTO.java

@@ -0,0 +1,28 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 省市区条目 —— 上游「获取省市区」接口(page 196236568)返回的 {@code area_list} 里的一个元素。
+ */
+@Data
+public class LsyAreaItemDTO {
+
+    /** 区划名称,如「东城区」 */
+    private String areaName;
+
+    /**
+     * 区划编码(上游的 {@code area_code})。
+     *
+     * <p>★ <b>必须保持 {@code String},不得改成数字型、也不得在映射时解析成数字。</b>
+     * page 196236568 的返回示例里 {@code area_code} 出现的是 {@code 100A}…{@code 100H}
+     * 这一类值,其中 {@code 100G} / {@code 100H} **不是合法十六进制**(连十六进制都不是,
+     * 更不是十进制)⇒ 任何 {@code Integer.parseInt} / {@code Long.parseLong} /
+     * {@code Long} 型字段都会在三级联动的第一屏抛 {@code NumberFormatException}。
+     *
+     * <p>它还会被原样回传给上游(下一级联动的 {@code parent_code}),所以任何「顺手转成数字」
+     * 的处理都同时改变出站报文。钉住这条的用例:
+     * {@code LsyOnboardingServiceTest#areaList_areaCode_isString_notNumber}。
+     */
+    private String areaCode;
+}

+ 103 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -0,0 +1,103 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+import cn.hutool.json.JSONNull;
+import com.payment.platform.module.payment.leshouying.client.LeshouyingClient;
+import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 进件(商户入网)服务。上游走 {@code /api/*} 信封 —— 与支付类 {@code /v3/*} 不是同一套
+ * (见 {@link LeshouyingClient#postApi}):非 {@code SUCCESS} 一律抛
+ * {@link com.payment.platform.module.payment.leshouying.client.LeshouyingApiException},
+ * 不存在「抛异常 = 通信失败 / 正常返回 = 业务失败」那种二分。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class LsyOnboardingService {
+
+    /**
+     * {@code level} 的省 / 直辖市取值。这个等级没有上级,{@code parent_code} 不填
+     * (page 196236568 的参数表:「parent_code 否,当前等级的上级 code,**1 级不填**」)。
+     */
+    private static final String LEVEL_PROVINCE = "1";
+
+    private final LeshouyingClient client;
+
+    /**
+     * 获取省市区,用于三级联动。上游 page 196236568。
+     *
+     * @param level      省/直辖市 {@code "1"}、市/州 {@code "2"}、区/县 {@code "3"}
+     * @param parentCode 当前等级的上级 code;{@code level} 为 {@code "1"} 时**不上送**
+     * @return 区划列表;上游没给 {@code area_list}(缺键 / null / 不是数组)时返回**空列表**,
+     *         不返回 null,也不抛
+     */
+    public List<LsyAreaItemDTO> areaList(String level, String parentCode) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("level", level);
+        // ★ 「1 级不填」的判据是 level,不是「parentCode 是不是 null」。
+        //   签名的规则是 **null 不参与、空串参与**,所以这里若改填 "",
+        //   签名原文会多出一项 `parent_code=`,与文档示例对不上 ——
+        //   而失败只会表现为上游一句笼统的「签名错误」,根因不可见。
+        //   反过来,若改成「parentCode 非 null 才填」,level=1 也会因为调用方多传一个值而漏出去。
+        if (!LEVEL_PROVINCE.equals(level)) {
+            p.put("parent_code", parentCode);
+        }
+        return toAreaItems(client.postApi("/api/info/area", p));
+    }
+
+    /**
+     * {@code area_list} → DTO 列表的**防御性读取**:缺键、null、不是数组,三种都归成空列表
+     * (契约里写明的三种输入),数组元素不是对象时跳过。
+     *
+     * <p>【为什么不引入 Jackson】本模块既有风格是直接读 {@code Map<String,Object>} 再手工映射
+     * (见 {@code LsyQueryDetailAssembler}),多一套反序列化就多一套形状口径。
+     *
+     * <p>【值为什么要过 {@link #asText}】{@code LeshouyingResponseParser} 对 JSON null 的归一化
+     * **只走顶层、不递归**(它自己的 javadoc 有实测记录,并由
+     * {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住),
+     * 而这里是全模块第一处读**嵌套**字段的地方;直接 {@code String.valueOf} 拿到的是
+     * **字符串** {@code "null"},那会被当成一个真实的区划编码继续回传给上游。
+     */
+    private static List<LsyAreaItemDTO> toAreaItems(Map<String, Object> resp) {
+        Object raw = resp.get("area_list");
+        if (!(raw instanceof List<?> list)) {
+            return new ArrayList<>();
+        }
+        List<LsyAreaItemDTO> items = new ArrayList<>(list.size());
+        for (Object o : list) {
+            // 非对象元素跳过:映射成一行「两个字段都为 null」的空区划,会让空行直接出现在
+            // 省市区下拉里被用户选中(那是比「少一行」更坏的失败形态)。
+            if (!(o instanceof Map<?, ?> m)) {
+                continue;
+            }
+            LsyAreaItemDTO item = new LsyAreaItemDTO();
+            item.setAreaName(asText(m.get("area_name")));
+            item.setAreaCode(asText(m.get("area_code")));
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * 取值转字符串:Java null 与 Hutool 的 {@link JSONNull} 都归成 Java null
+     * (为什么必须处理后者,见 {@link #toAreaItems});其余按 {@code String.valueOf} 带回。
+     *
+     * <p>只做「文本形态」这一件事,**不做数字解析** —— {@code area_code} 的真实形态不是纯数字
+     * (见 {@link LsyAreaItemDTO#getAreaCode()})。DTO 两个字段都是 {@code String},
+     * 上游若哪天把某个值发成裸数字/布尔,这里得到的是它的文本形态而不是类型转换异常。
+     */
+    private static String asText(Object value) {
+        if (value == null || value instanceof JSONNull) {
+            return null;
+        }
+        return String.valueOf(value);
+    }
+}

+ 211 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

@@ -0,0 +1,211 @@
+package com.payment.platform.module.payment.leshouying.service;
+
+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.dto.LsyAreaItemDTO;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * 进件服务「获取省市区」的出站契约与响应映射(上游 page 196236568)。
+ *
+ * <p>【mock 的是谁】这里 mock 的是 {@link LeshouyingClient},不是 {@code RestTemplate}
+ * —— 后者在客户端内部,服务层看不见。所以本类里「没有出海」的断言一律写作
+ * {@code verifyNoInteractions(client)},而「发了什么」一律抓 {@code client} 收到的 map
+ * (那已经是服务层能负责的最远端:补 {@code inst_no} / 签名是客户端的事,由
+ * {@code LeshouyingClientApiEnvelopeTest} 钉)。
+ *
+ * <p>【为什么断言「键集合恰好相等」而不只是 {@code containsKey}】本类的核心断言之一
+ * 是「1 级**不填** parent_code」。`containsKey` 只挡得住「多填了非空值」,
+ * 而签名规则是**null 不参与、空串参与**(见 {@link com.payment.platform.module.payment.leshouying.client.LeshouyingSigner})——
+ * 填 {@code ""} 与不填是两份不同的签名原文。所以取值一律 {@code assertEquals},
+ * 并额外钉住 {@code keySet()} 恰好等于期望集合:多一个键、少一个键都会红。
+ *
+ * <p>【桩为什么经真实解析器产生】服务层拿到的是 {@code LeshouyingClient.postApi} 的返回值,
+ * 也就是 {@link LeshouyingResponseParser#parseApi} 的产物:数组是 Hutool 的 {@code JSONArray}、
+ * 元素是 {@code JSONObject}、JSON null 是 {@code JSONNull} 单例。测试里手搓
+ * {@code Map.of("area_list", List.of(...))} 会把这三种真实形状全部换掉,
+ * 于是「服务层的类型判断对不对」这件事在本类里就没有证据了。
+ */
+@ExtendWith(MockitoExtension.class)
+class LsyOnboardingServiceTest {
+
+    @Mock private LeshouyingClient client;
+
+    private LsyOnboardingService service;
+
+    @BeforeEach
+    void setUp() {
+        service = new LsyOnboardingService(client);
+    }
+
+    /** 把上游报文原文喂给**真实的**解析器,取它真正会交给服务层的那个 Map 当桩的返回值 */
+    private void stubUpstream(String rawJson) {
+        when(client.postApi(anyString(), anyMap()))
+                .thenReturn(new LeshouyingResponseParser().parseApi(rawJson));
+    }
+
+    private static String ok(String extraFields) {
+        return "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"" + extraFields + "}";
+    }
+
+    /** 捕获唯一一次出站调用的业务参数,并钉住路径 */
+    @SuppressWarnings("unchecked")
+    private Map<String, Object> sentParams() {
+        ArgumentCaptor<Map<String, Object>> captor =
+                ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
+        verify(client, times(1)).postApi(eq("/api/info/area"), captor.capture());
+        return captor.getValue();
+    }
+
+    /**
+     * ★ 「1 级不填 parent_code」钉的是 level,**不是** parentCode 是否为 null。
+     *
+     * <p>两种输入都要不填:① 三联动第一步的现实调用(什么都没选,传 null);
+     * ② 调用方误传了上级 code —— 文档的参数表写着 parent_code 是「当前等级的上级 code」,
+     * 1 级不存在上级,所以判据只能是 level。
+     *
+     * <p>若实现统一填 {@code ""}:签名原文会多出一个 {@code parent_code=} 项
+     * (本仓签名规则:null 不参与、**空串参与**),与文档示例对不上,而失败只会表现为
+     * 上游一句笼统的「签名错误」。
+     */
+    @Test
+    void areaList_level1_omitsParentCode() {
+        stubUpstream(ok(""));
+
+        service.areaList("1", null);
+        service.areaList("1", "110000");
+
+        @SuppressWarnings("unchecked")
+        ArgumentCaptor<Map<String, Object>> captor =
+                ArgumentCaptor.forClass((Class<Map<String, Object>>) (Class<?>) Map.class);
+        verify(client, times(2)).postApi(eq("/api/info/area"), captor.capture());
+
+        for (Map<String, Object> sent : captor.getAllValues()) {
+            assertEquals("1", sent.get("level"));
+            assertFalse(sent.containsKey("parent_code"),
+                    "1 级不得上送 parent_code(连键都不该出现):" + sent);
+            assertEquals(Set.of("level"), sent.keySet(),
+                    "1 级出站参数必须恰好只有 level:" + sent);
+        }
+    }
+
+    @Test
+    void areaList_level3_sendsParentCode() {
+        stubUpstream(ok(""));
+
+        service.areaList("3", "110100");
+
+        Map<String, Object> sent = sentParams();
+
+        assertEquals("3", sent.get("level"));
+        assertEquals("110100", sent.get("parent_code"));
+        assertEquals(Set.of("level", "parent_code"), sent.keySet(),
+                "3 级出站参数必须恰好是 level + parent_code:" + sent);
+    }
+
+    @Test
+    void areaList_mapsSnakeCaseToCamel() {
+        stubUpstream(ok(",\"area_list\":[{\"area_name\":\"东城区\",\"area_code\":\"1000\"}]"));
+
+        List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
+
+        assertEquals(1, areas.size());
+        assertEquals("东城区", areas.get(0).getAreaName());
+        assertEquals("1000", areas.get(0).getAreaCode());
+    }
+
+    /**
+     * ★ 这条钉上游数据的**真实形状**:page 196236568 的返回示例里 {@code area_code}
+     * 出现 {@code 100A}…{@code 100H},其中 {@code 100G} / {@code 100H} **不是合法十六进制**
+     * (连十六进制都不是,更不是十进制)。任何 {@code Integer.parseInt} / {@code Long.parseLong} /
+     * 数字型 DTO 字段都会在这里炸 —— 而炸的是三级联动的第一屏。
+     */
+    @Test
+    void areaList_areaCode_isString_notNumber() {
+        stubUpstream(ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":\"100G\"}]"));
+
+        List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
+
+        assertEquals(1, areas.size());
+        Object code = areas.get(0).getAreaCode();
+        assertInstanceOf(String.class, code,
+                "area_code 必须原样当字符串带回,不得解析成数字");
+        assertEquals("100G", code);
+    }
+
+    /** 契约明写的三种「防御性读取」输入之一:上游没返回 area_list 这个键。 */
+    @Test
+    void areaList_blankList_returnsEmpty() {
+        stubUpstream(ok(""));
+
+        List<LsyAreaItemDTO> areas = service.areaList("1", null);
+
+        assertNotNull(areas, "上游没给 area_list 时必须返回空列表,不是 null");
+        assertTrue(areas.isEmpty(), "必须返回空列表,实际: " + areas);
+    }
+
+    /** 契约明写的另外两种输入:键存在但为 null、键存在但不是数组。同样归成空列表。 */
+    @Test
+    void areaList_nullOrNonListAreaList_returnsEmpty() {
+        for (String json : new String[]{
+                ok(",\"area_list\":null"),
+                ok(",\"area_list\":\"不是数组\"")}) {
+            stubUpstream(json);
+
+            List<LsyAreaItemDTO> areas = service.areaList("1", null);
+
+            assertNotNull(areas, json);
+            assertTrue(areas.isEmpty(), json + " ⇒ 必须返回空列表,实际: " + areas);
+        }
+    }
+
+    /**
+     * ★ 嵌套字段的 JSON null 必须变成 Java null。
+     *
+     * <p>{@link LeshouyingResponseParser} 的归一化**只走顶层**(不递归),这一点由它自己的
+     * javadoc 与 {@code parseV3_normalization_isTopLevelOnly_nestedJsonNullSurvives} 钉住,
+     * 那里还写着「谁要开始读嵌套字段,必须先把这里改成递归**或在这里补归一化**」。
+     * 本类是全模块第一个读嵌套字段的消费方,所以归一化补在读取处:
+     * {@code area_list[i].area_code} 的 JSON null 若按 {@code String.valueOf} 走,
+     * 得到的是**字符串** {@code "null"}(实测,见解析器 javadoc),
+     * 那会被当成一个真实的区划编码继续传给上游(三级联动的下一跳 parent_code)。
+     */
+    @Test
+    void areaList_itemJsonNull_isJavaNull_notStringNull() {
+        stubUpstream(ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":null}]"));
+
+        List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
+
+        assertEquals(1, areas.size());
+        assertEquals("某区", areas.get(0).getAreaName());
+        assertNull(areas.get(0).getAreaCode(), "JSON null 不得变成字符串 \"null\"");
+    }
+
+    /**
+     * 数组元素不是对象时跳过,而不是映射成一行「两个字段都空」的区划:
+     * 空行会直接出现在省市区下拉里被用户选中。
+     */
+    @Test
+    void areaList_nonMapItem_isSkipped_notBlankRow() {
+        stubUpstream(ok(",\"area_list\":[\"oops\",{\"area_name\":\"某区\",\"area_code\":\"100G\"}]"));
+
+        List<LsyAreaItemDTO> areas = service.areaList("3", "110100");
+
+        assertEquals(1, areas.size(), "非对象元素不得被映射成一行空区划,实际: " + areas);
+        assertEquals("某区", areas.get(0).getAreaName());
+        assertEquals("100G", areas.get(0).getAreaCode());
+    }
+}