|
|
@@ -0,0 +1,812 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import ch.qos.logback.classic.Level;
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
+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.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.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.dto.req.UnifiedRefundCreateCommand;
|
|
|
+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.entity.UnifiedRefundEntity;
|
|
|
+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 com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
|
|
|
+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.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.List;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+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.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.anyLong;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
+import static org.mockito.Mockito.clearInvocations;
|
|
|
+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;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 退款核心:事务 A(额度闸门 + 落单)、事务 B(状态收敛)、事件/回调编排(spec §7.4 / §7.6)。
|
|
|
+ *
|
|
|
+ * <p>本类盯的是「不会报错但会错钱」的地方:
|
|
|
+ * <ol>
|
|
|
+ * <li>对没收到钱的单发起退款;</li>
|
|
|
+ * <li>在途退款不占额度,并发两笔把总额退超;</li>
|
|
|
+ * <li>上游明确拒绝却把额度锁 30 分钟(state 留 0);</li>
|
|
|
+ * <li>退款成功但订单仍 0/1 时把未知金额的订单标成已支付(R25①);</li>
|
|
|
+ * <li>未支付单全额退款(0/1→2→5)把同一个 ORDER 事件推两遍(R25②);</li>
|
|
|
+ * <li>条件 UPDATE 落空仍发事件/回调。</li>
|
|
|
+ * </ol>
|
|
|
+ *
|
|
|
+ * <p>编排测试用 mock 的 {@link UnifiedRefundTxService};闸门与事务语义用真实 tx service +
|
|
|
+ * mock mapper。事件第二参一律是租户号(R23)。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedRefundServiceTest {
|
|
|
+
|
|
|
+ private static final ObjectMapper JSON = new ObjectMapper();
|
|
|
+ private static final Long TENANT = 2L;
|
|
|
+ private static final long ORDER_ID = 5L;
|
|
|
+ private static final long MERCHANT_ID = 11L;
|
|
|
+
|
|
|
+ @Mock private UnifiedRefundTxService tx;
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedOrderService orderService;
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
+ @Mock private UnifiedClient client;
|
|
|
+ @Mock private UnifiedCallbackService callbackService;
|
|
|
+ @Mock private OrderEventPublisher publisher;
|
|
|
+
|
|
|
+ /** 编排层:tx 是 mock。 */
|
|
|
+ private UnifiedRefundService service;
|
|
|
+ /** 真实事务层 + mock mapper。 */
|
|
|
+ private UnifiedRefundTxService realTx;
|
|
|
+ private UnifiedRefundService realService;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new UnifiedRefundService(tx, refundMapper, orderService, merchantService,
|
|
|
+ client, callbackService, publisher);
|
|
|
+ realTx = new UnifiedRefundTxService(orderMapper, refundMapper);
|
|
|
+ realService = new UnifiedRefundService(realTx, refundMapper, orderService, merchantService,
|
|
|
+ client, callbackService, publisher);
|
|
|
+
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedOrderEntity.class);
|
|
|
+ TableInfoHelper.initTableInfo(assistant, UnifiedRefundEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 工具 ──────────────────────────────
|
|
|
+
|
|
|
+ private static JsonNode json(String text) {
|
|
|
+ try {
|
|
|
+ return JSON.readTree(text);
|
|
|
+ } catch (JsonProcessingException e) {
|
|
|
+ throw new IllegalStateException("测试 JSON 写错了", e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity order(String tradeStatus) {
|
|
|
+ UnifiedOrderEntity o = new UnifiedOrderEntity();
|
|
|
+ o.setId(ORDER_ID);
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setMerchantId(MERCHANT_ID);
|
|
|
+ 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.setTradeStatus(tradeStatus);
|
|
|
+ o.setQueryCount(0);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity merchant() {
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setId(MERCHANT_ID);
|
|
|
+ cfg.setTenantId(TENANT);
|
|
|
+ cfg.setMchNo("M001");
|
|
|
+ cfg.setAppId("app-1");
|
|
|
+ cfg.setAppSecret("SECRET");
|
|
|
+ cfg.setStatus(UnifiedMerchantService.STATUS_ENABLED);
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundEntity refund(String state, long amount) {
|
|
|
+ UnifiedRefundEntity r = new UnifiedRefundEntity();
|
|
|
+ r.setId(9L);
|
|
|
+ r.setTenantId(TENANT);
|
|
|
+ r.setOrderId(ORDER_ID);
|
|
|
+ r.setOrderNo("UP5");
|
|
|
+ r.setRefundNo("UR1");
|
|
|
+ r.setOutRefundNo("R1");
|
|
|
+ r.setMchNo("M001");
|
|
|
+ r.setAppId("app-1");
|
|
|
+ r.setApiKey("KEY-1");
|
|
|
+ r.setSource("OPENAPI");
|
|
|
+ r.setAmount(amount);
|
|
|
+ r.setCurrency("cny");
|
|
|
+ r.setRefundReason("测试退款");
|
|
|
+ r.setState(state);
|
|
|
+ r.setQueryCount(0);
|
|
|
+ r.setNotifyStatus("PENDING");
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundCreateCommand command(long amount, String outRefundNo) {
|
|
|
+ UnifiedRefundCreateCommand cmd = new UnifiedRefundCreateCommand();
|
|
|
+ cmd.setOrderNo("UP5");
|
|
|
+ cmd.setRefundAmount(amount);
|
|
|
+ cmd.setRefundReason("测试退款");
|
|
|
+ cmd.setOutRefundNo(outRefundNo);
|
|
|
+ return cmd;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundCreateCommand command() {
|
|
|
+ return command(100L, "R1");
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建路径的公共桩:订单解析 + 商户配置。 */
|
|
|
+ private void stubCreateOrder(UnifiedOrderEntity o) {
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 创建路径的公共桩:事务 A 锁单 + 额度 + 落单。 */
|
|
|
+ private void stubReserve(UnifiedOrderEntity o, long occupied) {
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(occupied);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> refundUpdateCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static ArgumentCaptor<LambdaUpdateWrapper<UnifiedOrderEntity>> orderUpdateCaptor() {
|
|
|
+ return ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 从 SET 子句里取某一列绑定的参数值(MyBatis-Plus 的参数名形如 {@code paramNameValuePairs.MPGENVAL1})。 */
|
|
|
+ private static Object setValue(LambdaUpdateWrapper<?> w, String column) {
|
|
|
+ String set = w.getSqlSet();
|
|
|
+ Matcher m = Pattern.compile(Pattern.quote(column)
|
|
|
+ + "=#\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
|
|
|
+ .matcher(set);
|
|
|
+ assertTrue(m.find(), "SET 缺列 " + column + ",实测: " + set);
|
|
|
+ return w.getParamNameValuePairs().get(m.group(1));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
+ appender.start();
|
|
|
+ logger.addAppender(appender);
|
|
|
+ return appender;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 1. 未支付单 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unpaidOrder_isRejectedBeforeUpstream() {
|
|
|
+ UnifiedOrderEntity o = order("0");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(tx.reserve(eq(ORDER_ID), eq(TENANT), eq(100L), anyString(), anyString(), eq("R1"),
|
|
|
+ isNull(), isNull(), isNull(), eq(UnifiedSource.OPENAPI)))
|
|
|
+ .thenThrow(new BusinessException(400, "订单未支付成功,不能退款"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("订单未支付成功,不能退款", e.getMessage());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 2. 已全退 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fullyRefundedOrder_isRejectedWithNoRefundableAmount() {
|
|
|
+ UnifiedOrderEntity o = order("5");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("已无可退金额", e.getMessage());
|
|
|
+ verify(refundMapper, never()).insert(any());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 3. 在途占额度(Review Focus 5) ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void secondRefundExceedingInFlightQuota_isRejectedWithSingleUpstreamCall() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ // 第一笔落单后占用 60 分;第二笔只剩 40 分可退
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L, 60L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":0}"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity first = realService.createRefund(
|
|
|
+ command(60L, "R1"), TENANT, null, UnifiedSource.OPENAPI);
|
|
|
+ assertEquals("0", first.getState());
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> realService.createRefund(command(50L, "R2"), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("超过可退金额"), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("40"), "实测消息: " + e.getMessage());
|
|
|
+ verify(client, times(1)).refundOrder(any(), anyString(), anyString(), anyLong(), anyString());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 4. 幂等 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void idempotentSameOutRefundNo_returnsExistingWithoutUpstream() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity existing = refund("2", 100L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(existing);
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = service.createRefund(command(), TENANT, "KEY-1",
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertSame(existing, result);
|
|
|
+ verifyNoInteractions(client, merchantService, tx);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void idempotentDifferentAmount_409() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity existing = refund("2", 60L);
|
|
|
+ existing.setOutRefundNo("R1");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(existing);
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createRefund(command(100L, "R1"), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(409, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertTrue(e.getMessage().contains("out_refund_no"), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateInsertThenNotFound_rethrows() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenThrow(
|
|
|
+ new org.springframework.dao.DuplicateKeyException("uk_unified_refund_tenant_out_refund_no"));
|
|
|
+
|
|
|
+ assertThrows(org.springframework.dao.DuplicateKeyException.class,
|
|
|
+ () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 5. 上游明确拒绝 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void upstreamBusinessRejection_marksRefundFailedAndPublishes() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("0", 100L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, r);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectById(ORDER_ID)).thenReturn(o);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), eq(100L), anyString()))
|
|
|
+ .thenThrow(new UnifiedApiException(5, "余额不足"));
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ assertEquals("余额不足", e.getMessage());
|
|
|
+ assertEquals("3", r.getState(), "上游明确拒绝必须落 3,立即释放额度");
|
|
|
+ assertEquals("5", r.getErrCode());
|
|
|
+ assertEquals("余额不足", r.getErrMsg());
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
|
|
|
+ verify(callbackService).notifyRefund(same(r), same(o), isNull());
|
|
|
+ verify(callbackService, never()).notifyOrder(any(), any());
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = refundUpdateCaptor();
|
|
|
+ verify(refundMapper).update(isNull(), w.capture());
|
|
|
+ String set = w.getValue().getSqlSet();
|
|
|
+ assertTrue(set.contains("state"), "退款状态更新缺 state: " + set);
|
|
|
+ assertFalse(set.contains("upstream_data_json"),
|
|
|
+ "null upstream 不得把已有快照覆盖成空: " + set);
|
|
|
+ assertEquals("3", String.valueOf(setValue(w.getValue(), "state")));
|
|
|
+ assertEquals("5", String.valueOf(setValue(w.getValue(), "err_code")));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 6. 传输异常 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void transportFailure_keepsGeneratedAndSchedulesRetry() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenThrow(new UnifiedApiException(UnifiedApiException.CODE_TRANSPORT,
|
|
|
+ "connect timeout"));
|
|
|
+
|
|
|
+ OffsetDateTime before = OffsetDateTime.now();
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> realService.createRefund(command(), TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(500, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ ArgumentCaptor<UnifiedRefundEntity> inserted = ArgumentCaptor.forClass(UnifiedRefundEntity.class);
|
|
|
+ verify(refundMapper).insert(inserted.capture());
|
|
|
+ UnifiedRefundEntity reserved = inserted.getValue();
|
|
|
+ assertEquals("0", reserved.getState(), "超时/网络异常必须留在 0 等查询收敛");
|
|
|
+ assertNotNull(reserved.getNextQueryTime(), "失败单必须安排 15 秒后的重试查询");
|
|
|
+ assertTrue(reserved.getNextQueryTime().isAfter(before.plusSeconds(13))
|
|
|
+ && reserved.getNextQueryTime().isBefore(before.plusSeconds(18)),
|
|
|
+ "重试排期应约 15 秒后,实测: " + reserved.getNextQueryTime());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 7. 全额退款(已支付单) ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void fullRefund_paidOrder_convergesOrderTo5AndPublishesBoth() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("0", 100L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, r);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(100L);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":2,\"refundOrderId\":\"RF1\","
|
|
|
+ + "\"successTime\":1760000000000}"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("2", result.getState());
|
|
|
+ assertEquals(100L, o.getRefundedAmount());
|
|
|
+ assertEquals("5", o.getTradeStatus());
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ verify(callbackService).notifyRefund(same(r), same(o), any(JsonNode.class));
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> orderCallback =
|
|
|
+ ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ verify(callbackService).notifyOrder(orderCallback.capture(), any(JsonNode.class));
|
|
|
+ assertEquals("5", orderCallback.getValue().getTradeStatus(),
|
|
|
+ "全额退款的 ORDER 事件必须带最终快照(5)");
|
|
|
+
|
|
|
+ // 订单/退款行的 UPDATE 必须先于两次下游回调
|
|
|
+ InOrder inOrder = inOrder(refundMapper, orderMapper, callbackService);
|
|
|
+ inOrder.verify(refundMapper, org.mockito.Mockito.atLeastOnce())
|
|
|
+ .update(isNull(), any());
|
|
|
+ inOrder.verify(orderMapper, org.mockito.Mockito.atLeastOnce())
|
|
|
+ .update(isNull(), any());
|
|
|
+ inOrder.verify(callbackService).notifyRefund(any(), any(), any());
|
|
|
+ inOrder.verify(callbackService).notifyOrder(any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 8. 部分退款(已支付单) ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void partialRefund_paidOrder_publishesRefundOnly() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("0", 30L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, r);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":2}"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(
|
|
|
+ command(30L, "R1"), TENANT, null, UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("2", result.getState());
|
|
|
+ assertEquals("2", o.getTradeStatus(), "部分退款成功后订单仍应为 2");
|
|
|
+ assertEquals(30L, o.getRefundedAmount());
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
|
|
|
+ verify(callbackService).notifyRefund(any(), any(), any());
|
|
|
+ verify(callbackService, never()).notifyOrder(any(), any());
|
|
|
+ verify(orderMapper, times(1)).update(isNull(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 9. 退款成功但订单仍 0/1 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundSuccess_unpaidOrder_withMatchingPayAmount_setsOrderPaid() {
|
|
|
+ UnifiedOrderEntity o = order("0");
|
|
|
+ UnifiedRefundEntity r = refund("1", 30L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
|
|
|
+ 100L, null, null);
|
|
|
+
|
|
|
+ assertEquals("2", r.getState());
|
|
|
+ assertEquals("2", o.getTradeStatus(), "payAmount 与订单金额一致时补记支付成功");
|
|
|
+ assertEquals(30L, o.getRefundedAmount());
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> orderCallback =
|
|
|
+ ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ verify(callbackService).notifyOrder(orderCallback.capture(), any(JsonNode.class));
|
|
|
+ assertEquals("2", orderCallback.getValue().getTradeStatus());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** R25②:0/1→2→5 同一次 apply 只推一次 ORDER(最终 5),不是两次。 */
|
|
|
+ @Test
|
|
|
+ void refundSuccess_unpaidOrder_fullAmount_publishesSingleOrderEventWithFinalState5() {
|
|
|
+ UnifiedOrderEntity o = order("0");
|
|
|
+ UnifiedRefundEntity r = refund("1", 100L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(100L);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
|
|
|
+ 100L, null, null);
|
|
|
+
|
|
|
+ assertEquals("5", o.getTradeStatus());
|
|
|
+ assertEquals(100L, o.getRefundedAmount());
|
|
|
+
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, times(1)).changed(OrderEventPublisher.CHANNEL_UNIFIED_ORDER, TENANT);
|
|
|
+ ArgumentCaptor<UnifiedOrderEntity> orderCallback =
|
|
|
+ ArgumentCaptor.forClass(UnifiedOrderEntity.class);
|
|
|
+ verify(callbackService, times(1)).notifyOrder(orderCallback.capture(), any(JsonNode.class));
|
|
|
+ assertEquals("5", orderCallback.getValue().getTradeStatus(),
|
|
|
+ "同一次 apply 的 ORDER 事件必须带最终快照(5)");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 10. 金额缺失/不符(R25①) ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundSuccess_unpaidOrder_payAmountMissing_orderUntouched() {
|
|
|
+ assertUnpaidOrderUntouched(null, "缺失");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundSuccess_unpaidOrder_payAmountMismatch_orderUntouched() {
|
|
|
+ assertUnpaidOrderUntouched(99L, "不符");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertUnpaidOrderUntouched(Long payAmount, String label) {
|
|
|
+ UnifiedOrderEntity o = order("0");
|
|
|
+ UnifiedRefundEntity r = refund("1", 100L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundTxService.class);
|
|
|
+ try {
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
|
|
|
+ payAmount, null, null);
|
|
|
+ } finally {
|
|
|
+ ((Logger) LoggerFactory.getLogger(UnifiedRefundTxService.class))
|
|
|
+ .detachAppender(appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals("0", o.getTradeStatus(), label + ":金额未知/不等的单不得标成已支付");
|
|
|
+ assertEquals(0L, o.getRefundedAmount());
|
|
|
+ assertEquals("2", r.getState(), "退款行本身仍应落 2");
|
|
|
+
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+ verify(refundMapper, never()).sumSucceededAmount(anyLong());
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
|
|
|
+ verify(callbackService).notifyRefund(any(), any(), any());
|
|
|
+ verify(callbackService, never()).notifyOrder(any(), any());
|
|
|
+
|
|
|
+ assertTrue(appender.list.stream().anyMatch(ev ->
|
|
|
+ ev.getLevel() == Level.ERROR
|
|
|
+ && ev.getFormattedMessage().contains("UP5")),
|
|
|
+ "payAmount " + label + " 必须记 ERROR(含订单号),实测: "
|
|
|
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 11. 订单 3/4/6 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundSuccess_orderFailedRevokedOrClosed_orderUntouched() {
|
|
|
+ for (String status : List.of("3", "4", "6")) {
|
|
|
+ UnifiedOrderEntity o = order(status);
|
|
|
+ UnifiedRefundEntity r = refund("1", 100L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"),
|
|
|
+ 100L, null, null);
|
|
|
+
|
|
|
+ assertEquals(status, o.getTradeStatus(), "终态订单不得被退款回写改动");
|
|
|
+ assertEquals("2", r.getState());
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_UNIFIED_REFUND, TENANT);
|
|
|
+ verify(publisher, never()).changed(eq(OrderEventPublisher.CHANNEL_UNIFIED_ORDER), any());
|
|
|
+ verify(callbackService).notifyRefund(any(), any(), any());
|
|
|
+ verify(callbackService, never()).notifyOrder(any(), any());
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+
|
|
|
+ clearInvocations(publisher, callbackService, orderMapper);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 12. 重复收敛 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void duplicateSuccessApply_isNoOp() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("1", 30L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
|
|
|
+ assertEquals("2", r.getState());
|
|
|
+
|
|
|
+ clearInvocations(publisher, callbackService);
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
|
|
|
+
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ verify(refundMapper, times(1)).update(isNull(), any());
|
|
|
+
|
|
|
+ UnifiedRefundTxService.ApplyOutcome outcome =
|
|
|
+ realTx.apply("UR1", "2", json("{\"state\":2}"), 100L, null, null);
|
|
|
+ assertFalse(outcome.refundChanged(), "同态不得算一次变化");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundConditionalUpdateZeroRows_doesNotPublish() {
|
|
|
+ UnifiedRefundEntity r = refund("1", 100L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", json("{\"state\":2}"), 100L, null, null);
|
|
|
+
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ verify(orderMapper, never()).selectForUpdate(any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 13. 退款行租户取自订单 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void reserve_usesOrderTenantWhenCallerTenantIsNull() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, null)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+
|
|
|
+ UnifiedRefundEntity reserved = realTx.reserve(ORDER_ID, null, 100L, "测试退款",
|
|
|
+ "UR1", "R1", null, null, "KEY-1", UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals(TENANT, reserved.getTenantId(), "超管发起也必须落到订单所属租户");
|
|
|
+ ArgumentCaptor<UnifiedRefundEntity> inserted =
|
|
|
+ ArgumentCaptor.forClass(UnifiedRefundEntity.class);
|
|
|
+ verify(refundMapper).insert(inserted.capture());
|
|
|
+ assertEquals(TENANT, inserted.getValue().getTenantId());
|
|
|
+ assertEquals("0", inserted.getValue().getState());
|
|
|
+ assertEquals("cny", inserted.getValue().getCurrency());
|
|
|
+ assertEquals(0, inserted.getValue().getQueryCount());
|
|
|
+ assertEquals("PENDING", inserted.getValue().getNotifyStatus());
|
|
|
+ assertEquals("OPENAPI", inserted.getValue().getSource());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 14. 脱敏快照落库 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void apply_persistsSanitizedUpstreamSnapshot() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("1", 30L);
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(r);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(refundMapper.sumSucceededAmount(ORDER_ID)).thenReturn(30L);
|
|
|
+
|
|
|
+ JsonNode raw = json("{\"state\":2,\"refundOrderId\":\"RF1\","
|
|
|
+ + "\"channelExtra\":{\"auth_code\":\"CODE\"},\"openid\":\"OPEN\"}");
|
|
|
+ JsonNode snapshot = UnifiedUpstreamSnapshot.of(raw);
|
|
|
+
|
|
|
+ realService.applyRefundState("UR1", "2", snapshot, 100L, null, null);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = refundUpdateCaptor();
|
|
|
+ verify(refundMapper).update(isNull(), w.capture());
|
|
|
+ String stored = String.valueOf(setValue(w.getValue(), "upstream_data_json"));
|
|
|
+ assertTrue(stored.contains("\"refundOrderId\":\"RF1\""), "快照缺白名单字段: " + stored);
|
|
|
+ assertTrue(stored.contains("\"state\""), "快照缺 state: " + stored);
|
|
|
+ for (String forbidden : List.of("channelExtra", "auth_code", "CODE", "openid", "OPEN")) {
|
|
|
+ assertFalse(stored.contains(forbidden),
|
|
|
+ "快照不得包含敏感值 " + forbidden + ",实测: " + stored);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 15/16. 创建响应状态 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createResponseState1_transitionsWithoutEvent() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ UnifiedRefundEntity r = refund("0", 100L);
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null, r);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(orderMapper.selectById(ORDER_ID)).thenReturn(o);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":1,\"refundOrderId\":\"RF1\"}"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("1", result.getState());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void createResponseState0_schedulesNextQuery() {
|
|
|
+ UnifiedOrderEntity o = order("2");
|
|
|
+ when(orderService.findByOrderNo(TENANT, "UP5")).thenReturn(o);
|
|
|
+ when(merchantService.requireById(MERCHANT_ID)).thenReturn(merchant());
|
|
|
+ when(refundMapper.selectOne(any())).thenReturn(null);
|
|
|
+ when(orderMapper.selectForUpdate(ORDER_ID, TENANT)).thenReturn(o);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_ID)).thenReturn(0L);
|
|
|
+ when(refundMapper.insert(any())).thenReturn(1);
|
|
|
+ when(refundMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+ when(client.refundOrder(any(), eq("UP5"), anyString(), anyLong(), anyString()))
|
|
|
+ .thenReturn(json("{\"state\":0}"));
|
|
|
+
|
|
|
+ OffsetDateTime before = OffsetDateTime.now();
|
|
|
+ UnifiedRefundEntity result = realService.createRefund(command(), TENANT, null,
|
|
|
+ UnifiedSource.OPENAPI);
|
|
|
+
|
|
|
+ assertEquals("0", result.getState());
|
|
|
+ assertNotNull(result.getNextQueryTime(), "state=0 必须安排下一次查询");
|
|
|
+ assertTrue(result.getNextQueryTime().isAfter(before.plusSeconds(3))
|
|
|
+ && result.getNextQueryTime().isBefore(before.plusSeconds(8)),
|
|
|
+ "queryCount=1 应走退避首档 5s,实测: " + result.getNextQueryTime());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 来源与必填闸门 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void cashierSource_isRejectedForRefund() {
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createRefund(command(), TENANT, null, UnifiedSource.CASHIER));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(orderService, client, tx);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingOutRefundNo_isRejected() {
|
|
|
+ UnifiedRefundCreateCommand cmd = command();
|
|
|
+ cmd.setOutRefundNo(" ");
|
|
|
+
|
|
|
+ BusinessException e = assertThrows(BusinessException.class,
|
|
|
+ () -> service.createRefund(cmd, TENANT, null, UnifiedSource.OPENAPI));
|
|
|
+
|
|
|
+ assertEquals(400, e.getCode(), "实测消息: " + e.getMessage());
|
|
|
+ verifyNoInteractions(orderService, client, tx);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 查询语义(Task 13 依赖) ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findByOutRefundNo_nullTenant_returnsNull() {
|
|
|
+ // (tenant_id, out_refund_no) 只是租户内唯一;无租户时不能 selectOne 猜一行
|
|
|
+ assertEquals(null, realService.findByOutRefundNo(null, "R1"));
|
|
|
+ verifyNoInteractions(refundMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void findPendingForPoll_filtersAndClampsLimit() {
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(refund("0", 100L)));
|
|
|
+
|
|
|
+ List<UnifiedRefundEntity> rows = realService.findPendingForPoll(5000);
|
|
|
+
|
|
|
+ assertEquals(1, rows.size());
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper<UnifiedRefundEntity>> w =
|
|
|
+ ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(refundMapper).selectList(w.capture());
|
|
|
+ String sql = w.getValue().getSqlSegment();
|
|
|
+ for (String fragment : List.of("state IN", "next_query_time <=", "created_time >",
|
|
|
+ "query_count <", "ORDER BY next_query_time ASC", "LIMIT 500")) {
|
|
|
+ assertTrue(sql.contains(fragment), "退款轮询查询缺片段 " + fragment + ",实测: " + sql);
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|