Răsfoiți Sursa

feat(进件): 省市区只读端点 —— areaList 的第四个 HTTP 出口(Task 9a)

裁定 R-T9a(加记 29 §二)新增本任务:计划 Task 9 强制要求进件页做「省市区三级联动」,
而在此之前全仓没有任何 HTTP 出口把区域树给前端 —— 服务腿
LsyOnboardingService.areaList 已实现且已测,却零调用方(44 个控制器全量检索,
region|district|province|city 命中 0,正对照见加记 29 §一)。没有本端点,那条级联就是死的。

- 端点:GET /payment/collection/onboarding/areas?level=&parentCode=
  权限串**逐字**复用 :create(不新增第四个串 ⇒ Task 8 已交付的菜单 SQL 一个字不用改,
  用户也不需要重新执行那份 SQL);出参 Result<List<LsyAreaItemDTO>>,全局 SNAKE_CASE
  ⇒ 前端读到 area_name / area_code / province_code / city_code / county_code。
  上游没给 area_list ⇒ 成功 + 空数组(不是 404、不是 500)。
- 两条边界规则(服务层没有这两条,放在信任边界上):level 只接受 1/2/3;level ≠ 1 时
  必须给非空白 parentCode。两条都在**调用服务之前**判定 ⇒ 输入不合法时一次上游调用都不发生。
  依据是失败形态:级别 2/3 少了上级码时上游不报错、只回空列表,而「静默空列表」是
  LsyAreaItemDTO 记为「这一类错误最贵的形态」的那一类。
- 「level=1 不上送 parent_code」这条规则**不在**控制器:它归服务层,端点把 parentCode
  原样转交(在控制器里抄一份,就有了两份会各自漂移的规则)。端点不做 level 判断、不归一化。
- javadoc 写明 parentCode 填的是**上一级选中项的 areaCode**(不是 GB 行政码:填 "110000"
  上游回 0 条且不报错),否则下一个人会照 GB 码写。
- level 声明为 required=false:2026-09-23 实测本仓 GlobalExceptionHandler 是全仓唯一的
  advice 且没有 MissingServletRequestParameterException 分支 ⇒ 必填参数缺失会落进兜底分支,
  得到 HTTP 500「服务器内部错误」而不是 400 ⇒ 缺失与非法走**同一条**白名单判断。

实测:该类 19 绿(11 → 19);全量 622 绿(BUILD SUCCESS)。
两向变异:去掉 level 白名单 / 去掉 parentCode 闸 / 权限串改成 :list / 在控制器抄一份
level=1 规则 / 去掉租户闸 ⇒ 各自对应的用例变红;改 400 文案与对调两条规则的顺序 ⇒ 全绿。
alphaH 3 săptămâni în urmă
părinte
comite
0dcbc2d2b7

+ 122 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -5,6 +5,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
 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.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
@@ -25,8 +26,11 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import java.util.List;
+import java.util.Set;
+
 /**
- * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网,三个端点。
+ * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询,四个端点。
  *
  * <p><b>【本类存在的理由:这条腿在 Task 7 之前根本没有 HTTP 入口】</b>
  * Task 3–6 把 {@link LsyOnboardingService} 的四条腿做完了(建号 / 更新 / 提交入网 / 入网状态查询),
@@ -48,13 +52,14 @@ import org.springframework.web.bind.annotation.RestController;
  *
  * <p>★ <b>超管不继承拦截器的例外</b>:{@code TenantInnerInterceptor.ignoreTable} 对超管
  * 直接返回 {@code true}(不追加谓词),但本控制器的端点**一律**要求非空租户 ——
- * 代价是「超管账号的 {@code tenant_id} 若为 null 就拿不到本页」(响亮 403,不是静默越权)。
+ * 代价是「超管账号的 {@code tenant_id} 若为 null 就拿不到本页(以及级联要的区域树)」
+ * (响亮 403,不是静默越权)。
  * 出口是照 {@code EnterpriseController.all} 的先例加一个显式的 {@code tenant_id} 参数,
  * **本批次不实现**(Task 9 的进件页没有租户选择器,当前没有任何调用方会传它)。
  *
  * <h2>端点清单(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)</h2>
  * <table border="1">
- *   <caption>三个权限串 → 三个端点</caption>
+ *   <caption>三个权限串 → 四个端点</caption>
  *   <tr><th>方法</th><th>完整路径</th><th>权限串</th><th>带 mchNo</th><th>租户闸落在哪</th></tr>
  *   <tr><td>GET</td><td>{@code /payment/collection/onboarding/page}</td>
  *       <td>{@value #PERM_ONBOARDING_LIST}</td><td>否</td>
@@ -66,6 +71,10 @@ import org.springframework.web.bind.annotation.RestController;
  *   <tr><td>POST</td><td>{@code /payment/collection/onboarding/commit}</td>
  *       <td>{@value #PERM_ONBOARDING_COMMIT}</td><td>是</td>
  *       <td>{@code LsyMerchantService.requireByMchNoForTenant} —— 方法体的**第一行**之后立刻</td></tr>
+ *   <tr><td>GET</td><td>{@code /payment/collection/onboarding/areas}</td>
+ *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否</td>
+ *       <td>{@link #requireTenantId()}(区划树是**全局参考数据**、无租户维度,挂闸是为「一律」这条
+ *           统一口径,不是为挡越权 —— 见 {@link #areas})</td></tr>
  * </table>
  *
  * <p>★ 权限串在注解里写成**字面量**(不是 {@code PERM_*} 常量拼接):全仓
@@ -192,6 +201,116 @@ public class LeshouyingOnboardingController {
         return Result.ok();
     }
 
+    // ──────────────── 省市区只读端点(加记 29 §二 裁定 R-T9a) ────────────────
+
+    /** {@code level} 的白名单 —— 本端点**自己收窄**的,服务层没有这条规则。 */
+    private static final Set<String> ALLOWED_LEVELS = Set.of("1", "2", "3");
+
+    /**
+     * 省市区三级联动 —— 进件页三级下拉的**唯一**数据源(上游「获取省市区」page 196236568)。
+     *
+     * <p><b>【为什么这个端点必须存在】</b>计划 Task 9 强制要求进件页做「省市区三级联动」,
+     * 而在此之前**全仓没有任何 HTTP 出口**把区域树给前端(44 个控制器全量检索,
+     * {@code region|district|province|city} 命中 0,见加记 29 §一);服务腿
+     * {@link LsyOnboardingService#areaList} 已实现且已测,却**零调用方**。
+     * 没有本端点,那条级联就是死的。裁定 R-T9a(加记 29 §二)因此新增了它。
+     *
+     * <p>★★ <b>{@code parentCode} 填的是**上一级选中项的 {@code areaCode}**,不是 GB 行政码。</b>
+     * 省级选中项的 {@code area_code} 形如 {@code "1"} —— 填 {@code "1"} 才回数据;
+     * 填 {@code "110000"}(那是 {@code provinceCode})上游回 <b>0 条且不报错</b>。
+     * 而 {@code provinceCode} / {@code cityCode} / {@code countyCode} 是**另一件事**:
+     * 它们要填进创建商户的 {@code address_province} / {@code address_city} / {@code address_county}。
+     * 两者的区别与实测依据写在 {@link LsyAreaItemDTO} 的字段块注释里,本 javadoc 不复制它。
+     *
+     * <p>【权限串逐字复用 {@code :create},不新增第四个串】级联的用途就是填建号表单:
+     * 只有 {@code :list} 的角色拿到树也没用,而只有 {@code :create} 的角色**必须**能拿到树
+     * ⇒ 挂 {@code :create} 既必要又充分。于是 Task 8 已交付的菜单 SQL 一个字都不用改、
+     * 用户也就不需要重新执行那份 SQL。
+     *
+     * <p>【出参】直接回 {@link LsyAreaItemDTO}(照 {@code LeshouyingMerchantController} 直接回实体的
+     * 先例);全局 Jackson {@code SNAKE_CASE} ⇒ 前端读到 {@code area_name} / {@code area_code} /
+     * {@code province_code} / {@code city_code} / {@code county_code}。
+     * 上游没给 {@code area_list} ⇒ **成功 + 空数组**(不是 404、不是 500)。
+     *
+     * <p>★ <b>本端点在信任边界上收窄了两条规则</b>(服务层没有这两条,理由见
+     * {@link #requireLevelInWhitelist} 与 {@link #requireParentCodeForSubLevels}):
+     * {@code level} 只接受 {@code "1"} / {@code "2"} / {@code "3"};{@code level} 不是 {@code "1"} 时
+     * 必须给出非空白的 {@code parentCode}。两条都在**调用服务之前**判定 ⇒ 输入不合法时
+     * 一次上游调用都不会发生。
+     *
+     * <p>★ <b>「{@code level="1"} 不上送 {@code parent_code}」这条规则**不在**这里</b>:
+     * 它是服务层的事(判据是 {@code level},不是「{@code parentCode} 是否为 null」)。
+     * 本方法把收到的 {@code parentCode} **原样**转交 —— 在控制器里再抄一份判断,
+     * 就有了两份会各自漂移的规则。本方法只校验**自己的输入**。
+     *
+     * @param level      省 / 直辖市 {@code "1"}、市 / 州 {@code "2"}、区 / 县 {@code "3"};
+     *                   缺失 / 空白 / 其余值一律 400
+     * @param parentCode 上一级选中项的 {@code areaCode};{@code level="1"} 时可以不给
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+    @GetMapping("/areas")
+    public Result<List<LsyAreaItemDTO>> areas(
+            @RequestParam(required = false) String level,
+            @RequestParam(required = false) String parentCode) {
+        // 与另外三个端点同一条统一口径:本控制器的端点一律要求非空租户(见类 javadoc)。
+        // 本端点并不读租户 —— 区划树对每个租户都一样 —— 挂闸不为挡越权,只为口径一致。
+        requireTenantId();
+        requireLevelInWhitelist(level);
+        requireParentCodeForSubLevels(level, parentCode);
+        return Result.ok(onboardingService.areaList(level, parentCode));
+    }
+
+    /**
+     * {@code level} 的白名单:只接受 {@code "1"} / {@code "2"} / {@code "3"},
+     * 其余(含**整个参数不在**)一律 400。
+     *
+     * <p>【为什么这条规则在控制器而不在服务层】它是**外部输入**的收窄,而服务层没有这条规则
+     * —— 服务层只负责把值组装进出站报文。信任边界在这里。
+     *
+     * <p>★ <b>为什么「缺失」也走这里,而不是交给 {@code @RequestParam} 的必填</b>:
+     * 2026-09-23 实测 —— 本仓 {@code GlobalExceptionHandler} 是**全仓唯一**的 advice,
+     * 它没有 {@code MissingServletRequestParameterException} 分支,于是必填参数缺失落进兜底的
+     * {@code Exception} 分支,得到 <b>HTTP 500 + 业务码 500「服务器内部错误」</b>
+     * (读数见 task-9a-report.md),而那是「请重试」的语义,不是 400。
+     * 所以 {@code level} 声明为 {@code required = false},缺失与非法走**同一条**判断。
+     *
+     * <p>【文案】{@code level} 是**我们自己发布**的请求参数名(本端点的线参名)⇒ 文案里可以用它。
+     * 上游那个键名则**不得**出现:Global Constraint 1(文案零上游痕迹)的判据是「查建表语句
+     * 有没有这一列」+「查有没有对外 DTO/实体把它回给租户」,上游的键名两条都不成立。
+     */
+    private static void requireLevelInWhitelist(String level) {
+        // level == null 单独判:Set.of(...) 的 contains(null) 会抛 NPE(不是返回 false)。
+        if (level == null || !ALLOWED_LEVELS.contains(level)) {
+            // 文案不含上游名称 / 域名 / 上游键名(Global Constraint 1)。
+            throw new BusinessException(400, "level 只接受 1、2、3(分别表示省/直辖市、市/州、区/县)");
+        }
+    }
+
+    /**
+     * {@code level} 不是 {@code "1"} 时,{@code parentCode} 不得缺失 / 空白,否则 400。
+     *
+     * <p>【依据是失败形态,不是洁癖】级别 2/3 少了上级码时上游**不报错**,只是永远回空列表
+     * —— {@link LsyAreaItemDTO} 把「静默空列表」记为「这一类错误最贵的形态」。
+     * 前端看到的是「选了省之后,市的下拉一直是空的」,而链路上没有任何一处报错。
+     * 宁可在信任边界上响亮失败。
+     *
+     * <p>【与「{@code level="1"} 不上送 {@code parent_code}」**不是**同一条规则】那条讲的是
+     * 「上送时要不要带这个键」(在服务层,判据是 {@code level});本条讲的是「调用方必须不必须给」
+     * (在信任边界上)。两条只在「哪个 level 没有上级」这一点上共享同一个事实,
+     * **不是**互相的副本:本方法从不修改 {@code parentCode},只判断给没给 ——
+     * 所以 {@code level="1"} 时即使 {@code parentCode} 非空,它也是原样转交的
+     * (那是「端点不做 level 判断」要钉住的行为)。
+     *
+     * <p>【文案】用中文标签「上级区域编码」指代上游那个键 —— 它是上游的键名
+     * (建表语句里没有这一列、也没有任何对外 DTO 把它回给租户)⇒ 不进任何到达租户的字符串。
+     */
+    private static void requireParentCodeForSubLevels(String level, String parentCode) {
+        if (!"1".equals(level) && StrUtil.isBlank(parentCode)) {
+            throw new BusinessException(400,
+                    "level 为 2、3 时必须提供 parentCode(上一级选中项的区域编码,不是 GB 行政码)");
+        }
+    }
+
     // ──────────────── 服务端决定的值:只来自认证上下文 / 平台配置,不接受请求体指定 ────────────────
 
     /**

+ 276 - 14
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -11,6 +11,7 @@ import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.exception.GlobalExceptionHandler;
 import com.payment.platform.common.response.PageResult;
 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.entity.LsyMerchantEntity;
 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.test.web.servlet.MockMvc;
 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.RequestParam;
 
 import java.lang.reflect.Method;
+import java.lang.reflect.Parameter;
 import java.lang.reflect.ParameterizedType;
 import java.lang.reflect.Type;
 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.eq;
 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.result.MockMvcResultMatchers.jsonPath;
 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@@ -85,9 +90,12 @@ 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";
+
     @Mock private LsyMerchantService merchantService;
     @Mock private LsyOnboardingService onboardingService;
 
@@ -349,21 +357,23 @@ class LeshouyingOnboardingControllerTest {
     /**
      * 认证上下文里拿不到租户 ⇒ **显式拒绝**(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
     void everyEndpoint_rejectsPrincipalWithoutTenant_loudly() {
         login(null, false);
-        for (Runnable call : allThreeEndpoints()) {
+        for (Runnable call : allEndpoints()) {
             BusinessException ex = assertThrows(BusinessException.class, call::run,
                     "已登录但 tenantId == null 的主体必须被拒(Step 0 表第 3 行)");
             assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
         }
 
         SecurityContextHolder.clearContext();
-        for (Runnable call : allThreeEndpoints()) {
+        for (Runnable call : allEndpoints()) {
             BusinessException ex = assertThrows(BusinessException.class, call::run,
                     "无认证上下文同样必须被拒(Step 0 表第 4 行)");
             assertEquals(403, ex.getCode(), "必须显式拒绝(403),实测: " + ex.getCode());
@@ -424,6 +434,226 @@ class LeshouyingOnboardingControllerTest {
         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);
 
         List<Method> mapped = mappedMethods();
-        assertEquals(3, mapped.size(),
-                "本控制器应恰好三个端点(三个权限串 → 三个端点,裁定 2)。实测: "
+        assertEquals(4, mapped.size(),
+                "本控制器应恰好四个端点(三个权限串 → 四个端点:第四个 /areas 复用 :create,"
+                        + "加记 29 §二 裁定 R-T9a)。实测: "
                         + mapped.stream().map(Method::getName).toList());
 
         Set<String> onAnnotations = new LinkedHashSet<>();
@@ -479,10 +710,15 @@ class LeshouyingOnboardingControllerTest {
     /**
      * 应答字段名白名单(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 LsyAreaItemDTO} 的属性名(**我们自己发布**的字段名)。
+     *
+     * <p>★ 2026-09-23 订正:本行原先写的是「① 没有这一列 **且** ② **有**对外 DTO 把它回给租户」
+     * —— ②漏掉的那个「没有」会把「我们自己发布的字段名」判成上游专有(与 R32 的结论相反),
+     * 而那正是下面那五个 {@code area_*} / {@code *_code} 条目成立的依据。
      *
      * <p>两件事一起钉:
      * <ol>
@@ -505,7 +741,13 @@ class LeshouyingOnboardingControllerTest {
                 "page_no", "page_size", "total", "has_next", "list",
                 // pay_lsy_merchant 的列(V1.8 建表语句)
                 "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<>();
         for (Method m : mappedMethods()) {
@@ -524,6 +766,8 @@ class LeshouyingOnboardingControllerTest {
         assertTrue(seen.contains("mch_no") && seen.contains("review_status") && seen.contains("list"),
                 "应答字段名没收全,白名单断言已失效。实测: " + 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);
         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}」为筛选条件** —— 否则漏加注解的方法会被静默排除,
@@ -621,11 +882,12 @@ class LeshouyingOnboardingControllerTest {
 
     // ─────────────────────────────── 夹具 ───────────────────────────────
 
-    private List<Runnable> allThreeEndpoints() {
+    private List<Runnable> allEndpoints() {
         return List.of(
                 () -> controller.page(1, 20, null),
                 () -> controller.create(createBody("E1", "示例商户")),
-                () -> controller.commit(commitBody(MCH_NO)));
+                () -> controller.commit(commitBody(MCH_NO)),
+                () -> controller.areas("1", null));
     }
 
     /** 真服务 + mock mapper:要断言的是**写进去 / 查出去的东西**,不是「调了哪个方法」。 */