|
@@ -0,0 +1,195 @@
|
|
|
|
|
+package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
+
|
|
|
|
|
+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.client.LeshouyingResponseParser;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.Arrays;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 上游**明确拒绝**下单时,本地那一行不留幽灵单、也不留白原因。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么要有这个类】下单是「先落库、再打上游」(顺序的理由见 {@code LsyOrderService}
|
|
|
|
|
+ * 三个 {@code create*} 方法的注释),于是上游那一跳一旦抛异常,本地就留下一行
|
|
|
|
|
+ * {@code CREATED}:在订单列表里像一笔在途单,要等绝对过期才被收敛,而
|
|
|
|
|
+ * {@code upstream_result_code/msg} 两格**皆空**。2026-09-30 生产上那笔顾客页失败单
|
|
|
|
|
+ * ({@code CS2105183092730679296})就是这样:上游的原话(「门店不存在或已被关闭!」)
|
|
|
|
|
+ * 只活在一条 WARN 里,页面上一个字都看不到,取证多绕了一圈。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【判据的核心是「上游到底答没答话」】只有它能区分「它明说没受理」与
|
|
|
|
|
+ * 「我们不知道它建没建出单」。后者(连接/超时/5xx/读不懂报文)落在本地行上写 FAIL
|
|
|
|
|
+ * 是**不可逆**的({@code PAY_WRITE_FROM} 只放行 CREATED/PAYING ⇒ 写进去就再改不了),
|
|
|
|
|
+ * 等于把一笔可能真实存在的交易判了死刑 ⇒ 那几种**一律不动**。本类的负向用例就是钉这条边界的。
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class LsyOrderUpstreamRejectTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private LeshouyingClient client;
|
|
|
|
|
+ @Mock private LsyOrderMapper orderMapper;
|
|
|
|
|
+ @Mock private LsyQueryService queryService;
|
|
|
|
|
+ private LsyOrderService orderService;
|
|
|
|
|
+ private LeshouyingProperties props;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ props = new LeshouyingProperties();
|
|
|
|
|
+ props.setDefaultQrExpireMinutes(15);
|
|
|
|
|
+ props.setSyncPollTimes(3);
|
|
|
|
|
+ props.setSyncPollIntervalMs(0); // 测试里不真的睡
|
|
|
|
|
+ orderService = new LsyOrderService(orderMapper, client,
|
|
|
|
|
+ new LeshouyingResponseParser(), props, queryService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LsyOrderEntity order(long id, String outTradeNo, String payScene) {
|
|
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
|
|
+ o.setId(id);
|
|
|
|
|
+ o.setOutTradeNo(outTradeNo);
|
|
|
|
|
+ o.setMchNo("852609290114");
|
|
|
|
|
+ o.setPayScene(payScene);
|
|
|
|
|
+ o.setPayTraceNo("PT" + id);
|
|
|
|
|
+ o.setPayTime("20260930142856");
|
|
|
|
|
+ o.setTotalAmount(100L);
|
|
|
|
|
+ o.setStatus("CREATED");
|
|
|
|
|
+ return o;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────── ① 上游明说拒绝:三条腿都收敛 ──────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void barcode_上游明确拒绝_收敛FAIL并落下原因() {
|
|
|
|
|
+ assertConvergesOnReject(
|
|
|
|
|
+ () -> orderService.createBarcodeOrder(order(1L, "OUT001", "BARCODE"), "134567890123456789", "000"),
|
|
|
|
|
+ "被扫 /v3/pay");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void qr_上游明确拒绝_收敛FAIL并落下原因() {
|
|
|
|
|
+ assertConvergesOnReject(
|
|
|
|
|
+ () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null),
|
|
|
|
|
+ "主扫 /v3/qrpay");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void h5_上游明确拒绝_收敛FAIL并落下原因() {
|
|
|
|
|
+ assertConvergesOnReject(
|
|
|
|
|
+ () -> orderService.createH5Order(order(3L, "OUT003", "H5"), "400"),
|
|
|
|
|
+ "H5 /v3/h5pay");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 共用断言:一条腿调下去,本地行必须被就地收敛,且**原因就是上游那句话**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>三条腿各跑一遍而不是只测一条:这三处 {@code catch} 是**三个独立的**代码点
|
|
|
|
|
+ * (各自一行 {@code closeIfUpstreamRejected}),漏掉任何一处都不会有别的用例变红。
|
|
|
|
|
+ */
|
|
|
|
|
+ private void assertConvergesOnReject(Runnable leg, String legName) {
|
|
|
|
|
+ // 上游原话取自 2026-09-30 生产事故的日志:门店号被填成中文商户名,上游就是这么回的。
|
|
|
|
|
+ // 逐字照抄,好让这条用例与那次事故对得上。
|
|
|
|
|
+ LeshouyingApiException upstream = new LeshouyingApiException(
|
|
|
|
|
+ "FAIL", "门店不存在或已被关闭!", "{\"return_code\":\"FAIL\"}");
|
|
|
|
|
+ when(client.post(anyString(), anyMap())).thenThrow(upstream);
|
|
|
|
|
+ when(orderMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ LeshouyingApiException thrown = assertThrows(LeshouyingApiException.class, leg::run,
|
|
|
|
|
+ legName + ":上游拒绝的异常必须原样上抛(调用方靠它回错误给顾客)");
|
|
|
|
|
+ assertSame(upstream, thrown, legName + ":上抛的必须是同一个异常对象,不得被吞掉或包装");
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<LsyOrderEntity> patch = ArgumentCaptor.forClass(LsyOrderEntity.class);
|
|
|
|
|
+ verify(orderMapper).update(patch.capture(), any());
|
|
|
|
|
+ assertEquals("FAIL", patch.getValue().getStatus(),
|
|
|
|
|
+ legName + ":上游明确拒绝 ⇒ 本地行收敛为 FAIL,不能再挂着 CREATED 冒充在途单");
|
|
|
|
|
+ assertEquals("FAIL", patch.getValue().getUpstreamResultCode(),
|
|
|
|
|
+ legName + ":失败原因码要落库(页面上要看得到)");
|
|
|
|
|
+ assertEquals("门店不存在或已被关闭!", patch.getValue().getUpstreamResultMsg(),
|
|
|
|
|
+ legName + ":上游的原话要落库 —— 这一格空白正是 2026-09-30 那次多绕一圈的原因");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────── ② 不是「上游答了话」:一个字节都不许落 ──────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 传输层的四种码 + null:**不知道上游建没建出单**,一律维持改动前的行为(留在 CREATED)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这一条是本改动的**边界**,比上面的正向断言更要紧:写 FAIL 是不可逆的。
|
|
|
|
|
+ * 四种码逐一跑,是因为它们**四条不同的构造路径**({@code LeshouyingClient} 的四个 catch
|
|
|
|
|
+ * 分支各造一个),漏掉任何一个都不会有别的用例变红。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 传输层失败_不动本地行() {
|
|
|
|
|
+ List<String> transportCodes = Arrays.asList("NETWORK_ERROR", "PARSE_ERROR", "EMPTY", "HTTP_500", null);
|
|
|
|
|
+ for (String code : transportCodes) {
|
|
|
|
|
+ LeshouyingApiException e = new LeshouyingApiException(code, "连接超时", null);
|
|
|
|
|
+ when(client.post(eq("/v3/qrpay"), anyMap())).thenThrow(e);
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(LeshouyingApiException.class,
|
|
|
|
|
+ () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null),
|
|
|
|
|
+ "code=" + code + ":异常仍要上抛");
|
|
|
|
|
+
|
|
|
|
|
+ // 正对照:这一轮确实走到了那次调用(否则下面的 never 可能只是因为压根没调)。
|
|
|
|
|
+ verify(client).post(eq("/v3/qrpay"), anyMap());
|
|
|
|
|
+ reset(client);
|
|
|
|
|
+ }
|
|
|
|
|
+ verify(orderMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── ③ 列宽:超长原文要截断 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 上游原文超长时按列宽截断({@code upstream_result_code VARCHAR(32)} /
|
|
|
|
|
+ * {@code upstream_result_msg VARCHAR(255)},V1.8 DDL)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>不截断的后果不是「少存几个字」,而是那条 UPDATE 自己报错 ——
|
|
|
|
|
+ * 「上游拒绝了下单」被换成一条数据库错误,页面上看到的又是另一回事。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 上游原文超长_按列宽截断而不是让UPDATE报错() {
|
|
|
|
|
+ String longMsg = "x".repeat(300);
|
|
|
|
|
+ String longCode = "C".repeat(40);
|
|
|
|
|
+ when(client.post(anyString(), anyMap()))
|
|
|
|
|
+ .thenThrow(new LeshouyingApiException(longCode, longMsg, null));
|
|
|
|
|
+ when(orderMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(LeshouyingApiException.class,
|
|
|
|
|
+ () -> orderService.createQrOrder(order(2L, "OUT002", "QRCODE"), null));
|
|
|
|
|
+
|
|
|
|
|
+ ArgumentCaptor<LsyOrderEntity> patch = ArgumentCaptor.forClass(LsyOrderEntity.class);
|
|
|
|
|
+ verify(orderMapper).update(patch.capture(), any());
|
|
|
|
|
+ assertEquals(32, patch.getValue().getUpstreamResultCode().length(), "code 列宽 32");
|
|
|
|
|
+ assertEquals(255, patch.getValue().getUpstreamResultMsg().length(), "msg 列宽 255");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── ④ 没被写进去的那条路:条件更新未命中 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 条件更新命中 0 行(回调已先把这单推走了)时**不得**再动内存里的状态。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这条守的是 {@code applyPayResult} 的返回值语义:{@code rows == 0} = 没真流转,
|
|
|
|
|
+ * 调用方(本改动)只是记一条 WARN,不能因为「我调了收敛」就把内存对象也改成 FAIL ——
|
|
|
|
|
+ * 那会让调用方在上抛前看到两个不一致的状态。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void 条件更新未命中_只记日志不改内存状态() {
|
|
|
|
|
+ when(client.post(anyString(), anyMap()))
|
|
|
|
|
+ .thenThrow(new LeshouyingApiException("FAIL", "门店不存在或已被关闭!", null));
|
|
|
|
|
+ when(orderMapper.update(any(), any())).thenReturn(0);
|
|
|
|
|
+
|
|
|
|
|
+ LsyOrderEntity o = order(2L, "OUT002", "QRCODE");
|
|
|
|
|
+ assertThrows(LeshouyingApiException.class, () -> orderService.createQrOrder(o, null));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("CREATED", o.getStatus(),
|
|
|
|
|
+ "库里没流转成功时,内存对象也必须留在 CREATED(两个答案不能打架)");
|
|
|
|
|
+ }
|
|
|
|
|
+}
|