Quellcode durchsuchen

feat(进件): 行业类目查询 + 安心签个人/企业开户三条腿(Task 5)

服务层新增三条腿(计划 Task 5):

- `mccList(level, parentId, merchantType)` → `/api/merchant/getTopMccByType`,
  服务层交出去恰好 4 个键(inst_no / sign 归客户端补)。level=1 时不上送
  parent_id(判据是 level,不是「parentId 是不是 null」——与 areaList 同一条规则)。
  merchant_type 本地不校验、原样透传:该页值域与创建商户页那三处把 1 和 2 对调,
  本层不知道谁对,任何校验都是在挑一套猜。
- `anxinSignPerson(dto)` → `/api/merchant/anxinSignPerson`,恰好 5 个键。
- `anxinSignCompany(dto)` → `/api/merchant/anxinSignCompany`,恰好 9 个键。
  两个方法的 javadoc 都落了 §0.1 的标题歧义(菜单标题带「发送验证码」、
  接口描述为空、参数表是完整开户载荷 ⇒ 本批次按参数表发全量),
  并标明这是可实测项(拿凭据后真机跑一次看副作用即可判定)。

DTO:新增 LsyMccItemDTO(mccCode 按 String 收 —— 该页返回示例是数字、
返回参数表声明 String,按更宽的一侧收)、LsyAnxinSignPersonDTO、
LsyAnxinSignCompanyDTO。后两个是两个各自独立的出站腿,不做成继承。

LsyAreaItemDTO 补三个 GB 行政码字段(provinceCode / cityCode / countyCode,
spec §6.6.2 的「文档缺漏」,依据是 483 条穷举普查),并在字段块里写明
它们不是 parent_code(填 GB 码回 0 条且静默);toAreaItems 读这三个键。

凭据闸的适用面判据写明是「这条腿用不用那五项凭据」而不是「是不是进件腿」:
三条新腿里只有 mccList 走闸(它要用 channel_type),anxinSign 两条不设闸
—— 它们只用 inst_no 与机构密钥,那两个由启动期的 validate() 兜住。
channel_id 的空白处理抽成 trimOrNull,channel_type 走同一条口径。

测试:LsyOnboardingServiceTest 新增 14 条(brief 的 11 条 + 3 条覆盖空隙)。
其中 anxinSign_doesNotLogPii 带正对照(先断言捕获装置确实捕到了那两条出站行,
再断言 PII 值不在里面)—— 该用例用的桩客户端不会执行 traceOutbound,
没有正对照就是恒绿的空断言。

★ 依赖:anxinSign_doesNotLogPii 依赖前一个提交 adda032 的键层遮罩。
alphaH vor 3 Wochen
Ursprung
Commit
268633d22f

+ 60 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyAnxinSignCompanyDTO.java

@@ -0,0 +1,60 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 企业和个体安心签开户入参 —— 上游「企业和个体安心签开户发送验证码」接口(page 196236566,
+ * 路径 {@code /api/merchant/anxinSignCompany})。
+ *
+ * <p>该页请求参数表扣掉客户端自动补的 {@code inst_no} / {@code sign} 之后,
+ * 剩下的就是<b>本类这九个字段</b>——九个在该页**全是「必填=是」**。
+ * 与个人版({@link LsyAnxinSignPersonDTO})的关系是**严格超集**:前五个键名逐字相同,
+ * 本类多出 {@code business_licence} / {@code merchant_address} / {@code legal_cert_no} /
+ * {@code legal_name} 四个。
+ * <b>不要</b>把它做成「继承个人版 + 加四个字段」:两个 DTO 是两条**各自独立**的出站腿,
+ * 合并继承会让「个人版误发公司版的字段」变成一次静默的、只在签名原文里可见的差异。
+ *
+ * <p>★ <b>标题与载荷不一致(计划 §0.1,必须记在这里)</b>:菜单标题带「**发送验证码**」后缀,
+ * 而该页的 {@code 接口描述} 是**空的**、参数表却是**完整开户载荷**(含法人身份证、结算卡号)。
+ * ⇒ 从文档**无法判定**它到底是「只发一条短信」还是「开户并触发短信」。
+ * <b>本批次按参数表实现(请求发全量字段)</b>。同页的请求示例是「获取省市区」页的复制残留
+ * (计划 §0.5),不是本接口的形状。
+ *
+ * <p>★ <b>本类不做校验</b>:理由与 {@link LsyAnxinSignPersonDTO} 逐字相同(那里写了一次)。
+ */
+@Data
+public class LsyAnxinSignCompanyDTO {
+
+    /** 联系人手机号(上游 {@code contact_phone},必填)。**PII**。 */
+    private String contactPhone;
+
+    /** 结算人身份证(上游 {@code principal_cert_no},必填)。**PII**。 */
+    private String principalCertNo;
+
+    /** 结算姓名(上游 {@code account_card_name},必填)。 */
+    private String accountCardName;
+
+    /** 结算卡号(上游 {@code account_card_num},必填)。**PII**。 */
+    private String accountCardNum;
+
+    /** 商户名称(上游 {@code merchant_name},必填)。 */
+    private String merchantName;
+
+    /**
+     * 营业执照(上游 {@code business_licence},必填)。
+     *
+     * <p>★ 与创建商户腿的 {@code license_no} 是**两个不同的键名**(两张表各自的写法),
+     * 值域也未在文档里说明 ⇒ 原样透传,不做格式校验(与 {@code license_no} 同一条边界:
+     * 本模块的字段一个都不校验)。
+     */
+    private String businessLicence;
+
+    /** 营业地址(上游 {@code merchant_address},必填)。自由文本,与创建腿的 {@code address_street} 不同源。 */
+    private String merchantAddress;
+
+    /** 法人身份证(上游 {@code legal_cert_no},必填)。**PII**。与结算人身份证是两个不同的字段。 */
+    private String legalCertNo;
+
+    /** 法人名称(上游 {@code legal_name},必填)。 */
+    private String legalName;
+}

+ 49 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyAnxinSignPersonDTO.java

@@ -0,0 +1,49 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 安心签个人版开户入参 —— 上游「安心签个人版开户发送验证码」接口(page 196236565,
+ * 路径 {@code /api/merchant/anxinSignPerson})。
+ *
+ * <p>该页请求参数表扣掉客户端自动补的 {@code inst_no} / {@code sign} 之后,
+ * 剩下的就是<b>本类这五个字段</b>——五个在该页**全是「必填=是」**。
+ *
+ * <p>★ <b>标题与载荷不一致(计划 §0.1,必须记在这里)</b>:菜单标题带「**发送验证码**」后缀,
+ * 而该页的 {@code 接口描述} 是**空的**、参数表却是**完整开户载荷**(含身份证、结算卡号)。
+ * ⇒ 从文档**无法判定**它到底是「只发一条短信」还是「开户并触发短信」。
+ * <b>本批次按参数表实现(请求发全量字段)</b>,并把这条歧义留在这里。
+ * 这是**可实测项**:拿到凭据后真机跑一次,看应答与副作用(是否落了商户、是否真发短信)即可判定。
+ *
+ * <p>★ 该页的**请求示例**给的是 {@code {"inst_no","level","sign"}}——那是「获取省市区」页的
+ * 示例被原样复制过来的残留(计划 §0.5),**不是本接口的形状**。参数表是规范、示例是手打的。
+ *
+ * <p>★ <b>本类不做校验</b>:与 {@link LsyCommitMerchantInfoDTO} 不同,这两个方法的边界里
+ * **没有**本地必填校验(计划 Task 5 只要求它们的 javadoc 落标题歧义,未要求校验)。
+ * 空白字段由客户端侧的 {@code signedPayload} 剔 null ⇒ 报文里没有这个键 ⇒ 上游回一句
+ * 笼统的「参数不合法」。<b>这是一条已知的、可接受的失败形态</b>,不是漏做。
+ */
+@Data
+public class LsyAnxinSignPersonDTO {
+
+    /** 联系人手机号(上游 {@code contact_phone},必填)。**PII**。 */
+    private String contactPhone;
+
+    /**
+     * 身份证(上游 {@code principal_cert_no},必填)。**PII**。
+     *
+     * <p>★ 说明列在**个人版**是「身份证」,在**公司版**({@link LsyAnxinSignCompanyDTO})
+     * 同一键名写的是「结算人身份证」—— 是同一字段在两张表里的两种描述,
+     * 不是两个不同字段(键名逐字相同,两页参数表的键名集合只差公司版多出来的四个)。
+     */
+    private String principalCertNo;
+
+    /** 姓名(上游 {@code account_card_name},必填)。公司版同一键名的说明列写「结算姓名」。 */
+    private String accountCardName;
+
+    /** 结算卡号(上游 {@code account_card_num},必填)。**PII**。 */
+    private String accountCardNum;
+
+    /** 商户名称(上游 {@code merchant_name},必填)。 */
+    private String merchantName;
+}

+ 38 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyAreaItemDTO.java

@@ -25,4 +25,42 @@ public class LsyAreaItemDTO {
      * {@code areaList_areaCode_integerShape_isStringified}(活服务形态)。
      */
     private String areaCode;
+
+    // ---- 每级各带的那一个 GB 行政码(spec §6.6.2 / §6.6.3)------------------
+    //
+    // 【★★ 这三个字段**不是**三级联动的 parent_code —— 别拿它们去填 parent_code】
+    //   拉下一级时要传的是**上级选中项的 area_code**(真机实测:填 "110000" 回 **0 条**,
+    //   填 "1" 才回数据)。而「回 0 条」是**静默**的:不报错、不抛,只是列表永远为空
+    //   —— 这正是这一类错误最贵的形态,所以写在这里的显眼处,而不是藏在某一个字段下面。
+    //
+    // 【它们是给谁用的】三-A 用不到(/api/shop/create 只收自由文本 shop_address);
+    //   三-B 的**创建商户**要用它们填 address_province / address_city / address_county。
+    //   用途与 parent_code 完全不同:一个是「往上游问下一级」,一个是「把这一级的码记下来」。
+    //
+    // 【依据是实测,不是文档】上游文档**没有记录**这三个字段(spec §6.6.2 把它们列在
+    //   「文档缺漏」而不是「文档矛盾」—— 两者的区别是:矛盾要挑一套,缺漏是上游压根没写)。
+    //   字段存在性由一次 **483 条穷举普查**覆盖:level1 34 条 / level2 352 条 / level3 97 条,
+    //   **每条都带该级自己的那个 GB 码**,且**每级各多这一个**(level1 只有 province_code,
+    //   level2 只有 city_code,level3 只有 county_code)。
+    //   ⇒ 不要因为「文档里查不到」就判它不存在:这一整类事实**只存在于实测记录里**。
+    //
+    // 【类型】见各自的 javadoc:走 asText,与 areaCode 同一条理由(活服务形态可能不是文本)。
+
+    /**
+     * 省级 GB 行政码(上游 {@code province_code},如 {@code "110000"}),**仅 level1 的条目带**。
+     *
+     * <p>它是三-B 创建商户时 {@code address_province} 的**取值来源**。
+     * <b>不要把它当 {@code parent_code} 用</b>(理由见本类这段字段块开头的说明)。
+     *
+     * <p>类型按 {@code String} 承载、读取走 {@code asText}:483 条普查量的是
+     * {@code area_code} 的类型(Integer 483/483),**没有**单独量过这三个字段
+     * ⇒ 按更宽的一侧收(依据详见 {@link #areaCode},同一条理由)。
+     */
+    private String provinceCode;
+
+    /** 市级 GB 行政码(上游 {@code city_code},如 {@code "110100"}),**仅 level2 的条目带**。详见 {@link #provinceCode}。 */
+    private String cityCode;
+
+    /** 区/县级 GB 行政码(上游 {@code county_code},如 {@code "110101"}),**仅 level3 的条目带**。详见 {@link #provinceCode}。 */
+    private String countyCode;
 }

+ 46 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyMccItemDTO.java

@@ -0,0 +1,46 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 行业类目的一项 —— 上游「获取行业类目(根据通道)」接口(page 196236569,
+ * 路径 {@code /api/merchant/getTopMccByType})返回的 {@code mcc_list} 里的一个元素。
+ *
+ * <p>★ <b>键名按该页的「返回示例」取,不按「返回参数表」取。</b>同一页两处互相冲突:
+ * <ul>
+ *   <li><b>返回参数表</b>写的是 {@code area_list} / {@code area_name} / {@code area_code}
+ *       —— 与「获取省市区」页(page 196236568)逐字相同的**复制残留**(计划 §0.5 文档缺陷清单);
+ *       该页的 {@code 接口描述} 也是「获取省市区」那份文案,是同一个复制动作留下的第二处痕迹。</li>
+ *   <li><b>返回示例</b>给的是 {@code mcc_list} / {@code mcc_name} / {@code mcc_code}。</li>
+ * </ul>
+ * ⇒ 按示例取。判据不是「示例总是更权威」,而是**这一处冲突里只有示例那一组自洽**:
+ * 参数表那组的键名与接口语义(行业类目)完全不搭,照它实现会在活服务上永远回空列表
+ * —— 而那是**静默**失败(缺键回空列表,不报错、不抛)。
+ *
+ * <p>★ <b>兄弟接口不要混:</b>page 196236570({@code /api/info/mcc}「获取行业类目信息」)
+ * 是**另一个接口**,本批次不实现(计划 §0.6 的记账)。它才是真的可能用 {@code area_list}
+ * 形状的那一个 —— 两页是同级兄弟页,标题只差「信息」二字。不要把本 DTO 拿去给它用。
+ */
+@Data
+public class LsyMccItemDTO {
+
+    /** 类目名称(上游 {@code mcc_name}),如「商业及生活服务」。 */
+    private String mccName;
+
+    /**
+     * 类目编码(上游 {@code mcc_code})。**字段保持 {@code String}。**
+     *
+     * <p>★ 该页的返回示例里它是**数字**({@code 1} / {@code 7} / {@code 32} /
+     * {@code 47} / {@code 86} / {@code 125}),而同一页的返回参数表把它声明成
+     * {@code String} —— 又是一处「同页两处冲突」。
+     *
+     * <p>⇒ <b>按更宽的收</b>({@code String} + 服务侧的 {@code asText}),理由与
+     * {@link LsyAreaItemDTO#getAreaCode()} 同源、且那边有实测背书:{@code area_code}
+     * 在这条腿上是**文档说 String、活服务发 Integer**(483/483)。收窄成 {@code Integer}
+     * 会在上游真给字符串(或给空 / {@code null})时炸;反过来,宽的那一侧永远接得住。
+     *
+     * <p>它还要**原样回传上游**(创建商户的 {@code merchant_mcc_code}、以及本接口下一级
+     * 联动的 {@code parent_id}),所以文本形态本来就是它的正确形态。
+     */
+    private String mccCode;
+}

+ 235 - 6
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -6,8 +6,11 @@ import cn.hutool.json.JSONNull;
 import com.payment.platform.common.exception.BusinessException;
 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.LsyAnxinSignCompanyDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyAnxinSignPersonDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyAreaItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyCommitMerchantInfoDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMccItemDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
@@ -685,11 +688,172 @@ public class LsyOnboardingService {
     }
 
     /**
-     * 进件凭据闸 —— 三-B **四条腿**(创建 / 更新 / 提交入网 / 入网状态查询)共用一份。
+     * 获取行业类目(根据通道)—— 上游 page 196236569,路径
+     * {@code /api/merchant/getTopMccByType},用于进件表单里「行业类目」的三级联动。
+     *
+     * <p>上游入参:{@code inst_no}(是) / {@code level}(是:{@code 1} 一级行业、{@code 2} 二级、
+     * {@code 3} 三级) / {@code parent_id}(否,当前等级的上级 code,**1 级不填**) /
+     * {@code merchant_type}(否,星驿付必传) / {@code channel_type}(是) / {@code sign}(是);
+     * {@code inst_no} 与 {@code sign} 由 {@link LeshouyingClient} 补
+     * ⇒ 服务层交出去的是**恰好 4 个键**。
+     * 出参:{@code result_code} / {@code result_msg} / {@code mcc_list[]}。
+     *
+     * <p>★ <b>取 {@code mcc_list},不取 {@code area_list}</b>:该页的**返回参数表**写的是
+     * {@code area_list} / {@code area_name} / {@code area_code}(与「获取省市区」页逐字相同的
+     * 复制残留),而**返回示例**给的是 {@code mcc_list} / {@code mcc_name} / {@code mcc_code}。
+     * 判据与理由见 {@link LsyMccItemDTO} 的类注释(那里写了一次,不复述第二份)。
+     *
+     * <p>★ <b>该页的上级 code 键名是 {@code parent_id},不是「获取省市区」页的
+     * {@code parent_code}。</b>两页表达同一件事用了两个名字;键名进**签名原文**,
+     * 写错不抛异常,只会让上游回一句笼统的「参数不合法」。{@code level=1} 时**不上送**
+     * (判据是 {@code level},不是「{@code parentId} 是不是 null」—— 与
+     * {@link #areaList} 逐字同一条规则,那里的注释解释了为什么「填空串」也不行)。
+     *
+     * <p>★ <b>{@code merchant_type} 本地不校验,原样透传。</b>该页给的值域
+     * ({@code 1个体工商户 2企业 3政府组织及事业单位 4小微商户 5其他机构组织})
+     * 与创建商户页那三处一致的值域**把 1 和 2 对调**(计划 §0.5)⇒ 本层不知道谁对,
+     * 任何校验都是在挑一套猜。该参数在该页是「否」,且说明列写「星驿付必传」。
+     *
+     * <p>★ <b>本方法也走凭据闸</b>(见 {@link #requireOnboardingCredentials}):
+     * {@code channel_type} 是那五项之一,而它取自配置 ⇒ 与另外几条腿同源。
+     * 少了这道闸,配置缺失时本方法会把一个**从未被确认存在**的 {@code channel_type}
+     * 发出去(客户端剔 null ⇒ 报文里干脆没有这个必填键),失败只在上游、本侧静默。
+     *
+     * <p>★ {@code channel_type} 取自**配置**而不是形参:该页说明列逐字「由客户经理提供」
+     * —— 它是一个**部署级凭据**(存在性受闸),做成入参就等于让这条腿绕过闸。
+     * 它按该页声明的 {@code String} 发(不做数字规范化),但**过 trim** ——
+     * 与 {@code channel_id} 同一条口径,理由见 {@link #stringChannelId}。
+     *
+     * @param level        列表等级:{@code 1} 一级行业 / {@code 2} 二级 / {@code 3} 三级
+     * @param parentId     当前等级的上级 code;{@code level} 为 {@code 1} 时**不上送**
+     * @param merchantType 商户类型(该页为非必填)。**原样透传,不做本地校验**
+     * @return 类目列表;上游没给 {@code mcc_list}(缺键 / null / 不是数组)时返回**空列表**,
+     *         不返回 null,也不抛(口径与 {@link #areaList} 一致)
+     * @throws BusinessException 400 服务端进件凭据缺失,**不发上游**
+     */
+    public List<LsyMccItemDTO> mccList(String level, String parentId, String merchantType) {
+        // ① 凭据闸(channel_type 是五项之一)
+        requireOnboardingCredentials();
+
+        // ② 组装:4 个键(inst_no / sign 归客户端补)
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("level", level);
+        // ★ 「1 级不填」的判据是 level,不是「parentId 是不是 null」——与 areaList 同一条规则
+        //   (null 不参与、空串参与;这里若改填 "",签名原文会多出一项 parent_id=)。
+        if (!LEVEL_PROVINCE.equals(level)) {
+            p.put("parent_id", parentId);
+        }
+        // 原样透传:不在本层校验值域(两页的值域互相冲突,见 javadoc)
+        p.put("merchant_type", merchantType);
+        // 该页声明 String ⇒ 不做数字规范化;但 trim 与 channel_id 同口径
+        p.put("channel_type", trimOrNull(props.getChannelType()));
+
+        // ③ 上送 + 解析
+        return toMccItems(client.postApi("/api/merchant/getTopMccByType", p));
+    }
+
+    /**
+     * 安心签**个人版**开户(上游 page 196236565,路径 {@code /api/merchant/anxinSignPerson})。
+     *
+     * <p>上游入参:{@code inst_no}(是,客户端补) / {@code contact_phone}(是,联系人手机号) /
+     * {@code principal_cert_no}(是,身份证) / {@code account_card_name}(是,姓名) /
+     * {@code account_card_num}(是,结算卡号) / {@code merchant_name}(是,商户名称) /
+     * {@code sign}(是,客户端补)。出参:{@code result_code} / {@code result_msg}
+     * —— 非 {@code SUCCESS} 已在客户端抛异常,故本方法**不读应答**。
+     *
+     * <p>★★ <b>标题与载荷不一致,这是一条<u>记录在案的歧义</u>(计划 §0.1)。</b>
+     * 菜单标题是「安心签个人版开户**发送验证码**」,而该页的 {@code 接口描述} 是**空的**、
+     * 请求参数表却是**完整开户载荷**(含身份证、结算卡号)⇒ 从文档**无法判定**
+     * 它到底是「只发一条短信」还是「开户并触发短信」。
+     * <b>本批次按参数表实现(请求发全量字段)</b>,歧义留在
+     * {@link LsyAnxinSignPersonDTO} 的类注释里。
+     * <b>这是可实测项</b>:拿到凭据后真机跑一次、看应答与副作用(是否落了商户、是否真发短信)即可判定。
+     * 该页的**请求示例**({@code {"inst_no","level","sign"}})是「获取省市区」页的复制残留,
+     * 不是本接口的形状 —— 参数表是规范、示例是手打的。
+     *
+     * <p>★ <b>本方法不设凭据闸</b>,这是与 {@link #mccList} 的**有意差别**,判据是
+     * 「这条腿用不用那五项凭据」:它只用 {@code inst_no} 与机构密钥,而这两个
+     * **由启动期的 {@code LeshouyingProperties.validate()} 兜住**(缺失即起不来)
+     * ⇒ 再设一道运行时闸只是把已经 fail-fast 的东西再查一遍。
+     * 同理**也不 trim 任何业务字段**:它们不是配置项,是调用方给的报文内容。
+     *
+     * <p>★ <b>本方法不做必填校验</b>(计划 Task 5 对它只要求「javadoc 落标题歧义」,未要求校验);
+     * 空白字段会被客户端侧的 {@code signedPayload} 剔 null ⇒ 上游回一句笼统的「参数不合法」。
+     * 与 {@link #commitMerchantInfo} 的本地校验**是有意的不一致**,不是漏做 —— 记在报告里。
+     *
+     * <p>★ <b>入参是真人 PII</b>(身份证 / 结算卡号 / 手机号)。出站那一行 trace 日志
+     * 走 {@code maskByKey},键层已按 {@code LeshouyingSensitive} 的名单遮住
+     * {@code principal_cert_no} / {@code account_card_num} / {@code contact_phone}
+     * —— 名单与判据在那里,改名单时两边一起看。
+     *
+     * @param dto 五个字段;空白由上游判,本层不拦
+     * @throws com.payment.platform.module.payment.leshouying.client.LeshouyingApiException 上游非 {@code SUCCESS}
+     */
+    public void anxinSignPerson(LsyAnxinSignPersonDTO dto) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("contact_phone", dto.getContactPhone());
+        p.put("principal_cert_no", dto.getPrincipalCertNo());
+        p.put("account_card_name", dto.getAccountCardName());
+        p.put("account_card_num", dto.getAccountCardNum());
+        p.put("merchant_name", dto.getMerchantName());
+
+        client.postApi("/api/merchant/anxinSignPerson", p);
+    }
+
+    /**
+     * 安心签**企业和个体**开户(上游 page 196236566,路径 {@code /api/merchant/anxinSignCompany})。
      *
-     * <p>【为什么共用一个私有方法】四条腿的 {@code channel_id}(以及提交入网那条要用到的通道族字段)
-     * 都是**取自配置**的;只在创建腿设闸,会造出「配置缺失时创建腿拒、更新腿**静默用空值上送**」
-     * 的分叉。同理,抄四份就会出现「某天只给其中一处加校验」——与 {@link #mchNoParams} 同一条理由。
+     * <p>入参是 {@link #anxinSignPerson} 的**严格超集**:前五个键名逐字相同,另加
+     * {@code business_licence}(营业执照) / {@code merchant_address}(营业地址) /
+     * {@code legal_cert_no}(法人身份证) / {@code legal_name}(法人名称)
+     * ⇒ 服务层交出去的是**恰好 9 个键**。出参同个人版(只有两个信封字段,不读应答)。
+     *
+     * <p>★★ <b>标题与载荷不一致:与个人版逐字同一条歧义</b>(菜单标题带「发送验证码」、
+     * 接口描述为空、参数表是完整开户载荷 ⇒ 按参数表发全量)。完整说明与「这是可实测项」
+     * 那句写在 {@link #anxinSignPerson} 的 javadoc 里,**不复述第二份**;
+     * 两个 DTO 的类注释各自也留了一份(读哪个 DTO 的人不必回头翻服务)。
+     *
+     * <p>★ 不设凭据闸、不做必填校验、不 trim 业务字段:三条理由与 {@link #anxinSignPerson}
+     * 逐字相同(那里写了一次)。
+     *
+     * <p>★ <b>入参含四个 PII 字段</b>(结算人与法人各自的身份证、结算卡号、联系电话);
+     * 出站 trace 的遮罩名单见 {@link #anxinSignPerson} 的同名段落。
+     *
+     * @param dto 九个字段;空白由上游判,本层不拦
+     * @throws com.payment.platform.module.payment.leshouying.client.LeshouyingApiException 上游非 {@code SUCCESS}
+     */
+    public void anxinSignCompany(LsyAnxinSignCompanyDTO dto) {
+        Map<String, Object> p = new LinkedHashMap<>();
+        p.put("contact_phone", dto.getContactPhone());
+        p.put("principal_cert_no", dto.getPrincipalCertNo());
+        p.put("account_card_name", dto.getAccountCardName());
+        p.put("account_card_num", dto.getAccountCardNum());
+        p.put("merchant_name", dto.getMerchantName());
+        p.put("business_licence", dto.getBusinessLicence());
+        p.put("merchant_address", dto.getMerchantAddress());
+        // ★ legal_cert_no(法人身份证)与 principal_cert_no(结算人身份证)是两个不同的字段:
+        //   上游公司版把两者并列在参数表里,说明列逐字是「结算人身份证」与「法人身份证」。
+        //   取错 getter 不会抛异常,只会让法人身份证被填成结算人的 —— 而两者都是 PII。
+        p.put("legal_cert_no", dto.getLegalCertNo());
+        p.put("legal_name", dto.getLegalName());
+
+        client.postApi("/api/merchant/anxinSignCompany", p);
+    }
+
+    /**
+     * 进件凭据闸 —— 三-B **五条腿**(创建 / 更新 / 提交入网 / 入网状态查询 / 行业类目)共用一份。
+     *
+     * <p>【为什么共用一个私有方法】这五条腿的 {@code channel_id} 或 {@code channel_type}
+     * (以及提交入网那条要用到的通道族字段)都是**取自配置**的;只在一部分腿上设闸,
+     * 会造出「配置缺失时创建腿拒、别的腿**静默用空值上送**」的分叉。同理,抄几份就会出现
+     * 「某天只给其中一处加校验」——与 {@link #mchNoParams} 同一条理由。
+     *
+     * <p>★ <b>适用面是「用不用那五项凭据」,不是「是不是进件腿」</b>:Task 5 新增的三条腿里
+     * 只有 {@link #mccList} 走这道闸(它要用 {@code channel_type});
+     * {@link #anxinSignPerson} / {@link #anxinSignCompany} **不设**,因为它们只用
+     * {@code inst_no} 与机构密钥 —— 那两个由启动期的
+     * {@code LeshouyingProperties.validate()} 兜住(缺失即起不来),
+     * 再设一道运行时闸只是把已经 fail-fast 的东西再查一遍。
      *
      * <p>【对外文案与明细出口】明细(配置键名 + 环境变量名)**只进日志**,对外 message =
      * 中文固定文案 + **缺失项数**。对外文案与日志的口径、以及「不得把环境变量名换成中文标签再发出去」
@@ -732,8 +896,33 @@ public class LsyOnboardingService {
      * 这样即便将来闸与组装的顺序被改动,也不会在这里 NPE。
      */
     private String stringChannelId() {
-        String channelId = props.getChannelId();
-        return channelId == null ? null : channelId.trim();
+        return trimOrNull(props.getChannelId());
+    }
+
+    /**
+     * 配置项 {@code String} 出站值的**统一口径:只 trim,不做别的**。
+     *
+     * <p>【为什么抽出来】{@code channel_id}(五条腿)与 {@code channel_type}
+     * ({@link #mccList})走的是**同一条规则**。抄两份就会出现「某天只给其中一处加处理」
+     * —— 与 {@link #requireOnboardingCredentials} 抽出来共用是同一条理由。
+     *
+     * <p>【为什么必须 trim】创建/更新两条腿走 {@link #upstreamInteger},而它的第一步就是
+     * {@code trim()}。其余腿若原样发,同一个配置值就会「有的腿修剪、有的腿不修剪」——
+     * 配置来自**环境变量**(联调时用户手工注入),带首尾空白是现实风险,而这些值都进
+     * **签名原文** ⇒ 症状是上游回一句签名错误,本侧看不出是空白。
+     *
+     * <p>【为什么不做别的】{@code String} 腿**不做** {@code upstreamInteger} 那套数字规范化
+     * ——「按各页声明发」是既定口径(创建/更新页声明 {@code Integer(11)},
+     * 提交入网/入网状态/行业类目三页声明光秃秃的 {@code String})。
+     * 统一的是**空白**这一个轴,**不是**类型。
+     *
+     * <p>空白值到不了这些调用点:调用方先过 {@link #requireOnboardingCredentials}
+     * ({@code channel_id} / {@code channel_type} 都在那五项里)⇒ 本方法只需处理
+     * 「非空白、但首尾带空白」这一态。写法仍取 {@code null} 安全的形态,
+     * 这样即便将来闸与组装的顺序被改动,也不会在这里 NPE。
+     */
+    private static String trimOrNull(String value) {
+        return value == null ? null : value.trim();
     }
 
     /**
@@ -1230,6 +1419,46 @@ public class LsyOnboardingService {
             LsyAreaItemDTO item = new LsyAreaItemDTO();
             item.setAreaName(asText(m.get("area_name")));
             item.setAreaCode(asText(m.get("area_code")));
+            // ★ 每级各带的那一个 GB 行政码(spec §6.6.2 的「文档缺漏」)。
+            //   三行**分别写**而不是「按 level 选一个键」:同一份报文里它们本就互斥
+            //   (level1 只有 province_code),分着读既不会串级,也不必让映射依赖形参 level
+            //   —— 形参 level 是**调用方给的**,而上游回什么键是**上游决定**的,两者不必一致。
+            //   读不到就是 null(Map.get 给 Java null、JSONNull 由 asText 归一),不补空串:
+            //   它们要进 address_province 这类**签名原文**字段,空串与「没填」是两份不同的报文。
+            //   用途与「不是 parent_code」这条警告见 LsyAreaItemDTO 的字段块注释。
+            item.setProvinceCode(asText(m.get("province_code")));
+            item.setCityCode(asText(m.get("city_code")));
+            item.setCountyCode(asText(m.get("county_code")));
+            items.add(item);
+        }
+        return items;
+    }
+
+    /**
+     * {@code mcc_list} → DTO 列表的**防御性读取**:缺键、null、不是数组,三种都归成空列表;
+     * 数组元素不是对象时跳过。
+     *
+     * <p>口径、理由与 {@link #toAreaItems} **逐字相同**(那里写得细,不复述第二份):
+     * 三种输入都是契约里写明的,非对象元素跳过是为了不产出「两个字段都为 null」的空条目
+     * (那会出现在行业类目下拉里被用户选中)。字段值一律过 {@link #asText},
+     * 因为这里是**嵌套**数组而解析器对 JSON null 的归一化只走顶层。
+     *
+     * <p>★ <b>读的键是 {@code mcc_list}</b>,不是该页返回参数表里的 {@code area_list}
+     * ——理由见 {@link LsyMccItemDTO} 的类注释。
+     */
+    private static List<LsyMccItemDTO> toMccItems(Map<String, Object> resp) {
+        Object raw = resp.get("mcc_list");
+        if (!(raw instanceof List<?> list)) {
+            return new ArrayList<>();
+        }
+        List<LsyMccItemDTO> items = new ArrayList<>(list.size());
+        for (Object o : list) {
+            if (!(o instanceof Map<?, ?> m)) {
+                continue;
+            }
+            LsyMccItemDTO item = new LsyMccItemDTO();
+            item.setMccName(asText(m.get("mcc_name")));
+            item.setMccCode(asText(m.get("mcc_code")));
             items.add(item);
         }
         return items;

+ 477 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingServiceTest.java

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