|
|
@@ -0,0 +1,624 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
|
|
+import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.common.response.PageResult;
|
|
|
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedApiException;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedClient;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedOrderPayload;
|
|
|
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
|
|
|
+import com.payment.platform.module.payment.unified.dto.query.UnifiedOrderQueryDTO;
|
|
|
+import com.payment.platform.module.payment.unified.dto.req.UnifiedOrderCreateCommand;
|
|
|
+import com.payment.platform.module.payment.unified.dto.vo.UnifiedOrderVO;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedSource;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
|
|
|
+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.InOrder;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.springframework.dao.DuplicateKeyException;
|
|
|
+
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertSame;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertThrows;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
+import static org.mockito.Mockito.doReturn;
|
|
|
+import static org.mockito.Mockito.inOrder;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 下单主流程与订单状态机(spec §7.2 / §7.5)。
|
|
|
+ *
|
|
|
+ * <p>本类盯的是「不会报错但会错钱」的地方:
|
|
|
+ * <ol>
|
|
|
+ * <li>先调上游再落库 —— 上游通知可能比 HTTP 响应先到,通知会找不到单;</li>
|
|
|
+ * <li>未知状态直接落库 —— 一个 {@code state=99} 就能把订单推到不存在的状态;</li>
|
|
|
+ * <li>上游金额与本地不符仍落成功 —— 少付的单被标成已支付;</li>
|
|
|
+ * <li>上游失败就删单 —— 超时的那笔可能已经成功;</li>
|
|
|
+ * <li>并发重复 out_trade_no 撞唯一索引后 500 —— 幂等重试应当成功。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedOrderServiceTest {
|
|
|
+
|
|
|
+ private static final ObjectMapper JSON = new ObjectMapper();
|
|
|
+ private static final Long TENANT = 2L;
|
|
|
+
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
+ @Mock private UnifiedClient client;
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
+ @Mock private UnifiedCallbackService callbackService;
|
|
|
+ @Mock private OrderEventPublisher publisher;
|
|
|
+
|
|
|
+ private UnifiedPayProperties props;
|
|
|
+ private UnifiedOrderService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ props = new UnifiedPayProperties();
|
|
|
+ service = new UnifiedOrderService(orderMapper, refundMapper, client,
|
|
|
+ merchantService, callbackService, publisher, props);
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 getSqlSegment()/getSqlSet() 在无 Spring 上下文的单测中工作
|
|
|
+ // (同款引导见 UnifiedMerchantServiceTest。)
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static JsonNode json(String text) {
|
|
|
+ try {
|
|
|
+ return JSON.readTree(text);
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
+ throw new IllegalStateException("测试 JSON 写错了", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderCreateCommand command() {
|
|
|
+ UnifiedOrderCreateCommand cmd = new UnifiedOrderCreateCommand();
|
|
|
+ cmd.setAmount(100L);
|
|
|
+ cmd.setWayCode("QR_CASHIER");
|
|
|
+ cmd.setSubject("测试商品");
|
|
|
+ cmd.setOutTradeNo("T001");
|
|
|
+ cmd.setNotifyUrl("https://merchant.example/callback");
|
|
|
+ return cmd;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity merchant() {
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setId(11L);
|
|
|
+ cfg.setTenantId(TENANT);
|
|
|
+ cfg.setMchNo("M001");
|
|
|
+ cfg.setAppId("app-1");
|
|
|
+ cfg.setAppSecret("SECRET");
|
|
|
+ cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private UnifiedMerchantEntity stubMerchant() {
|
|
|
+ UnifiedMerchantEntity cfg = merchant();
|
|
|
+ when(merchantService.resolveForTenant(eq(TENANT), any())).thenReturn(cfg);
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity existing(String tradeStatus) {
|
|
|
+ UnifiedOrderEntity o = new UnifiedOrderEntity();
|
|
|
+ o.setId(5L);
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setMerchantId(11L);
|
|
|
+ o.setMchNo("M001");
|
|
|
+ o.setAppId("app-1");
|
|
|
+ o.setOrderNo("UP5");
|
|
|
+ o.setOutTradeNo("T001");
|
|
|
+ o.setApiKey("KEY-1");
|
|
|
+ o.setSource("OPENAPI");
|
|
|
+ o.setWayCode("QR_CASHIER");
|
|
|
+ o.setAmount(100L);
|
|
|
+ o.setRefundedAmount(0L);
|
|
|
+ o.setCurrency("cny");
|
|
|
+ o.setSubject("测试商品");
|
|
|
+ o.setTradeStatus(tradeStatus);
|
|
|
+ o.setQueryCount(0);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> queryCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> updateCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 创建前置闸门 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownWayCode_rejectedBeforeInsertAndUpstream() {
|
|
|
+ UnifiedOrderCreateCommand cmd = command();
|
|
|
+ cmd.setWayCode("XX");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingOutTradeNo_rejected() {
|
|
|
+ UnifiedOrderCreateCommand cmd = command();
|
|
|
+ cmd.setOutTradeNo(" ");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountZeroOrNegative_rejected() {
|
|
|
+ for (long amount : new long[]{0L, -1L}) {
|
|
|
+ UnifiedOrderCreateCommand cmd = command();
|
|
|
+ cmd.setAmount(amount);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "amount=" + amount + " 实测消息: " + e.getMessage());
|
|
|
+ }
|
|
|
+ verify(orderMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void sourceAdmin_rejectedForOrderCreate() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.ADMIN));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verify(orderMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 幂等 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void idempotentSameParams_returnsExistingWithoutUpstream() {
|
|
|
+ UnifiedOrderEntity existing = existing("0");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(existing);
|
|
|
+
|
|
|
+ UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertSame(existing, result);
|
|
|
+ verify(orderMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper).selectOne(w.capture());
|
|
|
+ String sql = w.getValue().getSqlSegment();
|
|
|
+ assertTrue(sql.contains("tenant_id"), "幂等键必须含租户: " + sql);
|
|
|
+ assertTrue(sql.contains("out_trade_no"), "幂等键必须含 out_trade_no: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void idempotentDifferentParams_409() {
|
|
|
+ UnifiedOrderEntity existing = existing("0");
|
|
|
+ existing.setAmount(99L);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(existing);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("out_trade_no"));
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateInsertSameParams_returnsExistingInsteadOf500() {
|
|
|
+ UnifiedOrderEntity existing = existing("0");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null, existing);
|
|
|
+ stubMerchant();
|
|
|
+ when(orderMapper.insert(any())).thenThrow(new DuplicateKeyException("uk_unified_order_tenant_out_trade_no"));
|
|
|
+
|
|
|
+ UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertSame(existing, result);
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateInsertDifferentParams_409() {
|
|
|
+ UnifiedOrderEntity existing = existing("0");
|
|
|
+ existing.setAmount(99L);
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null, existing);
|
|
|
+ stubMerchant();
|
|
|
+ when(orderMapper.insert(any())).thenThrow(new DuplicateKeyException("uk_unified_order_tenant_out_trade_no"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 创建主流程 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void insertCommitsBeforeUpstreamCall_andStoresCallbackSnapshot() {
|
|
|
+ stubMerchant();
|
|
|
+ when(client.unifiedOrder(any(), any())).thenReturn(json("{\"payOrderId\":\"P1\",\"orderState\":0}"));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ // 实体是活引用:记录 insert 那一刻的 queryCount(之后 recordQueryAttempt 会把它 ++)
|
|
|
+ java.util.concurrent.atomic.AtomicInteger queryCountAtInsert =
|
|
|
+ new java.util.concurrent.atomic.AtomicInteger(-1);
|
|
|
+ when(orderMapper.insert(any())).thenAnswer(inv -> {
|
|
|
+ queryCountAtInsert.set(inv.<UnifiedOrderEntity>getArgument(0).getQueryCount());
|
|
|
+ return 1;
|
|
|
+ });
|
|
|
+
|
|
|
+ UnifiedOrderCreateCommand cmd = command();
|
|
|
+ cmd.setChannelExtra(Map.of("payDataType", "codeUrl"));
|
|
|
+
|
|
|
+ service.create(cmd, TENANT, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ InOrder inOrder = inOrder(orderMapper, client);
|
|
|
+ inOrder.verify(orderMapper).insert(any());
|
|
|
+ inOrder.verify(client).unifiedOrder(any(), any());
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ verify(orderMapper).insert(insertedCap.capture());
|
|
|
+ UnifiedOrderEntity inserted = insertedCap.getValue();
|
|
|
+
|
|
|
+ assertEquals("0", inserted.getTradeStatus());
|
|
|
+ assertTrue(inserted.getOrderNo().startsWith("UP"), "order_no 实测: " + inserted.getOrderNo());
|
|
|
+ assertEquals(TENANT, inserted.getTenantId());
|
|
|
+ assertEquals("OPENAPI", inserted.getSource());
|
|
|
+ assertEquals("KEY-1", inserted.getApiKey());
|
|
|
+ assertEquals(11L, inserted.getMerchantId());
|
|
|
+ assertEquals("M001", inserted.getMchNo());
|
|
|
+ assertEquals("app-1", inserted.getAppId());
|
|
|
+ assertEquals(100L, inserted.getAmount());
|
|
|
+ assertEquals(0L, inserted.getRefundedAmount());
|
|
|
+ assertEquals("cny", inserted.getCurrency());
|
|
|
+ assertEquals("测试商品", inserted.getSubject());
|
|
|
+ assertEquals("payDataType", inserted.getChannelExtraKeys());
|
|
|
+ assertEquals("PENDING", inserted.getNotifyStatus());
|
|
|
+ assertEquals(0, queryCountAtInsert.get(), "落库那一刻 query_count 必须是 0");
|
|
|
+ // R22:调用方的 notifyUrl 只存 callback_url,绝不当上游 notifyUrl
|
|
|
+ assertEquals("https://merchant.example/callback", inserted.getCallbackUrl());
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderPayload> payloadCap = ArgumentCaptor.forClass(UnifiedOrderPayload.class);
|
|
|
+ verify(client).unifiedOrder(any(), payloadCap.capture());
|
|
|
+ UnifiedOrderPayload payload = payloadCap.getValue();
|
|
|
+ assertEquals(inserted.getOrderNo(), payload.mchOrderNo());
|
|
|
+ assertEquals(props.notifyOrderUrl(), payload.notifyUrl(),
|
|
|
+ "上游 notifyUrl 必须是我们自己的地址");
|
|
|
+ assertFalse(props.notifyOrderUrl().equals(cmd.getNotifyUrl()));
|
|
|
+ assertEquals("7200", payload.expiredTime());
|
|
|
+ assertEquals("测试商品", payload.body(), "body 空时用 subject 顶上");
|
|
|
+ assertTrue(payload.channelExtraJson().contains("payDataType"));
|
|
|
+ assertTrue(payload.channelExtraJson().contains("codeUrl"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void successResponse_state2_appliesAndPublishes() {
|
|
|
+ stubMerchant();
|
|
|
+ JsonNode data = json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":100,"
|
|
|
+ + "\"currency\":\"cny\",\"successTime\":1760000000000}");
|
|
|
+ when(client.unifiedOrder(any(), any())).thenReturn(data);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("2", result.getTradeStatus());
|
|
|
+ assertNotNull(result.getPayTime());
|
|
|
+ assertEquals("P1", result.getUpstreamPayOrderId());
|
|
|
+ assertEquals(1, result.getQueryCount(), "创建响应那次查询也要计数");
|
|
|
+ assertNotNull(result.getUpstreamDataJson());
|
|
|
+
|
|
|
+ // R23:事件带的是租户号(2),不是自增主键(5)
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> callbackOrder = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ ArgumentCaptor<JsonNode> callbackUpstream = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
+ verify(callbackService).notifyOrder(callbackOrder.capture(), callbackUpstream.capture());
|
|
|
+ assertEquals("2", callbackOrder.getValue().getTradeStatus());
|
|
|
+ assertEquals("P1", callbackUpstream.getValue().get("payOrderId").asText());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownState_doesNotAdvance() {
|
|
|
+ stubMerchant();
|
|
|
+ when(client.unifiedOrder(any(), any())).thenReturn(json("{\"payOrderId\":\"P1\",\"orderState\":99}"));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedOrderEntity result = service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("0", result.getTradeStatus());
|
|
|
+ assertEquals("P1", result.getUpstreamPayOrderId(), "回填仍应发生,只是状态不推进");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void successWithMissingOrInvalidAmount_isNotApplied() {
|
|
|
+ stubMerchant();
|
|
|
+ when(client.unifiedOrder(any(), any())).thenReturn(
|
|
|
+ json("{\"payOrderId\":\"P1\",\"orderState\":2}"),
|
|
|
+ json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":0,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":-1,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":99,\"currency\":\"cny\"}"),
|
|
|
+ json("{\"payOrderId\":\"P1\",\"orderState\":2,\"amount\":100,\"currency\":\"usd\"}"));
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ for (int i = 0; i < 5; i++) {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+ assertEquals(500, e.getCode(), "第 " + (i + 1) + " 例实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("AMOUNT_MISMATCH"),
|
|
|
+ "第 " + (i + 1) + " 例必须报 AMOUNT_MISMATCH,实测: " + e.getMessage());
|
|
|
+ }
|
|
|
+
|
|
|
+ verify(orderMapper, times(5)).insert(insertedCap.capture());
|
|
|
+ for (UnifiedOrderEntity order : insertedCap.getAllValues()) {
|
|
|
+ assertEquals("0", order.getTradeStatus(), "金额不符时状态不能推进");
|
|
|
+ assertEquals("AMOUNT_MISMATCH", order.getErrCode());
|
|
|
+ assertNotNull(order.getErrMsg());
|
|
|
+ }
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void upstreamFailure_keepsLocalOrderAndSchedulesRetry() {
|
|
|
+ stubMerchant();
|
|
|
+ when(client.unifiedOrder(any(), any())).thenThrow(
|
|
|
+ new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT, "connect timeout"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.create(command(), TENANT, "KEY-1", UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("out_trade_no"), "实测消息: " + e.getMessage());
|
|
|
+
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> insertedCap = ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ verify(orderMapper).insert(insertedCap.capture());
|
|
|
+ UnifiedOrderEntity order = insertedCap.getValue();
|
|
|
+ assertEquals("0", order.getTradeStatus());
|
|
|
+ assertNotNull(order.getNextQueryTime(), "失败单必须安排 15 秒后的重试查询");
|
|
|
+ assertTrue(order.getNextQueryTime().isAfter(OffsetDateTime.now().plusSeconds(5)));
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 状态机 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_zeroToOne_doesNotPublish() {
|
|
|
+ UnifiedOrderEntity order = existing("0");
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ assertTrue(service.applyOrderState(order, "1", null, null, null));
|
|
|
+
|
|
|
+ assertEquals("1", order.getTradeStatus());
|
|
|
+ assertNotNull(order.getNextQueryTime(), "非终态要安排下一次查询");
|
|
|
+ assertNull(order.getPayTime());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> w = updateCaptor();
|
|
|
+ verify(orderMapper).update(isNull(), w.capture());
|
|
|
+ String where = w.getValue().getSqlSegment();
|
|
|
+ assertTrue(where.contains("order_no"), "条件更新必须按 order_no: " + where);
|
|
|
+ assertTrue(where.contains("trade_status IN"), "条件更新必须带前态集合: " + where);
|
|
|
+ String set = w.getValue().getSqlSet();
|
|
|
+ for (String column : List.of("trade_status", "err_code", "err_msg",
|
|
|
+ "last_query_time", "updated_time", "next_query_time")) {
|
|
|
+ assertTrue(set.contains(column), "SET 缺列 " + column + ",实测: " + set);
|
|
|
+ }
|
|
|
+ assertFalse(set.contains("pay_time"), "没有 successTime 不该写 pay_time: " + set);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_toSuccess_publishesAndNotifies() {
|
|
|
+ UnifiedOrderEntity order = existing("0");
|
|
|
+ JsonNode upstream = json("{\"successTime\":1760000000000,\"payOrderId\":\"P1\"}");
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ assertTrue(service.applyOrderState(order, "2", upstream, null, null));
|
|
|
+
|
|
|
+ assertEquals("2", order.getTradeStatus());
|
|
|
+ assertNotNull(order.getPayTime());
|
|
|
+ assertNull(order.getNextQueryTime(), "终态不再轮询");
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ verify(callbackService).notifyOrder(same(order), same(upstream));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_twoToFive_publishes() {
|
|
|
+ UnifiedOrderEntity order = existing("2");
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ assertTrue(service.applyOrderState(order, UnifiedOrderStatus.REFUNDED, null, null, null));
|
|
|
+
|
|
|
+ assertEquals("5", order.getTradeStatus());
|
|
|
+ assertNull(order.getNextQueryTime());
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ verify(callbackService).notifyOrder(same(order), isNull());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_terminalToSuccess_rejected() {
|
|
|
+ UnifiedOrderEntity order = existing("3");
|
|
|
+
|
|
|
+ assertFalse(service.applyOrderState(order, "2", null, null, null));
|
|
|
+
|
|
|
+ assertEquals("3", order.getTradeStatus(), "终态不能回退");
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_sameState_notChanged() {
|
|
|
+ UnifiedOrderEntity order = existing("2");
|
|
|
+
|
|
|
+ assertFalse(service.applyOrderState(order, "2", null, null, null));
|
|
|
+
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_invalidState_rejected() {
|
|
|
+ UnifiedOrderEntity order = existing("0");
|
|
|
+
|
|
|
+ assertFalse(service.applyOrderState(order, "99", null, null, null));
|
|
|
+
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyOrderState_updateZeroRows_doesNotPublish() {
|
|
|
+ UnifiedOrderEntity order = existing("0");
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ assertFalse(service.applyOrderState(order, "2", null, null, null));
|
|
|
+
|
|
|
+ assertEquals("0", order.getTradeStatus(), "条件更新落空时内存也不能动");
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 查询与列表 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundableAmount_usesOccupiedSum() {
|
|
|
+ UnifiedOrderEntity order = existing("2");
|
|
|
+ when(refundMapper.sumOccupiedAmount(5L)).thenReturn(30L);
|
|
|
+
|
|
|
+ assertEquals(70L, service.refundableAmount(order));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void list_mapsRowsToVoWithNullRefundable() {
|
|
|
+ UnifiedOrderEntity row = existing("2");
|
|
|
+ Page<UnifiedOrderEntity> page = new Page<>(2, 10);
|
|
|
+ page.setRecords(List.of(row));
|
|
|
+ page.setTotal(1);
|
|
|
+ doReturn(page).when(orderMapper).selectPage(any(), any());
|
|
|
+
|
|
|
+ UnifiedOrderQueryDTO q = new UnifiedOrderQueryDTO();
|
|
|
+ q.setPageNo(2);
|
|
|
+ q.setPageSize(10);
|
|
|
+ q.setOrderNo("UP5");
|
|
|
+ q.setOutTradeNo("T001");
|
|
|
+ q.setMchNo("M001");
|
|
|
+ q.setWayCode("QR_CASHIER");
|
|
|
+ q.setTradeStatus("2");
|
|
|
+ q.setStartDate("2026-10-01");
|
|
|
+ q.setEndDate("2026-10-20");
|
|
|
+
|
|
|
+ PageResult<UnifiedOrderVO> result = service.list(q);
|
|
|
+
|
|
|
+ assertEquals(2, result.getPageNo());
|
|
|
+ assertEquals(10, result.getPageSize());
|
|
|
+ assertEquals(1, result.getTotal());
|
|
|
+ assertEquals(1, result.getList().size());
|
|
|
+ UnifiedOrderVO vo = result.getList().get(0);
|
|
|
+ assertEquals(5L, vo.getId());
|
|
|
+ assertEquals(TENANT, vo.getTenantId());
|
|
|
+ assertEquals("UP5", vo.getOrderNo());
|
|
|
+ assertEquals("T001", vo.getOutTradeNo());
|
|
|
+ assertEquals("M001", vo.getMchNo());
|
|
|
+ assertEquals("QR_CASHIER", vo.getWayCode());
|
|
|
+ assertEquals(100L, vo.getAmount());
|
|
|
+ assertEquals("2", vo.getTradeStatus());
|
|
|
+ assertNull(vo.getRefundableAmount(), "列表不算可退金额(详情才算)");
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper).selectPage(any(), w.capture());
|
|
|
+ String sql = w.getValue().getSqlSegment();
|
|
|
+ for (String fragment : List.of("order_no", "out_trade_no", "mch_no",
|
|
|
+ "way_code", "trade_status", "created_time", "ORDER BY created_time DESC")) {
|
|
|
+ assertTrue(sql.contains(fragment), "查询缺片段 " + fragment + ",实测: " + sql);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findPendingForPoll_filtersAndClampsLimit() {
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(existing("0")));
|
|
|
+
|
|
|
+ List<UnifiedOrderEntity> rows = service.findPendingForPoll(5000);
|
|
|
+
|
|
|
+ assertEquals(1, rows.size());
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper).selectList(w.capture());
|
|
|
+ String sql = w.getValue().getSqlSegment();
|
|
|
+ for (String fragment : List.of("trade_status IN", "next_query_time <=", "created_time >",
|
|
|
+ "query_count <", "ORDER BY next_query_time ASC", "LIMIT 500")) {
|
|
|
+ assertTrue(sql.contains(fragment), "轮询查询缺片段 " + fragment + ",实测: " + sql);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findForNotify_prefersUpstreamPayOrderId() {
|
|
|
+ UnifiedOrderEntity byUpstream = existing("2");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(byUpstream);
|
|
|
+
|
|
|
+ assertSame(byUpstream, service.findForNotify("P1", "UP5"));
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper).selectOne(w.capture());
|
|
|
+ assertTrue(w.getValue().getSqlSegment().contains("upstream_pay_order_id"),
|
|
|
+ "有上游单号时必须优先按它查: " + w.getValue().getSqlSegment());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findForNotify_fallsBackToOrderNo() {
|
|
|
+ UnifiedOrderEntity byOrderNo = existing("2");
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null, byOrderNo);
|
|
|
+
|
|
|
+ assertSame(byOrderNo, service.findForNotify("P1", "UP5"));
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedOrderEntity>> w = queryCaptor();
|
|
|
+ verify(orderMapper, times(2)).selectOne(w.capture());
|
|
|
+ List<LambdaQueryWrapper<UnifiedOrderEntity>> wrappers = w.getAllValues();
|
|
|
+ assertTrue(wrappers.get(1).getSqlSegment().contains("order_no"),
|
|
|
+ "上游单号查不到时必须回退到 order_no: " + wrappers.get(1).getSqlSegment());
|
|
|
+ }
|
|
|
+}
|