瀏覽代碼

feat(进件): 行业类目端点的 HTTP 出口 —— /mcc 与 /areas 共用一对入参护栏(Task 9b-③)

新增 GET /payment/collection/onboarding/mcc,把 LsyOnboardingService.mccList 接出一条
HTTP 出口。理由与 /areas 同源:merchant_mcc_code 在必填矩阵的 UNCONDITIONAL 里(每个商户
类别都必填),而在此之前全仓没有任何出口把类目树给前端 —— 那条服务腿已实现且已测,
却零调用方 ⇒ 建号表单里那一格填不出来。Task 9 的页面依赖它。

权限串逐字复用 :create(不新增第六个串,Task 8 已交付的菜单 SQL 一个字不用改)。
出参直接回 LsyMccItemDTO ⇒ 前端读到 mcc_name / mcc_code(全局 Jackson SNAKE_CASE)。

★ 线参名是 parentId,不是 parentCode:上游两页表达同一件事用了两个名字(省市区页是
parent_code、行业类目页是 parent_id),而键名进签名原文 ⇒ 两条腿各自与该页同形。
为了「和 /areas 一致」而改名 = 把上游的不一致在我们这边抹平,症状是上游回一句笼统的
「参数不合法」。裁定 R-T9f′ 因此定 parentId。
★ merchantType:端点收、原样透传,页面不发(同裁定)。缺省与空白都传 null 而不是空串
—— 服务层把 merchant_type 无条件放进参数 Map、客户端只剔 null ⇒ 空串会进签名原文。
★ 「level=1 不上送 parent_id」这条规则不在控制器里(判据是 level,是服务层的事):
端点把 parentId 原样转交,不在控制器里再抄一份会各自漂移的判断。

重构(为第三条腿共用,行为不变):requireLevelInWhitelist 与
requireParentCodeForSubLevels → 参数化,后者更名 requireParentForSubLevels;两句文案由
调用点给(两条腿的 1/2/3 不是同一件事:行政区划层级 vs 行业类目层级)。连带订正被更名
打断的 {@link}。

测试(+11 用例,本类 42 绿):
- 权限串逐字 :create(超管直通 ⇒ 「超管跑一遍能过」证明不了权限串)
- GET /mcc + 三个线参名与顺序(raw @RequestParam 的 name/value 必须是空串 ⇒ 名字只能来自形参)
- 三个实参逐字且按位到达 service(精确实参的 verify 同时钉值与位置)
- merchantType 缺省/空串/纯空白三种形态都到达 null(一个桩 + times(3) ⇒ 读数无歧义)
- level 缺席与非法都 400 不是 500,且零接触服务(反例先行 + 正对照)
- parentId 两向原样:level=2 给了就转交;level=1 也照样转交(端点不做 level 判断)
- 应答元素恰好 {mcc_name, mcc_code} 两个键(只断言「这两个键在」的话,把上游原始应答
  直接回出去也照样绿)
- 上游回空列表 ⇒ 成功 + 空数组
- 两条文案各自点名本腿的语义(行业不是行政区划;本腿的上级参叫 parentId 不是 parent_code)

★ 新增一条「故意钉措辞」的 /areas 用例(areasEndpoint_messagesArePinnedVerbatim):
brief ③-step3 说「既有两条 /areas 断言逐字钉着那两句、不许改」,实测**不成立** ——
一条只断言 $.code == 400,另一条只断言 containsString("parentCode")(参数名,不是那句话)。
实测佐证:把 /areas 那条腿的层级词换成行业那套(变异 R6),既有的两条断言**全绿**,
只有新加的这条红 ⇒ 它是这次参数化重构唯一真正的正对照。

两向变异 11 发(读数在报告里):
- 该红的 9 发全红:权限串改 :list / 动词改 POST / 线参名改 parentCode / 去掉空白归一 /
  行业腿用行政区划词 / ★/areas 腿用行业词 / 摘掉 requireTenantId / level 改必填(⇒ 500)/
  从白名单摘掉 mcc_code
- 无害的 2 发实测仍绿,且都用 javap 当场证明变异体进了被执行的 class(方向随还原翻转):
  用等价的显式写法替掉 StrUtil.blankToDefault / 调换两条护栏的调用顺序。
  ★ 后一发是刻意钝感:测试不该钉住「哪条护栏先跑」。为了让这条读数成立,两条文案用例都
  补上了合法的上级参(原本 level=4 且不给上级参会让两条护栏同时成立 ⇒ 换个顺序就红
  —— 那是把实现顺序当契约钉住,不是这两条用例声称要钉的东西)。

实测:本类 42 绿(31 → 42);全量 645 绿(上一提交 634,BUILD SUCCESS)。
③-step2 的「红」是一次编译失败(cannot find symbol: mcc)—— 它只证明方法不存在,
证明不了任何断言有牙;每族断言的牙由上面那 11 发变异来量。
alphaH 3 周之前
父節點
當前提交
5a3d1aecbf

+ 126 - 20
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -7,6 +7,7 @@ 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.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.service.LsyMerchantService;
@@ -32,7 +33,8 @@ import java.util.List;
 import java.util.Set;
 
 /**
- * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询 / 资质图片上传,五个端点。
+ * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询 / 资质图片上传 /
+ * 行业类目查询,六个端点。
  *
  * <p><b>【本类存在的理由:这条腿在 Task 7 之前根本没有 HTTP 入口】</b>
  * Task 3–6 把 {@link LsyOnboardingService} 的四条腿做完了(建号 / 更新 / 提交入网 / 入网状态查询),
@@ -61,7 +63,7 @@ import java.util.Set;
  *
  * <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>
@@ -81,6 +83,9 @@ import java.util.Set;
  *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否</td>
  *       <td>{@link #requireTenantId()}(上传出来的图片在上游是**平台级**的、不绑定租户,
  *           挂闸同样是为「一律」这条统一口径 —— 见 {@link #uploadImage})</td></tr>
+ *   <tr><td>GET</td><td>{@code /payment/collection/onboarding/mcc}</td>
+ *       <td>{@value #PERM_ONBOARDING_CREATE}</td><td>否</td>
+ *       <td>{@link #requireTenantId()}(行业类目同样是**全局参考数据** —— 见 {@link #mcc})</td></tr>
  * </table>
  *
  * <p>★ 权限串在注解里写成**字面量**(不是 {@code PERM_*} 常量拼接):全仓
@@ -296,7 +301,11 @@ public class LeshouyingOnboardingController {
 
     // ──────────────── 省市区只读端点(加记 29 §二 裁定 R-T9a) ────────────────
 
-    /** {@code level} 的白名单 —— 本端点**自己收窄**的,服务层没有这条规则。 */
+    /**
+     * {@code level} 的白名单 —— **控制器自己收窄**的,服务层没有这条规则。
+     * {@code /areas}(加记 29)与 {@code /mcc}(加记 35)共用它:两条腿的 {@code level} 值域相同,
+     * 只是 1/2/3 的含义不同(见 {@link #LEVEL_LABELS_ADMINISTRATIVE} / {@link #LEVEL_LABELS_INDUSTRY})。
+     */
     private static final Set<String> ALLOWED_LEVELS = Set.of("1", "2", "3");
 
     /**
@@ -326,7 +335,7 @@ public class LeshouyingOnboardingController {
      * 上游没给 {@code area_list} ⇒ **成功 + 空数组**(不是 404、不是 500)。
      *
      * <p>★ <b>本端点在信任边界上收窄了两条规则</b>(服务层没有这两条,理由见
-     * {@link #requireLevelInWhitelist} 与 {@link #requireParentCodeForSubLevels}):
+     * {@link #requireLevelInWhitelist} 与 {@link #requireParentForSubLevels}):
      * {@code level} 只接受 {@code "1"} / {@code "2"} / {@code "3"};{@code level} 不是 {@code "1"} 时
      * 必须给出非空白的 {@code parentCode}。两条都在**调用服务之前**判定 ⇒ 输入不合法时
      * 一次上游调用都不会发生。
@@ -348,14 +357,26 @@ public class LeshouyingOnboardingController {
         // 与另外三个端点同一条统一口径:本控制器的端点一律要求非空租户(见类 javadoc)。
         // 本端点并不读租户 —— 区划树对每个租户都一样 —— 挂闸不为挡越权,只为口径一致。
         requireTenantId();
-        requireLevelInWhitelist(level);
-        requireParentCodeForSubLevels(level, parentCode);
+        requireLevelInWhitelist(level, LEVEL_LABELS_ADMINISTRATIVE);
+        requireParentForSubLevels(level, parentCode, "parentCode",
+                "上一级选中项的区域编码,不是 GB 行政码");
         return Result.ok(onboardingService.areaList(level, parentCode));
     }
 
+    /**
+     * {@code level} 的三个取值在 {@code /areas} 这条腿上的说法。
+     *
+     * <p>与 {@link #LEVEL_LABELS_INDUSTRY} 成对:**同一个数字在两条腿上是两件事**
+     * (行政区划层级 vs 行业类目层级),所以文案必须由调用点给,不能写在 helper 里。
+     */
+    private static final String LEVEL_LABELS_ADMINISTRATIVE = "省/直辖市、市/州、区/县";
+
+    /** {@code level} 的三个取值在 {@code /mcc} 这条腿上的说法(见 {@link #LEVEL_LABELS_ADMINISTRATIVE})。 */
+    private static final String LEVEL_LABELS_INDUSTRY = "一级行业、二级行业、三级行业";
+
     /**
      * {@code level} 的白名单:只接受 {@code "1"} / {@code "2"} / {@code "3"},
-     * 其余(含**整个参数不在**)一律 400。
+     * 其余(含**整个参数不在**)一律 400。<b>{@code /areas} 与 {@code /mcc} 共用本方法。</b>
      *
      * <p>【为什么这条规则在控制器而不在服务层】它是**外部输入**的收窄,而服务层没有这条规则
      * —— 服务层只负责把值组装进出站报文。信任边界在这里。
@@ -367,20 +388,28 @@ public class LeshouyingOnboardingController {
      * (读数见 task-9a-report.md),而那是「请重试」的语义,不是 400。
      * 所以 {@code level} 声明为 {@code required = false},缺失与非法走**同一条**判断。
      *
-     * <p>【文案】{@code level} 是**我们自己发布**的请求参数名(本端点的线参名)⇒ 文案里可以用它。
-     * 上游那个键名则**不得**出现:Global Constraint 1(文案零上游痕迹)的判据是「查建表语句
-     * 有没有这一列」+「查有没有对外 DTO/实体把它回给租户」,上游的键名两条都不成立。
+     * <p>【文案】{@code level} 是**我们自己发布**的请求参数名(两个端点的线参名都叫它)
+     * ⇒ 文案里可以用它。上游那个键名则**不得**出现:Global Constraint 1(文案零上游痕迹)
+     * 的判据是「查建表语句有没有这一列」+「查有没有对外 DTO/实体把它回给租户」,
+     * 上游的键名两条都不成立。
+     *
+     * <p>★ <b>{@code labels} 由调用点给</b>:两条腿的 1/2/3 指的不是同一件事
+     * (行政区划层级 / 行业类目层级),写死在 helper 里就会有一条腿说错话。
+     * 两条腿各自的**整句**由
+     * {@code LeshouyingOnboardingControllerTest.areasEndpoint_messagesArePinnedVerbatim}
+     * 与 {@code …mccEndpoint_levelMessageNamesIndustryLevels_notAdministrativeDivisions} 逐字钉着。
      */
-    private static void requireLevelInWhitelist(String level) {
+    private static void requireLevelInWhitelist(String level, String labels) {
         // 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(分别表示省/直辖市、市/州、区/县)");
+            throw new BusinessException(400, "level 只接受 1、2、3(分别表示" + labels + ")");
         }
     }
 
     /**
-     * {@code level} 不是 {@code "1"} 时,{@code parentCode} 不得缺失 / 空白,否则 400。
+     * {@code level} 不是 {@code "1"} 时,本腿的上级参不得缺失 / 空白,否则 400。
+     * <b>{@code /areas} 与 {@code /mcc} 共用本方法</b>(两条腿的线参名与语义由调用点给,见下)。
      *
      * <p>【依据是失败形态,不是洁癖】级别 2/3 少了上级码时上游**不报错**,只是永远回空列表
      * —— {@link LsyAreaItemDTO} 把「静默空列表」记为「这一类错误最贵的形态」。
@@ -390,20 +419,97 @@ public class LeshouyingOnboardingController {
      * <p>【与「{@code level="1"} 不上送 {@code parent_code}」**不是**同一条规则】那条讲的是
      * 「上送时要不要带这个键」(在服务层,判据是 {@code level});本条讲的是「调用方必须不必须给」
      * (在信任边界上)。两条只在「哪个 level 没有上级」这一点上共享同一个事实,
-     * **不是**互相的副本:本方法从不修改 {@code parentCode},只判断给没给 ——
-     * 所以 {@code level="1"} 时即使 {@code parentCode} 非空,它也是原样转交的
+     * **不是**互相的副本:本方法从不修改那个值,只判断给没给 ——
+     * 所以 {@code level="1"} 时即使它非空,它也是原样转交的
      * (那是「端点不做 level 判断」要钉住的行为)。
      *
-     * <p>【文案】用中文标签「上级区域编码」指代上游那个键 —— 它是上游的键名
-     * (建表语句里没有这一列、也没有任何对外 DTO 把它回给租户)⇒ 不进任何到达租户的字符串。
+     * <p>【文案】用**我们自己发布**的线参名({@code /areas} 是 {@code parentCode}、
+     * {@code /mcc} 是 {@code parentId})点名该给哪个参数,再用一句 {@code hint} 说清那个值是
+     * 「什么东西的编码」—— 两条腿填的不是同一种编码。上游那个键名则不得出现 ⇒ 由调用点传
+     * {@code paramName} / {@code hint},本方法不写死任何一条腿的说法。
+     *
+     * @param parentValue 收到的原值;本方法**只判断给没给**,不改写它
+     * @param paramName   本腿的线参名(进文案)
+     * @param hint        本腿那个值的语义(进文案)
      */
-    private static void requireParentCodeForSubLevels(String level, String parentCode) {
-        if (!"1".equals(level) && StrUtil.isBlank(parentCode)) {
+    private static void requireParentForSubLevels(String level, String parentValue,
+                                                  String paramName, String hint) {
+        if (!"1".equals(level) && StrUtil.isBlank(parentValue)) {
             throw new BusinessException(400,
-                    "level 为 2、3 时必须提供 parentCode(上一级选中项的区域编码,不是 GB 行政码)");
+                    "level 为 2、3 时必须提供 " + paramName + "(" + hint + ")");
         }
     }
 
+    // ──────────────── 行业类目只读端点(加记 35 §二 / §三 裁定 R-T9f′) ────────────────
+
+    /**
+     * 行业类目三级联动 —— 进件页「商户行业类目」下拉的**唯一**数据源
+     * (上游「获取行业类目(根据通道)」page 196236569)。
+     *
+     * <p><b>【为什么这个端点必须存在】</b>{@code merchant_mcc_code} 在必填矩阵的
+     * {@code UNCONDITIONAL} 里 —— **每个商户类别都必填**,而在此之前全仓没有任何 HTTP 出口
+     * 把类目树给前端:服务腿 {@link LsyOnboardingService#mccList} 已实现且已测,却**零调用方**。
+     * 没有本端点,建号表单里那一格就填不出来。
+     *
+     * <p>★ <b>{@code parentId} 填的是**上一级选中项的 {@code mccCode}**,不是行政区划编码。</b>
+     * 与 {@link #areas} 的 {@code parentCode} 是同一种东西(上一级选中项的编码),
+     * 但**键名不同** —— 见下条。
+     *
+     * <p>★★ <b>【{@code parentId} 这个名字是上游文档的要害处,不许「统一」成 {@code parentCode}】</b>
+     * 上游两页表达同一件事用了两个名字:「获取省市区」页是 {@code parent_code},
+     * 「获取行业类目」页是 {@code parent_id}(计划 §0.5 文档缺陷清单记了这处不一致)。
+     * 而键名进**签名原文** ⇒ 两条腿必须各自与该页同形。为了「和 /areas 一致」而改名,
+     * 等于把上游的不一致在我们这边抹平 —— 症状是上游回一句笼统的「参数不合法」,
+     * 而我们对着一张写对了参数名的文档看不出问题。裁定 R-T9f′ 因此定了 {@code parentId}。
+     *
+     * <p>★ <b>{@code merchantType}:端点收、原样透传,页面不发</b>(同裁定 R-T9f′)。
+     * 它是「根据通道」这个语义的入参(上游文档给的值域是 {@code 1}/{@code 2}),
+     * 而 Task 9 的页面没有这个选择器 ⇒ 缺省与空白都传 {@code null},
+     * <b>不是空串</b>:服务层把 {@code merchant_type} **无条件**放进参数 Map,
+     * 而客户端只剔 {@code null} ⇒ 空串会照样上送成 {@code merchant_type=} 并进签名原文。
+     *
+     * <p>【权限串逐字复用 {@code :create},不新增第六个串】理由同 {@link #areas}:
+     * 类目树的用途就是填建号表单 ⇒ 只读的角色拿到它也没用、可建号的角色必须能拿到它。
+     * Task 8 已交付的菜单 SQL 一个字都不用改。
+     *
+     * <p>【出参】直接回 {@link LsyMccItemDTO}(照 {@link #areas} 的先例);全局 Jackson
+     * {@code SNAKE_CASE} ⇒ 前端读到 {@code mcc_name} / {@code mcc_code}。
+     * 上游没给 {@code mcc_list} ⇒ **成功 + 空数组**(不是 404、不是 500)。
+     *
+     * <p>★ <b>本端点在信任边界上收窄了与 {@link #areas} **同一对**规则</b>
+     * ({@link #requireLevelInWhitelist} / {@link #requireParentForSubLevels}):
+     * {@code level} 只接受 {@code "1"} / {@code "2"} / {@code "3"};{@code level} 不是 {@code "1"} 时
+     * 必须给出非空白的 {@code parentId}。两条都在**调用服务之前**判定。
+     * 两处文案与 {@code /areas} 那两句**只差调用点传进来的参数**:{@code /mcc} 说的是
+     * 「一级/二级/三级行业」与「行业类目编码」,不是行政区划那套词。
+     *
+     * <p>★ <b>「{@code level="1"} 不上送 {@code parent_id}」这条规则**不在**这里</b>:
+     * 它是服务层的事(判据是 {@code level},不是「{@code parentId} 是否为 null」)——
+     * 与 {@link #areas} 逐字同一条理由。本方法把收到的 {@code parentId} **原样**转交。
+     *
+     * @param level        一级行业 {@code "1"}、二级 {@code "2"}、三级 {@code "3"};
+     *                     缺失 / 空白 / 其余值一律 400
+     * @param parentId     上一级选中项的 {@code mccCode};{@code level="1"} 时可以不给
+     * @param merchantType 通道类型;本页不发它 ⇒ 缺省与空白都传 {@code null}
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+    @GetMapping("/mcc")
+    public Result<List<LsyMccItemDTO>> mcc(
+            @RequestParam(required = false) String level,
+            @RequestParam(required = false) String parentId,
+            @RequestParam(required = false) String merchantType) {
+        // 与另外五个端点同一条统一口径:本控制器的端点一律要求非空租户(见类 javadoc)。
+        // 本端点并不读租户 —— 类目树对每个租户都一样 —— 挂闸不为挡越权,只为口径一致。
+        requireTenantId();
+        requireLevelInWhitelist(level, LEVEL_LABELS_INDUSTRY);
+        requireParentForSubLevels(level, parentId, "parentId", "上一级选中项的行业类目编码");
+        // ★ 空白归一成 null 的那一步只对 merchantType 做(它是唯一「页面不发」的参数):
+        //   空串会进签名原文(服务层只剔 null,见服务层 mccList 的组装)。
+        //   另两个参数**逐字**转交,不做任何归一化 —— 它们由页面明确发出,
+        //   归一化只会让「原样透传」这条性质变得不可测。
+        return Result.ok(onboardingService.mccList(level, parentId, StrUtil.blankToDefault(merchantType, null)));
+    }
+
     // ──────────────── 服务端决定的值:只来自认证上下文 / 平台配置,不接受请求体指定 ────────────────
 
     /**

+ 392 - 6
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -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)));
     }