|
@@ -11,6 +11,7 @@ import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
import com.payment.platform.common.response.PageResult;
|
|
import com.payment.platform.common.response.PageResult;
|
|
|
import com.payment.platform.core.security.LoginUser;
|
|
import com.payment.platform.core.security.LoginUser;
|
|
|
|
|
+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.LsyCommitMerchantInfoDTO;
|
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
|
|
|
import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
|
|
import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
|
|
@@ -36,9 +37,12 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticatio
|
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
import org.springframework.security.core.context.SecurityContextHolder;
|
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
import org.springframework.test.web.servlet.MockMvc;
|
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
|
|
|
|
+import org.springframework.web.bind.annotation.GetMapping;
|
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
|
|
+import org.springframework.web.bind.annotation.RequestParam;
|
|
|
|
|
|
|
|
import java.lang.reflect.Method;
|
|
import java.lang.reflect.Method;
|
|
|
|
|
+import java.lang.reflect.Parameter;
|
|
|
import java.lang.reflect.ParameterizedType;
|
|
import java.lang.reflect.ParameterizedType;
|
|
|
import java.lang.reflect.Type;
|
|
import java.lang.reflect.Type;
|
|
|
import java.util.ArrayList;
|
|
import java.util.ArrayList;
|
|
@@ -54,6 +58,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.Mockito.*;
|
|
import static org.mockito.Mockito.*;
|
|
|
|
|
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
|
@@ -85,9 +90,12 @@ class LeshouyingOnboardingControllerTest {
|
|
|
|
|
|
|
|
private static final String MCH_NO = "Y52211090004";
|
|
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";
|
|
private static final String PREFIX = "/payment/collection/onboarding";
|
|
|
|
|
|
|
|
|
|
+ /** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
|
|
|
|
|
+ private static final String AREAS_PATH = PREFIX + "/areas";
|
|
|
|
|
+
|
|
|
@Mock private LsyMerchantService merchantService;
|
|
@Mock private LsyMerchantService merchantService;
|
|
|
@Mock private LsyOnboardingService onboardingService;
|
|
@Mock private LsyOnboardingService onboardingService;
|
|
|
|
|
|
|
@@ -349,21 +357,23 @@ class LeshouyingOnboardingControllerTest {
|
|
|
/**
|
|
/**
|
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
|
*
|
|
*
|
|
|
- * <p>三个端点一起钉:{@code page} 不带 {@code mchNo},但它同样是「以租户为范围」的读 ——
|
|
|
|
|
- * 一个没有租户的主体在 {@code TenantInnerInterceptor} 下**不会被追加任何谓词**
|
|
|
|
|
- * (Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
|
|
|
|
|
|
|
+ * <p><b>四个端点一起钉</b>(含 {@code /areas}):{@code page} 不带 {@code mchNo},但它同样是
|
|
|
|
|
+ * 「以租户为范围」的读 —— 一个没有租户的主体在 {@code TenantInnerInterceptor} 下
|
|
|
|
|
+ * **不会被追加任何谓词**(Step 0 实测第 3 行),拿到的是全平台所有租户的商户映射。所以它也要拒。
|
|
|
|
|
+ * {@code /areas} 是**全局参考数据**(区划树对每个租户都一样,服务层也不带租户),它挂闸
|
|
|
|
|
+ * 不是为了挡越权,而是为了「本控制器的端点一律要求非空租户」这条统一口径 —— 见类 javadoc。
|
|
|
*/
|
|
*/
|
|
|
@Test
|
|
@Test
|
|
|
void everyEndpoint_rejectsPrincipalWithoutTenant_loudly() {
|
|
void everyEndpoint_rejectsPrincipalWithoutTenant_loudly() {
|
|
|
login(null, false);
|
|
login(null, false);
|
|
|
- for (Runnable call : allThreeEndpoints()) {
|
|
|
|
|
|
|
+ for (Runnable call : allEndpoints()) {
|
|
|
BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
|
"已登录但 tenantId == null 的主体必须被拒(Step 0 表第 3 行)");
|
|
"已登录但 tenantId == null 的主体必须被拒(Step 0 表第 3 行)");
|
|
|
assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
SecurityContextHolder.clearContext();
|
|
SecurityContextHolder.clearContext();
|
|
|
- for (Runnable call : allThreeEndpoints()) {
|
|
|
|
|
|
|
+ for (Runnable call : allEndpoints()) {
|
|
|
BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
BusinessException ex = assertThrows(BusinessException.class, call::run,
|
|
|
"无认证上下文同样必须被拒(Step 0 表第 4 行)");
|
|
"无认证上下文同样必须被拒(Step 0 表第 4 行)");
|
|
|
assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
|
|
@@ -424,6 +434,226 @@ class LeshouyingOnboardingControllerTest {
|
|
|
gateFirst.verify(onboardingService).commitMerchantInfo(any());
|
|
gateFirst.verify(onboardingService).commitMerchantInfo(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ // ──────────────── 省市区只读端点(加记 29 §二 裁定 R-T9a) ────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 权限串**逐字**是 {@code :create}。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么不能拿「超管跑一遍能过」来证明】{@code ROLE_SUPERUSER} 直通,超管测得过的串里,
|
|
|
|
|
+ * 写错的串也过得去(与 {@link #endpointPermissionStrings_matchTheDeclaredConstants} 同一条理由)。
|
|
|
|
|
+ * 所以这里从注解上反射读出**字面量**,与 {@code PERM_ONBOARDING_CREATE} 逐字比。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么必须是 {@code :create}】裁定 R-T9a:复用就**不新增第四个权限串**,
|
|
|
|
|
+ * Task 8 已交付的菜单 SQL 一个字都不用改、用户也不需要重新执行那份 SQL。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_reusesTheCreatePermissionStringVerbatim() throws Exception {
|
|
|
|
|
+ PreAuthorize a = AnnotatedElementUtils.findMergedAnnotation(areasMethod(), PreAuthorize.class);
|
|
|
|
|
+
|
|
|
|
|
+ assertNotNull(a, "areas 端点缺 @PreAuthorize —— 端点会变成任何登录用户可调");
|
|
|
|
|
+ assertEquals("@perm.hasAny('" + LeshouyingOnboardingController.PERM_ONBOARDING_CREATE + "')",
|
|
|
|
|
+ a.value(),
|
|
|
|
|
+ "权限串必须逐字复用 :create(裁定 R-T9a:不新增第四个权限串)。实测: " + a.value());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 路由 + **线参名**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>线参名的证明靠**实测**而不是读注解:{@code level="2"} 时「{@code parentCode} 不得空白」
|
|
|
|
|
+ * 是端点自己的边界规则({@link #areasEndpoint_rejectsMissingOrBlankParentCode_whenLevelNeedsIt})
|
|
|
|
|
+ * ⇒ 应答成功 ⇔ 服务端看见的 {@code parentCode} 非空白;{@code level} 缺失则 400
|
|
|
|
|
+ * ⇒ 应答成功也证明 {@code level} 这个名字绑上了。本用例只发这两个参数名,
|
|
|
|
|
+ * 于是「成功」本身就是「线参名就是这两个」的读数。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>另一头钉住**不许改名**:形参上的 {@code @RequestParam} 若写了 {@code name} / {@code value},
|
|
|
|
|
+ * 线参名就不再是 Java 形参名 —— 那条路会被下面的断言拒掉(pom 里
|
|
|
|
|
+ * {@code maven-compiler-plugin} 有 {@code <parameters>true</parameters>},形参名进 class 文件)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_isGetOnAreas_andBindsTheTwoWireParamNames() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ Method areas = areasMethod();
|
|
|
|
|
+
|
|
|
|
|
+ GetMapping methodMapping = AnnotatedElementUtils.findMergedAnnotation(areas, GetMapping.class);
|
|
|
|
|
+ assertNotNull(methodMapping, "areas 端点必须是 GET(它是只读查询)");
|
|
|
|
|
+ assertEquals(List.of("/areas"), declaredPaths(methodMapping.value(), methodMapping.path()),
|
|
|
|
|
+ "方法级路径必须是 /areas。实测: " + declaredPaths(methodMapping.value(), methodMapping.path()));
|
|
|
|
|
+
|
|
|
|
|
+ RequestMapping classMapping = AnnotatedElementUtils.findMergedAnnotation(
|
|
|
|
|
+ LeshouyingOnboardingController.class, RequestMapping.class);
|
|
|
|
|
+ assertEquals(List.of(PREFIX), declaredPaths(classMapping.value(), classMapping.path()),
|
|
|
|
|
+ "类级前缀(完整路径 = 前缀 + /areas)。实测: "
|
|
|
|
|
+ + declaredPaths(classMapping.value(), classMapping.path()));
|
|
|
|
|
+
|
|
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
|
|
+ for (Parameter p : areas.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", "parentCode"), names, "形参名(= 线参名)与顺序。实测: " + names);
|
|
|
|
|
+
|
|
|
|
|
+ // 实测那一半:只发这两个名字,必须被受理(且真的把实参交给了服务)
|
|
|
|
|
+ when(onboardingService.areaList("2", "1")).thenReturn(List.of());
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "2").param("parentCode", "1"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService).areaList("2", "1");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 透传:service 收到的两个实参**逐字**是 {@code "2"} 与 {@code "1"},**顺序也对**。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_passesLevelAndParentCodeThrough_verbatim() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.areaList("2", "1")).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "2").param("parentCode", "1"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+
|
|
|
|
|
+ // 精确实参的 verify 同时钉住「值逐字」与「位置(顺序)」
|
|
|
|
|
+ verify(onboardingService).areaList("2", "1");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ {@code level="1"} 时 {@code parentCode} **原样**转交 —— 端点不做 level 判断。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【这一条防的是什么】防「控制器里也抄一份『1 级不带上级码』的判断」:抄了就有两份
|
|
|
|
|
+ * 会各自漂移的规则(服务层那份的判据是 {@code level},不是「parentCode 是否为 null」)。
|
|
|
|
|
+ * 所以本用例故意在 {@code level="1"} 时给一个**非空**值,并断言服务层收到的就是它 ——
|
|
|
|
|
+ * 一旦端点多出一行「level=1 就把 parentCode 置空」,本用例立刻红。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_doesNotApplyTheLevel1RuleItself() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.areaList("1", "110000")).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "1").param("parentCode", "110000"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+
|
|
|
|
|
+ verify(onboardingService).areaList("1", "110000");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 空列表 ⇒ **成功 + 空数组**,不是 404 / 500。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>上游没给 {@code area_list}(缺键 / null / 不是数组)时服务层返回空列表、不抛
|
|
|
|
|
+ * (口径见 {@code LsyOnboardingService.areaList} 的 {@code @return})。“查到了但没有”
|
|
|
|
|
+ * 与「查不到」是两件事:前者是成功,把它变成 404/500 会让前端把一次正常的空联动
|
|
|
|
|
+ * 当成后端故障。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_emptyAreaList_isSuccessWithEmptyArray() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.areaList("1", null)).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "1"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0))
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isArray())
|
|
|
|
|
+ .andExpect(jsonPath("$.data").isEmpty());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 边界①:{@code level} 白名单 = {@code {"1","2","3"}},其余 ⇒ 400,且**一次都不触达服务**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>「其余」**包含整个参数不在** —— 2026-09-23 实测:本仓 {@code GlobalExceptionHandler}
|
|
|
|
|
+ * 是全仓唯一的 advice,它**没有** {@code MissingServletRequestParameterException} 分支
|
|
|
|
|
+ * ⇒ 若把 {@code level} 声明成必填,缺失落进兜底的 {@code Exception} 分支,得到
|
|
|
|
|
+ * <b>HTTP 500 + 业务码 500「服务器内部错误」</b>(读数见 task-9a-report.md)
|
|
|
|
|
+ * —— 那是「请重试」的语义,不是 400。所以 {@code level} 声明为 {@code required = false},
|
|
|
|
|
+ * 缺失与非法走**同一条**白名单判断。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_rejectsLevelOutsideWhitelist_withoutTouchingService() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ for (String bad : List.of("0", "4", "9", "", " 1", "1 ", "01", "1.0", "ABC", "一")) {
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", bad).param("parentCode", "1"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
|
|
+ }
|
|
|
|
|
+ // 整个参数不在:同样是「其余」
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("parentCode", "1"))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
|
|
+
|
|
|
|
|
+ // 反例先行 ⇒ 这是一条**无歧义**的零接触读数
|
|
|
|
|
+ verifyNoInteractions(onboardingService, merchantService);
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:同形请求 + 合法 level ⇒ 受理(否则上面那些 400 可能只是「压根没路由到这个方法」)
|
|
|
|
|
+ when(onboardingService.areaList("3", "2")).thenReturn(List.of());
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "3").param("parentCode", "2"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService).areaList("3", "2");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 边界②:{@code level} ≠ {@code "1"} 而 {@code parentCode} 缺失 / 空白(空串、纯空白)⇒ 400,
|
|
|
|
|
+ * 且**不得触达服务**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这是边界规则而不是把服务层的规则抄一遍】服务层**没有**这条规则:它照发,
|
|
|
|
|
+ * 上游照回一个**空列表且不报错** —— 前端的级联表现为「选了省之后,市的下拉永远是空的」,
|
|
|
|
|
+ * 而链路上没有任何一处报错({@link LsyAreaItemDTO} 把「静默空列表」记为
|
|
|
|
|
+ * 「这一类错误最贵的形态」)。宁可在信任边界上响亮失败。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_rejectsMissingOrBlankParentCode_whenLevelNeedsIt() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ for (String level : List.of("2", "3")) {
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", level))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400))
|
|
|
|
|
+ // 钉的是**口径**而不是措辞:文案要点名**我们自己发布**的那个参数
|
|
|
|
|
+ // (parentCode),而不是上游的键名 —— 同 createEndpoint 那条
|
|
|
|
|
+ // 「费率模板」的理由(Global Constraint 1:文案零上游痕迹)。
|
|
|
|
|
+ .andExpect(jsonPath("$.msg", containsString("parentCode")));
|
|
|
|
|
+ for (String blank : List.of("", " ")) {
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", level).param("parentCode", blank))
|
|
|
|
|
+ .andExpect(status().isOk())
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(400));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ verifyNoInteractions(onboardingService, merchantService);
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:同一个 level、给一个非空 parentCode ⇒ 受理
|
|
|
|
|
+ when(onboardingService.areaList("2", "1")).thenReturn(List.of());
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "2").param("parentCode", "1"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ verify(onboardingService).areaList("2", "1");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ {@code level="1"} + 空 {@code parentCode} ⇒ **放行**(级别 1 本来就不需要上级码),
|
|
|
|
|
+ * 且 service 收到的 {@code parentCode} **原样**:缺失 ⇒ {@code null},空串 / 纯空白 ⇒
|
|
|
|
|
+ * 那几个字符本身(端点是信任边界上的**校验**,不是**归一化**)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>与 {@link #areasEndpoint_rejectsMissingOrBlankParentCode_whenLevelNeedsIt} 是**一对**:
|
|
|
|
|
+ * 那条证明「2/3 缺上级码要拒」,本条证明「1 不给也要放行」—— 只钉一条的话,
|
|
|
|
|
+ * 把判据写成「parentCode 空白就拒」也能全绿。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void areasEndpoint_allowsLevel1_withoutParentCode() throws Exception {
|
|
|
|
|
+ login(TENANT, false);
|
|
|
|
|
+ when(onboardingService.areaList("1", null)).thenReturn(List.of());
|
|
|
|
|
+ when(onboardingService.areaList("1", "")).thenReturn(List.of());
|
|
|
|
|
+ when(onboardingService.areaList("1", " ")).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "1"))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "1").param("parentCode", ""))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "1").param("parentCode", " "))
|
|
|
|
|
+ .andExpect(jsonPath("$.code").value(0));
|
|
|
|
|
+
|
|
|
|
|
+ verify(onboardingService).areaList("1", null);
|
|
|
|
|
+ verify(onboardingService).areaList("1", "");
|
|
|
|
|
+ verify(onboardingService).areaList("1", " ");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
|
|
// ───────────────────────── 权限串与应答字段名(结构性) ─────────────────────────
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -455,8 +685,9 @@ class LeshouyingOnboardingControllerTest {
|
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
|
|
|
|
|
|
|
|
List<Method> mapped = mappedMethods();
|
|
List<Method> mapped = mappedMethods();
|
|
|
- assertEquals(3, mapped.size(),
|
|
|
|
|
- "本控制器应恰好三个端点(三个权限串 → 三个端点,裁定 2)。实测: "
|
|
|
|
|
|
|
+ assertEquals(4, mapped.size(),
|
|
|
|
|
+ "本控制器应恰好四个端点(三个权限串 → 四个端点:第四个 /areas 复用 :create,"
|
|
|
|
|
+ + "加记 29 §二 裁定 R-T9a)。实测: "
|
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
+ mapped.stream().map(Method::getName).toList());
|
|
|
|
|
|
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
Set<String> onAnnotations = new LinkedHashSet<>();
|
|
@@ -479,10 +710,15 @@ class LeshouyingOnboardingControllerTest {
|
|
|
/**
|
|
/**
|
|
|
* 应答字段名白名单(Global Constraint 1:到达租户眼前的字符串不得带上游专有键名)。
|
|
* 应答字段名白名单(Global Constraint 1:到达租户眼前的字符串不得带上游专有键名)。
|
|
|
*
|
|
*
|
|
|
- * <p>判据(约束原文的两查):某个键名算「上游专有」,当且仅当
|
|
|
|
|
- * ① 建表语句里没有这一列 **且** ② 有对外 DTO/实体把它回给租户。
|
|
|
|
|
|
|
+ * <p>判据(约束原文的两查,逐字见 {@code _global-constraints.md} 的裁定 R32):某个键名算
|
|
|
|
|
+ * 「上游专有」,当且仅当 ① 建表语句里没有这一列 <b>且</b> ② <b>没有</b>对外 DTO/实体把它回给租户
|
|
|
|
|
+ * —— <b>两条都不成立</b>才叫「上游专有」,因为「我们对外发布的名字,不可能是「上游专有」的名字」。
|
|
|
* 于是白名单 = {@code Result} 信封 ∪ {@code PageResult} 分页壳 ∪ {@code LsyMerchantEntity}
|
|
* 于是白名单 = {@code Result} 信封 ∪ {@code PageResult} 分页壳 ∪ {@code LsyMerchantEntity}
|
|
|
- * 的列名(都是**本地**词)。
|
|
|
|
|
|
|
+ * 的列名(都是**本地**词)∪ {@code LsyAreaItemDTO} 的属性名(**我们自己发布**的字段名)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 2026-09-23 订正:本行原先写的是「① 没有这一列 **且** ② **有**对外 DTO 把它回给租户」
|
|
|
|
|
+ * —— ②漏掉的那个「没有」会把「我们自己发布的字段名」判成上游专有(与 R32 的结论相反),
|
|
|
|
|
+ * 而那正是下面那五个 {@code area_*} / {@code *_code} 条目成立的依据。
|
|
|
*
|
|
*
|
|
|
* <p>两件事一起钉:
|
|
* <p>两件事一起钉:
|
|
|
* <ol>
|
|
* <ol>
|
|
@@ -505,7 +741,13 @@ class LeshouyingOnboardingControllerTest {
|
|
|
"page_no", "page_size", "total", "has_next", "list",
|
|
"page_no", "page_size", "total", "has_next", "list",
|
|
|
// pay_lsy_merchant 的列(V1.8 建表语句)
|
|
// pay_lsy_merchant 的列(V1.8 建表语句)
|
|
|
"id", "tenant_id", "enterprise_id", "mch_no", "shop_no", "merchant_name",
|
|
"id", "tenant_id", "enterprise_id", "mch_no", "shop_no", "merchant_name",
|
|
|
- "review_status", "status", "description", "created_time", "updated_time"));
|
|
|
|
|
|
|
+ "review_status", "status", "description", "created_time", "updated_time",
|
|
|
|
|
+ // LsyAreaItemDTO 的五个属性(按 SNAKE_CASE 展开)。★ 按上面那两查它们**不是**
|
|
|
|
|
+ // 「上游专有」:① 建表语句里没有这五列(在 src/main/resources/db 的 23 个 .sql 上
|
|
|
|
|
+ // 量得 0 命中;同一把量具上 merchant_name 命中 3 处,可作正对照),
|
|
|
|
|
+ // ② 但 **/areas 这个端点把它们发布给了租户** ⇒ 落进 R32 那句
|
|
|
|
|
+ // 「我们对外发布的名字,不可能是「上游专有」的名字」。
|
|
|
|
|
+ "area_name", "area_code", "province_code", "city_code", "county_code"));
|
|
|
|
|
|
|
|
Set<String> seen = new TreeSet<>();
|
|
Set<String> seen = new TreeSet<>();
|
|
|
for (Method m : mappedMethods()) {
|
|
for (Method m : mappedMethods()) {
|
|
@@ -524,6 +766,8 @@ class LeshouyingOnboardingControllerTest {
|
|
|
assertTrue(seen.contains("mch_no") && seen.contains("review_status") && seen.contains("list"),
|
|
assertTrue(seen.contains("mch_no") && seen.contains("review_status") && seen.contains("list"),
|
|
|
"应答字段名没收全,白名单断言已失效。实测: " + seen);
|
|
"应答字段名没收全,白名单断言已失效。实测: " + seen);
|
|
|
assertTrue(seen.size() >= 15, "收集到的字段名太少(" + seen.size() + ")。实测: " + seen);
|
|
assertTrue(seen.size() >= 15, "收集到的字段名太少(" + seen.size() + ")。实测: " + seen);
|
|
|
|
|
+ assertTrue(seen.contains("area_code") && seen.contains("county_code"),
|
|
|
|
|
+ "/areas 的应答字段名没收进 seen —— 那五个白名单条目就成了摆设。实测: " + seen);
|
|
|
|
|
|
|
|
Set<String> extra = new TreeSet<>(seen);
|
|
Set<String> extra = new TreeSet<>(seen);
|
|
|
extra.removeAll(allowed);
|
|
extra.removeAll(allowed);
|
|
@@ -533,6 +777,23 @@ class LeshouyingOnboardingControllerTest {
|
|
|
|
|
|
|
|
// ─────────────────────────────── 反射工具 ───────────────────────────────
|
|
// ─────────────────────────────── 反射工具 ───────────────────────────────
|
|
|
|
|
|
|
|
|
|
+ /** 省市区端点的方法本身。{@code getDeclaredMethod} 找不到就是没这个端点 —— 红得直白。 */
|
|
|
|
|
+ private static Method areasMethod() throws NoSuchMethodException {
|
|
|
|
|
+ return LeshouyingOnboardingController.class.getDeclaredMethod("areas", String.class, String.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 注解上声明的路径。{@code value} 与 {@code path} 互为别名,而
|
|
|
|
|
+ * {@code AnnotatedElementUtils.findMergedAnnotation} 会把**两者都填上**同一个值
|
|
|
|
|
+ * (2026-09-23 实测:{@code @GetMapping("/areas")} 的 {@code value()} 与 {@code path()}
|
|
|
|
|
+ * 都返回 {@code ["/areas"]})⇒ 取并集后必须去重,否则读数是 {@code [/areas, /areas]}。
|
|
|
|
|
+ */
|
|
|
|
|
+ private static List<String> declaredPaths(String[] value, String[] path) {
|
|
|
|
|
+ Set<String> out = new LinkedHashSet<>(List.of(value));
|
|
|
|
|
+ out.addAll(List.of(path));
|
|
|
|
|
+ return List.copyOf(out);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 本控制器自己声明的、带请求映射的方法。
|
|
* 本控制器自己声明的、带请求映射的方法。
|
|
|
* ★ **不以「有没有 {@code @PreAuthorize}」为筛选条件** —— 否则漏加注解的方法会被静默排除,
|
|
* ★ **不以「有没有 {@code @PreAuthorize}」为筛选条件** —— 否则漏加注解的方法会被静默排除,
|
|
@@ -621,11 +882,12 @@ class LeshouyingOnboardingControllerTest {
|
|
|
|
|
|
|
|
// ─────────────────────────────── 夹具 ───────────────────────────────
|
|
// ─────────────────────────────── 夹具 ───────────────────────────────
|
|
|
|
|
|
|
|
- private List<Runnable> allThreeEndpoints() {
|
|
|
|
|
|
|
+ private List<Runnable> allEndpoints() {
|
|
|
return List.of(
|
|
return List.of(
|
|
|
() -> controller.page(1, 20, null),
|
|
() -> controller.page(1, 20, null),
|
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
() -> controller.create(createBody("E1", "示例商户")),
|
|
|
- () -> controller.commit(commitBody(MCH_NO)));
|
|
|
|
|
|
|
+ () -> controller.commit(commitBody(MCH_NO)),
|
|
|
|
|
+ () -> controller.areas("1", null));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
|
|
/** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */
|