From ed24181e710e62d7b65f9357073516d99771f9ac Mon Sep 17 00:00:00 2001 From: YunaiV Date: Tue, 6 Oct 2026 19:26:27 +0800 Subject: [PATCH] fix(pay): 收敛退款转账并发与待确认状态 --- yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java | 3 +++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java | 3 +++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java | 19 +++++++++++++++++++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/transfer/PayTransferMapper.java | 23 +++++++++++++++++++++++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java | 18 ++++++++++++++++++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java | 40 ++++++++++++++++++++++++++++++++++++++++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java | 40 ++++++++++++++++++++++++++++++++++++++++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java | 3 +++ yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java | 249 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------- yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java | 183 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------- yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java | 48 ++++++++++++++++++++++++++++++++++++++---------- yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java | 522 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java | 433 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-module-pay/src/test/resources/sql/clean.sql | 1 + yudao-module-pay/src/test/resources/sql/create_tables.sql | 16 ++++++++++++++++ 16 files changed, 1606 insertions(+), 80 deletions(-) create mode 100644 yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java create mode 100644 yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java create mode 100644 yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java index fa768f8..6fd3e49 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java @@ -15,6 +15,9 @@ public interface PayRefundApi { /** * 创建退款单 * + * 注意:不加入调用方的事务,退款单、退款结果(包括退款通知)独立提交,调用方的事务回滚时不会回滚。 + * 调用方需要使用相同的 merchantRefundId 重新发起,并通过退款通知处理最终结果;渠道明确失败时,会抛出渠道错误的业务异常 + * * @param reqDTO 创建请求 * @return 退款单编号 */ diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java index e3227eb..4502785 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java @@ -15,6 +15,9 @@ public interface PayTransferApi { /** * 创建转账单 * + * 注意:不加入调用方的事务,转账单、转账结果(包括转账通知)独立提交,调用方的事务回滚时不会回滚。 + * 调用方需要使用相同的 merchantTransferId 重新发起,并通过转账通知处理最终结果;渠道明确失败时,会抛出渠道错误的业务异常 + * * @param reqDTO 创建请求 * @return 创建结果 */ diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java index 0b620ea..34af794 100755 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java @@ -7,6 +7,7 @@ import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundExportReq import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO; import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.apache.ibatis.annotations.Mapper; import java.util.List; @@ -46,6 +47,24 @@ public interface PayRefundMapper extends BaseMapperX { .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status)); } + /** + * 更新退款单,并清空上一次退款的渠道结果 + * + * 用于退款失败后重新发起退款,避免新的退款结果残留上一次的失败原因、渠道退款单号等 + * + * @param id 退款单编号 + * @param status 原状态 + * @param update 更新对象 + * @return 更新数量 + */ + default int updateByIdAndStatusAndClearChannelResult(Long id, Integer status, PayRefundDO update) { + return update(update, new LambdaUpdateWrapper() + .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status) + .set(PayRefundDO::getChannelRefundNo, null).set(PayRefundDO::getSuccessTime, null) + .set(PayRefundDO::getChannelErrorCode, null).set(PayRefundDO::getChannelErrorMsg, null) + .set(PayRefundDO::getChannelNotifyData, null)); + } + default PageResult selectPage(PayRefundPageReqVO reqVO) { return selectPage(reqVO, new LambdaQueryWrapperX() .eqIfPresent(PayRefundDO::getAppId, reqVO.getAppId()) diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/transfer/PayTransferMapper.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/transfer/PayTransferMapper.java index b6fb5f3..44ed504 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/transfer/PayTransferMapper.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/transfer/PayTransferMapper.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.pay.controller.admin.transfer.vo.PayTransferPageReqVO; import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import org.apache.ibatis.annotations.Mapper; import java.util.Collection; @@ -26,6 +27,28 @@ public interface PayTransferMapper extends BaseMapperX { .eq(PayTransferDO::getStatus, whereStatus)); } + /** + * 更新转账单,并清空上一次转账的渠道结果 + * + * 用于转账关闭后重新发起转账,避免新的转账残留上一次的失败原因、渠道转账单号、确认收款 package 信息等 + * 特殊:本次请求没有传递的可选字段(收款人姓名、渠道额外参数、回调地址),也需要清空。原因是:MyBatis Plus 更新时会忽略为 null 的字段 + * + * @param id 转账单编号 + * @param whereStatus 原状态 + * @param updateObj 更新对象 + * @return 更新数量 + */ + default int updateByIdAndStatusAndClearChannelResult(Long id, Integer whereStatus, PayTransferDO updateObj) { + return update(updateObj, new LambdaUpdateWrapper() + .eq(PayTransferDO::getId, id).eq(PayTransferDO::getStatus, whereStatus) + .set(PayTransferDO::getChannelTransferNo, null).set(PayTransferDO::getSuccessTime, null) + .set(PayTransferDO::getChannelErrorCode, null).set(PayTransferDO::getChannelErrorMsg, null) + .set(PayTransferDO::getChannelNotifyData, null).set(PayTransferDO::getChannelPackageInfo, null) + .set(updateObj.getUserName() == null, PayTransferDO::getUserName, null) + .set(updateObj.getChannelExtras() == null, PayTransferDO::getChannelExtras, null) + .set(updateObj.getNotifyUrl() == null, PayTransferDO::getNotifyUrl, null)); + } + default int updateChannelPackageInfoIfAbsent(Long id, String channelPackageInfo) { return update(new PayTransferDO().setChannelPackageInfo(channelPackageInfo), new LambdaQueryWrapper() diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java index 6de0e21..1c98e39 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java @@ -26,6 +26,24 @@ public interface RedisKeyConstants { String PAY_WALLET_LOCK = "pay_wallet:lock:%d"; /** + * 退款单创建的分布式锁 + * + * KEY 格式:pay_refund:lock:%d // 参数为支付订单编号 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定 + */ + String PAY_REFUND_LOCK = "pay_refund:lock:%d"; + + /** + * 转账单创建的分布式锁 + * + * KEY 格式:pay_transfer:lock:%d:%s // 参数为应用编号、商户转账单编号 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定 + */ + String PAY_TRANSFER_LOCK = "pay_transfer:lock:%d:%s"; + + /** * 支付序号的缓存 * * KEY 格式:pay_no:{prefix} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java new file mode 100644 index 0000000..48b7d13 --- /dev/null +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.pay.dal.redis.refund; + +import jakarta.annotation.Resource; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_REFUND_LOCK; + +/** + * 支付退款的锁 Redis DAO + * + * @author HUIHUI + */ +@Repository +public class PayRefundLockRedisDAO { + + @Resource + private RedissonClient redissonClient; + + public V lock(Long orderId, Long timeoutMillis, Supplier supplier) { + String lockKey = formatKey(orderId); + RLock lock = redissonClient.getLock(lockKey); + try { + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS); + // 执行逻辑 + return supplier.get(); + } finally { + lock.unlock(); + } + } + + private static String formatKey(Long orderId) { + return String.format(PAY_REFUND_LOCK, orderId); + } + +} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java new file mode 100644 index 0000000..db329e4 --- /dev/null +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.pay.dal.redis.transfer; + +import jakarta.annotation.Resource; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_TRANSFER_LOCK; + +/** + * 支付转账的锁 Redis DAO + * + * @author HUIHUI + */ +@Repository +public class PayTransferLockRedisDAO { + + @Resource + private RedissonClient redissonClient; + + public V lock(Long appId, String merchantTransferId, Long timeoutMillis, Supplier supplier) { + String lockKey = formatKey(appId, merchantTransferId); + RLock lock = redissonClient.getLock(lockKey); + try { + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS); + // 执行逻辑 + return supplier.get(); + } finally { + lock.unlock(); + } + } + + private static String formatKey(Long appId, String merchantTransferId) { + return String.format(PAY_TRANSFER_LOCK, appId, merchantTransferId); + } + +} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java index 160dad3..b2cee37 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java @@ -40,6 +40,9 @@ public interface ErrorCodeConstants { ErrorCode REFUND_EXISTS = new ErrorCode(1_007_006_003, "已经存在退款单"); ErrorCode REFUND_NOT_FOUND = new ErrorCode(1_007_006_004, "支付退款单不存在"); ErrorCode REFUND_STATUS_IS_NOT_WAITING = new ErrorCode(1_007_006_005, "支付退款单不处于待退款"); + ErrorCode REFUND_SUBMIT_CHANNEL_ERROR = new ErrorCode(1_007_006_006, "发起退款报错,错误码:{},错误提示:{}"); + ErrorCode REFUND_CREATE_FAIL_ORDER_NOT_MATCH = new ErrorCode(1_007_006_007, "发起退款失败,原因:两次相同退款请求的支付订单不匹配"); + ErrorCode REFUND_CREATE_FAIL_PRICE_NOT_MATCH = new ErrorCode(1_007_006_008, "发起退款失败,原因:两次相同退款请求的退款金额不匹配"); // ========== 钱包模块 1-007-007-000 ========== ErrorCode WALLET_NOT_FOUND = new ErrorCode(1_007_007_000, "用户钱包不存在"); diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java index 051d46c..4ece8f2 100755 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java @@ -1,8 +1,12 @@ package cn.iocoder.yudao.module.pay.service.refund; import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO; import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; @@ -17,6 +21,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; @@ -28,6 +33,7 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; @@ -47,6 +53,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; @Validated public class PayRefundServiceImpl implements PayRefundService { + /** + * 创建退款单的锁超时时间,单位:毫秒 + */ + private static final long CREATE_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS; + @Resource private PayProperties payProperties; @@ -54,6 +65,8 @@ public class PayRefundServiceImpl implements PayRefundService { private PayRefundMapper refundMapper; @Resource private PayNoRedisDAO noRedisDAO; + @Resource + private PayRefundLockRedisDAO refundLockRedisDAO; @Resource private PayOrderService orderService; @@ -90,6 +103,9 @@ public class PayRefundServiceImpl implements PayRefundService { } @Override + // 注意:不加入调用方的事务,退款单、退款结果(包括退款通知)各自独立提交。 + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的退款单,否则无法使用原退款单号重新发起 + @Transactional(propagation = Propagation.NOT_SUPPORTED) public Long createRefund(PayRefundCreateReqDTO reqDTO) { // 1.1 校验 App PayAppDO app = appService.validPayApp(reqDTO.getAppKey()); @@ -99,31 +115,18 @@ public class PayRefundServiceImpl implements PayRefundService { PayChannelDO channel = channelService.validPayChannel(order.getChannelId()); PayClient client = channelService.getPayClient(channel.getId()); if (client == null) { - log.error("[refund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); + log.error("[createRefund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); throw exception(CHANNEL_NOT_FOUND); } - // 1.4 校验退款订单是否已经存在 - PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId( - app.getId(), reqDTO.getMerchantRefundId()); - if (refund != null) { - throw exception(REFUND_EXISTS); - } + // 2. 加锁,创建退款单、或者重新发起退款失败的退款单 + // 原因是:同一支付订单串行创建退款单,避免 select 后 insert 时,并发请求重复创建退款单,导致重复退款 + // 注意:createRefund 不加入调用方的事务,退款单立即提交,所以释放锁后,其它请求可以查询到该退款单 + PayRefundDO refund = refundLockRedisDAO.lock(order.getId(), CREATE_LOCK_TIMEOUT_MILLIS, + () -> createOrRetryRefund(reqDTO, app, order)); - // 2.1 插入退款单 - String no = noRedisDAO.generate(payProperties.getRefundNoPrefix()); - refund = PayRefundConvert.INSTANCE.convert(reqDTO) - .setNo(no).setAppId(app.getId()).setOrderId(order.getId()).setOrderNo(order.getNo()) - .setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode()) - // 商户相关的字段 - .setNotifyUrl(app.getRefundNotifyUrl()) - // 渠道相关字段 - .setChannelOrderNo(order.getChannelOrderNo()) - // 退款相关字段 - .setStatus(PayRefundStatusEnum.WAITING.getStatus()) - .setPayPrice(order.getPrice()).setRefundPrice(reqDTO.getPrice()); - refundMapper.insert(refund); + // 3.1 调用三方渠道发起退款 + PayRefundRespDTO refundRespDTO = null; try { - // 2.2 向渠道发起退款申请 PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO() .setPayPrice(order.getPrice()) .setRefundPrice(reqDTO.getPrice()) @@ -131,22 +134,147 @@ public class PayRefundServiceImpl implements PayRefundService { .setOutRefundNo(refund.getNo()) .setNotifyUrl(genChannelRefundNotifyUrl(channel)) .setReason(reqDTO.getReason()); - PayRefundRespDTO refundRespDTO = client.unifiedRefund(unifiedReqDTO); - // 2.3 处理退款返回 - getSelf().notifyRefund(channel, refundRespDTO); + refundRespDTO = client.unifiedRefund(unifiedReqDTO); } catch (Throwable e) { // 注意:这里仅打印异常,不进行抛出。 - // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。这个结果,后续通过退款回调、或者退款轮询补偿可以拿到。 - // 最终,在异常的情况下,支付中心会异步回调业务的退款回调接口,提供退款结果 - log.error("[createPayRefund][退款 id({}) requestDTO({}) 发生异常]", - refund.getId(), reqDTO, e); + // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。 + // 所以下面会查询一次渠道退款单,查询不到明确结果时,后续通过退款回调、或者退款轮询补偿可以拿到 + log.error("[createRefund][退款编号({}) requestDTO({}) 发生异常]", refund.getId(), reqDTO, e); + } + // 3.2 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,避免误判 + // 关联知识星球帖子:https://t.zsxq.com/JRLye、https://t.zsxq.com/gDETH + if (refundRespDTO == null || PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) { + refundRespDTO = getRefundAfterUnifiedRefund(client, refund, refundRespDTO); } - // 返回退款编号 + // 4. 通知退款结果 + if (refundRespDTO != null) { + if (PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) { + // 4.1 情况一:退款失败 + PayRefundDO latestRefund = null; + try { + getSelf().notifyRefund(channel, refundRespDTO); + } catch (ServiceException ex) { + // 由于退款回调、退款轮询可能同时更新退款单,导致存在并发更新问题,此时以最新的退款状态为准 + latestRefund = validateRefundNotifyConcurrent(refund.getId(), ex); + log.warn("[createRefund][refund({}) channel({}) 退款结果({}) 通知时发生并发更新,最新状态为({})]", + refund.getId(), channel.getId(), refundRespDTO, latestRefund.getStatus(), ex); + } + // 如有渠道错误码,则抛出业务异常,提示用户 + // 特殊:如果已被并发更新为成功,则以最新状态为准,避免旧的失败结果误报 + if (StrUtil.isNotEmpty(refundRespDTO.getChannelErrorCode()) + && (latestRefund == null || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) { + throw exception(REFUND_SUBMIT_CHANNEL_ERROR, refundRespDTO.getChannelErrorCode(), + refundRespDTO.getChannelErrorMsg()); + } + } else { + // 4.2 情况二:退款成功、或者退款中 + // 注意:这里仅打印异常,不进行抛出。原因是:渠道可能已经退款成功,抛出会导致调用方回滚,后续通过退款回调、或者退款轮询补偿 + try { + getSelf().notifyRefund(channel, refundRespDTO); + } catch (Throwable e) { + log.error("[createRefund][退款编号({}) 退款结果({}) 通知时发生异常]", refund.getId(), refundRespDTO, e); + } + } + } return refund.getId(); } /** + * 创建退款单;如果存在退款失败的退款单,则重置为待退款,用于重新发起退款 + * + * @param reqDTO 退款申请信息 + * @param app 支付应用 + * @param order 支付订单 + * @return 退款单 + */ + private PayRefundDO createOrRetryRefund(PayRefundCreateReqDTO reqDTO, PayAppDO app, PayOrderDO order) { + // 1. 校验退款单是否可以发起:不存在,或者之前退款失败 + PayRefundDO refund = validateRefundCanCreate(reqDTO, app.getId(), order); + + if (refund == null) { + // 2.1 情况一:不存在退款单,则插入退款单 + String no = noRedisDAO.generate(payProperties.getRefundNoPrefix()); + refund = PayRefundConvert.INSTANCE.convert(reqDTO) + .setNo(no).setAppId(app.getId()).setOrderId(order.getId()).setOrderNo(order.getNo()) + .setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode()) + // 商户相关的字段 + .setNotifyUrl(app.getRefundNotifyUrl()) + // 渠道相关字段 + .setChannelOrderNo(order.getChannelOrderNo()) + // 退款相关字段 + .setStatus(PayRefundStatusEnum.WAITING.getStatus()) + .setPayPrice(order.getPrice()).setRefundPrice(reqDTO.getPrice()); + refundMapper.insert(refund); + } else { + // 2.2 情况二:存在退款失败的退款单,则复用原退款单(包括原退款单号)重新发起,并清空上一次退款的渠道结果 + // 原因是:微信、支付宝等渠道要求退款失败后重新提交时,使用原商户退款单号,避免上一次实际受理时重复退款。未校验一致的请求字段,以本次请求为准 + int updateCount = refundMapper.updateByIdAndStatusAndClearChannelResult(refund.getId(), refund.getStatus(), + new PayRefundDO().setStatus(PayRefundStatusEnum.WAITING.getStatus()) + .setReason(reqDTO.getReason()).setUserIp(reqDTO.getUserIp()) + .setNotifyUrl(app.getRefundNotifyUrl())); + if (updateCount == 0) { // 校验状态,避免并发重新发起退款 + throw exception(REFUND_HAS_REFUNDING); + } + } + return refund; + } + + /** + * 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,确认退款结果 + * + * 注意:只有查询到渠道已受理退款,才以查询结果为准;查询到的退款失败,不作为最终结果。 + * 原因是:一次查询可能存在渠道退款单尚未生成、或者结果尚未同步的情况,例如说微信、钱包查询不存在的退款单时返回失败 + * + * @param client 支付客户端 + * @param refund 退款单 + * @param unifiedRespDTO 发起退款的返回结果;为 null 时,表示发起退款发生异常 + * @return 退款结果;为 null 时,表示结果未知,保留 WAITING 交给退款回调、或者退款轮询补偿 + */ + private PayRefundRespDTO getRefundAfterUnifiedRefund(PayClient client, PayRefundDO refund, PayRefundRespDTO unifiedRespDTO) { + PayRefundRespDTO queryRespDTO = null; + try { + queryRespDTO = client.getRefund(refund.getOrderNo(), refund.getNo()); + } catch (Throwable e) { + log.warn("[getRefundAfterUnifiedRefund][退款编号({}) 查询渠道退款单发生异常]", refund.getId(), e); + } + // 情况一:查询到退款成功、或者已有渠道退款单号的退款中,说明渠道实际已受理退款,以查询结果为准 + if (queryRespDTO != null && (PayRefundStatusEnum.isSuccess(queryRespDTO.getStatus()) + || (PayRefundStatusEnum.WAITING.getStatus().equals(queryRespDTO.getStatus()) + && StrUtil.isNotEmpty(queryRespDTO.getChannelRefundNo())))) { + return queryRespDTO; + } + // 情况二:其它情况,以发起退款的结果为准 + // ① 发起退款发生异常:返回 null,保留 WAITING 交给退款回调、或者退款轮询补偿 + // ② 发起退款明确返回失败:以渠道明确返回的失败为准。原因是:支付宝查询不存在的退款单时返回 WAITING、钱包查询 WAITING 退款单时抛出异常,退款轮询无法补偿 + return unifiedRespDTO; + } + + /** + * 校验退款结果通知时的并发更新 + * + * 只有状态竞争导致的通知失败,并且退款单已被并发更新为成功或失败时,才返回最新的退款单; + * 否则,直接抛出原异常,避免掩盖真实的通知失败 + * + * @param id 退款单编号 + * @param ex 通知退款结果时的业务异常 + * @return 最新的退款单 + */ + private PayRefundDO validateRefundNotifyConcurrent(Long id, ServiceException ex) { + // 1. 校验是否为状态竞争 + if (ObjectUtil.notEqual(ex.getCode(), REFUND_STATUS_IS_NOT_WAITING.getCode())) { + throw ex; + } + // 2. 校验最新状态为成功或失败 + PayRefundDO latestRefund = refundMapper.selectById(id); + if (latestRefund == null || !(PayRefundStatusEnum.isSuccess(latestRefund.getStatus()) + || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) { + throw ex; + } + return latestRefund; + } + + /** * 校验支付订单是否可以退款 * * @param reqDTO 退款申请信息 @@ -166,12 +294,39 @@ public class PayRefundServiceImpl implements PayRefundService { if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()) { throw exception(REFUND_PRICE_EXCEED); } - // 是否有退款中的订单 + return order; + } + + /** + * 校验退款单是否可以发起 + * + * @param reqDTO 退款申请信息 + * @param appId 应用编号 + * @param order 支付订单 + * @return 退款失败、需要重新发起的退款单;为 null 时,表示需要新建退款单 + */ + private PayRefundDO validateRefundCanCreate(PayRefundCreateReqDTO reqDTO, Long appId, PayOrderDO order) { + // 是否有退款中的订单。注意:需要在锁内校验,避免并发创建退款单 if (refundMapper.selectCountByAppIdAndOrderId(appId, order.getId(), PayRefundStatusEnum.WAITING.getStatus()) > 0) { throw exception(REFUND_HAS_REFUNDING); } - return order; + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(appId, reqDTO.getMerchantRefundId()); + if (refund == null) { + return null; + } + // 只有退款失败的退款单,才能再次发起退款 + if (!PayRefundStatusEnum.isFailure(refund.getStatus())) { + throw exception(REFUND_EXISTS); + } + // 校验参数是否一致 + if (ObjectUtil.notEqual(refund.getOrderId(), order.getId())) { + throw exception(REFUND_CREATE_FAIL_ORDER_NOT_MATCH); + } + if (ObjectUtil.notEqual(refund.getRefundPrice(), reqDTO.getPrice())) { + throw exception(REFUND_CREATE_FAIL_PRICE_NOT_MATCH); + } + return refund; } /** @@ -209,7 +364,39 @@ public class PayRefundServiceImpl implements PayRefundService { // 情况二:退款失败 if (PayRefundStatusEnum.isFailure(notify.getStatus())) { notifyRefundFailure(channel, notify); + return; + } + // 情况三:退款中 + if (PayRefundStatusEnum.WAITING.getStatus().equals(notify.getStatus())) { + notifyRefundWaiting(channel, notify); + } + } + + private void notifyRefundWaiting(PayChannelDO channel, PayRefundRespDTO notify) { + // 没有渠道退款单号,说明渠道未明确受理退款,无需更新。例如说:支付宝查询不到退款单时返回 WAITING + if (StrUtil.isEmpty(notify.getChannelRefundNo())) { + return; + } + // 1.1 查询 PayRefundDO + PayRefundDO refund = refundMapper.selectByAppIdAndNo( + channel.getAppId(), notify.getOutRefundNo()); + if (refund == null) { + throw exception(REFUND_NOT_FOUND); + } + // 如果不是待退款(例如说,已被退款回调并发更新为最终状态)、或者渠道退款单号已一致,直接返回,不用重复更新 + if (!PayRefundStatusEnum.WAITING.getStatus().equals(refund.getStatus()) + || ObjectUtil.equal(refund.getChannelRefundNo(), notify.getChannelRefundNo())) { + log.info("[notifyRefundWaiting][退款订单({}) 状态({}) 无需更新渠道退款单号]", refund.getId(), refund.getStatus()); + return; + } + // 1.2 更新 PayRefundDO:仍是待退款,补充渠道已受理的退款单号 + int updateCounts = refundMapper.updateByIdAndStatus(refund.getId(), refund.getStatus(), new PayRefundDO() + .setChannelRefundNo(notify.getChannelRefundNo()).setChannelNotifyData(toJsonString(notify))); + if (updateCounts == 0) { // 已被并发更新为最终状态,以最新状态为准 + log.info("[notifyRefundWaiting][退款订单({}) 已被并发更新,无需更新渠道退款单号]", refund.getId()); + return; } + log.info("[notifyRefundWaiting][退款订单({}) 更新渠道退款单号({})]", refund.getId(), notify.getChannelRefundNo()); } private void notifyRefundSuccess(PayChannelDO channel, PayRefundRespDTO notify) { diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java index 545d6f7..48fbd62 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; @@ -20,6 +21,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO; import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; @@ -29,6 +31,7 @@ import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -47,6 +50,11 @@ public class PayTransferServiceImpl implements PayTransferService { private static final String TRANSFER_NO_PREFIX = "T"; + /** + * 创建转账单的锁超时时间,单位:毫秒 + */ + private static final long CREATE_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS; + @Resource private PayProperties payProperties; @@ -60,8 +68,14 @@ public class PayTransferServiceImpl implements PayTransferService { private PayNotifyService notifyService; @Resource private PayNoRedisDAO noRedisDAO; + @Resource + private PayTransferLockRedisDAO transferLockRedisDAO; @Override + // 注意:不加入调用方的事务,转账单、转账结果(包括转账通知)各自独立提交。 + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的转账单,否则无法使用原转账单号重新发起 + // 特殊:钱包渠道查询不到调用方事务未提交的数据,所以钱包创建时独立提交,见 PayWalletServiceImpl#createWalletIfAbsent 方法 + @Transactional(propagation = Propagation.NOT_SUPPORTED) public PayTransferCreateRespDTO createTransfer(PayTransferCreateReqDTO reqDTO) { // 1.1 校验 App PayAppDO payApp = appService.validPayApp(reqDTO.getAppKey()); @@ -72,35 +86,29 @@ public class PayTransferServiceImpl implements PayTransferService { log.error("[createTransfer][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); throw exception(CHANNEL_NOT_FOUND); } - // 1.3 校验转账单已经发起过转账 - PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); + // 2. 加锁,创建转账单、或者重新发起转账关闭的转账单 + // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账 + // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单 + PayTransferDO transfer = transferLockRedisDAO.lock(payApp.getId(), reqDTO.getMerchantTransferId(), + CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel)); - // 2.1 情况一:不存在创建转账单,则进行创建 - if (transfer == null) { - String no = noRedisDAO.generate(TRANSFER_NO_PREFIX); - transfer = BeanUtils.toBean(reqDTO, PayTransferDO.class) - .setAppId(channel.getAppId()).setChannelId(channel.getId()) - .setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()) - .setNotifyUrl(payApp.getTransferNotifyUrl()); - transferMapper.insert(transfer); - } else { - // 2.2 情况二:存在创建转账单,但是状态为关闭,则更新为等待中 - transferMapper.updateByIdAndStatus(transfer.getId(), transfer.getStatus(), - new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus())); - } + // 3.1 调用三方渠道发起转账 PayTransferRespDTO unifiedTransferResp = null; try { - // 3. 调用三方渠道发起转账 PayTransferUnifiedReqDTO transferUnifiedReq = BeanUtils.toBean(reqDTO, PayTransferUnifiedReqDTO.class) .setOutTransferNo(transfer.getNo()) .setNotifyUrl(genChannelTransferNotifyUrl(channel)); unifiedTransferResp = client.unifiedTransfer(transferUnifiedReq); } catch (Throwable e) { - // 注意这里仅打印异常,不进行抛出。 - // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。这个结果,后续转账轮询可以拿到。 - // 或者,使用相同 no 再次发起转账请求 + // 注意:这里仅打印异常,不进行抛出。 + // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。 + // 所以下面会查询一次渠道转账单,查询不到明确结果时,后续通过转账回调、或者转账轮询补偿可以拿到 log.error("[createTransfer][转账编号({}) requestDTO({}) 发生异常]", transfer.getId(), reqDTO, e); } + // 3.2 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,避免误判 + if (unifiedTransferResp == null || PayTransferStatusEnum.isClosed(unifiedTransferResp.getStatus())) { + unifiedTransferResp = getTransferAfterUnifiedTransfer(client, transfer, unifiedTransferResp); + } // 4. 通知转账结果 if (unifiedTransferResp != null) { @@ -126,6 +134,74 @@ public class PayTransferServiceImpl implements PayTransferService { } /** + * 创建转账单;如果存在转账关闭的转账单,则重置为待转账,用于重新发起转账 + * + * @param reqDTO 转账申请信息 + * @param payApp 支付应用 + * @param channel 支付渠道 + * @return 转账单 + */ + private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp, PayChannelDO channel) { + // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭 + PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); + + if (transfer == null) { + // 2.1 情况一:不存在转账单,则插入转账单 + String no = noRedisDAO.generate(TRANSFER_NO_PREFIX); + transfer = BeanUtils.toBean(reqDTO, PayTransferDO.class) + .setAppId(channel.getAppId()).setChannelId(channel.getId()) + .setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()) + .setNotifyUrl(payApp.getTransferNotifyUrl()); + transferMapper.insert(transfer); + } else { + // 2.2 情况二:存在关闭的转账单,则复用原转账单(包括原转账单号)重新发起,并清空上一次转账的渠道结果 + // 原因是:渠道通过转账单号保证幂等,避免上一次实际受理时重复转账。未校验一致的请求字段,以本次请求为准 + // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号 + int updateCount = transferMapper.updateByIdAndStatusAndClearChannelResult(transfer.getId(), transfer.getStatus(), + new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId()) + .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount()) + .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras()) + .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl())); + if (updateCount == 0) { // 校验状态,避免并发重新发起转账 + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + } + } + return transfer; + } + + /** + * 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,确认转账结果 + * + * 注意:只有查询到渠道已受理转账,才以查询结果为准;查询到的转账关闭,不作为最终结果。 + * 原因是:一次查询可能存在渠道转账单尚未生成、或者结果尚未同步的情况,例如说钱包查询不存在的转账单时返回关闭 + * + * @param client 支付客户端 + * @param transfer 转账单 + * @param unifiedResp 发起转账的返回结果;为 null 时,表示发起转账发生异常 + * @return 转账结果;为 null 时,表示结果未知,保留 WAITING 交给转账回调、或者转账轮询补偿 + */ + private PayTransferRespDTO getTransferAfterUnifiedTransfer(PayClient client, PayTransferDO transfer, + PayTransferRespDTO unifiedResp) { + PayTransferRespDTO queryResp = null; + try { + queryResp = client.getTransfer(transfer.getNo()); + } catch (Throwable e) { + log.warn("[getTransferAfterUnifiedTransfer][转账编号({}) 查询渠道转账单发生异常]", transfer.getId(), e); + } + // 情况一:查询到转账成功、转账中、或者已有渠道转账单号的待转账,说明渠道实际已受理转账,以查询结果为准 + if (queryResp != null && (PayTransferStatusEnum.isSuccess(queryResp.getStatus()) + || PayTransferStatusEnum.isProcessing(queryResp.getStatus()) + || (PayTransferStatusEnum.isWaiting(queryResp.getStatus()) + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())))) { + return queryResp; + } + // 情况二:其它情况,以发起转账的结果为准 + // ① 发起转账发生异常:返回 null,保留 WAITING 交给转账回调、或者转账轮询补偿 + // ② 发起转账明确返回关闭:以渠道明确返回的关闭为准。原因是:支付宝查询不存在的转账单时返回 WAITING、微信查询不存在的转账单时抛出异常 + return unifiedResp; + } + + /** * 校验转账结果通知时的并发更新 * * 只有状态竞争导致的通知失败,并且转账单已被并发更新为成功或关闭时,才返回最新的转账单; @@ -159,23 +235,30 @@ public class PayTransferServiceImpl implements PayTransferService { return payProperties.getTransferNotifyUrl() + "/" + channel.getId(); } + /** + * 校验转账单是否可以发起 + * + * @param reqDTO 转账申请信息 + * @param appId 应用编号 + * @return 转账关闭、需要重新发起的转账单;为 null 时,表示需要新建转账单 + */ private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO reqDTO, Long appId) { PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantOrderId(appId, reqDTO.getMerchantTransferId()); - if (transfer != null) { - // 只有转账单状态为关闭,才能再次发起转账 - if (!PayTransferStatusEnum.isClosed(transfer.getStatus())) { - throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); - } - // 校验参数是否一致 - if (ObjectUtil.notEqual(reqDTO.getPrice(), transfer.getPrice())) { - throw exception(PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); - } - if (ObjectUtil.notEqual(reqDTO.getChannelCode(), transfer.getChannelCode())) { - throw exception(PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); - } + if (transfer == null) { + return null; + } + // 只有转账关闭的转账单,才能再次发起转账 + // 原因是:待转账、转账中时,不知道渠道转账是否已经受理,交给转账回调、或者转账轮询补偿 + if (!PayTransferStatusEnum.isClosed(transfer.getStatus())) { + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + } + // 校验参数是否一致 + if (ObjectUtil.notEqual(reqDTO.getChannelCode(), transfer.getChannelCode())) { + throw exception(PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); + } + if (ObjectUtil.notEqual(reqDTO.getPrice(), transfer.getPrice())) { + throw exception(PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); } - // 如果状态为等待状态:不知道渠道转账是否发起成功 - // 特殊:允许使用相同的 no 再次发起转账,渠道会保证幂等 return transfer; } @@ -194,7 +277,38 @@ public class PayTransferServiceImpl implements PayTransferService { if (PayTransferStatusEnum.isProcessing(notify.getStatus())) { notifyTransferProgressing(channel, notify); } - // WAITING 状态无需处理 + // 转账等待的回调 + if (PayTransferStatusEnum.isWaiting(notify.getStatus())) { + notifyTransferWaiting(channel, notify); + } + } + + private void notifyTransferWaiting(PayChannelDO channel, PayTransferRespDTO notify) { + // 没有渠道转账单号,说明渠道未明确受理转账,无需更新。例如说:支付宝查询不到转账单时返回 WAITING + if (StrUtil.isEmpty(notify.getChannelTransferNo())) { + return; + } + // 1. 校验 + PayTransferDO transfer = transferMapper.selectByAppIdAndNo(channel.getAppId(), notify.getOutTransferNo()); + if (transfer == null) { + throw exception(PAY_TRANSFER_NOT_FOUND); + } + // 如果不是待转账(例如说,已被转账回调并发更新)、或者渠道转账单号已一致,直接返回,不用重复更新 + if (!PayTransferStatusEnum.isWaiting(transfer.getStatus()) + || ObjectUtil.equal(transfer.getChannelTransferNo(), notify.getChannelTransferNo())) { + log.info("[notifyTransferWaiting][transfer({}) 状态({}) 无需更新渠道转账单号]", transfer.getId(), transfer.getStatus()); + return; + } + + // 2. 更新渠道转账单号:仍是待转账,补充渠道已受理的转账单号 + int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), PayTransferStatusEnum.WAITING.getStatus(), + new PayTransferDO().setChannelTransferNo(notify.getChannelTransferNo()) + .setChannelNotifyData(JsonUtils.toJsonString(notify))); + if (updateCounts == 0) { // 已被并发更新,以最新状态为准 + log.info("[notifyTransferWaiting][transfer({}) 已被并发更新,无需更新渠道转账单号]", transfer.getId()); + return; + } + log.info("[notifyTransferWaiting][transfer({}) 更新渠道转账单号({})]", transfer.getId(), notify.getChannelTransferNo()); } private void notifyTransferProgressing(PayChannelDO channel, PayTransferRespDTO notify) { @@ -216,6 +330,7 @@ public class PayTransferServiceImpl implements PayTransferService { int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), PayTransferStatusEnum.WAITING.getStatus(), new PayTransferDO().setStatus(PayTransferStatusEnum.PROCESSING.getStatus()) + .setChannelTransferNo(notify.getChannelTransferNo()) .setChannelPackageInfo(notify.getChannelPackageInfo())); if (updateCounts == 0) { PayTransferDO latestTransfer = transferMapper.selectById(transfer.getId()); diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java index d091b96..81cfd6c 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.pay.service.wallet; import cn.hutool.core.lang.Assert; +import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.date.DateUtils; import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletPageReqVO; @@ -19,6 +20,7 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -64,20 +66,37 @@ public class PayWalletServiceImpl implements PayWalletService { if (wallet == null) { // 使用双重检查锁,保证钱包创建并发问题 // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1475/files - wallet = lockRedisDAO.lock(userId, UPDATE_TIMEOUT_MILLIS, () -> { - PayWalletDO newWallet = walletMapper.selectByUserIdAndType(userId, userType); - if (newWallet == null) { - newWallet = new PayWalletDO().setUserId(userId).setUserType(userType) - .setBalance(0).setTotalExpense(0).setTotalRecharge(0); - newWallet.setCreateTime(LocalDateTime.now()); - walletMapper.insert(newWallet); - } - return newWallet; - }); + wallet = lockRedisDAO.lock(userId, UPDATE_TIMEOUT_MILLIS, + () -> getSelf().createWalletIfAbsent(userId, userType)); } return wallet; } + /** + * 创建钱包,如果不存在的话 + * + * 注意:使用新事务,在释放锁之前提交,不加入调用方的事务。原因是: + * 1. 调用方的事务未提交时,其它请求查询不到钱包,会重复创建钱包 + * 2. 后续不加入调用方事务的操作,也需要查询到钱包。例如说,佣金提现到钱包时,转账 createTransfer 不加入调用方的事务 + * 另外,钱包是空钱包,即使调用方的事务回滚,保留也没有影响 + * + * @param userId 用户编号 + * @param userType 用户类型 + * @return 钱包 + */ + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + public PayWalletDO createWalletIfAbsent(Long userId, Integer userType) { + PayWalletDO wallet = walletMapper.selectByUserIdAndType(userId, userType); + if (wallet != null) { + return wallet; + } + wallet = new PayWalletDO().setUserId(userId).setUserType(userType) + .setBalance(0).setTotalExpense(0).setTotalRecharge(0); + wallet.setCreateTime(LocalDateTime.now()); + walletMapper.insert(wallet); + return wallet; + } + @Override public PayWalletDO getWallet(Long walletId) { return walletMapper.selectById(walletId); @@ -245,4 +264,13 @@ public class PayWalletServiceImpl implements PayWalletService { } } + /** + * 获得自身的代理对象,解决 AOP 生效问题 + * + * @return 自己 + */ + private PayWalletServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + } diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java index 59c7e25..d6cd9a7 100755 --- a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.pay.service.refund; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; @@ -12,6 +13,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; @@ -30,9 +32,17 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CompletableFuture; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; @@ -45,6 +55,7 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -53,7 +64,7 @@ import static org.mockito.Mockito.*; * * @author 芋艿 */ -@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class}) +@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class, PayRefundLockRedisDAO.class}) public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { @Resource @@ -62,6 +73,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { @Resource private PayRefundMapper refundMapper; + @Resource + private PlatformTransactionManager transactionManager; + @MockitoBean private PayProperties payProperties; @MockitoBean @@ -332,9 +346,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { // mock 方法(client) PayClient client = mock(PayClient.class); when(channelService.getPayClient(eq(10L))).thenReturn(client); - // mock 数据(refund 已存在) + // mock 数据(refund 已存在,且不是退款失败) PayRefundDO refund = randomPojo(PayRefundDO.class, o -> - o.setAppId(1L).setMerchantRefundId("200")); + o.setAppId(1L).setMerchantRefundId("200").setStatus(PayRefundStatusEnum.SUCCESS.getStatus())); refundMapper.insert(refund); // 调用,并断言异常 @@ -379,6 +393,453 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { .containsExactly(order.getId(), order.getNo(), channel.getId(), channel.getCode(), app.getRefundNotifyUrl(), order.getChannelOrderNo(), PayRefundStatusEnum.WAITING.getStatus(), order.getPrice(), reqDTO.getPrice()); + // 断言:查询一次渠道退款单,查询不到结果时保留 WAITING + verify(client).getRefund(eq(order.getNo()), eq(refundDO.getNo())); + } + + @Test // 调用 unifiedRefund 接口发生异常,但查询确认渠道退款成功 + public void testCreateRefund_invokeException_querySuccess() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", + LocalDateTime.now(), invocation.getArgument(1), "success")); + + // 调用 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言:按查询结果更新为退款成功 + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); + } + + @Test // 调用 unifiedRefund 接口发生异常,查询返回失败(例如说:微信查询不到退款单) + public void testCreateRefund_invokeException_queryFailure() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); + + // 调用:一次查询的失败(可能是渠道退款单尚未生成),不作为最终结果,保留 WAITING 交给退款轮询 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + assertNull(refund.getChannelErrorCode()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedRefund 接口发生异常,查询返回 WAITING 且无渠道退款单号(例如说:支付宝查询不到退款单) + public void testCreateRefund_invokeException_queryWaitingWithoutChannelRefundNo() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( + null, invocation.getArgument(1), "waiting")); + + // 调用:结果未知,保留 WAITING 交给退款轮询 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedRefund 接口发生异常,查询渠道退款单也发生异常 + public void testCreateRefund_invokeException_queryException() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询超时")); + + // 调用:结果未知,保留 WAITING 交给退款轮询 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedRefund 接口返回失败,查询也确认渠道退款失败 + public void testCreateRefund_invokeFailure_queryFailure() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); + + // 调用,并断言异常:以发起退款的失败原因为准 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); + // 断言:退款单已更新为失败 + assertRefundFailure("BALANCE_NOT_ENOUGH"); + } + + @Test // 调用 unifiedRefund 接口返回失败,查询返回 WAITING 且无渠道退款单号(例如说:支付宝查询不到退款单) + public void testCreateRefund_invokeFailure_queryWaitingWithoutChannelRefundNo() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( + null, invocation.getArgument(1), "waiting")); + + // 调用,并断言异常:以发起退款的失败结果为准,避免一直 WAITING + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); + // 断言:退款单已更新为失败 + assertRefundFailure("BALANCE_NOT_ENOUGH"); + } + + @Test // 调用 unifiedRefund 接口返回失败,查询渠道退款单发生异常(例如说:钱包查询 WAITING 退款单) + public void testCreateRefund_invokeFailure_queryException() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询异常")); + + // 调用,并断言异常:以发起退款的失败结果为准,避免一直 WAITING + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); + // 断言:退款单已更新为失败 + assertRefundFailure("BALANCE_NOT_ENOUGH"); + } + + @Test // 调用 unifiedRefund 接口返回失败,但查询到渠道退款单处理中 + public void testCreateRefund_invokeFailure_queryProcessing() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( + "CHANNEL_R001", invocation.getArgument(1), "processing")); + + // 调用:渠道实际已受理退款,保留 WAITING 交给退款回调、或者退款轮询 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言:保留渠道已受理的退款单号 + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); + assertNull(refund.getChannelErrorCode()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedRefund 接口返回退款中,并返回渠道退款单号(例如说:微信退款受理) + public void testCreateRefund_invokeWaitingWithChannelRefundNo() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( + "CHANNEL_R001", ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "processing")); + + // 调用 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言:仍是 WAITING,并保存渠道退款单号、渠道返回数据 + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); + assertNotNull(refund.getChannelNotifyData()); + verify(client, never()).getRefund(any(), any()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 退款中的通知晚于退款成功到达,不能覆盖已成功的退款单 + public void testNotifyRefund_waitingWhenSuccess() { + // mock 数据(refund 已退款成功) + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setNo("R001") + .setStatus(PayRefundStatusEnum.SUCCESS.getStatus()).setChannelRefundNo("CHANNEL_R001")); + refundMapper.insert(refund); + // 准备参数 + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(1L)); + + // 调用 + refundService.notifyRefund(channel, PayRefundRespDTO.waitingOf("CHANNEL_R002", "R001", "processing")); + // 断言:不更新 + PayRefundDO dbRefund = refundMapper.selectById(refund.getId()); + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), dbRefund.getStatus()); + assertEquals("CHANNEL_R001", dbRefund.getChannelRefundNo()); + assertEquals(refund.getChannelNotifyData(), dbRefund.getChannelNotifyData()); + } + + @Test // 并发使用相同的商户退款编号发起退款,只能创建一个退款单,避免重复退款 + public void testCreateRefund_concurrentCreate() throws Exception { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( + "CHANNEL_R001", ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "processing")); + // mock 方法(mapper):查询后等待一段时间,放大 select 后 insert 的时间窗口 + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper)); + doAnswer(invocation -> { + Long count = refundMapper.selectCountByAppIdAndOrderId(invocation.getArgument(0), + invocation.getArgument(1), invocation.getArgument(2)); + Thread.sleep(300); + return count; + }).when(refundMapperSpy).selectCountByAppIdAndOrderId(any(), any(), any()); + Object target = AopTestUtils.getTargetObject(refundService); + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy); + List exceptions = new ArrayList<>(); + try { + // 调用:两个请求并发发起退款 + List> futures = List.of( + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO())), + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO()))); + for (CompletableFuture future : futures) { + try { + future.get(); + } catch (Exception ex) { + exceptions.add(ex.getCause()); + } + } + } finally { + ReflectionTestUtils.setField(target, "refundMapper", refundMapper); + } + // 断言:只有一个请求创建退款单,另一个请求提示存在退款中的退款单 + assertEquals(1, exceptions.size()); + assertEquals(REFUND_HAS_REFUNDING.getCode(), ((ServiceException) exceptions.get(0)).getCode()); + assertEquals(1, refundMapper.selectList().size()); + verify(client, times(1)).unifiedRefund(any()); + } + + @Test // 调用 unifiedRefund 接口返回失败,但查询确认渠道退款成功 + public void testCreateRefund_invokeFailure_querySuccess() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", + LocalDateTime.now(), invocation.getArgument(1), "success")); + + // 调用 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言:按查询结果更新为退款成功 + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); + } + + @Test // 调用 unifiedRefund 接口返回成功,但处理退款结果时发生异常 + public void testCreateRefund_invokeSuccess_notifyException() { + // mock 方法(client):返回不存在的退款单号,使 notifyRefund 抛出异常 + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenReturn(PayRefundRespDTO.successOf( + "CHANNEL_R001", LocalDateTime.now(), "NOT_EXISTS", "success")); + + // 调用:渠道已退款成功,不向调用方抛出异常,避免调用方回滚退款单 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); + verify(client, never()).getRefund(any(), any()); + } + + @Test // 调用 unifiedRefund 接口返回存在渠道错误,但退款单已被并发更新为成功(例如说:退款回调) + public void testCreateRefund_channelErrorWhenConcurrentSuccess() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> { + // 模拟:退款回调并发更新退款单为退款成功 + PayRefundDO refund = refundMapper.selectByNo(invocation.getArgument(1)); + refundMapper.updateById(new PayRefundDO().setId(refund.getId()) + .setStatus(PayRefundStatusEnum.SUCCESS.getStatus())); + return PayRefundRespDTO.failureOf("REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure"); + }); + + // 调用:以最新的退款成功为准,不向调用方误报失败 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refundMapper.selectById(refundId).getStatus()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedRefund 接口返回存在渠道错误,通知时发生状态竞争,但退款单仍是待退款 + public void testCreateRefund_channelErrorWhenConcurrentWaiting() { + PayRefundServiceImpl payRefundServiceImpl = mock(PayRefundServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayRefundServiceImpl.class))) + .thenReturn(payRefundServiceImpl); + doThrow(exception(REFUND_STATUS_IS_NOT_WAITING)) + .when(payRefundServiceImpl).notifyRefund(any(PayChannelDO.class), any()); + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + + // 调用,并断言异常:无法确认最新状态,抛出原通知异常 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_STATUS_IS_NOT_WAITING); + // 断言 + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + } + } + + @Test // 调用 unifiedRefund 接口返回存在渠道错误,但创建退款通知任务时发生异常 + public void testCreateRefund_channelErrorAndNotifyTaskException() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + // mock 方法(notify) + RuntimeException notifyTaskException = new RuntimeException("模拟创建通知任务异常"); + doThrow(notifyTaskException).when(notifyService).createPayNotifyTask(any(), any()); + + // 调用,并断言异常:抛出真实的通知异常,而不是渠道错误 + RuntimeException ex = assertThrows(RuntimeException.class, + () -> refundService.createRefund(buildRefundCreateReqDTO())); + assertSame(notifyTaskException, ex); + // 断言:失败状态随通知事务回滚 + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + assertNull(refund.getChannelErrorCode()); + } + + @Test // 调用 unifiedRefund 接口,返回失败,但不存在渠道错误 + public void testCreateRefund_failureWithoutChannelError() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> + PayRefundRespDTO.failureOf(null, null, + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "failure")); + + // 调用:不抛出异常,由退款通知告知调用方 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(refundId).getStatus()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); + } + + @Test // 退款失败(例如说:余额不足)后,使用相同的商户退款编号再次发起,退款成功 + public void testCreateRefund_retryAfterFailure_success() { + // 准备参数:首次发起退款失败 + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); + PayRefundDO failureRefund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); + // mock 方法(client 再次发起时,退款成功) + doAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(), + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success")) + .when(client).unifiedRefund(any(PayRefundUnifiedReqDTO.class)); + + // 调用 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO().setReason("再次退款")); + // 断言:复用原退款单及原退款单号,并清空上一次的失败原因 + assertEquals(failureRefund.getId(), refundId); + assertEquals(1, refundMapper.selectList().size()); + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(failureRefund.getNo(), refund.getNo()); + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); + assertEquals("再次退款", refund.getReason()); + assertNull(refund.getChannelErrorCode()); + assertNull(refund.getChannelErrorMsg()); + // 断言:两次都使用原退款单号向渠道发起退款,并分别通知退款失败、退款成功 + verify(client, times(2)).unifiedRefund(argThat(unifiedReqDTO -> + failureRefund.getNo().equals(unifiedReqDTO.getOutRefundNo()))); + verify(notifyService, times(2)).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); + } + + @Test // 退款失败后再次发起,渠道结果未知,保留 WAITING 并清空上一次的渠道结果 + public void testCreateRefund_retryAfterFailure_unknown() { + // 准备参数:已存在退款失败的退款单 + PayClient client = mockPayClient(); + PayRefundDO failureRefund = insertFailureRefund(9); + // mock 方法(client 调用发生异常,查询也发生异常) + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询超时")); + + // 调用 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); + // 断言:复用原退款单号,状态重置为 WAITING,交给退款回调、或者退款轮询 + assertEquals(failureRefund.getId(), refundId); + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); + assertNull(refund.getChannelRefundNo()); + assertNull(refund.getChannelErrorCode()); + assertNull(refund.getChannelErrorMsg()); + assertNull(refund.getChannelNotifyData()); + verify(client).getRefund(eq(failureRefund.getOrderNo()), eq(failureRefund.getNo())); + } + + @Test // 退款失败后再次发起,但退款单已被并发重新发起 + public void testCreateRefund_retryAfterFailure_concurrentRetry() { + // 准备参数:已存在退款失败的退款单 + PayClient client = mockPayClient(); + PayRefundDO failureRefund = insertFailureRefund(9); + // mock 方法(mapper):校验通过后、更新前,其它请求已将退款单重新发起为 WAITING + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper)); + doAnswer(invocation -> { + refundMapper.updateById(new PayRefundDO().setId(failureRefund.getId()) + .setStatus(PayRefundStatusEnum.WAITING.getStatus())); + return refundMapper.updateByIdAndStatusAndClearChannelResult(invocation.getArgument(0), + invocation.getArgument(1), invocation.getArgument(2)); + }).when(refundMapperSpy).updateByIdAndStatusAndClearChannelResult(any(), any(), any()); + Object target = AopTestUtils.getTargetObject(refundService); + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy); + try { + // 调用,并断言异常 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_HAS_REFUNDING); + } finally { + ReflectionTestUtils.setField(target, "refundMapper", refundMapper); + } + // 断言:不会再次向渠道发起退款 + verify(client, never()).unifiedRefund(any()); + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); + } + + @Test // 调用方存在事务,退款失败后调用方事务回滚,退款失败的结果仍需保存,并可使用原退款单号重新发起 + public void testCreateRefund_outerTransactionRollback() { + // mock 方法(client) + PayClient client = mockPayClient(); + mockUnifiedRefundFailure(client); + PayRefundDO outerRefund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setMerchantRefundId("300")); + + // 调用:调用方事务中,先写入自己的数据,再发起退款,渠道错误导致调用方事务回滚 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + assertServiceException(() -> transactionTemplate.executeWithoutResult(status -> { + refundMapper.insert(outerRefund); + refundService.createRefund(buildRefundCreateReqDTO()); + }), REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); + // 断言:调用方事务已回滚,但退款失败的结果(包括退款通知)已保存 + assertNull(refundMapper.selectById(outerRefund.getId())); + assertRefundFailure("BALANCE_NOT_ENOUGH"); + PayRefundDO failureRefund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); + + // mock 方法(client 再次发起时,退款成功) + doAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(), + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success")) + .when(client).unifiedRefund(any(PayRefundUnifiedReqDTO.class)); + // 调用:使用相同的商户退款编号再次发起 + Long refundId = transactionTemplate.execute(status -> refundService.createRefund(buildRefundCreateReqDTO())); + // 断言:复用原退款单号 + assertEquals(failureRefund.getId(), refundId); + PayRefundDO refund = refundMapper.selectById(refundId); + assertEquals(failureRefund.getNo(), refund.getNo()); + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); + } + + @Test // 退款失败后再次发起,但支付订单不一致 + public void testCreateRefund_retryAfterFailure_orderNotMatch() { + // 准备参数:已存在退款失败的退款单,关联其它支付订单 + PayClient client = mockPayClient(); + PayRefundDO failureRefund = insertFailureRefund(9); + refundMapper.updateById(new PayRefundDO().setId(failureRefund.getId()).setOrderId(-1L)); + + // 调用,并断言异常 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_CREATE_FAIL_ORDER_NOT_MATCH); + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); + verify(client, never()).unifiedRefund(any()); + } + + @Test // 退款失败后再次发起,但退款金额不一致 + public void testCreateRefund_retryAfterFailure_priceNotMatch() { + // 准备参数:已存在退款金额为 5 的退款失败的退款单 + PayClient client = mockPayClient(); + PayRefundDO failureRefund = insertFailureRefund(5); + + // 调用,并断言异常 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), + REFUND_CREATE_FAIL_PRICE_NOT_MATCH); + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); + verify(client, never()).unifiedRefund(any()); } @Test @@ -696,4 +1157,59 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { assertEquals(count, 0); } + private PayClient mockPayClient() { + // mock 方法(app) + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L)); + when(appService.validPayApp(eq("demo"))).thenReturn(app); + // mock 数据(order) + PayOrderDO order = randomPojo(PayOrderDO.class, o -> + o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus()) + .setPrice(10).setRefundPrice(0) + .setChannelId(10L).setChannelCode(PayChannelEnum.ALIPAY_APP.getCode())); + when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order); + // mock 方法(channel) + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setAppId(1L) + .setCode(PayChannelEnum.ALIPAY_APP.getCode())); + when(channelService.validPayChannel(eq(10L))).thenReturn(channel); + // mock 方法(client) + PayClient client = mock(PayClient.class); + when(channelService.getPayClient(eq(10L))).thenReturn(client); + return client; + } + + private static void mockUnifiedRefundFailure(PayClient client) { + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> { + PayRefundUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayRefundRespDTO.failureOf("BALANCE_NOT_ENOUGH", "余额不足", + unifiedReqDTO.getOutRefundNo(), "failure"); + }); + } + + private static PayRefundCreateReqDTO buildRefundCreateReqDTO() { + return new PayRefundCreateReqDTO().setAppKey("demo").setUserIp("127.0.0.1") + .setUserId(1L).setUserType(1) + .setMerchantOrderId("100").setMerchantRefundId("200") + .setReason("测试退款").setPrice(9); + } + + /** + * 插入退款失败的退款单,关联 {@link #mockPayClient()} 的支付订单 + */ + private PayRefundDO insertFailureRefund(Integer refundPrice) { + PayOrderDO order = orderService.getOrder(1L, "100"); + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setMerchantRefundId("200") + .setOrderId(order.getId()).setOrderNo(order.getNo()).setRefundPrice(refundPrice) + .setStatus(PayRefundStatusEnum.FAILURE.getStatus()) + .setChannelErrorCode("BALANCE_NOT_ENOUGH").setChannelErrorMsg("余额不足")); + refundMapper.insert(refund); + return refund; + } + + private void assertRefundFailure(String channelErrorCode) { + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refund.getStatus()); + assertEquals(channelErrorCode, refund.getChannelErrorCode()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refund.getId())); + } + } diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java index 787b5b8..e889f93 100644 --- a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.pay.service.transfer; import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO; import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateRespDTO; @@ -9,6 +10,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO; import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; @@ -24,14 +26,23 @@ import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.AopTestUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalAnswers.delegatesTo; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; @@ -41,7 +52,7 @@ import static org.mockito.Mockito.*; * * @author 芋道源码 */ -@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class}) +@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class, PayTransferLockRedisDAO.class}) public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { @Resource @@ -50,6 +61,9 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { @Resource private PayTransferMapper transferMapper; + @Resource + private PlatformTransactionManager transactionManager; + @MockitoBean private PayProperties payProperties; @MockitoBean @@ -123,6 +137,207 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { verify(notifyService, never()).createPayNotifyTask(any(), any()); } + @Test // 调用 unifiedTransfer 接口发生异常,查询返回关闭(例如说:钱包查询不到转账单) + public void testCreateTransfer_unifiedTransferException_queryClosed() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.closedOf("ORDER_NOT_EXIST", "转账单不存在", invocation.getArgument(0), "closed")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:一次查询的关闭(可能是渠道转账单尚未生成),不作为最终结果,保留 WAITING 交给转账轮询 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + assertNull(dbTransfer.getChannelErrorCode()); + verify(client).getTransfer(any()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedTransfer 接口发生异常,查询返回 WAITING(例如说:支付宝查询不到转账单) + public void testCreateTransfer_unifiedTransferException_queryWaiting() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.waitingOf(null, invocation.getArgument(0), "waiting")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:结果未知,保留 WAITING 交给转账轮询 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), transferMapper.selectById(respDTO.getId()).getStatus()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedTransfer 接口返回关闭,但查询到渠道转账中(例如说:上一次实际已受理) + public void testCreateTransfer_closed_queryProcessing() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.closedOf("SYSTEM_ERROR", "系统繁忙", unifiedReqDTO.getOutTransferNo(), "closed"); + }); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.processingOf("CHANNEL_T001", invocation.getArgument(0), "processing")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:渠道实际已受理转账,以查询结果为准,不向调用方误报渠道错误 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); + assertNull(dbTransfer.getChannelErrorCode()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 调用 unifiedTransfer 接口返回关闭,但查询到已有渠道转账单号的待转账(例如说:上一次实际已受理) + public void testCreateTransfer_closed_queryWaitingWithChannelTransferNo() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.closedOf("SYSTEM_ERROR", "系统繁忙", unifiedReqDTO.getOutTransferNo(), "closed"); + }); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.waitingOf("CHANNEL_T001", invocation.getArgument(0), "waiting")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:渠道实际已受理转账,以查询结果为准,不向调用方误报渠道错误 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言:仍是 WAITING,并保存渠道转账单号、渠道返回数据 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo()); + assertNotNull(dbTransfer.getChannelNotifyData()); + assertNull(dbTransfer.getChannelErrorCode()); + verify(notifyService, never()).createPayNotifyTask(any(), any()); + } + + @Test // 待转账的通知晚于转账中到达,不能覆盖转账中的转账单 + public void testNotifyTransferWaiting_whenProcessing() { + // mock 数据(PayTransferDO) + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(10L).setNo("T110") + .setStatus(PayTransferStatusEnum.PROCESSING.getStatus()).setChannelTransferNo("WX_TRANSFER_110")); + transferMapper.insert(transfer); + // 准备参数 + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(10L)); + + // 调用 + transferService.notifyTransfer(channel, PayTransferRespDTO.waitingOf("WX_TRANSFER_111", "T110", "waiting")); + // 断言:不更新 + PayTransferDO dbTransfer = transferMapper.selectById(transfer.getId()); + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); + assertEquals("WX_TRANSFER_110", dbTransfer.getChannelTransferNo()); + assertEquals(transfer.getChannelNotifyData(), dbTransfer.getChannelNotifyData()); + } + + @Test // 并发使用相同的商户转账编号发起转账,只能创建一个转账单,避免重复转账 + public void testCreateTransfer_concurrentCreate() throws Exception { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.processingOf("CHANNEL_T001", unifiedReqDTO.getOutTransferNo(), "processing"); + }); + // mock 方法(mapper):查询后等待一段时间,放大 select 后 insert 的时间窗口 + PayTransferMapper transferMapperSpy = mock(PayTransferMapper.class, delegatesTo(transferMapper)); + doAnswer(invocation -> { + PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantOrderId(invocation.getArgument(0), + invocation.getArgument(1)); + Thread.sleep(300); + return transfer; + }).when(transferMapperSpy).selectByAppIdAndMerchantOrderId(any(), any()); + Object target = AopTestUtils.getTargetObject(transferService); + ReflectionTestUtils.setField(target, "transferMapper", transferMapperSpy); + List exceptions = new ArrayList<>(); + try { + // 调用:两个请求并发发起转账 + List> futures = List.of( + CompletableFuture.supplyAsync(() -> transferService.createTransfer(buildTransferCreateReqDTO())), + CompletableFuture.supplyAsync(() -> transferService.createTransfer(buildTransferCreateReqDTO()))); + for (CompletableFuture future : futures) { + try { + future.get(); + } catch (Exception ex) { + exceptions.add(ex.getCause()); + } + } + } finally { + ReflectionTestUtils.setField(target, "transferMapper", transferMapper); + } + // 断言:只有一个请求创建转账单,另一个请求提示转账单不是关闭状态 + assertEquals(1, exceptions.size()); + assertEquals(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED.getCode(), ((ServiceException) exceptions.get(0)).getCode()); + assertEquals(1, transferMapper.selectList().size()); + verify(client, times(1)).unifiedTransfer(any()); + } + + @Test // 调用 unifiedTransfer 接口返回关闭,查询返回 WAITING(例如说:支付宝查询不到转账单) + public void testCreateTransfer_closed_queryWaiting() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在", + unifiedReqDTO.getOutTransferNo(), "closed"); + }); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.waitingOf(null, invocation.getArgument(0), "waiting")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用,并断言异常:以渠道明确返回的关闭为准 + assertServiceException(() -> transferService.createTransfer(reqDTO), + PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在"); + // 断言 + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId()); + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), dbTransfer.getStatus()); + assertEquals("PAYEE_NOT_EXIST", dbTransfer.getChannelErrorCode()); + } + + @Test // 调用 unifiedTransfer 接口发生异常,但查询确认渠道转账成功 + public void testCreateTransfer_unifiedTransferException_querySuccess() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(), + invocation.getArgument(0), "success")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:查询确认渠道转账成功 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言:按查询结果落库为成功 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(dbTransfer.getId())); + } + + @Test // 调用 unifiedTransfer 接口发生异常,查询渠道状态也发生异常 + public void testCreateTransfer_unifiedTransferException_queryException() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); + // 准备参数 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); + + // 调用:查询失败时保留 WAITING,交给后续轮询补偿 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); + // 断言 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + verify(client).getTransfer(any()); + } + @Test // 调用 unifiedTransfer 接口,返回 null public void testCreateTransfer_nullResponse() { // mock 方法(client) @@ -223,6 +438,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); assertEquals("package-info-001", dbTransfer.getChannelPackageInfo()); + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo()); } @Test // 调用 unifiedTransfer 接口返回成功,但通知转账结果时找不到转账单 @@ -360,6 +576,203 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { } } + @Test // 已存在相同的转账单,且不是转账关闭 + public void testCreateTransfer_transferExists() { + // mock 方法(client) + PayClient client = mockPayClient(); + // mock 数据:已存在转账成功的转账单 + PayTransferDO transfer = insertClosedTransfer(); + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()) + .setStatus(PayTransferStatusEnum.SUCCESS.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + verify(client, never()).unifiedTransfer(any()); + } + + @Test // 转账关闭后再次发起,但转账渠道不一致 + public void testCreateTransfer_retryAfterClosed_channelNotMatch() { + // mock 方法(client) + PayClient client = mockPayClient(); + // mock 数据:已存在其它渠道的关闭的转账单 + PayTransferDO transfer = insertClosedTransfer(); + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode())); + + // 调用,并断言异常 + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(transfer.getId()).getStatus()); + verify(client, never()).unifiedTransfer(any()); + } + + @Test // 转账关闭后再次发起,但转账金额不一致 + public void testCreateTransfer_retryAfterClosed_priceNotMatch() { + // mock 方法(client) + PayClient client = mockPayClient(); + // mock 数据:已存在金额为 50 的关闭的转账单 + PayTransferDO transfer = insertClosedTransfer(); + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setPrice(50)); + + // 调用,并断言异常 + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(transfer.getId()).getStatus()); + verify(client, never()).unifiedTransfer(any()); + } + + @Test // 转账关闭后再次发起,渠道结果未知,保留 WAITING 并清空上一次的渠道结果 + public void testCreateTransfer_retryAfterClosed_unknown() { + // mock 方法(client 调用发生异常,查询也发生异常) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); + // mock 数据:已存在关闭的转账单 + PayTransferDO closedTransfer = insertClosedTransfer(); + + // 调用 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO()); + // 断言:复用原转账单号,状态重置为 WAITING,并清空上一次转账的渠道结果 + assertEquals(closedTransfer.getId(), respDTO.getId()); + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + assertNull(dbTransfer.getChannelTransferNo()); + assertNull(dbTransfer.getSuccessTime()); + assertNull(dbTransfer.getChannelErrorCode()); + assertNull(dbTransfer.getChannelErrorMsg()); + assertNull(dbTransfer.getChannelNotifyData()); + assertNull(dbTransfer.getChannelPackageInfo()); + assertEquals("佣金提现", dbTransfer.getSubject()); + assertEquals("test@example.com", dbTransfer.getUserAccount()); + assertEquals("芋艿", dbTransfer.getUserName()); + assertEquals(Map.of("sceneId", "1000"), dbTransfer.getChannelExtras()); + assertEquals("http://127.0.0.1/transfer", dbTransfer.getNotifyUrl()); + // 断言:渠道编号更新为本次按渠道编码校验的渠道 + assertEquals(10L, dbTransfer.getChannelId()); + verify(client).unifiedTransfer(argThat(unifiedReqDTO -> + closedTransfer.getNo().equals(unifiedReqDTO.getOutTransferNo()))); + } + + @Test // 转账关闭后再次发起,本次请求没有传递可选字段,需要清空上一次的值 + public void testCreateTransfer_retryAfterClosed_clearOptionalFields() { + // mock 方法(client 调用发生异常,查询也发生异常) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); + // mock 方法(app):没有配置转账回调地址 + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl(null)); + when(appService.validPayApp(eq("demo"))).thenReturn(app); + // mock 数据:已存在关闭的转账单 + PayTransferDO closedTransfer = insertClosedTransfer(); + + // 调用:本次请求没有传递收款人姓名、渠道额外参数 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO() + .setUserName(null).setChannelExtras(null)); + // 断言:上一次的收款人姓名、渠道额外参数、回调地址已清空 + assertEquals(closedTransfer.getId(), respDTO.getId()); + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + assertNull(dbTransfer.getUserName()); + assertNull(dbTransfer.getChannelExtras()); + assertNull(dbTransfer.getNotifyUrl()); + assertEquals(10L, dbTransfer.getChannelId()); + } + + @Test // 转账关闭后再次发起,但转账单已被并发重新发起 + public void testCreateTransfer_retryAfterClosed_concurrentRetry() { + // mock 方法(client) + PayClient client = mockPayClient(); + // mock 数据:已存在关闭的转账单 + PayTransferDO closedTransfer = insertClosedTransfer(); + // mock 方法(mapper):校验通过后、更新前,其它请求已将转账单重新发起为 WAITING + PayTransferMapper transferMapperSpy = mock(PayTransferMapper.class, delegatesTo(transferMapper)); + doAnswer(invocation -> { + updateTransferStatus(closedTransfer.getNo(), PayTransferStatusEnum.WAITING.getStatus()); + return transferMapper.updateByIdAndStatusAndClearChannelResult(invocation.getArgument(0), + invocation.getArgument(1), invocation.getArgument(2)); + }).when(transferMapperSpy).updateByIdAndStatusAndClearChannelResult(any(), any(), any()); + Object target = AopTestUtils.getTargetObject(transferService); + ReflectionTestUtils.setField(target, "transferMapper", transferMapperSpy); + try { + // 调用,并断言异常 + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + } finally { + ReflectionTestUtils.setField(target, "transferMapper", transferMapper); + } + // 断言:不会再次向渠道发起转账 + verify(client, never()).unifiedTransfer(any()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus()); + } + + @Test // 调用方存在事务,转账关闭后调用方事务回滚,转账关闭的结果仍需保存,并可使用原转账单号重新发起 + public void testCreateTransfer_outerTransactionRollback() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在", + unifiedReqDTO.getOutTransferNo(), "closed"); + }); + PayTransferDO outerTransfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setMerchantTransferId("2002")); + + // 调用:调用方事务中,先写入自己的数据,再发起转账,渠道错误导致调用方事务回滚 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + assertServiceException(() -> transactionTemplate.executeWithoutResult(status -> { + transferMapper.insert(outerTransfer); + transferService.createTransfer(buildTransferCreateReqDTO()); + }), PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在"); + // 断言:调用方事务已回滚,但转账关闭的结果(包括转账通知)已保存 + assertNull(transferMapper.selectById(outerTransfer.getId())); + PayTransferDO closedTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, "1001"); + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), closedTransfer.getStatus()); + assertEquals("PAYEE_NOT_EXIST", closedTransfer.getChannelErrorCode()); + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(closedTransfer.getId())); + + // mock 方法(client 再次发起时,转账成功) + doAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(), + unifiedReqDTO.getOutTransferNo(), "success"); + }).when(client).unifiedTransfer(any()); + // 调用:使用相同的商户转账编号再次发起 + PayTransferCreateRespDTO respDTO = transactionTemplate.execute(status -> + transferService.createTransfer(buildTransferCreateReqDTO())); + // 断言:复用原转账单号 + assertEquals(closedTransfer.getId(), respDTO.getId()); + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(closedTransfer.getNo(), dbTransfer.getNo()); + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus()); + } + + @Test // 转账关闭后再次发起,返回转账中及新的 package 信息 + public void testCreateTransfer_retryAfterClosed_processing() { + // mock 方法(client) + PayClient client = mockPayClient(); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + PayTransferRespDTO resp = PayTransferRespDTO.processingOf("CHANNEL_T002", + unifiedReqDTO.getOutTransferNo(), "processing"); + resp.setChannelPackageInfo("package-info-002"); + return resp; + }); + // mock 数据:已存在关闭的转账单 + PayTransferDO closedTransfer = insertClosedTransfer(); + + // 调用 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO()); + // 断言:使用新的 package 信息,且不残留上一次的失败原因 + assertEquals(closedTransfer.getId(), respDTO.getId()); + assertEquals("package-info-002", respDTO.getChannelPackageInfo()); + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); + assertEquals("package-info-002", dbTransfer.getChannelPackageInfo()); + assertNull(dbTransfer.getChannelErrorCode()); + assertNull(dbTransfer.getChannelErrorMsg()); + assertEquals("CHANNEL_T002", dbTransfer.getChannelTransferNo()); + } + private PayClient mockPayClient() { // mock 方法(app) PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer")); @@ -375,6 +788,22 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { return client; } + /** + * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果 + * + * 特殊:渠道编号使用旧的渠道编号,模拟渠道删除重建的情况 + */ + private PayTransferDO insertClosedTransfer() { + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setChannelId(9L) + .setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()).setMerchantTransferId("1001").setPrice(100) + .setUserName("旧的收款人").setChannelExtras(Map.of("sceneId", "1005")) + .setStatus(PayTransferStatusEnum.CLOSED.getStatus()) + .setChannelErrorCode("PAYEE_NOT_EXIST").setChannelErrorMsg("收款账号不存在") + .setChannelPackageInfo("package-info-001")); + transferMapper.insert(transfer); + return transfer; + } + private void updateTransferStatus(String no, Integer status) { PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status)); @@ -384,7 +813,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { return new PayTransferCreateReqDTO().setAppKey("demo").setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()) .setUserIp("127.0.0.1").setUserId(1L).setUserType(1) .setMerchantTransferId("1001").setSubject("佣金提现").setPrice(100) - .setUserAccount("test@example.com").setUserName("芋艿"); + .setUserAccount("test@example.com").setUserName("芋艿").setChannelExtras(Map.of("sceneId", "1000")); } } diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java new file mode 100644 index 0000000..a53d5f2 --- /dev/null +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.pay.service.wallet; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO; +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO; +import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletMapper; +import cn.iocoder.yudao.module.pay.dal.redis.wallet.PayWalletLockRedisDAO; +import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum; +import cn.iocoder.yudao.module.pay.service.order.PayOrderService; +import cn.iocoder.yudao.module.pay.service.refund.PayRefundService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * {@link PayWalletServiceImpl} 的单元测试类 + * + * @author HUIHUI + */ +@Import({PayWalletServiceImpl.class, PayWalletLockRedisDAO.class}) +public class PayWalletServiceTest extends BaseDbAndRedisUnitTest { + + @Resource + private PayWalletServiceImpl walletService; + + @Resource + private PayWalletMapper walletMapper; + + @Resource + private PlatformTransactionManager transactionManager; + + @MockitoBean + private PayWalletTransactionService walletTransactionService; + @MockitoBean + private PayOrderService orderService; + @MockitoBean + private PayRefundService refundService; + + @Test + public void testGetOrCreateWallet_create() { + // 调用:首次获取,创建钱包 + PayWalletDO wallet = walletService.getOrCreateWallet(1L, 2); + // 断言 + assertNotNull(wallet.getId()); + PayWalletDO dbWallet = walletMapper.selectById(wallet.getId()); + assertEquals(1L, dbWallet.getUserId()); + assertEquals(2, dbWallet.getUserType()); + assertEquals(0, dbWallet.getBalance()); + + // 调用:再次获取,不重复创建钱包 + assertEquals(wallet.getId(), walletService.getOrCreateWallet(1L, 2).getId()); + assertEquals(1, walletMapper.selectList().size()); + } + + @Test // 调用方存在事务时,首次创建的钱包需要独立提交(例如说:佣金提现审核时,创建钱包后再转账到钱包) + public void testGetOrCreateWallet_outerTransaction() { + // mock 方法(钱包流水) + when(walletTransactionService.createWalletTransaction(any())).thenReturn(new PayWalletTransactionDO()); + + // 调用:调用方事务中,首次创建钱包;随后不加入调用方事务(同 createTransfer),给钱包增加余额;最后调用方事务回滚 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + TransactionTemplate notSupportedTemplate = new TransactionTemplate(transactionManager); + notSupportedTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); + Long walletId = transactionTemplate.execute(status -> { + PayWalletDO wallet = walletService.getOrCreateWallet(1L, 2); + notSupportedTemplate.executeWithoutResult(notSupportedStatus -> walletService.addWalletBalance( + wallet.getId(), "T001", PayWalletBizTypeEnum.TRANSFER, 100)); + status.setRollbackOnly(); + return wallet.getId(); + }); + // 断言:钱包已独立提交,钱包余额已增加,不受调用方事务回滚影响 + PayWalletDO dbWallet = walletMapper.selectById(walletId); + assertNotNull(dbWallet); + assertEquals(100, dbWallet.getBalance()); + } + +} diff --git a/yudao-module-pay/src/test/resources/sql/clean.sql b/yudao-module-pay/src/test/resources/sql/clean.sql index e7bf56b..41b4d2d 100644 --- a/yudao-module-pay/src/test/resources/sql/clean.sql +++ b/yudao-module-pay/src/test/resources/sql/clean.sql @@ -4,5 +4,6 @@ DELETE FROM pay_order; DELETE FROM pay_order_extension; DELETE FROM pay_refund; DELETE FROM pay_transfer; +DELETE FROM pay_wallet; DELETE FROM pay_notify_task; DELETE FROM pay_notify_log; diff --git a/yudao-module-pay/src/test/resources/sql/create_tables.sql b/yudao-module-pay/src/test/resources/sql/create_tables.sql index a3df280..833e61c 100644 --- a/yudao-module-pay/src/test/resources/sql/create_tables.sql +++ b/yudao-module-pay/src/test/resources/sql/create_tables.sql @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` ( `deleted` bit(1) NOT NULL DEFAULT FALSE, PRIMARY KEY ("id") ) COMMENT = '转账单'; + +CREATE TABLE IF NOT EXISTS `pay_wallet` ( + "id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY, + `user_id` bigint(20) NOT NULL, + `user_type` tinyint(4) NOT NULL, + `balance` int(11) NOT NULL DEFAULT 0, + `freeze_price` int(11) NOT NULL DEFAULT 0, + `total_expense` int(11) NOT NULL DEFAULT 0, + `total_recharge` int(11) NOT NULL DEFAULT 0, + `creator` varchar(64) NULL DEFAULT '', + `create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updater` varchar(64) NULL DEFAULT '', + `update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` bit(1) NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +) COMMENT = '会员钱包'; -- libgit2 0.21.4