浏览代码

feat(进件): 更新/提交入网/入网状态查询三条薄腿(Task 4)

三条腿照 createMerchant 的四段形状落地(①凭据闸 → ②必填 → ③组装 → ④上送),
并把①抽成 requireOnboardingCredentials() 由四条腿共用(裁定 R38:只在创建腿设闸,
会造出「配置缺失时创建腿拒、更新腿静默用空 channel_id 签名上送」的分叉 —— 那种形态
本侧不报错,错只在上游)。

- updateMerchant(page 196236557,/api/merchant/update):按页末「增量更新」那句实现
  「有值才发」——空白字段**不进参数 Map**(putIfNotBlank 不 put,而不是 put(null) 或 put("")),
  于是增量语义可以用一句键集断言钉住;channel_id 按该页声明发 Integer(11)。
  account_type 是普通可选字段(该页表里根本没有 merchant_type,创建腿那套一个 category
  派生两个输出的机制在这条腿不成立);rate_code **不回落配置**(增量更新下硬发配置值
  = 静默把商户费率改成平台默认)。该页「必填=是」与「增量更新」两句互相矛盾,
  两段原文逐字记在 DTO 类 javadoc 里,并按可逆的那一侧实现(判错则联调第一个请求响亮失败;
  反方向是静默改账户类型)——已列入待确认项。
- commitMerchantInfo(page 196236563,/api/merchant/commitMerchantInfo):六个业务字段逐个
  校验(该页无增量语句,必填标记无歧义),标签逐字取说明列(含上游错字「营业地址维度」);
  store_Loca / mcht_Landuse 按参数表原文大小写发出(进签名原文,改一个字母就换签名);
  channel_id 按该页声明发 String。该页请求示例是「获取省市区」页残留,按参数表实现。
- reportQuery(page 196236564,/api/merchant/reportQuery):只做 asText(reviewed_status)
  原样返回,不映射码表、**不复用 LsyReviewStatus**(裁定 R41:同名 reviewed_status 在本仓
  已有第三套值域,码 2 在 196236564 是「审核驳回」而在那一套里没有对应项,复用会静默给出
  错误文案);该页同页两套互相冲突的 0-6 映射逐字抄进 javadoc;缺键/null 回 Java null 不抛。
  channel_id 只收一个参数、取自配置(裁定 R37,原 Produces 的 (mchNo, channelId) 作废)。

测试 +9(本类 57 → 66):三条腿的组装/键集/大小写/必填/码表原样/缺键,外加一条三条腿
共用的凭据闸 —— 这条是变异探针逼出来的:把 requireOnboardingCredentials() 从
commitMerchantInfo 与 reportQuery 里各删一行,65 条全绿(只有 updateMerchant 那条在 ③ 段
钉了闸),那种缺口本侧不可见,故单独补一条把三条腿的闸一次钉住。

变异验证(两向,读数与还原指纹见 task-4-report.md):
  M-1 putIfNotBlank 改成无条件 put        2 红(键集断言点出多出的空白键)
  M-2 store_Loca 改小写 store_loca        2 红(大小写用例 + 九键集)
  M-3 两条腿各删凭据闸一行                65 绿 → 补用例后 1 红(先红后绿,TDD 序)
  M-4 reportQuery 改走 LsyReviewStatus    2 红(码 9 读成「审核驳回」,即 R41 那个陷阱)
  M-5 加一个恒真的 value != null(无害)   66 绿,javap 证明变异体进了被执行的 class

测试:全量 538 → 547,0 fail / 0 error / 0 skipped,BUILD SUCCESS。
alphaH 3 周之前
父节点
当前提交
84a3039bc7

+ 62 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyCommitMerchantInfoDTO.java

@@ -0,0 +1,62 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 提交商户入网信息入参 —— 上游「提交商户入网信息」接口(page 196236563,
+ * 路径 {@code /api/merchant/commitMerchantInfo})。
+ *
+ * <p>该页请求参数表扣掉客户端自动补的 {@code inst_no} / {@code sign}、服务端生成的
+ * {@code trace_no}、以及取自身份/配置的 {@code merchant_no} / {@code channel_id} 之后,
+ * 剩下的就是<b>本类这六个字段</b>——六个在该页**全是「必填=是」**(与更新页不同:本页没有
+ * 「增量更新」那句话,必填标记没有歧义 ⇒ 本地逐个校验,把上游的必填要求提前到出站之前,
+ * 只改失败形态)。
+ *
+ * <p>★ 该页的**请求示例**给的是 {@code {"inst_no","level","sign"}}——那是「获取省市区」页的
+ * 示例被原样复制过来的残留(计划 §0.5 的文档缺陷清单),**不是本接口的形状**。
+ * 参数表是规范、示例是手打的 ⇒ 按参数表实现。
+ *
+ * <p>★ <b>本类不做校验</b>:空白一律由 {@code LsyOnboardingService.commitMerchantInfo} 在
+ * **组报文之前**拒掉(400,文案用中文字段名),与 {@link LsyShopCreateDTO} 同一条边界。
+ */
+@Data
+public class LsyCommitMerchantInfoDTO {
+
+    /** 乐收赢商户号(上游 {@code merchant_no},必填)。空白 ⇒ 400,**不发上游**。 */
+    private String mchNo;
+
+    /**
+     * 商户资质(上游 {@code mcht_aptitude},必填)。
+     *
+     * <p>该页给出的值域:{@code 01} 央企、国企,{@code 02} 央企国企子公司,{@code 03} 上市公司,
+     * {@code 04} 上市公司子公司,{@code 05} 全国连锁企业,{@code 06} 当地连锁企业,
+     * {@code 07} 一般商户,{@code 08} 五星级及以上或同等级商务酒店,{@code 09} 四星级或同等级商务酒店,
+     * {@code 10} 准四星酒店,{@code 11} 三星酒店,{@code 12} 二星级及以下酒店宾馆。
+     * <b>本类不按这张表校验</b>(上游加码是预期内的事,本地拒绝的代价是拒绝服务)——原样透传。
+     */
+    private String mchtAptitude;
+
+    /** 商户资质备注(上游 {@code mcht_aptitude_note},必填)。 */
+    private String mchtAptitudeNote;
+
+    /**
+     * 门店所在位置(上游 {@code store_Loca},必填)。
+     *
+     * <p>★ <b>键名大小写是参数表原文</b>(首段小写 + {@code L} 大写):进签名原文,改一个字母就换签名。
+     */
+    private String storeLoca;
+
+    /**
+     * 营业用地(上游 {@code mcht_Landuse},必填)。
+     *
+     * <p>★ 键名大小写同 {@link #storeLoca}:{@code L} 大写是原文,不得「顺手规整」成
+     * {@code mcht_landuse}。
+     */
+    private String mchtLanduse;
+
+    /** 营业地址经度(上游 {@code longitude},必填)。 */
+    private String longitude;
+
+    /** 营业地址纬度(上游 {@code latitude},必填)。上游说明列原文写的是「营业地址维度」(错字),如实照抄。 */
+    private String latitude;
+}

+ 231 - 0
java/src/main/java/com/payment/platform/module/payment/leshouying/dto/LsyMerchantUpdateDTO.java

@@ -0,0 +1,231 @@
+package com.payment.platform.module.payment.leshouying.dto;
+
+import lombok.Data;
+
+/**
+ * 更新商户信息入参 —— 上游「更新商户信息」接口(page 196236557,路径 {@code /api/merchant/update})。
+ *
+ * <p><b>字段 = 该页请求参数表</b>({@code mchNo} 之外 50 个),**不是** {@link LsyMerchantCreateDTO}
+ * 的子集/超集,两个类不通约:
+ * <ul>
+ *   <li><b>创建页有、本页没有</b>:{@code merchant_type} / {@code merchant_name_business}
+ *       (该页接口描述逐字写着「存在少数字段商户创建后即固定,无法修改,如:
+ *       {@code merchant_name_business},{@code merchant_type}等」)、{@code bd_id} / {@code notify_url}、
+ *       {@code lease_contract} / {@code share_scene} / {@code share_certificate} /
+ *       {@code tripartite_flow_certificate_url}。</li>
+ *   <li><b>本页有、创建页没有</b>:无。</li>
+ * </ul>
+ * ⇒ <b>{@code merchant_type} 与 {@code account_type} 在本页的处境完全不同</b>:
+ * {@code merchant_type} 压根不在表里,而 {@code account_type} 在表里且标「必填=是」。
+ * 所以创建腿那套「{@code category} 一个输入派生出 {@code merchant_type} / {@code account_type}
+ * <b>两个</b>输出」的机制在这条腿**不成立**(少了一个输出)——
+ * {@link #accountType} 因此是一个**普通字段**,本类**没有** {@code category}。
+ *
+ * <p>上游入参里另有 {@code inst_no} / {@code sign}(由 {@code LeshouyingClient} 在出站链路上补)、
+ * {@code trace_no}(由 {@code mchNoParams} 每次新生成)、{@code merchant_no}(就是
+ * {@link #mchNo})、{@code channel_id}(取自配置)——**一个都不在本类里**。
+ *
+ * <p><b>★★ 本页文档内部自相矛盾,两段都逐字记在这里,不要照着其中一段改另一段:</b>
+ * <ol>
+ *   <li>参数表把 {@code account_type} 标成「<b>必填=是</b>」;</li>
+ *   <li>同一页页末逐字写着「<b>注:此接口更新模式为增量更新,请求参数有值则更新,请求参数无值则保持原数据</b>」。</li>
+ * </ol>
+ * 这两句在增量语义下互斥(「必填」要求每次都带,「有值才更新」允许不带)。
+ * <b>本批次按增量语义实现</b>({@code account_type} 有值才发),理由两条:
+ * ① 反方向(每次硬发一个值)会**静默改掉**商户的结算账户类型 —— 那是会造成真实损失的方向;
+ * ② 增量语义下「必填」读成「要改这个字段时必须有值」也自洽。
+ * <b>代价若判错</b>:若上游真要求每次都带,联调时**第一个请求就响亮失败**(信封非 SUCCESS ⇒
+ * {@code LeshouyingApiException}),改一处即可 —— 与静默改账户类型相比,这是可逆的那一侧。
+ * <br>★ 这是**待确认项**,已记入本批次的联调清单。
+ *
+ * <p>★ <b>本类不做校验、不做归一化</b>:空白一律由
+ * {@code LsyOnboardingService.updateMerchant} 在**组报文之前**按增量语义剔掉
+ * (校验点只有一处 —— 放在 DTO 的注解上会变成两份口径,而两份口径分叉的症状是
+ * 「某个字段有时拦、有时不拦」,与 {@link LsyShopCreateDTO} 同一条边界)。
+ */
+@Data
+public class LsyMerchantUpdateDTO {
+
+    /** 乐收赢商户号(上游 {@code merchant_no},必填)。空白 ⇒ 400,**不发上游**。 */
+    private String mchNo;
+
+    // ===== 商户基础信息(本页全为可选)=====
+
+    /** 商户名称(上游 {@code merchant_name})。 */
+    private String merchantName;
+
+    /** 商户简称(上游 {@code merchant_name_short})。 */
+    private String merchantNameShort;
+
+    /** 商户行业类目 code(上游 {@code merchant_mcc_code})。 */
+    private String merchantMccCode;
+
+    /** 商户签约负责人(上游 {@code contact_name})。 */
+    private String contactName;
+
+    /** 商户签约负责人电话(上游 {@code contact_phone})。 */
+    private String contactPhone;
+
+    /** 商户签约负责人邮箱(上游 {@code contact_email})。 */
+    private String contactEmail;
+
+    /** 商户客服电话(上游 {@code contact_service})。 */
+    private String contactService;
+
+    /** 商户地址省 code(上游 {@code address_province})。 */
+    private String addressProvince;
+
+    /** 商户地址市 code(上游 {@code address_city})。 */
+    private String addressCity;
+
+    /** 商户地址区县 code(上游 {@code address_county})。 */
+    private String addressCounty;
+
+    /** 商户地址具体街道门牌号(上游 {@code address_street})。 */
+    private String addressStreet;
+
+    /** 营业执照类型(上游 {@code license_type})。 */
+    private String licenseType;
+
+    /** 营业执照编号(上游 {@code license_no})。 */
+    private String licenseNo;
+
+    /**
+     * 营业执照有效期起(上游 {@code license_stt})。
+     *
+     * <p>★ 本页的说明列写的是「营业执照有效期(格式YYYYMMDD)」,与 {@link #licenseExpire} 的说明
+     * **逐字相同**(计划 §0.5:该页两行的说明重复)⇒ 本字段的确切语义**不明**。
+     * 按 §0.5 的裁定**原样透传、不加校验**(校验就是猜)。
+     */
+    private String licenseStt;
+
+    /** 营业执照有效期止(上游 {@code license_expire})——「长期传20991231」。 */
+    private String licenseExpire;
+
+    // ===== 结算信息 =====
+
+    /**
+     * 账户类型(上游 {@code account_type}):{@code 1}对公结算、{@code 2}法人对私结算、
+     * {@code 3}非法人对私结算。
+     *
+     * <p>★ <b>本页标它「必填=是」,但按增量语义实现(有值才发)</b> —— 两段依据的原文与代价
+     * 逐字写在**类 javadoc** 里(那里是唯一的落点,不要在这里复述第二份)。
+     */
+    private String accountType;
+
+    /** 法人名称(上游 {@code legal_name})。 */
+    private String legalName;
+
+    /** 法人证件号(上游 {@code legal_cert_no})。 */
+    private String legalCertNo;
+
+    /** 法人证件起始日(上游 {@code legal_cert_stt}),格式 {@code YYYYMMDD}。 */
+    private String legalCertStt;
+
+    /** 法人证件到期日(上游 {@code legal_cert_expire}),长期传 {@code 20991231}。 */
+    private String legalCertExpire;
+
+    /** 持卡人身份证号(上游 {@code account_idcard_num})。本模块的 PII,日志走既有脱敏。 */
+    private String accountIdcardNum;
+
+    /** 持卡人身份证起始日(上游 {@code account_idcard_stt}),格式 {@code YYYYMMDD}。 */
+    private String accountIdcardStt;
+
+    /** 持卡人身份证到期日(上游 {@code account_idcard_expire}),长期传 {@code 20991231}。 */
+    private String accountIdcardExpire;
+
+    /** 结算卡账户名(上游 {@code account_card_name})。 */
+    private String accountCardName;
+
+    /** 结算卡卡号(上游 {@code account_card_num})。本模块的 PII,日志走既有脱敏。 */
+    private String accountCardNum;
+
+    /** 结算卡预留手机号(上游 {@code account_card_phone})。 */
+    private String accountCardPhone;
+
+    /** 结算银行支行联行号(上游 {@code account_bank_code})。 */
+    private String accountBankCode;
+
+    /** 注册资本,单位元(上游 {@code reg_capital};本页标 {@code C}「平安收银宝必填」)。 */
+    private String regCapital;
+
+    /** 结算账户类型(上游 {@code fSettle_account_type};本页标 {@code C}「平安收银宝必填」)。 */
+    private String fSettleAccountType;
+
+    /** 营业地址(上游 {@code actual_addr};本页标 {@code C}「平安收银宝必填」)。 */
+    private String actualAddr;
+
+    /** 营业范围(上游 {@code business_scope};本页标 {@code C}「平安收银宝必填」)。 */
+    private String businessScope;
+
+    /** 安心签验证码(上游 {@code check_code};本页标 {@code C}「星驿付必填」)。 */
+    private String checkCode;
+
+    /**
+     * 费率模板(上游 {@code rate_code})。
+     *
+     * <p>★ <b>这里**不**回落配置</b>(与创建腿 {@code rate_code} 的 §0.8 口径**不同**,那是**有意**的):
+     * 创建腿「DTO 没值就用平台级配置」是给新商户定一个默认费率;而本接口是**增量更新**,
+     * 每次更新都硬塞一个配置值 = **静默把商户的费率改成平台默认**(针对这个商户谈的费率会无声消失)。
+     * 所以本字段只在调用方给了值时才发。
+     */
+    private String rateCode;
+
+    // ===== 资质图片 =====
+
+    /** 营业执照(或持件人照片)(上游 {@code picture_license})。 */
+    private String pictureLicense;
+
+    /** 负责人身份证正面(上游 {@code picture_idcard_a})。 */
+    private String pictureIdcardA;
+
+    /** 负责人身份证反面(上游 {@code picture_idcard_b})。 */
+    private String pictureIdcardB;
+
+    /** 银行卡正面(上游 {@code picture_bankcard_a})。 */
+    private String pictureBankcardA;
+
+    /** 银行卡反面(上游 {@code picture_bankcard_b})。 */
+    private String pictureBankcardB;
+
+    /** 门头照片(上游 {@code picture_outside})。 */
+    private String pictureOutside;
+
+    /** 内部前台照片(上游 {@code picture_inside})。 */
+    private String pictureInside;
+
+    /** 三联单合同照片(上游 {@code picture_tripartite})。 */
+    private String pictureTripartite;
+
+    /**
+     * 手持身份证(上游 {@code picture_idcard_hander})。
+     *
+     * <p>★ 键名里 {@code hander} 是**上游自己拼错的**(应为 handler),照抄 —— 键名进签名原文,
+     * 改一个字母就换签名。同族先例见 {@link LsyMerchantCreateDTO}。
+     */
+    private String pictureIdcardHander;
+
+    /** 开户许可证照片(上游 {@code picture_open_account})。 */
+    private String pictureOpenAccount;
+
+    /** 组织机构代码证照片(上游 {@code picture_organization})。 */
+    private String pictureOrganization;
+
+    /** 税务登记证照片(上游 {@code picture_tax_registration})。 */
+    private String pictureTaxRegistration;
+
+    /** 入账非法人证明照片(上游 {@code picture_unincorporated})。 */
+    private String pictureUnincorporated;
+
+    /** 对私账户身份证正面照片(上游 {@code picture_bank_idcard_a})。 */
+    private String pictureBankIdcardA;
+
+    /** 对私账户身份证反面照片(上游 {@code picture_bank_idcard_b})。 */
+    private String pictureBankIdcardB;
+
+    /** 收银台照(上游 {@code p_cashier})。 */
+    private String pCashier;
+
+    /** 其他证明材料(上游 {@code picture_other})。 */
+    private String pictureOther;
+}

+ 312 - 9
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingService.java

@@ -7,7 +7,9 @@ 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.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.dto.LsyMerchantUpdateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
@@ -370,15 +372,8 @@ public class LsyOnboardingService {
      *                           502 上游返回成功但没给可用的商户号
      */
     public String createMerchant(LsyMerchantCreateDTO dto) {
-        // ① 凭据闸
-        List<String> missingCredentials = props.missingOnboardingCredentials();
-        if (!missingCredentials.isEmpty()) {
-            // 明细只进日志(Global Constraint 1):这里打的是**键名**不是值 ⇒ 不触 Global Constraint 5。
-            log.error("进件凭据缺失(共 {} 项),拒绝上送创建商户请求:{}",
-                    missingCredentials.size(), String.join("; ", missingCredentials));
-            throw new BusinessException(400, "服务端进件配置缺失(共 " + missingCredentials.size()
-                    + " 项),明细见服务端日志");
-        }
+        // ① 凭据闸(四条腿共用一份:创建 / 更新 / 提交入网 / 入网状态查询 —— 见 requireOnboardingCredentials)
+        requireOnboardingCredentials();
 
         // ② 必填矩阵
         List<String> missingFields = LsyMerchantRequiredFields.missingRequiredFields(dto);
@@ -408,6 +403,314 @@ public class LsyOnboardingService {
         return merchantNo;
     }
 
+    /**
+     * 更新商户信息(上游 page 196236557,路径 {@code /api/merchant/update})。
+     *
+     * <p>上游入参 = 信封({@code inst_no} / {@code trace_no} / {@code sign};
+     * {@code inst_no} 与 {@code sign} 由 {@link LeshouyingClient} 补,{@code trace_no} 由
+     * {@link #mchNoParams} 每次新生成)+ 配置的 {@code channel_id} +
+     * {@link LsyMerchantUpdateDTO} 的 50 个业务字段。
+     * 出参:{@code result_code} / {@code result_msg} / {@code shoubei_merchant_no} / {@code sign}
+     * (信封非 SUCCESS 一律抛 {@code LeshouyingApiException} ⇒ 走得下去就说明上游**认了**这次更新)。
+     *
+     * <p><b>【四步,顺序不许换】</b>① 凭据闸 → ② 必填 → ③ 组装 → ④ 上送。
+     * ① 排在 ② 前面是**硬的**,理由与 {@link #createMerchant} 那边逐字同源(不复述):
+     * {@code channel_id} 是报文的一部分 ⇒ 凭据缺失时连报文都补不全;而先报必填的话,
+     * 操作者会先把一整屏字段填完,再被告知「服务端配置没配」——那个配置他改不了。
+     *
+     * <p>① 的实现在 {@link #requireOnboardingCredentials}(四条腿共用一份)。
+     * ② 只有**两项**:{@code mchNo}(DTO)走 {@link #mchNoParams} 的空白校验,
+     * {@code channel_id}(配置)走 ① 的凭据闸。★ <b>其余 50 个字段一个都不进 400 的必填集</b>
+     * (裁定 R40):该页参数表只把 {@code account_type} 标成「必填=是」,而那是与页末「增量更新」
+     * 那句话互相矛盾的一句 —— 详见 {@link LsyMerchantUpdateDTO} 的类 javadoc(那里逐字记着两段原文
+     * 与取增量语义的两条理由)。把 {@code account_type} 判成必填的代价是**拒绝服务**:
+     * 一个只改商户名称的请求会被我们自己 400 掉。
+     *
+     * <p><b>【③ 组装:增量语义 —— 空白字段不进参数 Map】</b>该页页末逐字:
+     * 「此接口更新模式为增量更新,请求参数有值则更新,请求参数无值则保持原数据」⇒ 逐字段过
+     * {@link #putIfNotBlank}。★ 这条与本仓的 U3 契约(**不得送空串**)方向相同但**不是同一件事**:
+     * 增量语义说的是「空白字段不得出现在参数 Map 里」(键集),U3 说的是「值不得是空串」
+     * (本仓签名规则是 null 不参与、**空串参与**)。
+     * <br>★ {@code channel_id} **不**走增量:它是该页的必填项、取自配置,每次都在报文里
+     * (也正因为它取自配置而不是 DTO,本方法才可以一个 {@code channelId} 形参都不收)。
+     * 它的类型走 {@link #upstreamInteger}:该页把它声明成 {@code Integer(11)},与创建页同形。
+     *
+     * <p><b>【④ 本方法不读任何应答字段】</b>返回值是 {@code void}(计划的口径)。走到这里信封
+     * 必然是 SUCCESS(非 SUCCESS 已在客户端抛),而唯一值得读的 {@code shoubei_merchant_no}
+     * 是「操作成功时返回」的商户号 —— 调用方手里那个 {@code mchNo} 就在 DTO 里。
+     * 将来要用它,就**改返回值**,不要另开一条读同一次响应的路径(与 {@link #queryReview}
+     * 不带 {@code merchant_name} 是同一条边界)。
+     *
+     * <p>★ 租户缺口(R-4)对本法同样成立:{@code dto.mchNo} 不受租户约束,三-B 的端点必须先证明
+     * 它属于当前租户,见类 javadoc。
+     *
+     * @param dto {@code mchNo} 必填;其余字段留空表示「本次不改这一项」
+     * @throws BusinessException 400 服务端进件凭据缺失 / 配置项不是 Integer(11) 的规范写法 /
+     *                           {@code mchNo} 空白,三种都**不发上游**
+     */
+    public void updateMerchant(LsyMerchantUpdateDTO dto) {
+        // ① 凭据闸(四条腿共用)
+        requireOnboardingCredentials();
+
+        // ② mchNo 必填 + ③ 组装(merchant_no + 新的 trace_no 由 mchNoParams 一并产出)
+        Map<String, Object> p = mchNoParams(dto.getMchNo());
+        p.put("channel_id", upstreamInteger(props.getChannelId(), "channel_id"));
+
+        // 增量语义:有值才发(空白一律不进 Map)
+        putIfNotBlank(p, "merchant_name", dto.getMerchantName());
+        putIfNotBlank(p, "merchant_name_short", dto.getMerchantNameShort());
+        putIfNotBlank(p, "merchant_mcc_code", dto.getMerchantMccCode());
+        putIfNotBlank(p, "contact_name", dto.getContactName());
+        putIfNotBlank(p, "contact_phone", dto.getContactPhone());
+        putIfNotBlank(p, "contact_email", dto.getContactEmail());
+        putIfNotBlank(p, "contact_service", dto.getContactService());
+        putIfNotBlank(p, "address_province", dto.getAddressProvince());
+        putIfNotBlank(p, "address_city", dto.getAddressCity());
+        putIfNotBlank(p, "address_county", dto.getAddressCounty());
+        putIfNotBlank(p, "address_street", dto.getAddressStreet());
+        putIfNotBlank(p, "license_type", dto.getLicenseType());
+        putIfNotBlank(p, "license_no", dto.getLicenseNo());
+        putIfNotBlank(p, "license_stt", dto.getLicenseStt());
+        putIfNotBlank(p, "license_expire", dto.getLicenseExpire());
+        // ★ account_type 是**普通可选字段**:本页没有 category 可供派生(merchant_type 压根不在表里),
+        //   而它的「必填=是」与页末的增量语义冲突 ⇒ 取增量语义。见 LsyMerchantUpdateDTO 的类 javadoc。
+        putIfNotBlank(p, "account_type", dto.getAccountType());
+        putIfNotBlank(p, "legal_name", dto.getLegalName());
+        putIfNotBlank(p, "legal_cert_no", dto.getLegalCertNo());
+        putIfNotBlank(p, "legal_cert_stt", dto.getLegalCertStt());
+        putIfNotBlank(p, "legal_cert_expire", dto.getLegalCertExpire());
+        putIfNotBlank(p, "account_idcard_num", dto.getAccountIdcardNum());
+        putIfNotBlank(p, "account_idcard_stt", dto.getAccountIdcardStt());
+        putIfNotBlank(p, "account_idcard_expire", dto.getAccountIdcardExpire());
+        putIfNotBlank(p, "account_card_name", dto.getAccountCardName());
+        putIfNotBlank(p, "account_card_num", dto.getAccountCardNum());
+        putIfNotBlank(p, "account_card_phone", dto.getAccountCardPhone());
+        putIfNotBlank(p, "account_bank_code", dto.getAccountBankCode());
+        putIfNotBlank(p, "reg_capital", dto.getRegCapital());
+        // ★ 这个键首段自带大写(上游原文),别规整成别的形状
+        putIfNotBlank(p, "fSettle_account_type", dto.getFSettleAccountType());
+        putIfNotBlank(p, "actual_addr", dto.getActualAddr());
+        putIfNotBlank(p, "business_scope", dto.getBusinessScope());
+        putIfNotBlank(p, "check_code", dto.getCheckCode());
+        // ★ rate_code **不**回落配置(与创建腿的 §0.8 口径有意不同):增量语义下每次硬塞一个配置值
+        //   就是静默把商户的费率改成平台默认。理由写在 LsyMerchantUpdateDTO#rateCode 的 javadoc 里。
+        putIfNotBlank(p, "rate_code", dto.getRateCode());
+        putIfNotBlank(p, "picture_license", dto.getPictureLicense());
+        putIfNotBlank(p, "picture_idcard_a", dto.getPictureIdcardA());
+        putIfNotBlank(p, "picture_idcard_b", dto.getPictureIdcardB());
+        putIfNotBlank(p, "picture_bankcard_a", dto.getPictureBankcardA());
+        putIfNotBlank(p, "picture_bankcard_b", dto.getPictureBankcardB());
+        putIfNotBlank(p, "picture_outside", dto.getPictureOutside());
+        putIfNotBlank(p, "picture_inside", dto.getPictureInside());
+        putIfNotBlank(p, "picture_tripartite", dto.getPictureTripartite());
+        // ★ 上游把 handler 拼成了 hander,照抄(键名进签名原文)
+        putIfNotBlank(p, "picture_idcard_hander", dto.getPictureIdcardHander());
+        putIfNotBlank(p, "picture_open_account", dto.getPictureOpenAccount());
+        putIfNotBlank(p, "picture_organization", dto.getPictureOrganization());
+        putIfNotBlank(p, "picture_tax_registration", dto.getPictureTaxRegistration());
+        putIfNotBlank(p, "picture_unincorporated", dto.getPictureUnincorporated());
+        putIfNotBlank(p, "picture_bank_idcard_a", dto.getPictureBankIdcardA());
+        putIfNotBlank(p, "picture_bank_idcard_b", dto.getPictureBankIdcardB());
+        putIfNotBlank(p, "p_cashier", dto.getPCashier());
+        putIfNotBlank(p, "picture_other", dto.getPictureOther());
+
+        // ④ 上送
+        client.postApi("/api/merchant/update", p);
+    }
+
+    /**
+     * 提交商户入网信息(上游 page 196236563,路径 {@code /api/merchant/commitMerchantInfo})。
+     *
+     * <p>上游入参 = 信封({@code inst_no} / {@code trace_no} / {@code sign};{@code inst_no} 与
+     * {@code sign} 由 {@link LeshouyingClient} 补,{@code trace_no} 由 {@link #mchNoParams} 生成)
+     * + 配置的 {@code channel_id} + {@link LsyCommitMerchantInfoDTO} 的六个业务字段。
+     * 该页返回参数表只有 {@code result_code} / {@code result_msg},且**返回示例是空代码块**
+     * ⇒ 本方法不读任何应答字段。
+     *
+     * <p><b>【四步,顺序不许换】</b>① 凭据闸 → ② 必填 → ③ 组装 → ④ 上送(与
+     * {@link #createMerchant} / {@link #updateMerchant} 同一形状)。
+     * ① 的实现是 {@link #requireOnboardingCredentials}(四条腿共用一份)。
+     *
+     * <p><b>【② 六个字段全必填】</b>该页参数表把 {@code mcht_aptitude} / {@code mcht_aptitude_note} /
+     * {@code store_Loca} / {@code mcht_Landuse} / {@code longitude} / {@code latitude}
+     * **六个全标「必填=是」**,而该页**没有**「增量更新」那句话 ⇒ 必填标记没有歧义 ⇒ 逐个过
+     * {@link #requireText}(与 {@link #createShop} 对「必填=是」那五个的处理同一条口径:
+     * 把上游的必填要求提前到出站之前,**只改失败形态** —— 本侧是一句点名中文字段的 400,
+     * 上游那边只会回一句笼统的「参数不合法」,从它看不出是哪个参数)。
+     * <br>★ 与 {@link #updateMerchant} 的取舍**不同是有意的**:那边只校验两项,因为该页的
+     * 「必填=是」与「增量更新」互相矛盾(裁定 R39/R40)。
+     *
+     * <p><b>【③ 组装:9 个键,键名照参数表逐字】</b>
+     * {@code mchNoParams} 产出的 {@code merchant_no} + {@code trace_no},加配置的 {@code channel_id},
+     * 加那六个 —— <b>恰好 9 个</b>。★ 别把它数成 7:{@code trace_no} **每次调用都要重新生成**
+     * (写成常量或缓存在字段里会撞流水,而失败形态是笼统的「重复请求」),漏掉它的后果不是一个
+     * 「少发的可选字段」,是一族查不出来的重复请求。
+     * <br>★ {@code store_Loca} / {@code mcht_Landuse} 的**大小写是参数表原文**
+     * (首段小写 + {@code L} 大写):键名进**签名原文**,改一个字母就换签名。
+     * 同族先例:创建腿的 {@code fSettle_account_type}、上游自己拼错的 {@code picture_idcard_hander}。
+     * <br>★ {@code channel_id} 在本页声明成光秃秃的 {@code String}(创建页 / 更新页是
+     * {@code Integer(11)})⇒ **原样发配置字符串,不过 {@code upstreamInteger}**。
+     * 同一物理值在四个页面上的声明类型不同,本类按**各页自己的声明**发(计划 §0.5 对同批字段的口径)。
+     *
+     * <p>★ 租户缺口(R-4)对本法同样成立:{@code dto.mchNo} 不受租户约束,三-B 的端点必须先证明
+     * 它属于当前租户,见类 javadoc。
+     *
+     * @param dto 七个字段({@code mchNo} + 六个)全必填;空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @throws BusinessException 400 服务端进件凭据缺失 / 必填项空白,两种都**不发上游**
+     */
+    public void commitMerchantInfo(LsyCommitMerchantInfoDTO dto) {
+        // ① 凭据闸(四条腿共用)
+        requireOnboardingCredentials();
+
+        // ② 必填(六个业务字段;mchNo 的空白校验在 mchNoParams 里,不另写一份)
+        requireText(dto.getMchtAptitude(), "商户资质");
+        requireText(dto.getMchtAptitudeNote(), "商户资质备注");
+        requireText(dto.getStoreLoca(), "门店所在位置");
+        requireText(dto.getMchtLanduse(), "营业用地");
+        requireText(dto.getLongitude(), "营业地址经度");
+        // 上游说明列原文是「营业地址**维度**」("维度" 疑为 "纬度" 的错字):标签按说明列**逐字**取
+        // (与 LsyMerchantRequiredFields.LABELS 同一条规则),不顺手改字。
+        requireText(dto.getLatitude(), "营业地址维度");
+
+        // ③ 组装:9 个键
+        Map<String, Object> p = mchNoParams(dto.getMchNo());
+        // 本页把 channel_id 声明成 String ⇒ 原样发(与创建/更新页的 Integer(11) 不同,见 javadoc)
+        p.put("channel_id", props.getChannelId());
+        p.put("mcht_aptitude", dto.getMchtAptitude());
+        p.put("mcht_aptitude_note", dto.getMchtAptitudeNote());
+        // ★ store_Loca / mcht_Landuse 的大小写是签名原文,不得规整
+        p.put("store_Loca", dto.getStoreLoca());
+        p.put("mcht_Landuse", dto.getMchtLanduse());
+        p.put("longitude", dto.getLongitude());
+        p.put("latitude", dto.getLatitude());
+
+        // ④ 上送(不读应答:该页只有 result_code / result_msg,非 SUCCESS 已在客户端抛)
+        client.postApi("/api/merchant/commitMerchantInfo", p);
+    }
+
+    /**
+     * 查询商户入网状态(上游 page 196236564,路径 {@code /api/merchant/reportQuery})。
+     *
+     * <p>上游入参 = 信封({@code inst_no} / {@code trace_no} / {@code sign};{@code inst_no} 与
+     * {@code sign} 由 {@link LeshouyingClient} 补,{@code trace_no} 由 {@link #mchNoParams} 生成)
+     * + 配置的 {@code channel_id};出参 {@code result_code} / {@code result_msg} /
+     * {@code reviewed_status}。该页**返回示例是空代码块** ⇒ 应答结构只能等实测。
+     *
+     * <p><b>【本方法只做一件事:{@code asText(resp.get("reviewed_status"))} 原样返回】</b>
+     * 不做任何映射、不建枚举。缺键 / JSON {@code null} ⇒ 回 Java {@code null},**不抛**
+     * (该页返回示例是空的,键可能真的不在;口径与 {@link #areaList} 的「缺就当没有」一致)。
+     *
+     * <p><b>★★ 为什么不建枚举:page 196236564</b>**同一页内**就有两套互相冲突的 0–6 映射,
+     * 逐字抄在下面(内容与「指向文档」的出处照 spec §6.4 三-C 的逐字要求:
+     * 「记录在代码注释里指向文档,等联调时用真实应答确定」):
+     * <ol>
+     *   <li><b>接口描述的「状态说明」块</b>逐字:{@code 0:已发起进件生成乐收赢商户号}
+     *       {@code 1:返回渠道子商户进件流程成功的终态} {@code 2:重新修改信息进件}
+     *       {@code 3:银行审核中} {@code 4:需要调用入网接口} {@code 5:银行审核中}
+     *       {@code 6:需要重新调用创建商户接口};</li>
+     *   <li><b>返回参数表里 {@code reviewed_status} 一行</b>逐字:
+     *       {@code 0.审核中,1.审核通过,2.审核驳回,3.预审核通过 4.待提交 5.已提交 6.重新进件}。</li>
+     * </ol>
+     * 两套对同一个码给出**完全不同**的语义({@code 2} 一边是「重新修改信息进件」、另一边是
+     * 「审核驳回」),⇒ 任何映射都是在挑一套猜。<b>不要</b>在代码里挑一套,也**不要**把这段冲突
+     * 「归一化」掉(它的正确出口是联调时用真实应答确定,然后一次改动落下映射 + 改本段 javadoc)。
+     *
+     * <p><b>★★ 另一个陷阱:不要复用 {@code LsyReviewStatus}。</b>三-A 留下了一个**长得非常像**的
+     * 现成枚举(同名 {@code reviewed_status}),顺手复用它是最可能发生的错,而它是错的:
+     * <table border="1">
+     *   <caption>两个接口的同名字段</caption>
+     *   <tr><th></th><th>{@code LsyReviewStatus}(三-A 产)</th><th>本方法</th></tr>
+     *   <tr><td>上游页</td><td>196236567</td><td><b>196236564</b></td></tr>
+     *   <tr><td>路径</td><td>{@code /api/merchant/query}</td><td>{@code /api/merchant/reportQuery}</td></tr>
+     *   <tr><td>值域</td><td>{@code 0审核通过,1审核中,9审核驳回,10通道报备中,98停用,99关闭}</td>
+     *       <td>0–6 的**另外两套**(同页内互相冲突,见上)</td></tr>
+     * </table>
+     * {@code LsyReviewStatus} 的类注释**逐字**写着「★ <b>本枚举不通用 —— 不得拿它去解别的接口的
+     * {@code reviewed_status}。</b>」,它自己的类注释也明写「入网状态那一套({@code reportQuery})
+     * 属三-B,用到时另建,不要扩本枚举」。
+     * 复用会**静默**给出错误文案(不抛、不红):码 {@code 2} 在本页是「审核驳回」,
+     * 而在那套值域里**没有对应项**。
+     *
+     * <p><b>【{@code channel_id} 取自配置,不是形参】</b>本页参数表里它的说明**逐字**是
+     * 「由客户经理提供,**同创建商户接口**」—— 与创建腿**同一个值**,而创建腿取的就是
+     * {@code props.getChannelId()}。它同时是 {@link LeshouyingProperties#missingOnboardingCredentials()}
+     * 五项之一 ⇒ 平台把它当**部署级凭据**(存在性受闸);做成入参就等于让这条腿**绕过凭据闸**,
+     * 用一个没配置的通道号去签名上送。⇒ 本方法只收 {@code mchNo} 一个参数。
+     *
+     * <p>★ 租户缺口(R-4)对本法同样成立:{@code mchNo} 不受租户约束,三-B 的端点必须先证明
+     * 它属于当前租户,见类 javadoc。
+     *
+     * @param mchNo 乐收赢商户号。空白 ⇒ {@link BusinessException}(400),**不发上游**
+     * @return 上游 {@code reviewed_status} 的**原值**({@code String.valueOf} 语义,
+     *         故上游给数字也能读成文本);缺键 / {@code null} ⇒ Java {@code null},**不抛**
+     * @throws BusinessException 400 服务端进件凭据缺失 / {@code mchNo} 空白,两种都**不发上游**
+     */
+    public String reportQuery(String mchNo) {
+        // ① 凭据闸(四条腿共用)
+        requireOnboardingCredentials();
+
+        // ② mchNo 必填 + ③ 组装
+        Map<String, Object> p = mchNoParams(mchNo);
+        // 本页把 channel_id 声明成 String ⇒ 原样发配置字符串(与创建/更新页的 Integer(11) 不同)
+        p.put("channel_id", props.getChannelId());
+
+        // ④ 上送 + 解析:只做 asText 一件事,不映射任何码表(见 javadoc 的两套冲突映射)
+        return asText(client.postApi("/api/merchant/reportQuery", p).get("reviewed_status"));
+    }
+
+    /**
+     * 进件凭据闸 —— 三-B **四条腿**(创建 / 更新 / 提交入网 / 入网状态查询)共用一份。
+     *
+     * <p>【为什么共用一个私有方法】四条腿的 {@code channel_id}(以及提交入网那条要用到的通道族字段)
+     * 都是**取自配置**的;只在创建腿设闸,会造出「配置缺失时创建腿拒、更新腿**静默用空值上送**」
+     * 的分叉。同理,抄四份就会出现「某天只给其中一处加校验」——与 {@link #mchNoParams} 同一条理由。
+     *
+     * <p>【对外文案与明细出口】明细(配置键名 + 环境变量名)**只进日志**,对外 message =
+     * 中文固定文案 + **缺失项数**。对外文案与日志的口径、以及「不得把环境变量名换成中文标签再发出去」
+     * 那条禁令,逐条写在 {@link #createMerchant} 的 javadoc「① 凭据闸」一段里(**不复述第二份**)——
+     * 那里是这套口径的落点,改文案时两处一起看。
+     *
+     * <p>★ 日志里打的是**键名不是值** ⇒ 不触 Global Constraint 5(秘密只打印长度)。
+     *
+     * @throws BusinessException 400,文案为中文固定文案 + 缺失项数(不含上游名号 / 环境变量名)
+     */
+    private void requireOnboardingCredentials() {
+        List<String> missingCredentials = props.missingOnboardingCredentials();
+        if (missingCredentials.isEmpty()) {
+            return;
+        }
+        // 明细只进日志(Global Constraint 1):这里打的是**键名**不是值 ⇒ 不触 Global Constraint 5。
+        log.error("进件凭据缺失(共 {} 项),拒绝上送进件请求:{}",
+                missingCredentials.size(), String.join("; ", missingCredentials));
+        throw new BusinessException(400, "服务端进件配置缺失(共 " + missingCredentials.size()
+                + " 项),明细见服务端日志");
+    }
+
+    /**
+     * 增量语义的落点:**有值才进 Map**,空白({@code null} / 空串 / 全空格)一律**不 put**。
+     *
+     * <p>依据是更新商户页页末逐字那句「此接口更新模式为增量更新,请求参数有值则更新,
+     * 请求参数无值则保持原数据」。空白字段若照样上送,就把商户的该项**改成了空**(或让上游
+     * 判「参数不合法」)—— 两种都不是调用方要的「这次不改这一项」。
+     *
+     * <p>★ <b>为什么是「不 put」而不是「{@code put(key, null)}」</b>:对**签名与报文**而言两者
+     * 等价({@link LeshouyingClient} 的 {@code signedPayload} 剔 {@code null}),但键集不同 ——
+     * 「没填」在参数 Map 里就该**没有这个键**,这样才能用一句键集断言把增量语义钉住
+     * ({@code updateMerchant_sendsOnlyNonBlankFields})。
+     *
+     * <p>★ <b>绝不 {@code put("")}</b>:本仓签名规则是 **null 不参与、空串参与**,一个空串会让
+     * 签名原文凭空多出一项 {@code xxx=},报文与签名一起变,而出错只在上游、本侧看不出是哪个字段
+     * (Task 3 的 U3 契约,本任务同样受它约束)。
+     *
+     * <p>值**不做 trim**:把 {@code " 张三 "} 改成 {@code "张三"} 是一次静默改写,
+     * 与 {@link #upstreamInteger} 那条「能解析不等于没改写」是同一种警戒。
+     */
+    private static void putIfNotBlank(Map<String, Object> params, String key, String value) {
+        if (StrUtil.isNotBlank(value)) {
+            params.put(key, value);
+        }
+    }
+
     /**
      * 获取门店列表(上游 page 196236560,路径 {@code /api/shop/list})。
      *

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

@@ -8,7 +8,9 @@ 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.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.LsyMerchantCreateDTO;
+import com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyReviewStatusDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopCreateDTO;
 import com.payment.platform.module.payment.leshouying.dto.LsyShopItemDTO;
@@ -1903,6 +1905,542 @@ class LsyOnboardingServiceTest {
         assertNotEquals(first, second, "两笔请求必须是两个流水号(trace_no 定义就是唯一标识一次请求)");
     }
 
+    // ==================== Task 4:更新商户 / 提交入网信息 / 查询入网状态 ====================
+    //
+    // 三个薄腿(上游 page 196236557 / 196236563 / 196236564),形状照 createMerchant 的四步:
+    // ① 凭据闸 → ② 必填 → ③ 组装 → ④ 上送。三条都复用 mchNoParams(merchant_no + 新 trace_no)。
+    // ★ 键名大小写就是签名原文:store_Loca / mcht_Landuse 不得「顺手规整」。
+    // ★ reportQuery **不映射**任何码表(同名 reviewed_status 在本仓已有第三套值域),原样透传。
+
+    /**
+     * 一份**逐字段填满**的更新商户入参(51 个字段:{@code mchNo} + 该页参数表的 50 个业务字段)。
+     *
+     * <p>★ 每个字段的值就是它自己的上游键名({@code merchant_name → "merchant_name"}),
+     * 理由与 {@link #merchantDto()} 逐字同源(那里写得细):一处接错 getter 就会让**值**对不上。
+     *
+     * <p>★ 本页与创建页**没有**的字段一个都不设:{@code merchant_name_business} / {@code merchant_type}
+     * (该页接口描述逐字写着这两个「创建后即固定,无法修改」)、{@code bd_id} / {@code notify_url}、
+     * {@code lease_contract} / {@code share_scene} / {@code share_certificate} /
+     * {@code tripartite_flow_certificate_url} —— 它们**不在**更新页的参数表里。
+     */
+    private static LsyMerchantUpdateDTO updateDto() {
+        LsyMerchantUpdateDTO dto = new LsyMerchantUpdateDTO();
+        dto.setMchNo(MCH_NO);
+        dto.setMerchantName("merchant_name");
+        dto.setMerchantNameShort("merchant_name_short");
+        dto.setMerchantMccCode("merchant_mcc_code");
+        dto.setContactName("contact_name");
+        dto.setContactPhone("contact_phone");
+        dto.setContactEmail("contact_email");
+        dto.setContactService("contact_service");
+        dto.setAddressProvince("address_province");
+        dto.setAddressCity("address_city");
+        dto.setAddressCounty("address_county");
+        dto.setAddressStreet("address_street");
+        dto.setLicenseType("license_type");
+        dto.setLicenseNo("license_no");
+        dto.setLicenseStt("license_stt");
+        dto.setLicenseExpire("license_expire");
+        dto.setAccountType("account_type");
+        dto.setLegalName("legal_name");
+        dto.setLegalCertNo("legal_cert_no");
+        dto.setLegalCertStt("legal_cert_stt");
+        dto.setLegalCertExpire("legal_cert_expire");
+        dto.setAccountIdcardNum("account_idcard_num");
+        dto.setAccountIdcardStt("account_idcard_stt");
+        dto.setAccountIdcardExpire("account_idcard_expire");
+        dto.setAccountCardName("account_card_name");
+        dto.setAccountCardNum("account_card_num");
+        dto.setAccountCardPhone("account_card_phone");
+        dto.setAccountBankCode("account_bank_code");
+        dto.setRegCapital("reg_capital");
+        dto.setFSettleAccountType("fSettle_account_type");
+        dto.setActualAddr("actual_addr");
+        dto.setBusinessScope("business_scope");
+        dto.setCheckCode("check_code");
+        dto.setRateCode("rate_code");
+        dto.setPictureLicense("picture_license");
+        dto.setPictureIdcardA("picture_idcard_a");
+        dto.setPictureIdcardB("picture_idcard_b");
+        dto.setPictureBankcardA("picture_bankcard_a");
+        dto.setPictureBankcardB("picture_bankcard_b");
+        dto.setPictureOutside("picture_outside");
+        dto.setPictureInside("picture_inside");
+        dto.setPictureTripartite("picture_tripartite");
+        dto.setPictureIdcardHander("picture_idcard_hander");
+        dto.setPictureOpenAccount("picture_open_account");
+        dto.setPictureOrganization("picture_organization");
+        dto.setPictureTaxRegistration("picture_tax_registration");
+        dto.setPictureUnincorporated("picture_unincorporated");
+        dto.setPictureBankIdcardA("picture_bank_idcard_a");
+        dto.setPictureBankIdcardB("picture_bank_idcard_b");
+        dto.setPCashier("p_cashier");
+        dto.setPictureOther("picture_other");
+        return dto;
+    }
+
+    /** 在 {@link #updateDto()}(全填)上改一项。 */
+    private static LsyMerchantUpdateDTO updateDto(Consumer<LsyMerchantUpdateDTO> mutation) {
+        LsyMerchantUpdateDTO dto = updateDto();
+        mutation.accept(dto);
+        return dto;
+    }
+
+    /**
+     * 更新腿出站参数的**字面期望表**:51 个键({@code trace_no} 另算,它每次新生成)。
+     *
+     * <p>★ 手写字面表,不从 {@code LsyMerchantUpdateDTO} 反射生成 —— 理由与
+     * {@link #expectedMerchantParams()} 同源(裁定 h1):反射表是自洽式断言,实现改错它跟着错。
+     *
+     * <p>★ {@code channel_id} 是 **{@code Integer}**:page 196236557 把它声明成 {@code Integer(11)},
+     * 与创建页同形(**不是**提交入网页 / 入网状态页那种光秃秃的 {@code String})⇒ 走既有的
+     * {@code upstreamInteger}(与创建腿同一处转换、同一道「规范十进制」守卫)。请求体是 JSON,
+     * {@code 1001} 与 {@code "1001"} 在报文上不同。
+     */
+    private static Map<String, Object> expectedUpdateParams() {
+        Map<String, Object> p = new LinkedHashMap<>();
+        // ---- 服务端拥有的一项(不是 DTO 字段)----
+        p.put("channel_id", 1001);          // ★ Integer:page 196236557 声明 Integer(11)
+        // ---- DTO 的 50 个:值就是它自己的上游键名 ----
+        p.put("merchant_name", "merchant_name");
+        p.put("merchant_name_short", "merchant_name_short");
+        p.put("merchant_mcc_code", "merchant_mcc_code");
+        p.put("contact_name", "contact_name");
+        p.put("contact_phone", "contact_phone");
+        p.put("contact_email", "contact_email");
+        p.put("contact_service", "contact_service");
+        p.put("address_province", "address_province");
+        p.put("address_city", "address_city");
+        p.put("address_county", "address_county");
+        p.put("address_street", "address_street");
+        p.put("license_type", "license_type");
+        p.put("license_no", "license_no");
+        p.put("license_stt", "license_stt");
+        p.put("license_expire", "license_expire");
+        p.put("account_type", "account_type");
+        p.put("legal_name", "legal_name");
+        p.put("legal_cert_no", "legal_cert_no");
+        p.put("legal_cert_stt", "legal_cert_stt");
+        p.put("legal_cert_expire", "legal_cert_expire");
+        p.put("account_idcard_num", "account_idcard_num");
+        p.put("account_idcard_stt", "account_idcard_stt");
+        p.put("account_idcard_expire", "account_idcard_expire");
+        p.put("account_card_name", "account_card_name");
+        p.put("account_card_num", "account_card_num");
+        p.put("account_card_phone", "account_card_phone");
+        p.put("account_bank_code", "account_bank_code");
+        p.put("reg_capital", "reg_capital");
+        p.put("fSettle_account_type", "fSettle_account_type");
+        p.put("actual_addr", "actual_addr");
+        p.put("business_scope", "business_scope");
+        p.put("check_code", "check_code");
+        p.put("rate_code", "rate_code");
+        p.put("picture_license", "picture_license");
+        p.put("picture_idcard_a", "picture_idcard_a");
+        p.put("picture_idcard_b", "picture_idcard_b");
+        p.put("picture_bankcard_a", "picture_bankcard_a");
+        p.put("picture_bankcard_b", "picture_bankcard_b");
+        p.put("picture_outside", "picture_outside");
+        p.put("picture_inside", "picture_inside");
+        p.put("picture_tripartite", "picture_tripartite");
+        p.put("picture_idcard_hander", "picture_idcard_hander");
+        p.put("picture_open_account", "picture_open_account");
+        p.put("picture_organization", "picture_organization");
+        p.put("picture_tax_registration", "picture_tax_registration");
+        p.put("picture_unincorporated", "picture_unincorporated");
+        p.put("picture_bank_idcard_a", "picture_bank_idcard_a");
+        p.put("picture_bank_idcard_b", "picture_bank_idcard_b");
+        p.put("p_cashier", "p_cashier");
+        p.put("picture_other", "picture_other");
+        return p;
+    }
+
+    /**
+     * ★ <b>增量语义</b>:page 196236557 页末逐字写着「此接口更新模式为<u>增量更新</u>,
+     * 请求参数有值则更新,请求参数无值则保持原数据」⇒ <b>空白字段不得出现在参数 Map 里</b>。
+     *
+     * <p>两种输入各钉一件事,都在同一次断言里:
+     * <ol>
+     *   <li><b>全填</b> ⇒ 键集恰好 51 个字面键 + {@code trace_no},且除 {@code trace_no} 外每个键的
+     *       值(含类型)都对得上(挡住「A 字段取了 B 的 getter」);</li>
+     *   <li><b>四个字段置空白</b> ⇒ 那四个键**一个都不出现**({@code merchant_name} 空串、
+     *       {@code contact_email} 全空格、{@code picture_other} {@code null}、
+     *       {@code account_type} 空串 —— 三种「没填」的形态都覆盖到)。</li>
+     * </ol>
+     *
+     * <p>★ 为什么是**键集**断言而不是「值不为空」:本仓签名规则是 <b>null 不参与、空串参与</b>
+     * ⇒ 一个 {@code ""} 会让签名原文凭空多一项 {@code xxx=},报文与签名一起变,而失败只在上游
+     * 表现为一句笼统的话。客户端 {@code signedPayload} 只剔 {@code null},**不剔空串** ——
+     * 所以「不得送空串」这条只能由组装层保证(Task 3 的 U3 契约,本任务同样受它约束)。
+     *
+     * <p>★ {@code account_type} 在本页标「必填=是」,但它照样走增量(有值才发):该页「必填=是」
+     * 与页末「增量更新」两句在增量语义下互相矛盾,裁定 R39/R40 取增量语义 —— 理由见
+     * {@link com.payment.platform.module.payment.leshouying.dto.LsyMerchantUpdateDTO} 的类 javadoc。
+     */
+    @Test
+    void updateMerchant_sendsOnlyNonBlankFields() {
+        withOnboardingCredentials();
+        stubUpstream(ok(""));
+
+        service.updateMerchant(updateDto());
+        service.updateMerchant(updateDto(d -> {
+            d.setMerchantName("");
+            d.setContactEmail("   ");
+            d.setPictureOther(null);
+            d.setAccountType("");
+        }));
+
+        List<Map<String, Object>> sentList = allSentParams("/api/merchant/update", 2);
+        Map<String, Object> full = sentList.get(0);
+        Map<String, Object> partial = sentList.get(1);
+
+        // ① 全填:除 trace_no 外每个键的值(含类型)都要对得上
+        Map<String, Object> expectedFullValues = new LinkedHashMap<>();
+        expectedFullValues.put("merchant_no", MCH_NO);   // 来自 mchNoParams
+        expectedFullValues.putAll(expectedUpdateParams());
+        Set<String> expectedFull = new LinkedHashSet<>(expectedFullValues.keySet());
+        expectedFull.add("trace_no");
+        assertEquals(expectedFull, full.keySet(),
+                "出站参数键集必须恰好是 merchant_no + channel_id + DTO 的 50 个字段 + trace_no(共 53 个):"
+                        + full.keySet());
+        Object traceNo = full.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+        Map<String, Object> actualFull = new LinkedHashMap<>(full);
+        actualFull.remove("trace_no");
+        assertEquals(expectedFullValues, actualFull,
+                "每个键的值(含类型)都要对得上 —— 夹具里每个字段的值就是它自己的上游键名,"
+                        + "所以「A 字段取了 B 的 getter」也会在这里红");
+
+        // ② 四个空白字段 ⇒ 四个键都不在(增量语义)
+        Set<String> expectedPartial = new LinkedHashSet<>(expectedFull);
+        expectedPartial.removeAll(Set.of("merchant_name", "contact_email", "picture_other", "account_type"));
+        assertEquals(expectedPartial, partial.keySet(),
+                "空白字段**不得**出现在参数 Map 里(增量语义;空串还会参与签名):" + partial.keySet());
+        Map<String, Object> actualPartial = new LinkedHashMap<>(partial);
+        actualPartial.remove("trace_no");
+        Map<String, Object> expectedPartialValues = new LinkedHashMap<>(expectedFullValues);
+        for (String blank : new String[]{"merchant_name", "contact_email", "picture_other", "account_type"}) {
+            expectedPartialValues.remove(blank);
+        }
+        assertEquals(expectedPartialValues, actualPartial,
+                "被剔掉的只能是那四个空白字段,其余字段的值一个都不许变:" + actualPartial);
+
+        // ③ 不变式:两次出站里**任何**值都不得是空白串(空串参与签名 ⇒ 报文与签名一起变)
+        for (Map<String, Object> sent : sentList) {
+            sent.forEach((key, value) -> assertFalse(value instanceof String s && s.isBlank(),
+                    "参数 " + key + " 的值是空白串(U3 契约:没填就不发这个键):" + sent));
+        }
+    }
+
+    /**
+     * ★ <b>这一页的两个必填项是「商户号」与「通道 id」,一个由 DTO 给、一个由配置给</b> ——
+     * 「即使 DTO 里为空也要有校验(400)」。
+     *
+     * <p>三个子场景:
+     * <ol>
+     *   <li><b>DTO 的其余 50 个字段全空</b> ⇒ 出站仍恰好是 {@code merchant_no} + {@code channel_id}
+     *       (+ 服务自己生成的 {@code trace_no})—— 增量语义的另一个方向:可选的都不发,
+     *       必填的两个照发;</li>
+     *   <li>{@code mchNo} 空白 ⇒ 400,文案「商户号不能为空」(走 {@code mchNoParams},
+     *       与创建/查询腿同一处校验);</li>
+     *   <li>{@code channel-id} 没配 ⇒ 凭据闸 400(与创建腿**同一道闸**:这三条腿的
+     *       {@code channel_id} 同样取自配置,只在创建腿设闸会造出「配置缺失时创建腿拒、
+     *       更新腿静默用空值上送」的分叉)。</li>
+     * </ol>
+     * 全程**只出海一次**(末行那条 {@code times(1)} 就是 ②③ 没发上游的证据)。
+     */
+    @Test
+    void updateMerchant_alwaysSendsMchNoAndChannelId() {
+        withOnboardingCredentials();
+        stubUpstream(ok(""));
+
+        // ① 只给 mchNo
+        LsyMerchantUpdateDTO onlyMchNo = new LsyMerchantUpdateDTO();
+        onlyMchNo.setMchNo(MCH_NO);
+        service.updateMerchant(onlyMchNo);
+
+        Map<String, Object> sent = sentParams("/api/merchant/update");
+        assertEquals(Set.of("merchant_no", "trace_no", "channel_id"), sent.keySet(),
+                "DTO 其余字段全空时,出站只该有这三个键:" + sent.keySet());
+        assertEquals(MCH_NO, sent.get("merchant_no"));
+        Object channelId = sent.get("channel_id");
+        assertInstanceOf(Integer.class, channelId,
+                "page 196236557 把 channel_id 声明成 Integer(11),实际: " + channelId);
+        assertEquals(1001, ((Integer) channelId).intValue());
+
+        // ② mchNo 空白 ⇒ 400(文案证明它过了凭据闸、是被 mchNoParams 拒的)
+        BusinessException blankMchNo = assertThrows(BusinessException.class,
+                () -> service.updateMerchant(updateDto(d -> d.setMchNo("   "))),
+                "商户号空白必须被拒");
+        assertEquals(400, blankMchNo.getCode());
+        assertEquals("商户号不能为空", blankMchNo.getMessage());
+
+        // ③ channel-id 没配 ⇒ 凭据闸 400
+        props.setChannelId("  ");
+        BusinessException noCredential = assertThrows(BusinessException.class,
+                () -> service.updateMerchant(updateDto()), "通道 id 没配时必须被拒");
+        assertEquals(400, noCredential.getCode());
+        assertCredentialsMessage(noCredential.getMessage(), 1);
+
+        // 全程恰好一次出站 —— ②③ 一次都没发
+        verify(client, times(1)).postApi(anyString(), anyMap());
+    }
+
+    // ---- 提交商户入网信息(上游 page 196236563,路径 /api/merchant/commitMerchantInfo)----
+
+    /**
+     * 一份**逐字段填满**的提交入网信息入参:{@code mchNo} + 该页参数表的六个业务字段
+     * (六个在该页**全是「必填=是」**)。值就是它自己的上游键名。
+     */
+    private static LsyCommitMerchantInfoDTO commitDto() {
+        LsyCommitMerchantInfoDTO dto = new LsyCommitMerchantInfoDTO();
+        dto.setMchNo(MCH_NO);
+        dto.setMchtAptitude("mcht_aptitude");
+        dto.setMchtAptitudeNote("mcht_aptitude_note");
+        dto.setStoreLoca("store_Loca");
+        dto.setMchtLanduse("mcht_Landuse");
+        dto.setLongitude("longitude");
+        dto.setLatitude("latitude");
+        return dto;
+    }
+
+    /** 在 {@link #commitDto()}(全填)上改一项。 */
+    private static LsyCommitMerchantInfoDTO commitDto(Consumer<LsyCommitMerchantInfoDTO> mutation) {
+        LsyCommitMerchantInfoDTO dto = commitDto();
+        mutation.accept(dto);
+        return dto;
+    }
+
+    /**
+     * ★ 出站参数**恰好 9 个**:{@code merchant_no} + {@code trace_no}({@code mchNoParams} 一并产出的
+     * 两个)+ 配置的 {@code channel_id} + 该页参数表那六个业务字段。
+     *
+     * <p>★ 为什么是 9 而不是 7:参数表里扣掉客户端自动补的 {@code inst_no} / {@code sign} 之后,
+     * 服务层要组装的就是 {@code merchant_no} + {@code channel_id} + 六个。把
+     * {@code merchant_no}/{@code trace_no} 漏掉是**最可能**的错法,而 {@code trace_no} 每次调用
+     * 都要重新生成,缺了会撞流水 —— 失败形态是笼统的「重复请求」,看不出是哪个参数。
+     *
+     * <p>★ {@code channel_id} 在**本页**声明成光秃秃的 {@code String}(不是创建页/更新页的
+     * {@code Integer(11)})⇒ 原样发配置字符串,不过 {@code upstreamInteger}。同一物理值在四个页面上
+     * 的声明类型不同,本类按**各页自己的声明**发(计划 §0.5 对同批字段的口径:「按各自页的类型发」)。
+     */
+    @Test
+    void commitMerchantInfo_sendsAllNineParams() {
+        withOnboardingCredentials();
+        stubUpstream(ok(""));
+
+        service.commitMerchantInfo(commitDto());
+
+        Map<String, Object> sent = sentParams("/api/merchant/commitMerchantInfo");
+        Set<String> expected = new LinkedHashSet<>(Arrays.asList(
+                "merchant_no", "trace_no", "channel_id",
+                "mcht_aptitude", "mcht_aptitude_note", "store_Loca", "mcht_Landuse",
+                "longitude", "latitude"));
+        assertEquals(expected, sent.keySet(),
+                "出站参数必须恰好这 9 个键(含 merchant_no 与 trace_no):" + sent.keySet());
+        assertEquals(MCH_NO, sent.get("merchant_no"));
+        assertEquals(CFG_CHANNEL_ID, sent.get("channel_id"),
+                "本页把 channel_id 声明成 String ⇒ 原样发配置字符串,实际: " + sent.get("channel_id"));
+        assertEquals("mcht_aptitude", sent.get("mcht_aptitude"));
+        assertEquals("mcht_aptitude_note", sent.get("mcht_aptitude_note"));
+        assertEquals("store_Loca", sent.get("store_Loca"));
+        assertEquals("mcht_Landuse", sent.get("mcht_Landuse"));
+        assertEquals("longitude", sent.get("longitude"));
+        assertEquals("latitude", sent.get("latitude"));
+        Object traceNo = sent.get("trace_no");
+        assertInstanceOf(String.class, traceNo, "trace_no 必须是字符串,实际: " + traceNo);
+        assertEquals(32, ((String) traceNo).length(),
+                "trace_no 必须是 IdUtil.fastSimpleUUID() 的形状(32 位),实际: " + traceNo);
+    }
+
+    /**
+     * ★ <b>{@code store_Loca} / {@code mcht_Landuse} 的大小写是参数表原文,不得「顺手规整」</b>
+     * —— 键名进**签名原文**,改一个字母就换签名,而失败只会表现为上游一句笼统的「签名错误」。
+     * 同族的既有先例:{@code fSettle_account_type}、上游自己拼错的 {@code picture_idcard_hander}。
+     *
+     * <p>两条断言各钉一半:① 「忽略大小写后恰好只有一个键、且拼写就是原文」(挡住改名,也挡住
+     * 同时发出规整版与原版两个键);② 逐字 {@code containsKey}(失败信息直接点出实际键集,
+     * 比集合相等那条更好读)。
+     */
+    @Test
+    void commitMerchantInfo_preservesUpstreamKeyCasing() {
+        withOnboardingCredentials();
+        stubUpstream(ok(""));
+
+        service.commitMerchantInfo(commitDto());
+
+        Map<String, Object> sent = sentParams("/api/merchant/commitMerchantInfo");
+        for (String exact : new String[]{"store_Loca", "mcht_Landuse"}) {
+            assertTrue(sent.containsKey(exact),
+                    "键名必须是参数表原文 " + exact + "(签名对键名敏感),实际键集: " + sent.keySet());
+            List<String> sameIgnoringCase = sent.keySet().stream()
+                    .filter(k -> k.equalsIgnoreCase(exact))
+                    .toList();
+            assertEquals(List.of(exact), sameIgnoringCase,
+                    exact + " 一族(忽略大小写)必须恰好一个键、且拼写是 " + exact
+                            + ",实际: " + sameIgnoringCase);
+        }
+        assertEquals("store_Loca", sent.get("store_Loca"), "值必须落在原文键上");
+        assertEquals("mcht_Landuse", sent.get("mcht_Landuse"), "值必须落在原文键上");
+    }
+
+    /**
+     * 该页六个业务字段**全是「必填=是」** ⇒ 逐个置空白都要在**组报文之前**被拒(400),
+     * 一次上游调用都不发;文案点名**中文字段名**(不是键名 —— 报键名的话操作者得在文档里
+     * 做一次心算才能对上是界面上的哪个输入框)。
+     *
+     * <p>空白的三种形态({@code null} / 空串 / 全空格)逐个试,因为「漏了其中一个」正是这条要防的。
+     * 形状照 {@link #createShop_blankRequiredField_throws400}(那里的上游必填列同样是「是」)。
+     *
+     * <p>★ <b>为什么这条腿要校验、而更新腿只校验两个</b>:本页**没有**「增量更新」那句话,
+     * 它的必填标记没有歧义;更新页有那句话,两句在增量语义下互相矛盾,裁定取增量语义(R39/R40)。
+     */
+    @Test
+    void commitMerchantInfo_blankRequiredField_throws400() {
+        withOnboardingCredentials();
+        for (String blank : new String[]{null, "", "   "}) {
+            assertCommitRejected400("商户号不能为空", d -> d.setMchNo(blank));
+            assertCommitRejected400("商户资质不能为空", d -> d.setMchtAptitude(blank));
+            assertCommitRejected400("商户资质备注不能为空", d -> d.setMchtAptitudeNote(blank));
+            assertCommitRejected400("门店所在位置不能为空", d -> d.setStoreLoca(blank));
+            assertCommitRejected400("营业用地不能为空", d -> d.setMchtLanduse(blank));
+            assertCommitRejected400("营业地址经度不能为空", d -> d.setLongitude(blank));
+            // 上游说明列原文是「营业地址**维度**」("维度" 疑为 "纬度" 的错字):标签按说明列
+            // **逐字**取(与 LsyMerchantRequiredFields.LABELS 同一条规则),不顺手改字。
+            assertCommitRejected400("营业地址维度不能为空", d -> d.setLatitude(blank));
+        }
+        verifyNoInteractions(client);
+    }
+
+    /** 断言某一次 {@code commitMerchantInfo} 被 400 拒掉,且文案点名的是**中文字段名**。 */
+    private void assertCommitRejected400(String expectedMessage,
+                                         Consumer<LsyCommitMerchantInfoDTO> mutation) {
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> service.commitMerchantInfo(commitDto(mutation)), expectedMessage + " 必须被拒");
+        assertEquals(400, ex.getCode(), expectedMessage);
+        assertEquals(expectedMessage, ex.getMessage(), expectedMessage);
+    }
+
+    // ---- 查询商户入网状态(上游 page 196236564,路径 /api/merchant/reportQuery)----
+
+    /**
+     * ★ <b>0..6 逐个原样回,不映射、不建枚举。</b>该页**同页内**就有两套互相冲突的 0–6 映射
+     * (返回参数表一套、接口描述块另一套,逐字抄在
+     * {@code LsyOnboardingService#reportQuery} 的 javadoc 里)⇒ 任何映射都是在挑一套猜。
+     *
+     * <p>顺带把出站参数也钉住:{@code reportQuery} 只收一个参数({@code mchNo}),
+     * {@code channel_id} 与另外两条腿一样取自配置 ⇒ 七次调用每次都恰好三个键,
+     * 且七个 {@code trace_no} 两两不同({@code mchNoParams} 的「每次新生成」在这条腿上同样成立)。
+     */
+    @Test
+    void reportQuery_returnsRawStatus_forAllSevenKnownCodes() {
+        withOnboardingCredentials();
+
+        List<String> codes = List.of("0", "1", "2", "3", "4", "5", "6");
+        for (String code : codes) {
+            stubUpstream(ok(",\"reviewed_status\":\"" + code + "\""));
+            assertEquals(code, service.reportQuery(MCH_NO),
+                    "码 " + code + " 必须**原样**返回 —— 本页两套映射自己就冲突,映射它就是挑一套猜");
+        }
+
+        List<Map<String, Object>> sentList = allSentParams("/api/merchant/reportQuery", codes.size());
+        Set<String> traceNos = new LinkedHashSet<>();
+        for (Map<String, Object> sent : sentList) {
+            assertEquals(Set.of("merchant_no", "trace_no", "channel_id"), sent.keySet(),
+                    "出站参数恰好三个键:" + sent.keySet());
+            assertEquals(MCH_NO, sent.get("merchant_no"));
+            assertEquals(CFG_CHANNEL_ID, sent.get("channel_id"),
+                    "channel_id 取自配置(R37:本页说明逐字写「同创建商户接口」)");
+            traceNos.add((String) sent.get("trace_no"));
+        }
+        assertEquals(codes.size(), traceNos.size(), "七次调用的 trace_no 必须两两不同:" + traceNos);
+    }
+
+    /**
+     * ★ 未知码原样回、不抛 —— 这里选的几个码**不是**「没人用过的数字」:
+     * {@code 9 / 10 / 98 / 99} 正是**同名字段的另一套值域**(page 196236567,
+     * {@code LsyReviewStatus})里的码,{@code "审核通过"} 是 page 196236556 示例里的中文值。
+     *
+     * <p>换句话说本用例就是那条陷阱的探测器:谁把 {@code LsyReviewStatus} 接进来做映射
+     * (「长得非常像的现成枚举」,顺手复用是最可能发生的错),{@code 9} 这一格立刻红
+     * —— 码 {@code 2} 在**本页**是「审核驳回」,而在那套值域里根本没有对应项,复用会**静默**
+     * 给出错误文案(不抛、不红)。所以本方法返回的是**原始字符串**,不是任何文案。
+     */
+    @Test
+    void reportQuery_returnsRawStatus_forUnknownCode() {
+        withOnboardingCredentials();
+
+        for (String code : new String[]{"9", "10", "98", "99", "7", "审核通过"}) {
+            stubUpstream(ok(",\"reviewed_status\":\"" + code + "\""));
+            assertEquals(code, service.reportQuery(MCH_NO),
+                    "未知码 " + code + " 必须原样回,不抛也不映射");
+        }
+    }
+
+    /**
+     * ★ 缺键 / JSON {@code null} ⇒ 回 Java {@code null},**不抛**(§0.5:该页的返回示例是空代码块,
+     * 应答结构只能等实测 ⇒ 键可能真的不在)。
+     *
+     * <p>两种输入都走 {@code asText}(Java null 与 Hutool {@code JSONNull} 都归成 null);
+     * 后者是**真实形态**:解析器只把**顶层**的 JSON null 归一成 Java null(嵌套的不归一,
+     * 见 {@code toAreaItems}),而这里是顶层键。
+     *
+     * <p>★ 本用例**不**覆盖「键在、值是空串」这一格:那会返回 {@code ""} 而不是 {@code null}
+     * (实现就是 {@code asText(resp.get(...))} 一件事,不做空串归一化)。本页没有依据说上游会给空串,
+     * 记在报告的待确认项里,不在断言里替它定行为。
+     */
+    @Test
+    void reportQuery_missingStatus_returnsNull() {
+        withOnboardingCredentials();
+
+        for (String json : new String[]{ok(""), ok(",\"reviewed_status\":null")}) {
+            stubUpstream(json);
+            assertNull(service.reportQuery(MCH_NO),
+                    "缺键 / JSON null ⇒ 必须是 Java null(不抛、不编):" + json);
+        }
+    }
+
+    // ---- 三条薄腿共用的凭据闸(裁定 R38)----
+
+    /**
+     * ★ 三条**薄腿**(更新 / 提交入网 / 入网状态查询)都走同一道凭据闸 —— 裁定 R38。
+     *
+     * <p>为什么单独一条:上面每条腿的用例都先调 {@link #withOnboardingCredentials()}(把配置配齐),
+     * 于是「闸在不在」这件事**对它们全都不可见** —— 实测把 {@code requireOnboardingCredentials()}
+     * 从 {@code commitMerchantInfo} 与 {@code reportQuery} 里**各删一行**,本类 65 条**全绿**
+     * (只有 {@code updateMerchant} 那条在 ③ 段里钉了闸)。
+     * 那种形态的后果不是本侧报错,而是**配置缺失时拿空 {@code channel_id} 去签名上送** ——
+     * 出错只在上游,本侧静默。所以这条用例把三条腿的闸一次性钉住。
+     *
+     * <p>断言三件事,缺一不可:① 三条腿各自 400;② 文案是「凭据缺失」那套(不是
+     * {@code mchNoParams} 的「商户号不能为空」——否则 400 可能来自另一处校验,闸照样能是缺的);
+     * ③ 一次上游都没发。
+     */
+    @Test
+    void newLegs_missingCredentials_allThreeLegsThrow400WithoutCallingUpstream() {
+        // 一个键都不配(通道 id 等五项全缺,走 props 的默认空值)
+        BusinessException update = assertThrows(BusinessException.class,
+                () -> service.updateMerchant(updateDto()), "更新腿缺凭据时必须被拒");
+        BusinessException commit = assertThrows(BusinessException.class,
+                () -> service.commitMerchantInfo(commitDto()), "提交入网腿缺凭据时必须被拒");
+        BusinessException report = assertThrows(BusinessException.class,
+                () -> service.reportQuery(MCH_NO), "入网状态腿缺凭据时必须被拒");
+
+        for (BusinessException e : new BusinessException[]{update, commit, report}) {
+            assertEquals(400, e.getCode(), "凭据缺失一律 400,实际: " + e.getMessage());
+            // 文案必须是凭据闸那套:若这里是「商户号不能为空」,说明 400 来自 mchNoParams,
+            // 凭据闸有没有设这条用例都没咬住(② 就是为此存在的)
+            assertFalse("商户号不能为空".equals(e.getMessage()),
+                    "400 来自 mchNoParams 而不是凭据闸,本用例对闸是空的: " + e.getMessage());
+            assertCredentialsMessage(e.getMessage(), 5);
+        }
+
+        // ③ 三条腿一次都没发上游(闸在组装/上送之前)
+        verifyNoInteractions(client);
+    }
+
     /**
      * 把 {@link ListAppender} 挂到指定 logger 上、跑完 body、**无论成败都摘掉**
      * —— 与 {@code LsyNotifyServiceTest} / {@code LsyQrPayTest} 同款(本仓库按测试文件各自持有一份,