Browse Source

feat(进件): 开出更新商户的 HTTP 入口,R-7c 的封盖与门一起装

R-7c 登记的是「LsyMerchantUpdateDTO.rateCode + LsyOnboardingService:500 是同一
mass-assignment 的更新腿,今天不可达(updateMerchant 无生产调用方、无控制器绑定),
将来开 HTTP 入口时需同样封盖」。本次就是开那扇门 —— 封盖与门一起装,不开一扇裸门。

  POST /payment/collection/onboarding/update   module_payment:leshouying:onboarding:create

权限串复用 :create(不新增第四个):裁定 R-T9a 的既有口径,加记 33 的 /upload-image
与加记 35 §二 的 /mcc 走的也是这条路。菜单 SQL 已由用户执行完毕,新增第四个权限串
会要求用户再执行一次 SQL 才能用得上。

闸的位置与 /commit 逐字同形:requireTenantId() 之后立刻 requireByMchNoForTenant。
这条腿比 /commit 更要紧 —— /commit 只是「提交审核」,失败还能再提交一次;而 /update
直接改上游商户的结算卡号 / 法人证件 / 资质图片,拿别人的商户号调它就是改别人的结算账户。

两道封盖排在闸之后、服务调用之前:闸必须是第一件事(计划 Task 7 的口径),
而封盖必须在报文被组出来之前 —— 两侧都不得越位。两个 reject* 的形参从请求 DTO 收成
裸的 Long / String:/create 与 /update 都要它们,而两个 DTO 除这一个字段没有共同父类,
收窄是为了只有一份文案(两份文案分叉的症状是同一个错误在两扇门上说法不同)。

UpdateMerchantRequest 与 CreateMerchantRequest 有意不同,两处都没有:
- 没有 enterprise_id。企业归属是建行时定下的,pay_lsy_merchant 那一列不在上游报文里,
  也不该被一次「改商户资料」顺手改掉(那会是一次静默的归属迁移)。
- 没有 category。更新页没有 merchant_type,创建腿「一个 category 派生两个输出」
  那套在这里不成立;account_type 在这条腿是普通可选字段。

★ 不动 LsyOnboardingService:498-499:那条「更新腿有意不回落配置」是正确行为
(增量语义下每次硬塞一个配置值就是静默把商户费率改成平台默认),不是要顺带修掉的 bug。

TDD 读数:
- RED 先行 —— 先只放一个「只有 onboardingService.updateMerchant 一行」的空壳端点再跑,
  5 红:gatesTenantBeforeTouchingUpstream / foreignMchNo_is404_withoutTouchingUpstream /
  rejectsBodySuppliedTenantId / rejectsBodySuppliedRateCode /
  everyEndpoint_rejectsPrincipalWithoutTenant_loudly,各自红在自己的断言上。
  allowsBlankRateCode 与权限串反射两条按设计仍绿(它们不依赖本次的行为)。
- GREEN:本类 49 绿(原 44 + 新增 5);全量 mvn clean test = 652 绿(原 647 + 5)。

两向变异 6 条。生产码基线 6074a06e3a18b2c19f6b1c7fdb7d5ec8ab5d5a4fa874e6a6abb82d519b62e792
(53519 B / 754 LF / 0 CR),每条还原后逐字节复核回基线,跑完 grep 变异体残留串 = 0:
- M-1 摘掉租户闸 ⇒ 2 红(两条闸用例)
- M-2 摘掉 rate_code 封盖 ⇒ 1 红($.code 期望 400 得 0)—— 即不装这道闸时,带
  rate_code 的请求会一路走通,那正是 R-7c 要封的那件事
- M-3 把 updateMerchant 提到闸之前(顺序反了)⇒ 4 红:InOrder 那条 + 三条
  verifyNoInteractions / never-updateMerchant —— 顺序被四个独立角度钉住,不是只靠一条
- M-4 isNotBlank 改 isNotEmpty ⇒ 2 红(创建腿与更新腿的空白值用例,都红在 "   " 那格)
- M-5 摘掉 tenant_id 封盖 ⇒ 1 红
- M-A 无害方向:两道封盖对调 ⇒ 仍 49 绿。★ 绿向补了产物级证据(源码级「我改了」不算):
  javap -c -p 反汇编 update 方法,invokestatic 顺序为 rejectBodySuppliedRateCode
  (offset 22) → rejectBodySuppliedTenantId(offset 29),即变异体确实进了被执行的 class;
  正对照在同一段里:requireByMchNoForTenant@14 / updateMerchant@37 / Result.ok@40。

前端没有对应入口(onboarding.ts 现有四个调用 /areas /mcc /upload-image /create,
没有 update;进件页也没有编辑 UI)—— 本次只开后端入口,前端要不要补 UI 待裁定。
alphaH 3 weeks ago
parent
commit
1af6ef2a59

+ 94 - 9
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -10,6 +10,7 @@ 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.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
@@ -36,8 +37,8 @@ import java.util.List;
 import java.util.Set;
 
 /**
- * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 省市区查询 / 资质图片上传 /
- * 行业类目查询,六个端点。
+ * 乐收赢进件(三-B)的**管理端入口** —— 建号 / 落本地行 / 提交入网 / 更新商户 / 省市区查询 /
+ * 资质图片上传 / 行业类目查询,七个端点。
  *
  * <p><b>【本类存在的理由:这条腿在 Task 7 之前根本没有 HTTP 入口】</b>
  * Task 3–6 把 {@link LsyOnboardingService} 的四条腿做完了(建号 / 更新 / 提交入网 / 入网状态查询),
@@ -46,6 +47,10 @@ import java.util.Set;
  * 服务层看不到租户,所以「谁可以发起进件」这件事只能在**这一个同时握着新 {@code mch_no}
  * 与认证租户的地方**定下来。
  *
+ * <p>★ <b>四条腿里仍有一条没有 HTTP 入口</b>:入网状态查询({@code LsyOnboardingService.reportQuery})
+ * —— 「审核结果怎么回到我们这边」走的是**上游主动推**的免登录通知端点(Task 6),
+ * 不从管理端发起。所以「四条腿 vs 七个端点」不是漏做,是这条腿的入口本来就在别处。
+ *
  * <h2>★★ 租户归属闸(本任务的安全要点)</h2>
  * <b>每个带 {@code mchNo} 的端点,第一行就是闸。</b>顺序固定为
  * 「取认证上下文 → {@code requireByMchNoForTenant(mchNo, tenantId)} → 才调服务」——
@@ -66,7 +71,7 @@ import java.util.Set;
  *
  * <h2>端点清单(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)</h2>
  * <table border="1">
- *   <caption>三个权限串 → 六个端点</caption>
+ *   <caption>三个权限串 → 七个端点</caption>
  *   <tr><th>方法</th><th>完整路径</th><th>权限串</th><th>带 mchNo</th><th>租户闸落在哪</th></tr>
  *   <tr><td>GET</td><td>{@code /payment/collection/onboarding/page}</td>
  *       <td>{@value #PERM_ONBOARDING_LIST}</td><td>否</td>
@@ -180,8 +185,8 @@ public class LeshouyingOnboardingController {
     @PostMapping("/create")
     public Result<LsyMerchantEntity> create(@RequestBody CreateMerchantRequest body) {
         Long tenantId = requireTenantId();
-        rejectBodySuppliedTenantId(body);
-        rejectBodySuppliedRateCode(body);
+        rejectBodySuppliedTenantId(body.getTenantId());
+        rejectBodySuppliedRateCode(body.getRateCode());
         // 建号:上游返回的商户号是**唯一**能让本地行与上游对上号的东西,且保证非空白(服务层 502 兜底)。
         String mchNo = onboardingService.createMerchant(body);
         try {
@@ -215,6 +220,66 @@ public class LeshouyingOnboardingController {
         return Result.ok();
     }
 
+    /**
+     * 更新商户信息(上游 {@code /api/merchant/update},**增量**语义:报文里有值的项才改)。
+     *
+     * <p>★ <b>本方法的第一件事就是租户闸</b>,与 {@link #commit} 逐字同形
+     * ({@link #requireTenantId()} 之后立刻 {@code requireByMchNoForTenant})。
+     * 这条腿比 {@code /commit} 更要紧:{@code /commit} 只是「提交审核」,失败还能再提交一次;
+     * 而本端点直接改上游商户的结算卡号 / 法人证件 / 资质图片 —— 拿别人的商户号调它,
+     * 就是改别人的结算账户。
+     *
+     * <p>★ <b>封盖(R-7c)</b>:两道 {@code reject*} 是本端点的安全边界,不是参数校验 ——
+     * 理由逐条写在 {@link #rejectBodySuppliedRateCode(String)} 的 javadoc 里(那里是唯一落点)。
+     * 它们排在闸**之后**、服务调用**之前**:闸必须是第一件事(计划 Task 7 的口径),
+     * 而封盖必须在报文被组出来之前 —— 两侧都不得越位。
+     *
+     * <p>★ 报文里那 {@code null} / 空白之外的 50 个字段一律**原样透传**给服务层,
+     * 本端点不校验、不归一化(与创建腿同一条边界:校验点只有服务层一处,
+     * 放在这里会变成两份口径)。
+     */
+    @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
+    @PostMapping("/update")
+    public Result<Void> update(@RequestBody UpdateMerchantRequest body) {
+        Long tenantId = requireTenantId();
+        merchantService.requireByMchNoForTenant(body.getMchNo(), tenantId);
+        rejectBodySuppliedTenantId(body.getTenantId());
+        rejectBodySuppliedRateCode(body.getRateCode());
+        onboardingService.updateMerchant(body);
+        return Result.ok();
+    }
+
+    /**
+     * 更新请求体 = 上游「更新商户信息」那 50 个字段({@link LsyMerchantUpdateDTO})**加**一个
+     * 只为响亮拒绝而存在的 {@code tenantId}。
+     *
+     * <p>★ 与创建腿的 {@link CreateMerchantRequest} **不同**:那里还多一个本地的
+     * {@code enterprise_id}(那一行属于哪个企业)。更新腿**没有**这个字段 ——
+     * 企业归属是**建行时**定下的,{@code pay_lsy_merchant} 里那一列不在上游报文里,
+     * 也不该被一次「改商户资料」顺手改掉(那会是一次静默的归属迁移)。
+     *
+     * <p>★ 也**没有** {@code category}:更新页没有 {@code merchant_type},
+     * 创建腿那套「一个 category 派生两个输出」在这里不成立 —— 见 {@link LsyMerchantUpdateDTO}
+     * 的类 javadoc(那里是唯一落点)。
+     */
+    @Data
+    @EqualsAndHashCode(callSuper = true)
+    public static class UpdateMerchantRequest extends LsyMerchantUpdateDTO {
+
+        /**
+         * ★ <b>只为「响亮拒绝」而声明,从不被读取。</b>同 {@link CreateMerchantRequest#tenantId}:
+         * 本仓 {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true ⇒ 不声明它,请求体里的 {@code tenant_id}
+         * 会变成 HTTP 500「服务器内部错误」;声明了则由
+         * {@link #rejectBodySuppliedTenantId(Long)} 当场拒成 400 + 一句人话。
+         *
+         * <p>删掉它不会让归属变得可指定(归属只由 {@link #requireTenantId()} 决定),
+         * 只会把这个请求的出口从「400 + 一句人话」降级成「500 服务器内部错误」。
+         * {@code LeshouyingOnboardingControllerTest.updateEndpoint_rejectsBodySuppliedTenantId}
+         * 钉着这个行为。
+         */
+        private Long tenantId;
+    }
+
     // ──────────────── 资质图片上传端点(加记 33) ────────────────
 
     /**
@@ -594,9 +659,13 @@ public class LeshouyingOnboardingController {
      * <p>同 {@code LeshouyingMerchantController.save} 的既有教训(那里是
      * {@code entity.setTenantId(null)} —— 归属不由调用方指定)。这里选拒绝的理由是
      * 失败形态:忽略会让一个写错了这个字段的调用方拿到「成功」,并以为自己的指定生效了。
+     *
+     * <p>★ 参数是裸的 {@code Long} 而不是某个请求 DTO:{@code /create} 与 {@code /update}
+     * 两条腿都要它,而两个 DTO 除了这一个字段没有共同父类 —— 收成 {@code Long} 是为了
+     * **只有一份文案**(两份文案分叉的症状是「同一个错误在两扇门上说法不同」)。
      */
-    private static void rejectBodySuppliedTenantId(CreateMerchantRequest body) {
-        if (body.getTenantId() != null) {
+    private static void rejectBodySuppliedTenantId(Long tenantId) {
+        if (tenantId != null) {
             throw new BusinessException(400, "归属租户只能由登录身份决定,请求体里不得出现 tenant_id");
         }
     }
@@ -621,14 +690,30 @@ public class LeshouyingOnboardingController {
      * 服务层本来就把空白当「没给」⇒ 回落平台配置,所以只有**非空白**值才可能真正覆盖平台配置,
      * 也才是攻击形态。拒掉空白值不会更安全,只会把一次本来正常的进件变成失败。
      *
+     * <p>★★ <b>同一个方法也封 {@code /update} 那条腿</b>(R-7c 的裁定:两处是同一 mass-assignment
+     * 的两条腿,「将来开 HTTP 入口时**需同样封盖**」)。两条腿**攻击形态不同、判据相同**:
+     * <ul>
+     *   <li><b>创建腿</b>:{@code createMerchant} 是「DTO 有值以 DTO 为准,没有则用平台配置」
+     *       ⇒ 报文里给一个非空 {@code rate_code} 就**覆盖**了平台配置(新商户的费率)。</li>
+     *   <li><b>更新腿</b>:{@code updateMerchant} 是**增量**更新({@code putIfNotBlank}),
+     *       且那条腿**有意不回落配置**({@code LsyOnboardingService:498-499})
+     *       ⇒ 报文里给一个非空 {@code rate_code} 就是把**这个商户谈好的费率**改成任意值,
+     *       连「绕过回落逻辑」这一步都不需要,一个键就够了。</li>
+     * </ul>
+     * 判据取 {@code isNotBlank} 在两条腿上同一条理由:空白值都被服务层的
+     * {@code putIfNotBlank} / 回落分支当成「没给」⇒ 报文里根本不会出现 {@code rate_code}
+     * ⇒ 改不动费率,不是攻击形态。
+     * <br>★ <b>别顺手去「修」{@code LsyOnboardingService:498-499}</b>:那条「更新腿不回落配置」
+     * 是**有意**的(增量语义下每次硬塞一个配置值就是静默把商户费率改成平台默认),不是 bug。
+     *
      * <p>【文案用中文标签而不是键名】{@code rate_code} 不是本地列({@code pay_lsy_merchant}
      * 建表语句里没有它),对外文案里出现它会违反 Global Constraint 1(文案零上游痕迹)。
      * 本模块的口径见 {@code LsyOnboardingService} 的 {@code labelsOf}:那里**宁可抛
      * {@code IllegalStateException} 也不退回键名**。所以这里用中文标签「费率模板」
      * ——它就在 {@code LsyMerchantRequiredFields.LABELS} 里,是这一项对外的既有叫法。
      */
-    private static void rejectBodySuppliedRateCode(CreateMerchantRequest body) {
-        if (StrUtil.isNotBlank(body.getRateCode())) {
+    private static void rejectBodySuppliedRateCode(String rateCode) {
+        if (StrUtil.isNotBlank(rateCode)) {
             throw new BusinessException(400, "费率模板由平台统一配置,请求体里不得指定");
         }
     }

+ 176 - 4
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -105,7 +105,7 @@ class LeshouyingOnboardingControllerTest {
 
     private static final String MCH_NO = "Y52211090004";
 
-    /** 六个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
+    /** 七个端点共用的路径前缀(Task 8 的菜单 SQL 与 Task 9 的前端 API 层按它对齐)。 */
     private static final String PREFIX = "/payment/collection/onboarding";
 
     /** 省市区只读端点的完整路径(加记 29 §二 裁定 R-T9a 新增的第四个端点)。 */
@@ -114,6 +114,9 @@ class LeshouyingOnboardingControllerTest {
     /** 行业类目只读端点的完整路径(加记 35 §二 裁定 R-T9f′ 新增的第六个端点)。 */
     private static final String MCC_PATH = PREFIX + "/mcc";
 
+    /** 更新商户端点的完整路径(R-7c 开口后新增的第七个端点)。 */
+    private static final String UPDATE_PATH = PREFIX + "/update";
+
     // ---- 资质图片上传端点(加记 33)用的夹具 ----
 
     /** 上传端点的完整路径。 */
@@ -467,6 +470,160 @@ class LeshouyingOnboardingControllerTest {
         verify(onboardingService, times(2)).createMerchant(any());
     }
 
+    // ──────────────── 更新商户端点(R-7c 开口 + 同一处 mass-assignment 封盖) ────────────────
+
+    /**
+     * ★★ 更新腿的租户闸:**先证明这个商户号属于本租户,才允许改它**,且闸排在改动之前。
+     *
+     * <p>为什么这条腿比 {@code /commit} 更要紧:{@code /commit} 是「提交审核」(可重复、可再提交,
+     * 错一次还能再提交一次),而 {@code /update} 直接改上游商户的结算卡号 / 法人证件 / 资质图片 ——
+     * 拿别人的商户号调它,就是改别人的结算账户。所以闸的位置与 {@code /commit} 逐字同形:
+     * {@code requireTenantId()} 之后**立刻** {@code requireByMchNoForTenant}。
+     *
+     * <p>两臂:
+     * <ol>
+     *   <li>查询自带 {@code tenant_id = ?} 且绑定的就是上下文里的租户 —— 主体取**超管**
+     *       ({@code TenantInnerInterceptor.ignoreTable} 的第一个分支,构造上不追加谓词)
+     *       ⇒ 谓词只能由**被调方**自己构造,与拦截器在不在无关(同 {@code /commit} 那条用例);</li>
+     *   <li>{@code InOrder} 钉住闸的查询排在 {@code updateMerchant}(上游调用)之前 ——
+     *       顺序反了**不是**「同样安全」:那等于拿一个还没证明属于本租户的商户号去改上游数据,
+     *       404/403 只是发晚了。</li>
+     * </ol>
+     */
+    @Test
+    void updateEndpoint_gatesTenantBeforeTouchingUpstream() {
+        LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+        LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
+        login(TENANT, true);
+        when(mapper.selectList(any())).thenReturn(List.of(row(MCH_NO)));
+
+        c.update(updateBody(MCH_NO));
+
+        AbstractWrapper<?, ?, ?> w = capturedSelectList(mapper);
+        assertTrue(w.getSqlSegment().contains("tenant_id = #{"),
+                "查询必须自带 tenant_id 谓词(超管主体下拦截器不会追加)。实测: " + w.getSqlSegment());
+        assertTrue(w.getParamNameValuePairs().containsValue(TENANT),
+                "谓词占位符必须绑定上下文里的租户 " + TENANT + "。实测: " + w.getParamNameValuePairs());
+
+        InOrder gateFirst = inOrder(mapper, onboardingService);
+        gateFirst.verify(mapper).selectList(any());
+        gateFirst.verify(onboardingService).updateMerchant(any());
+    }
+
+    /**
+     * ★ 商户号不属于当前租户 ⇒ **404,且一次上游都不碰**。
+     *
+     * <p>零接触读数是**无歧义**的:{@code onboardingService} 是本控制器里唯一能发出上游报文的入口,
+     * 而闸是它之前唯一那一步。断言的判据是**异常码**(404)而不是「查不到」——
+     * 后者在把租户校验整条删掉之后仍会绿({@code selectList} 返回什么完全由 mock 决定)。
+     */
+    @Test
+    void updateEndpoint_foreignMchNo_is404_withoutTouchingUpstream() {
+        LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+        LeshouyingOnboardingController c = controllerWithRealMerchantService(mapper);
+        login(TENANT, false);
+        when(mapper.selectList(any())).thenReturn(List.of()); // 本租户下没有这个商户号
+
+        BusinessException ex = assertThrows(BusinessException.class,
+                () -> c.update(updateBody(MCH_NO)),
+                "不属于本租户的商户号必须在触达上游之前被拒");
+
+        assertEquals(404, ex.getCode(), "必须显式拒绝(404)。实测: " + ex.getCode());
+        verifyNoInteractions(onboardingService);
+    }
+
+    /**
+     * ★ 请求体里出现 {@code tenant_id} ⇒ **400(业务码,HTTP 200)**,不是 500「服务器内部错误」。
+     *
+     * <p>同创建腿那条:{@link LeshouyingOnboardingController.UpdateMerchantRequest#tenantId}
+     * 是**只为响亮拒绝**而声明的字段 —— 不声明它,本仓 {@code FAIL_ON_UNKNOWN_PROPERTIES} 是 true,
+     * 这个多出来的键会变成一份 {@code HttpMessageNotReadableException},而
+     * {@code GlobalExceptionHandler} 五个分支里没有它 ⇒ 落兜底 500。
+     *
+     * <p>★ 拒绝的是**这一项**,不是整份报文:正对照紧跟其后(同形报文、去掉该键必须被受理),
+     * 否则那个 400 也可能只是「整份报文压根没绑上」造成的。
+     */
+    @Test
+    void updateEndpoint_rejectsBodySuppliedTenantId() throws Exception {
+        login(TENANT, false);
+
+        // 反例:多一个 tenant_id ⇒ 拒绝
+        mockMvc.perform(post(UPDATE_PATH)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"mch_no\":\"" + MCH_NO + "\",\"tenant_id\":" + BODY_TENANT + "}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400));
+
+        // 被拒的那一刻:这次更新一次都没发生,且请求体里那个租户号一次都没被当作租户用过
+        verify(onboardingService, never()).updateMerchant(any());
+        verify(merchantService, never()).requireByMchNoForTenant(any(), eq(BODY_TENANT));
+
+        // 正对照:同形报文、去掉 tenant_id ⇒ 受理
+        mockMvc.perform(post(UPDATE_PATH)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"mch_no\":\"" + MCH_NO + "\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+        verify(onboardingService, times(1)).updateMerchant(any());
+    }
+
+    /**
+     * ★★ <b>R-7c 的封盖</b>:请求体里带了**非空**费率模板 ⇒ 响亮拒绝,且**不得触达上游**。
+     *
+     * <p>【为什么更新腿也必须有这道闸】台账 R-7c 逐字:「{@code LsyMerchantUpdateDTO.rateCode}
+     * + {@code LsyOnboardingService:500} 是同一 mass-assignment 的更新腿 …… 将来开 HTTP 入口时
+     * **需同样封盖**」。本端点就是那扇门。
+     *
+     * <p>它和创建腿的攻击形态**不同、但同源**:创建腿是「DTO 有值则覆盖平台配置」(新商户的费率),
+     * 而 {@code updateMerchant} 是**增量**更新({@code putIfNotBlank})⇒ 报文里给一个非空的
+     * {@code rate_code} 就是把**这个商户谈好的费率**改成任意值;而这条腿**有意不回落配置**
+     * ({@code LsyOnboardingService:498-499})⇒ 覆盖它不需要绕过任何回落逻辑,一个键就够了。
+     *
+     * <p>★ 判据与创建腿**同一个**({@code StrUtil.isNotBlank}),理由在两条腿上一致:
+     * 空白值被服务层的 {@code putIfNotBlank} 直接丢掉 ⇒ 报文里根本不会出现 {@code rate_code}
+     * ⇒ 它改不动费率,不是攻击形态;拒掉它只会把一次正常更新变成失败。
+     *
+     * <p>★ <b>本用例**不**动 {@code LsyOnboardingService:498-499}</b>:那是「有意不回落配置」,
+     * 是这条腿的正确行为,不是要顺带「修掉」的 bug。
+     */
+    @Test
+    void updateEndpoint_rejectsBodySuppliedRateCode_beforeTouchingUpstream() throws Exception {
+        login(TENANT, false);
+
+        // 反例:费率模板由请求体给 ⇒ 400(文案用 LABELS 里的中文标签「费率模板」,不带上游键名)
+        mockMvc.perform(post(UPDATE_PATH)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"mch_no\":\"" + MCH_NO + "\",\"rate_code\":\"RT0001\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                .andExpect(jsonPath("$.msg", containsString("费率模板")));
+        verifyNoInteractions(onboardingService);
+
+        // 正对照:同形报文、去掉 rate_code ⇒ 受理
+        mockMvc.perform(post(UPDATE_PATH)
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"mch_no\":\"" + MCH_NO + "\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+        verify(onboardingService).updateMerchant(any());
+    }
+
+    /**
+     * 口径:**空串 / 纯空白不拒** —— 与 {@link #createEndpoint_allowsBlankRateCode} 同一条判据
+     * (服务层 {@code putIfNotBlank} 的补集),不该因为换了条腿就分叉成两套。
+     */
+    @Test
+    void updateEndpoint_allowsBlankRateCode() throws Exception {
+        login(TENANT, false);
+
+        for (String blank : List.of("", "   ")) {
+            mockMvc.perform(post(UPDATE_PATH)
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content("{\"mch_no\":\"" + MCH_NO + "\",\"rate_code\":\"" + blank + "\"}"))
+                    .andExpect(jsonPath("$.code").value(0));
+        }
+
+        verify(onboardingService, times(2)).updateMerchant(any());
+    }
+
     /**
      * 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
      *
@@ -1552,9 +1709,9 @@ class LeshouyingOnboardingControllerTest {
                 LeshouyingOnboardingController.PERM_ONBOARDING_COMMIT);
 
         List<Method> mapped = mappedMethods();
-        assertEquals(6, mapped.size(),
-                "本控制器应恰好六个端点(三个权限串 → 六个端点:/areas、/upload-image、/mcc 都复用 :create,"
-                        + "加记 29 §二 裁定 R-T9a、加记 33、加记 35 §二 裁定 R-T9f′)。实测: "
+        assertEquals(7, mapped.size(),
+                "本控制器应恰好七个端点(三个权限串 → 七个端点:/areas、/upload-image、/mcc、/update 都复用 :create,"
+                        + "加记 29 §二 裁定 R-T9a、加记 33、加记 35 §二 裁定 R-T9f′、R-7c)。实测: "
                         + mapped.stream().map(Method::getName).toList());
 
         Set<String> onAnnotations = new LinkedHashSet<>();
@@ -1817,6 +1974,7 @@ class LeshouyingOnboardingControllerTest {
                 () -> controller.page(1, 20, null),
                 () -> controller.create(createBody("E1", "示例商户")),
                 () -> controller.commit(commitBody(MCH_NO)),
+                () -> controller.update(updateBody(MCH_NO)),
                 () -> controller.areas("1", null),
                 () -> controller.mcc("1", null, null),
                 () -> controller.uploadImage(new MockMultipartFile("file", UPLOAD_NAME, "image/jpeg", IMG_BYTES)));
@@ -1873,6 +2031,20 @@ class LeshouyingOnboardingControllerTest {
         props.setOnboardingNotifyUrl("https://example.com/xjz/notify/lsy/onboarding");
     }
 
+    /**
+     * 更新报文的 DTO 夹具 —— **只给 {@code mchNo}**。
+     *
+     * <p>为什么其余字段一个都不填:{@code updateMerchant} 是**增量**语义({@code putIfNotBlank}),
+     * 只给 {@code mchNo} 就等于「本次不改任何一项」——这正是这条腿最小的合法报文,
+     * 也让「闸有没有过」这件事不被别的字段干扰。
+     */
+    private static LeshouyingOnboardingController.UpdateMerchantRequest updateBody(String mchNo) {
+        LeshouyingOnboardingController.UpdateMerchantRequest b =
+                new LeshouyingOnboardingController.UpdateMerchantRequest();
+        b.setMchNo(mchNo);
+        return b;
+    }
+
     private static LsyCommitMerchantInfoDTO commitBody(String mchNo) {
         LsyCommitMerchantInfoDTO d = new LsyCommitMerchantInfoDTO();
         d.setMchNo(mchNo);