|
|
@@ -1,14 +1,19 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
+import ch.qos.logback.classic.Level;
|
|
|
import ch.qos.logback.classic.Logger;
|
|
|
import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
import ch.qos.logback.core.read.ListAppender;
|
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
|
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.client.LeshouyingSigner;
|
|
|
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyAnxinSignCompanyDTO;
|
|
|
+import com.payment.platform.module.payment.leshouying.dto.LsyAnxinSignPersonDTO;
|
|
|
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.LsyMccItemDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
|
|
|
@@ -22,6 +27,10 @@ import org.mockito.ArgumentCaptor;
|
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
+import org.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
import java.util.Arrays;
|
|
|
@@ -290,6 +299,105 @@ class LsyOnboardingServiceTest {
|
|
|
assertEquals("100G", areas.get(0).getAreaCode());
|
|
|
}
|
|
|
|
|
|
+ // ---- Task 5:每级各带的那一个 GB 行政码(spec §6.6.2 / §6.6.3)----------
|
|
|
+ //
|
|
|
+ // 上游文档**没有**记录这三个字段(它们是 spec §6.6.2 列的「文档缺漏」),
|
|
|
+ // 字段存在性由一次 483 条穷举普查覆盖:level1 34 / level2 352 / level3 97,
|
|
|
+ // **每条都带该级 GB 码**。三个等级各一条用例,因为「按 level 选字段」若写错,
|
|
|
+ // 只会在其中一个等级上露出来(例如三处都读 province_code)。
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void areaList_carriesProvinceCode_atLevel1() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"北京\",\"area_code\":\"1\","
|
|
|
+ + "\"province_code\":\"110000\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("1", null);
|
|
|
+
|
|
|
+ assertEquals("110000", areas.get(0).getProvinceCode(),
|
|
|
+ "level1 的 province_code 必须进 DTO(spec §6.6.3 的缺口)");
|
|
|
+ assertNull(areas.get(0).getCityCode(), "level1 不该凭空多出市码");
|
|
|
+ assertNull(areas.get(0).getCountyCode(), "level1 不该凭空多出区码");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void areaList_carriesCityCode_atLevel2() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"市辖区\",\"area_code\":\"2\","
|
|
|
+ + "\"city_code\":\"110100\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("2", "1");
|
|
|
+
|
|
|
+ assertEquals("110100", areas.get(0).getCityCode());
|
|
|
+ assertNull(areas.get(0).getProvinceCode(), "level2 不该凭空多出省码");
|
|
|
+ assertNull(areas.get(0).getCountyCode(), "level2 不该凭空多出区码");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void areaList_carriesCountyCode_atLevel3() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"东城区\",\"area_code\":\"3\","
|
|
|
+ + "\"county_code\":\"110101\"}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("3", "2");
|
|
|
+
|
|
|
+ assertEquals("110101", areas.get(0).getCountyCode());
|
|
|
+ assertNull(areas.get(0).getProvinceCode(), "level3 不该凭空多出省码");
|
|
|
+ assertNull(areas.get(0).getCityCode(), "level3 不该凭空多出市码");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上游没给那级 GB 码 ⇒ Java {@code null},**不抛、不填 {@code ""}**。
|
|
|
+ *
|
|
|
+ * <p>三种输入一起试,因为它们钉的**不是同一件事**:① 键不存在({@code Map.get} 给
|
|
|
+ * Java null);② 键在、值是 JSON {@code null}(嵌套的 JSON null **不被**解析器归一化,
|
|
|
+ * 是 Hutool 的 {@code JSONNull} 单例 ⇒ 不过 {@code asText} 就会变成 4 个字符的
|
|
|
+ * 字符串 {@code "null"});③ 键在、值不是那一级该有的那个码(level3 只给
|
|
|
+ * {@code province_code})⇒ 另两格必须是 null。
|
|
|
+ *
|
|
|
+ * <p>★ 填 {@code ""} 比留 null 更坏:{@code address_province} 这类字段会带着空串
|
|
|
+ * 进**签名原文**(本仓规则:null 不参与、**空串参与**),报文与签名一起变,
|
|
|
+ * 而出错只在上游。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_missingGbCode_leavesFieldNull() {
|
|
|
+ String[] jsons = {
|
|
|
+ ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":\"1\"}]"),
|
|
|
+ ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":\"1\",\"province_code\":null}]"),
|
|
|
+ ok(",\"area_list\":[{\"area_name\":\"某区\",\"area_code\":\"3\",\"province_code\":\"110000\"}]"),
|
|
|
+ };
|
|
|
+ for (String json : jsons) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("1", null);
|
|
|
+
|
|
|
+ assertEquals(1, areas.size(), json);
|
|
|
+ assertNull(areas.get(0).getCountyCode(), "缺键 / JSON null ⇒ 必须是 Java null:" + json);
|
|
|
+ assertNull(areas.get(0).getCityCode(), "缺键 / JSON null ⇒ 必须是 Java null:" + json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>GB 码的运行期类型是**未测项**,所以按更宽的收。</b>
|
|
|
+ *
|
|
|
+ * <p>483 条普查量的是 {@code area_code} 的类型(**Integer 483/483**),
|
|
|
+ * **没有**单独量过 {@code province_code} / {@code city_code} / {@code county_code}。
|
|
|
+ * 上游文档说它们是 {@code String}(spec §6.6.2),但同一份文档把 {@code area_code}
|
|
|
+ * 也写成 {@code String},而活服务发的是数字 —— 文档形态与活服务形态在这条腿上
|
|
|
+ * 已经分过一次家。⇒ 与 {@link #areaList_areaCode_integerShape_isStringified}
|
|
|
+ * 同一条理由,走 {@code asText}:活服务真发数字时取回 {@code "110000"},
|
|
|
+ * 而 {@code (String)} 强转会 {@code ClassCastException}。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areaList_gbCode_integerShape_isStringified() {
|
|
|
+ stubUpstream(ok(",\"area_list\":[{\"area_name\":\"北京\",\"area_code\":1,"
|
|
|
+ + "\"province_code\":110000}]"));
|
|
|
+
|
|
|
+ List<LsyAreaItemDTO> areas = service.areaList("1", null);
|
|
|
+
|
|
|
+ Object code = areas.get(0).getProvinceCode();
|
|
|
+ assertInstanceOf(String.class, code,
|
|
|
+ "province_code 若以数字形态回来,必须取成字符串而不是强转");
|
|
|
+ assertEquals("110000", code);
|
|
|
+ }
|
|
|
+
|
|
|
// ================= 获取商户信息(上游 page 196236558) =================
|
|
|
|
|
|
/**
|
|
|
@@ -2499,6 +2607,349 @@ class LsyOnboardingServiceTest {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // ==================== Task 5:行业类目 + 安心签两条 ====================
|
|
|
+ //
|
|
|
+ // 三个上游页:196236569(/api/merchant/getTopMccByType)、
|
|
|
+ // 196236565(/api/merchant/anxinSignPerson)、196236566(/api/merchant/anxinSignCompany)。
|
|
|
+
|
|
|
+ /**
|
|
|
+ * page 196236569 的**返回示例**逐字照抄(六项)。
|
|
|
+ *
|
|
|
+ * <p>★ 用的是**返回示例**而不是返回参数表:该页的参数表写的是
|
|
|
+ * {@code area_list} / {@code area_name} / {@code area_code} —— 与「获取省市区」页
|
|
|
+ * 逐字相同的**复制残留**(计划 §0.5)。示例给的是 {@code mcc_list} / {@code mcc_name} /
|
|
|
+ * {@code mcc_code}。同一页两处冲突时,本模块的既定口径是**按示例取**
|
|
|
+ * (先例:{@code createShop} 的 {@code manager_name} 按**参数表**取 —— 那一次冲突的是
|
|
|
+ * 「表 vs 示例」中的拼写错字,判据是「表是规范、示例是手打的」;
|
|
|
+ * 这里冲突的是**整段键名**,示例是唯一能自洽的一组,参数表那组连语义都不对)。
|
|
|
+ */
|
|
|
+ private static final String MCC_LIST_EXAMPLE =
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"查询成功\",\"mcc_list\":["
|
|
|
+ + "{\"mcc_name\":\"商业及生活服务\",\"mcc_code\":1},"
|
|
|
+ + "{\"mcc_name\":\"专业服务\",\"mcc_code\":7},"
|
|
|
+ + "{\"mcc_name\":\"航旅交通\",\"mcc_code\":32},"
|
|
|
+ + "{\"mcc_name\":\"网络虚拟\",\"mcc_code\":47},"
|
|
|
+ + "{\"mcc_name\":\"生活百货\",\"mcc_code\":86},"
|
|
|
+ + "{\"mcc_name\":\"专业销售\",\"mcc_code\":125}]}";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 按**返回示例**取 {@code mcc_list}(该页返回参数表写的 {@code area_list} 是复制残留)。
|
|
|
+ *
|
|
|
+ * <p>六项**逐项**断言,且 {@code mcc_code} 断言的是 {@code String}:示例里它们是**数字**
|
|
|
+ * ({@code 1}/{@code 7}/{@code 32}/{@code 47}/{@code 86}/{@code 125}),而同一页的返回参数表
|
|
|
+ * 把它声明成 {@code String} —— 又是一处「同页两处冲突」。
|
|
|
+ * 口径与 {@code area_code} 同源:**按更宽的收**({@code String} + {@code asText}),
|
|
|
+ * 收窄成 {@code Integer} 会在上游真发字符串时炸。逐项断言而不是只看第一项,
|
|
|
+ * 是因为 {@code 125} 这种三位数最容易被「按位截断」类实现写坏,而它只在最后一项出现。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_readsMccListKey() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MCC_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ List<LsyMccItemDTO> items = service.mccList("1", null, null);
|
|
|
+
|
|
|
+ assertEquals(6, items.size(), "上游示例里就是六项:" + items);
|
|
|
+ String[][] expected = {
|
|
|
+ {"商业及生活服务", "1"}, {"专业服务", "7"}, {"航旅交通", "32"},
|
|
|
+ {"网络虚拟", "47"}, {"生活百货", "86"}, {"专业销售", "125"}};
|
|
|
+ for (int i = 0; i < expected.length; i++) {
|
|
|
+ assertEquals(expected[i][0], items.get(i).getMccName(), "第 " + i + " 项的 mcc_name");
|
|
|
+ Object code = items.get(i).getMccCode();
|
|
|
+ assertInstanceOf(String.class, code,
|
|
|
+ "第 " + i + " 项的 mcc_code 必须取成字符串(示例是数字、参数表声明 String)");
|
|
|
+ assertEquals(expected[i][1], code, "第 " + i + " 项的 mcc_code");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 键缺失 / 为 null / 不是数组 ⇒ **空列表**(不返回 null、也不抛)—— 照 {@code toAreaItems}
|
|
|
+ * 先例(计划 §0.5 对该页的要求逐字就是这两句)。三种输入都试,因为它们是
|
|
|
+ * {@code toAreaItems} 那条 {@code instanceof} 守卫的三条分支,只试一种会漏掉两条。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_missingKey_returnsEmptyList() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ for (String json : new String[]{
|
|
|
+ ok(""),
|
|
|
+ ok(",\"mcc_list\":null"),
|
|
|
+ ok(",\"mcc_list\":\"不是数组\"")}) {
|
|
|
+ stubUpstream(json);
|
|
|
+
|
|
|
+ List<LsyMccItemDTO> items = service.mccList("1", null, null);
|
|
|
+
|
|
|
+ assertNotNull(items, "必须返回空列表,不是 null:" + json);
|
|
|
+ assertTrue(items.isEmpty(), "必须返回空列表,实际: " + items + " / " + json);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 出站参数**恰好**是服务自己拥有的 4 个键,且 {@code channel_type} 取**配置**
|
|
|
+ * (上游参数表:{@code channel_type} 必填,「由客户经理提供」⇒ 与另外四条腿同源,
|
|
|
+ * 走 {@link LeshouyingProperties#getChannelType()},不是形参)。
|
|
|
+ *
|
|
|
+ * <p>{@code inst_no} / {@code sign} 由 {@link LeshouyingClient} 在客户端内部补,
|
|
|
+ * 本类 mock 的正是那个客户端 ⇒ 集合相等里永远不含这两个键
|
|
|
+ * (理由与 {@code createShop_sendsExactlyTheSevenServiceOwnedParams} 同源)。
|
|
|
+ *
|
|
|
+ * <p>★ 本用例同时钉住 {@code parent_id} 的**键名**:该页写的是 {@code parent_id},
|
|
|
+ * 而「获取省市区」页写的是 {@code parent_code} —— 两页用了**不同的名字**表达同一件事。
|
|
|
+ * 键名进**签名原文**,写成 {@code parent_code} 不会抛异常,只会让上游回一句笼统的
|
|
|
+ * 「参数不合法」。所以这里断言的是 {@code keySet()} 相等,而不是「有个键叫 parent_code」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_sendsChannelTypeFromConfig() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MCC_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ service.mccList("2", "420100", null);
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/getTopMccByType");
|
|
|
+ assertEquals(CFG_CHANNEL_TYPE, sent.get("channel_type"),
|
|
|
+ "channel_type 必须取配置(上游说明列逐字:「由客户经理提供」)");
|
|
|
+ assertEquals("2", sent.get("level"));
|
|
|
+ assertEquals("420100", sent.get("parent_id"),
|
|
|
+ "该页的上级 code 键名是 parent_id(不是「获取省市区」页的 parent_code)");
|
|
|
+ assertEquals(Set.of("level", "parent_id", "merchant_type", "channel_type"), sent.keySet(),
|
|
|
+ "出站参数必须恰好是服务拥有的 4 个键(inst_no / sign 归客户端补):" + sent);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 「1 级不填 {@code parent_id}」的判据是 **level**,不是「{@code parentId} 是不是 null」
|
|
|
+ * —— 与 {@link #areaList_level1_omitsParentCode} 逐字同一条规则(该页参数表写
|
|
|
+ * 「{@code parent_id} 否,当前等级的上级 code,**1 级不填**」)。
|
|
|
+ *
|
|
|
+ * <p>两种输入都要不填:① 三联动第一步的现实调用(传 null);
|
|
|
+ * ② 调用方误传了上级 code。若实现统一填 {@code ""},签名原文会多出一项
|
|
|
+ * {@code parent_id=}(本仓签名规则:null 不参与、**空串参与**),与文档示例对不上,
|
|
|
+ * 而失败只表现为上游一句笼统的「签名错误」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_level1_omitsParentId() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MCC_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ service.mccList("1", null, null);
|
|
|
+ service.mccList("1", "420100", null);
|
|
|
+
|
|
|
+ for (Map<String, Object> sent : allSentParams("/api/merchant/getTopMccByType", 2)) {
|
|
|
+ assertFalse(sent.containsKey("parent_id"),
|
|
|
+ "1 级不得上送 parent_id(连键都不该出现):" + sent);
|
|
|
+ assertEquals(Set.of("level", "merchant_type", "channel_type"), sent.keySet(),
|
|
|
+ "1 级出站参数必须恰好是 level + merchant_type + channel_type:" + sent);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code merchant_type} **本地不校验,原样透传**。
|
|
|
+ *
|
|
|
+ * <p>依据是计划 §0.5 第 4 行:该页的 {@code merchant_type} 值域
|
|
|
+ * ({@code 1个体工商户 2企业 3政府组织及事业单位 4小微商户 5其他机构组织})
|
|
|
+ * 与创建商户页那三处一致的值域**把 1 和 2 对调**了。该参数在该页是「否」(非必填)、
|
|
|
+ * 且说明列写「星驿付必传」⇒ **本层不知道谁对**,任何本地校验都是在挑一套猜。
|
|
|
+ *
|
|
|
+ * <p>夹具故意用一个**两套值域里都没有**的值({@code "9"}):若实现里加了白名单校验,
|
|
|
+ * 它会抛;若实现里加了「归一化/纠正」,它会被改写。断言的是「发出去的与传进来的逐字相同」,
|
|
|
+ * 这两类实现都过不了。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_doesNotValidateMerchantType() {
|
|
|
+ withOnboardingCredentials();
|
|
|
+ stubUpstream(MCC_LIST_EXAMPLE);
|
|
|
+
|
|
|
+ service.mccList("1", null, "9");
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/getTopMccByType");
|
|
|
+ assertEquals("9", sent.get("merchant_type"),
|
|
|
+ "merchant_type 必须原样透传,不得本地校验、不得归一化(两页值域互相冲突)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code mccList} 也走**凭据闸** —— 它要用 {@code channel_type},而那正是
|
|
|
+ * {@link LeshouyingProperties#missingOnboardingCredentials()} 五项之一。
|
|
|
+ *
|
|
|
+ * <p>为什么这条要单独立:闸的 javadoc 自己写着「三-B 四条腿共用一份」,理由是
|
|
|
+ * 「只在创建腿设闸,会造出『配置缺失时创建腿拒、更新腿**静默用空值上送**』的分叉」。
|
|
|
+ * {@code mccList} 是**第五条**用配置凭据的腿,所以它落进同一句话的适用面:
|
|
|
+ * 少了这道闸,配置缺失时它会把 {@code channel_type} 以 null 发出去
|
|
|
+ * (客户端剔 null ⇒ 报文里**没有**这个必填键),失败只在上游、本侧静默。
|
|
|
+ *
|
|
|
+ * <p>{@code merchant_type} 那一列不设闸是有意的:它由调用方给,不是配置凭据。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccList_missingCredentials_throws400WithoutCallingUpstream() {
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.mccList("1", null, null), "缺凭据时必须被拒");
|
|
|
+
|
|
|
+ assertEquals(400, ex.getCode(), "凭据缺失一律 400,实际: " + ex.getMessage());
|
|
|
+ assertCredentialsMessage(ex.getMessage(), 5);
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ---- 安心签两条(page 196236565 / 196236566)--------------------------
|
|
|
+
|
|
|
+ /** 夹具:联系电话(11 位、**一眼可辨是假号**,GC 5 要求秘密只打印长度、不落值)。 */
|
|
|
+ private static final String ANXIN_CONTACT_PHONE = "15800000001";
|
|
|
+
|
|
|
+ /** 夹具:身份证(18 位、连号形态,一眼可辨是假值)。公司版是「结算人身份证」。 */
|
|
|
+ private static final String ANXIN_PRINCIPAL_CERT_NO = "100000000000000003";
|
|
|
+
|
|
|
+ /** 夹具:结算卡号。 */
|
|
|
+ private static final String ANXIN_ACCOUNT_CARD_NUM = "6200000000000001";
|
|
|
+
|
|
|
+ /** 夹具:公司版的法人身份证 —— 与结算人身份证**必须不同值**,否则「取错 getter」不可见。 */
|
|
|
+ private static final String ANXIN_LEGAL_CERT_NO = "100000000000000001";
|
|
|
+
|
|
|
+ /** 个人版入参:五个字段,值各异(接错 getter 会让值对不上)。 */
|
|
|
+ private static LsyAnxinSignPersonDTO personDto() {
|
|
|
+ LsyAnxinSignPersonDTO dto = new LsyAnxinSignPersonDTO();
|
|
|
+ dto.setContactPhone(ANXIN_CONTACT_PHONE);
|
|
|
+ dto.setPrincipalCertNo(ANXIN_PRINCIPAL_CERT_NO);
|
|
|
+ dto.setAccountCardName("account_card_name");
|
|
|
+ dto.setAccountCardNum(ANXIN_ACCOUNT_CARD_NUM);
|
|
|
+ dto.setMerchantName("merchant_name");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 公司版入参:比个人版多四个字段({@code business_licence} / {@code merchant_address} / {@code legal_cert_no} / {@code legal_name})。 */
|
|
|
+ private static LsyAnxinSignCompanyDTO companyDto() {
|
|
|
+ LsyAnxinSignCompanyDTO dto = new LsyAnxinSignCompanyDTO();
|
|
|
+ dto.setContactPhone(ANXIN_CONTACT_PHONE);
|
|
|
+ dto.setPrincipalCertNo(ANXIN_PRINCIPAL_CERT_NO);
|
|
|
+ dto.setAccountCardName("account_card_name");
|
|
|
+ dto.setAccountCardNum(ANXIN_ACCOUNT_CARD_NUM);
|
|
|
+ dto.setMerchantName("merchant_name");
|
|
|
+ dto.setBusinessLicence("business_licence");
|
|
|
+ dto.setMerchantAddress("merchant_address");
|
|
|
+ dto.setLegalCertNo(ANXIN_LEGAL_CERT_NO);
|
|
|
+ dto.setLegalName("legal_name");
|
|
|
+ return dto;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ <b>用例名里的 {@code Seven} = 上游参数表的**行数**;断言的 {@code 5} = 服务拥有的**键集**。</b>
|
|
|
+ * 两者差 2,差的正是 {@code inst_no} 与 {@code sign} —— 它们由 {@link LeshouyingClient}
|
|
|
+ * 在客户端内部补,本类 mock 的正是那个客户端 ⇒ 服务层交出去的 map 里**永远**不含这两个键。
|
|
|
+ *
|
|
|
+ * <p>★ <b>名字不改,数字要解释清楚</b>(裁定 R44):本仓既有的数法就是「捕获集不含
|
|
|
+ * {@code inst_no} / {@code sign}」—— 先例逐字见
|
|
|
+ * {@code createShop_sendsExactlyTheSevenServiceOwnedParams} 的
|
|
|
+ * 「出站参数必须恰好是服务拥有的 7 个键({@code inst_no} / {@code sign} 归客户端补)」
|
|
|
+ * 与它的「【为什么不是上游入参表的 9 个键】」一段。名字与断言不一致时,
|
|
|
+ * **下一个人要靠这段 javadoc 才对得上**,所以这段是必须的、不是可选说明。
|
|
|
+ *
|
|
|
+ * <p>键集**相等**而不是 {@code contains}:多发的字段会进签名原文,上游可能因此判
|
|
|
+ * 「参数不合法」,而失败形态是一句笼统的话,根因不可见。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void anxinSignPerson_sendsAllSevenParams() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.anxinSignPerson(personDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/anxinSignPerson");
|
|
|
+ assertEquals(Set.of("contact_phone", "principal_cert_no", "account_card_name",
|
|
|
+ "account_card_num", "merchant_name"), sent.keySet(),
|
|
|
+ "出站参数必须恰好是服务拥有的 5 个键(inst_no / sign 归客户端补;"
|
|
|
+ + "用例名里的 7 是上游参数表的行数):" + sent);
|
|
|
+ assertEquals(ANXIN_CONTACT_PHONE, sent.get("contact_phone"));
|
|
|
+ assertEquals(ANXIN_PRINCIPAL_CERT_NO, sent.get("principal_cert_no"));
|
|
|
+ assertEquals("account_card_name", sent.get("account_card_name"));
|
|
|
+ assertEquals(ANXIN_ACCOUNT_CARD_NUM, sent.get("account_card_num"));
|
|
|
+ assertEquals("merchant_name", sent.get("merchant_name"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ <b>用例名里的 {@code Eleven} = 上游参数表的**行数**;断言的 {@code 9} = 服务拥有的**键集**</b>
|
|
|
+ * —— 差 2,同 {@link #anxinSignPerson_sendsAllSevenParams}({@code inst_no} / {@code sign} 归客户端补)。
|
|
|
+ *
|
|
|
+ * <p>比个人版多的正是四个键:{@code business_licence} / {@code merchant_address} /
|
|
|
+ * {@code legal_cert_no} / {@code legal_name}。这里**逐个断言值**、且四个值两两不同,
|
|
|
+ * 是为了让「公司版复用了个人版的组装、漏掉了多的那四个」与
|
|
|
+ * 「四个里取错了一个 getter」这两种实现都过不了集合相等这一关(漏键会红、
|
|
|
+ * 取错 getter 会让值对不上)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void anxinSignCompany_sendsAllElevenParams() {
|
|
|
+ stubUpstream(ok(""));
|
|
|
+
|
|
|
+ service.anxinSignCompany(companyDto());
|
|
|
+
|
|
|
+ Map<String, Object> sent = sentParams("/api/merchant/anxinSignCompany");
|
|
|
+ assertEquals(Set.of("contact_phone", "principal_cert_no", "account_card_name",
|
|
|
+ "account_card_num", "merchant_name", "business_licence",
|
|
|
+ "merchant_address", "legal_cert_no", "legal_name"), sent.keySet(),
|
|
|
+ "出站参数必须恰好是服务拥有的 9 个键(inst_no / sign 归客户端补;"
|
|
|
+ + "用例名里的 11 是上游参数表的行数):" + sent);
|
|
|
+ assertEquals("business_licence", sent.get("business_licence"));
|
|
|
+ assertEquals("merchant_address", sent.get("merchant_address"));
|
|
|
+ assertEquals(ANXIN_LEGAL_CERT_NO, sent.get("legal_cert_no"));
|
|
|
+ assertEquals("legal_name", sent.get("legal_name"));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ <b>这两条腿的入参是真人 PII</b>(身份证号 / 结算卡号 / 手机号),
|
|
|
+ * 所以「它们不得出现在任何日志里」必须是一条**可执行**的断言,不能只是注释里的约定
|
|
|
+ * (GC 5「秘密只打印长度,不打印值」)。
|
|
|
+ *
|
|
|
+ * <p>★ <b>本用例为什么必须用真客户端(R43):</b>要断言的出站 trace 行住在
|
|
|
+ * {@link LeshouyingClient#traceOutbound} 里,而本类其他地方 mock 的正是那个客户端
|
|
|
+ * ⇒ 在本类的常规装置下 {@code traceOutbound} **一行都不执行**,
|
|
|
+ * 于是「断言 PII 不在日志里」**恒绿** —— 把那条日志语句整个删掉它照样绿。
|
|
|
+ * 那是一个**空断言**。所以这里自建一套:真 {@link LeshouyingClient}({@code RestTemplate}
|
|
|
+ * 打桩,不出网)+ 真服务,让那条日志真的被执行。
|
|
|
+ *
|
|
|
+ * <p>★ <b>断言顺序不许颠倒</b>:<b>先</b>证明捕获装置确实捕到了那两条出站行
|
|
|
+ * (否则「日志里没有 PII」可能只是因为**根本没有日志**),<b>再</b>断言 PII 不在里面。
|
|
|
+ * 少了第 ① 步,本用例与「把日志删掉」这一种实现无法区分。
|
|
|
+ *
|
|
|
+ * <p>这里**不**断言「日志里一定有 {@code ***}」:脱敏的名单与口径归
|
|
|
+ * {@code LeshouyingSensitiveTest} 钉(那里跑的是真的 {@code maskByKey}),
|
|
|
+ * 本用例只负责「PII 值不出现在任何一行日志里」这一件事。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void anxinSign_doesNotLogPii() {
|
|
|
+ RestTemplate restTemplate = mock(RestTemplate.class);
|
|
|
+ when(restTemplate.postForEntity(any(String.class), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>(
|
|
|
+ "{\"result_code\":\"SUCCESS\",\"result_msg\":\"操作成功\"}", HttpStatus.OK));
|
|
|
+
|
|
|
+ LeshouyingProperties realProps = new LeshouyingProperties();
|
|
|
+ realProps.setBaseUrl("https://test.example.com/yfpay");
|
|
|
+ realProps.setInstNo("900112");
|
|
|
+ // ★ 占位密钥,**故意不是**任何真实凭据(与 LeshouyingClientApiEnvelopeTest 同款):
|
|
|
+ // 签名器对 key 只做拼接,不校验形状,所以本用例跑得通而不必把真密钥抄进测试文件。
|
|
|
+ realProps.setKey("t5-placeholder-key-not-a-credential");
|
|
|
+ LeshouyingClient realClient = new LeshouyingClient(restTemplate, new LeshouyingSigner(),
|
|
|
+ new LeshouyingResponseParser(), realProps);
|
|
|
+ LsyOnboardingService realService = new LsyOnboardingService(realClient, realProps);
|
|
|
+
|
|
|
+ LsyAnxinSignPersonDTO person = personDto();
|
|
|
+ LsyAnxinSignCompanyDTO company = companyDto();
|
|
|
+
|
|
|
+ withTraceLogCapture(LeshouyingClient.class, events -> {
|
|
|
+ realService.anxinSignPerson(person);
|
|
|
+ realService.anxinSignCompany(company);
|
|
|
+
|
|
|
+ List<String> msgs = messages(events);
|
|
|
+
|
|
|
+ // ① 正对照:捕获装置**确实捕到了**那两条出站行。没有这一步,下面的断言是空断言。
|
|
|
+ assertTrue(msgs.stream().anyMatch(m -> m.contains("/api/merchant/anxinSignPerson")),
|
|
|
+ "没捕到 anxinSignPerson 的出站 trace 行 ⇒ 下面的 PII 断言是空断言。实际捕获: " + msgs);
|
|
|
+ assertTrue(msgs.stream().anyMatch(m -> m.contains("/api/merchant/anxinSignCompany")),
|
|
|
+ "没捕到 anxinSignCompany 的出站 trace 行 ⇒ 下面的 PII 断言是空断言。实际捕获: " + msgs);
|
|
|
+
|
|
|
+ // ② 再断言 PII 值不在任何一行日志里。逐行 × 逐值,命中即报出那一行。
|
|
|
+ for (String pii : new String[]{ANXIN_CONTACT_PHONE, ANXIN_PRINCIPAL_CERT_NO,
|
|
|
+ ANXIN_ACCOUNT_CARD_NUM, ANXIN_LEGAL_CERT_NO}) {
|
|
|
+ for (String msg : msgs) {
|
|
|
+ assertFalse(msg.contains(pii),
|
|
|
+ "出站日志里出现了 PII(GC 5):" + pii + " —— 该行: " + msg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
|
|
|
* —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,
|
|
|
@@ -2516,6 +2967,32 @@ class LsyOnboardingServiceTest {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 与 {@link #withLogCapture} 同款,但**把该 logger 的级别临时拨到 TRACE**、跑完必定还原。
|
|
|
+ *
|
|
|
+ * <p>为什么必须拨:出站那行 trace 日志的前置条件是 {@code log.isTraceEnabled()}
|
|
|
+ * (见 {@code LeshouyingClient.traceOutbound}),而 logback 在**事件到达 appender 之前**
|
|
|
+ * 就按 logger 的**有效级别**过滤掉了 ⇒ 级别还是 INFO 时,挂 appender 也捕不到任何
|
|
|
+ * TRACE 事件。不拨级别的话,{@code anxinSign_doesNotLogPii} 的**正对照**会红
|
|
|
+ * —— 而那是好事:正对照的存在就是为了让这种「装置其实没在工作」当场暴露,
|
|
|
+ * 而不是让主断言在空转中恒绿。
|
|
|
+ *
|
|
|
+ * <p>★ 与生产配置是有意对齐的:{@code application.yml} 里 {@code com.payment.platform}
|
|
|
+ * 是 {@code info}(TRACE 默认关)⇒ 这条日志**今天不会输出**;拨到 TRACE 模拟的正是
|
|
|
+ * 「联调时开 TRACE」这个预期动作 —— 而这条日志的唯一存在理由就是调试。
|
|
|
+ * <b>还原是必需的</b>:级别是 logger 上的共享状态,不还原会改变后续用例的日志行为。
|
|
|
+ */
|
|
|
+ private static void withTraceLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
+ Level previous = logger.getLevel();
|
|
|
+ logger.setLevel(Level.TRACE);
|
|
|
+ try {
|
|
|
+ withLogCapture(type, body);
|
|
|
+ } finally {
|
|
|
+ logger.setLevel(previous);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
/** 断言失败时把实际捕获到的日志正文全列出来 —— 否则只看到「没匹配上」,看不到实际打了什么。 */
|
|
|
private static List<String> messages(List<ILoggingEvent> events) {
|
|
|
return events.stream().map(ILoggingEvent::getFormattedMessage).toList();
|