diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImpl.java index d414202..2929c8c 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImpl.java +++ b/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 { if (ObjectUtil.notEqual(afterSale.getStatus(), AfterSaleStatusEnum.WAIT_REFUND.getStatus())) { throw exception(AFTER_SALE_REFUND_FAIL_STATUS_NOT_WAIT_REFUND); } + // 1.2 校验退款单编号,与售后单绑定的退款单编号一致。避免其它支付应用、或重新退款前的退款单被用于更新状态 + if (ObjectUtil.notEqual(afterSale.getPayRefundId(), payRefundId)) { + log.error("[updateAfterSaleRefunded][afterSale({}) 绑定的退款单({}) 与回调的退款单({}) 不匹配,请进行处理!]", + id, afterSale.getPayRefundId(), payRefundId); + throw exception(AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + } // 2. 校验退款单 PayRefundRespDTO payRefund = validatePayRefund(afterSale, payRefundId); diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java index 5db6568..b78ff6c 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImpl.java +++ b/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 { log.error("[updateBrokerageWithdrawTransferred][withdraw({}) 转账单不匹配({}),请进行处理!]", withdraw, payTransferId); throw exception(BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR); } + // 1.3 校验转账单编号,与提现单绑定的转账单编号一致。避免其它支付应用中,相同商户转账编号、金额的转账单被用于更新状态 + if (ObjectUtil.notEqual(withdraw.getPayTransferId(), payTransferId)) { + log.error("[updateBrokerageWithdrawTransferred][withdraw({}) 绑定的转账单({}) 与回调的转账单({}) 不匹配,请进行处理!]", + id, withdraw.getPayTransferId(), payTransferId); + throw exception(BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR); + } // 2. 校验转账单的合法性 PayTransferRespDTO payTransfer = validateBrokerageTransferStatusCanUpdate(withdraw, payTransferId); diff --git a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java b/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java index 5195b04..85f9b9a 100644 --- a/yudao-module-mall/yudao-module-trade/src/main/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImpl.java +++ b/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 { id, payOrderId, JsonUtils.toJsonString(order)); throw exception(ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR); } + // 1.3 校验支付单号,与订单绑定的支付单号一致。避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单 + if (ObjectUtil.notEqual(order.getPayOrderId(), payOrderId)) { + log.error("[updateOrderPaid][order({}) 绑定的支付单({}) 与回调的支付单({}) 不匹配,请进行处理!]", + id, order.getPayOrderId(), payOrderId); + throw exception(ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR); + } // 2. 校验支付订单的合法性 PayOrderRespDTO payOrder = validatePayOrderPaid(order, payOrderId); diff --git a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImplUnitTest.java b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImplUnitTest.java new file mode 100644 index 0000000..8842ec7 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/aftersale/AfterSaleServiceImplUnitTest.java @@ -0,0 +1,196 @@ +package cn.iocoder.yudao.module.trade.service.aftersale; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi; +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO; +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; +import cn.iocoder.yudao.module.trade.dal.dataobject.aftersale.AfterSaleDO; +import cn.iocoder.yudao.module.trade.dal.mysql.aftersale.AfterSaleMapper; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleOperateTypeEnum; +import cn.iocoder.yudao.module.trade.enums.aftersale.AfterSaleStatusEnum; +import cn.iocoder.yudao.module.trade.framework.aftersale.core.utils.AfterSaleLogUtils; +import cn.iocoder.yudao.module.trade.service.order.TradeOrderUpdateService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link AfterSaleServiceImpl} 的单元测试类 + * + * 重点覆盖 {@link AfterSaleServiceImpl#updateAfterSaleRefunded(Long, Long, Long)}:回调传入的退款单编号, + * 必须等于售后单绑定的退款单编号,避免其它支付应用、或重新退款前的退款单被用于更新状态 + * + * @author 芋道源码 + */ +public class AfterSaleServiceImplUnitTest extends BaseMockitoUnitTest { + + private static final long AFTER_SALE_ID = 5L; + private static final long ORDER_ID = 1L; + private static final long ORDER_ITEM_ID = 50L; + /** + * 售后单绑定的退款单编号 + */ + private static final long PAY_REFUND_ID = 500L; + /** + * 其它退款单编号,例如说其它支付应用中,相同商户退款编号、金额的退款单 + */ + private static final long OTHER_PAY_REFUND_ID = 600L; + private static final int REFUND_PRICE = 300; + + @InjectMocks + private AfterSaleServiceImpl afterSaleService; + + @Mock + private AfterSaleMapper tradeAfterSaleMapper; + @Mock + private PayRefundApi payRefundApi; + @Mock + private TradeOrderUpdateService tradeOrderUpdateService; + + private MockedStatic afterSaleLogUtilsMockedStatic; + + @BeforeEach + public void setUp() { + // mock 售后操作日志的上下文,用于断言是否记录日志 + afterSaleLogUtilsMockedStatic = mockStatic(AfterSaleLogUtils.class); + } + + @AfterEach + public void tearDown() { + afterSaleLogUtilsMockedStatic.close(); + } + + @ParameterizedTest + @NullSource // 绑定的退款单编号尚未回写 + @ValueSource(longs = PAY_REFUND_ID) // 绑定的是其它退款单 + public void testUpdateAfterSaleRefunded_bindingMismatch(Long boundPayRefundId) { + // mock 数据:待退款的售后单 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, boundPayRefundId); + + // 调用,并断言异常:在查询退款单前失败 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, OTHER_PAY_REFUND_ID), + AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + assertNoSideEffect(); + } + + @Test + public void testUpdateAfterSaleRefunded_refundSuccess() { + // mock 数据:待退款的售后单,绑定的退款单与回调一致 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID); + // mock 方法 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund()); + when(tradeAfterSaleMapper.updateByIdAndStatus(eq(AFTER_SALE_ID), eq(AfterSaleStatusEnum.WAIT_REFUND.getStatus()), + argThat(update -> Objects.equals(update.getStatus(), AfterSaleStatusEnum.COMPLETE.getStatus()) + && update.getRefundTime() != null))).thenReturn(1); + + // 调用 + afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID); + // 断言:更新订单项的售后状态;记录售后日志 + verify(tradeOrderUpdateService).updateOrderItemWhenAfterSaleSuccess(ORDER_ITEM_ID, REFUND_PRICE); + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleInfo(AFTER_SALE_ID, + AfterSaleStatusEnum.WAIT_REFUND.getStatus(), AfterSaleStatusEnum.COMPLETE.getStatus())); + } + + @Test + public void testUpdateAfterSaleRefunded_refundFailure() { + // mock 数据:待退款的售后单,绑定的退款单与回调一致 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID); + // mock 方法 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund() + .setStatus(PayRefundStatusEnum.FAILURE.getStatus())); + + // 调用 + afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID); + // 断言:售后单状态不变,只记录退款失败日志 + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID); + verifyNoMoreInteractions(tradeAfterSaleMapper); + verifyNoInteractions(tradeOrderUpdateService); + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleOperateType( + AfterSaleOperateTypeEnum.SYSTEM_REFUND_FAIL)); + afterSaleLogUtilsMockedStatic.verify(() -> AfterSaleLogUtils.setAfterSaleInfo(AFTER_SALE_ID, + AfterSaleStatusEnum.WAIT_REFUND.getStatus(), AfterSaleStatusEnum.WAIT_REFUND.getStatus())); + } + + @Test + public void testUpdateAfterSaleRefunded_completedRepeat() { + // mock 数据:已完成的售后单,绑定的退款单与回调一致 + mockAfterSale(AfterSaleStatusEnum.COMPLETE, PAY_REFUND_ID); + + // 调用,并断言异常:重复回调仍是原有的状态错误 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID), + AFTER_SALE_REFUND_FAIL_STATUS_NOT_WAIT_REFUND); + assertNoSideEffect(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPayRefunds") + public void testUpdateAfterSaleRefunded_payRefundInvalid(String name, UnaryOperator payRefundModifier, + ErrorCode errorCode) { + // mock 数据:待退款的售后单,绑定的退款单与回调一致 + mockAfterSale(AfterSaleStatusEnum.WAIT_REFUND, PAY_REFUND_ID); + // mock 方法 + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(payRefundModifier.apply(buildPayRefund())); + + // 调用,并断言异常:绑定一致时,原有的退款单校验仍然执行 + assertServiceException(() -> afterSaleService.updateAfterSaleRefunded(AFTER_SALE_ID, ORDER_ID, PAY_REFUND_ID), + errorCode); + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID); + verifyNoMoreInteractions(tradeAfterSaleMapper); + verifyNoInteractions(tradeOrderUpdateService); + afterSaleLogUtilsMockedStatic.verifyNoInteractions(); + } + + private static Stream invalidPayRefunds() { + return Stream.of( + Arguments.of("退款单不存在", (UnaryOperator) payRefund -> null, + AFTER_SALE_REFUND_FAIL_REFUND_NOT_FOUND), + Arguments.of("退款单无退款结果", (UnaryOperator) payRefund -> payRefund + .setStatus(PayRefundStatusEnum.WAITING.getStatus()), AFTER_SALE_REFUND_FAIL_REFUND_NOT_SUCCESS_OR_FAILURE), + Arguments.of("退款金额不一致", (UnaryOperator) payRefund -> payRefund + .setRefundPrice(REFUND_PRICE - 1), AFTER_SALE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH), + Arguments.of("商户退款编号不一致", (UnaryOperator) payRefund -> payRefund + .setMerchantRefundId(String.valueOf(AFTER_SALE_ID + 1)), AFTER_SALE_REFUND_FAIL_REFUND_ORDER_ID_ERROR)); + } + + private void mockAfterSale(AfterSaleStatusEnum status, Long payRefundId) { + AfterSaleDO afterSale = new AfterSaleDO().setId(AFTER_SALE_ID).setStatus(status.getStatus()) + .setOrderId(ORDER_ID).setOrderItemId(ORDER_ITEM_ID) + .setPayRefundId(payRefundId).setRefundPrice(REFUND_PRICE); + when(tradeAfterSaleMapper.selectById(AFTER_SALE_ID)).thenReturn(afterSale); + } + + private static PayRefundRespDTO buildPayRefund() { + return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(PayRefundStatusEnum.SUCCESS.getStatus()) + .setRefundPrice(REFUND_PRICE).setMerchantRefundId(String.valueOf(AFTER_SALE_ID)); + } + + /** + * 断言:只查询了售后单,没有查询退款单、更新售后单、更新订单项或记录日志 + */ + private void assertNoSideEffect() { + verify(tradeAfterSaleMapper).selectById(AFTER_SALE_ID); + verifyNoMoreInteractions(tradeAfterSaleMapper); + verifyNoInteractions(payRefundApi, tradeOrderUpdateService); + afterSaleLogUtilsMockedStatic.verifyNoInteractions(); + } + +} diff --git a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImplUnitTest.java b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImplUnitTest.java new file mode 100644 index 0000000..f091cca --- /dev/null +++ b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageWithdrawServiceImplUnitTest.java @@ -0,0 +1,170 @@ +package cn.iocoder.yudao.module.trade.service.brokerage; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pay.api.transfer.PayTransferApi; +import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferRespDTO; +import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; +import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageWithdrawDO; +import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageWithdrawMapper; +import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageWithdrawStatusEnum; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.*; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.PAY_TRANSFER_NOT_FOUND; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link BrokerageWithdrawServiceImpl} 的单元测试类 + * + * 重点覆盖 {@link BrokerageWithdrawServiceImpl#updateBrokerageWithdrawTransferred(Long, Long)}:回调传入的转账单编号, + * 必须等于提现单绑定的转账单编号,避免其它支付应用中,相同商户转账编号、金额的转账单被用于更新状态 + * + * @author 芋道源码 + */ +public class BrokerageWithdrawServiceImplUnitTest extends BaseMockitoUnitTest { + + private static final long WITHDRAW_ID = 3L; + /** + * 提现单绑定的转账单编号 + */ + private static final long PAY_TRANSFER_ID = 300L; + /** + * 其它转账单编号,例如说其它支付应用中,相同商户转账编号、金额的转账单 + */ + private static final long OTHER_PAY_TRANSFER_ID = 400L; + private static final int WITHDRAW_PRICE = 1000; + private static final int WITHDRAW_FEE_PRICE = 100; + private static final String TRANSFER_CHANNEL_CODE = "wx_lite"; + + @InjectMocks + private BrokerageWithdrawServiceImpl brokerageWithdrawService; + + @Mock + private BrokerageWithdrawMapper brokerageWithdrawMapper; + @Mock + private PayTransferApi payTransferApi; + + @ParameterizedTest + @NullSource // 绑定的转账单编号尚未回写 + @ValueSource(longs = PAY_TRANSFER_ID) // 绑定的是其它转账单 + public void testUpdateBrokerageWithdrawTransferred_unfinishedBindingMismatch(Long boundPayTransferId) { + // mock 数据:未结束的提现单 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, boundPayTransferId); + + // 调用,并断言异常:在查询转账单前失败 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, OTHER_PAY_TRANSFER_ID), + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR); + assertNoSideEffect(); + } + + @ParameterizedTest + @MethodSource("transferResults") + public void testUpdateBrokerageWithdrawTransferred_success(PayTransferStatusEnum transferStatus, + BrokerageWithdrawStatusEnum expectedStatus) { + // mock 数据:未结束的提现单,绑定的转账单与回调一致 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, PAY_TRANSFER_ID); + // mock 方法 + when(payTransferApi.getTransfer(PAY_TRANSFER_ID)).thenReturn(buildPayTransfer().setStatus(transferStatus.getStatus())); + + // 调用 + brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID); + // 断言:转账成功时提现成功,转账关闭时提现失败 + verify(brokerageWithdrawMapper).updateByIdAndStatus(eq(WITHDRAW_ID), eq(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS.getStatus()), + argThat(update -> Objects.equals(update.getStatus(), expectedStatus.getStatus()))); + } + + private static Stream transferResults() { + return Stream.of( + Arguments.of(PayTransferStatusEnum.SUCCESS, BrokerageWithdrawStatusEnum.WITHDRAW_SUCCESS), + Arguments.of(PayTransferStatusEnum.CLOSED, BrokerageWithdrawStatusEnum.WITHDRAW_FAIL)); + } + + @ParameterizedTest + @EnumSource(value = BrokerageWithdrawStatusEnum.class, names = {"WITHDRAW_SUCCESS", "WITHDRAW_FAIL"}) + public void testUpdateBrokerageWithdrawTransferred_finishedDuplicate(BrokerageWithdrawStatusEnum status) { + // mock 数据:已结束的提现单 + mockWithdraw(status, PAY_TRANSFER_ID); + + // 调用:重复回调,直接返回 + brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID); + // 断言 + assertNoSideEffect(); + } + + @Test + public void testUpdateBrokerageWithdrawTransferred_finishedBindingMismatch() { + // mock 数据:已结束的提现单 + mockWithdraw(BrokerageWithdrawStatusEnum.WITHDRAW_SUCCESS, PAY_TRANSFER_ID); + + // 调用,并断言异常 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, OTHER_PAY_TRANSFER_ID), + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_ID_ERROR); + assertNoSideEffect(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPayTransfers") + public void testUpdateBrokerageWithdrawTransferred_payTransferInvalid(String name, + UnaryOperator payTransferModifier, + ErrorCode errorCode) { + // mock 数据:未结束的提现单,绑定的转账单与回调一致 + mockWithdraw(BrokerageWithdrawStatusEnum.AUDIT_SUCCESS, PAY_TRANSFER_ID); + // mock 方法 + when(payTransferApi.getTransfer(PAY_TRANSFER_ID)).thenReturn(payTransferModifier.apply(buildPayTransfer())); + + // 调用,并断言异常:绑定一致时,原有的转账单校验仍然执行 + assertServiceException(() -> brokerageWithdrawService.updateBrokerageWithdrawTransferred(WITHDRAW_ID, PAY_TRANSFER_ID), + errorCode); + verify(brokerageWithdrawMapper).selectById(WITHDRAW_ID); + verifyNoMoreInteractions(brokerageWithdrawMapper); + } + + private static Stream invalidPayTransfers() { + return Stream.of( + Arguments.of("转账单不存在", (UnaryOperator) payTransfer -> null, PAY_TRANSFER_NOT_FOUND), + Arguments.of("转账单未结束", (UnaryOperator) payTransfer -> payTransfer + .setStatus(PayTransferStatusEnum.PROCESSING.getStatus()), + BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_TRANSFER_STATUS_NOT_SUCCESS_OR_CLOSED), + Arguments.of("转账金额不一致", (UnaryOperator) payTransfer -> payTransfer + .setPrice(WITHDRAW_PRICE), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_PRICE_NOT_MATCH), + Arguments.of("商户转账编号不一致", (UnaryOperator) payTransfer -> payTransfer + .setMerchantTransferId(String.valueOf(WITHDRAW_ID + 1)), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_MERCHANT_EXISTS), + Arguments.of("转账渠道不一致", (UnaryOperator) payTransfer -> payTransfer + .setChannelCode("alipay_pc"), BROKERAGE_WITHDRAW_UPDATE_STATUS_FAIL_PAY_CHANNEL_NOT_MATCH)); + } + + private void mockWithdraw(BrokerageWithdrawStatusEnum status, Long payTransferId) { + BrokerageWithdrawDO withdraw = new BrokerageWithdrawDO().setId(WITHDRAW_ID).setStatus(status.getStatus()) + .setPayTransferId(payTransferId).setPrice(WITHDRAW_PRICE).setFeePrice(WITHDRAW_FEE_PRICE) + .setTransferChannelCode(TRANSFER_CHANNEL_CODE); + when(brokerageWithdrawMapper.selectById(WITHDRAW_ID)).thenReturn(withdraw); + } + + private static PayTransferRespDTO buildPayTransfer() { + return new PayTransferRespDTO().setId(PAY_TRANSFER_ID).setStatus(PayTransferStatusEnum.SUCCESS.getStatus()) + .setPrice(WITHDRAW_PRICE - WITHDRAW_FEE_PRICE).setMerchantTransferId(String.valueOf(WITHDRAW_ID)) + .setChannelCode(TRANSFER_CHANNEL_CODE); + } + + /** + * 断言:只查询了提现单,没有查询转账单或更新提现单 + */ + private void assertNoSideEffect() { + verify(brokerageWithdrawMapper).selectById(WITHDRAW_ID); + verifyNoMoreInteractions(brokerageWithdrawMapper); + verifyNoInteractions(payTransferApi); + } + +} diff --git a/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImplUnitTest.java b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImplUnitTest.java new file mode 100644 index 0000000..aeef277 --- /dev/null +++ b/yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/order/TradeOrderUpdateServiceImplUnitTest.java @@ -0,0 +1,201 @@ +package cn.iocoder.yudao.module.trade.service.order; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pay.api.order.PayOrderApi; +import cn.iocoder.yudao.module.pay.api.order.dto.PayOrderRespDTO; +import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderDO; +import cn.iocoder.yudao.module.trade.dal.dataobject.order.TradeOrderItemDO; +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderItemMapper; +import cn.iocoder.yudao.module.trade.dal.mysql.order.TradeOrderMapper; +import cn.iocoder.yudao.module.trade.enums.order.TradeOrderStatusEnum; +import cn.iocoder.yudao.module.trade.framework.order.core.utils.TradeOrderLogUtils; +import cn.iocoder.yudao.module.trade.service.order.handler.TradeOrderHandler; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.*; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link TradeOrderUpdateServiceImpl} 的单元测试类 + * + * 重点覆盖 {@link TradeOrderUpdateServiceImpl#updateOrderPaid(Long, Long)}:回调传入的支付单编号, + * 必须等于订单绑定的支付单编号,避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单 + * + * @author 芋道源码 + */ +public class TradeOrderUpdateServiceImplUnitTest extends BaseMockitoUnitTest { + + private static final long ORDER_ID = 1L; + private static final long ORDER_USER_ID = 10L; + private static final long ORDER_ITEM_ID = 50L; + /** + * 订单绑定的支付单编号 + */ + private static final long PAY_ORDER_ID = 100L; + /** + * 其它支付单编号,例如说其它支付应用中,相同商户订单编号、金额的支付单 + */ + private static final long OTHER_PAY_ORDER_ID = 200L; + private static final int ORDER_PAY_PRICE = 500; + private static final String PAY_CHANNEL_CODE = "wx_lite"; + + @InjectMocks + private TradeOrderUpdateServiceImpl tradeOrderUpdateService; + + @Mock + private TradeOrderMapper tradeOrderMapper; + @Mock + private TradeOrderItemMapper tradeOrderItemMapper; + @Mock + private TradeOrderHandler tradeOrderHandler; + @Mock + private PayOrderApi payOrderApi; + + private MockedStatic tradeOrderLogUtilsMockedStatic; + + @BeforeEach + public void setUp() { + ReflectionTestUtils.setField(tradeOrderUpdateService, "tradeOrderHandlers", + Collections.singletonList(tradeOrderHandler)); + // mock 订单操作日志的上下文,用于断言是否记录日志 + tradeOrderLogUtilsMockedStatic = mockStatic(TradeOrderLogUtils.class); + } + + @AfterEach + public void tearDown() { + tradeOrderLogUtilsMockedStatic.close(); + } + + @ParameterizedTest + @NullSource // 绑定的支付单编号尚未回写 + @ValueSource(longs = PAY_ORDER_ID) // 绑定的是其它支付单 + public void testUpdateOrderPaid_unpaidBindingMismatch(Long boundPayOrderId) { + // mock 数据:未支付的订单 + mockOrder(TradeOrderStatusEnum.UNPAID, false, boundPayOrderId); + + // 调用,并断言异常:在查询支付单前失败 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, OTHER_PAY_ORDER_ID), + ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR); + assertNoSideEffect(); + } + + @Test + public void testUpdateOrderPaid_success() { + // mock 数据:未支付的订单,绑定的支付单与回调一致 + TradeOrderDO order = mockOrder(TradeOrderStatusEnum.UNPAID, false, PAY_ORDER_ID); + List orderItems = Collections.singletonList(new TradeOrderItemDO().setId(ORDER_ITEM_ID)); + // mock 方法 + when(payOrderApi.getOrder(PAY_ORDER_ID)).thenReturn(buildPayOrder()); + when(tradeOrderMapper.updateByIdAndStatus(eq(ORDER_ID), eq(TradeOrderStatusEnum.UNPAID.getStatus()), + argThat(update -> Objects.equals(update.getStatus(), TradeOrderStatusEnum.UNDELIVERED.getStatus()) + && Boolean.TRUE.equals(update.getPayStatus()) && update.getPayTime() != null + && Objects.equals(update.getPayChannelCode(), PAY_CHANNEL_CODE)))).thenReturn(1); + when(tradeOrderItemMapper.selectListByOrderId(ORDER_ID)).thenReturn(orderItems); + + // 调用 + tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID); + // 断言:执行后置处理;记录订单日志 + verify(tradeOrderHandler).afterPayOrder(order, orderItems); + tradeOrderLogUtilsMockedStatic.verify(() -> TradeOrderLogUtils.setOrderInfo(ORDER_ID, + TradeOrderStatusEnum.UNPAID.getStatus(), TradeOrderStatusEnum.UNDELIVERED.getStatus())); + tradeOrderLogUtilsMockedStatic.verify(() -> TradeOrderLogUtils.setUserInfo(ORDER_USER_ID, + UserTypeEnum.MEMBER.getValue())); + } + + @Test + public void testUpdateOrderPaid_paidDuplicate() { + // mock 数据:已支付的订单 + mockOrder(TradeOrderStatusEnum.UNDELIVERED, true, PAY_ORDER_ID); + + // 调用:重复回调,直接返回 + tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID); + // 断言 + assertNoSideEffect(); + } + + @Test + public void testUpdateOrderPaid_paidBindingMismatch() { + // mock 数据:已支付的订单 + mockOrder(TradeOrderStatusEnum.UNDELIVERED, true, PAY_ORDER_ID); + + // 调用,并断言异常 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, OTHER_PAY_ORDER_ID), + ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR); + assertNoSideEffect(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPayOrders") + public void testUpdateOrderPaid_payOrderInvalid(String name, UnaryOperator payOrderModifier, + ErrorCode errorCode) { + // mock 数据:未支付的订单,绑定的支付单与回调一致 + mockOrder(TradeOrderStatusEnum.UNPAID, false, PAY_ORDER_ID); + // mock 方法 + when(payOrderApi.getOrder(PAY_ORDER_ID)).thenReturn(payOrderModifier.apply(buildPayOrder())); + + // 调用,并断言异常:绑定一致时,原有的支付单校验仍然执行 + assertServiceException(() -> tradeOrderUpdateService.updateOrderPaid(ORDER_ID, PAY_ORDER_ID), errorCode); + verify(tradeOrderMapper).selectById(ORDER_ID); + verifyNoMoreInteractions(tradeOrderMapper); + verifyNoInteractions(tradeOrderItemMapper, tradeOrderHandler); + tradeOrderLogUtilsMockedStatic.verifyNoInteractions(); + } + + private static Stream invalidPayOrders() { + return Stream.of( + Arguments.of("支付单不存在", (UnaryOperator) payOrder -> null, ORDER_NOT_FOUND), + Arguments.of("支付单未支付", (UnaryOperator) payOrder -> payOrder + .setStatus(PayOrderStatusEnum.WAITING.getStatus()), ORDER_UPDATE_PAID_FAIL_PAY_ORDER_STATUS_NOT_SUCCESS), + Arguments.of("支付金额不一致", (UnaryOperator) payOrder -> payOrder + .setPrice(ORDER_PAY_PRICE - 1), ORDER_UPDATE_PAID_FAIL_PAY_PRICE_NOT_MATCH), + Arguments.of("商户订单编号不一致", (UnaryOperator) payOrder -> payOrder + .setMerchantOrderId(String.valueOf(ORDER_ID + 1)), ORDER_UPDATE_PAID_FAIL_PAY_ORDER_ID_ERROR)); + } + + private TradeOrderDO mockOrder(TradeOrderStatusEnum status, boolean payStatus, Long payOrderId) { + TradeOrderDO order = new TradeOrderDO().setId(ORDER_ID).setUserId(ORDER_USER_ID) + .setStatus(status.getStatus()).setPayStatus(payStatus).setPayOrderId(payOrderId) + .setPayPrice(ORDER_PAY_PRICE); + when(tradeOrderMapper.selectById(ORDER_ID)).thenReturn(order); + return order; + } + + private static PayOrderRespDTO buildPayOrder() { + return new PayOrderRespDTO().setId(PAY_ORDER_ID).setStatus(PayOrderStatusEnum.SUCCESS.getStatus()) + .setPrice(ORDER_PAY_PRICE).setMerchantOrderId(String.valueOf(ORDER_ID)).setChannelCode(PAY_CHANNEL_CODE); + } + + /** + * 断言:只查询了订单,没有查询支付单、更新订单、执行后置处理或记录日志 + */ + private void assertNoSideEffect() { + verify(tradeOrderMapper).selectById(ORDER_ID); + verifyNoMoreInteractions(tradeOrderMapper); + verifyNoInteractions(payOrderApi, tradeOrderItemMapper, tradeOrderHandler); + tradeOrderLogUtilsMockedStatic.verifyNoInteractions(); + } + +} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java index a49936d..a91532f 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java @@ -135,6 +135,12 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService { log.error("[updateWalletRechargerPaid][recharge({}) 已支付,但是支付单号不同({}),请进行处理!]", recharge, payOrderId); throw exception(WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR); } + // 1.3 校验支付单号,与充值记录绑定的支付单号一致。避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单 + if (ObjectUtil.notEqual(recharge.getPayOrderId(), payOrderId)) { + log.error("[updateWalletRechargerPaid][recharge({}) 绑定的支付单({}) 与回调的支付单({}) 不匹配,请进行处理!]", + id, recharge.getPayOrderId(), payOrderId); + throw exception(WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR); + } // 2. 校验支付订单的合法性 PayOrderDO payOrderDO = validatePayOrderPaid(recharge, payOrderId); @@ -227,53 +233,73 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService { log.error("[updateWalletRechargerPaid][钱包充值记录不存在,钱包充值记录 id({})]", id); throw exception(WALLET_RECHARGE_NOT_FOUND); } - // 1.2 校验钱包充值是否可以更新已退款 + // 1.2 校验退款单号,与充值记录绑定的退款单号一致 + if (notEqual(walletRecharge.getPayRefundId(), payRefundId)) { + log.error("[updateWalletRechargeRefunded][钱包充值({}) 退款单不匹配({}),请进行处理!钱包充值的数据是:{}]", + walletRecharge.getId(), payRefundId, toJsonString(walletRecharge)); + throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + } + // 1.3 特殊:已经是退款终态(成功、失败),且退款单号相同,直接返回,说明重复回调 + if (PayRefundStatusEnum.isSuccess(walletRecharge.getRefundStatus()) + || PayRefundStatusEnum.isFailure(walletRecharge.getRefundStatus())) { + log.warn("[updateWalletRechargeRefunded][钱包充值({}) 已是退款终态({}),且退款单号相同({}),直接返回]", + id, walletRecharge.getRefundStatus(), payRefundId); + return; + } + // 1.4 校验钱包充值是否可以更新已退款 PayRefundRespDTO payRefund = validateWalletRechargeCanRefunded(walletRecharge, payRefundId); // 2. 处理退款结果 PayWalletRechargeDO updateObj = new PayWalletRechargeDO().setId(id); + boolean refundSuccess = PayRefundStatusEnum.isSuccess(payRefund.getStatus()); // 情况一:退款成功 - if (PayRefundStatusEnum.isSuccess(payRefund.getStatus())) { - // 2.1 更新钱包余额 - payWalletService.reduceWalletBalance(walletRecharge.getWalletId(), id, - PayWalletBizTypeEnum.RECHARGE_REFUND, walletRecharge.getTotalPrice()); - + if (refundSuccess) { updateObj.setRefundStatus(SUCCESS.getStatus()).setRefundTime(payRefund.getSuccessTime()) .setRefundTotalPrice(walletRecharge.getTotalPrice()).setRefundPayPrice(walletRecharge.getPayPrice()) .setRefundBonusPrice(walletRecharge.getBonusPrice()); // 情况二:退款失败 } else if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) { - // 2.2 解冻余额 - payWalletService.unfreezePrice(walletRecharge.getWalletId(), walletRecharge.getTotalPrice()); - updateObj.setRefundStatus(FAILURE.getStatus()); + // 情况三:退款未结束,不更新退款状态,也不操作钱包 + } else { + log.warn("[updateWalletRechargeRefunded][钱包充值({}) payRefund({}) 退款状态({}) 未结束,直接返回]", + id, payRefundId, payRefund.getStatus()); + return; } - // 3. 更新钱包充值的退款字段 - walletRechargeMapper.updateByIdAndRefunded(id, WAITING.getStatus(), updateObj); - } + // 3. 更新钱包充值的退款字段:基于【未退款】状态的乐观锁,保证并发的重复回调,只有一次能操作钱包 + int updateCount = walletRechargeMapper.updateByIdAndRefunded(id, WAITING.getStatus(), updateObj); + if (updateCount == 0) { + log.warn("[updateWalletRechargeRefunded][钱包充值({}) payRefund({}) 不是【未退款】状态,可能被并发处理,直接返回]", + id, payRefundId); + return; + } - private PayRefundRespDTO validateWalletRechargeCanRefunded(PayWalletRechargeDO walletRecharge, Long payRefundId) { - // 1. 校验退款订单匹配 - if (notEqual(walletRecharge.getPayRefundId(), payRefundId)) { - log.error("[validateWalletRechargeCanRefunded][钱包充值({}) 退款单不匹配({}),请进行处理!钱包充值的数据是:{}]", - walletRecharge.getId(), payRefundId, toJsonString(walletRecharge)); - throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + // 4. 操作钱包:在同一事务中,钱包操作失败时,回滚上面的退款字段 + if (refundSuccess) { + // 4.1 退款成功:扣减钱包余额 + payWalletService.reduceWalletBalance(walletRecharge.getWalletId(), id, + PayWalletBizTypeEnum.RECHARGE_REFUND, walletRecharge.getTotalPrice()); + } else { + // 4.2 退款失败:解冻余额 + payWalletService.unfreezePrice(walletRecharge.getWalletId(), walletRecharge.getTotalPrice()); } + } - // 2.1 校验退款订单 + private PayRefundRespDTO validateWalletRechargeCanRefunded(PayWalletRechargeDO walletRecharge, Long payRefundId) { + // 1. 校验退款订单 PayRefundRespDTO payRefund = payRefundApi.getRefund(payRefundId); if (payRefund == null) { log.error("[validateWalletRechargeCanRefunded][payRefund({})不存在]", payRefundId); throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_NOT_FOUND); } - // 2.2 校验退款金额一致 + // 2. 校验退款金额一致 if (notEqual(payRefund.getRefundPrice(), walletRecharge.getPayPrice())) { log.error("[validateWalletRechargeCanRefunded][钱包({}) payRefund({}) 退款金额不匹配,请进行处理!钱包数据是:{},payRefund 数据是:{}]", walletRecharge.getId(), payRefundId, toJsonString(walletRecharge), toJsonString(payRefund)); throw exception(WALLET_RECHARGE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH); } - // 2.3 校验退款订单商户退款单是否匹配 + // 3. 校验退款订单商户退款单是否匹配 if (notEqual(payRefund.getMerchantRefundId(), walletRecharge.getId() + "-refund")) { log.error("[validateWalletRechargeCanRefunded][钱包({}) 退款单不匹配({}),请进行处理!payRefund 数据是:{}]", walletRecharge.getId(), payRefundId, toJsonString(payRefund)); diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImplUnitTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImplUnitTest.java new file mode 100644 index 0000000..1708d3b --- /dev/null +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImplUnitTest.java @@ -0,0 +1,388 @@ +package cn.iocoder.yudao.module.pay.service.wallet; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pay.api.refund.PayRefundApi; +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO; +import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO; +import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper; +import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; +import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum; +import cn.iocoder.yudao.module.pay.service.order.PayOrderService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InOrder; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link PayWalletRechargeServiceImpl} 的单元测试类 + * + * 重点覆盖 {@link PayWalletRechargeServiceImpl#updateWalletRechargerPaid(Long, Long)}:回调传入的支付单编号, + * 必须等于充值记录绑定的支付单编号,避免其它支付应用中,相同商户订单编号、金额的支付单被用于串单 + * + * 以及 {@link PayWalletRechargeServiceImpl#updateWalletRechargeRefunded(Long, String, Long)}:回调传入的退款单编号, + * 必须等于充值记录绑定的退款单编号;已是退款终态的重复回调直接返回;先基于【未退款】状态更新退款字段成功,才操作一次钱包 + * + * @author 芋道源码 + */ +public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { + + private static final long RECHARGE_ID = 41L; + private static final long WALLET_ID = 1L; + /** + * 充值记录绑定的支付单编号 + */ + private static final long PAY_ORDER_ID = 100L; + /** + * 其它支付单编号,例如说其它支付应用中,相同商户订单编号、金额的支付单 + */ + private static final long OTHER_PAY_ORDER_ID = 200L; + private static final int PAY_PRICE = 500; + private static final int TOTAL_PRICE = 600; + private static final String CHANNEL_CODE = "wx_lite"; + /** + * 充值记录绑定的退款单编号 + */ + private static final long PAY_REFUND_ID = 300L; + /** + * 其它退款单编号 + */ + private static final long OTHER_PAY_REFUND_ID = 400L; + private static final String MERCHANT_REFUND_ID = RECHARGE_ID + "-refund"; + + @InjectMocks + private PayWalletRechargeServiceImpl walletRechargeService; + + @Mock + private PayWalletRechargeMapper walletRechargeMapper; + @Mock + private PayWalletService payWalletService; + @Mock + private PayOrderService payOrderService; + @Mock + private PayRefundApi payRefundApi; + + private MockedStatic springUtilMockedStatic; + + @BeforeEach + public void setUp() { + // mock getSelf() 使用的 SpringUtil,在正常路径中按需 mock 返回值 + springUtilMockedStatic = mockStatic(SpringUtil.class); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + @ParameterizedTest + @NullSource // 绑定的支付单编号尚未回写 + @ValueSource(longs = PAY_ORDER_ID) // 绑定的是其它支付单 + public void testUpdateWalletRechargerPaid_unpaidBindingMismatch(Long boundPayOrderId) { + // mock 数据:未支付的充值记录 + mockRecharge(false, boundPayOrderId); + + // 调用,并断言异常:在查询支付单前失败 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, OTHER_PAY_ORDER_ID), + WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR); + assertNoSideEffect(); + } + + @Test + public void testUpdateWalletRechargerPaid_success() { + // mock 数据:未支付的充值记录,绑定的支付单与回调一致 + PayWalletRechargeDO recharge = mockRecharge(false, PAY_ORDER_ID); + // mock 方法(支付单) + when(payOrderService.getOrder(PAY_ORDER_ID)).thenReturn(buildPayOrder()); + // mock 方法(更新支付状态) + when(walletRechargeMapper.updateByIdAndPaid(eq(RECHARGE_ID), eq(false), argThat(update -> + Boolean.TRUE.equals(update.getPayStatus()) && update.getPayTime() != null + && Objects.equals(update.getPayChannelCode(), CHANNEL_CODE)))).thenReturn(1); + // mock 方法(订阅消息,异步执行,这里只断言被调用) + PayWalletRechargeServiceImpl self = mock(PayWalletRechargeServiceImpl.class); + springUtilMockedStatic.when(() -> SpringUtil.getBean(PayWalletRechargeServiceImpl.class)).thenReturn(self); + + // 调用 + walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID); + // 断言:增加钱包余额;发送订阅消息 + verify(payWalletService).addWalletBalance(WALLET_ID, String.valueOf(RECHARGE_ID), + PayWalletBizTypeEnum.RECHARGE, TOTAL_PRICE); + verify(self).sendWalletRechargerPaidMessage(PAY_ORDER_ID, recharge); + } + + @Test + public void testUpdateWalletRechargerPaid_paidDuplicate() { + // mock 数据:已支付的充值记录 + mockRecharge(true, PAY_ORDER_ID); + + // 调用:重复回调,直接返回 + walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID); + // 断言 + assertNoSideEffect(); + } + + @Test + public void testUpdateWalletRechargerPaid_paidBindingMismatch() { + // mock 数据:已支付的充值记录 + mockRecharge(true, PAY_ORDER_ID); + + // 调用,并断言异常 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, OTHER_PAY_ORDER_ID), + WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR); + assertNoSideEffect(); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPayOrders") + public void testUpdateWalletRechargerPaid_payOrderInvalid(String name, UnaryOperator payOrderModifier, + ErrorCode errorCode) { + // mock 数据:未支付的充值记录,绑定的支付单与回调一致 + mockRecharge(false, PAY_ORDER_ID); + // mock 方法(支付单) + when(payOrderService.getOrder(PAY_ORDER_ID)).thenReturn(payOrderModifier.apply(buildPayOrder())); + + // 调用,并断言异常:绑定一致时,原有的支付单校验仍然执行 + assertServiceException(() -> walletRechargeService.updateWalletRechargerPaid(RECHARGE_ID, PAY_ORDER_ID), + errorCode); + verify(walletRechargeMapper).selectById(RECHARGE_ID); + verifyNoMoreInteractions(walletRechargeMapper); + verifyNoInteractions(payWalletService); + springUtilMockedStatic.verifyNoInteractions(); + } + + private static Stream invalidPayOrders() { + return Stream.of( + Arguments.of("支付单不存在", (UnaryOperator) payOrder -> null, + PAY_ORDER_NOT_FOUND), + Arguments.of("支付单未支付", (UnaryOperator) payOrder -> payOrder + .setStatus(PayOrderStatusEnum.WAITING.getStatus()), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_STATUS_NOT_SUCCESS), + Arguments.of("支付金额不一致", (UnaryOperator) payOrder -> payOrder + .setPrice(PAY_PRICE - 1), WALLET_RECHARGE_UPDATE_PAID_PAY_PRICE_NOT_MATCH), + Arguments.of("商户订单编号不一致", (UnaryOperator) payOrder -> payOrder + .setMerchantOrderId(String.valueOf(RECHARGE_ID + 1)), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR)); + } + + // ========== updateWalletRechargeRefunded ========== + + @ParameterizedTest + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"}) + public void testUpdateWalletRechargeRefunded_refundedDuplicate(PayRefundStatusEnum refundStatus) { + // mock 数据:已是退款终态的充值记录 + mockRefundRecharge(refundStatus, PAY_REFUND_ID); + + // 调用:重复回调,直接返回 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID); + // 断言:不查询退款单、不更新充值记录、不操作钱包 + assertNoRefundSideEffect(); + } + + @ParameterizedTest + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"}) + public void testUpdateWalletRechargeRefunded_refundedBindingMismatch(PayRefundStatusEnum refundStatus) { + // mock 数据:已是退款终态的充值记录 + mockRefundRecharge(refundStatus, PAY_REFUND_ID); + + // 调用,并断言异常 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded( + RECHARGE_ID, MERCHANT_REFUND_ID, OTHER_PAY_REFUND_ID), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + assertNoRefundSideEffect(); + } + + @ParameterizedTest + @NullSource // 绑定的退款单编号尚未回写 + @ValueSource(longs = PAY_REFUND_ID) // 绑定的是其它退款单 + public void testUpdateWalletRechargeRefunded_waitingBindingMismatch(Long boundPayRefundId) { + // mock 数据:未退款的充值记录 + mockRefundRecharge(PayRefundStatusEnum.WAITING, boundPayRefundId); + + // 调用,并断言异常:在查询退款单前失败 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded( + RECHARGE_ID, MERCHANT_REFUND_ID, OTHER_PAY_REFUND_ID), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR); + assertNoRefundSideEffect(); + } + + @Test + public void testUpdateWalletRechargeRefunded_success() { + // mock 数据:未退款的充值记录 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID); + // mock 方法(退款单) + LocalDateTime successTime = LocalDateTime.of(2026, 10, 28, 12, 0); + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.SUCCESS) + .setSuccessTime(successTime)); + // mock 方法(更新退款状态) + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()), + argThat(update -> Objects.equals(update.getId(), RECHARGE_ID) + && Objects.equals(update.getRefundStatus(), PayRefundStatusEnum.SUCCESS.getStatus()) + && Objects.equals(update.getRefundTime(), successTime) + && Objects.equals(update.getRefundTotalPrice(), TOTAL_PRICE) + && Objects.equals(update.getRefundPayPrice(), PAY_PRICE) + && Objects.equals(update.getRefundBonusPrice(), TOTAL_PRICE - PAY_PRICE)))).thenReturn(1); + + // 调用 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID); + // 断言:先认领退款状态,再扣减钱包余额,且只扣减一次 + InOrder inOrder = inOrder(walletRechargeMapper, payWalletService); + inOrder.verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID), + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class)); + inOrder.verify(payWalletService).reduceWalletBalance(WALLET_ID, RECHARGE_ID, + PayWalletBizTypeEnum.RECHARGE_REFUND, TOTAL_PRICE); + verifyNoMoreInteractions(payWalletService); + } + + @Test + public void testUpdateWalletRechargeRefunded_failure() { + // mock 数据:未退款的充值记录 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID); + // mock 方法(退款单) + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.FAILURE)); + // mock 方法(更新退款状态) + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()), + argThat(update -> Objects.equals(update.getId(), RECHARGE_ID) + && Objects.equals(update.getRefundStatus(), PayRefundStatusEnum.FAILURE.getStatus()) + && update.getRefundTime() == null && update.getRefundTotalPrice() == null + && update.getRefundPayPrice() == null && update.getRefundBonusPrice() == null))).thenReturn(1); + + // 调用 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID); + // 断言:先认领退款状态,再解冻余额,且只解冻一次 + InOrder inOrder = inOrder(walletRechargeMapper, payWalletService); + inOrder.verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID), + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class)); + inOrder.verify(payWalletService).unfreezePrice(WALLET_ID, TOTAL_PRICE); + verifyNoMoreInteractions(payWalletService); + } + + @ParameterizedTest + @EnumSource(value = PayRefundStatusEnum.class, names = {"SUCCESS", "FAILURE"}) + public void testUpdateWalletRechargeRefunded_updateStatusFail(PayRefundStatusEnum payRefundStatus) { + // mock 数据:未退款的充值记录 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID); + // mock 方法(退款单) + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(payRefundStatus)); + // mock 方法(更新退款状态失败,例如说被并发的重复回调处理) + when(walletRechargeMapper.updateByIdAndRefunded(eq(RECHARGE_ID), eq(PayRefundStatusEnum.WAITING.getStatus()), + any(PayWalletRechargeDO.class))).thenReturn(0); + + // 调用 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID); + // 断言:不操作钱包 + verify(walletRechargeMapper).updateByIdAndRefunded(eq(RECHARGE_ID), + eq(PayRefundStatusEnum.WAITING.getStatus()), any(PayWalletRechargeDO.class)); + verifyNoInteractions(payWalletService); + } + + @Test + public void testUpdateWalletRechargeRefunded_refundNotFinished() { + // mock 数据:未退款的充值记录 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID); + // mock 方法(退款单未结束) + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn(buildPayRefund(PayRefundStatusEnum.WAITING)); + + // 调用 + walletRechargeService.updateWalletRechargeRefunded(RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID); + // 断言:不更新退款状态、不操作钱包 + verify(walletRechargeMapper).selectById(RECHARGE_ID); + verifyNoMoreInteractions(walletRechargeMapper); + verifyNoInteractions(payWalletService); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPayRefunds") + public void testUpdateWalletRechargeRefunded_payRefundInvalid(String name, + UnaryOperator payRefundModifier, + ErrorCode errorCode) { + // mock 数据:未退款的充值记录,绑定的退款单与回调一致 + mockRefundRecharge(PayRefundStatusEnum.WAITING, PAY_REFUND_ID); + // mock 方法(退款单) + when(payRefundApi.getRefund(PAY_REFUND_ID)).thenReturn( + payRefundModifier.apply(buildPayRefund(PayRefundStatusEnum.SUCCESS))); + + // 调用,并断言异常:绑定一致时,原有的退款单校验仍然执行 + assertServiceException(() -> walletRechargeService.updateWalletRechargeRefunded( + RECHARGE_ID, MERCHANT_REFUND_ID, PAY_REFUND_ID), errorCode); + verify(walletRechargeMapper).selectById(RECHARGE_ID); + verifyNoMoreInteractions(walletRechargeMapper); + verifyNoInteractions(payWalletService); + } + + private static Stream invalidPayRefunds() { + return Stream.of( + Arguments.of("退款单不存在", (UnaryOperator) payRefund -> null, + WALLET_RECHARGE_REFUND_FAIL_REFUND_NOT_FOUND), + Arguments.of("退款金额不一致", (UnaryOperator) payRefund -> payRefund + .setRefundPrice(PAY_PRICE - 1), WALLET_RECHARGE_REFUND_FAIL_REFUND_PRICE_NOT_MATCH), + Arguments.of("商户退款单编号不一致", (UnaryOperator) payRefund -> payRefund + .setMerchantRefundId((RECHARGE_ID + 1) + "-refund"), WALLET_RECHARGE_REFUND_FAIL_REFUND_ORDER_ID_ERROR)); + } + + private PayWalletRechargeDO mockRecharge(boolean payStatus, Long payOrderId) { + PayWalletRechargeDO recharge = new PayWalletRechargeDO().setId(RECHARGE_ID).setWalletId(WALLET_ID) + .setPayStatus(payStatus).setPayOrderId(payOrderId) + .setPayPrice(PAY_PRICE).setBonusPrice(TOTAL_PRICE - PAY_PRICE).setTotalPrice(TOTAL_PRICE); + when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge); + return recharge; + } + + private static PayOrderDO buildPayOrder() { + return new PayOrderDO().setId(PAY_ORDER_ID).setStatus(PayOrderStatusEnum.SUCCESS.getStatus()) + .setPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID)).setChannelCode(CHANNEL_CODE); + } + + private void mockRefundRecharge(PayRefundStatusEnum refundStatus, Long payRefundId) { + PayWalletRechargeDO recharge = new PayWalletRechargeDO().setId(RECHARGE_ID).setWalletId(WALLET_ID) + .setPayStatus(true).setPayOrderId(PAY_ORDER_ID) + .setPayPrice(PAY_PRICE).setBonusPrice(TOTAL_PRICE - PAY_PRICE).setTotalPrice(TOTAL_PRICE) + .setPayRefundId(payRefundId).setRefundStatus(refundStatus.getStatus()); + when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge); + } + + private static PayRefundRespDTO buildPayRefund(PayRefundStatusEnum status) { + return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(status.getStatus()) + .setRefundPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID)) + .setMerchantRefundId(MERCHANT_REFUND_ID); + } + + /** + * 断言:只查询了充值记录,没有查询支付单、写入充值记录、增加余额或发送消息 + */ + private void assertNoSideEffect() { + verify(walletRechargeMapper).selectById(RECHARGE_ID); + verifyNoMoreInteractions(walletRechargeMapper); + verifyNoInteractions(payOrderService, payWalletService); + springUtilMockedStatic.verifyNoInteractions(); + } + + /** + * 断言:只查询了充值记录,没有查询退款单、写入充值记录或操作钱包 + */ + private void assertNoRefundSideEffect() { + verify(walletRechargeMapper).selectById(RECHARGE_ID); + verifyNoMoreInteractions(walletRechargeMapper); + verifyNoInteractions(payRefundApi, payWalletService); + } + +}