Commit 319a7e1f584862ec0259a01e3947b510da2ca015
1 parent
2c781729
fix(pay): 校验业务回调绑定并防止钱包退款重复处理
Showing
8 changed files
with
1020 additions
and
21 deletions
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 | +} | ... | ... |