Parcourir la source

fix(进件): Task 7 修复轮 1 —— 落库失败的出口 + 请求体自填费率模板

评审 F2 与安全评审 Vuln 2(都是 Medium),落在同一个方法上,故一并提交。

**F2:本地落库失败原样上抛 ⇒ HTTP 500,而上游那次建号已经发生。**
旧出口实测(`t7-probe/t7-fix1-mA1c.log`,摘掉护栏后的读数):
`Status expected:<200> but was:<500>` —— 500 的语义是「重试可能成功」,
调用方照做就是在上游多建一个商户。改法:只把**本地写**那一次调用包起来,不包上游。

  · 捕获类型取 `RuntimeException`,不建立在 MyBatis-Spring 的异常翻译配置上。
    真库探针(`@Transactional` 回滚)实测同一条代码路径上真是两个类:
    主键冲突(SQLState 23505) ⇒ `DuplicateKeyException`;值过长(22001 —— 报文没有
    长度校验、merchant_name 是 varchar(128)) ⇒ `DataIntegrityViolationException`;
    翻译器不在场时则是裸 `PersistenceException`。两个真类 ⇒ 任何窄 catch 都会漏掉
    别种形态(读数见 `t7-fix1-probe.log` / `t7-fix1-probe2.log`)。
  · 转 `BusinessException(409, ...)`:文案带**上游返回的商户号** + 一句行为指令,
    写法照 `LsyOnboardingService:403` 那条 502 的先例(「商户创建成功但未返回商户号
    (商户可能已创建,不得当作失败直接重试)」);原始异常只进 log.error(带栈),不进文案。
  · 为什么是 409:本模块已有 409 表示「状态不允许、要人工处置」的先例
    (`LsyMerchantService:334`)。500 会招致重试;502 会把「上游已成功」说成「上游异常」;
    400/403/404 会把责任推给调用方,同样招致重提 —— 而重提会在上游再建一个商户。

**Vuln 2:请求体可指定平台费率。**
`CreateMerchantRequest extends LsyMerchantCreateDTO` ⇒ 上游建号那 54 个字段整份可被
租户侧赋值;其中 `rate_code` 在服务层是「DTO 有值以 DTO 为准、配置只作回落」
(`LsyOnboardingService.java:1221`),而必填矩阵把它归在「配置」层
⇒ 没有既有校验会拦下它。

  改法:请求体里出现**非空** `rate_code` ⇒ 就地 **400**,位置与
  `rejectBodySuppliedTenantId` 同一块、在**上游调用之前**(「不进上游」由
  `verifyNoInteractions` 钉住)。判据取服务层那条 `StrUtil.isBlank` 三元式的**补集**:
  空白值不拒 —— 服务层本来就把空白当「没给」⇒ 回落平台配置,只有非空白值才可能
  真正覆盖它。文案用模块已注册的中文标签「费率模板」而不是上游键名(GC1;
  先例:`LsyMerchantRequiredFields` 的 labelsOf 宁可抛异常也不退回键名)。

测试:+3 条(落库失败四种异常形态各跑一遍 / 非空 rate_code 拒绝且不进上游 /
空白 rate_code 放行)。两向变异 6 条读数:摘掉或收窄护栏 4 条红,纯重命名 2 条绿
(绿向附 class 级证据:`javap -l` 的 `create` LocalVariableTable 槽位 4 由 `e` 变 `ex`、
`rejectBodySuppliedRateCode` 槽位 0 由 `body` 变 `request`;还原后源码与 class
都与基线逐字节相同)。

全量 `Tests run: 614, Failures: 0, Errors: 0, Skipped: 0`(基线 611 + 3),BUILD SUCCESS。

未碰 GlobalExceptionHandler / 两个 DTO / `@Valid` / LeshouyingMerchantController.save /
LsyMerchantService。读数与脚本在 java/target/t7-probe/(gitignored)。
alphaH il y a 3 semaines
Parent
commit
556d271897

+ 72 - 6
java/src/main/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingController.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.leshouying.controller;
 
+import cn.hutool.core.util.StrUtil;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.response.Result;
@@ -9,6 +10,7 @@ import com.payment.platform.module.payment.leshouying.dto.LsyMerchantCreateDTO;
 import com.payment.platform.module.payment.leshouying.entity.LsyMerchantEntity;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
+import com.payment.platform.module.payment.leshouying.util.LsyLogSanitizer;
 import lombok.Data;
 import lombok.EqualsAndHashCode;
 import lombok.RequiredArgsConstructor;
@@ -126,19 +128,51 @@ public class LeshouyingOnboardingController {
      * {@code review_status} 钉成 {@code APPROVED}(A 阶段手工维护存量行的口径),
      * 而新商户是「还没审核」——落到那个值等于把「未审核」读成「审核过了」。
      *
-     * <p>★ 已知窗口(**未做补偿**,本次按 brief 的顺序实现):上游建号成功、本地落行失败时,
-     * 上游多了一个商户而本地没有行 —— 与上面那条相反方向的半边。
-     * 上游调用无法纳入本地事务,所以这不是一个 {@code @Transactional} 能关掉的口子。
+     * <p>★ 已知窗口(**关不掉**):上游建号成功、本地落行失败时,上游多了一个商户而本地没有行 ——
+     * 与上面那条相反方向的半边。上游调用无法纳入本地事务,所以这不是一个 {@code @Transactional}
+     * 能关掉的口子;**能决定的是它长什么样**(见下)。
+     *
+     * <p>★ <b>本地落库那一次调用单独包在 try 里</b>(上游调用在 try 之外 —— 它一旦成功,
+     * 「上游已建号」就是既成事实,而这条出口的整个意义就是把那个事实说出来):
+     * 失败时不再逃逸成 {@code GlobalExceptionHandler} 的 catch-all
+     * —— 那是 HTTP 500「服务器内部错误」+ 零信息,而 500 的语义就是「重试」,
+     * 重试就是**再建一个上游商户、本地仍然没有行**。现在它响亮地说出
+     * 「商户创建成功(商户号 X),但本地进件记录写入失败,不得当作失败直接重试」,
+     * 并把原始异常连同 {@code tenantId} / {@code mchNo} 打进日志。
+     * 窗口本身仍然在,只是它现在**有编号(mchNo)、有出口、有对账凭据**。
+     *
+     * <p><b>catch 为什么是 {@code RuntimeException} 而不是某个具体类型</b>:落库失败抛出的类型
+     * 取决于 MyBatis-Spring 把那个 SQLException 翻成了什么 —— 2026-09-23 探针实测(真库、
+     * 回滚事务)**同一条代码路径上有两个不同的类**:唯一键冲突(SQLState 23505)⇒
+     * {@code DuplicateKeyException},值超长(22001)⇒ {@code DataIntegrityViolationException};
+     * 而翻译器不在时抛的又是未翻译的 {@code PersistenceException}。
+     * 护栏建立在其中任何一个上都会漏掉另一个(请求体没有长度校验,超长那条今天就能到)。
+     *
+     * <p>★ 这也会接住 {@code createOnboardingRow} 自己的 403/400(contract 违例,在这条路径上
+     * 构造上不可达:{@code tenantId} 刚验过、{@code mchNo} 由服务层 502 保证非空白)——
+     * 这是**有意**的:万一可达,处境与本方法要报的完全一样(上游已建号、本地没有行),
+     * 而原样上抛一个 403 会把人引向「补个租户再试一次」,再试一次就是再建一个上游商户。
      */
     @PreAuthorize("@perm.hasAny('module_payment:leshouying:onboarding:create')")
     @PostMapping("/create")
     public Result<LsyMerchantEntity> create(@RequestBody CreateMerchantRequest body) {
         Long tenantId = requireTenantId();
         rejectBodySuppliedTenantId(body);
+        rejectBodySuppliedRateCode(body);
         // 建号:上游返回的商户号是**唯一**能让本地行与上游对上号的东西,且保证非空白(服务层 502 兜底)。
         String mchNo = onboardingService.createMerchant(body);
-        return Result.ok(merchantService.createOnboardingRow(
-                tenantId, body.getEnterpriseId(), mchNo, body.getMerchantName()));
+        try {
+            return Result.ok(merchantService.createOnboardingRow(
+                    tenantId, body.getEnterpriseId(), mchNo, body.getMerchantName()));
+        } catch (RuntimeException e) {
+            // 原始异常进日志(带栈),不进应答文案。
+            log.error("进件的本地落库失败(上游商户已创建,本地无行):tenantId={}, mchNo={}, 异常={}",
+                    tenantId, LsyLogSanitizer.flatten(mchNo), e.toString(), e);
+            // 文案照 createMerchant 那条 502 的先例:说清既成事实 + 给一句行为指令。
+            // 不含上游名号 / 域名 / 上游专有键名(Global Constraint 1 —— 它原样回给调用方)。
+            throw new BusinessException(409, "商户创建成功(商户号 " + mchNo
+                    + "),但本地进件记录写入失败,不得当作失败直接重试,请联系管理员处理");
+        }
     }
 
     /**
@@ -158,7 +192,7 @@ public class LeshouyingOnboardingController {
         return Result.ok();
     }
 
-    // ─────────────────────────────── 归属:只来自认证上下文 ───────────────────────────────
+    // ──────────────── 服务端决定的值:只来自认证上下文 / 平台配置,不接受请求体指定 ────────────────
 
     /**
      * 取当前主体的租户;取不到就**显式拒绝**。
@@ -193,6 +227,38 @@ public class LeshouyingOnboardingController {
         }
     }
 
+    /**
+     * 请求体里出现**非空**费率模板 ⇒ **拒绝**(而不是忽略)。
+     *
+     * <p>【为什么这是安全边界,不是参数校验】费率模板({@link LsyMerchantCreateDTO#getRateCode()},
+     * 出站报文里的 {@code rate_code})按计划 §0.8 是**平台级配置**({@code leshouying.rate-code})。
+     * 服务层组装出站报文时是「DTO 有值以 DTO 为准,没有则用平台配置」——
+     * 那个覆盖口是给**程序化调用方**设计的;而本端点把同一份 DTO **整份**绑到了
+     * **租户侧**的 JSON 请求体上({@link CreateMerchantRequest} 继承 {@link LsyMerchantCreateDTO}),
+     * 于是「租户在报文里多带一个键,就能自己指定自己的结算费率」。必填矩阵不管这一项
+     * ({@code LsyMerchantRequiredFields} 把它归在「配置」层)⇒ 没有任何既有校验会拦下它。
+     * <b>本方法就是那个覆盖口在租户侧的封盖</b>:非空值就地拒掉,上游那一次调用根本不会发生。
+     *
+     * <p>【为什么是「拒绝」而不是 {@code @JsonIgnore} / 静默置空】同
+     * {@link #rejectBodySuppliedTenantId} 的理由:静默丢弃会让写错这个字段的调用方拿到「成功」,
+     * 并以为自己的指定生效了 —— 那是把一次定价篡改伪装成一次看起来正常的进件。
+     *
+     * <p>【口径:空白值不拒】判据取服务层那条三元表达式的**补集**({@code StrUtil.isBlank}):
+     * 服务层本来就把空白当「没给」⇒ 回落平台配置,所以只有**非空白**值才可能真正覆盖平台配置,
+     * 也才是攻击形态。拒掉空白值不会更安全,只会把一次本来正常的进件变成失败。
+     *
+     * <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())) {
+            throw new BusinessException(400, "费率模板由平台统一配置,请求体里不得指定");
+        }
+    }
+
     /**
      * 建号请求体 = 上游「创建商户」那 54 个字段({@link LsyMerchantCreateDTO})**加**本地的
      * {@code enterprise_id}。

+ 141 - 5
java/src/test/java/com/payment/platform/module/payment/leshouying/controller/LeshouyingOnboardingControllerTest.java

@@ -17,6 +17,7 @@ import com.payment.platform.module.payment.leshouying.mapper.LsyMerchantMapper;
 import com.payment.platform.module.payment.leshouying.service.LsyMerchantService;
 import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
 import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.apache.ibatis.exceptions.PersistenceException;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -26,6 +27,8 @@ import org.mockito.InOrder;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.dao.DuplicateKeyException;
 import org.springframework.http.MediaType;
 import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -46,6 +49,7 @@ import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
 
+import static org.hamcrest.Matchers.containsString;
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
@@ -93,16 +97,25 @@ class LeshouyingOnboardingControllerTest {
     @BeforeEach
     void setUp() {
         controller = new LeshouyingOnboardingController(merchantService, onboardingService);
-        ObjectMapper appMapper = new JacksonConfig().objectMapper();
-        mockMvc = MockMvcBuilders.standaloneSetup(controller)
-                .setControllerAdvice(new GlobalExceptionHandler())
-                .setMessageConverters(new MappingJackson2HttpMessageConverter(appMapper))
-                .build();
+        mockMvc = standaloneMvc(controller);
         // LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
         TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
                 LsyMerchantEntity.class);
     }
 
+    /**
+     * 单控制器 + <b>真</b> {@code GlobalExceptionHandler} + 应用真实的 ObjectMapper。
+     *
+     * <p>为什么真 handler:出口的**形态**(HTTP 码与业务码)是它决定的,mock 掉它等于把
+     * 「落库失败长什么样」这件事也 mock 掉 —— 见 {@code GlobalExceptionHandler} 的五个分支。
+     */
+    private static MockMvc standaloneMvc(LeshouyingOnboardingController c) {
+        return MockMvcBuilders.standaloneSetup(c)
+                .setControllerAdvice(new GlobalExceptionHandler())
+                .setMessageConverters(new MappingJackson2HttpMessageConverter(new JacksonConfig().objectMapper()))
+                .build();
+    }
+
     @AfterEach
     void clearContext() {
         SecurityContextHolder.clearContext();
@@ -177,6 +190,63 @@ class LeshouyingOnboardingControllerTest {
         verify(mapper, never()).insert(any());
     }
 
+    /**
+     * ★ 本地落库失败**不得**逃逸成 HTTP 500:那是一个「请重试」的语义,而上游那一次建号
+     * **已经发生** —— 重试一次就是再建一个上游商户、本地仍然没有行。
+     *
+     * <p>【为什么这条出口必须存在】上游调用无法纳入本地事务(见 {@code create} 的 javadoc),
+     * 所以「上游有号、本地没行」这个窗口**关不掉**;能决定的是它**长什么样**。
+     * 原先它落 {@code GlobalExceptionHandler} 的 catch-all(该类五个分支里没有
+     * {@code DuplicateKeyException} / {@code DataIntegrityViolationException})
+     * ⇒ HTTP 500「服务器内部错误」+ 零信息 ⇒ 调用方只能重试。
+     *
+     * <p>★ <b>四态一起钉</b>,前两个不是猜的:2026-09-23 探针在**真库**上(回滚事务、主键/长度冲突)
+     * 量出**同一条代码路径上有两个不同的真实类型** —— 唯一键冲突(SQLState 23505)⇒
+     * {@code DuplicateKeyException},值超长(22001)⇒ {@code DataIntegrityViolationException}
+     * ({@code merchant_name VARCHAR(128)},而请求体**没有**长度校验 ⇒ 超长那条今天就能到);
+     * 后两个挡的是「翻译器不在 ⇒ 抛未翻译的 {@code PersistenceException}」与
+     * 「既不属 MyBatis 也不属 Spring」两种形态。四者都不是 {@code BusinessException}
+     * ⇒ **没有护栏时四个都会红**;而 {@code catch (DuplicateKeyException)} 这类窄护栏会漏掉后三个。
+     *
+     * <p>夹具用 {@code enterprise_id = null}:那是「租户级映射」的主路径,也正是撞
+     * {@code uk_lsy_merchant_tenant_level} 的那一类输入。
+     */
+    @Test
+    void createEndpoint_localPersistFailure_saysUpstreamMerchantCreated_andForbidsRetry() throws Exception {
+        login(TENANT, false);
+
+        List<RuntimeException> shapes = List.of(
+                new DuplicateKeyException("uk_lsy_merchant_tenant_level"),
+                new DataIntegrityViolationException("value too long for type character varying(128)"),
+                new PersistenceException("### Error updating database. Cause: 唯一键冲突"),
+                new IllegalStateException("既不属 MyBatis 也不属 Spring 的形态"));
+
+        for (RuntimeException shape : shapes) {
+            LsyMerchantMapper mapper = mock(LsyMerchantMapper.class);
+            LsyOnboardingService onboarding = mock(LsyOnboardingService.class);
+            MockMvc mvc = standaloneMvc(
+                    new LeshouyingOnboardingController(new LsyMerchantService(mapper), onboarding));
+            when(onboarding.createMerchant(any())).thenReturn(MCH_NO);
+            when(mapper.insert(any())).thenThrow(shape);
+
+            // 走真 handler 才钉得住「出口长什么样」:没有护栏时这一枪是 **HTTP 500「服务器内部错误」**
+            // (GlobalExceptionHandler 的 catch-all),而不是本仓 BusinessException 的既有出口
+            // (HTTP 200 + 业务码)。assertAll 的标题带上异常形态,红的时候能直接看出是哪一种漏了。
+            assertAll(shape.getClass().getSimpleName(), () ->
+                    mvc.perform(post(PREFIX + "/create")
+                                    .contentType(MediaType.APPLICATION_JSON)
+                                    .content("{\"merchant_name\":\"示例商户\"}"))
+                            .andExpect(status().isOk())
+                            .andExpect(jsonPath("$.code").value(409))
+                            .andExpect(jsonPath("$.msg", containsString(MCH_NO)))
+                            .andExpect(jsonPath("$.msg", containsString("不得当作失败直接重试"))));
+
+            // 前提(也是这条出口存在的全部原因):上游那一次建号**已经**发生 ——
+            // 所以文案里的「别重试」不是提醒,是**唯一**正确的指令。
+            verify(onboarding).createMerchant(any());
+        }
+    }
+
     /**
      * ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
      *
@@ -210,6 +280,72 @@ class LeshouyingOnboardingControllerTest {
         verify(merchantService, times(1)).createOnboardingRow(any(), any(), any(), any());
     }
 
+    /**
+     * ★ 请求体里带了**非空**的费率模板 ⇒ **响亮拒绝**,且**不得触达上游**。
+     *
+     * <p>【为什么这是安全边界而不是参数校验】费率模板({@code LsyMerchantCreateDTO.rateCode})
+     * 按计划 §0.8 是**平台级配置**({@code leshouying.rate-code})。服务层组装出站报文时是
+     * 「DTO 有值以 DTO 为准,没有则用平台配置」—— 那个覆盖口是给**程序化调用方**设计的,
+     * 而本端点把同一份 DTO 整份绑到了**租户侧**的 JSON 请求体上 ⇒ 不封盖的话,
+     * 租户在报文里多带一个键就能自己指定自己的结算费率;且必填矩阵不管这一项
+     * ({@code LsyMerchantRequiredFields} 把它归在「配置」层)⇒ 没有任何既有校验会拦下它。
+     *
+     * <p>★ <b>反例先行</b>:被拒的那一刻一个服务都没被碰过 —— 于是
+     * {@code verifyNoInteractions} 是一条**无歧义**的零接触读数。
+     * 正对照放在后面:同形报文、去掉该键必须被受理(否则那个 400 可能只是因为整份报文
+     * 压根没绑上,而不是因为这条护栏)。
+     */
+    @Test
+    void createEndpoint_rejectsBodySuppliedRateCode_beforeTouchingUpstream() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        // 反例:费率模板由请求体给 ⇒ 400(业务码,HTTP 200 —— 本仓 BusinessException 的既有出口),
+        //       且此刻上游一次都没被调到。
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\","
+                                + "\"rate_code\":\"RT0001\"}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.code").value(400))
+                // 钉的是**口径**而不是措辞:对外文案用 LABELS 里登记的中文标签(费率模板),
+                // 不带上游键名 —— 同 LsyOnboardingService.labelsOf 的理由(Global Constraint 1)。
+                .andExpect(jsonPath("$.msg", containsString("费率模板")));
+        verifyNoInteractions(onboardingService, merchantService);
+
+        // 正对照:同形报文、去掉 rate_code ⇒ 受理
+        mockMvc.perform(post(PREFIX + "/create")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
+                .andExpect(jsonPath("$.code").value(0));
+        verify(onboardingService).createMerchant(any());
+    }
+
+    /**
+     * 口径:**空串 / 纯空白不拒**。
+     *
+     * <p>判据取服务层那条三元表达式的**补集**({@code StrUtil.isBlank}):服务层本来就把空白
+     * 当「没给」⇒ 回落平台配置,所以只有**非空白**值才可能真正覆盖平台配置,也才是攻击形态。
+     * 拒掉空白值不会更安全(它压根改不了费率),只会把一次本来正常的进件变成失败。
+     */
+    @Test
+    void createEndpoint_allowsBlankRateCode() throws Exception {
+        login(TENANT, false);
+        when(onboardingService.createMerchant(any())).thenReturn(MCH_NO);
+        when(merchantService.createOnboardingRow(any(), any(), any(), any())).thenReturn(row(MCH_NO));
+
+        for (String blank : List.of("", "   ")) {
+            mockMvc.perform(post(PREFIX + "/create")
+                            .contentType(MediaType.APPLICATION_JSON)
+                            .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\","
+                                    + "\"rate_code\":\"" + blank + "\"}"))
+                    .andExpect(jsonPath("$.code").value(0));
+        }
+
+        verify(onboardingService, times(2)).createMerchant(any());
+    }
+
     /**
      * 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
      *