|
@@ -0,0 +1,683 @@
|
|
|
|
|
+package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
+
|
|
|
|
|
+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.entity.LsyOrderEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
|
|
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
|
|
+import org.mockito.Mock;
|
|
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
+
|
|
|
|
|
+import java.util.LinkedHashMap;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Map;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class LsyRefundServiceTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private LeshouyingClient client;
|
|
|
|
|
+ @Mock private LsyRefundMapper refundMapper;
|
|
|
|
|
+ @Mock private LsyOrderMapper orderMapper;
|
|
|
|
|
+ @Mock private LsyOrderService orderService;
|
|
|
|
|
+ @Mock private LsyCallbackService callbackService;
|
|
|
|
|
+ private LsyRefundService service;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ service = new LsyRefundService(client, new LeshouyingResponseParser(), refundMapper,
|
|
|
|
|
+ orderMapper, orderService, callbackService);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LsyOrderEntity order() {
|
|
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
|
|
+ o.setId(1L);
|
|
|
|
|
+ o.setOutTradeNo("OUT001");
|
|
|
|
|
+ o.setMchNo("Y52211090004");
|
|
|
|
|
+ o.setPayTraceNo("PT001");
|
|
|
|
|
+ o.setPayTime("20260921120000");
|
|
|
|
|
+ o.setTradeNo("T001");
|
|
|
|
|
+ o.setTotalAmount(100L);
|
|
|
|
|
+ o.setRefundedAmount(0L);
|
|
|
|
|
+ o.setStatus("SUCCESS");
|
|
|
|
|
+ return o;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> resp(String rc) {
|
|
|
|
|
+ Map<String, Object> m = new LinkedHashMap<>();
|
|
|
|
|
+ m.put("return_code", "SUCCESS");
|
|
|
|
|
+ m.put("result_code", rc);
|
|
|
|
|
+ m.put("refund_no", "R001");
|
|
|
|
|
+ m.put("refund_settle", "100");
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsAmountExceedingRemaining() {
|
|
|
|
|
+ // ★ 夹具随 R12 调整:「已退」的取值源已从 order 快照改为**退款表**。
|
|
|
|
|
+ // 旧夹具只把 80 设进 order.setRefundedAmount(...),新实现根本不读那个字段,
|
|
|
|
|
+ // 于是 selectList 返回空 → settled=0 → 30 会被放行,用例必红。
|
|
|
|
|
+ // 现在把 80 放进退款表的 SUCCESS 行,而**快照刻意留 0**
|
|
|
|
|
+ // —— 这样这条用例顺带证明「余额取自退款表、不取自快照」。
|
|
|
|
|
+ LsyOrderEntity o = order();
|
|
|
|
|
+ o.setRefundedAmount(0L); // 快照说一分没退(陈旧/误导)
|
|
|
|
|
+ LsyRefundEntity prior = new LsyRefundEntity();
|
|
|
|
|
+ prior.setId(9L);
|
|
|
|
|
+ prior.setStatus("SUCCESS");
|
|
|
|
|
+ prior.setRefundFee(80L); // 权威账说已退 80 → 剩余 20
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(prior));
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.createRefund(o, "RF001", 30L));
|
|
|
|
|
+ verify(client, never()).post(anyString(), anyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsWhenLedgerAlreadyRefunded_evenIfOrderSnapshotStale() {
|
|
|
|
|
+ // ★ R12 的核心回归:快照陈旧 + 在途闸门放行 ⇒ 同一张单退两次。
|
|
|
|
|
+ // 快照说"一分没退"(可退 10000),但退款表里已有一笔退满的 SUCCESS 行。
|
|
|
|
|
+ // 那笔已 SUCCESS、不在途,所以在途闸门会放行;旧实现信快照 → 余额校验也放行
|
|
|
|
|
+ // → 一路走到 /v3/refundorder,钱真扣两次。
|
|
|
|
|
+ LsyOrderEntity o = order();
|
|
|
|
|
+ o.setTotalAmount(10000L);
|
|
|
|
|
+ o.setRefundedAmount(0L); // ★ 陈旧快照:看起来可退 10000
|
|
|
|
|
+ o.setStatus("SUCCESS");
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity alreadyRefunded = new LsyRefundEntity();
|
|
|
|
|
+ alreadyRefunded.setId(9L);
|
|
|
|
|
+ alreadyRefunded.setStatus("SUCCESS");
|
|
|
|
|
+ alreadyRefunded.setRefundFee(10000L);
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(alreadyRefunded));
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.createRefund(o, "RF002", 10000L));
|
|
|
|
|
+ // 非空转的关键:旧实现在这个夹具下会**走到上游调用**,新实现必须在上游之前拒掉。
|
|
|
|
|
+ verify(client, never()).post(anyString(), anyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsNonPositiveAmount() {
|
|
|
|
|
+ assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.createRefund(order(), "RF001", 0L));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsOrderNotInSuccessState() {
|
|
|
|
|
+ LsyOrderEntity o = order();
|
|
|
|
|
+ o.setStatus("PAYING");
|
|
|
|
|
+ assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.createRefund(o, "RF001", 10L));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_mapsTimeFieldsCorrectly_notConfused() {
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
|
|
|
|
|
+ verify(client).post(eq("/v3/refundorder"), captor.capture());
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Map<String, Object> sent = captor.getValue();
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("100", sent.get("refund_fee"));
|
|
|
|
|
+ // 上游 pay_time = 退款请求时间(本次),pay_trace_time = 原支付时间
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ 形状断言(与 LsyCancelServiceTest 的房规一致):只断言非 null 挡不住
|
|
|
|
|
+ // "哪天被写成 LocalDateTime.toString()" —— 那会得到 2026-09-21T10:00 这种非 null
|
|
|
|
|
+ // 但不是 14 位的值,测试照样绿,而失败会推迟到上游回「参数不合法」,根因不可见。
|
|
|
|
|
+ Object payTime = sent.get("pay_time");
|
|
|
|
|
+ assertNotNull(payTime);
|
|
|
|
|
+ assertTrue(payTime instanceof String && ((String) payTime).matches("\\d{14}"),
|
|
|
|
|
+ "pay_time 必须是 14 位 yyyyMMddHHmmss 形状(退款请求时间),实际=" + payTime);
|
|
|
|
|
+
|
|
|
|
|
+ Object payTraceTime = sent.get("pay_trace_time");
|
|
|
|
|
+ assertEquals("20260921120000", payTraceTime,
|
|
|
|
|
+ "pay_trace_time 必须传原支付时间,传反会导致上游找不到原单");
|
|
|
|
|
+ assertTrue(payTraceTime instanceof String && ((String) payTraceTime).matches("\\d{14}"),
|
|
|
|
|
+ "pay_trace_time 必须是 14 位 yyyyMMddHHmmss 形状,实际=" + payTraceTime);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_success_statusIsSuccess() {
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("SUCCESS", r.getStatus());
|
|
|
|
|
+ assertEquals("R001", r.getRefundNo());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_insertsAsRefundingFirst_neverDirectlyTerminal() {
|
|
|
|
|
+ // 必须先进 REFUNDING 再收敛:insert 成 SUCCESS 会让本行落在
|
|
|
|
|
+ // uk_lsy_refund_one_pending 的谓词之外,并发第二笔退款撞不上约束。
|
|
|
|
|
+ // 这个断言就是在钉住"互斥窗口必须真的存在"。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ 必须用 doAnswer 在**调用那一刻**断言,不能用 ArgumentCaptor。
|
|
|
|
|
+ // captor 存的是**引用**,而 createRefund 返回前会在同一个实例上把状态翻成 SUCCESS
|
|
|
|
|
+ // (applyNotifyResult 是退款行唯一的状态写入口,同步路径也照样走它),
|
|
|
|
|
+ // 于是 captor.getValue().getStatus() 在返回之后求值永远得到 SUCCESS ——
|
|
|
|
|
+ // 那条断言恒假,与实现有没有先落 REFUNDING 无关。
|
|
|
|
|
+ // 这里观测的是 INSERT 那一刻的实参状态,才是"先落非终态"的可验证含义。
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ doAnswer(inv -> {
|
|
|
|
|
+ assertEquals("REFUNDING", ((LsyRefundEntity) inv.getArgument(0)).getStatus(),
|
|
|
|
|
+ "INSERT 那一刻必须是非终态,不能直接落终态");
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }).when(refundMapper).insert(any());
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_syncSuccess_settlesOrderSideImmediately() {
|
|
|
|
|
+ // ★ 这条是防"钱退了两遍"的核心回归。
|
|
|
|
|
+ // 同步成功若不在本地就累加 refunded_amount,回调到达时本行已是 SUCCESS、
|
|
|
|
|
+ // 不在 PAY_WRITE_FROM 里 → applyNotifyResult 命中 0 行 → handleRefundNotify
|
|
|
|
|
+ // 判为重复回调 → refunded_amount 永不累加 → remaining 永远虚高 → 可以再退一次。
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ verify(orderService).applyRefundResult(any(), eq(100L), eq("REFUNDED"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsWhenAnotherRefundIsPending() {
|
|
|
|
|
+ // 在途退款挡住新退款:refunded_amount 只在退款成功后才累加,
|
|
|
|
|
+ // 所以在途期间 remaining 是虚高的,金额校验挡不住重复退款。
|
|
|
|
|
+ LsyRefundEntity inFlight = new LsyRefundEntity();
|
|
|
|
|
+ inFlight.setId(9L);
|
|
|
|
|
+ inFlight.setStatus("REFUNDING");
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(inFlight));
|
|
|
|
|
+
|
|
|
|
|
+ IllegalStateException ex = assertThrows(IllegalStateException.class,
|
|
|
|
|
+ () -> service.createRefund(order(), "RF002", 100L));
|
|
|
|
|
+ assertTrue(ex.getMessage().contains("正在处理中"));
|
|
|
|
|
+ verify(client, never()).post(anyString(), anyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_pendingGuard_passesWhenPreviousRefundIsTerminal() {
|
|
|
|
|
+ // 对照组:上一笔已终态(SUCCESS/FAIL)不得挡路,否则退款只能退一次。
|
|
|
|
|
+ LsyRefundEntity settled = new LsyRefundEntity();
|
|
|
|
|
+ settled.setId(9L);
|
|
|
|
|
+ settled.setStatus("SUCCESS");
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(settled));
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity r = service.createRefund(order(), "RF002", 30L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("REFUNDING", r.getStatus());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_payInProcess_statusIsRefunding() {
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("REFUNDING", r.getStatus());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_payFail_leavesStatusRefundingAndNeverSettlesOrder() {
|
|
|
|
|
+ // ★ R11:PAY_FAIL 是已知码,必须留信号,但**绝不能**落终态 FAIL。
|
|
|
|
|
+ // 落 FAIL 会释放 uk_lsy_refund_one_pending 的互斥窗口与在途闸门:
|
|
|
|
|
+ // 若上游这个 PAY_FAIL 是偶发误报(钱其实出去了),第二笔退款就真放行了。
|
|
|
|
|
+ // 停在 REFUNDING + 记日志让人看见,是唯一方向安全的选项。
|
|
|
|
|
+ Map<String, Object> fail = new LinkedHashMap<>();
|
|
|
|
|
+ fail.put("return_code", "SUCCESS");
|
|
|
|
|
+ fail.put("result_code", "PAY_FAIL");
|
|
|
|
|
+ fail.put("return_msg", "退款失败");
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(fail);
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("REFUNDING", r.getStatus());
|
|
|
|
|
+ assertNotEquals("FAIL", r.getStatus(), "PAY_FAIL 不得被落成终态 FAIL(会释放互斥窗口)");
|
|
|
|
|
+ // 下面两条是让本用例**非空转**的关键:只断言 status 的话,
|
|
|
|
|
+ // 一个「把 FAIL 写成 REFUNDING」的实现也会绿。
|
|
|
|
|
+ verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
|
|
|
|
|
+ verify(refundMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_unknownResultCode_leavesStatusRefundingAndNeverSettlesOrder() {
|
|
|
|
|
+ // ★ R21:R11 那条契约的另一半。`LeshouyingResponseParser.isPayFail` 的 javadoc 要求
|
|
|
|
|
+ // 「三个谓词全为 false 时,调用方必须走未知码分支:记日志 + 待人工核对,不得静默归类」。
|
|
|
|
|
+ // R11 只堵了 PAY_FAIL,三个谓词全 false 的路径此前一个字节都不打。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ 夹具刻意用通用成功码 "SUCCESS" —— 正是 `LeshouyingResponseParser` 那句 javadoc
|
|
|
|
|
+ // 点名的场景(「某端点回的是通用成功码 "SUCCESS" 而非 "PAY_SUCCESS"」)。
|
|
|
|
|
+ // 本用例**非空转是结构保证的**:isPaySuccess / isPayFail / isPayInProcess 比的都是
|
|
|
|
|
+ // PAY_* 字面量,对 "SUCCESS" 全为 false ⇒ 必然走进新分支,
|
|
|
|
|
+ // 不存在"被前面某个分支吃掉所以其实没走到"的可能。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ 已知待确认项(不是遗漏):若联调发现 /v3/refundorder 确实回裸 "SUCCESS" 而非 "PAY_SUCCESS",
|
|
|
|
|
+ // 那么新分支与这条用例**必须一起改**,否则每一笔正常退款都会被记成"未知码"。
|
|
|
|
|
+ Map<String, Object> unknown = new LinkedHashMap<>();
|
|
|
|
|
+ unknown.put("return_code", "SUCCESS");
|
|
|
|
|
+ unknown.put("result_code", "SUCCESS"); // 通用成功码,不是 PAY_SUCCESS
|
|
|
|
|
+ unknown.put("return_msg", "成功");
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(unknown);
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity r = service.createRefund(order(), "RF001", 100L);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("REFUNDING", r.getStatus());
|
|
|
|
|
+ // 与 R11 那条同形:只断言 status 的话,一个「把未知码落成终态」的实现也会绿。
|
|
|
|
|
+ verify(orderService, never()).applyRefundResult(any(), anyLong(), any());
|
|
|
|
|
+ verify(refundMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_persistsEntityWithOutRefundNo() {
|
|
|
|
|
+ when(client.post(eq("/v3/refundorder"), anyMap())).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderService.applyRefundResult(any(), anyLong(), any())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ service.createRefund(order(), "RF001", 50L);
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
|
|
+ verify(refundMapper).insert(captor.capture());
|
|
|
|
|
+ LsyRefundEntity saved = captor.getValue();
|
|
|
|
|
+ assertEquals("RF001", saved.getOutRefundNo());
|
|
|
|
|
+ assertEquals("OUT001", saved.getOutTradeNo());
|
|
|
|
|
+ assertEquals(50L, saved.getRefundFee());
|
|
|
|
|
+ assertNotNull(saved.getRefundTraceNo());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- queryRefund:只有开放 API 的退款查询端点调它(不是死代码) ----
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void queryRefund_withoutRefundNo_sendsRefundTraceNoAndRequestTime() {
|
|
|
|
|
+ // queryRefund 踩的是与 createRefund 同源的那个字段陷阱:
|
|
|
|
|
+ // 上游退款查询接口的 refund_time 传的是**退款请求时间**(refundRequestTime),
|
|
|
|
|
+ // 传成原支付时间上游就找不到这笔退款。
|
|
|
|
|
+ // ★ 调用者有两个:开放 API 的退款查询端点
|
|
|
|
|
+ // (LeshouyingOpenController.refundQuery,@PostMapping("/refund/query")),
|
|
|
|
|
+ // 以及 **LsyReconcileScheduler.reconcilePendingRefunds**(退款对账腿,2026-09-22 补)。
|
|
|
|
|
+ // ⚠ 后者的存在使本方法的**入参正确性**变成真钱路径上的事:它的 mch_no 之前一个字节
|
|
|
|
|
+ // 都没送,而这条腿现在真的会被调度器调用(见下面 mch_no 那段)。
|
|
|
|
|
+ // (旧注释写「没有任何定时任务调它」,那是补腿之前的事实。)
|
|
|
|
|
+ //
|
|
|
|
|
+ // 本条只覆盖「没有上游退款单号」那一支:有 refund_no 时走 refund_no,
|
|
|
|
|
+ // 二选一互斥(两组同时上送时上游取哪一组不可预期)。
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(1L); // ★ mch_no 由订单反查
|
|
|
|
|
+ refund.setRefundNo(null); // ★ 走 trace_no 分支
|
|
|
|
|
+ refund.setRefundTraceNo("RT001");
|
|
|
|
|
+ refund.setRefundRequestTime("20260921120500");
|
|
|
|
|
+ when(orderMapper.selectById(1L)).thenReturn(order());
|
|
|
|
|
+
|
|
|
|
|
+ when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+
|
|
|
|
|
+ service.queryRefund(refund);
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
|
|
|
|
|
+ verify(client).post(eq("/v3/queryrefund"), captor.capture());
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Map<String, Object> sent = captor.getValue();
|
|
|
|
|
+
|
|
|
|
|
+ // keySet 恰好相等(房规:不用 containsKey —— 那种形状对「值写错了」「多塞了一个键」零保护)。
|
|
|
|
|
+ // 四个键 = 无条件写入的 mch_no / query_trace_no + 无 refund_no 分支写入的
|
|
|
|
|
+ // refund_trace_no / refund_time。refund_no **不得**出现(本条正是它的反面)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ mch_no 是 2026-09-22 补的:上游文档 §15 把 refund 查询的 mch_no 标为**必填**
|
|
|
|
|
+ // (`D:\project2\leshouying-api-docs.md` :596),而本方法此前一个字节都没送。
|
|
|
|
|
+ // 判据不是「文档这么写」,是**结构孪生方法**:`LsyQueryService.queryOrder`
|
|
|
|
|
+ // 形状完全平行(同样的 inst_no/mch_no/query_trace_no/二选一定位键)、它送了 mch_no
|
|
|
|
|
+ // 且联调实测查单走通;同类的 `createRefund` 也送。缺它的只有本方法一处。
|
|
|
|
|
+ assertEquals(Set.of("mch_no", "query_trace_no", "refund_trace_no", "refund_time"),
|
|
|
|
|
+ sent.keySet(), "queryrefund 入参集合必须恰好是这四个");
|
|
|
|
|
+ // keySet 相等**不能**证明值非 null(`LeshouyingClient.post` 的 null 过滤作用在副本上,
|
|
|
|
|
+ // 而且一个值为 null 的键照样在 keySet 里),所以下面这条是真覆盖,不是重复。
|
|
|
|
|
+ assertNotNull(sent.get("query_trace_no"), "query_trace_no 必填,且必须真的填了值");
|
|
|
|
|
+ assertEquals("Y52211090004", sent.get("mch_no"), "mch_no 必填,必须取自订单而不是留空");
|
|
|
|
|
+ assertEquals("RT001", sent.get("refund_trace_no"));
|
|
|
|
|
+ // 值断言用字面量而非 refund.getRefundRequestTime():让期望值独立于 fixture 的取值 ——
|
|
|
|
|
+ // 将来有人改 refund.setRefundRequestTime(...) 时,期望值不会跟着静默漂移。
|
|
|
|
|
+ Object refundTime = sent.get("refund_time");
|
|
|
|
|
+ assertEquals("20260921120500", refundTime);
|
|
|
|
|
+ assertTrue(refundTime instanceof String && ((String) refundTime).matches("\\d{14}"),
|
|
|
|
|
+ "refund_time 必须是 14 位 yyyyMMddHHmmss 形状(退款请求时间),实际=" + refundTime);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void queryRefund_withRefundNo_sendsRefundNoOnly() {
|
|
|
|
|
+ // 与上一条互为镜像:手上有上游退款单号时走 refund_no 一支。
|
|
|
|
|
+ // 两支都要覆盖 —— 走哪一支取决于库里那一行有没有 refund_no,不是「哪一支更常用」。
|
|
|
|
|
+ //
|
|
|
|
|
+ // ★ 三样都设上(refundNo / refundTraceNo / refundRequestTime),走错分支才可被观测到:
|
|
|
|
|
+ // 只设 refundNo 的话,实现就算误走 trace_no 分支,入参里也会多出 refund_trace_no 与
|
|
|
|
|
+ // refund_time,「keySet 恰好相等」这条断言才拦得住 —— 那是本用例真正的判据。
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(1L);
|
|
|
|
|
+ refund.setRefundNo("RF999");
|
|
|
|
|
+ refund.setRefundTraceNo("RT001"); // ★ 故意设上:走错分支就会多出来
|
|
|
|
|
+ refund.setRefundRequestTime("20260921120500"); // ★ 同上
|
|
|
|
|
+ when(orderMapper.selectById(1L)).thenReturn(order());
|
|
|
|
|
+
|
|
|
|
|
+ when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+
|
|
|
|
|
+ service.queryRefund(refund);
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
|
|
|
|
|
+ verify(client).post(eq("/v3/queryrefund"), captor.capture());
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Map<String, Object> sent = captor.getValue();
|
|
|
|
|
+
|
|
|
|
|
+ // 恰好相等:有 refund_no 时**不得**再上送 refund_trace_no / refund_time ——
|
|
|
|
|
+ // 两组同时上送时上游取哪一组不可预期(二选一互斥)。
|
|
|
|
|
+ assertEquals(Set.of("mch_no", "query_trace_no", "refund_no"), sent.keySet(),
|
|
|
|
|
+ "queryrefund 有 refund_no 时入参集合必须恰好是这三个");
|
|
|
|
|
+ assertEquals("RF999", sent.get("refund_no"));
|
|
|
|
|
+ // keySet 相等不能证明值非 null(值为 null 的键照样在 keySet 里),故这条是真覆盖。
|
|
|
|
|
+ assertNotNull(sent.get("query_trace_no"), "query_trace_no 必填,且必须真的填了值");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 订单查不到时**不送** `mch_no` —— 退化为改动前的行为,而不是送一个 null 键。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么单独立一条】`LeshouyingClient.post` 会过滤 null 值,所以「送 null」与「不送」
|
|
|
|
|
+ * 在上游看到的是同一件事;但只有后者能被 keySet 断言区分。若实现写成
|
|
|
|
|
+ * `p.put("mch_no", null)`,上面两条「keySet 恰好相等」的用例**照样全绿**,
|
|
|
|
|
+ * 于是「反查失败时怎么办」这条路径永远不被覆盖。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void queryRefund_订单查不到_不送mchNo() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(404L);
|
|
|
|
|
+ refund.setRefundNo("RF999");
|
|
|
|
|
+ when(orderMapper.selectById(404L)).thenReturn(null);
|
|
|
|
|
+ when(client.post(eq("/v3/queryrefund"), anyMap())).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+
|
|
|
|
|
+ service.queryRefund(refund);
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(Map.class);
|
|
|
|
|
+ verify(client).post(eq("/v3/queryrefund"), captor.capture());
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ Map<String, Object> sent = captor.getValue();
|
|
|
|
|
+ assertFalse(sent.containsKey("mch_no"),
|
|
|
|
|
+ "查不到订单时不得送 mch_no(送 null 键会让上两条 keySet 用例失去覆盖),实测="
|
|
|
|
|
+ + sent.keySet());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- settleFromQuery:退款对账腿的解释点(Job 2 第五条腿,2026-09-22) ----
|
|
|
|
|
+ //
|
|
|
|
|
+ // 【为什么它必须存在】设计 §6.6 写了「PAY_IN_PROCESS → REFUNDING,由 Job 2 查
|
|
|
|
|
+ // v3/queryrefund 推进」,实现时掉了:LsyReconcileScheduler 四条腿全是订单
|
|
|
|
|
+ // ⇒ 停在 REFUNDING 的行永久卡住(台账 §2.3)。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 【为什么解释放在 Service 而不是调度器】调度器 javadoc 明令
|
|
|
|
|
+ // 「本调度器自己不解析任何上游报文」—— 与 LsyOrderService.settleFromQuery 同一条纪律。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 【上游词表依据】`D:\project2\leshouying-api-docs.md` §15 :603 —— 退款**查询**回的是
|
|
|
|
|
+ // PAY_SUCCESS / PAY_IN_PROCESS / PAY_FAIL,而退款**回调**回的是 TRADE_REFUND /
|
|
|
|
|
+ // TRADE_REFUND_FAIL(§8 :441)。两套词表,所以解析器复用支付侧那三个谓词是对的。
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 正控:查询确认退款成功 ⇒ 落 SUCCESS + 收敛订单侧 + 推订单回调。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么正控不可省】Mockito 对未 stub 的 `update` 默认返回 **0**,
|
|
|
|
|
+ * 没有这一条,一个「永远不写、永远不收敛」的坏实现能让所有负路径用例全绿。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void settleFromQuery_退款成功_落SUCCESS并收敛订单侧并推订单回调() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(1L);
|
|
|
|
|
+ LsyOrderEntity o = order();
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ when(orderMapper.selectById(1L)).thenReturn(o);
|
|
|
|
|
+ when(orderService.applyRefundResult(eq(o), eq(30L), anyString())).thenReturn(true);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("SUCCESS", service.settleFromQuery(refund, resp("PAY_SUCCESS")));
|
|
|
|
|
+ assertEquals("SUCCESS", refund.getStatus());
|
|
|
|
|
+ verify(callbackService).notifyOrder(o);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * `PAY_FAIL` ⇒ 落 FAIL 终态。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【它与 `createRefund` 同步分支守闸**不矛盾**】同步分支手上没有权威答案,
|
|
|
|
|
+ * 所以停住;对账腿拿到的是上游对**这笔退款**的直接回答,而上游文档 :485 明说
|
|
|
|
|
+ * 「`PAY_IN_PROCESS` 需调用**退款查询接口**确认退款结果」—— 查询就是权威确认通道。
|
|
|
|
|
+ * 另外:若这里也不落终态,这条腿就只收敛成功、收敛不了失败,等于没解决 §2.3。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void settleFromQuery_退款失败_落FAIL() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(1L);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals("FAIL", service.settleFromQuery(refund, resp("PAY_FAIL")));
|
|
|
|
|
+ assertEquals("FAIL", refund.getStatus());
|
|
|
|
|
+ verify(orderService, never()).applyRefundResult(any(), anyLong(), anyString());
|
|
|
|
|
+ verify(callbackService, never()).notifyOrder(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void settleFromQuery_退款申请中_不落状态返回null() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+
|
|
|
|
|
+ assertNull(service.settleFromQuery(refund, resp("PAY_IN_PROCESS")));
|
|
|
|
|
+ assertEquals("REFUNDING", refund.getStatus());
|
|
|
|
|
+ verify(refundMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 未知码 ⇒ **什么都不写**,留在 PENDING 等下一轮。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>与 `LsyOrderService.settleFromQuery` 的未知码分支同纪律:落任何终态都是替上游猜方向,
|
|
|
|
|
+ * 而落 FAIL 会释放 `uk_lsy_refund_one_pending` 与在途闸门 ⇒ 可能重复出金。
|
|
|
|
|
+ * 停在 PENDING 有界:下一轮对账会拿同一个问题再问上游一次。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void settleFromQuery_未知码_不落任何状态() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+
|
|
|
|
|
+ assertNull(service.settleFromQuery(refund, resp("SOME_UNKNOWN_CODE")));
|
|
|
|
|
+ assertEquals("REFUNDING", refund.getStatus());
|
|
|
|
|
+ verify(refundMapper, never()).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 回调先到、对账后到:`applyNotifyResult` 命中 0 行 ⇒ 不得重复收敛订单侧。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>这是 §2.3 与回调路径的必然交叉面 —— 对账腿存在的意义之一就是与回调赛跑,
|
|
|
|
|
+ * 所以「赛输的那一次不能重复累加 refunded_amount」是它的核心不变量。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void settleFromQuery_已被回调写成终态_返回null且不重复收敛订单() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ refund.setOrderId(1L);
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(0); // 已被回调抢先写终态
|
|
|
|
|
+
|
|
|
|
|
+ assertNull(service.settleFromQuery(refund, resp("PAY_SUCCESS")));
|
|
|
|
|
+ verify(orderService, never()).applyRefundResult(any(), anyLong(), anyString());
|
|
|
|
|
+ verify(callbackService, never()).notifyOrder(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- applyNotifyResult:退款行的幂等闸门(Task 12 依赖) ----
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_transitionApplied_returnsTrue() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS"));
|
|
|
|
|
+ assertEquals("SUCCESS", refund.getStatus());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_非终态流转_不写finishTime() {
|
|
|
|
|
+ // finish_time 的列注释是「完成时间(终态)」(V1.8:161)。
|
|
|
|
|
+ // §2.1 预插入之后本方法多了一个非终态流转(CREATED → REFUNDING),
|
|
|
|
|
+ // 无条件写会让**还在途**的退款带上完成时间 —— 事后按 finish_time IS NULL
|
|
|
|
|
+ // 捞未完结退款就会漏掉它。MP 的 NOT_NULL 策略下「patch 里为 null」= 该列不进 SET。
|
|
|
|
|
+ LsyRefundEntity r = new LsyRefundEntity();
|
|
|
|
|
+ r.setId(9L);
|
|
|
|
|
+ r.setStatus("CREATED");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(r, "PAY_IN_PROCESS", "处理中", "REFUNDING"));
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
|
|
+ verify(refundMapper).update(captor.capture(), any());
|
|
|
|
|
+ assertNull(captor.getValue().getFinishTime(),
|
|
|
|
|
+ "非终态流转不得写 finish_time(该列语义是「完成时间(终态)」)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_终态流转_仍然写finishTime() {
|
|
|
|
|
+ // ★ 阳性对照:证明上面那条不是因为「finish_time 被整个删掉了」才绿的。
|
|
|
|
|
+ // 两向都要有读数,否则「不写」与「根本不会写」在结果上无法区分。
|
|
|
|
|
+ LsyRefundEntity r = new LsyRefundEntity();
|
|
|
|
|
+ r.setId(9L);
|
|
|
|
|
+ r.setStatus("REFUNDING");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(r, "PAY_SUCCESS", "成功", "SUCCESS"));
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
|
|
+ verify(refundMapper).update(captor.capture(), any());
|
|
|
|
|
+ assertNotNull(captor.getValue().getFinishTime(),
|
|
|
|
|
+ "终态必须写 finish_time —— 否则这列永远不会被写");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_duplicateCallback_returnsFalse() {
|
|
|
|
|
+ // 同一笔退款的回调重复到达 → status 已不在 {CREATED, REFUNDING} → 命中 0 行
|
|
|
|
|
+ LsyRefundEntity refund = refund("SUCCESS");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(0);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(service.applyNotifyResult(refund, "TRADE_REFUND", "成功", "SUCCESS"),
|
|
|
|
|
+ "重复回调必须返回 false,否则订单侧会重复累加 refunded_amount");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * ★ 退款到达终态必须推下游回调。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【这条钉的是一个真实缺陷】原实现里没有任何地方调用 `callbackService.notifyRefund`:
|
|
|
|
|
+ * 退款行 insert 时写 `notify_status='PENDING'`,而 `LsyCallbackService.retryFailed()`
|
|
|
|
|
+ * 的退款腿只捞 `notify_status='FAIL'`,能把行写成 FAIL 的 `recordRefundNotifyResult`
|
|
|
|
|
+ * 又只被 `notifyRefund` 调 ⇒ **退款行永远停在 PENDING,下游一个退款回调都收不到**。
|
|
|
|
|
+ * `applyNotifyResult` 是退款行**唯一**的状态写入口(同步成功与异步回调两条路径都走它),
|
|
|
|
|
+ * 所以推送挂在这里,两条路径一起覆盖。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_terminalStatus_pushesDownstreamRefundCallback() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"));
|
|
|
|
|
+ verify(callbackService).notifyRefund(refund);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 退款失败也是终态,同样要推 —— 下游需要知道这笔退款没成。 */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_failedStatus_alsoPushesDownstream() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(refund, "PAY_FAIL", "失败", "FAIL"));
|
|
|
|
|
+ verify(callbackService).notifyRefund(refund);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 重复回调(条件更新命中 0 行)**不得**再推一次。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>推送挂在 `applyNotifyResult` 成功分支之后,就是为了让这条闸门同时管住推送 ——
|
|
|
|
|
+ * 上游重推回调时下游不该收到第二条相同的退款通知。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_duplicateCallback_doesNotPushAgain() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("SUCCESS");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(0);
|
|
|
|
|
+
|
|
|
|
|
+ assertFalse(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"));
|
|
|
|
|
+ verify(callbackService, never()).notifyRefund(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 推送本身抛异常**不得**改变返回值。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>钱已经退出去了。此时让异常冒到 `createRefund` 的调用方(OpenAPI 控制器),
|
|
|
|
|
+ * 租户收到的是「退款失败」—— 而实际上退款成功了,那是最坏的一类谎报。
|
|
|
|
|
+ * 推送失败该由 `notify_status='FAIL'` + 重试腿兜,
|
|
|
|
|
+ * 不该由「把成功的退款报成失败」兜。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void applyNotifyResult_pushThrows_stillReturnsTrue() {
|
|
|
|
|
+ LsyRefundEntity refund = refund("REFUNDING");
|
|
|
|
|
+ when(refundMapper.update(any(), any())).thenReturn(1);
|
|
|
|
|
+ doThrow(new RuntimeException("推送通道炸了")).when(callbackService).notifyRefund(any());
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(service.applyNotifyResult(refund, "PAY_SUCCESS", "成功", "SUCCESS"),
|
|
|
|
|
+ "推送失败不得让调用方以为退款失败");
|
|
|
|
|
+ assertEquals("SUCCESS", refund.getStatus(), "本地状态回写不受推送影响");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void refund_rejectsWhenOrderSnapshotExceedsLedger() {
|
|
|
|
|
+ // ★ 本用例封的是「订单侧已累加、退款行却是 FAIL」这个状态。
|
|
|
|
|
+ // 该状态由 LsyRefundService 同步成功分支的设计产生:`if (!applyNotifyResult(...))` 与
|
|
|
|
|
+ // `if (!settleOrderAfterRefund(...))` 是**并列**两条,后者不在前者的 else 里,
|
|
|
|
|
+ // 故 applyNotifyResult 返回 false 时订单侧照样累加,于是快照 > 台账
|
|
|
|
|
+ // —— 按内容定位,别按行号(交付时在 LsyRefundService.java:188-193)。
|
|
|
|
|
+ // Task 10 自己写不出 FAIL 行(它只写 REFUNDING/SUCCESS),所以本任务内不可达 ——
|
|
|
|
|
+ // 但 Task 12 的回调路径会写,所以闸门必须现在就收紧。
|
|
|
|
|
+ //
|
|
|
|
|
+ // 断言方向:**必须拒**。旧实现(只取台账)在这里会算出 remaining=100 而放行,
|
|
|
|
|
+ // 也就是「钱已经出去了还能再退一次」—— 本用例在改动前必红。
|
|
|
|
|
+ LsyOrderEntity o = order();
|
|
|
|
|
+ o.setTotalAmount(100L);
|
|
|
|
|
+ o.setRefundedAmount(60L); // ★ 订单侧已累加 60
|
|
|
|
|
+ o.setStatus("SUCCESS");
|
|
|
|
|
+
|
|
|
|
|
+ LsyRefundEntity failed = new LsyRefundEntity();
|
|
|
|
|
+ failed.setId(9L);
|
|
|
|
|
+ failed.setStatus("FAIL"); // ★ 台账里这一笔不是 SUCCESS ⇒ Σ(SUCCESS)=0
|
|
|
|
|
+ failed.setRefundFee(60L);
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(failed));
|
|
|
|
|
+
|
|
|
|
|
+ assertThrows(IllegalArgumentException.class,
|
|
|
|
|
+ () -> service.createRefund(o, "RF003", 50L));
|
|
|
|
|
+ // 非空转的关键:旧实现会走到上游调用,改动后必须在上游之前拒掉。
|
|
|
|
|
+ verify(client, never()).post(anyString(), anyMap());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LsyRefundEntity refund(String status) {
|
|
|
|
|
+ LsyRefundEntity r = new LsyRefundEntity();
|
|
|
|
|
+ r.setId(9L);
|
|
|
|
|
+ r.setOutRefundNo("RF001");
|
|
|
|
|
+ r.setRefundFee(30L);
|
|
|
|
|
+ r.setStatus(status);
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|