|
@@ -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.LsyMerchantService;
|
|
|
import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
|
|
import com.payment.platform.module.payment.leshouying.service.LsyOnboardingService;
|
|
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
|
|
+import org.apache.ibatis.exceptions.PersistenceException;
|
|
|
import org.junit.jupiter.api.AfterEach;
|
|
import org.junit.jupiter.api.AfterEach;
|
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
import org.junit.jupiter.api.BeforeEach;
|
|
|
import org.junit.jupiter.api.Test;
|
|
import org.junit.jupiter.api.Test;
|
|
@@ -26,6 +27,8 @@ import org.mockito.InOrder;
|
|
|
import org.mockito.Mock;
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
import org.springframework.core.annotation.AnnotatedElementUtils;
|
|
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.MediaType;
|
|
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
import org.springframework.security.access.prepost.PreAuthorize;
|
|
@@ -46,6 +49,7 @@ import java.util.Map;
|
|
|
import java.util.Set;
|
|
import java.util.Set;
|
|
|
import java.util.TreeSet;
|
|
import java.util.TreeSet;
|
|
|
|
|
|
|
|
|
|
+import static org.hamcrest.Matchers.containsString;
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
@@ -93,16 +97,25 @@ class LeshouyingOnboardingControllerTest {
|
|
|
@BeforeEach
|
|
@BeforeEach
|
|
|
void setUp() {
|
|
void setUp() {
|
|
|
controller = new LeshouyingOnboardingController(merchantService, onboardingService);
|
|
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。
|
|
// LambdaQueryWrapper 的元数据缓存(无 Spring 上下文时必需),同 LsyMerchantServiceTest。
|
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
|
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""),
|
|
|
LsyMerchantEntity.class);
|
|
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
|
|
@AfterEach
|
|
|
void clearContext() {
|
|
void clearContext() {
|
|
|
SecurityContextHolder.clearContext();
|
|
SecurityContextHolder.clearContext();
|
|
@@ -177,6 +190,63 @@ class LeshouyingOnboardingControllerTest {
|
|
|
verify(mapper, never()).insert(any());
|
|
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} ⇒ **响亮拒绝**,且一个服务都没碰。
|
|
* ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
|
|
|
*
|
|
*
|
|
@@ -210,6 +280,72 @@ class LeshouyingOnboardingControllerTest {
|
|
|
verify(merchantService, times(1)).createOnboardingRow(any(), any(), any(), any());
|
|
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 继续往下走。
|
|
* 认证上下文里拿不到租户 ⇒ **显式拒绝**(403),不得带着 null 继续往下走。
|
|
|
*
|
|
*
|