Commit c842ebde474d5a38660d18b2bb9c8b2fd2c92632
1 parent
319a7e1f
fix(pay): 防止钱包充值并发退款重复冻结余额
Showing
2 changed files
with
138 additions
and
2 deletions
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() { |