Commit c842ebde474d5a38660d18b2bb9c8b2fd2c92632

Authored by YunaiV
1 parent 319a7e1f

fix(pay): 防止钱包充值并发退款重复冻结余额

yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImpl.java
@@ -197,8 +197,10 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService { @@ -197,8 +197,10 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
197 @Override 197 @Override
198 @Transactional(rollbackFor = Exception.class) 198 @Transactional(rollbackFor = Exception.class)
199 public void refundWalletRecharge(Long id, String userIp) { 199 public void refundWalletRecharge(Long id, String userIp) {
200 - // 1.1 获取钱包充值记录  
201 - PayWalletRechargeDO walletRecharge = walletRechargeMapper.selectById(id); 200 + // 1.1 获取钱包充值记录,并使用 FOR UPDATE 锁定
  201 + // 原因是:并发发起退款时,避免都读到未退款(payRefundId 为空)的旧记录,导致重复冻结余额。
  202 + // 加锁后,同一充值记录串行发起:后发起的请求等待前一个事务提交,读取到已绑定的退款单,从而拒绝重复退款
  203 + PayWalletRechargeDO walletRecharge = walletRechargeMapper.selectOneForUpdate(PayWalletRechargeDO::getId, id);
202 if (walletRecharge == null) { 204 if (walletRecharge == null) {
203 log.error("[refundWalletRecharge][钱包充值记录不存在,钱包充值记录 id({})]", id); 205 log.error("[refundWalletRecharge][钱包充值记录不存在,钱包充值记录 id({})]", id);
204 throw exception(WALLET_RECHARGE_NOT_FOUND); 206 throw exception(WALLET_RECHARGE_NOT_FOUND);
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletRechargeServiceImplUnitTest.java
1 package cn.iocoder.yudao.module.pay.service.wallet; 1 package cn.iocoder.yudao.module.pay.service.wallet;
2 2
3 import cn.hutool.extra.spring.SpringUtil; 3 import cn.hutool.extra.spring.SpringUtil;
  4 +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
4 import cn.iocoder.yudao.framework.common.exception.ErrorCode; 5 import cn.iocoder.yudao.framework.common.exception.ErrorCode;
5 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; 6 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.PayRefundApi;
  8 +import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO;
7 import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO; 9 import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundRespDTO;
8 import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; 10 import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO;
  11 +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
9 import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO; 12 import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletRechargeDO;
10 import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper; 13 import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper;
11 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; 14 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
12 import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; 15 import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
13 import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum; 16 import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
  17 +import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
14 import cn.iocoder.yudao.module.pay.service.order.PayOrderService; 18 import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
  19 +import com.baomidou.mybatisplus.core.toolkit.support.SFunction;
15 import org.junit.jupiter.api.AfterEach; 20 import org.junit.jupiter.api.AfterEach;
16 import org.junit.jupiter.api.BeforeEach; 21 import org.junit.jupiter.api.BeforeEach;
17 import org.junit.jupiter.api.Test; 22 import org.junit.jupiter.api.Test;
@@ -26,11 +31,13 @@ import org.mockito.InjectMocks; @@ -26,11 +31,13 @@ import org.mockito.InjectMocks;
26 import org.mockito.Mock; 31 import org.mockito.Mock;
27 import org.mockito.MockedStatic; 32 import org.mockito.MockedStatic;
28 33
  34 +import java.io.Serializable;
29 import java.time.LocalDateTime; 35 import java.time.LocalDateTime;
30 import java.util.Objects; 36 import java.util.Objects;
31 import java.util.function.UnaryOperator; 37 import java.util.function.UnaryOperator;
32 import java.util.stream.Stream; 38 import java.util.stream.Stream;
33 39
  40 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
34 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; 41 import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
35 import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; 42 import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
36 import static org.mockito.ArgumentMatchers.any; 43 import static org.mockito.ArgumentMatchers.any;
@@ -47,6 +54,9 @@ import static org.mockito.Mockito.*; @@ -47,6 +54,9 @@ import static org.mockito.Mockito.*;
47 * 以及 {@link PayWalletRechargeServiceImpl#updateWalletRechargeRefunded(Long, String, Long)}:回调传入的退款单编号, 54 * 以及 {@link PayWalletRechargeServiceImpl#updateWalletRechargeRefunded(Long, String, Long)}:回调传入的退款单编号,
48 * 必须等于充值记录绑定的退款单编号;已是退款终态的重复回调直接返回;先基于【未退款】状态更新退款字段成功,才操作一次钱包 55 * 必须等于充值记录绑定的退款单编号;已是退款终态的重复回调直接返回;先基于【未退款】状态更新退款字段成功,才操作一次钱包
49 * 56 *
  57 + * 以及 {@link PayWalletRechargeServiceImpl#refundWalletRecharge(Long, String)}:使用 FOR UPDATE 加锁读取充值记录后,
  58 + * 再校验、冻结余额、创建退款单,避免并发发起时重复冻结余额。注意:行锁、事务回滚的实际效果,需要在真实数据库中验证
  59 + *
50 * @author 芋道源码 60 * @author 芋道源码
51 */ 61 */
52 public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { 62 public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
@@ -73,6 +83,10 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { @@ -73,6 +83,10 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
73 */ 83 */
74 private static final long OTHER_PAY_REFUND_ID = 400L; 84 private static final long OTHER_PAY_REFUND_ID = 400L;
75 private static final String MERCHANT_REFUND_ID = RECHARGE_ID + "-refund"; 85 private static final String MERCHANT_REFUND_ID = RECHARGE_ID + "-refund";
  86 + private static final long USER_ID = 10L;
  87 + private static final Integer USER_TYPE = UserTypeEnum.MEMBER.getValue();
  88 + private static final String USER_IP = "127.0.0.1";
  89 + private static final String WALLET_PAY_APP_KEY = "wallet";
76 90
77 @InjectMocks 91 @InjectMocks
78 private PayWalletRechargeServiceImpl walletRechargeService; 92 private PayWalletRechargeServiceImpl walletRechargeService;
@@ -85,6 +99,8 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { @@ -85,6 +99,8 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
85 private PayOrderService payOrderService; 99 private PayOrderService payOrderService;
86 @Mock 100 @Mock
87 private PayRefundApi payRefundApi; 101 private PayRefundApi payRefundApi;
  102 + @Mock
  103 + private PayProperties payProperties;
88 104
89 private MockedStatic<SpringUtil> springUtilMockedStatic; 105 private MockedStatic<SpringUtil> springUtilMockedStatic;
90 106
@@ -186,6 +202,103 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { @@ -186,6 +202,103 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
186 .setMerchantOrderId(String.valueOf(RECHARGE_ID + 1)), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR)); 202 .setMerchantOrderId(String.valueOf(RECHARGE_ID + 1)), WALLET_RECHARGE_UPDATE_PAID_PAY_ORDER_ID_ERROR));
187 } 203 }
188 204
  205 + // ========== refundWalletRecharge ==========
  206 +
  207 + @Test
  208 + public void testRefundWalletRecharge_success() {
  209 + // mock 数据:已支付、未退款的充值记录;钱包余额刚好足够
  210 + mockRefundableRecharge(true, null);
  211 + when(payWalletService.getWallet(WALLET_ID)).thenReturn(buildWallet(TOTAL_PRICE));
  212 + // mock 方法(创建退款单)
  213 + when(payProperties.getWalletPayAppKey()).thenReturn(WALLET_PAY_APP_KEY);
  214 + when(payRefundApi.createRefund(argThat(reqDTO -> Objects.equals(reqDTO.getAppKey(), WALLET_PAY_APP_KEY)
  215 + && Objects.equals(reqDTO.getUserIp(), USER_IP)
  216 + && Objects.equals(reqDTO.getUserId(), USER_ID) && Objects.equals(reqDTO.getUserType(), USER_TYPE)
  217 + && Objects.equals(reqDTO.getMerchantOrderId(), String.valueOf(RECHARGE_ID))
  218 + && Objects.equals(reqDTO.getMerchantRefundId(), MERCHANT_REFUND_ID)
  219 + && Objects.equals(reqDTO.getPrice(), PAY_PRICE)))).thenReturn(PAY_REFUND_ID);
  220 +
  221 + // 调用
  222 + walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP);
  223 + // 断言:加锁读取充值记录 -> 校验钱包 -> 冻结余额 -> 创建退款单 -> 回写退款单编号、未退款状态
  224 + InOrder inOrder = inOrder(walletRechargeMapper, payWalletService, payRefundApi);
  225 + inOrder.verify(walletRechargeMapper).selectOneForUpdate(any(SFunction.class), eq(RECHARGE_ID));
  226 + inOrder.verify(payWalletService).getWallet(WALLET_ID);
  227 + inOrder.verify(payWalletService).freezePrice(WALLET_ID, TOTAL_PRICE);
  228 + inOrder.verify(payRefundApi).createRefund(any(PayRefundCreateReqDTO.class));
  229 + inOrder.verify(walletRechargeMapper).updateById(argThat((PayWalletRechargeDO update) ->
  230 + Objects.equals(update.getId(), RECHARGE_ID) && Objects.equals(update.getPayRefundId(), PAY_REFUND_ID)
  231 + && Objects.equals(update.getRefundStatus(), PayRefundStatusEnum.WAITING.getStatus())));
  232 + // 断言:没有使用普通(不加锁)的查询,也没有其它调用
  233 + verify(walletRechargeMapper, never()).selectById(any(Serializable.class));
  234 + verifyNoMoreInteractions(walletRechargeMapper, payWalletService, payRefundApi);
  235 + }
  236 +
  237 + @Test
  238 + public void testRefundWalletRecharge_refunded() {
  239 + // mock 数据:已绑定退款单的充值记录,例如说并发发起时,前一个请求已提交
  240 + mockRefundableRecharge(true, PAY_REFUND_ID);
  241 +
  242 + // 调用,并断言异常
  243 + assertServiceException(() -> walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP),
  244 + WALLET_RECHARGE_REFUND_FAIL_REFUNDED);
  245 + assertNoRefundWalletRechargeSideEffect();
  246 + }
  247 +
  248 + @Test
  249 + public void testRefundWalletRecharge_notPaid() {
  250 + // mock 数据:未支付的充值记录
  251 + mockRefundableRecharge(false, null);
  252 +
  253 + // 调用,并断言异常
  254 + assertServiceException(() -> walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP),
  255 + WALLET_RECHARGE_REFUND_FAIL_NOT_PAID);
  256 + assertNoRefundWalletRechargeSideEffect();
  257 + }
  258 +
  259 + @Test
  260 + public void testRefundWalletRecharge_notFound() {
  261 + // 调用(充值记录不存在),并断言异常
  262 + assertServiceException(() -> walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP),
  263 + WALLET_RECHARGE_NOT_FOUND);
  264 + assertNoRefundWalletRechargeSideEffect();
  265 + }
  266 +
  267 + @Test
  268 + public void testRefundWalletRecharge_balanceNotEnough() {
  269 + // mock 数据:已支付、未退款的充值记录;钱包余额不足
  270 + mockRefundableRecharge(true, null);
  271 + when(payWalletService.getWallet(WALLET_ID)).thenReturn(buildWallet(TOTAL_PRICE - 1));
  272 +
  273 + // 调用,并断言异常
  274 + assertServiceException(() -> walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP),
  275 + WALLET_RECHARGE_REFUND_BALANCE_NOT_ENOUGH);
  276 + // 断言:不冻结余额、不创建退款单、不回写充值记录
  277 + verify(walletRechargeMapper).selectOneForUpdate(any(SFunction.class), eq(RECHARGE_ID));
  278 + verifyNoMoreInteractions(walletRechargeMapper);
  279 + verify(payWalletService).getWallet(WALLET_ID);
  280 + verifyNoMoreInteractions(payWalletService);
  281 + verifyNoInteractions(payRefundApi);
  282 + }
  283 +
  284 + @Test
  285 + public void testRefundWalletRecharge_createRefundFail() {
  286 + // mock 数据:已支付、未退款的充值记录
  287 + mockRefundableRecharge(true, null);
  288 + when(payWalletService.getWallet(WALLET_ID)).thenReturn(buildWallet(TOTAL_PRICE));
  289 + // mock 方法(创建退款单失败)
  290 + when(payProperties.getWalletPayAppKey()).thenReturn(WALLET_PAY_APP_KEY);
  291 + when(payRefundApi.createRefund(any(PayRefundCreateReqDTO.class))).thenThrow(exception(REFUND_HAS_REFUNDING));
  292 +
  293 + // 调用,并断言异常
  294 + assertServiceException(() -> walletRechargeService.refundWalletRecharge(RECHARGE_ID, USER_IP),
  295 + REFUND_HAS_REFUNDING);
  296 + // 断言:异常向上抛出,不回写退款单编号。注意:冻结余额的撤销依赖 @Transactional 回滚,此处 mock 环境不验证
  297 + verify(walletRechargeMapper).selectOneForUpdate(any(SFunction.class), eq(RECHARGE_ID));
  298 + verifyNoMoreInteractions(walletRechargeMapper);
  299 + verify(payWalletService).freezePrice(WALLET_ID, TOTAL_PRICE);
  300 + }
  301 +
189 // ========== updateWalletRechargeRefunded ========== 302 // ========== updateWalletRechargeRefunded ==========
190 303
191 @ParameterizedTest 304 @ParameterizedTest
@@ -360,6 +473,18 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { @@ -360,6 +473,18 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
360 when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge); 473 when(walletRechargeMapper.selectById(RECHARGE_ID)).thenReturn(recharge);
361 } 474 }
362 475
  476 + private void mockRefundableRecharge(boolean payStatus, Long payRefundId) {
  477 + PayWalletRechargeDO recharge = new PayWalletRechargeDO().setId(RECHARGE_ID).setWalletId(WALLET_ID)
  478 + .setPayStatus(payStatus).setPayOrderId(PAY_ORDER_ID)
  479 + .setPayPrice(PAY_PRICE).setBonusPrice(TOTAL_PRICE - PAY_PRICE).setTotalPrice(TOTAL_PRICE)
  480 + .setPayRefundId(payRefundId);
  481 + when(walletRechargeMapper.selectOneForUpdate(any(SFunction.class), eq(RECHARGE_ID))).thenReturn(recharge);
  482 + }
  483 +
  484 + private static PayWalletDO buildWallet(int balance) {
  485 + return new PayWalletDO().setId(WALLET_ID).setUserId(USER_ID).setUserType(USER_TYPE).setBalance(balance);
  486 + }
  487 +
363 private static PayRefundRespDTO buildPayRefund(PayRefundStatusEnum status) { 488 private static PayRefundRespDTO buildPayRefund(PayRefundStatusEnum status) {
364 return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(status.getStatus()) 489 return new PayRefundRespDTO().setId(PAY_REFUND_ID).setStatus(status.getStatus())
365 .setRefundPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID)) 490 .setRefundPrice(PAY_PRICE).setMerchantOrderId(String.valueOf(RECHARGE_ID))
@@ -377,6 +502,15 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest { @@ -377,6 +502,15 @@ public class PayWalletRechargeServiceImplUnitTest extends BaseMockitoUnitTest {
377 } 502 }
378 503
379 /** 504 /**
  505 + * 断言:只加锁查询了充值记录,没有查询钱包、冻结余额、创建退款单或写入充值记录
  506 + */
  507 + private void assertNoRefundWalletRechargeSideEffect() {
  508 + verify(walletRechargeMapper).selectOneForUpdate(any(SFunction.class), eq(RECHARGE_ID));
  509 + verifyNoMoreInteractions(walletRechargeMapper);
  510 + verifyNoInteractions(payWalletService, payRefundApi);
  511 + }
  512 +
  513 + /**
380 * 断言:只查询了充值记录,没有查询退款单、写入充值记录或操作钱包 514 * 断言:只查询了充值记录,没有查询退款单、写入充值记录或操作钱包
381 */ 515 */
382 private void assertNoRefundSideEffect() { 516 private void assertNoRefundSideEffect() {