Commit 319a7e1f584862ec0259a01e3947b510da2ca015

Authored by YunaiV
1 parent 2c781729

fix(pay): 校验业务回调绑定并防止钱包退款重复处理

yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImpl.java
... ... @@ -392,6 +392,12 @@ public class AfterSaleServiceImpl implements AfterSaleService {
392 392 if (ObjectUtil.notEqual(afterSale.getStatus(), AfterSaleStatusEnum.WAIT_REFUND.getStatus())) {
393 393 throw exception(AFTER_SALE_REFUND_FAIL_STATUS_NOT_WAIT_REFUND);
394 394 }
  395 + // 1.2 校验退款单编号,与售后单绑定的退款单编号一致。避免其它支付应用、或重新退款前的退款单被用于更新状态
  396 + if (ObjectUtil.notEqual(afterSale.getPayRefundId(), payRefundId)) {
  397 + log.error("[updateAfterSaleRefunded][afterSale({}) 绑定的退款单({}) 与回调的退款单({}) 不匹配,请进行处理!]",
  398 + id, afterSale.getPayRefundId(), payRefundId);
  399 + throw exception(AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  400 + }
395 401  
396 402 // 2. 校验退款单
397 403 PayRefundRespDTO payRefund = validatePayRefund(afterSale, payRefundId);
... ...
yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java
... ... @@ -253,6 +253,12 @@ public class BrokerageWithdrawServiceImpl implements BrokerageWithdrawService {
253 253 log.error("[updateBrokerageWithdrawTransferred][withdraw({}) 转账单不匹配({}),请进行处理!]", withdraw, payTransferId);
254 254 throw exception(BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR);
255 255 }
  256 + // 1.3 校验转账单编号,与提现单绑定的转账单编号一致。避免其它支付应用中,相同商户转账编号、金额的转账单被用于更新状态
  257 + if (ObjectUtil.notEqual(withdraw.getPayTransferId(), payTransferId)) {
  258 + log.error("[updateBrokerageWithdrawTransferred][withdraw({}) 绑定的转账单({}) 与回调的转账单({}) 不匹配,请进行处理!]",
  259 + id, withdraw.getPayTransferId(), payTransferId);
  260 + throw exception(BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR);
  261 + }
256 262  
257 263 // 2. 校验转账单的合法性
258 264 PayTransferRespDTO payTransfer = validateBrokerageTransferStatusCanUpdate(withdraw, payTransferId);
... ...
yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java
... ... @@ -299,6 +299,12 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
299 299 id, payOrderId, JsonUtils.toJsonString(order));
300 300 throw exception(ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR);
301 301 }
  302 + // 1.3 校验支付单号,与订单绑定的支付单号一致。避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单
  303 + if (ObjectUtil.notEqual(order.getPayOrderId(), payOrderId)) {
  304 + log.error("[updateOrderPaid][order({}) 绑定的支付单({}) 与回调的支付单({}) 不匹配,请进行处理!]",
  305 + id, order.getPayOrderId(), payOrderId);
  306 + throw exception(ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR);
  307 + }
302 308  
303 309 // 2. 校验支付订单的合法性
304 310 PayOrderRespDTO payOrder = validatePayOrderPaid(order, payOrderId);
... ...
yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImplUnitTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.trade.service.aftersale;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi;
  6 +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
  7 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
  8 +import cn.iocoder.yudao.module.trade.dal.dataobject.aftersale.AfterSaleDO;
  9 +import cn.iocoder.yudao.module.trade.dal.mysql.aftersale.AfterSaleMapper;
  10 +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleOperateTypeEnum;
  11 +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleStatusEnum;
  12 +import cn.iocoder.yudao.module.trade.framework.aftersale.core.utils.AfterSaleLogUtils;
  13 +import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService;
  14 +import org.junit.jupiter.api.AfterEach;
  15 +import org.junit.jupiter.api.BeforeEach;
  16 +import org.junit.jupiter.api.Test;
  17 +import org.junit.jupiter.params.ParameterizedTest;
  18 +import org.junit.jupiter.params.provider.Arguments;
  19 +import org.junit.jupiter.params.provider.MethodSource;
  20 +import org.junit.jupiter.params.provider.NullSource;
  21 +import org.junit.jupiter.params.provider.ValueSource;
  22 +import org.mockito.InjectMocks;
  23 +import org.mockito.Mock;
  24 +import org.mockito.MockedStatic;
  25 +
  26 +import java.util.Objects;
  27 +import java.util.function.UnaryOperator;
  28 +import java.util.stream.Stream;
  29 +
  30 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  31 +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*;
  32 +import static org.mockito.ArgumentMatchers.argThat;
  33 +import static org.mockito.ArgumentMatchers.eq;
  34 +import static org.mockito.Mockito.*;
  35 +
  36 +/**
  37 + * {@link AfterSaleServiceImpl} 的单元测试类
  38 + *
  39 + * 重点覆盖 {@link AfterSaleServiceImpl#updateAfterSaleRefunded(Long, Long, Long)}:回调传入的退款单编号,
  40 + * 必须等于售后单绑定的退款单编号,避免其它支付应用、或重新退款前的退款单被用于更新状态
  41 + *
  42 + * @author 芋道源码
  43 + */
  44 +public class AfterSaleServiceImplUnitTest extends BaseMockitoUnitTest {
  45 +
  46 + private static final long AFTER_SALE_ID = 5L;
  47 + private static final long ORDER_ID = 1L;
  48 + private static final long ORDER_ITEM_ID = 50L;
  49 + /**
  50 + * 售后单绑定的退款单编号
  51 + */
  52 + private static final long PAY_REFUND_ID = 500L;
  53 + /**
  54 + * 其它退款单编号,例如说其它支付应用中,相同商户退款编号、金额的退款单
  55 + */
  56 + private static final long OTHER_PAY_REFUND_ID = 600L;
  57 + private static final int REFUND_PRICE = 300;
  58 +
  59 + @InjectMocks
  60 + private AfterSaleServiceImpl afterSaleService;
  61 +
  62 + @Mock
  63 + private AfterSaleMapper tradeAfterSaleMapper;
  64 + @Mock
  65 + private PayRefundApi payRefundApi;
  66 + @Mock
  67 + private TradeOrderUpdateService tradeOrderUpdateService;
  68 +
  69 + private MockedStatic<AfterSaleLogUtils> afterSaleLogUtilsMockedStatic;
  70 +
  71 + @BeforeEach
  72 + public void setUp() {
  73 + // mock 售后操作日志的上下文,用于断言是否记录日志
  74 + afterSaleLogUtilsMockedStatic = mockStatic(AfterSaleLogUtils.class);
  75 + }
  76 +
  77 + @AfterEach
  78 + public void tearDown() {
  79 + afterSaleLogUtilsMockedStatic.close();
  80 + }
  81 +
  82 + @ParameterizedTest
  83 + @NullSource // 绑定的退款单编号尚未回写
  84 + @ValueSource(longs = PAY_REFUND_ID) // 绑定的是其它退款单
  85 + public void testUpdateAfterSaleRefunded_bindingMismatch(Long boundPayRefundId) {
  86 + // mock 数据:待退款的售后单
  87 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, boundPayRefundId);
  88 +
  89 + // 调用,并断言异常:在查询退款单前失败
  90 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, OTHER_PAY_REFUND_ID),
  91 + AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  92 + assertNoSideEffect();
  93 + }
  94 +
  95 + @Test
  96 + public void testUpdateAfterSaleRefunded_refundSuccess() {
  97 + // mock 数据:待退款的售后单,绑定的退款单与回调一致
  98 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID);
  99 + // mock 方法
  100 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund());
  101 + when(tradeAfterSaleMapper.updateByIdAndStatus(eq(AFTER_SALE_ID), eq(AfterSaleStatusEnum.WAIT_REFUND.getStatus()),
  102 + argThat(update -> Objects.equals(update.getStatus(), AfterSaleStatusEnum.COMPLETE.getStatus())
  103 + && update.getRefundTime() != null))).thenReturn(1);
  104 +
  105 + // 调用
  106 + afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID);
  107 + // 断言:更新订单项的售后状态;记录售后日志
  108 + verify(tradeOrderUpdateService).updateOrderItemWhenAfterSaleSuccess(ORDER_ITEM_ID, REFUND_PRICE);
  109 + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleInfo(AFTER_SALE_ID,
  110 + AfterSaleStatusEnum.WAIT_REFUND.getStatus(), AfterSaleStatusEnum.COMPLETE.getStatus()));
  111 + }
  112 +
  113 + @Test
  114 + public void testUpdateAfterSaleRefunded_refundFailure() {
  115 + // mock 数据:待退款的售后单,绑定的退款单与回调一致
  116 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID);
  117 + // mock 方法
  118 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund()
  119 + .setStatus(PayRefundStatusEnum.FAILURE.getStatus()));
  120 +
  121 + // 调用
  122 + afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID);
  123 + // 断言:售后单状态不变,只记录退款失败日志
  124 + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID);
  125 + verifyNoMoreInteractions(tradeAfterSaleMapper);
  126 + verifyNoInteractions(tradeOrderUpdateService);
  127 + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleOperateType(
  128 + AfterSaleOperateTypeEnum.SYSTEM_REFUND_FAIL));
  129 + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleInfo(AFTER_SALE_ID,
  130 + AfterSaleStatusEnum.WAIT_REFUND.getStatus(), AfterSaleStatusEnum.WAIT_REFUND.getStatus()));
  131 + }
  132 +
  133 + @Test
  134 + public void testUpdateAfterSaleRefunded_completedRepeat() {
  135 + // mock 数据:已完成的售后单,绑定的退款单与回调一致
  136 + mockAfterSale(AfterSaleStatusEnum.COMPLETE, PAY_REFUND_ID);
  137 +
  138 + // 调用,并断言异常:重复回调仍是原有的状态错误
  139 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID),
  140 + AFTER_SALE_REFUND_FAIL_STATUS_NOT_WAIT_REFUND);
  141 + assertNoSideEffect();
  142 + }
  143 +
  144 + @ParameterizedTest(name = "{0}")
  145 + @MethodSource("invalidPayRefunds")
  146 + public void testUpdateAfterSaleRefunded_payRefundInvalid(String name, UnaryOperator<PayRefundRespDTO> payRefundModifier,
  147 + ErrorCode errorCode) {
  148 + // mock 数据:待退款的售后单,绑定的退款单与回调一致
  149 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID);
  150 + // mock 方法
  151 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(payRefundModifier.apply(buildPayRefund()));
  152 +
  153 + // 调用,并断言异常:绑定一致时,原有的退款单校验仍然执行
  154 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID),
  155 + errorCode);
  156 + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID);
  157 + verifyNoMoreInteractions(tradeAfterSaleMapper);
  158 + verifyNoInteractions(tradeOrderUpdateService);
  159 + afterSaleLogUtilsMockedStatic.verifyNoInteractions();
  160 + }
  161 +
  162 + private static Stream<Arguments> invalidPayRefunds() {
  163 + return Stream.of(
  164 + Arguments.of("退款单不存在", (UnaryOperator<PayRefundRespDTO>) payRefund -> null,
  165 + AFTER_SALE_REFUND_FAIL_REFUND_NOT_FOUND),
  166 + Arguments.of("退款单无退款结果", (UnaryOperator<PayRefundRespDTO>) payRefund -> payRefund
  167 + .setStatus(PayRefundStatusEnum.WAITING.getStatus()), AFTER_SALE_REFUND_FAIL_REFUND_NOT_SUCCESS_OR_FAILURE),
  168 + Arguments.of("退款金额不一致", (UnaryOperator<PayRefundRespDTO>) payRefund -> payRefund
  169 + .setRefundPrice(REFUND_PRICE - 1), AFTER_SALE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH),
  170 + Arguments.of("商户退款编号不一致", (UnaryOperator<PayRefundRespDTO>) payRefund -> payRefund
  171 + .setMerchantRefundId(String.valueOf(AFTER_SALE_ID + 1)), AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR));
  172 + }
  173 +
  174 + private void mockAfterSale(AfterSaleStatusEnum status, Long payRefundId) {
  175 + AfterSaleDO afterSale = new AfterSaleDO().setId(AFTER_SALE_ID).setStatus(status.getStatus())
  176 + .setOrderId(ORDER_ID).setOrderItemId(ORDER_ITEM_ID)
  177 + .setPayRefundId(payRefundId).setRefundPrice(REFUND_PRICE);
  178 + when(tradeAfterSaleMapper.selectById(AFTER_SALE_ID)).thenReturn(afterSale);
  179 + }
  180 +
  181 + private static PayRefundRespDTO buildPayRefund() {
  182 + return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(PayRefundStatusEnum.SUCCESS.getStatus())
  183 + .setRefundPrice(REFUND_PRICE).setMerchantRefundId(String.valueOf(AFTER_SALE_ID));
  184 + }
  185 +
  186 + /**
  187 + * 断言:只查询了售后单,没有查询退款单、更新售后单、更新订单项或记录日志
  188 + */
  189 + private void assertNoSideEffect() {
  190 + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID);
  191 + verifyNoMoreInteractions(tradeAfterSaleMapper);
  192 + verifyNoInteractions(payRefundApi, tradeOrderUpdateService);
  193 + afterSaleLogUtilsMockedStatic.verifyNoInteractions();
  194 + }
  195 +
  196 +}
... ...
yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImplUnitTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.trade.service.brokerage;
  2 +
  3 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  4 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  5 +import cn.iocoder.yudao.module.pay.api.transfer.PayTransferApi;
  6 +import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferRespDTO;
  7 +import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
  8 +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageWithdrawDO;
  9 +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageWithdrawMapper;
  10 +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageWithdrawStatusEnum;
  11 +import org.junit.jupiter.api.Test;
  12 +import org.junit.jupiter.params.ParameterizedTest;
  13 +import org.junit.jupiter.params.provider.*;
  14 +import org.mockito.InjectMocks;
  15 +import org.mockito.Mock;
  16 +
  17 +import java.util.Objects;
  18 +import java.util.function.UnaryOperator;
  19 +import java.util.stream.Stream;
  20 +
  21 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  22 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.PAY_TRANSFER_NOT_FOUND;
  23 +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*;
  24 +import static org.mockito.ArgumentMatchers.argThat;
  25 +import static org.mockito.ArgumentMatchers.eq;
  26 +import static org.mockito.Mockito.*;
  27 +
  28 +/**
  29 + * {@link BrokerageWithdrawServiceImpl} 的单元测试类
  30 + *
  31 + * 重点覆盖 {@link BrokerageWithdrawServiceImpl#updateBrokerageWithdrawTransferred(Long, Long)}:回调传入的转账单编号,
  32 + * 必须等于提现单绑定的转账单编号,避免其它支付应用中,相同商户转账编号、金额的转账单被用于更新状态
  33 + *
  34 + * @author 芋道源码
  35 + */
  36 +public class BrokerageWithdrawServiceImplUnitTest extends BaseMockitoUnitTest {
  37 +
  38 + private static final long WITHDRAW_ID = 3L;
  39 + /**
  40 + * 提现单绑定的转账单编号
  41 + */
  42 + private static final long PAY_TRANSFER_ID = 300L;
  43 + /**
  44 + * 其它转账单编号,例如说其它支付应用中,相同商户转账编号、金额的转账单
  45 + */
  46 + private static final long OTHER_PAY_TRANSFER_ID = 400L;
  47 + private static final int WITHDRAW_PRICE = 1000;
  48 + private static final int WITHDRAW_FEE_PRICE = 100;
  49 + private static final String TRANSFER_CHANNEL_CODE = "wx_lite";
  50 +
  51 + @InjectMocks
  52 + private BrokerageWithdrawServiceImpl brokerageWithdrawService;
  53 +
  54 + @Mock
  55 + private BrokerageWithdrawMapper brokerageWithdrawMapper;
  56 + @Mock
  57 + private PayTransferApi payTransferApi;
  58 +
  59 + @ParameterizedTest
  60 + @NullSource // 绑定的转账单编号尚未回写
  61 + @ValueSource(longs = PAY_TRANSFER_ID) // 绑定的是其它转账单
  62 + public void testUpdateBrokerageWithdrawTransferred_unfinishedBindingMismatch(Long boundPayTransferId) {
  63 + // mock 数据:未结束的提现单
  64 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, boundPayTransferId);
  65 +
  66 + // 调用,并断言异常:在查询转账单前失败
  67 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, OTHER_PAY_TRANSFER_ID),
  68 + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR);
  69 + assertNoSideEffect();
  70 + }
  71 +
  72 + @ParameterizedTest
  73 + @MethodSource("transferResults")
  74 + public void testUpdateBrokerageWithdrawTransferred_success(PayTransferStatusEnum transferStatus,
  75 + BrokerageWithdrawStatusEnum expectedStatus) {
  76 + // mock 数据:未结束的提现单,绑定的转账单与回调一致
  77 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, PAY_TRANSFER_ID);
  78 + // mock 方法
  79 + when(payTransferApi.getTransfer(PAY_TRANSFER_ID)).thenReturn(buildPayTransfer().setStatus(transferStatus.getStatus()));
  80 +
  81 + // 调用
  82 + brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID);
  83 + // 断言:转账成功时提现成功,转账关闭时提现失败
  84 + verify(brokerageWithdrawMapper).updateByIdAndStatus(eq(WITHDRAW_ID), eq(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS.getStatus()),
  85 + argThat(update -> Objects.equals(update.getStatus(), expectedStatus.getStatus())));
  86 + }
  87 +
  88 + private static Stream<Arguments> transferResults() {
  89 + return Stream.of(
  90 + Arguments.of(PayTransferStatusEnum.SUCCESS, BrokerageWithdrawStatusEnum.WITHDRAW_SUCCESS),
  91 + Arguments.of(PayTransferStatusEnum.CLOSED, BrokerageWithdrawStatusEnum.WITHDRAW_FAIL));
  92 + }
  93 +
  94 + @ParameterizedTest
  95 + @EnumSource(value = BrokerageWithdrawStatusEnum.class, names = {"WITHDRAW_SUCCESS", "WITHDRAW_FAIL"})
  96 + public void testUpdateBrokerageWithdrawTransferred_finishedDuplicate(BrokerageWithdrawStatusEnum status) {
  97 + // mock 数据:已结束的提现单
  98 + mockWithdraw(status, PAY_TRANSFER_ID);
  99 +
  100 + // 调用:重复回调,直接返回
  101 + brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID);
  102 + // 断言
  103 + assertNoSideEffect();
  104 + }
  105 +
  106 + @Test
  107 + public void testUpdateBrokerageWithdrawTransferred_finishedBindingMismatch() {
  108 + // mock 数据:已结束的提现单
  109 + mockWithdraw(BrokerageWithdrawStatusEnum.WITHDRAW_SUCCESS, PAY_TRANSFER_ID);
  110 +
  111 + // 调用,并断言异常
  112 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, OTHER_PAY_TRANSFER_ID),
  113 + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR);
  114 + assertNoSideEffect();
  115 + }
  116 +
  117 + @ParameterizedTest(name = "{0}")
  118 + @MethodSource("invalidPayTransfers")
  119 + public void testUpdateBrokerageWithdrawTransferred_payTransferInvalid(String name,
  120 + UnaryOperator<PayTransferRespDTO> payTransferModifier,
  121 + ErrorCode errorCode) {
  122 + // mock 数据:未结束的提现单,绑定的转账单与回调一致
  123 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, PAY_TRANSFER_ID);
  124 + // mock 方法
  125 + when(payTransferApi.getTransfer(PAY_TRANSFER_ID)).thenReturn(payTransferModifier.apply(buildPayTransfer()));
  126 +
  127 + // 调用,并断言异常:绑定一致时,原有的转账单校验仍然执行
  128 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID),
  129 + errorCode);
  130 + verify(brokerageWithdrawMapper).selectById(WITHDRAW_ID);
  131 + verifyNoMoreInteractions(brokerageWithdrawMapper);
  132 + }
  133 +
  134 + private static Stream<Arguments> invalidPayTransfers() {
  135 + return Stream.of(
  136 + Arguments.of("转账单不存在", (UnaryOperator<PayTransferRespDTO>) payTransfer -> null, PAY_TRANSFER_NOT_FOUND),
  137 + Arguments.of("转账单未结束", (UnaryOperator<PayTransferRespDTO>) payTransfer -> payTransfer
  138 + .setStatus(PayTransferStatusEnum.PROCESSING.getStatus()),
  139 + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_STATUS_NOT_SUCCESS_OR_CLOSED),
  140 + Arguments.of("转账金额不一致", (UnaryOperator<PayTransferRespDTO>) payTransfer -> payTransfer
  141 + .setPrice(WITHDRAW_PRICE), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_PRICE_NOT_MATCH),
  142 + Arguments.of("商户转账编号不一致", (UnaryOperator<PayTransferRespDTO>) payTransfer -> payTransfer
  143 + .setMerchantTransferId(String.valueOf(WITHDRAW_ID + 1)), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_MERCHANT_EXISTS),
  144 + Arguments.of("转账渠道不一致", (UnaryOperator<PayTransferRespDTO>) payTransfer -> payTransfer
  145 + .setChannelCode("alipay_pc"), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_CHANNEL_NOT_MATCH));
  146 + }
  147 +
  148 + private void mockWithdraw(BrokerageWithdrawStatusEnum status, Long payTransferId) {
  149 + BrokerageWithdrawDO withdraw = new BrokerageWithdrawDO().setId(WITHDRAW_ID).setStatus(status.getStatus())
  150 + .setPayTransferId(payTransferId).setPrice(WITHDRAW_PRICE).setFeePrice(WITHDRAW_FEE_PRICE)
  151 + .setTransferChannelCode(TRANSFER_CHANNEL_CODE);
  152 + when(brokerageWithdrawMapper.selectById(WITHDRAW_ID)).thenReturn(withdraw);
  153 + }
  154 +
  155 + private static PayTransferRespDTO buildPayTransfer() {
  156 + return new PayTransferRespDTO().setId(PAY_TRANSFER_ID).setStatus(PayTransferStatusEnum.SUCCESS.getStatus())
  157 + .setPrice(WITHDRAW_PRICE - WITHDRAW_FEE_PRICE).setMerchantTransferId(String.valueOf(WITHDRAW_ID))
  158 + .setChannelCode(TRANSFER_CHANNEL_CODE);
  159 + }
  160 +
  161 + /**
  162 + * 断言:只查询了提现单,没有查询转账单或更新提现单
  163 + */
  164 + private void assertNoSideEffect() {
  165 + verify(brokerageWithdrawMapper).selectById(WITHDRAW_ID);
  166 + verifyNoMoreInteractions(brokerageWithdrawMapper);
  167 + verifyNoInteractions(payTransferApi);
  168 + }
  169 +
  170 +}
... ...
yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImplUnitTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.trade.service.order;
  2 +
  3 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
  4 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.pay.api.order.PayOrderApi;
  7 +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderRespDTO;
  8 +import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
  9 +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO;
  10 +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO;
  11 +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderItemMapper;
  12 +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderMapper;
  13 +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum;
  14 +import cn.iocoder.yudao.module.trade.framework.order.core.utils.TradeOrderLogUtils;
  15 +import cn.iocoder.yudao.module.trade.service.order.handler.TradeOrderHandler;
  16 +import org.junit.jupiter.api.AfterEach;
  17 +import org.junit.jupiter.api.BeforeEach;
  18 +import org.junit.jupiter.api.Test;
  19 +import org.junit.jupiter.params.ParameterizedTest;
  20 +import org.junit.jupiter.params.provider.Arguments;
  21 +import org.junit.jupiter.params.provider.MethodSource;
  22 +import org.junit.jupiter.params.provider.NullSource;
  23 +import org.junit.jupiter.params.provider.ValueSource;
  24 +import org.mockito.InjectMocks;
  25 +import org.mockito.Mock;
  26 +import org.mockito.MockedStatic;
  27 +import org.springframework.test.util.ReflectionTestUtils;
  28 +
  29 +import java.util.Collections;
  30 +import java.util.List;
  31 +import java.util.Objects;
  32 +import java.util.function.UnaryOperator;
  33 +import java.util.stream.Stream;
  34 +
  35 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  36 +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*;
  37 +import static org.mockito.ArgumentMatchers.argThat;
  38 +import static org.mockito.ArgumentMatchers.eq;
  39 +import static org.mockito.Mockito.*;
  40 +
  41 +/**
  42 + * {@link TradeOrderUpdateServiceImpl} 的单元测试类
  43 + *
  44 + * 重点覆盖 {@link TradeOrderUpdateServiceImpl#updateOrderPaid(Long, Long)}:回调传入的支付单编号,
  45 + * 必须等于订单绑定的支付单编号,避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单
  46 + *
  47 + * @author 芋道源码
  48 + */
  49 +public class TradeOrderUpdateServiceImplUnitTest extends BaseMockitoUnitTest {
  50 +
  51 + private static final long ORDER_ID = 1L;
  52 + private static final long ORDER_USER_ID = 10L;
  53 + private static final long ORDER_ITEM_ID = 50L;
  54 + /**
  55 + * 订单绑定的支付单编号
  56 + */
  57 + private static final long PAY_ORDER_ID = 100L;
  58 + /**
  59 + * 其它支付单编号,例如说其它支付应用中,相同商户订单编号、金额的支付单
  60 + */
  61 + private static final long OTHER_PAY_ORDER_ID = 200L;
  62 + private static final int ORDER_PAY_PRICE = 500;
  63 + private static final String PAY_CHANNEL_CODE = "wx_lite";
  64 +
  65 + @InjectMocks
  66 + private TradeOrderUpdateServiceImpl tradeOrderUpdateService;
  67 +
  68 + @Mock
  69 + private TradeOrderMapper tradeOrderMapper;
  70 + @Mock
  71 + private TradeOrderItemMapper tradeOrderItemMapper;
  72 + @Mock
  73 + private TradeOrderHandler tradeOrderHandler;
  74 + @Mock
  75 + private PayOrderApi payOrderApi;
  76 +
  77 + private MockedStatic<TradeOrderLogUtils> tradeOrderLogUtilsMockedStatic;
  78 +
  79 + @BeforeEach
  80 + public void setUp() {
  81 + ReflectionTestUtils.setField(tradeOrderUpdateService, "tradeOrderHandlers",
  82 + Collections.singletonList(tradeOrderHandler));
  83 + // mock 订单操作日志的上下文,用于断言是否记录日志
  84 + tradeOrderLogUtilsMockedStatic = mockStatic(TradeOrderLogUtils.class);
  85 + }
  86 +
  87 + @AfterEach
  88 + public void tearDown() {
  89 + tradeOrderLogUtilsMockedStatic.close();
  90 + }
  91 +
  92 + @ParameterizedTest
  93 + @NullSource // 绑定的支付单编号尚未回写
  94 + @ValueSource(longs = PAY_ORDER_ID) // 绑定的是其它支付单
  95 + public void testUpdateOrderPaid_unpaidBindingMismatch(Long boundPayOrderId) {
  96 + // mock 数据:未支付的订单
  97 + mockOrder(TradeOrderStatusEnum.UNPAID, false, boundPayOrderId);
  98 +
  99 + // 调用,并断言异常:在查询支付单前失败
  100 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, OTHER_PAY_ORDER_ID),
  101 + ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR);
  102 + assertNoSideEffect();
  103 + }
  104 +
  105 + @Test
  106 + public void testUpdateOrderPaid_success() {
  107 + // mock 数据:未支付的订单,绑定的支付单与回调一致
  108 + TradeOrderDO order = mockOrder(TradeOrderStatusEnum.UNPAID, false, PAY_ORDER_ID);
  109 + List<TradeOrderItemDO> orderItems = Collections.singletonList(new TradeOrderItemDO().setId(ORDER_ITEM_ID));
  110 + // mock 方法
  111 + when(payOrderApi.getOrder(PAY_ORDER_ID)).thenReturn(buildPayOrder());
  112 + when(tradeOrderMapper.updateByIdAndStatus(eq(ORDER_ID), eq(TradeOrderStatusEnum.UNPAID.getStatus()),
  113 + argThat(update -> Objects.equals(update.getStatus(), TradeOrderStatusEnum.UNDELIVERED.getStatus())
  114 + && Boolean.TRUE.equals(update.getPayStatus()) && update.getPayTime() != null
  115 + && Objects.equals(update.getPayChannelCode(), PAY_CHANNEL_CODE)))).thenReturn(1);
  116 + when(tradeOrderItemMapper.selectListByOrderId(ORDER_ID)).thenReturn(orderItems);
  117 +
  118 + // 调用
  119 + tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID);
  120 + // 断言:执行后置处理;记录订单日志
  121 + verify(tradeOrderHandler).afterPayOrder(order, orderItems);
  122 + tradeOrderLogUtilsMockedStatic.verify(() -> TradeOrderLogUtils.setOrderInfo(ORDER_ID,
  123 + TradeOrderStatusEnum.UNPAID.getStatus(), TradeOrderStatusEnum.UNDELIVERED.getStatus()));
  124 + tradeOrderLogUtilsMockedStatic.verify(() -> TradeOrderLogUtils.setUserInfo(ORDER_USER_ID,
  125 + UserTypeEnum.MEMBER.getValue()));
  126 + }
  127 +
  128 + @Test
  129 + public void testUpdateOrderPaid_paidDuplicate() {
  130 + // mock 数据:已支付的订单
  131 + mockOrder(TradeOrderStatusEnum.UNDELIVERED, true, PAY_ORDER_ID);
  132 +
  133 + // 调用:重复回调,直接返回
  134 + tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID);
  135 + // 断言
  136 + assertNoSideEffect();
  137 + }
  138 +
  139 + @Test
  140 + public void testUpdateOrderPaid_paidBindingMismatch() {
  141 + // mock 数据:已支付的订单
  142 + mockOrder(TradeOrderStatusEnum.UNDELIVERED, true, PAY_ORDER_ID);
  143 +
  144 + // 调用,并断言异常
  145 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, OTHER_PAY_ORDER_ID),
  146 + ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR);
  147 + assertNoSideEffect();
  148 + }
  149 +
  150 + @ParameterizedTest(name = "{0}")
  151 + @MethodSource("invalidPayOrders")
  152 + public void testUpdateOrderPaid_payOrderInvalid(String name, UnaryOperator<PayOrderRespDTO> payOrderModifier,
  153 + ErrorCode errorCode) {
  154 + // mock 数据:未支付的订单,绑定的支付单与回调一致
  155 + mockOrder(TradeOrderStatusEnum.UNPAID, false, PAY_ORDER_ID);
  156 + // mock 方法
  157 + when(payOrderApi.getOrder(PAY_ORDER_ID)).thenReturn(payOrderModifier.apply(buildPayOrder()));
  158 +
  159 + // 调用,并断言异常:绑定一致时,原有的支付单校验仍然执行
  160 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID), errorCode);
  161 + verify(tradeOrderMapper).selectById(ORDER_ID);
  162 + verifyNoMoreInteractions(tradeOrderMapper);
  163 + verifyNoInteractions(tradeOrderItemMapper, tradeOrderHandler);
  164 + tradeOrderLogUtilsMockedStatic.verifyNoInteractions();
  165 + }
  166 +
  167 + private static Stream<Arguments> invalidPayOrders() {
  168 + return Stream.of(
  169 + Arguments.of("支付单不存在", (UnaryOperator<PayOrderRespDTO>) payOrder -> null, ORDER_NOT_FOUND),
  170 + Arguments.of("支付单未支付", (UnaryOperator<PayOrderRespDTO>) payOrder -> payOrder
  171 + .setStatus(PayOrderStatusEnum.WAITING.getStatus()), ORDER_UPDATE_PAID_FAIL_PAY_ORDER_STATUS_NOT_SUCCESS),
  172 + Arguments.of("支付金额不一致", (UnaryOperator<PayOrderRespDTO>) payOrder -> payOrder
  173 + .setPrice(ORDER_PAY_PRICE - 1), ORDER_UPDATE_PAID_FAIL_PAY_PRICE_NOT_MATCH),
  174 + Arguments.of("商户订单编号不一致", (UnaryOperator<PayOrderRespDTO>) payOrder -> payOrder
  175 + .setMerchantOrderId(String.valueOf(ORDER_ID + 1)), ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR));
  176 + }
  177 +
  178 + private TradeOrderDO mockOrder(TradeOrderStatusEnum status, boolean payStatus, Long payOrderId) {
  179 + TradeOrderDO order = new TradeOrderDO().setId(ORDER_ID).setUserId(ORDER_USER_ID)
  180 + .setStatus(status.getStatus()).setPayStatus(payStatus).setPayOrderId(payOrderId)
  181 + .setPayPrice(ORDER_PAY_PRICE);
  182 + when(tradeOrderMapper.selectById(ORDER_ID)).thenReturn(order);
  183 + return order;
  184 + }
  185 +
  186 + private static PayOrderRespDTO buildPayOrder() {
  187 + return new PayOrderRespDTO().setId(PAY_ORDER_ID).setStatus(PayOrderStatusEnum.SUCCESS.getStatus())
  188 + .setPrice(ORDER_PAY_PRICE).setMerchantOrderId(String.valueOf(ORDER_ID)).setChannelCode(PAY_CHANNEL_CODE);
  189 + }
  190 +
  191 + /**
  192 + * 断言:只查询了订单,没有查询支付单、更新订单、执行后置处理或记录日志
  193 + */
  194 + private void assertNoSideEffect() {
  195 + verify(tradeOrderMapper).selectById(ORDER_ID);
  196 + verifyNoMoreInteractions(tradeOrderMapper);
  197 + verifyNoInteractions(payOrderApi, tradeOrderItemMapper, tradeOrderHandler);
  198 + tradeOrderLogUtilsMockedStatic.verifyNoInteractions();
  199 + }
  200 +
  201 +}
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java
... ... @@ -135,6 +135,12 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
135 135 log.error("[updateWalletRechargerPaid][recharge({}) 已支付,但是支付单号不同({}),请进行处理!]", recharge, payOrderId);
136 136 throw exception(WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR);
137 137 }
  138 + // 1.3 校验支付单号,与充值记录绑定的支付单号一致。避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单
  139 + if (ObjectUtil.notEqual(recharge.getPayOrderId(), payOrderId)) {
  140 + log.error("[updateWalletRechargerPaid][recharge({}) 绑定的支付单({}) 与回调的支付单({}) 不匹配,请进行处理!]",
  141 + id, recharge.getPayOrderId(), payOrderId);
  142 + throw exception(WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR);
  143 + }
138 144  
139 145 // 2. 校验支付订单的合法性
140 146 PayOrderDO payOrderDO = validatePayOrderPaid(recharge, payOrderId);
... ... @@ -227,53 +233,73 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
227 233 log.error("[updateWalletRechargerPaid][钱包充值记录不存在,钱包充值记录 id({})]", id);
228 234 throw exception(WALLET_RECHARGE_NOT_FOUND);
229 235 }
230   - // 1.2 校验钱包充值是否可以更新已退款
  236 + // 1.2 校验退款单号,与充值记录绑定的退款单号一致
  237 + if (notEqual(walletRecharge.getPayRefundId(), payRefundId)) {
  238 + log.error("[updateWalletRechargeRefunded][钱包充值({}) 退款单不匹配({}),请进行处理!钱包充值的数据是:{}]",
  239 + walletRecharge.getId(), payRefundId, toJsonString(walletRecharge));
  240 + throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  241 + }
  242 + // 1.3 特殊:已经是退款终态(成功、失败),且退款单号相同,直接返回,说明重复回调
  243 + if (PayRefundStatusEnum.isSuccess(walletRecharge.getRefundStatus())
  244 + || PayRefundStatusEnum.isFailure(walletRecharge.getRefundStatus())) {
  245 + log.warn("[updateWalletRechargeRefunded][钱包充值({}) 已是退款终态({}),且退款单号相同({}),直接返回]",
  246 + id, walletRecharge.getRefundStatus(), payRefundId);
  247 + return;
  248 + }
  249 + // 1.4 校验钱包充值是否可以更新已退款
231 250 PayRefundRespDTO payRefund = validateWalletRechargeCanRefunded(walletRecharge, payRefundId);
232 251  
233 252 // 2. 处理退款结果
234 253 PayWalletRechargeDO updateObj = new PayWalletRechargeDO().setId(id);
  254 + boolean refundSuccess = PayRefundStatusEnum.isSuccess(payRefund.getStatus());
235 255 // 情况一:退款成功
236   - if (PayRefundStatusEnum.isSuccess(payRefund.getStatus())) {
237   - // 2.1 更新钱包余额
238   - payWalletService.reduceWalletBalance(walletRecharge.getWalletId(), id,
239   - PayWalletBizTypeEnum.RECHARGE_REFUND, walletRecharge.getTotalPrice());
240   -
  256 + if (refundSuccess) {
241 257 updateObj.setRefundStatus(SUCCESS.getStatus()).setRefundTime(payRefund.getSuccessTime())
242 258 .setRefundTotalPrice(walletRecharge.getTotalPrice()).setRefundPayPrice(walletRecharge.getPayPrice())
243 259 .setRefundBonusPrice(walletRecharge.getBonusPrice());
244 260 // 情况二:退款失败
245 261 } else if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) {
246   - // 2.2 解冻余额
247   - payWalletService.unfreezePrice(walletRecharge.getWalletId(), walletRecharge.getTotalPrice());
248   -
249 262 updateObj.setRefundStatus(FAILURE.getStatus());
  263 + // 情况三:退款未结束,不更新退款状态,也不操作钱包
  264 + } else {
  265 + log.warn("[updateWalletRechargeRefunded][钱包充值({}) payRefund({}) 退款状态({}) 未结束,直接返回]",
  266 + id, payRefundId, payRefund.getStatus());
  267 + return;
250 268 }
251 269  
252   - // 3. 更新钱包充值的退款字段
253   - walletRechargeMapper.updateByIdAndRefunded(id, WAITING.getStatus(), updateObj);
254   - }
  270 + // 3. 更新钱包充值的退款字段:基于【未退款】状态的乐观锁,保证并发的重复回调,只有一次能操作钱包
  271 + int updateCount = walletRechargeMapper.updateByIdAndRefunded(id, WAITING.getStatus(), updateObj);
  272 + if (updateCount == 0) {
  273 + log.warn("[updateWalletRechargeRefunded][钱包充值({}) payRefund({}) 不是【未退款】状态,可能被并发处理,直接返回]",
  274 + id, payRefundId);
  275 + return;
  276 + }
255 277  
256   - private PayRefundRespDTO validateWalletRechargeCanRefunded(PayWalletRechargeDO walletRecharge, Long payRefundId) {
257   - // 1. 校验退款订单匹配
258   - if (notEqual(walletRecharge.getPayRefundId(), payRefundId)) {
259   - log.error("[validateWalletRechargeCanRefunded][钱包充值({}) 退款单不匹配({}),请进行处理!钱包充值的数据是:{}]",
260   - walletRecharge.getId(), payRefundId, toJsonString(walletRecharge));
261   - throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  278 + // 4. 操作钱包:在同一事务中,钱包操作失败时,回滚上面的退款字段
  279 + if (refundSuccess) {
  280 + // 4.1 退款成功:扣减钱包余额
  281 + payWalletService.reduceWalletBalance(walletRecharge.getWalletId(), id,
  282 + PayWalletBizTypeEnum.RECHARGE_REFUND, walletRecharge.getTotalPrice());
  283 + } else {
  284 + // 4.2 退款失败:解冻余额
  285 + payWalletService.unfreezePrice(walletRecharge.getWalletId(), walletRecharge.getTotalPrice());
262 286 }
  287 + }
263 288  
264   - // 2.1 校验退款订单
  289 + private PayRefundRespDTO validateWalletRechargeCanRefunded(PayWalletRechargeDO walletRecharge, Long payRefundId) {
  290 + // 1. 校验退款订单
265 291 PayRefundRespDTO payRefund = payRefundApi.getRefund(payRefundId);
266 292 if (payRefund == null) {
267 293 log.error("[validateWalletRechargeCanRefunded][payRefund({})不存在]", payRefundId);
268 294 throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_NOT_FOUND);
269 295 }
270   - // 2.2 校验退款金额一致
  296 + // 2. 校验退款金额一致
271 297 if (notEqual(payRefund.getRefundPrice(), walletRecharge.getPayPrice())) {
272 298 log.error("[validateWalletRechargeCanRefunded][钱包({}) payRefund({}) 退款金额不匹配,请进行处理!钱包数据是:{},payRefund 数据是:{}]",
273 299 walletRecharge.getId(), payRefundId, toJsonString(walletRecharge), toJsonString(payRefund));
274 300 throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH);
275 301 }
276   - // 2.3 校验退款订单商户退款单是否匹配
  302 + // 3. 校验退款订单商户退款单是否匹配
277 303 if (notEqual(payRefund.getMerchantRefundId(), walletRecharge.getId() + "-refund")) {
278 304 log.error("[validateWalletRechargeCanRefunded][钱包({}) 退款单不匹配({}),请进行处理!payRefund 数据是:{}]",
279 305 walletRecharge.getId(), payRefundId, toJsonString(payRefund));
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImplUnitTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.pay.service.wallet;
  2 +
  3 +import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.common.exception.ErrorCode;
  5 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  6 +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi;
  7 +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
  8 +import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
  9 +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO;
  10 +import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper;
  11 +import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
  12 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
  13 +import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
  14 +import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
  15 +import org.junit.jupiter.api.AfterEach;
  16 +import org.junit.jupiter.api.BeforeEach;
  17 +import org.junit.jupiter.api.Test;
  18 +import org.junit.jupiter.params.ParameterizedTest;
  19 +import org.junit.jupiter.params.provider.Arguments;
  20 +import org.junit.jupiter.params.provider.EnumSource;
  21 +import org.junit.jupiter.params.provider.MethodSource;
  22 +import org.junit.jupiter.params.provider.NullSource;
  23 +import org.junit.jupiter.params.provider.ValueSource;
  24 +import org.mockito.InOrder;
  25 +import org.mockito.InjectMocks;
  26 +import org.mockito.Mock;
  27 +import org.mockito.MockedStatic;
  28 +
  29 +import java.time.LocalDateTime;
  30 +import java.util.Objects;
  31 +import java.util.function.UnaryOperator;
  32 +import java.util.stream.Stream;
  33 +
  34 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  35 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
  36 +import static org.mockito.ArgumentMatchers.any;
  37 +import static org.mockito.ArgumentMatchers.argThat;
  38 +import static org.mockito.ArgumentMatchers.eq;
  39 +import static org.mockito.Mockito.*;
  40 +
  41 +/**
  42 + * {@link PayWalletRechargeServiceImpl} 的单元测试类
  43 + *
  44 + * 重点覆盖 {@link PayWalletRechargeServiceImpl#updateWalletRechargerPaid(Long, Long)}:回调传入的支付单编号,
  45 + * 必须等于充值记录绑定的支付单编号,避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单
  46 + *
  47 + * 以及 {@link PayWalletRechargeServiceImpl#updateWalletRechargeRefunded(Long, String, Long)}:回调传入的退款单编号,
  48 + * 必须等于充值记录绑定的退款单编号;已是退款终态的重复回调直接返回;先基于【未退款】状态更新退款字段成功,才操作一次钱包
  49 + *
  50 + * @author 芋道源码
  51 + */
  52 +public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
  53 +
  54 + private static final long RECHARGE_ID = 41L;
  55 + private static final long WALLET_ID = 1L;
  56 + /**
  57 + * 充值记录绑定的支付单编号
  58 + */
  59 + private static final long PAY_ORDER_ID = 100L;
  60 + /**
  61 + * 其它支付单编号,例如说其它支付应用中,相同商户订单编号、金额的支付单
  62 + */
  63 + private static final long OTHER_PAY_ORDER_ID = 200L;
  64 + private static final int PAY_PRICE = 500;
  65 + private static final int TOTAL_PRICE = 600;
  66 + private static final String CHANNEL_CODE = "wx_lite";
  67 + /**
  68 + * 充值记录绑定的退款单编号
  69 + */
  70 + private static final long PAY_REFUND_ID = 300L;
  71 + /**
  72 + * 其它退款单编号
  73 + */
  74 + private static final long OTHER_PAY_REFUND_ID = 400L;
  75 + private static final String MERCHANT_REFUND_ID = RECHARGE_ID + "-refund";
  76 +
  77 + @InjectMocks
  78 + private PayWalletRechargeServiceImpl walletRechargeService;
  79 +
  80 + @Mock
  81 + private PayWalletRechargeMapper walletRechargeMapper;
  82 + @Mock
  83 + private PayWalletService payWalletService;
  84 + @Mock
  85 + private PayOrderService payOrderService;
  86 + @Mock
  87 + private PayRefundApi payRefundApi;
  88 +
  89 + private MockedStatic<SpringUtil> springUtilMockedStatic;
  90 +
  91 + @BeforeEach
  92 + public void setUp() {
  93 + // mock getSelf() 使用的 SpringUtil,在正常路径中按需 mock 返回值
  94 + springUtilMockedStatic = mockStatic(SpringUtil.class);
  95 + }
  96 +
  97 + @AfterEach
  98 + public void tearDown() {
  99 + springUtilMockedStatic.close();
  100 + }
  101 +
  102 + @ParameterizedTest
  103 + @NullSource // 绑定的支付单编号尚未回写
  104 + @ValueSource(longs = PAY_ORDER_ID) // 绑定的是其它支付单
  105 + public void testUpdateWalletRechargerPaid_unpaidBindingMismatch(Long boundPayOrderId) {
  106 + // mock 数据:未支付的充值记录
  107 + mockRecharge(false, boundPayOrderId);
  108 +
  109 + // 调用,并断言异常:在查询支付单前失败
  110 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, OTHER_PAY_ORDER_ID),
  111 + WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR);
  112 + assertNoSideEffect();
  113 + }
  114 +
  115 + @Test
  116 + public void testUpdateWalletRechargerPaid_success() {
  117 + // mock 数据:未支付的充值记录,绑定的支付单与回调一致
  118 + PayWalletRechargeDO recharge = mockRecharge(false, PAY_ORDER_ID);
  119 + // mock 方法(支付单)
  120 + when(payOrderService.getOrder(PAY_ORDER_ID)).thenReturn(buildPayOrder());
  121 + // mock 方法(更新支付状态)
  122 + when(walletRechargeMapper.updateByIdAndPaid(eq(RECHARGE_ID), eq(false), argThat(update ->
  123 + Boolean.TRUE.equals(update.getPayStatus()) && update.getPayTime() != null
  124 + && Objects.equals(update.getPayChannelCode(), CHANNEL_CODE)))).thenReturn(1);
  125 + // mock 方法(订阅消息,异步执行,这里只断言被调用)
  126 + PayWalletRechargeServiceImpl self = mock(PayWalletRechargeServiceImpl.class);
  127 + springUtilMockedStatic.when(() -> SpringUtil.getBean(PayWalletRechargeServiceImpl.class)).thenReturn(self);
  128 +
  129 + // 调用
  130 + walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID);
  131 + // 断言:增加钱包余额;发送订阅消息
  132 + verify(payWalletService).addWalletBalance(WALLET_ID, String.valueOf(RECHARGE_ID),
  133 + PayWalletBizTypeEnum.RECHARGE, TOTAL_PRICE);
  134 + verify(self).sendWalletRechargerPaidMessage(PAY_ORDER_ID, recharge);
  135 + }
  136 +
  137 + @Test
  138 + public void testUpdateWalletRechargerPaid_paidDuplicate() {
  139 + // mock 数据:已支付的充值记录
  140 + mockRecharge(true, PAY_ORDER_ID);
  141 +
  142 + // 调用:重复回调,直接返回
  143 + walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID);
  144 + // 断言
  145 + assertNoSideEffect();
  146 + }
  147 +
  148 + @Test
  149 + public void testUpdateWalletRechargerPaid_paidBindingMismatch() {
  150 + // mock 数据:已支付的充值记录
  151 + mockRecharge(true, PAY_ORDER_ID);
  152 +
  153 + // 调用,并断言异常
  154 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, OTHER_PAY_ORDER_ID),
  155 + WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR);
  156 + assertNoSideEffect();
  157 + }
  158 +
  159 + @ParameterizedTest(name = "{0}")
  160 + @MethodSource("invalidPayOrders")
  161 + public void testUpdateWalletRechargerPaid_payOrderInvalid(String name, UnaryOperator<PayOrderDO> payOrderModifier,
  162 + ErrorCode errorCode) {
  163 + // mock 数据:未支付的充值记录,绑定的支付单与回调一致
  164 + mockRecharge(false, PAY_ORDER_ID);
  165 + // mock 方法(支付单)
  166 + when(payOrderService.getOrder(PAY_ORDER_ID)).thenReturn(payOrderModifier.apply(buildPayOrder()));
  167 +
  168 + // 调用,并断言异常:绑定一致时,原有的支付单校验仍然执行
  169 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID),
  170 + errorCode);
  171 + verify(walletRechargeMapper).selectById(RECHARGE_ID);
  172 + verifyNoMoreInteractions(walletRechargeMapper);
  173 + verifyNoInteractions(payWalletService);
  174 + springUtilMockedStatic.verifyNoInteractions();
  175 + }
  176 +
  177 + private static Stream<Arguments> invalidPayOrders() {
  178 + return Stream.of(
  179 + Arguments.of("支付单不存在", (UnaryOperator<PayOrderDO>) payOrder -> null,
  180 + PAY_ORDER_NOT_FOUND),
  181 + Arguments.of("支付单未支付", (UnaryOperator<PayOrderDO>) payOrder -> payOrder
  182 + .setStatus(PayOrderStatusEnum.WAITING.getStatus()), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_STATUS_NOT_SUCCESS),
  183 + Arguments.of("支付金额不一致", (UnaryOperator<PayOrderDO>) payOrder -> payOrder
  184 + .setPrice(PAY_PRICE - 1), WALLET_RECHARGE_UPDATE_PAID_PAY_PRICE_NOT_MATCH),
  185 + Arguments.of("商户订单编号不一致", (UnaryOperator<PayOrderDO>) payOrder -> payOrder
  186 + .setMerchantOrderId(String.valueOf(RECHARGE_ID + 1)), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR));
  187 + }
  188 +
  189 + // ========== updateWalletRechargeRefunded ==========
  190 +
  191 + @ParameterizedTest
  192 + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"})
  193 + public void testUpdateWalletRechargeRefunded_refundedDuplicate(PayRefundStatusEnum refundStatus) {
  194 + // mock 数据:已是退款终态的充值记录
  195 + mockRefundRecharge(refundStatus, PAY_REFUND_ID);
  196 +
  197 + // 调用:重复回调,直接返回
  198 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID);
  199 + // 断言:不查询退款单、不更新充值记录、不操作钱包
  200 + assertNoRefundSideEffect();
  201 + }
  202 +
  203 + @ParameterizedTest
  204 + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"})
  205 + public void testUpdateWalletRechargeRefunded_refundedBindingMismatch(PayRefundStatusEnum refundStatus) {
  206 + // mock 数据:已是退款终态的充值记录
  207 + mockRefundRecharge(refundStatus, PAY_REFUND_ID);
  208 +
  209 + // 调用,并断言异常
  210 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded(
  211 + RECHARGE_ID, MERCHANT_REFUND_ID, OTHER_PAY_REFUND_ID), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  212 + assertNoRefundSideEffect();
  213 + }
  214 +
  215 + @ParameterizedTest
  216 + @NullSource // 绑定的退款单编号尚未回写
  217 + @ValueSource(longs = PAY_REFUND_ID) // 绑定的是其它退款单
  218 + public void testUpdateWalletRechargeRefunded_waitingBindingMismatch(Long boundPayRefundId) {
  219 + // mock 数据:未退款的充值记录
  220 + mockRefundRecharge(PayRefundStatusEnum.WAITING, boundPayRefundId);
  221 +
  222 + // 调用,并断言异常:在查询退款单前失败
  223 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded(
  224 + RECHARGE_ID, MERCHANT_REFUND_ID, OTHER_PAY_REFUND_ID), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR);
  225 + assertNoRefundSideEffect();
  226 + }
  227 +
  228 + @Test
  229 + public void testUpdateWalletRechargeRefunded_success() {
  230 + // mock 数据:未退款的充值记录
  231 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID);
  232 + // mock 方法(退款单)
  233 + LocalDateTime successTime = LocalDateTime.of(2026, 10, 28, 12, 0);
  234 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.SUCCESS)
  235 + .setSuccessTime(successTime));
  236 + // mock 方法(更新退款状态)
  237 + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()),
  238 + argThat(update -> Objects.equals(update.getId(), RECHARGE_ID)
  239 + && Objects.equals(update.getRefundStatus(), PayRefundStatusEnum.SUCCESS.getStatus())
  240 + && Objects.equals(update.getRefundTime(), successTime)
  241 + && Objects.equals(update.getRefundTotalPrice(), TOTAL_PRICE)
  242 + && Objects.equals(update.getRefundPayPrice(), PAY_PRICE)
  243 + && Objects.equals(update.getRefundBonusPrice(), TOTAL_PRICE - PAY_PRICE)))).thenReturn(1);
  244 +
  245 + // 调用
  246 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID);
  247 + // 断言:先认领退款状态,再扣减钱包余额,且只扣减一次
  248 + InOrder inOrder = inOrder(walletRechargeMapper, payWalletService);
  249 + inOrder.verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID),
  250 + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class));
  251 + inOrder.verify(payWalletService).reduceWalletBalance(WALLET_ID, RECHARGE_ID,
  252 + PayWalletBizTypeEnum.RECHARGE_REFUND, TOTAL_PRICE);
  253 + verifyNoMoreInteractions(payWalletService);
  254 + }
  255 +
  256 + @Test
  257 + public void testUpdateWalletRechargeRefunded_failure() {
  258 + // mock 数据:未退款的充值记录
  259 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID);
  260 + // mock 方法(退款单)
  261 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.FAILURE));
  262 + // mock 方法(更新退款状态)
  263 + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()),
  264 + argThat(update -> Objects.equals(update.getId(), RECHARGE_ID)
  265 + && Objects.equals(update.getRefundStatus(), PayRefundStatusEnum.FAILURE.getStatus())
  266 + && update.getRefundTime() == null && update.getRefundTotalPrice() == null
  267 + && update.getRefundPayPrice() == null && update.getRefundBonusPrice() == null))).thenReturn(1);
  268 +
  269 + // 调用
  270 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID);
  271 + // 断言:先认领退款状态,再解冻余额,且只解冻一次
  272 + InOrder inOrder = inOrder(walletRechargeMapper, payWalletService);
  273 + inOrder.verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID),
  274 + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class));
  275 + inOrder.verify(payWalletService).unfreezePrice(WALLET_ID, TOTAL_PRICE);
  276 + verifyNoMoreInteractions(payWalletService);
  277 + }
  278 +
  279 + @ParameterizedTest
  280 + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"})
  281 + public void testUpdateWalletRechargeRefunded_updateStatusFail(PayRefundStatusEnum payRefundStatus) {
  282 + // mock 数据:未退款的充值记录
  283 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID);
  284 + // mock 方法(退款单)
  285 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(payRefundStatus));
  286 + // mock 方法(更新退款状态失败,例如说被并发的重复回调处理)
  287 + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()),
  288 + any(PayWalletRechargeDO.class))).thenReturn(0);
  289 +
  290 + // 调用
  291 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID);
  292 + // 断言:不操作钱包
  293 + verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID),
  294 + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class));
  295 + verifyNoInteractions(payWalletService);
  296 + }
  297 +
  298 + @Test
  299 + public void testUpdateWalletRechargeRefunded_refundNotFinished() {
  300 + // mock 数据:未退款的充值记录
  301 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID);
  302 + // mock 方法(退款单未结束)
  303 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.WAITING));
  304 +
  305 + // 调用
  306 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID);
  307 + // 断言:不更新退款状态、不操作钱包
  308 + verify(walletRechargeMapper).selectById(RECHARGE_ID);
  309 + verifyNoMoreInteractions(walletRechargeMapper);
  310 + verifyNoInteractions(payWalletService);
  311 + }
  312 +
  313 + @ParameterizedTest(name = "{0}")
  314 + @MethodSource("invalidPayRefunds")
  315 + public void testUpdateWalletRechargeRefunded_payRefundInvalid(String name,
  316 + UnaryOperator<PayRefundRespDTO> payRefundModifier,
  317 + ErrorCode errorCode) {
  318 + // mock 数据:未退款的充值记录,绑定的退款单与回调一致
  319 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID);
  320 + // mock 方法(退款单)
  321 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(
  322 + payRefundModifier.apply(buildPayRefund(PayRefundStatusEnum.SUCCESS)));
  323 +
  324 + // 调用,并断言异常:绑定一致时,原有的退款单校验仍然执行
  325 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded(
  326 + RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID), errorCode);
  327 + verify(walletRechargeMapper).selectById(RECHARGE_ID);
  328 + verifyNoMoreInteractions(walletRechargeMapper);
  329 + verifyNoInteractions(payWalletService);
  330 + }
  331 +
  332 + private static Stream<Arguments> invalidPayRefunds() {
  333 + return Stream.of(
  334 + Arguments.of("退款单不存在", (UnaryOperator<PayRefundRespDTO>) payRefund -> null,
  335 + WALLET_RECHARGE_REFUND_FAIL_REFUND_NOT_FOUND),
  336 + Arguments.of("退款金额不一致", (UnaryOperator<PayRefundRespDTO>) payRefund -> payRefund
  337 + .setRefundPrice(PAY_PRICE - 1), WALLET_RECHARGE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH),
  338 + Arguments.of("商户退款单编号不一致", (UnaryOperator<PayRefundRespDTO>) payRefund -> payRefund
  339 + .setMerchantRefundId((RECHARGE_ID + 1) + "-refund"), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR));
  340 + }
  341 +
  342 + private PayWalletRechargeDO mockRecharge(boolean payStatus, Long payOrderId) {
  343 + PayWalletRechargeDO recharge = new PayWalletRechargeDO().setId(RECHARGE_ID).setWalletId(WALLET_ID)
  344 + .setPayStatus(payStatus).setPayOrderId(payOrderId)
  345 + .setPayPrice(PAY_PRICE).setBonusPrice(TOTAL_PRICE - PAY_PRICE).setTotalPrice(TOTAL_PRICE);
  346 + when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge);
  347 + return recharge;
  348 + }
  349 +
  350 + private static PayOrderDO buildPayOrder() {
  351 + return new PayOrderDO().setId(PAY_ORDER_ID).setStatus(PayOrderStatusEnum.SUCCESS.getStatus())
  352 + .setPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID)).setChannelCode(CHANNEL_CODE);
  353 + }
  354 +
  355 + private void mockRefundRecharge(PayRefundStatusEnum refundStatus, Long payRefundId) {
  356 + PayWalletRechargeDO recharge = new PayWalletRechargeDO().setId(RECHARGE_ID).setWalletId(WALLET_ID)
  357 + .setPayStatus(true).setPayOrderId(PAY_ORDER_ID)
  358 + .setPayPrice(PAY_PRICE).setBonusPrice(TOTAL_PRICE - PAY_PRICE).setTotalPrice(TOTAL_PRICE)
  359 + .setPayRefundId(payRefundId).setRefundStatus(refundStatus.getStatus());
  360 + when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge);
  361 + }
  362 +
  363 + private static PayRefundRespDTO buildPayRefund(PayRefundStatusEnum status) {
  364 + return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(status.getStatus())
  365 + .setRefundPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID))
  366 + .setMerchantRefundId(MERCHANT_REFUND_ID);
  367 + }
  368 +
  369 + /**
  370 + * 断言:只查询了充值记录,没有查询支付单、写入充值记录、增加余额或发送消息
  371 + */
  372 + private void assertNoSideEffect() {
  373 + verify(walletRechargeMapper).selectById(RECHARGE_ID);
  374 + verifyNoMoreInteractions(walletRechargeMapper);
  375 + verifyNoInteractions(payOrderService, payWalletService);
  376 + springUtilMockedStatic.verifyNoInteractions();
  377 + }
  378 +
  379 + /**
  380 + * 断言:只查询了充值记录,没有查询退款单、写入充值记录或操作钱包
  381 + */
  382 + private void assertNoRefundSideEffect() {
  383 + verify(walletRechargeMapper).selectById(RECHARGE_ID);
  384 + verifyNoMoreInteractions(walletRechargeMapper);
  385 + verifyNoInteractions(payRefundApi, payWalletService);
  386 + }
  387 +
  388 +}
... ...