|
|
@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
import com.fasterxml.jackson.databind.JavaType;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import com.payment.platform.common.config.JacksonConfig;
|
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
|
@@ -15,6 +16,7 @@ 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.LsyMccItemDTO;
|
|
|
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;
|
|
|
@@ -41,6 +43,7 @@ import org.springframework.security.access.prepost.PreAuthorize;
|
|
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
|
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
|
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
import org.springframework.web.bind.annotation.GetMapping;
|
|
|
import org.springframework.web.bind.annotation.PostMapping;
|
|
|
@@ -100,12 +103,15 @@ class LeshouyingOnboardingControllerTest {
|
|
|
|
|
|
private static final String MCH_NO = "Y52211090004";
|
|
|
|
|
|
- /** 四个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
|
|
|
+ /** 六个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
|
|
|
private static final String PREFIX = "/payment/collection/onboarding";
|
|
|
|
|
|
/** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
|
private static final String AREAS_PATH = PREFIX + "/areas";
|
|
|
|
|
|
+ /** 行业类目只读端点的完整路径(加记 35 §二 裁定 R-T9f′ 新增的第六个端点)。 */
|
|
|
+ private static final String MCC_PATH = PREFIX + "/mcc";
|
|
|
+
|
|
|
// ---- 资质图片上传端点(加记 33)用的夹具 ----
|
|
|
|
|
|
/** 上传端点的完整路径。 */
|
|
|
@@ -818,6 +824,327 @@ class LeshouyingOnboardingControllerTest {
|
|
|
verify(onboardingService).areaList("1", " ");
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * ★★ 这两条 400 文案**逐字**钉住 —— 这次是**故意的措辞钉子**(与上面那条
|
|
|
+ * 「钉的是口径而不是措辞」不同,理由见下)。
|
|
|
+ *
|
|
|
+ * <p>【为什么这里必须钉措辞】{@code /areas} 与 {@code /mcc} **共用同一对 helper**
|
|
|
+ * ({@code requireLevelInWhitelist} / {@code requireParentForSubLevels}),
|
|
|
+ * 两条腿的文案差别**只来自调用点传进来的参数**。于是「改 helper 时把 {@code /areas}
|
|
|
+ * 那句话改坏了」是一种真实且**不留痕迹**的缺陷:两条腿都还在 400、都还在说人话,
|
|
|
+ * 只是 {@code /areas} 的文案不再说行政区划。
|
|
|
+ *
|
|
|
+ * <p>★ <b>实测(2026-09-23):这件事此前没有任何用例约束。</b>
|
|
|
+ * 既有那两条用例——{@code areasEndpoint_rejectsLevelOutsideWhitelist_withoutTouchingService}
|
|
|
+ * 与 {@code areasEndpoint_rejectsMissingOrBlankParentCode_whenLevelNeedsIt}——
|
|
|
+ * 前者**一个字的文案都没钉**(只有 {@code $.code == 400}),后者只钉了
|
|
|
+ * {@code containsString("parentCode")}(参数名,不是那句话)。
|
|
|
+ * ⇒ 把括号里那截改掉,全类**仍绿**。本条补上这个约束:它就是那次参数化重构的正对照。
|
|
|
+ *
|
|
|
+ * <p>★ 为什么不改那两条既有断言:它们各自钉着别的东西(非法值集合、零接触、正对照),
|
|
|
+ * 动它们等于把那些证据一起动掉。这里**只加不减**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areasEndpoint_messagesArePinnedVerbatim() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ // ★ 同下:带上合法的上级参,让 level 那条护栏成为**唯一**能触发的那个(见
|
|
|
+ // {@code mccEndpoint_levelMessageNamesIndustryLevels_…} 里那条注释的完整理由)。
|
|
|
+ assertEquals("level 只接受 1、2、3(分别表示省/直辖市、市/州、区/县)",
|
|
|
+ rejectionMessage(get(AREAS_PATH).param("level", "4").param("parentCode", "1")),
|
|
|
+ "/areas 的 level 文案被改坏了(两个端点共用 helper ⇒ 这条是它的正对照)");
|
|
|
+ assertEquals("level 为 2、3 时必须提供 parentCode(上一级选中项的区域编码,不是 GB 行政码)",
|
|
|
+ rejectionMessage(get(AREAS_PATH).param("level", "2")),
|
|
|
+ "/areas 的 parentCode 文案被改坏了(同上)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ──────────────── 行业类目端点(加记 35 §二/§三 裁定 R-T9f′) ────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 权限串**逐字**是 {@code :create}(同 {@link #areasEndpoint_reusesTheCreatePermissionStringVerbatim}
|
|
|
+ * 的理由:超管直通 ⇒ 「超管跑一遍能过」证明不了任何权限串)。
|
|
|
+ *
|
|
|
+ * <p>【为什么必须是 {@code :create}】行业类目是建号表单里「商户行业类目 code」那一格的**唯一**
|
|
|
+ * 取值来源,而 {@code merchant_mcc_code} 在必填矩阵的 {@code UNCONDITIONAL} 里
|
|
|
+ * ⇒ **每个商户类别都必填**。只有 {@code :list} 的角色拿到类目也没用,而只有 {@code :create}
|
|
|
+ * 的角色**必须**能拿到 ⇒ 挂 {@code :create} 既必要又充分。复用就**不新增第六个权限串**,
|
|
|
+ * Task 8 已交付的菜单 SQL 一个字都不用改。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_reusesTheCreatePermissionStringVerbatim() throws Exception {
|
|
|
+ PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(mccMethod(), PreAuthorize.class);
|
|
|
+
|
|
|
+ assertNotNull(a, "mcc 端点缺 @PreAuthorize —— 端点会变成任何登录用户可调");
|
|
|
+ assertEquals("@perm.hasAny('" + LeshouyingOnboardingController.PERM_ONBOARDING_CREATE + "')",
|
|
|
+ a.value(),
|
|
|
+ "权限串必须逐字复用 :create(不新增第六个权限串)。实测: " + a.value());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 路由 + **三个线参名**(同名同序)。
|
|
|
+ *
|
|
|
+ * <p>★ 为什么形参名取 {@code parentId} 而**不是** {@code parentCode}:上游两页表达同一件事
|
|
|
+ * 用了两个名字(「获取省市区」页是 {@code parent_code}、「获取行业类目」页是 {@code parent_id}),
|
|
|
+ * 而键名进**签名原文** ⇒ 两条腿必须各自与该页同形。为了「和 /areas 一致」而改名,
|
|
|
+ * 就等于把上游的不一致在我们这边抹平 —— 症状是上游回一句笼统的「参数不合法」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_isGetOnMcc_andBindsTheThreeWireParamNames() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ Method mcc = mccMethod();
|
|
|
+
|
|
|
+ GetMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(mcc, GetMapping.class);
|
|
|
+ assertNotNull(methodMapping, "mcc 端点必须是 GET(它是只读查询)");
|
|
|
+ assertEquals(List.of("/mcc"), declaredPaths(methodMapping.value(), methodMapping.path()),
|
|
|
+ "方法级路径必须是 /mcc。实测: " + declaredPaths(methodMapping.value(), methodMapping.path()));
|
|
|
+
|
|
|
+ RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(
|
|
|
+ LeshouyingOnboardingController.class, RequestMapping.class);
|
|
|
+ assertEquals(List.of(PREFIX), declaredPaths(classMapping.value(), classMapping.path()),
|
|
|
+ "类级前缀(完整路径 = 前缀 + /mcc)。实测: "
|
|
|
+ + declaredPaths(classMapping.value(), classMapping.path()));
|
|
|
+
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ for (Parameter p : mcc.getParameters()) {
|
|
|
+ names.add(p.getName());
|
|
|
+ RequestParam ann = p.getAnnotation(RequestParam.class);
|
|
|
+ assertNotNull(ann, p.getName() + " 缺 @RequestParam");
|
|
|
+ assertEquals("", ann.name(), p.getName() + " 的 @RequestParam 写了 name ⇒ 线参名会被改掉");
|
|
|
+ assertEquals("", ann.value(), p.getName() + " 的 @RequestParam 写了 value ⇒ 线参名会被改掉");
|
|
|
+ }
|
|
|
+ assertEquals(List.of("level", "parentId", "merchantType"), names,
|
|
|
+ "形参名(= 线参名)与顺序。实测: " + names);
|
|
|
+
|
|
|
+ // 实测那一半:只发这三个名字,必须被受理(且真的把三个实参交给了服务)
|
|
|
+ when(onboardingService.mccList("2", "A0001", "2")).thenReturn(List.of());
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "2").param("parentId", "A0001").param("merchantType", "2"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ verify(onboardingService).mccList("2", "A0001", "2");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 三个参数**逐字**到达 service,且**顺序**正确({@code level} / {@code parentId} / {@code merchantType})。
|
|
|
+ *
|
|
|
+ * <p>精确实参的 {@code verify} 同时钉住「值逐字」与「位置」—— 只断言「被调用过」的话,
|
|
|
+ * 三个参数互换位置也照样绿。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_passesAllThreeParamsThrough_verbatimAndInOrder() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.mccList("2", "A0001", "2")).thenReturn(List.of());
|
|
|
+
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "2").param("parentId", "A0001").param("merchantType", "2"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ verify(onboardingService).mccList("2", "A0001", "2");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ {@code merchantType} **缺省与空白都传 {@code null}**(**不是**空串)。
|
|
|
+ *
|
|
|
+ * <p>【依据是本仓写死的那条签名规则】「{@code null} 不参与、**空串参与**」——
|
|
|
+ * 服务层把 {@code merchant_type} **无条件**放进参数 Map,而客户端只剔 {@code null}。
|
|
|
+ * ⇒ 传空串会照样上送 {@code merchant_type=},那一项**进签名原文**。三个形态
|
|
|
+ * (不发 / 空串 / 纯空白)在这里都必须是同一件事。
|
|
|
+ *
|
|
|
+ * <p>★ 本条只用**一个**桩({@code mccList("1", null, null)})配合 {@code times(3)}:
|
|
|
+ * 三次调用若有一次传的不是 {@code null},Mockito 会以「多余的/不匹配的调用」红,
|
|
|
+ * 于是「三个形态都归成 null」是一条**无歧义**的读数。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_merchantTypeAbsentOrBlank_reachesServiceAsNull() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.mccList("1", null, null)).thenReturn(List.of());
|
|
|
+
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1").param("merchantType", ""))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1").param("merchantType", " "))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ verify(onboardingService, times(3)).mccList("1", null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code level} 缺省 / 非法 ⇒ **400,不是 500**(与 {@code /areas} 同一条白名单)。
|
|
|
+ *
|
|
|
+ * <p>★ <b>「整个参数不在」必须单独成条</b>:{@code required = false} + 白名单兜底
|
|
|
+ * 是**有意**的写法 —— 2026-09-23 实测本仓 {@code GlobalExceptionHandler} 没有
|
|
|
+ * {@code MissingServletRequestParameterException} 分支,必填参数缺失会落进兜底分支
|
|
|
+ * ⇒ HTTP 500「服务器内部错误」(裁定 R-T9a-b:缺席也必须是 400)。
|
|
|
+ * 若哪天有人把 {@code required = false} 去掉,这一条是唯一会红的地方。
|
|
|
+ *
|
|
|
+ * <p>★ <b>与 brief ③-step1 第 5 条「{@code level} 缺省是 {@code "1"}」实测不符</b>
|
|
|
+ * —— 见 {@code task-9b-report.md} 的偏离记录:brief 同一句的括号里又要求
|
|
|
+ * 「照抄 {@code /areas} 的既有行为」,而 {@code /areas} 对缺席 {@code level} 是 **400**
|
|
|
+ * (由 {@code areasEndpoint_rejectsAbsentLevel_withoutTouchingService} 与裁定 R-T9a-b 钉着)。
|
|
|
+ * 两半互相矛盾,取「与 /areas 同形」那半(也是安全的那半:不给 {@code defaultValue}
|
|
|
+ * 只会更严,不会静默按 level=1 返回一级行业)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_rejectsAbsentOrIllegalLevel_withoutTouchingService() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ // 整个参数不在(★ 这一枪必须 400;500 就是 `required` 被改坏了)
|
|
|
+ mockMvc.perform(get(MCC_PATH))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+
|
|
|
+ for (String bad : List.of("0", "4", "9", "", " 1", "1 ", "01", "1.0", "ABC", "一")) {
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", bad).param("parentId", "A0001"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 反例先行 ⇒ 这是一条**无歧义**的零接触读数
|
|
|
+ verifyNoInteractions(onboardingService, merchantService);
|
|
|
+
|
|
|
+ // 正对照:同形请求 + 合法 level ⇒ 受理(否则上面那些 400 可能只是「压根没路由到这个方法」)
|
|
|
+ when(onboardingService.mccList("3", "A0001", null)).thenReturn(List.of());
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "3").param("parentId", "A0001"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ verify(onboardingService).mccList("3", "A0001", null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code parentId} 的**两向**:{@code level="2"} 给了就原样到达;{@code level="1"} 也**原样转交**。
|
|
|
+ *
|
|
|
+ * <p>【为什么 level=1 也要转交,而不是在这里擦成 null】「{@code level="1"} 不上送
|
|
|
+ * {@code parent_id}」是**服务层**的规则(判据是 {@code level},不是「{@code parentId}
|
|
|
+ * 是不是 null」)。控制器再抄一份判断,就有了两份会各自漂移的规则 ——
|
|
|
+ * 与 {@code /areas} 的 {@code areasEndpoint_allowsLevel1_withoutParentCode} 逐字同一条理由。
|
|
|
+ * 那条规则由服务层自己的用例钉着,这里钉的是「端点**不做**那个判断」。
|
|
|
+ *
|
|
|
+ * <p>★ 缺口那一半也钉:{@code level="1"} 且**不给** {@code parentId} ⇒ 到达的是 {@code null}
|
|
|
+ * (不是空串)—— 端点不归一化,没给就是没给。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_parentIdIsPassedThroughVerbatim_evenWhenLevel1() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.mccList("2", "A0001", null)).thenReturn(List.of());
|
|
|
+ when(onboardingService.mccList("1", "A0001", null)).thenReturn(List.of());
|
|
|
+ when(onboardingService.mccList("1", null, null)).thenReturn(List.of());
|
|
|
+
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "2").param("parentId", "A0001"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1").param("parentId", "A0001"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1"))
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
+
|
|
|
+ verify(onboardingService).mccList("2", "A0001", null);
|
|
|
+ verify(onboardingService).mccList("1", "A0001", null);
|
|
|
+ verify(onboardingService).mccList("1", null, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 返回形状:{@code $.data} 是数组,元素**恰好**含 {@code mcc_name} / {@code mcc_code} 两个键。
|
|
|
+ *
|
|
|
+ * <p>「恰好两个键」是**结构性**的钉子:只断言这两个键在不在,是把上游的原始 map
|
|
|
+ * 直接回出去也照样绿(多出来的键没人管)。所以这里把键集收成 {@code Set} 做**相等**断言。
|
|
|
+ * 全局 {@code SNAKE_CASE} ⇒ 线上键是 {@code mcc_name} / {@code mcc_code}。
|
|
|
+ *
|
|
|
+ * <p>★ 这两个键名是**白名单里的有意例外**(判定见
|
|
|
+ * {@link #noEndpointReturnsUpstreamOnlyFields} 里那两查的读数):MCC 是通用行业词汇
|
|
|
+ * (Merchant Category Code),不含上游身份;且 {@code mcc_code} 早就由**另一个模块**的
|
|
|
+ * 既有端点发布给租户了({@code FacetofaceController.apply} 的 {@code F2fOrderCreateDTO.mccCode},
|
|
|
+ * 文档标签「MCC码」)。不是漏扫。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_returnsArrayOfExactlyMccNameAndMccCode() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ LsyMccItemDTO item = new LsyMccItemDTO();
|
|
|
+ item.setMccName("商业及生活服务");
|
|
|
+ item.setMccCode("A0001");
|
|
|
+ when(onboardingService.mccList("1", null, null)).thenReturn(List.of(item));
|
|
|
+
|
|
|
+ String body = mockMvc.perform(get(MCC_PATH).param("level", "1"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data").isArray())
|
|
|
+ .andExpect(jsonPath("$.data[0].mcc_name").value("商业及生活服务"))
|
|
|
+ .andExpect(jsonPath("$.data[0].mcc_code").value("A0001"))
|
|
|
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ JsonNode first = new JacksonConfig().objectMapper().readTree(body).get("data").get(0);
|
|
|
+ Set<String> keys = new TreeSet<>();
|
|
|
+ first.fieldNames().forEachRemaining(keys::add);
|
|
|
+ assertEquals(Set.of("mcc_code", "mcc_name"), keys,
|
|
|
+ "应答元素必须**恰好**两个键(多一个键就是「把上游原始应答直接回出去」的形状)。实测: " + keys);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** ★ 上游回空列表 ⇒ 成功 + 空数组(不是 404、不是 500)—— 口径同 {@code /areas}。 */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_emptyList_isSuccessWithEmptyArray() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.mccList("1", null, null)).thenReturn(List.of());
|
|
|
+
|
|
|
+ mockMvc.perform(get(MCC_PATH).param("level", "1"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
+ .andExpect(jsonPath("$.data").isArray())
|
|
|
+ .andExpect(jsonPath("$.data").isEmpty());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ 同一对 helper 在**这条腿上**说过的话必须换掉行政区划那套词。
|
|
|
+ *
|
|
|
+ * <p>行业类目的 {@code 1/2/3} 是**一/二/三级行业**,不是「省/直辖市、市/州、区/县」——
|
|
|
+ * 对着行业类目说行政区划,会让调用方以为自己选错了参数。文案经
|
|
|
+ * {@code GlobalExceptionHandler} **原样**回给调用方,所以这是到达租户的字符串。
|
|
|
+ *
|
|
|
+ * <p>★ 判据是「**改用我们自己发布的那套词**」而不是「说人话就行」:{@code /areas} 那句话是
|
|
|
+ * 给区划的,{@code /mcc} 这句是给行业的,两句都必须点名自己那条腿的语义。
|
|
|
+ * 反方向({@code /areas} 那句没被改坏)由 {@link #areasEndpoint_messagesArePinnedVerbatim} 钉着。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_levelMessageNamesIndustryLevels_notAdministrativeDivisions() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ // ★ 带上一个**合法**的 parentId:本条钉的是「level 那句文案」,
|
|
|
+ // 不是「两条护栏谁先跑」。不带 parentId 的话 level="4" 会让**两条**护栏都成立,
|
|
|
+ // 于是换一下调用顺序本条就红了 —— 那是把实现顺序当成契约钉住(不是本条要钉的东西)。
|
|
|
+ String msg = rejectionMessage(get(MCC_PATH).param("level", "4").param("parentId", "A0001"));
|
|
|
+
|
|
|
+ assertEquals("level 只接受 1、2、3(分别表示一级行业、二级行业、三级行业)", msg,
|
|
|
+ "行业类目的 1/2/3 是一/二/三级行业。实测: " + msg);
|
|
|
+ assertFalse(msg.contains("省/直辖市"),
|
|
|
+ "/mcc 的文案里残留了行政区划那套词(那是 /areas 的语义)。实测: " + msg);
|
|
|
+ verifyNoInteractions(onboardingService, merchantService);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★★ 本腿的上级参叫 {@code parentId}(**我们自己发布的线参名**),不是 {@code parentCode}、
|
|
|
+ * 更不是上游那个键名。
|
|
|
+ *
|
|
|
+ * <p>两件事一起钉:① 文案必须点名 {@code parentId}(调用方才知道该填哪个参数);
|
|
|
+ * ② 不得出现 {@code parent_code}(那是「获取省市区」页的键名)与「GB 行政码」那套语义
|
|
|
+ * —— 本腿填的是**行业类目编码**。Global Constraint 1 的判据里,
|
|
|
+ * 「我们自己发布的名字可以用、上游键名不可以」是写死了的。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void mccEndpoint_parentMessageNamesParentId_notParentCode() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+
|
|
|
+ String msg = rejectionMessage(get(MCC_PATH).param("level", "2"));
|
|
|
+
|
|
|
+ assertEquals("level 为 2、3 时必须提供 parentId(上一级选中项的行业类目编码)", msg,
|
|
|
+ "本腿的线参名是 parentId、值是行业类目编码。实测: " + msg);
|
|
|
+ assertTrue(msg.contains("parentId"),
|
|
|
+ "文案必须点名我们自己发布的那三个线参之一。实测: " + msg);
|
|
|
+ assertFalse(msg.contains("parent_code"),
|
|
|
+ "文案里出现了上游那个键名(Global Constraint 1)。实测: " + msg);
|
|
|
+ assertFalse(msg.contains("GB 行政码"),
|
|
|
+ "/mcc 的文案里残留了行政区划那套语义。实测: " + msg);
|
|
|
+ verifyNoInteractions(onboardingService, merchantService);
|
|
|
+ }
|
|
|
+
|
|
|
// ──────────────── 资质图片上传端点(加记 33 裁定 R-T9c) ────────────────
|
|
|
|
|
|
/**
|
|
|
@@ -1142,9 +1469,9 @@ class LeshouyingOnboardingControllerTest {
|
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
|
|
|
List<Method> mapped = mappedMethods();
|
|
|
- assertEquals(5, mapped.size(),
|
|
|
- "本控制器应恰好五个端点(三个权限串 → 五个端点:/areas 与 /upload-image 都复用 :create,"
|
|
|
- + "加记 29 §二 裁定 R-T9a 与加记 33)。实测: "
|
|
|
+ assertEquals(6, mapped.size(),
|
|
|
+ "本控制器应恰好六个端点(三个权限串 → 六个端点:/areas、/upload-image、/mcc 都复用 :create,"
|
|
|
+ + "加记 29 §二 裁定 R-T9a、加记 33、加记 35 §二 裁定 R-T9f′)。实测: "
|
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
|
|
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
|
@@ -1201,10 +1528,37 @@ class LeshouyingOnboardingControllerTest {
|
|
|
"review_status", "status", "description", "created_time", "updated_time",
|
|
|
// LsyAreaItemDTO 的五个属性(按 SNAKE_CASE 展开)。★ 按上面那两查它们**不是**
|
|
|
// 「上游专有」:① 建表语句里没有这五列(在 src/main/resources/db 的 23 个 .sql 上
|
|
|
- // 量得 0 命中;同一把量具上 merchant_name 命中 3 处,可作正对照),
|
|
|
+ // 量得 0 命中;同一把量具上 merchant_name 命中 3 行 / 2 个文件,可作正对照
|
|
|
+ // —— 单位写全:`grep -ril … | wc -l` 得 2,`grep -rin … | wc -l` 得 3),
|
|
|
// ② 但 **/areas 这个端点把它们发布给了租户** ⇒ 落进 R32 那句
|
|
|
// 「我们对外发布的名字,不可能是「上游专有」的名字」。
|
|
|
- "area_name", "area_code", "province_code", "city_code", "county_code"));
|
|
|
+ "area_name", "area_code", "province_code", "city_code", "county_code",
|
|
|
+ // LsyMccItemDTO 的两个属性(加记 35 §二)。★ 与上面那五个**同一条依据**,
|
|
|
+ // 但这里把两查的原样读数写全(2026-09-23 量,命令与读数成对):
|
|
|
+ // 查① 建表语句有没有这一列 ——
|
|
|
+ // `find . -name '*.sql' | wc -l` = 23;
|
|
|
+ // `grep -ril 'mcc_code' --include='*.sql' . | wc -l` = **0**;
|
|
|
+ // `grep -ril 'mcc_name' …` = **0**;
|
|
|
+ // 正对照(同一把量具、同一目录):`grep -ril 'merchant_name' … | wc -l` = 2 个文件
|
|
|
+ // (明细:V1.8__create_pay_lsy_tables.sql:18 列定义 + :34 列注释、
|
|
|
+ // lsy-merchant-seed-test.sql:40 插入列表)⇒ 量具确实能把存在的列数出来。
|
|
|
+ // ⇒ ① 成立(这两列确实不在我们库里)。
|
|
|
+ // 查② 有没有对外 DTO/实体把它回给租户 —— **不成立**,两处:
|
|
|
+ // (a) 本端点自己就把它回给租户:LsyMccItemDTO 是我们**发布**的字段名,
|
|
|
+ // 与上面五个 area_* 逐字同一条理由;
|
|
|
+ // (b) 一个**独立的、早于本批次的**旁证:`mcc_code` 早在 2026-06-30
|
|
|
+ // (commit 4d2cac6)就已经是租户可见的线参名 —— 另一个模块的既有端点
|
|
|
+ // `FacetofaceController.apply`(第 56 行,`@RequestBody`)用
|
|
|
+ // `F2fOrderCreateDTO.mccCode`(第 41 行,标签「商家经营类目编码,如 A0001_B0199」、
|
|
|
+ // `@NotBlank(message = "MCC码不能为空")`)收这一项。全 main 侧 `grep -rn 'mccCode'`
|
|
|
+ // 只有两处命中,另一处就是它自己。
|
|
|
+ // ★ 说清楚这句旁证**证到哪**:它证的是「这个键名不是上游专有的」(我们产品
|
|
|
+ // 在别的腿上也用这个名字称呼同一件东西),**不是**在说它满足②字面的
|
|
|
+ // 「回给租户」(那条腿是**收**不是回)。② 被证伪靠的是 (a)。
|
|
|
+ // ⇒ 两条查都指向「不是上游专有」⇒ 进白名单。这是**有意的例外**,不是漏扫。
|
|
|
+ // ★ 次要理由(不作判据):MCC = Merchant Category Code,行业通用缩写,
|
|
|
+ // 不含任何上游身份信息 —— 但判据始终是上面那两查,不是「听起来通用」。
|
|
|
+ "mcc_name", "mcc_code"));
|
|
|
|
|
|
Set<String> seen = new TreeSet<>();
|
|
|
for (Method m : mappedMethods()) {
|
|
|
@@ -1225,6 +1579,10 @@ class LeshouyingOnboardingControllerTest {
|
|
|
assertTrue(seen.size() >= 15, "收集到的字段名太少(" + seen.size() + ")。实测: " + seen);
|
|
|
assertTrue(seen.contains("area_code") && seen.contains("county_code"),
|
|
|
"/areas 的应答字段名没收进 seen —— 那五个白名单条目就成了摆设。实测: " + seen);
|
|
|
+ // 同上,加记 35 §二 的两个 —— `/mcc` 返回的是 Result<List<LsyMccItemDTO>>,
|
|
|
+ // 元素那一层要递归下沉才收得到;收不到的话这两个白名单条目同样是摆设。
|
|
|
+ assertTrue(seen.contains("mcc_code") && seen.contains("mcc_name"),
|
|
|
+ "/mcc 的应答字段名没收进 seen —— 那两个白名单条目成了摆设(递归下沉漏了集合元素?)。实测: " + seen);
|
|
|
|
|
|
Set<String> extra = new TreeSet<>(seen);
|
|
|
extra.removeAll(allowed);
|
|
|
@@ -1244,6 +1602,33 @@ class LeshouyingOnboardingControllerTest {
|
|
|
return LeshouyingOnboardingController.class.getDeclaredMethod("uploadImage", MultipartFile.class);
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 行业类目端点的方法本身。三个形参都是 {@code String}({@code level} / {@code parentId} /
|
|
|
+ * {@code merchantType}),{@code getDeclaredMethod} 按类型列表定位 ⇒ 形参**顺序**也在这里被钉住。
|
|
|
+ */
|
|
|
+ private static Method mccMethod() throws NoSuchMethodException {
|
|
|
+ return LeshouyingOnboardingController.class.getDeclaredMethod(
|
|
|
+ "mcc", String.class, String.class, String.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 把一次拒绝({@code BusinessException} ⇒ HTTP 200 + {@code $.code})的**文案**取出来。
|
|
|
+ *
|
|
|
+ * <p>为什么不直接 {@code assertThrows(BusinessException.class, …)}:走 MockMvc 才经过
|
|
|
+ * {@code GlobalExceptionHandler},而**文案是它原样回给调用方的**({@code Result.of(code, msg)})
|
|
|
+ * —— 断言的对象必须是「调用方真会看到的那串字符」,不是异常对象里的字段。
|
|
|
+ *
|
|
|
+ * <p>★ 必须用带编码的 {@code getContentAsString(UTF_8)}:不带参的那个方法按 ISO-8859-1 解,
|
|
|
+ * 中文会变成 {@code ?} ⇒ 中文子串断言**恒真**(2026-09-23 实测记录在案)。
|
|
|
+ */
|
|
|
+ private String rejectionMessage(MockHttpServletRequestBuilder req) throws Exception {
|
|
|
+ String body = mockMvc.perform(req)
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
+ .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);
|
|
|
+ return new JacksonConfig().objectMapper().readTree(body).get("msg").asText();
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* 注解上声明的路径。{@code value} 与 {@code path} 互为别名,而
|
|
|
* {@code AnnotatedElementUtils.findMergedAnnotation} 会把**两者都填上**同一个值
|
|
|
@@ -1350,6 +1735,7 @@ class LeshouyingOnboardingControllerTest {
|
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
|
() -> controller.commit(commitBody(MCH_NO)),
|
|
|
() -> controller.areas("1", null),
|
|
|
+ () -> controller.mcc("1", null, null),
|
|
|
() -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
|
|
|
}
|
|
|
|