|
@@ -0,0 +1,211 @@
|
|
|
|
|
+package com.payment.platform.module.payment.leshouying.controller;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.entity.LsyNotifyLogEntity;
|
|
|
|
|
+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.LsyNotifyLogMapper;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyOrderMapper;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.mapper.LsyRefundMapper;
|
|
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
|
|
+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.ArrayList;
|
|
|
|
|
+import java.util.HashSet;
|
|
|
|
|
+import java.util.List;
|
|
|
|
|
+import java.util.Set;
|
|
|
|
|
+import java.util.regex.Matcher;
|
|
|
|
|
+import java.util.regex.Pattern;
|
|
|
|
|
+
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 订单抽屉要用的两条只读查询:回调轨迹、本单的退款单。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这个类的断言落在 wrapper 上,而不是落在返回值上】
|
|
|
|
|
+ * `selectList` 是 mock,返回什么由用例自己 stub —— 断言返回值等于自己刚设的值,恒真。
|
|
|
|
|
+ * 唯一能证明「查的是哪些 key」的读数是**去看 wrapper 里绑的值**(同款手法见
|
|
|
|
|
+ * {@code LsyNotifyServiceTest#capturedRefundLookup})。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【缺口是什么】`notifyLogs` 原先只按订单自己的 `pay_trace_no` 查,
|
|
|
|
|
+ * 而**退款回调的审计行绑的是那笔退款的 trace**(上游报文里的 `pay_trace_no` 就是我们的
|
|
|
|
|
+ * `refund_trace_no`,见 `LsyNotifyService#handleRefundNotify`)⇒ 退款回调行**结构上**
|
|
|
|
|
+ * 永远不可能出现在订单的回调轨迹里。用户 2026-09-29 实测到的正是这个:
|
|
|
|
|
+ * 订单已「全额退款」,回调轨迹却仍然是支付的那几条。
|
|
|
|
|
+ */
|
|
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
|
|
+class LeshouyingOrderControllerTest {
|
|
|
|
|
+
|
|
|
|
|
+ @Mock private LsyOrderMapper orderMapper;
|
|
|
|
|
+ @Mock private LsyNotifyLogMapper notifyLogMapper;
|
|
|
|
|
+ @Mock private LsyRefundMapper refundMapper;
|
|
|
|
|
+
|
|
|
|
|
+ private LeshouyingOrderController controller;
|
|
|
|
|
+
|
|
|
|
|
+ @BeforeEach
|
|
|
|
|
+ void setUp() {
|
|
|
|
|
+ controller = new LeshouyingOrderController(orderMapper, notifyLogMapper, refundMapper);
|
|
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在
|
|
|
|
|
+ // 无 Spring 上下文的单测中工作(否则抛 can not find lambda cache for this entity)。
|
|
|
|
|
+ // 本类**每一条**断言都读 wrapper,所以这一句是必需的,不是保险起见。
|
|
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyOrderEntity.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyRefundEntity.class);
|
|
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), LsyNotifyLogEntity.class);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ private LsyOrderEntity order() {
|
|
|
|
|
+ LsyOrderEntity o = new LsyOrderEntity();
|
|
|
|
|
+ o.setId(1L);
|
|
|
|
|
+ o.setTenantId(1L);
|
|
|
|
|
+ o.setOutTradeNo("OUT001");
|
|
|
|
|
+ o.setPayTraceNo("PT001");
|
|
|
|
|
+ o.setStatus("REFUNDED");
|
|
|
|
|
+ o.setTotalAmount(100L);
|
|
|
|
|
+ o.setRefundedAmount(100L);
|
|
|
|
|
+ return o;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private LsyRefundEntity refund(long id, String trace) {
|
|
|
|
|
+ LsyRefundEntity r = new LsyRefundEntity();
|
|
|
|
|
+ r.setId(id);
|
|
|
|
|
+ r.setTenantId(1L);
|
|
|
|
|
+ r.setOrderId(1L);
|
|
|
|
|
+ r.setOutTradeNo("OUT001");
|
|
|
|
|
+ r.setOutRefundNo("RF00" + id);
|
|
|
|
|
+ r.setRefundTraceNo(trace);
|
|
|
|
|
+ r.setRefundFee(30L);
|
|
|
|
|
+ r.setStatus("SUCCESS");
|
|
|
|
|
+ return r;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 取出审计表的 `selectList` 实际用掉的 wrapper。 */
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private AbstractWrapper<?, ?, ?> capturedLogQuery() {
|
|
|
|
|
+ ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyNotifyLogEntity>> c =
|
|
|
|
|
+ ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
|
|
|
|
|
+ verify(notifyLogMapper).selectList(c.capture());
|
|
|
|
|
+ return (AbstractWrapper<?, ?, ?>) c.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
|
|
+ private AbstractWrapper<?, ?, ?> capturedRefundQuery() {
|
|
|
|
|
+ ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<LsyRefundEntity>> c =
|
|
|
|
|
+ ArgumentCaptor.forClass(com.baomidou.mybatisplus.core.conditions.Wrapper.class);
|
|
|
|
|
+ verify(refundMapper).selectList(c.capture());
|
|
|
|
|
+ return (AbstractWrapper<?, ?, ?>) c.getValue();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * WHERE 里 `pay_trace_no IN (...)` 绑的**每一个**值。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 两个坑,都是实测踩出来的,不是保险起见:
|
|
|
|
|
+ * <ol>
|
|
|
|
|
+ * <li><b>必须先把 `getSqlSegment()` 求出来</b> —— `getParamNameValuePairs()` 是在渲染 SQL 段
|
|
|
|
|
+ * 那一步才被填的。先读 map 会读到**空 map**,于是「一个值都没有」与「断言写错了」
|
|
|
|
|
+ * 两种情形长得一模一样(第一版就是这么红的)。</li>
|
|
|
|
|
+ * <li><b>`in(...)` 的元素是逐个占位符,不是一个 Collection</b> ——
|
|
|
|
|
+ * 渲染出来是 `IN (#{...MPGENVAL1},#{...MPGENVAL2},#{...MPGENVAL3})`。
|
|
|
|
|
+ * 按「找 map 里那个 Collection」去取会一个都找不到。</li>
|
|
|
|
|
+ * </ol>
|
|
|
|
|
+ */
|
|
|
|
|
+ private static List<Object> inValues(AbstractWrapper<?, ?, ?> w) {
|
|
|
|
|
+ String sql = w.getSqlSegment(); // ← 必须先求值,见上
|
|
|
|
|
+ int i = sql.indexOf("pay_trace_no IN (");
|
|
|
|
|
+ assertTrue(i >= 0, "WHERE 里应有 pay_trace_no IN (...);实测: " + sql);
|
|
|
|
|
+ String tail = sql.substring(i, sql.indexOf(')', i));
|
|
|
|
|
+ Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)").matcher(tail);
|
|
|
|
|
+ List<Object> out = new ArrayList<>();
|
|
|
|
|
+ while (m.find()) {
|
|
|
|
|
+ out.add(w.getParamNameValuePairs().get(m.group(1)));
|
|
|
|
|
+ }
|
|
|
|
|
+ assertFalse(out.isEmpty(), "IN 列表不该是空的;实测: " + sql);
|
|
|
|
|
+ return out;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** WHERE 里绑定的**所有**值(同样必须在 `getSqlSegment()` 之后读)。 */
|
|
|
|
|
+ private static List<Object> boundValues(AbstractWrapper<?, ?, ?> w) {
|
|
|
|
|
+ w.getSqlSegment();
|
|
|
|
|
+ return new ArrayList<>(w.getParamNameValuePairs().values());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 回调轨迹 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void notifyLogs_coversTheOrderTraceAndEveryRefundTrace() {
|
|
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order());
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001"), refund(10L, "RT002")));
|
|
|
|
|
+ when(notifyLogMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ controller.notifyLogs("OUT001");
|
|
|
|
|
+
|
|
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedLogQuery();
|
|
|
|
|
+ assertTrue(w.getSqlSegment().contains("pay_trace_no IN"),
|
|
|
|
|
+ "必须按 pay_trace_no 的**集合**查;实测: " + w.getSqlSegment());
|
|
|
|
|
+ assertEquals(Set.of("PT001", "RT001", "RT002"), new HashSet<>(inValues(w)),
|
|
|
|
|
+ "订单自身的 trace + 每一笔退款的 trace 缺一不可 —— 少任何一个,"
|
|
|
|
|
+ + "对应的回调行就结构性地看不见(这正是用户看到的现象)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void notifyLogs_withoutAnyRefund_stillQueriesTheOrderTraceAlone() {
|
|
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order());
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+ when(notifyLogMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
+
|
|
|
|
|
+ controller.notifyLogs("OUT001");
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(Set.of("PT001"), new HashSet<>(inValues(capturedLogQuery())),
|
|
|
|
|
+ "没有退款时行为必须与改动前一致(IN 一个元素的语义等于等值)");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void notifyLogs_unknownOrder_is404_andQueriesNothingElse() {
|
|
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> controller.notifyLogs("NOPE"));
|
|
|
|
|
+
|
|
|
|
|
+ // 与 requireOrder 的既有纪律一致:不区分「不存在」与「不属于你」
|
|
|
|
|
+ assertEquals(404, e.getCode());
|
|
|
|
|
+ verify(refundMapper, never()).selectList(any());
|
|
|
|
|
+ verify(notifyLogMapper, never()).selectList(any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ───────────────────────── 本单的退款单 ─────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void orderRefunds_areScopedToThisOrder() {
|
|
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order());
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(refund(9L, "RT001")));
|
|
|
|
|
+
|
|
|
|
|
+ controller.refunds("OUT001");
|
|
|
|
|
+
|
|
|
|
|
+ AbstractWrapper<?, ?, ?> w = capturedRefundQuery();
|
|
|
|
|
+ assertTrue(w.getSqlSegment().contains("out_trade_no ="),
|
|
|
|
|
+ "退款单必须按本单过滤;实测: " + w.getSqlSegment());
|
|
|
|
|
+ assertTrue(boundValues(w).contains("OUT001"),
|
|
|
|
|
+ "过滤条件绑的必须是**本单**的订单号;实测绑定值=" + boundValues(w));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void orderRefunds_unknownOrder_is404() {
|
|
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
|
|
+
|
|
|
|
|
+ BusinessException e = assertThrows(BusinessException.class, () -> controller.refunds("NOPE"));
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(404, e.getCode());
|
|
|
|
|
+ verify(refundMapper, never()).selectList(any());
|
|
|
|
|
+ }
|
|
|
|
|
+}
|