Przeglądaj źródła

by gateway:
1. 完善部分 refund 单元测试

zhijiantianya@gmail.com 1 rok temu
rodzic
commit
e27ec2fd50

+ 55 - 54
yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java

@@ -1,7 +1,6 @@
 package cn.iocoder.yudao.module.pay.service.refund;
 
-import cn.hutool.core.date.DateUtil;
-import cn.hutool.core.util.RandomUtil;
+import cn.hutool.extra.spring.SpringUtil;
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
 import cn.iocoder.yudao.framework.pay.core.client.PayClient;
 import cn.iocoder.yudao.framework.pay.core.client.PayClientFactory;
@@ -16,9 +15,9 @@ import cn.iocoder.yudao.module.pay.convert.refund.PayRefundConvert;
 import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
 import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
 import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
-import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO;
 import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
 import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper;
+import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
 import cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants;
 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
@@ -35,12 +34,11 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.validation.annotation.Validated;
 
 import javax.annotation.Resource;
-import java.time.LocalDateTime;
 import java.util.List;
 
 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
 import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
-import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.CHANNEL_NOT_FOUND;
+import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
 
 /**
  * 退款订单 Service 实现类
@@ -60,6 +58,8 @@ public class PayRefundServiceImpl implements PayRefundService {
 
     @Resource
     private PayRefundMapper refundMapper;
+    @Resource
+    private PayNoRedisDAO noRedisDAO;
 
     @Resource
     private PayOrderService orderService;
@@ -112,8 +112,9 @@ public class PayRefundServiceImpl implements PayRefundService {
         }
 
         // 2.1 插入退款单
+        String no = noRedisDAO.generate(payProperties.getRefundNoPrefix());
         refund = PayRefundConvert.INSTANCE.convert(reqDTO)
-                .setNo(generateRefundNo()).setOrderId(order.getId())
+                .setNo(no).setOrderId(order.getId())
                 .setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode())
                 // 商户相关的字段
                 .setNotifyUrl(app.getRefundNotifyUrl())
@@ -123,20 +124,27 @@ public class PayRefundServiceImpl implements PayRefundService {
                 .setStatus(PayRefundStatusEnum.WAITING.getStatus())
                 .setPayPrice(order.getPrice()).setRefundPrice(reqDTO.getPrice());
         refundMapper.insert(refund);
-        // 2.2 向渠道发起退款申请
-        PayOrderExtensionDO orderExtension = orderService.getOrderExtension(order.getExtensionId());
-        PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO()
-                .setPayPrice(order.getPrice())
-                .setRefundPrice(reqDTO.getPrice())
-                .setOutTradeNo(orderExtension.getNo())
-                .setOutRefundNo(refund.getNo())
-                .setNotifyUrl(genChannelRefundNotifyUrl(channel))
-                .setReason(reqDTO.getReason());
-        PayRefundRespDTO refundRespDTO = client.unifiedRefund(unifiedReqDTO);
-        // 2.3 处理退款返回
-        notifyRefund(channel, refundRespDTO);
+        try {
+            // 2.2 向渠道发起退款申请
+            PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO()
+                    .setPayPrice(order.getPrice())
+                    .setRefundPrice(reqDTO.getPrice())
+                    .setOutTradeNo(order.getNo())
+                    .setOutRefundNo(refund.getNo())
+                    .setNotifyUrl(genChannelRefundNotifyUrl(channel))
+                    .setReason(reqDTO.getReason());
+            PayRefundRespDTO refundRespDTO = client.unifiedRefund(unifiedReqDTO);
+            // 2.3 处理退款返回
+            getSelf().notifyRefund(channel, refundRespDTO);
+        } catch (Throwable e) {
+            // 注意:这里仅打印异常,不进行抛出。
+            // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。这个结果,后续通过退款回调、或者退款轮询补偿可以拿到。
+            // 最终,在异常的情况下,支付中心会异步回调业务的退款回调接口,提供退款结果
+            log.error("[createPayRefund][退款 id({}) requestDTO({}) 发生异常]",
+                    refund.getId(), reqDTO, e);
+        }
 
-        // 成功在 退款回调中处理
+        // 返回退款编号
         return refund.getId();
     }
 
@@ -151,12 +159,12 @@ public class PayRefundServiceImpl implements PayRefundService {
         if (order == null) {
             throw exception(ErrorCodeConstants.ORDER_NOT_FOUND);
         }
-        // 校验状态,必须是支付状态
-        if (!PayOrderStatusEnum.SUCCESS.getStatus().equals(order.getStatus())) {
-            throw exception(ErrorCodeConstants.ORDER_STATUS_IS_NOT_SUCCESS);
+        // 校验状态,必须是已支付、或者已退款
+        if (!PayOrderStatusEnum.isSuccessOrRefund(order.getStatus())) {
+            throw exception(ORDER_REFUND_FAIL_STATUS_ERROR);
         }
 
-        // 校验金额 退款金额不能大于原定的金额
+        // 校验金额退款金额不能大于原定的金额
         if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()){
             throw exception(ErrorCodeConstants.REFUND_PRICE_EXCEED);
         }
@@ -178,38 +186,22 @@ public class PayRefundServiceImpl implements PayRefundService {
         return payProperties.getRefundNotifyUrl() + "/" + channel.getId();
     }
 
-    private String generateRefundNo() {
-//    wx
-//    2014
-//    10
-//    27
-//    20
-//    09
-//    39
-//    5522657
-//    a690389285100
-        // 目前的算法
-        // 时间序列,年月日时分秒 14 位
-        // 纯随机,6 位 TODO 芋艿:此处估计是会有问题的,后续在调整
-        return DateUtil.format(LocalDateTime.now(), "yyyyMMddHHmmss") + // 时间序列
-                RandomUtil.randomInt(100000, 999999) // 随机。为什么是这个范围,因为偷懒
-                ;
-    }
-
     @Override
     public void notifyRefund(Long channelId, PayRefundRespDTO notify) {
-        // 校验支付渠道是否有效
-        channelService.validPayChannel(channelId);
-        // 通知结果
-
         // 校验支付渠道是否有效
         PayChannelDO channel = channelService.validPayChannel(channelId);
         // 更新退款订单
-        TenantUtils.execute(channel.getTenantId(), () -> notifyRefund(channel, notify));
+        TenantUtils.execute(channel.getTenantId(), () -> getSelf().notifyRefund(channel, notify));
     }
 
-    // TODO 芋艿:事务问题
-    private void notifyRefund(PayChannelDO channel, PayRefundRespDTO notify) {
+    /**
+     * 通知并更新订单的退款结果
+     *
+     * @param channel 支付渠道
+     * @param notify 通知
+     */
+    @Transactional(rollbackFor = Exception.class)  // 注意,如果是方法内调用该方法,需要通过 getSelf().notifyRefund(channel, notify) 调用,否则事务不生效
+    public void notifyRefund(PayChannelDO channel, PayRefundRespDTO notify) {
         // 情况一:退款成功
         if (PayRefundStatusRespEnum.isSuccess(notify.getStatus())) {
             notifyRefundSuccess(channel, notify);
@@ -226,14 +218,14 @@ public class PayRefundServiceImpl implements PayRefundService {
         PayRefundDO refund = refundMapper.selectByAppIdAndNo(
                 channel.getAppId(), notify.getOutRefundNo());
         if (refund == null) {
-            throw exception(ErrorCodeConstants.REFUND_NOT_FOUND);
+            throw exception(REFUND_NOT_FOUND);
         }
         if (PayRefundStatusEnum.isSuccess(refund.getStatus())) { // 如果已经是成功,直接返回,不用重复更新
             log.info("[notifyRefundSuccess][退款订单({}) 已经是退款成功,无需更新]", refund.getId());
             return;
         }
         if (!PayRefundStatusEnum.WAITING.getStatus().equals(refund.getStatus())) {
-            throw exception(ErrorCodeConstants.REFUND_STATUS_IS_NOT_WAITING);
+            throw exception(REFUND_STATUS_IS_NOT_WAITING);
         }
         // 1.2 更新 PayRefundDO
         PayRefundDO updateRefundObj = new PayRefundDO()
@@ -243,7 +235,7 @@ public class PayRefundServiceImpl implements PayRefundService {
                 .setChannelNotifyData(toJsonString(notify));
         int updateCounts = refundMapper.updateByIdAndStatus(refund.getId(), refund.getStatus(), updateRefundObj);
         if (updateCounts == 0) { // 校验状态,必须是等待状态
-            throw exception(ErrorCodeConstants.REFUND_STATUS_IS_NOT_WAITING);
+            throw exception(REFUND_STATUS_IS_NOT_WAITING);
         }
         log.info("[notifyRefundSuccess][退款订单({}) 更新为退款成功]", refund.getId());
 
@@ -261,14 +253,14 @@ public class PayRefundServiceImpl implements PayRefundService {
         PayRefundDO refund = refundMapper.selectByAppIdAndNo(
                 channel.getAppId(), notify.getOutRefundNo());
         if (refund == null) {
-            throw exception(ErrorCodeConstants.REFUND_NOT_FOUND);
+            throw exception(REFUND_NOT_FOUND);
         }
         if (PayRefundStatusEnum.isFailure(refund.getStatus())) { // 如果已经是成功,直接返回,不用重复更新
             log.info("[notifyRefundSuccess][退款订单({}) 已经是退款关闭,无需更新]", refund.getId());
             return;
         }
         if (!PayRefundStatusEnum.WAITING.getStatus().equals(refund.getStatus())) {
-            throw exception(ErrorCodeConstants.REFUND_STATUS_IS_NOT_WAITING);
+            throw exception(REFUND_STATUS_IS_NOT_WAITING);
         }
         // 1.2 更新 PayRefundDO
         PayRefundDO updateRefundObj = new PayRefundDO()
@@ -278,7 +270,7 @@ public class PayRefundServiceImpl implements PayRefundService {
                 .setChannelErrorCode(notify.getChannelErrorCode()).setChannelErrorMsg(notify.getChannelErrorMsg());
         int updateCounts = refundMapper.updateByIdAndStatus(refund.getId(), refund.getStatus(), updateRefundObj);
         if (updateCounts == 0) { // 校验状态,必须是等待状态
-            throw exception(ErrorCodeConstants.REFUND_STATUS_IS_NOT_WAITING);
+            throw exception(REFUND_STATUS_IS_NOT_WAITING);
         }
         log.info("[notifyRefundFailure][退款订单({}) 更新为退款失败]", refund.getId());
 
@@ -287,4 +279,13 @@ public class PayRefundServiceImpl implements PayRefundService {
                 .type(PayNotifyTypeEnum.REFUND.getType()).dataId(refund.getId()).build());
     }
 
+    /**
+     * 获得自身的代理对象,解决 AOP 生效问题
+     *
+     * @return 自己
+     */
+    private PayRefundServiceImpl getSelf() {
+        return SpringUtil.getBean(getClass());
+    }
+
 }

+ 233 - 11
yudao-module-pay/yudao-module-pay-biz/src/test/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceTest.java

@@ -30,7 +30,6 @@ import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
 import cn.iocoder.yudao.module.pay.service.notify.dto.PayNotifyTaskCreateReqDTO;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentMatcher;
 import org.mockito.MockedStatic;
 import org.springframework.boot.test.mock.mockito.MockBean;
 import org.springframework.context.annotation.Import;
@@ -44,8 +43,7 @@ import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString
 import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId;
 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
-import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
-import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
 import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -85,6 +83,51 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
         when(properties.getOrderNotifyUrl()).thenReturn("http://127.0.0.1");
     }
 
+    @Test
+    public void testGetOrder_id() {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order = randomPojo(PayOrderDO.class);
+        orderMapper.insert(order);
+        // 准备参数
+        Long id = order.getId();
+
+        // 调用
+        PayOrderDO dbOrder = orderService.getOrder(id);
+        // 断言
+        assertPojoEquals(dbOrder, order);
+    }
+
+    @Test
+    public void testGetOrder_appIdAndMerchantOrderId() {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order = randomPojo(PayOrderDO.class);
+        orderMapper.insert(order);
+        // 准备参数
+        Long appId = order.getAppId();
+        String merchantOrderId = order.getMerchantOrderId();
+
+        // 调用
+        PayOrderDO dbOrder = orderService.getOrder(appId, merchantOrderId);
+        // 断言
+        assertPojoEquals(dbOrder, order);
+    }
+
+    @Test
+    public void testGetOrderCountByAppId() {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order01 = randomPojo(PayOrderDO.class);
+        orderMapper.insert(order01);
+        PayOrderDO order02 = randomPojo(PayOrderDO.class);
+        orderMapper.insert(order02);
+        // 准备参数
+        Long appId = order01.getAppId();
+
+        // 调用
+        Long count = orderService.getOrderCountByAppId(appId);
+        // 断言
+        assertEquals(count, 1L);
+    }
+
     @Test
     public void testGetOrderPage() {
         // mock 数据
@@ -350,7 +393,7 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
             // mock 方法(client)
             PayClient client = mock(PayClient.class);
             when(payClientFactory.getPayClient(eq(10L))).thenReturn(client);
-            // mock 方法()
+            // mock 方法(支付渠道的调用
             PayOrderRespDTO unifiedOrderResp = randomPojo(PayOrderRespDTO.class, o -> o.setChannelErrorCode(null).setChannelErrorMsg(null)
                     .setDisplayMode(PayOrderDisplayModeEnum.URL.getMode()).setDisplayContent("tudou"));
             when(client.unifiedOrder(argThat(payOrderUnifiedReqDTO -> {
@@ -553,14 +596,193 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
         assertPojoEquals(order, orderMapper.selectOne(null),
                 "updateTime", "updater");
         // 断言,调用
-        verify(notifyService).createPayNotifyTask(argThat(new ArgumentMatcher<PayNotifyTaskCreateReqDTO>() {
-            @Override
-            public boolean matches(PayNotifyTaskCreateReqDTO reqDTO) {
-                assertEquals(reqDTO.getType(), PayNotifyTypeEnum.ORDER.getType());
-                assertEquals(reqDTO.getDataId(), orderExtension.getOrderId());
-                return true;
-            }
+        verify(notifyService).createPayNotifyTask(argThat(reqDTO -> {
+            assertEquals(reqDTO.getType(), PayNotifyTypeEnum.ORDER.getType());
+            assertEquals(reqDTO.getDataId(), orderExtension.getOrderId());
+            return true;
         }));
     }
 
+    @Test
+    public void testNotifyOrderClosed_orderExtension_notFound() {
+        // 准备参数
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L));
+        PayOrderRespDTO notify = randomPojo(PayOrderRespDTO.class,
+                o -> o.setStatus(PayOrderStatusRespEnum.CLOSED.getStatus()));
+
+        // 调用,并断言异常
+        assertServiceException(() -> orderService.notifyOrder(channel, notify),
+                ORDER_EXTENSION_NOT_FOUND);
+    }
+
+    @Test
+    public void testNotifyOrderClosed_orderExtension_closed() {
+        // mock 数据(PayOrderExtensionDO)
+        PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class,
+                o -> o.setStatus(PayOrderStatusEnum.CLOSED.getStatus())
+                        .setNo("P110"));
+        orderExtensionMapper.insert(orderExtension);
+        // 准备参数
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L));
+        PayOrderRespDTO notify = randomPojo(PayOrderRespDTO.class,
+                o -> o.setStatus(PayOrderStatusRespEnum.CLOSED.getStatus())
+                        .setOutTradeNo("P110"));
+
+        // 调用,并断言
+        orderService.notifyOrder(channel, notify);
+        // 断言 PayOrderExtensionDO :数据未更新,因为它是 CLOSED
+        assertPojoEquals(orderExtension, orderExtensionMapper.selectOne(null));
+    }
+
+    @Test
+    public void testNotifyOrderClosed_orderExtension_paid() {
+        // mock 数据(PayOrderExtensionDO)
+        PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class,
+                o -> o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus())
+                        .setNo("P110"));
+        orderExtensionMapper.insert(orderExtension);
+        // 准备参数
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L));
+        PayOrderRespDTO notify = randomPojo(PayOrderRespDTO.class,
+                o -> o.setStatus(PayOrderStatusRespEnum.CLOSED.getStatus())
+                        .setOutTradeNo("P110"));
+
+        // 调用,并断言
+        orderService.notifyOrder(channel, notify);
+        // 断言 PayOrderExtensionDO :数据未更新,因为它是 SUCCESS
+        assertPojoEquals(orderExtension, orderExtensionMapper.selectOne(null));
+    }
+
+    @Test
+    public void testNotifyOrderClosed_orderExtension_refund() {
+        // mock 数据(PayOrderExtensionDO)
+        PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class,
+                o -> o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
+                        .setNo("P110"));
+        orderExtensionMapper.insert(orderExtension);
+        // 准备参数
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L));
+        PayOrderRespDTO notify = randomPojo(PayOrderRespDTO.class,
+                o -> o.setStatus(PayOrderStatusRespEnum.CLOSED.getStatus())
+                        .setOutTradeNo("P110"));
+
+        // 调用,并断言异常
+        assertServiceException(() -> orderService.notifyOrder(channel, notify),
+                ORDER_EXTENSION_STATUS_IS_NOT_WAITING);
+    }
+
+    @Test
+    public void testNotifyOrderClosed_orderExtension_waiting() {
+        // mock 数据(PayOrderExtensionDO)
+        PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class,
+                o -> o.setStatus(PayOrderStatusEnum.WAITING.getStatus())
+                        .setNo("P110"));
+        orderExtensionMapper.insert(orderExtension);
+        // 准备参数
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L));
+        PayOrderRespDTO notify = randomPojo(PayOrderRespDTO.class,
+                o -> o.setStatus(PayOrderStatusRespEnum.CLOSED.getStatus())
+                        .setOutTradeNo("P110"));
+
+        // 调用
+        orderService.notifyOrder(channel, notify);
+        // 断言 PayOrderExtensionDO
+        orderExtension.setStatus(PayOrderStatusEnum.CLOSED.getStatus()).setChannelNotifyData(toJsonString(notify))
+                .setChannelErrorCode(notify.getChannelErrorCode()).setChannelErrorMsg(notify.getChannelErrorMsg());
+        assertPojoEquals(orderExtension, orderExtensionMapper.selectOne(null),
+                "updateTime", "updater");
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_notFound() {
+        // 准备参数
+        Long id = randomLongId();
+        Integer incrRefundPrice = randomInteger();
+
+        // 调用,并断言异常
+        assertServiceException(() -> orderService.updateOrderRefundPrice(id, incrRefundPrice),
+                ORDER_NOT_FOUND);
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_waiting() {
+        testUpdateOrderRefundPrice_waitingOrClosed(PayOrderStatusEnum.WAITING.getStatus());
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_closed() {
+        testUpdateOrderRefundPrice_waitingOrClosed(PayOrderStatusEnum.CLOSED.getStatus());
+    }
+
+    private void testUpdateOrderRefundPrice_waitingOrClosed(Integer status) {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order = randomPojo(PayOrderDO.class,
+                o -> o.setStatus(status));
+        orderMapper.insert(order);
+        // 准备参数
+        Long id = order.getId();
+        Integer incrRefundPrice = randomInteger();
+
+        // 调用,并断言异常
+        assertServiceException(() -> orderService.updateOrderRefundPrice(id, incrRefundPrice),
+                ORDER_REFUND_FAIL_STATUS_ERROR);
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_priceExceed() {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order = randomPojo(PayOrderDO.class,
+                o -> o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus())
+                        .setRefundPrice(1).setPrice(10));
+        orderMapper.insert(order);
+        // 准备参数
+        Long id = order.getId();
+        Integer incrRefundPrice = 10;
+
+        // 调用,并断言异常
+        assertServiceException(() -> orderService.updateOrderRefundPrice(id, incrRefundPrice),
+                REFUND_PRICE_EXCEED);
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_refund() {
+        testUpdateOrderRefundPrice_refundOrSuccess(PayOrderStatusEnum.REFUND.getStatus());
+    }
+
+    @Test
+    public void testUpdateOrderRefundPrice_success() {
+        testUpdateOrderRefundPrice_refundOrSuccess(PayOrderStatusEnum.SUCCESS.getStatus());
+    }
+
+    private void testUpdateOrderRefundPrice_refundOrSuccess(Integer status) {
+        // mock 数据(PayOrderDO)
+        PayOrderDO order = randomPojo(PayOrderDO.class,
+                o -> o.setStatus(status).setRefundPrice(1).setPrice(10));
+        orderMapper.insert(order);
+        // 准备参数
+        Long id = order.getId();
+        Integer incrRefundPrice = 8;
+
+        // 调用
+        orderService.updateOrderRefundPrice(id, incrRefundPrice);
+        // 断言
+        order.setRefundPrice(9).setStatus(PayOrderStatusEnum.REFUND.getStatus());
+        assertPojoEquals(order, orderMapper.selectOne(null),
+                "updateTime", "updater");
+    }
+
+    @Test
+    public void testGetOrderExtension() {
+        // mock 数据(PayOrderExtensionDO)
+        PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class);
+        orderExtensionMapper.insert(orderExtension);
+        // 准备参数
+        Long id = orderExtension.getId();
+
+        // 调用
+        PayOrderExtensionDO dbOrderExtension = orderService.getOrderExtension(id);
+        // 断言
+        assertPojoEquals(dbOrderExtension, orderExtension);
+    }
+
 }

+ 198 - 41
yudao-module-pay/yudao-module-pay-biz/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java

@@ -1,14 +1,19 @@
 package cn.iocoder.yudao.module.pay.service.refund;
 
 import cn.iocoder.yudao.framework.common.pojo.PageResult;
+import cn.iocoder.yudao.framework.pay.core.client.PayClient;
 import cn.iocoder.yudao.framework.pay.core.client.PayClientFactory;
 import cn.iocoder.yudao.framework.pay.core.enums.channel.PayChannelEnum;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest;
 import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
-import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundExportReqVO;
+import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO;import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundExportReqVO;
 import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO;
+import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
+import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
+import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
 import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
 import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper;
+import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
 import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
@@ -24,12 +29,25 @@ import javax.annotation.Resource;
 import java.time.LocalDateTime;
 import java.util.List;
 
+import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
+import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime;
 import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId;
 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
+import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
+import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
+import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
-@Import(PayRefundServiceImpl.class)
+/**
+ * {@link PayRefundServiceImpl} 的单元测试类
+ *
+ * @author 芋艿
+ */
+@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class})
 public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
 
     @Resource
@@ -56,45 +74,41 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
         // mock 数据
         PayRefundDO dbRefund = randomPojo(PayRefundDO.class, o -> { // 等会查询到
             o.setAppId(1L);
-            o.setChannelId(1L);
             o.setChannelCode(PayChannelEnum.WX_PUB.getCode());
-            o.setOrderId(1L);
-            o.setNo("OT0000001");
             o.setMerchantOrderId("MOT0000001");
             o.setMerchantRefundId("MRF0000001");
-            o.setNotifyUrl("https://www.cancanzi.com");
             o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus());
-            o.setPayPrice(100);
-            o.setRefundPrice(500);
-            o.setReason("就是想退款了,你有意见吗");
-            o.setUserIp("127.0.0.1");
             o.setChannelOrderNo("CH0000001");
             o.setChannelRefundNo("CHR0000001");
-            o.setChannelErrorCode("");
-            o.setChannelErrorMsg("");
-            o.setSuccessTime(LocalDateTime.of(2021, 1, 1, 10, 10, 15));
-            o.setCreateTime(LocalDateTime.of(2021, 1, 1, 10, 10, 10));
-            o.setUpdateTime(LocalDateTime.of(2021, 1, 1, 10, 10, 35));
+            o.setCreateTime(buildTime(2021, 1, 10));
         });
         refundMapper.insert(dbRefund);
         // 测试 appId 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setAppId(2L)));
         // 测试 channelCode 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelCode(PayChannelEnum.ALIPAY_APP.getCode())));
-        // 测试 merchantRefundNo 不匹配
-        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantRefundId("MRF1111112")));
+        // 测试 merchantOrderId 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantOrderId(randomString())));
+        // 测试 merchantRefundId 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantRefundId(randomString())));
+        // 测试 channelOrderNo 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelOrderNo(randomString())));
+        // 测试 channelRefundNo 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelRefundNo(randomString())));
         // 测试 status 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setStatus(PayOrderStatusEnum.WAITING.getStatus())));
         // 测试 createTime 不匹配
-        refundMapper.insert(cloneIgnoreId(dbRefund, o ->
-                o.setCreateTime(LocalDateTime.of(2022, 1, 1, 10, 10, 10))));
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setCreateTime(buildTime(2021, 1, 1))));
         // 准备参数
         PayRefundPageReqVO reqVO = new PayRefundPageReqVO();
         reqVO.setAppId(1L);
         reqVO.setChannelCode(PayChannelEnum.WX_PUB.getCode());
+        reqVO.setMerchantOrderId("MOT0000001");
         reqVO.setMerchantRefundId("MRF0000001");
-        reqVO.setStatus(PayRefundStatusEnum.SUCCESS.getStatus());
-        reqVO.setCreateTime((new LocalDateTime[]{LocalDateTime.of(2021, 1, 1, 10, 10, 10), LocalDateTime.of(2021, 1, 1, 10, 10, 12)}));
+        reqVO.setStatus(PayOrderStatusEnum.SUCCESS.getStatus());
+        reqVO.setChannelOrderNo("CH0000001");
+        reqVO.setChannelRefundNo("CHR0000001");
+        reqVO.setCreateTime(buildBetweenTime(2021, 1, 9, 2021, 1, 11));
 
         // 调用
         PageResult<PayRefundDO> pageResult = refundService.getRefundPage(reqVO);
@@ -109,45 +123,41 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
         // mock 数据
         PayRefundDO dbRefund = randomPojo(PayRefundDO.class, o -> { // 等会查询到
             o.setAppId(1L);
-            o.setChannelId(1L);
             o.setChannelCode(PayChannelEnum.WX_PUB.getCode());
-            o.setOrderId(1L);
-            o.setNo("OT0000001");
             o.setMerchantOrderId("MOT0000001");
             o.setMerchantRefundId("MRF0000001");
-            o.setNotifyUrl("https://www.cancanzi.com");
             o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus());
-            o.setPayPrice(100);
-            o.setRefundPrice(500);
-            o.setReason("就是想退款了,你有意见吗");
-            o.setUserIp("127.0.0.1");
             o.setChannelOrderNo("CH0000001");
             o.setChannelRefundNo("CHR0000001");
-            o.setChannelErrorCode("");
-            o.setChannelErrorMsg("");
-            o.setSuccessTime(LocalDateTime.of(2021, 1, 1, 10, 10, 15));
-            o.setCreateTime(LocalDateTime.of(2021, 1, 1, 10, 10, 10));
-            o.setUpdateTime(LocalDateTime.of(2021, 1, 1, 10, 10, 35));
+            o.setCreateTime(buildTime(2021, 1, 10));
         });
         refundMapper.insert(dbRefund);
         // 测试 appId 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setAppId(2L)));
         // 测试 channelCode 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelCode(PayChannelEnum.ALIPAY_APP.getCode())));
-        // 测试 merchantRefundNo 不匹配
-        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantRefundId("MRF1111112")));
+        // 测试 merchantOrderId 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantOrderId(randomString())));
+        // 测试 merchantRefundId 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setMerchantRefundId(randomString())));
+        // 测试 channelOrderNo 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelOrderNo(randomString())));
+        // 测试 channelRefundNo 不匹配
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setChannelRefundNo(randomString())));
         // 测试 status 不匹配
         refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setStatus(PayOrderStatusEnum.WAITING.getStatus())));
         // 测试 createTime 不匹配
-        refundMapper.insert(cloneIgnoreId(dbRefund, o ->
-                o.setCreateTime(LocalDateTime.of(2022, 1, 1, 10, 10, 10))));
-
+        refundMapper.insert(cloneIgnoreId(dbRefund, o -> o.setCreateTime(buildTime(2021, 1, 1))));
         // 准备参数
         PayRefundExportReqVO reqVO = new PayRefundExportReqVO();
         reqVO.setAppId(1L);
         reqVO.setChannelCode(PayChannelEnum.WX_PUB.getCode());
-        reqVO.setStatus(PayRefundStatusEnum.SUCCESS.getStatus());
-        reqVO.setCreateTime((new LocalDateTime[]{LocalDateTime.of(2021, 1, 1, 10, 10, 10), LocalDateTime.of(2021, 1, 1, 10, 10, 12)}));
+        reqVO.setMerchantOrderId("MOT0000001");
+        reqVO.setMerchantRefundId("MRF0000001");
+        reqVO.setStatus(PayOrderStatusEnum.SUCCESS.getStatus());
+        reqVO.setChannelOrderNo("CH0000001");
+        reqVO.setChannelRefundNo("CHR0000001");
+        reqVO.setCreateTime(buildBetweenTime(2021, 1, 9, 2021, 1, 11));
 
         // 调用
         List<PayRefundDO> list = refundService.getRefundList(reqVO);
@@ -156,4 +166,151 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
         assertPojoEquals(dbRefund, list.get(0));
     }
 
+    @Test
+    public void testCreateRefund_orderNotFound() {
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                ORDER_NOT_FOUND);
+    }
+
+    @Test
+    public void testCreateRefund_orderWaiting() {
+        testCreateRefund_orderWaitingOrClosed(PayOrderStatusEnum.WAITING.getStatus());
+    }
+
+    @Test
+    public void testCreateRefund_orderClosed() {
+        testCreateRefund_orderWaitingOrClosed(PayOrderStatusEnum.CLOSED.getStatus());
+    }
+
+    private void testCreateRefund_orderWaitingOrClosed(Integer status) {
+        // 准备参数
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L).setMerchantOrderId("100"));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+        // mock 数据(order)
+        PayOrderDO order = randomPojo(PayOrderDO.class, o -> o.setStatus(status));
+        when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                ORDER_REFUND_FAIL_STATUS_ERROR);
+    }
+
+    @Test
+    public void testCreateRefund_refundPriceExceed() {
+        // 准备参数
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(10));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+        // mock 数据(order)
+        PayOrderDO order = randomPojo(PayOrderDO.class, o ->
+                o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
+                        .setPrice(10).setRefundPrice(1));
+        when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                REFUND_PRICE_EXCEED);
+    }
+
+    @Test
+    public void testCreateRefund_orderHasRefunding() {
+        // 准备参数
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(10));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+        // mock 数据(order)
+        PayOrderDO order = randomPojo(PayOrderDO.class, o ->
+                o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
+                        .setPrice(10).setRefundPrice(1));
+        when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
+        // mock 数据(refund 在退款中)
+        PayRefundDO refund = randomPojo(PayRefundDO.class, o ->
+                o.setOrderId(order.getId()).setStatus(PayOrderStatusEnum.WAITING.getStatus()));
+        refundMapper.insert(refund);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                REFUND_PRICE_EXCEED);
+    }
+
+    @Test
+    public void testCreateRefund_channelNotFound() {
+        // 准备参数
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+        // mock 数据(order)
+        PayOrderDO order = randomPojo(PayOrderDO.class, o ->
+                o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
+                        .setPrice(10).setRefundPrice(1)
+                        .setChannelId(1L).setChannelCode(PayChannelEnum.ALIPAY_APP.getCode()));
+        when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
+        // mock 方法(channel)
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L)
+                .setCode(PayChannelEnum.ALIPAY_APP.getCode()));
+        when(channelService.validPayChannel(eq(1L))).thenReturn(channel);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                CHANNEL_NOT_FOUND);
+    }
+
+    @Test
+    public void testCreateRefund_refundExists() {
+        // 准备参数
+        PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
+                o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9)
+                        .setReason("测试退款"));
+        // mock 方法(app)
+        PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
+        when(appService.validPayApp(eq(1L))).thenReturn(app);
+        // mock 数据(order)
+        PayOrderDO order = randomPojo(PayOrderDO.class, o ->
+                o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
+                        .setPrice(10).setRefundPrice(1)
+                        .setChannelId(1L).setChannelCode(PayChannelEnum.ALIPAY_APP.getCode()));
+        when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
+        // mock 方法(channel)
+        PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L)
+                .setCode(PayChannelEnum.ALIPAY_APP.getCode()));
+        when(channelService.validPayChannel(eq(1L))).thenReturn(channel);
+        // mock 方法(client)
+        PayClient client = mock(PayClient.class);
+        when(payClientFactory.getPayClient(eq(10L))).thenReturn(client);
+        // mock 数据(refund 已存在)
+        PayRefundDO refund = randomPojo(PayRefundDO.class, o ->
+                o.setOrderId(order.getId()).setStatus(PayOrderStatusEnum.WAITING.getStatus()));
+        refundMapper.insert(refund);
+
+        // 调用,并断言异常
+        assertServiceException(() -> refundService.createPayRefund(reqDTO),
+                REFUND_EXISTS);
+    }
+
+    @Test
+    public void testCreateRefund_invokeException() {
+
+    }
+
+    @Test
+    public void testCreateRefund_invokeSuccess() {
+
+    }
+
 }

+ 6 - 10
yudao-module-pay/yudao-module-pay-biz/src/test/resources/sql/create_tables.sql

@@ -82,29 +82,25 @@ CREATE TABLE IF NOT EXISTS `pay_order_extension` (
 
 CREATE TABLE IF NOT EXISTS `pay_refund` (
     "id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+    `no`           varchar(64)         NOT NULL,
     `app_id`             bigint(20)    NOT NULL,
     `channel_id`         bigint(20)    NOT NULL,
     `channel_code`       varchar(32)   NOT NULL,
     `order_id`           bigint(20)    NOT NULL,
-    `trade_no`           varchar(64)   NOT NULL,
     `merchant_order_id`  varchar(64)   NOT NULL,
-    `merchant_refund_no` varchar(64)   NOT NULL,
+    `merchant_refund_id` varchar(64)   NOT NULL,
     `notify_url`         varchar(1024) NOT NULL,
-    `notify_status`      tinyint(4)    NOT NULL,
     `status`             tinyint(4)    NOT NULL,
-    `type`               tinyint(4)    NOT NULL,
-    `pay_amount`         bigint(20)    NOT NULL,
-    `refund_amount`      bigint(20)    NOT NULL,
+    `pay_price`         bigint(20)    NOT NULL,
+    `refund_price`      bigint(20)    NOT NULL,
     `reason`             varchar(256)  NOT NULL,
     `user_ip`            varchar(50)   NULL     DEFAULT NULL,
     `channel_order_no`   varchar(64)   NOT NULL,
     `channel_refund_no`  varchar(64)   NULL     DEFAULT NULL,
+    `success_time`       datetime(0)   NULL     DEFAULT NULL,
     `channel_error_code` varchar(128)  NULL     DEFAULT NULL,
     `channel_error_msg`  varchar(256)  NULL     DEFAULT NULL,
-    `channel_extras`     varchar(1024) NULL     DEFAULT NULL,
-    `expire_time`        datetime(0)   NULL     DEFAULT NULL,
-    `success_time`       datetime(0)   NULL     DEFAULT NULL,
-    `notify_time`        datetime(0)   NULL     DEFAULT NULL,
+    `channel_notify_data` varchar(1024)  NULL,
     `creator`            varchar(64)   NULL     DEFAULT '',
     `create_time`        datetime(0)   NOT NULL DEFAULT CURRENT_TIMESTAMP,
     `updater`            varchar(64)   NULL     DEFAULT '',