|
|
@@ -12,6 +12,7 @@ import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.common.exception.GlobalExceptionHandler;
|
|
|
import com.payment.platform.common.response.PageResult;
|
|
|
import com.payment.platform.core.security.LoginUser;
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
|
|
|
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;
|
|
|
@@ -66,6 +67,7 @@ import java.util.Set;
|
|
|
import java.util.TreeSet;
|
|
|
|
|
|
import static org.hamcrest.Matchers.containsString;
|
|
|
+import static org.hamcrest.Matchers.not;
|
|
|
import static org.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
|
@@ -285,6 +287,87 @@ class LeshouyingOnboardingControllerTest {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * ★ <b>窗口的另一半边:上游调用**本身**失败时,同样不得逃逸成 catch-all 500。</b>
|
|
|
+ *
|
|
|
+ * <p>上面那条堵的是「上游已建号、本地落行失败」;本条堵的是它的反向半边 ——
|
|
|
+ * {@link LeshouyingApiException}(通信失败 / 上游 {@code return_code} 非成功 / 报文不可解析)
|
|
|
+ * 从 {@code createMerchant} 里抛出来的那一格。
|
|
|
+ *
|
|
|
+ * <p><b>【为什么这条出口必须存在】</b>上游调用在 {@code create} 方法体的中间那一行,
|
|
|
+ * 而下面那个 {@code try} 从**再下一行**才开始 ⇒ 它抛出的异常**不在**那条护栏里。
|
|
|
+ * 全仓唯一的 advice({@code GlobalExceptionHandler})五个分支里没有
|
|
|
+ * {@link LeshouyingApiException} ⇒ 它落兜底的 {@code Exception} 分支
|
|
|
+ * ⇒ <b>HTTP 500 + 业务码 500「服务器内部错误」</b>,零信息。而 500 的语义就是「重试」,
|
|
|
+ * 重试一次就是**再建一个上游商户**。
|
|
|
+ *
|
|
|
+ * <p>★★ <b>这一格比「上游已建号」那条更常发生、也更不可分辨。</b>超时 / 连接中断时我们
|
|
|
+ * **根本不知道**上游那次调用有没有落地(请求可能已经送达并建了号,只是应答没回来)。
|
|
|
+ * 所以文案不能断言「没建成功」,只能给**防重试**的指令 —— 方向是不对称的:
|
|
|
+ * 多谨慎一次只是让管理员多查一遍,乐观一次就是多一个上游商户。
|
|
|
+ *
|
|
|
+ * <p>★ <b>文案不得带上游痕迹</b>(Global Constraint 1):本例喂进去的那个异常的
|
|
|
+ * {@code getMessage()} 逐字是「乐收赢接口失败[HTTP_504]: gateway timeout」——
|
|
|
+ * 既有上游名号又有上游返回码 ⇒ 它只能进日志,不能进应答。下面两条 {@code not} 断言钉住这一点。
|
|
|
+ *
|
|
|
+ * <p>★ 读端点上的**正对照**见
|
|
|
+ * {@code areasEndpoint_upstreamCallFailure_saysRetryable_notTheWriteEndpointsAntiRetryCopy}:
|
|
|
+ * 同一个异常在 GET 腿上走的是另一句文案 —— 两处各自被钉住,才说明那条分支不是「怎么写都过」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void createEndpoint_upstreamCallFailure_isNotCatchAll500_andForbidsBlindRetry() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.createMerchant(any()))
|
|
|
+ .thenThrow(new LeshouyingApiException("HTTP_504", "gateway timeout", "{}"));
|
|
|
+
|
|
|
+ mockMvc.perform(post(PREFIX + "/create")
|
|
|
+ .contentType(MediaType.APPLICATION_JSON)
|
|
|
+ .content("{\"enterprise_id\":\"E1\",\"merchant_name\":\"示例商户\"}"))
|
|
|
+ // 出口形态:本仓业务失败的既有出口是 HTTP 200 + 业务码(同上面那条 409 的用例)。
|
|
|
+ // 掉回 catch-all 的话这两个断言会一起红(HTTP 500 / 业务码 500)。
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(502))
|
|
|
+ .andExpect(jsonPath("$.msg", containsString("不得当作失败直接重试")))
|
|
|
+ // Global Constraint 1:上游名号与上游返回码都不得出现在应答里。
|
|
|
+ .andExpect(jsonPath("$.msg", not(containsString("乐收赢"))))
|
|
|
+ .andExpect(jsonPath("$.msg", not(containsString("HTTP_504"))));
|
|
|
+
|
|
|
+ // 上游失败 ⇒ 本地一行都不写(与上面那条「不得留半个行」同一条理由)。
|
|
|
+ verify(merchantService, never()).createOnboardingRow(any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ <b>写腿那句「不得当作失败直接重试」不得被搬到读腿上</b> —— 同一个异常的 GET 正对照。
|
|
|
+ *
|
|
|
+ * <p>【为什么要分开】本控制器的六个端点里,五个都走上游(见类 javadoc 的端点清单:
|
|
|
+ * {@code /create} 建号、{@code /commit} 提交、{@code /upload-image} 上传、
|
|
|
+ * {@code /areas} 与 {@code /mcc} 取上游参考数据;只有 {@code /page} 只读本地库)。
|
|
|
+ * 一个 handler 若对它们回同一句话,那么在 {@code /areas} 上它说的就是**假话**:
|
|
|
+ * 一个下拉框没加载出来,「无法确认本次操作是否已经生效」无从谈起,
|
|
|
+ * 而「联系管理员核实」会把一次刷新页面的操作升级成一次人工工单。
|
|
|
+ *
|
|
|
+ * <p>【判据取 HTTP 语义,不取路径白名单】GET 被定义为 **safe**(重放不产生副作用)
|
|
|
+ * ⇒ 读端点重试是安全的,可以照 {@code LeshouyingOpenController} 的既有「可重试」口径回。
|
|
|
+ * 若改用「哪些路径是写」的名单去枚举,将来往本控制器加端点就会漏 —— 而漏的方向恰好是
|
|
|
+ * 「新写端点拿到可重试文案」,正是本条要防的那件事。
|
|
|
+ *
|
|
|
+ * <p>两条断言缺一不可:{@code containsString("请稍后重试")} 只说明「是读腿文案」,
|
|
|
+ * {@code not(containsString("不得当作失败直接重试"))} 才说明「没拿到写腿那句」。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void areasEndpoint_upstreamCallFailure_saysRetryable_notTheWriteEndpointsAntiRetryCopy() throws Exception {
|
|
|
+ login(TENANT, false);
|
|
|
+ when(onboardingService.areaList("2", "1"))
|
|
|
+ .thenThrow(new LeshouyingApiException("HTTP_504", "gateway timeout", "{}"));
|
|
|
+
|
|
|
+ mockMvc.perform(get(AREAS_PATH).param("level", "2").param("parentCode", "1"))
|
|
|
+ .andExpect(status().isOk())
|
|
|
+ .andExpect(jsonPath("$.code").value(502))
|
|
|
+ .andExpect(jsonPath("$.msg", containsString("请稍后重试")))
|
|
|
+ .andExpect(jsonPath("$.msg", not(containsString("不得当作失败直接重试"))))
|
|
|
+ .andExpect(jsonPath("$.msg", not(containsString("乐收赢"))));
|
|
|
+ }
|
|
|
+
|
|
|
/**
|
|
|
* ★ 请求体里塞 {@code tenant_id} ⇒ **响亮拒绝**,且一个服务都没碰。
|
|
|
*
|