Commit ed24181e710e62d7b65f9357073516d99771f9ac
1 parent
8dc18cb4
fix(pay): 收敛退款转账并发与待确认状态
Showing
16 changed files
with
1606 additions
and
80 deletions
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/refund/PayRefundApi.java
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java
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 |
| 7 | 7 | import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO; |
| 8 | 8 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 9 | 9 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| 10 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 10 | 11 | import org.apache.ibatis.annotations.Mapper; |
| 11 | 12 | |
| 12 | 13 | import java.util.List; |
| ... | ... | @@ -46,6 +47,24 @@ public interface PayRefundMapper extends BaseMapperX<PayRefundDO> { |
| 46 | 47 | .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status)); |
| 47 | 48 | } |
| 48 | 49 | |
| 50 | + /** | |
| 51 | + * 更新退款单,并清空上一次退款的渠道结果 | |
| 52 | + * | |
| 53 | + * 用于退款失败后重新发起退款,避免新的退款结果残留上一次的失败原因、渠道退款单号等 | |
| 54 | + * | |
| 55 | + * @param id 退款单编号 | |
| 56 | + * @param status 原状态 | |
| 57 | + * @param update 更新对象 | |
| 58 | + * @return 更新数量 | |
| 59 | + */ | |
| 60 | + default int updateByIdAndStatusAndClearChannelResult(Long id, Integer status, PayRefundDO update) { | |
| 61 | + return update(update, new LambdaUpdateWrapper<PayRefundDO>() | |
| 62 | + .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status) | |
| 63 | + .set(PayRefundDO::getChannelRefundNo, null).set(PayRefundDO::getSuccessTime, null) | |
| 64 | + .set(PayRefundDO::getChannelErrorCode, null).set(PayRefundDO::getChannelErrorMsg, null) | |
| 65 | + .set(PayRefundDO::getChannelNotifyData, null)); | |
| 66 | + } | |
| 67 | + | |
| 49 | 68 | default PageResult<PayRefundDO> selectPage(PayRefundPageReqVO reqVO) { |
| 50 | 69 | return selectPage(reqVO, new LambdaQueryWrapperX<PayRefundDO>() |
| 51 | 70 | .eqIfPresent(PayRefundDO::getAppId, reqVO.getAppId()) | ... | ... |
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; |
| 6 | 6 | import cn.iocoder.yudao.module.pay.controller.admin.transfer.vo.PayTransferPageReqVO; |
| 7 | 7 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 8 | 8 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| 9 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | |
| 9 | 10 | import org.apache.ibatis.annotations.Mapper; |
| 10 | 11 | |
| 11 | 12 | import java.util.Collection; |
| ... | ... | @@ -26,6 +27,28 @@ public interface PayTransferMapper extends BaseMapperX<PayTransferDO> { |
| 26 | 27 | .eq(PayTransferDO::getStatus, whereStatus)); |
| 27 | 28 | } |
| 28 | 29 | |
| 30 | + /** | |
| 31 | + * 更新转账单,并清空上一次转账的渠道结果 | |
| 32 | + * | |
| 33 | + * 用于转账关闭后重新发起转账,避免新的转账残留上一次的失败原因、渠道转账单号、确认收款 package 信息等 | |
| 34 | + * 特殊:本次请求没有传递的可选字段(收款人姓名、渠道额外参数、回调地址),也需要清空。原因是:MyBatis Plus 更新时会忽略为 null 的字段 | |
| 35 | + * | |
| 36 | + * @param id 转账单编号 | |
| 37 | + * @param whereStatus 原状态 | |
| 38 | + * @param updateObj 更新对象 | |
| 39 | + * @return 更新数量 | |
| 40 | + */ | |
| 41 | + default int updateByIdAndStatusAndClearChannelResult(Long id, Integer whereStatus, PayTransferDO updateObj) { | |
| 42 | + return update(updateObj, new LambdaUpdateWrapper<PayTransferDO>() | |
| 43 | + .eq(PayTransferDO::getId, id).eq(PayTransferDO::getStatus, whereStatus) | |
| 44 | + .set(PayTransferDO::getChannelTransferNo, null).set(PayTransferDO::getSuccessTime, null) | |
| 45 | + .set(PayTransferDO::getChannelErrorCode, null).set(PayTransferDO::getChannelErrorMsg, null) | |
| 46 | + .set(PayTransferDO::getChannelNotifyData, null).set(PayTransferDO::getChannelPackageInfo, null) | |
| 47 | + .set(updateObj.getUserName() == null, PayTransferDO::getUserName, null) | |
| 48 | + .set(updateObj.getChannelExtras() == null, PayTransferDO::getChannelExtras, null) | |
| 49 | + .set(updateObj.getNotifyUrl() == null, PayTransferDO::getNotifyUrl, null)); | |
| 50 | + } | |
| 51 | + | |
| 29 | 52 | default int updateChannelPackageInfoIfAbsent(Long id, String channelPackageInfo) { |
| 30 | 53 | return update(new PayTransferDO().setChannelPackageInfo(channelPackageInfo), |
| 31 | 54 | new LambdaQueryWrapper<PayTransferDO>() | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java
| ... | ... | @@ -26,6 +26,24 @@ public interface RedisKeyConstants { |
| 26 | 26 | String PAY_WALLET_LOCK = "pay_wallet:lock:%d"; |
| 27 | 27 | |
| 28 | 28 | /** |
| 29 | + * 退款单创建的分布式锁 | |
| 30 | + * | |
| 31 | + * KEY 格式:pay_refund:lock:%d // 参数为支付订单编号 | |
| 32 | + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 | |
| 33 | + * 过期时间:不固定 | |
| 34 | + */ | |
| 35 | + String PAY_REFUND_LOCK = "pay_refund:lock:%d"; | |
| 36 | + | |
| 37 | + /** | |
| 38 | + * 转账单创建的分布式锁 | |
| 39 | + * | |
| 40 | + * KEY 格式:pay_transfer:lock:%d:%s // 参数为应用编号、商户转账单编号 | |
| 41 | + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 | |
| 42 | + * 过期时间:不固定 | |
| 43 | + */ | |
| 44 | + String PAY_TRANSFER_LOCK = "pay_transfer:lock:%d:%s"; | |
| 45 | + | |
| 46 | + /** | |
| 29 | 47 | * 支付序号的缓存 |
| 30 | 48 | * |
| 31 | 49 | * KEY 格式:pay_no:{prefix} | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/refund/PayRefundLockRedisDAO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.pay.dal.redis.refund; | |
| 2 | + | |
| 3 | +import jakarta.annotation.Resource; | |
| 4 | +import org.redisson.api.RLock; | |
| 5 | +import org.redisson.api.RedissonClient; | |
| 6 | +import org.springframework.stereotype.Repository; | |
| 7 | + | |
| 8 | +import java.util.concurrent.TimeUnit; | |
| 9 | +import java.util.function.Supplier; | |
| 10 | + | |
| 11 | +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_REFUND_LOCK; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 支付退款的锁 Redis DAO | |
| 15 | + * | |
| 16 | + * @author HUIHUI | |
| 17 | + */ | |
| 18 | +@Repository | |
| 19 | +public class PayRefundLockRedisDAO { | |
| 20 | + | |
| 21 | + @Resource | |
| 22 | + private RedissonClient redissonClient; | |
| 23 | + | |
| 24 | + public <V> V lock(Long orderId, Long timeoutMillis, Supplier<V> supplier) { | |
| 25 | + String lockKey = formatKey(orderId); | |
| 26 | + RLock lock = redissonClient.getLock(lockKey); | |
| 27 | + try { | |
| 28 | + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS); | |
| 29 | + // 执行逻辑 | |
| 30 | + return supplier.get(); | |
| 31 | + } finally { | |
| 32 | + lock.unlock(); | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + private static String formatKey(Long orderId) { | |
| 37 | + return String.format(PAY_REFUND_LOCK, orderId); | |
| 38 | + } | |
| 39 | + | |
| 40 | +} | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/transfer/PayTransferLockRedisDAO.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.pay.dal.redis.transfer; | |
| 2 | + | |
| 3 | +import jakarta.annotation.Resource; | |
| 4 | +import org.redisson.api.RLock; | |
| 5 | +import org.redisson.api.RedissonClient; | |
| 6 | +import org.springframework.stereotype.Repository; | |
| 7 | + | |
| 8 | +import java.util.concurrent.TimeUnit; | |
| 9 | +import java.util.function.Supplier; | |
| 10 | + | |
| 11 | +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_TRANSFER_LOCK; | |
| 12 | + | |
| 13 | +/** | |
| 14 | + * 支付转账的锁 Redis DAO | |
| 15 | + * | |
| 16 | + * @author HUIHUI | |
| 17 | + */ | |
| 18 | +@Repository | |
| 19 | +public class PayTransferLockRedisDAO { | |
| 20 | + | |
| 21 | + @Resource | |
| 22 | + private RedissonClient redissonClient; | |
| 23 | + | |
| 24 | + public <V> V lock(Long appId, String merchantTransferId, Long timeoutMillis, Supplier<V> supplier) { | |
| 25 | + String lockKey = formatKey(appId, merchantTransferId); | |
| 26 | + RLock lock = redissonClient.getLock(lockKey); | |
| 27 | + try { | |
| 28 | + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS); | |
| 29 | + // 执行逻辑 | |
| 30 | + return supplier.get(); | |
| 31 | + } finally { | |
| 32 | + lock.unlock(); | |
| 33 | + } | |
| 34 | + } | |
| 35 | + | |
| 36 | + private static String formatKey(Long appId, String merchantTransferId) { | |
| 37 | + return String.format(PAY_TRANSFER_LOCK, appId, merchantTransferId); | |
| 38 | + } | |
| 39 | + | |
| 40 | +} | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java
| ... | ... | @@ -40,6 +40,9 @@ public interface ErrorCodeConstants { |
| 40 | 40 | ErrorCode REFUND_EXISTS = new ErrorCode(1_007_006_003, "已经存在退款单"); |
| 41 | 41 | ErrorCode REFUND_NOT_FOUND = new ErrorCode(1_007_006_004, "支付退款单不存在"); |
| 42 | 42 | ErrorCode REFUND_STATUS_IS_NOT_WAITING = new ErrorCode(1_007_006_005, "支付退款单不处于待退款"); |
| 43 | + ErrorCode REFUND_SUBMIT_CHANNEL_ERROR = new ErrorCode(1_007_006_006, "发起退款报错,错误码:{},错误提示:{}"); | |
| 44 | + ErrorCode REFUND_CREATE_FAIL_ORDER_NOT_MATCH = new ErrorCode(1_007_006_007, "发起退款失败,原因:两次相同退款请求的支付订单不匹配"); | |
| 45 | + ErrorCode REFUND_CREATE_FAIL_PRICE_NOT_MATCH = new ErrorCode(1_007_006_008, "发起退款失败,原因:两次相同退款请求的退款金额不匹配"); | |
| 43 | 46 | |
| 44 | 47 | // ========== 钱包模块 1-007-007-000 ========== |
| 45 | 48 | ErrorCode WALLET_NOT_FOUND = new ErrorCode(1_007_007_000, "用户钱包不存在"); | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.pay.service.refund; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjectUtil; | |
| 5 | +import cn.hutool.core.util.StrUtil; | |
| 4 | 6 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 5 | 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 9 | +import cn.iocoder.yudao.framework.common.util.date.DateUtils; | |
| 6 | 10 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; |
| 7 | 11 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO; |
| 8 | 12 | 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; |
| 17 | 21 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 18 | 22 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; |
| 19 | 23 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 24 | +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; | |
| 20 | 25 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 21 | 26 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; |
| 22 | 27 | import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; |
| ... | ... | @@ -28,6 +33,7 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService; |
| 28 | 33 | import jakarta.annotation.Resource; |
| 29 | 34 | import lombok.extern.slf4j.Slf4j; |
| 30 | 35 | import org.springframework.stereotype.Service; |
| 36 | +import org.springframework.transaction.annotation.Propagation; | |
| 31 | 37 | import org.springframework.transaction.annotation.Transactional; |
| 32 | 38 | import org.springframework.validation.annotation.Validated; |
| 33 | 39 | |
| ... | ... | @@ -47,6 +53,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; |
| 47 | 53 | @Validated |
| 48 | 54 | public class PayRefundServiceImpl implements PayRefundService { |
| 49 | 55 | |
| 56 | + /** | |
| 57 | + * 创建退款单的锁超时时间,单位:毫秒 | |
| 58 | + */ | |
| 59 | + private static final long CREATE_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS; | |
| 60 | + | |
| 50 | 61 | @Resource |
| 51 | 62 | private PayProperties payProperties; |
| 52 | 63 | |
| ... | ... | @@ -54,6 +65,8 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 54 | 65 | private PayRefundMapper refundMapper; |
| 55 | 66 | @Resource |
| 56 | 67 | private PayNoRedisDAO noRedisDAO; |
| 68 | + @Resource | |
| 69 | + private PayRefundLockRedisDAO refundLockRedisDAO; | |
| 57 | 70 | |
| 58 | 71 | @Resource |
| 59 | 72 | private PayOrderService orderService; |
| ... | ... | @@ -90,6 +103,9 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 90 | 103 | } |
| 91 | 104 | |
| 92 | 105 | @Override |
| 106 | + // 注意:不加入调用方的事务,退款单、退款结果(包括退款通知)各自独立提交。 | |
| 107 | + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的退款单,否则无法使用原退款单号重新发起 | |
| 108 | + @Transactional(propagation = Propagation.NOT_SUPPORTED) | |
| 93 | 109 | public Long createRefund(PayRefundCreateReqDTO reqDTO) { |
| 94 | 110 | // 1.1 校验 App |
| 95 | 111 | PayAppDO app = appService.validPayApp(reqDTO.getAppKey()); |
| ... | ... | @@ -99,31 +115,18 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 99 | 115 | PayChannelDO channel = channelService.validPayChannel(order.getChannelId()); |
| 100 | 116 | PayClient<?> client = channelService.getPayClient(channel.getId()); |
| 101 | 117 | if (client == null) { |
| 102 | - log.error("[refund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); | |
| 118 | + log.error("[createRefund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); | |
| 103 | 119 | throw exception(CHANNEL_NOT_FOUND); |
| 104 | 120 | } |
| 105 | - // 1.4 校验退款订单是否已经存在 | |
| 106 | - PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId( | |
| 107 | - app.getId(), reqDTO.getMerchantRefundId()); | |
| 108 | - if (refund != null) { | |
| 109 | - throw exception(REFUND_EXISTS); | |
| 110 | - } | |
| 121 | + // 2. 加锁,创建退款单、或者重新发起退款失败的退款单 | |
| 122 | + // 原因是:同一支付订单串行创建退款单,避免 select 后 insert 时,并发请求重复创建退款单,导致重复退款 | |
| 123 | + // 注意:createRefund 不加入调用方的事务,退款单立即提交,所以释放锁后,其它请求可以查询到该退款单 | |
| 124 | + PayRefundDO refund = refundLockRedisDAO.lock(order.getId(), CREATE_LOCK_TIMEOUT_MILLIS, | |
| 125 | + () -> createOrRetryRefund(reqDTO, app, order)); | |
| 111 | 126 | |
| 112 | - // 2.1 插入退款单 | |
| 113 | - String no = noRedisDAO.generate(payProperties.getRefundNoPrefix()); | |
| 114 | - refund = PayRefundConvert.INSTANCE.convert(reqDTO) | |
| 115 | - .setNo(no).setAppId(app.getId()).setOrderId(order.getId()).setOrderNo(order.getNo()) | |
| 116 | - .setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode()) | |
| 117 | - // 商户相关的字段 | |
| 118 | - .setNotifyUrl(app.getRefundNotifyUrl()) | |
| 119 | - // 渠道相关字段 | |
| 120 | - .setChannelOrderNo(order.getChannelOrderNo()) | |
| 121 | - // 退款相关字段 | |
| 122 | - .setStatus(PayRefundStatusEnum.WAITING.getStatus()) | |
| 123 | - .setPayPrice(order.getPrice()).setRefundPrice(reqDTO.getPrice()); | |
| 124 | - refundMapper.insert(refund); | |
| 127 | + // 3.1 调用三方渠道发起退款 | |
| 128 | + PayRefundRespDTO refundRespDTO = null; | |
| 125 | 129 | try { |
| 126 | - // 2.2 向渠道发起退款申请 | |
| 127 | 130 | PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO() |
| 128 | 131 | .setPayPrice(order.getPrice()) |
| 129 | 132 | .setRefundPrice(reqDTO.getPrice()) |
| ... | ... | @@ -131,22 +134,147 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 131 | 134 | .setOutRefundNo(refund.getNo()) |
| 132 | 135 | .setNotifyUrl(genChannelRefundNotifyUrl(channel)) |
| 133 | 136 | .setReason(reqDTO.getReason()); |
| 134 | - PayRefundRespDTO refundRespDTO = client.unifiedRefund(unifiedReqDTO); | |
| 135 | - // 2.3 处理退款返回 | |
| 136 | - getSelf().notifyRefund(channel, refundRespDTO); | |
| 137 | + refundRespDTO = client.unifiedRefund(unifiedReqDTO); | |
| 137 | 138 | } catch (Throwable e) { |
| 138 | 139 | // 注意:这里仅打印异常,不进行抛出。 |
| 139 | - // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。这个结果,后续通过退款回调、或者退款轮询补偿可以拿到。 | |
| 140 | - // 最终,在异常的情况下,支付中心会异步回调业务的退款回调接口,提供退款结果 | |
| 141 | - log.error("[createPayRefund][退款 id({}) requestDTO({}) 发生异常]", | |
| 142 | - refund.getId(), reqDTO, e); | |
| 140 | + // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。 | |
| 141 | + // 所以下面会查询一次渠道退款单,查询不到明确结果时,后续通过退款回调、或者退款轮询补偿可以拿到 | |
| 142 | + log.error("[createRefund][退款编号({}) requestDTO({}) 发生异常]", refund.getId(), reqDTO, e); | |
| 143 | + } | |
| 144 | + // 3.2 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,避免误判 | |
| 145 | + // 关联知识星球帖子:https://t.zsxq.com/JRLye、https://t.zsxq.com/gDETH | |
| 146 | + if (refundRespDTO == null || PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) { | |
| 147 | + refundRespDTO = getRefundAfterUnifiedRefund(client, refund, refundRespDTO); | |
| 143 | 148 | } |
| 144 | 149 | |
| 145 | - // 返回退款编号 | |
| 150 | + // 4. 通知退款结果 | |
| 151 | + if (refundRespDTO != null) { | |
| 152 | + if (PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) { | |
| 153 | + // 4.1 情况一:退款失败 | |
| 154 | + PayRefundDO latestRefund = null; | |
| 155 | + try { | |
| 156 | + getSelf().notifyRefund(channel, refundRespDTO); | |
| 157 | + } catch (ServiceException ex) { | |
| 158 | + // 由于退款回调、退款轮询可能同时更新退款单,导致存在并发更新问题,此时以最新的退款状态为准 | |
| 159 | + latestRefund = validateRefundNotifyConcurrent(refund.getId(), ex); | |
| 160 | + log.warn("[createRefund][refund({}) channel({}) 退款结果({}) 通知时发生并发更新,最新状态为({})]", | |
| 161 | + refund.getId(), channel.getId(), refundRespDTO, latestRefund.getStatus(), ex); | |
| 162 | + } | |
| 163 | + // 如有渠道错误码,则抛出业务异常,提示用户 | |
| 164 | + // 特殊:如果已被并发更新为成功,则以最新状态为准,避免旧的失败结果误报 | |
| 165 | + if (StrUtil.isNotEmpty(refundRespDTO.getChannelErrorCode()) | |
| 166 | + && (latestRefund == null || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) { | |
| 167 | + throw exception(REFUND_SUBMIT_CHANNEL_ERROR, refundRespDTO.getChannelErrorCode(), | |
| 168 | + refundRespDTO.getChannelErrorMsg()); | |
| 169 | + } | |
| 170 | + } else { | |
| 171 | + // 4.2 情况二:退款成功、或者退款中 | |
| 172 | + // 注意:这里仅打印异常,不进行抛出。原因是:渠道可能已经退款成功,抛出会导致调用方回滚,后续通过退款回调、或者退款轮询补偿 | |
| 173 | + try { | |
| 174 | + getSelf().notifyRefund(channel, refundRespDTO); | |
| 175 | + } catch (Throwable e) { | |
| 176 | + log.error("[createRefund][退款编号({}) 退款结果({}) 通知时发生异常]", refund.getId(), refundRespDTO, e); | |
| 177 | + } | |
| 178 | + } | |
| 179 | + } | |
| 146 | 180 | return refund.getId(); |
| 147 | 181 | } |
| 148 | 182 | |
| 149 | 183 | /** |
| 184 | + * 创建退款单;如果存在退款失败的退款单,则重置为待退款,用于重新发起退款 | |
| 185 | + * | |
| 186 | + * @param reqDTO 退款申请信息 | |
| 187 | + * @param app 支付应用 | |
| 188 | + * @param order 支付订单 | |
| 189 | + * @return 退款单 | |
| 190 | + */ | |
| 191 | + private PayRefundDO createOrRetryRefund(PayRefundCreateReqDTO reqDTO, PayAppDO app, PayOrderDO order) { | |
| 192 | + // 1. 校验退款单是否可以发起:不存在,或者之前退款失败 | |
| 193 | + PayRefundDO refund = validateRefundCanCreate(reqDTO, app.getId(), order); | |
| 194 | + | |
| 195 | + if (refund == null) { | |
| 196 | + // 2.1 情况一:不存在退款单,则插入退款单 | |
| 197 | + String no = noRedisDAO.generate(payProperties.getRefundNoPrefix()); | |
| 198 | + refund = PayRefundConvert.INSTANCE.convert(reqDTO) | |
| 199 | + .setNo(no).setAppId(app.getId()).setOrderId(order.getId()).setOrderNo(order.getNo()) | |
| 200 | + .setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode()) | |
| 201 | + // 商户相关的字段 | |
| 202 | + .setNotifyUrl(app.getRefundNotifyUrl()) | |
| 203 | + // 渠道相关字段 | |
| 204 | + .setChannelOrderNo(order.getChannelOrderNo()) | |
| 205 | + // 退款相关字段 | |
| 206 | + .setStatus(PayRefundStatusEnum.WAITING.getStatus()) | |
| 207 | + .setPayPrice(order.getPrice()).setRefundPrice(reqDTO.getPrice()); | |
| 208 | + refundMapper.insert(refund); | |
| 209 | + } else { | |
| 210 | + // 2.2 情况二:存在退款失败的退款单,则复用原退款单(包括原退款单号)重新发起,并清空上一次退款的渠道结果 | |
| 211 | + // 原因是:微信、支付宝等渠道要求退款失败后重新提交时,使用原商户退款单号,避免上一次实际受理时重复退款。未校验一致的请求字段,以本次请求为准 | |
| 212 | + int updateCount = refundMapper.updateByIdAndStatusAndClearChannelResult(refund.getId(), refund.getStatus(), | |
| 213 | + new PayRefundDO().setStatus(PayRefundStatusEnum.WAITING.getStatus()) | |
| 214 | + .setReason(reqDTO.getReason()).setUserIp(reqDTO.getUserIp()) | |
| 215 | + .setNotifyUrl(app.getRefundNotifyUrl())); | |
| 216 | + if (updateCount == 0) { // 校验状态,避免并发重新发起退款 | |
| 217 | + throw exception(REFUND_HAS_REFUNDING); | |
| 218 | + } | |
| 219 | + } | |
| 220 | + return refund; | |
| 221 | + } | |
| 222 | + | |
| 223 | + /** | |
| 224 | + * 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,确认退款结果 | |
| 225 | + * | |
| 226 | + * 注意:只有查询到渠道已受理退款,才以查询结果为准;查询到的退款失败,不作为最终结果。 | |
| 227 | + * 原因是:一次查询可能存在渠道退款单尚未生成、或者结果尚未同步的情况,例如说微信、钱包查询不存在的退款单时返回失败 | |
| 228 | + * | |
| 229 | + * @param client 支付客户端 | |
| 230 | + * @param refund 退款单 | |
| 231 | + * @param unifiedRespDTO 发起退款的返回结果;为 null 时,表示发起退款发生异常 | |
| 232 | + * @return 退款结果;为 null 时,表示结果未知,保留 WAITING 交给退款回调、或者退款轮询补偿 | |
| 233 | + */ | |
| 234 | + private PayRefundRespDTO getRefundAfterUnifiedRefund(PayClient<?> client, PayRefundDO refund, PayRefundRespDTO unifiedRespDTO) { | |
| 235 | + PayRefundRespDTO queryRespDTO = null; | |
| 236 | + try { | |
| 237 | + queryRespDTO = client.getRefund(refund.getOrderNo(), refund.getNo()); | |
| 238 | + } catch (Throwable e) { | |
| 239 | + log.warn("[getRefundAfterUnifiedRefund][退款编号({}) 查询渠道退款单发生异常]", refund.getId(), e); | |
| 240 | + } | |
| 241 | + // 情况一:查询到退款成功、或者已有渠道退款单号的退款中,说明渠道实际已受理退款,以查询结果为准 | |
| 242 | + if (queryRespDTO != null && (PayRefundStatusEnum.isSuccess(queryRespDTO.getStatus()) | |
| 243 | + || (PayRefundStatusEnum.WAITING.getStatus().equals(queryRespDTO.getStatus()) | |
| 244 | + && StrUtil.isNotEmpty(queryRespDTO.getChannelRefundNo())))) { | |
| 245 | + return queryRespDTO; | |
| 246 | + } | |
| 247 | + // 情况二:其它情况,以发起退款的结果为准 | |
| 248 | + // ① 发起退款发生异常:返回 null,保留 WAITING 交给退款回调、或者退款轮询补偿 | |
| 249 | + // ② 发起退款明确返回失败:以渠道明确返回的失败为准。原因是:支付宝查询不存在的退款单时返回 WAITING、钱包查询 WAITING 退款单时抛出异常,退款轮询无法补偿 | |
| 250 | + return unifiedRespDTO; | |
| 251 | + } | |
| 252 | + | |
| 253 | + /** | |
| 254 | + * 校验退款结果通知时的并发更新 | |
| 255 | + * | |
| 256 | + * 只有状态竞争导致的通知失败,并且退款单已被并发更新为成功或失败时,才返回最新的退款单; | |
| 257 | + * 否则,直接抛出原异常,避免掩盖真实的通知失败 | |
| 258 | + * | |
| 259 | + * @param id 退款单编号 | |
| 260 | + * @param ex 通知退款结果时的业务异常 | |
| 261 | + * @return 最新的退款单 | |
| 262 | + */ | |
| 263 | + private PayRefundDO validateRefundNotifyConcurrent(Long id, ServiceException ex) { | |
| 264 | + // 1. 校验是否为状态竞争 | |
| 265 | + if (ObjectUtil.notEqual(ex.getCode(), REFUND_STATUS_IS_NOT_WAITING.getCode())) { | |
| 266 | + throw ex; | |
| 267 | + } | |
| 268 | + // 2. 校验最新状态为成功或失败 | |
| 269 | + PayRefundDO latestRefund = refundMapper.selectById(id); | |
| 270 | + if (latestRefund == null || !(PayRefundStatusEnum.isSuccess(latestRefund.getStatus()) | |
| 271 | + || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) { | |
| 272 | + throw ex; | |
| 273 | + } | |
| 274 | + return latestRefund; | |
| 275 | + } | |
| 276 | + | |
| 277 | + /** | |
| 150 | 278 | * 校验支付订单是否可以退款 |
| 151 | 279 | * |
| 152 | 280 | * @param reqDTO 退款申请信息 |
| ... | ... | @@ -166,12 +294,39 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 166 | 294 | if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()) { |
| 167 | 295 | throw exception(REFUND_PRICE_EXCEED); |
| 168 | 296 | } |
| 169 | - // 是否有退款中的订单 | |
| 297 | + return order; | |
| 298 | + } | |
| 299 | + | |
| 300 | + /** | |
| 301 | + * 校验退款单是否可以发起 | |
| 302 | + * | |
| 303 | + * @param reqDTO 退款申请信息 | |
| 304 | + * @param appId 应用编号 | |
| 305 | + * @param order 支付订单 | |
| 306 | + * @return 退款失败、需要重新发起的退款单;为 null 时,表示需要新建退款单 | |
| 307 | + */ | |
| 308 | + private PayRefundDO validateRefundCanCreate(PayRefundCreateReqDTO reqDTO, Long appId, PayOrderDO order) { | |
| 309 | + // 是否有退款中的订单。注意:需要在锁内校验,避免并发创建退款单 | |
| 170 | 310 | if (refundMapper.selectCountByAppIdAndOrderId(appId, order.getId(), |
| 171 | 311 | PayRefundStatusEnum.WAITING.getStatus()) > 0) { |
| 172 | 312 | throw exception(REFUND_HAS_REFUNDING); |
| 173 | 313 | } |
| 174 | - return order; | |
| 314 | + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(appId, reqDTO.getMerchantRefundId()); | |
| 315 | + if (refund == null) { | |
| 316 | + return null; | |
| 317 | + } | |
| 318 | + // 只有退款失败的退款单,才能再次发起退款 | |
| 319 | + if (!PayRefundStatusEnum.isFailure(refund.getStatus())) { | |
| 320 | + throw exception(REFUND_EXISTS); | |
| 321 | + } | |
| 322 | + // 校验参数是否一致 | |
| 323 | + if (ObjectUtil.notEqual(refund.getOrderId(), order.getId())) { | |
| 324 | + throw exception(REFUND_CREATE_FAIL_ORDER_NOT_MATCH); | |
| 325 | + } | |
| 326 | + if (ObjectUtil.notEqual(refund.getRefundPrice(), reqDTO.getPrice())) { | |
| 327 | + throw exception(REFUND_CREATE_FAIL_PRICE_NOT_MATCH); | |
| 328 | + } | |
| 329 | + return refund; | |
| 175 | 330 | } |
| 176 | 331 | |
| 177 | 332 | /** |
| ... | ... | @@ -209,7 +364,39 @@ public class PayRefundServiceImpl implements PayRefundService { |
| 209 | 364 | // 情况二:退款失败 |
| 210 | 365 | if (PayRefundStatusEnum.isFailure(notify.getStatus())) { |
| 211 | 366 | notifyRefundFailure(channel, notify); |
| 367 | + return; | |
| 368 | + } | |
| 369 | + // 情况三:退款中 | |
| 370 | + if (PayRefundStatusEnum.WAITING.getStatus().equals(notify.getStatus())) { | |
| 371 | + notifyRefundWaiting(channel, notify); | |
| 372 | + } | |
| 373 | + } | |
| 374 | + | |
| 375 | + private void notifyRefundWaiting(PayChannelDO channel, PayRefundRespDTO notify) { | |
| 376 | + // 没有渠道退款单号,说明渠道未明确受理退款,无需更新。例如说:支付宝查询不到退款单时返回 WAITING | |
| 377 | + if (StrUtil.isEmpty(notify.getChannelRefundNo())) { | |
| 378 | + return; | |
| 379 | + } | |
| 380 | + // 1.1 查询 PayRefundDO | |
| 381 | + PayRefundDO refund = refundMapper.selectByAppIdAndNo( | |
| 382 | + channel.getAppId(), notify.getOutRefundNo()); | |
| 383 | + if (refund == null) { | |
| 384 | + throw exception(REFUND_NOT_FOUND); | |
| 385 | + } | |
| 386 | + // 如果不是待退款(例如说,已被退款回调并发更新为最终状态)、或者渠道退款单号已一致,直接返回,不用重复更新 | |
| 387 | + if (!PayRefundStatusEnum.WAITING.getStatus().equals(refund.getStatus()) | |
| 388 | + || ObjectUtil.equal(refund.getChannelRefundNo(), notify.getChannelRefundNo())) { | |
| 389 | + log.info("[notifyRefundWaiting][退款订单({}) 状态({}) 无需更新渠道退款单号]", refund.getId(), refund.getStatus()); | |
| 390 | + return; | |
| 391 | + } | |
| 392 | + // 1.2 更新 PayRefundDO:仍是待退款,补充渠道已受理的退款单号 | |
| 393 | + int updateCounts = refundMapper.updateByIdAndStatus(refund.getId(), refund.getStatus(), new PayRefundDO() | |
| 394 | + .setChannelRefundNo(notify.getChannelRefundNo()).setChannelNotifyData(toJsonString(notify))); | |
| 395 | + if (updateCounts == 0) { // 已被并发更新为最终状态,以最新状态为准 | |
| 396 | + log.info("[notifyRefundWaiting][退款订单({}) 已被并发更新,无需更新渠道退款单号]", refund.getId()); | |
| 397 | + return; | |
| 212 | 398 | } |
| 399 | + log.info("[notifyRefundWaiting][退款订单({}) 更新渠道退款单号({})]", refund.getId(), notify.getChannelRefundNo()); | |
| 213 | 400 | } |
| 214 | 401 | |
| 215 | 402 | private void notifyRefundSuccess(PayChannelDO channel, PayRefundRespDTO notify) { | ... | ... |
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; |
| 6 | 6 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | 7 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 8 | 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 9 | +import cn.iocoder.yudao.framework.common.util.date.DateUtils; | |
| 9 | 10 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 10 | 11 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 11 | 12 | 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; |
| 20 | 21 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 21 | 22 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; |
| 22 | 23 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 24 | +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; | |
| 23 | 25 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 24 | 26 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; |
| 25 | 27 | import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; |
| ... | ... | @@ -29,6 +31,7 @@ import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService; |
| 29 | 31 | import jakarta.annotation.Resource; |
| 30 | 32 | import lombok.extern.slf4j.Slf4j; |
| 31 | 33 | import org.springframework.stereotype.Service; |
| 34 | +import org.springframework.transaction.annotation.Propagation; | |
| 32 | 35 | import org.springframework.transaction.annotation.Transactional; |
| 33 | 36 | |
| 34 | 37 | import java.util.List; |
| ... | ... | @@ -47,6 +50,11 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 47 | 50 | |
| 48 | 51 | private static final String TRANSFER_NO_PREFIX = "T"; |
| 49 | 52 | |
| 53 | + /** | |
| 54 | + * 创建转账单的锁超时时间,单位:毫秒 | |
| 55 | + */ | |
| 56 | + private static final long CREATE_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS; | |
| 57 | + | |
| 50 | 58 | @Resource |
| 51 | 59 | private PayProperties payProperties; |
| 52 | 60 | |
| ... | ... | @@ -60,8 +68,14 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 60 | 68 | private PayNotifyService notifyService; |
| 61 | 69 | @Resource |
| 62 | 70 | private PayNoRedisDAO noRedisDAO; |
| 71 | + @Resource | |
| 72 | + private PayTransferLockRedisDAO transferLockRedisDAO; | |
| 63 | 73 | |
| 64 | 74 | @Override |
| 75 | + // 注意:不加入调用方的事务,转账单、转账结果(包括转账通知)各自独立提交。 | |
| 76 | + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的转账单,否则无法使用原转账单号重新发起 | |
| 77 | + // 特殊:钱包渠道查询不到调用方事务未提交的数据,所以钱包创建时独立提交,见 PayWalletServiceImpl#createWalletIfAbsent 方法 | |
| 78 | + @Transactional(propagation = Propagation.NOT_SUPPORTED) | |
| 65 | 79 | public PayTransferCreateRespDTO createTransfer(PayTransferCreateReqDTO reqDTO) { |
| 66 | 80 | // 1.1 校验 App |
| 67 | 81 | PayAppDO payApp = appService.validPayApp(reqDTO.getAppKey()); |
| ... | ... | @@ -72,35 +86,29 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 72 | 86 | log.error("[createTransfer][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); |
| 73 | 87 | throw exception(CHANNEL_NOT_FOUND); |
| 74 | 88 | } |
| 75 | - // 1.3 校验转账单已经发起过转账 | |
| 76 | - PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); | |
| 89 | + // 2. 加锁,创建转账单、或者重新发起转账关闭的转账单 | |
| 90 | + // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账 | |
| 91 | + // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单 | |
| 92 | + PayTransferDO transfer = transferLockRedisDAO.lock(payApp.getId(), reqDTO.getMerchantTransferId(), | |
| 93 | + CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel)); | |
| 77 | 94 | |
| 78 | - // 2.1 情况一:不存在创建转账单,则进行创建 | |
| 79 | - if (transfer == null) { | |
| 80 | - String no = noRedisDAO.generate(TRANSFER_NO_PREFIX); | |
| 81 | - transfer = BeanUtils.toBean(reqDTO, PayTransferDO.class) | |
| 82 | - .setAppId(channel.getAppId()).setChannelId(channel.getId()) | |
| 83 | - .setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()) | |
| 84 | - .setNotifyUrl(payApp.getTransferNotifyUrl()); | |
| 85 | - transferMapper.insert(transfer); | |
| 86 | - } else { | |
| 87 | - // 2.2 情况二:存在创建转账单,但是状态为关闭,则更新为等待中 | |
| 88 | - transferMapper.updateByIdAndStatus(transfer.getId(), transfer.getStatus(), | |
| 89 | - new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus())); | |
| 90 | - } | |
| 95 | + // 3.1 调用三方渠道发起转账 | |
| 91 | 96 | PayTransferRespDTO unifiedTransferResp = null; |
| 92 | 97 | try { |
| 93 | - // 3. 调用三方渠道发起转账 | |
| 94 | 98 | PayTransferUnifiedReqDTO transferUnifiedReq = BeanUtils.toBean(reqDTO, PayTransferUnifiedReqDTO.class) |
| 95 | 99 | .setOutTransferNo(transfer.getNo()) |
| 96 | 100 | .setNotifyUrl(genChannelTransferNotifyUrl(channel)); |
| 97 | 101 | unifiedTransferResp = client.unifiedTransfer(transferUnifiedReq); |
| 98 | 102 | } catch (Throwable e) { |
| 99 | - // 注意这里仅打印异常,不进行抛出。 | |
| 100 | - // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。这个结果,后续转账轮询可以拿到。 | |
| 101 | - // 或者,使用相同 no 再次发起转账请求 | |
| 103 | + // 注意:这里仅打印异常,不进行抛出。 | |
| 104 | + // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。 | |
| 105 | + // 所以下面会查询一次渠道转账单,查询不到明确结果时,后续通过转账回调、或者转账轮询补偿可以拿到 | |
| 102 | 106 | log.error("[createTransfer][转账编号({}) requestDTO({}) 发生异常]", transfer.getId(), reqDTO, e); |
| 103 | 107 | } |
| 108 | + // 3.2 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,避免误判 | |
| 109 | + if (unifiedTransferResp == null || PayTransferStatusEnum.isClosed(unifiedTransferResp.getStatus())) { | |
| 110 | + unifiedTransferResp = getTransferAfterUnifiedTransfer(client, transfer, unifiedTransferResp); | |
| 111 | + } | |
| 104 | 112 | |
| 105 | 113 | // 4. 通知转账结果 |
| 106 | 114 | if (unifiedTransferResp != null) { |
| ... | ... | @@ -126,6 +134,74 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 126 | 134 | } |
| 127 | 135 | |
| 128 | 136 | /** |
| 137 | + * 创建转账单;如果存在转账关闭的转账单,则重置为待转账,用于重新发起转账 | |
| 138 | + * | |
| 139 | + * @param reqDTO 转账申请信息 | |
| 140 | + * @param payApp 支付应用 | |
| 141 | + * @param channel 支付渠道 | |
| 142 | + * @return 转账单 | |
| 143 | + */ | |
| 144 | + private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp, PayChannelDO channel) { | |
| 145 | + // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭 | |
| 146 | + PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); | |
| 147 | + | |
| 148 | + if (transfer == null) { | |
| 149 | + // 2.1 情况一:不存在转账单,则插入转账单 | |
| 150 | + String no = noRedisDAO.generate(TRANSFER_NO_PREFIX); | |
| 151 | + transfer = BeanUtils.toBean(reqDTO, PayTransferDO.class) | |
| 152 | + .setAppId(channel.getAppId()).setChannelId(channel.getId()) | |
| 153 | + .setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()) | |
| 154 | + .setNotifyUrl(payApp.getTransferNotifyUrl()); | |
| 155 | + transferMapper.insert(transfer); | |
| 156 | + } else { | |
| 157 | + // 2.2 情况二:存在关闭的转账单,则复用原转账单(包括原转账单号)重新发起,并清空上一次转账的渠道结果 | |
| 158 | + // 原因是:渠道通过转账单号保证幂等,避免上一次实际受理时重复转账。未校验一致的请求字段,以本次请求为准 | |
| 159 | + // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号 | |
| 160 | + int updateCount = transferMapper.updateByIdAndStatusAndClearChannelResult(transfer.getId(), transfer.getStatus(), | |
| 161 | + new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId()) | |
| 162 | + .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount()) | |
| 163 | + .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras()) | |
| 164 | + .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl())); | |
| 165 | + if (updateCount == 0) { // 校验状态,避免并发重新发起转账 | |
| 166 | + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); | |
| 167 | + } | |
| 168 | + } | |
| 169 | + return transfer; | |
| 170 | + } | |
| 171 | + | |
| 172 | + /** | |
| 173 | + * 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,确认转账结果 | |
| 174 | + * | |
| 175 | + * 注意:只有查询到渠道已受理转账,才以查询结果为准;查询到的转账关闭,不作为最终结果。 | |
| 176 | + * 原因是:一次查询可能存在渠道转账单尚未生成、或者结果尚未同步的情况,例如说钱包查询不存在的转账单时返回关闭 | |
| 177 | + * | |
| 178 | + * @param client 支付客户端 | |
| 179 | + * @param transfer 转账单 | |
| 180 | + * @param unifiedResp 发起转账的返回结果;为 null 时,表示发起转账发生异常 | |
| 181 | + * @return 转账结果;为 null 时,表示结果未知,保留 WAITING 交给转账回调、或者转账轮询补偿 | |
| 182 | + */ | |
| 183 | + private PayTransferRespDTO getTransferAfterUnifiedTransfer(PayClient<?> client, PayTransferDO transfer, | |
| 184 | + PayTransferRespDTO unifiedResp) { | |
| 185 | + PayTransferRespDTO queryResp = null; | |
| 186 | + try { | |
| 187 | + queryResp = client.getTransfer(transfer.getNo()); | |
| 188 | + } catch (Throwable e) { | |
| 189 | + log.warn("[getTransferAfterUnifiedTransfer][转账编号({}) 查询渠道转账单发生异常]", transfer.getId(), e); | |
| 190 | + } | |
| 191 | + // 情况一:查询到转账成功、转账中、或者已有渠道转账单号的待转账,说明渠道实际已受理转账,以查询结果为准 | |
| 192 | + if (queryResp != null && (PayTransferStatusEnum.isSuccess(queryResp.getStatus()) | |
| 193 | + || PayTransferStatusEnum.isProcessing(queryResp.getStatus()) | |
| 194 | + || (PayTransferStatusEnum.isWaiting(queryResp.getStatus()) | |
| 195 | + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())))) { | |
| 196 | + return queryResp; | |
| 197 | + } | |
| 198 | + // 情况二:其它情况,以发起转账的结果为准 | |
| 199 | + // ① 发起转账发生异常:返回 null,保留 WAITING 交给转账回调、或者转账轮询补偿 | |
| 200 | + // ② 发起转账明确返回关闭:以渠道明确返回的关闭为准。原因是:支付宝查询不存在的转账单时返回 WAITING、微信查询不存在的转账单时抛出异常 | |
| 201 | + return unifiedResp; | |
| 202 | + } | |
| 203 | + | |
| 204 | + /** | |
| 129 | 205 | * 校验转账结果通知时的并发更新 |
| 130 | 206 | * |
| 131 | 207 | * 只有状态竞争导致的通知失败,并且转账单已被并发更新为成功或关闭时,才返回最新的转账单; |
| ... | ... | @@ -159,23 +235,30 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 159 | 235 | return payProperties.getTransferNotifyUrl() + "/" + channel.getId(); |
| 160 | 236 | } |
| 161 | 237 | |
| 238 | + /** | |
| 239 | + * 校验转账单是否可以发起 | |
| 240 | + * | |
| 241 | + * @param reqDTO 转账申请信息 | |
| 242 | + * @param appId 应用编号 | |
| 243 | + * @return 转账关闭、需要重新发起的转账单;为 null 时,表示需要新建转账单 | |
| 244 | + */ | |
| 162 | 245 | private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO reqDTO, Long appId) { |
| 163 | 246 | PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantOrderId(appId, reqDTO.getMerchantTransferId()); |
| 164 | - if (transfer != null) { | |
| 165 | - // 只有转账单状态为关闭,才能再次发起转账 | |
| 166 | - if (!PayTransferStatusEnum.isClosed(transfer.getStatus())) { | |
| 167 | - throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); | |
| 168 | - } | |
| 169 | - // 校验参数是否一致 | |
| 170 | - if (ObjectUtil.notEqual(reqDTO.getPrice(), transfer.getPrice())) { | |
| 171 | - throw exception(PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); | |
| 172 | - } | |
| 173 | - if (ObjectUtil.notEqual(reqDTO.getChannelCode(), transfer.getChannelCode())) { | |
| 174 | - throw exception(PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); | |
| 175 | - } | |
| 247 | + if (transfer == null) { | |
| 248 | + return null; | |
| 249 | + } | |
| 250 | + // 只有转账关闭的转账单,才能再次发起转账 | |
| 251 | + // 原因是:待转账、转账中时,不知道渠道转账是否已经受理,交给转账回调、或者转账轮询补偿 | |
| 252 | + if (!PayTransferStatusEnum.isClosed(transfer.getStatus())) { | |
| 253 | + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); | |
| 254 | + } | |
| 255 | + // 校验参数是否一致 | |
| 256 | + if (ObjectUtil.notEqual(reqDTO.getChannelCode(), transfer.getChannelCode())) { | |
| 257 | + throw exception(PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); | |
| 258 | + } | |
| 259 | + if (ObjectUtil.notEqual(reqDTO.getPrice(), transfer.getPrice())) { | |
| 260 | + throw exception(PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); | |
| 176 | 261 | } |
| 177 | - // 如果状态为等待状态:不知道渠道转账是否发起成功 | |
| 178 | - // 特殊:允许使用相同的 no 再次发起转账,渠道会保证幂等 | |
| 179 | 262 | return transfer; |
| 180 | 263 | } |
| 181 | 264 | |
| ... | ... | @@ -194,7 +277,38 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 194 | 277 | if (PayTransferStatusEnum.isProcessing(notify.getStatus())) { |
| 195 | 278 | notifyTransferProgressing(channel, notify); |
| 196 | 279 | } |
| 197 | - // WAITING 状态无需处理 | |
| 280 | + // 转账等待的回调 | |
| 281 | + if (PayTransferStatusEnum.isWaiting(notify.getStatus())) { | |
| 282 | + notifyTransferWaiting(channel, notify); | |
| 283 | + } | |
| 284 | + } | |
| 285 | + | |
| 286 | + private void notifyTransferWaiting(PayChannelDO channel, PayTransferRespDTO notify) { | |
| 287 | + // 没有渠道转账单号,说明渠道未明确受理转账,无需更新。例如说:支付宝查询不到转账单时返回 WAITING | |
| 288 | + if (StrUtil.isEmpty(notify.getChannelTransferNo())) { | |
| 289 | + return; | |
| 290 | + } | |
| 291 | + // 1. 校验 | |
| 292 | + PayTransferDO transfer = transferMapper.selectByAppIdAndNo(channel.getAppId(), notify.getOutTransferNo()); | |
| 293 | + if (transfer == null) { | |
| 294 | + throw exception(PAY_TRANSFER_NOT_FOUND); | |
| 295 | + } | |
| 296 | + // 如果不是待转账(例如说,已被转账回调并发更新)、或者渠道转账单号已一致,直接返回,不用重复更新 | |
| 297 | + if (!PayTransferStatusEnum.isWaiting(transfer.getStatus()) | |
| 298 | + || ObjectUtil.equal(transfer.getChannelTransferNo(), notify.getChannelTransferNo())) { | |
| 299 | + log.info("[notifyTransferWaiting][transfer({}) 状态({}) 无需更新渠道转账单号]", transfer.getId(), transfer.getStatus()); | |
| 300 | + return; | |
| 301 | + } | |
| 302 | + | |
| 303 | + // 2. 更新渠道转账单号:仍是待转账,补充渠道已受理的转账单号 | |
| 304 | + int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), PayTransferStatusEnum.WAITING.getStatus(), | |
| 305 | + new PayTransferDO().setChannelTransferNo(notify.getChannelTransferNo()) | |
| 306 | + .setChannelNotifyData(JsonUtils.toJsonString(notify))); | |
| 307 | + if (updateCounts == 0) { // 已被并发更新,以最新状态为准 | |
| 308 | + log.info("[notifyTransferWaiting][transfer({}) 已被并发更新,无需更新渠道转账单号]", transfer.getId()); | |
| 309 | + return; | |
| 310 | + } | |
| 311 | + log.info("[notifyTransferWaiting][transfer({}) 更新渠道转账单号({})]", transfer.getId(), notify.getChannelTransferNo()); | |
| 198 | 312 | } |
| 199 | 313 | |
| 200 | 314 | private void notifyTransferProgressing(PayChannelDO channel, PayTransferRespDTO notify) { |
| ... | ... | @@ -216,6 +330,7 @@ public class PayTransferServiceImpl implements PayTransferService { |
| 216 | 330 | int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), |
| 217 | 331 | PayTransferStatusEnum.WAITING.getStatus(), |
| 218 | 332 | new PayTransferDO().setStatus(PayTransferStatusEnum.PROCESSING.getStatus()) |
| 333 | + .setChannelTransferNo(notify.getChannelTransferNo()) | |
| 219 | 334 | .setChannelPackageInfo(notify.getChannelPackageInfo())); |
| 220 | 335 | if (updateCounts == 0) { |
| 221 | 336 | PayTransferDO latestTransfer = transferMapper.selectById(transfer.getId()); | ... | ... |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java
| 1 | 1 | package cn.iocoder.yudao.module.pay.service.wallet; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.core.lang.Assert; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 6 | import cn.iocoder.yudao.framework.common.util.date.DateUtils; |
| 6 | 7 | import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletPageReqVO; |
| ... | ... | @@ -19,6 +20,7 @@ import lombok.SneakyThrows; |
| 19 | 20 | import lombok.extern.slf4j.Slf4j; |
| 20 | 21 | import org.springframework.context.annotation.Lazy; |
| 21 | 22 | import org.springframework.stereotype.Service; |
| 23 | +import org.springframework.transaction.annotation.Propagation; | |
| 22 | 24 | import org.springframework.transaction.annotation.Transactional; |
| 23 | 25 | |
| 24 | 26 | import java.time.LocalDateTime; |
| ... | ... | @@ -64,20 +66,37 @@ public class PayWalletServiceImpl implements PayWalletService { |
| 64 | 66 | if (wallet == null) { |
| 65 | 67 | // 使用双重检查锁,保证钱包创建并发问题 |
| 66 | 68 | // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1475/files |
| 67 | - wallet = lockRedisDAO.lock(userId, UPDATE_TIMEOUT_MILLIS, () -> { | |
| 68 | - PayWalletDO newWallet = walletMapper.selectByUserIdAndType(userId, userType); | |
| 69 | - if (newWallet == null) { | |
| 70 | - newWallet = new PayWalletDO().setUserId(userId).setUserType(userType) | |
| 71 | - .setBalance(0).setTotalExpense(0).setTotalRecharge(0); | |
| 72 | - newWallet.setCreateTime(LocalDateTime.now()); | |
| 73 | - walletMapper.insert(newWallet); | |
| 74 | - } | |
| 75 | - return newWallet; | |
| 76 | - }); | |
| 69 | + wallet = lockRedisDAO.lock(userId, UPDATE_TIMEOUT_MILLIS, | |
| 70 | + () -> getSelf().createWalletIfAbsent(userId, userType)); | |
| 77 | 71 | } |
| 78 | 72 | return wallet; |
| 79 | 73 | } |
| 80 | 74 | |
| 75 | + /** | |
| 76 | + * 创建钱包,如果不存在的话 | |
| 77 | + * | |
| 78 | + * 注意:使用新事务,在释放锁之前提交,不加入调用方的事务。原因是: | |
| 79 | + * 1. 调用方的事务未提交时,其它请求查询不到钱包,会重复创建钱包 | |
| 80 | + * 2. 后续不加入调用方事务的操作,也需要查询到钱包。例如说,佣金提现到钱包时,转账 createTransfer 不加入调用方的事务 | |
| 81 | + * 另外,钱包是空钱包,即使调用方的事务回滚,保留也没有影响 | |
| 82 | + * | |
| 83 | + * @param userId 用户编号 | |
| 84 | + * @param userType 用户类型 | |
| 85 | + * @return 钱包 | |
| 86 | + */ | |
| 87 | + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) | |
| 88 | + public PayWalletDO createWalletIfAbsent(Long userId, Integer userType) { | |
| 89 | + PayWalletDO wallet = walletMapper.selectByUserIdAndType(userId, userType); | |
| 90 | + if (wallet != null) { | |
| 91 | + return wallet; | |
| 92 | + } | |
| 93 | + wallet = new PayWalletDO().setUserId(userId).setUserType(userType) | |
| 94 | + .setBalance(0).setTotalExpense(0).setTotalRecharge(0); | |
| 95 | + wallet.setCreateTime(LocalDateTime.now()); | |
| 96 | + walletMapper.insert(wallet); | |
| 97 | + return wallet; | |
| 98 | + } | |
| 99 | + | |
| 81 | 100 | @Override |
| 82 | 101 | public PayWalletDO getWallet(Long walletId) { |
| 83 | 102 | return walletMapper.selectById(walletId); |
| ... | ... | @@ -245,4 +264,13 @@ public class PayWalletServiceImpl implements PayWalletService { |
| 245 | 264 | } |
| 246 | 265 | } |
| 247 | 266 | |
| 267 | + /** | |
| 268 | + * 获得自身的代理对象,解决 AOP 生效问题 | |
| 269 | + * | |
| 270 | + * @return 自己 | |
| 271 | + */ | |
| 272 | + private PayWalletServiceImpl getSelf() { | |
| 273 | + return SpringUtil.getBean(getClass()); | |
| 274 | + } | |
| 275 | + | |
| 248 | 276 | } | ... | ... |
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java
| 1 | 1 | package cn.iocoder.yudao.module.pay.service.refund; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.extra.spring.SpringUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 4 | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; |
| 6 | 7 | import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; |
| ... | ... | @@ -12,6 +13,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; |
| 12 | 13 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 13 | 14 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; |
| 14 | 15 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 16 | +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; | |
| 15 | 17 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; |
| 16 | 18 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 17 | 19 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; |
| ... | ... | @@ -30,9 +32,17 @@ import org.junit.jupiter.api.Test; |
| 30 | 32 | import org.mockito.MockedStatic; |
| 31 | 33 | import org.springframework.context.annotation.Import; |
| 32 | 34 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 35 | +import org.springframework.test.util.AopTestUtils; | |
| 36 | +import org.springframework.test.util.ReflectionTestUtils; | |
| 37 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 38 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 33 | 39 | |
| 40 | +import java.time.LocalDateTime; | |
| 41 | +import java.util.ArrayList; | |
| 34 | 42 | import java.util.List; |
| 43 | +import java.util.concurrent.CompletableFuture; | |
| 35 | 44 | |
| 45 | +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | |
| 36 | 46 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; |
| 37 | 47 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; |
| 38 | 48 | import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; |
| ... | ... | @@ -45,6 +55,7 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; |
| 45 | 55 | import static org.assertj.core.api.Assertions.assertThat; |
| 46 | 56 | import static org.junit.jupiter.api.Assertions.*; |
| 47 | 57 | import static org.mockito.ArgumentMatchers.any; |
| 58 | +import static org.mockito.AdditionalAnswers.delegatesTo; | |
| 48 | 59 | import static org.mockito.ArgumentMatchers.eq; |
| 49 | 60 | import static org.mockito.Mockito.*; |
| 50 | 61 | |
| ... | ... | @@ -53,7 +64,7 @@ import static org.mockito.Mockito.*; |
| 53 | 64 | * |
| 54 | 65 | * @author 芋艿 |
| 55 | 66 | */ |
| 56 | -@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class}) | |
| 67 | +@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class, PayRefundLockRedisDAO.class}) | |
| 57 | 68 | public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 58 | 69 | |
| 59 | 70 | @Resource |
| ... | ... | @@ -62,6 +73,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 62 | 73 | @Resource |
| 63 | 74 | private PayRefundMapper refundMapper; |
| 64 | 75 | |
| 76 | + @Resource | |
| 77 | + private PlatformTransactionManager transactionManager; | |
| 78 | + | |
| 65 | 79 | @MockitoBean |
| 66 | 80 | private PayProperties payProperties; |
| 67 | 81 | @MockitoBean |
| ... | ... | @@ -332,9 +346,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 332 | 346 | // mock 方法(client) |
| 333 | 347 | PayClient<?> client = mock(PayClient.class); |
| 334 | 348 | when(channelService.getPayClient(eq(10L))).thenReturn(client); |
| 335 | - // mock 数据(refund 已存在) | |
| 349 | + // mock 数据(refund 已存在,且不是退款失败) | |
| 336 | 350 | PayRefundDO refund = randomPojo(PayRefundDO.class, o -> |
| 337 | - o.setAppId(1L).setMerchantRefundId("200")); | |
| 351 | + o.setAppId(1L).setMerchantRefundId("200").setStatus(PayRefundStatusEnum.SUCCESS.getStatus())); | |
| 338 | 352 | refundMapper.insert(refund); |
| 339 | 353 | |
| 340 | 354 | // 调用,并断言异常 |
| ... | ... | @@ -379,6 +393,453 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 379 | 393 | .containsExactly(order.getId(), order.getNo(), channel.getId(), channel.getCode(), |
| 380 | 394 | app.getRefundNotifyUrl(), order.getChannelOrderNo(), PayRefundStatusEnum.WAITING.getStatus(), |
| 381 | 395 | order.getPrice(), reqDTO.getPrice()); |
| 396 | + // 断言:查询一次渠道退款单,查询不到结果时保留 WAITING | |
| 397 | + verify(client).getRefund(eq(order.getNo()), eq(refundDO.getNo())); | |
| 398 | + } | |
| 399 | + | |
| 400 | + @Test // 调用 unifiedRefund 接口发生异常,但查询确认渠道退款成功 | |
| 401 | + public void testCreateRefund_invokeException_querySuccess() { | |
| 402 | + // mock 方法(client) | |
| 403 | + PayClient<?> client = mockPayClient(); | |
| 404 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); | |
| 405 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", | |
| 406 | + LocalDateTime.now(), invocation.getArgument(1), "success")); | |
| 407 | + | |
| 408 | + // 调用 | |
| 409 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 410 | + // 断言:按查询结果更新为退款成功 | |
| 411 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 412 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); | |
| 413 | + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); | |
| 414 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); | |
| 415 | + } | |
| 416 | + | |
| 417 | + @Test // 调用 unifiedRefund 接口发生异常,查询返回失败(例如说:微信查询不到退款单) | |
| 418 | + public void testCreateRefund_invokeException_queryFailure() { | |
| 419 | + // mock 方法(client) | |
| 420 | + PayClient<?> client = mockPayClient(); | |
| 421 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); | |
| 422 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( | |
| 423 | + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); | |
| 424 | + | |
| 425 | + // 调用:一次查询的失败(可能是渠道退款单尚未生成),不作为最终结果,保留 WAITING 交给退款轮询 | |
| 426 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 427 | + // 断言 | |
| 428 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 429 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 430 | + assertNull(refund.getChannelErrorCode()); | |
| 431 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 432 | + } | |
| 433 | + | |
| 434 | + @Test // 调用 unifiedRefund 接口发生异常,查询返回 WAITING 且无渠道退款单号(例如说:支付宝查询不到退款单) | |
| 435 | + public void testCreateRefund_invokeException_queryWaitingWithoutChannelRefundNo() { | |
| 436 | + // mock 方法(client) | |
| 437 | + PayClient<?> client = mockPayClient(); | |
| 438 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); | |
| 439 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( | |
| 440 | + null, invocation.getArgument(1), "waiting")); | |
| 441 | + | |
| 442 | + // 调用:结果未知,保留 WAITING 交给退款轮询 | |
| 443 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 444 | + // 断言 | |
| 445 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); | |
| 446 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 447 | + } | |
| 448 | + | |
| 449 | + @Test // 调用 unifiedRefund 接口发生异常,查询渠道退款单也发生异常 | |
| 450 | + public void testCreateRefund_invokeException_queryException() { | |
| 451 | + // mock 方法(client) | |
| 452 | + PayClient<?> client = mockPayClient(); | |
| 453 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); | |
| 454 | + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询超时")); | |
| 455 | + | |
| 456 | + // 调用:结果未知,保留 WAITING 交给退款轮询 | |
| 457 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 458 | + // 断言 | |
| 459 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); | |
| 460 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 461 | + } | |
| 462 | + | |
| 463 | + @Test // 调用 unifiedRefund 接口返回失败,查询也确认渠道退款失败 | |
| 464 | + public void testCreateRefund_invokeFailure_queryFailure() { | |
| 465 | + // mock 方法(client) | |
| 466 | + PayClient<?> client = mockPayClient(); | |
| 467 | + mockUnifiedRefundFailure(client); | |
| 468 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( | |
| 469 | + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); | |
| 470 | + | |
| 471 | + // 调用,并断言异常:以发起退款的失败原因为准 | |
| 472 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 473 | + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); | |
| 474 | + // 断言:退款单已更新为失败 | |
| 475 | + assertRefundFailure("BALANCE_NOT_ENOUGH"); | |
| 476 | + } | |
| 477 | + | |
| 478 | + @Test // 调用 unifiedRefund 接口返回失败,查询返回 WAITING 且无渠道退款单号(例如说:支付宝查询不到退款单) | |
| 479 | + public void testCreateRefund_invokeFailure_queryWaitingWithoutChannelRefundNo() { | |
| 480 | + // mock 方法(client) | |
| 481 | + PayClient<?> client = mockPayClient(); | |
| 482 | + mockUnifiedRefundFailure(client); | |
| 483 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( | |
| 484 | + null, invocation.getArgument(1), "waiting")); | |
| 485 | + | |
| 486 | + // 调用,并断言异常:以发起退款的失败结果为准,避免一直 WAITING | |
| 487 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 488 | + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); | |
| 489 | + // 断言:退款单已更新为失败 | |
| 490 | + assertRefundFailure("BALANCE_NOT_ENOUGH"); | |
| 491 | + } | |
| 492 | + | |
| 493 | + @Test // 调用 unifiedRefund 接口返回失败,查询渠道退款单发生异常(例如说:钱包查询 WAITING 退款单) | |
| 494 | + public void testCreateRefund_invokeFailure_queryException() { | |
| 495 | + // mock 方法(client) | |
| 496 | + PayClient<?> client = mockPayClient(); | |
| 497 | + mockUnifiedRefundFailure(client); | |
| 498 | + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询异常")); | |
| 499 | + | |
| 500 | + // 调用,并断言异常:以发起退款的失败结果为准,避免一直 WAITING | |
| 501 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 502 | + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); | |
| 503 | + // 断言:退款单已更新为失败 | |
| 504 | + assertRefundFailure("BALANCE_NOT_ENOUGH"); | |
| 505 | + } | |
| 506 | + | |
| 507 | + @Test // 调用 unifiedRefund 接口返回失败,但查询到渠道退款单处理中 | |
| 508 | + public void testCreateRefund_invokeFailure_queryProcessing() { | |
| 509 | + // mock 方法(client) | |
| 510 | + PayClient<?> client = mockPayClient(); | |
| 511 | + mockUnifiedRefundFailure(client); | |
| 512 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( | |
| 513 | + "CHANNEL_R001", invocation.getArgument(1), "processing")); | |
| 514 | + | |
| 515 | + // 调用:渠道实际已受理退款,保留 WAITING 交给退款回调、或者退款轮询 | |
| 516 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 517 | + // 断言:保留渠道已受理的退款单号 | |
| 518 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 519 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 520 | + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); | |
| 521 | + assertNull(refund.getChannelErrorCode()); | |
| 522 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 523 | + } | |
| 524 | + | |
| 525 | + @Test // 调用 unifiedRefund 接口返回退款中,并返回渠道退款单号(例如说:微信退款受理) | |
| 526 | + public void testCreateRefund_invokeWaitingWithChannelRefundNo() { | |
| 527 | + // mock 方法(client) | |
| 528 | + PayClient<?> client = mockPayClient(); | |
| 529 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( | |
| 530 | + "CHANNEL_R001", ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "processing")); | |
| 531 | + | |
| 532 | + // 调用 | |
| 533 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 534 | + // 断言:仍是 WAITING,并保存渠道退款单号、渠道返回数据 | |
| 535 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 536 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 537 | + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); | |
| 538 | + assertNotNull(refund.getChannelNotifyData()); | |
| 539 | + verify(client, never()).getRefund(any(), any()); | |
| 540 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 541 | + } | |
| 542 | + | |
| 543 | + @Test // 退款中的通知晚于退款成功到达,不能覆盖已成功的退款单 | |
| 544 | + public void testNotifyRefund_waitingWhenSuccess() { | |
| 545 | + // mock 数据(refund 已退款成功) | |
| 546 | + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setNo("R001") | |
| 547 | + .setStatus(PayRefundStatusEnum.SUCCESS.getStatus()).setChannelRefundNo("CHANNEL_R001")); | |
| 548 | + refundMapper.insert(refund); | |
| 549 | + // 准备参数 | |
| 550 | + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(1L)); | |
| 551 | + | |
| 552 | + // 调用 | |
| 553 | + refundService.notifyRefund(channel, PayRefundRespDTO.waitingOf("CHANNEL_R002", "R001", "processing")); | |
| 554 | + // 断言:不更新 | |
| 555 | + PayRefundDO dbRefund = refundMapper.selectById(refund.getId()); | |
| 556 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), dbRefund.getStatus()); | |
| 557 | + assertEquals("CHANNEL_R001", dbRefund.getChannelRefundNo()); | |
| 558 | + assertEquals(refund.getChannelNotifyData(), dbRefund.getChannelNotifyData()); | |
| 559 | + } | |
| 560 | + | |
| 561 | + @Test // 并发使用相同的商户退款编号发起退款,只能创建一个退款单,避免重复退款 | |
| 562 | + public void testCreateRefund_concurrentCreate() throws Exception { | |
| 563 | + // mock 方法(client) | |
| 564 | + PayClient<?> client = mockPayClient(); | |
| 565 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf( | |
| 566 | + "CHANNEL_R001", ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "processing")); | |
| 567 | + // mock 方法(mapper):查询后等待一段时间,放大 select 后 insert 的时间窗口 | |
| 568 | + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper)); | |
| 569 | + doAnswer(invocation -> { | |
| 570 | + Long count = refundMapper.selectCountByAppIdAndOrderId(invocation.getArgument(0), | |
| 571 | + invocation.getArgument(1), invocation.getArgument(2)); | |
| 572 | + Thread.sleep(300); | |
| 573 | + return count; | |
| 574 | + }).when(refundMapperSpy).selectCountByAppIdAndOrderId(any(), any(), any()); | |
| 575 | + Object target = AopTestUtils.getTargetObject(refundService); | |
| 576 | + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy); | |
| 577 | + List<Throwable> exceptions = new ArrayList<>(); | |
| 578 | + try { | |
| 579 | + // 调用:两个请求并发发起退款 | |
| 580 | + List<CompletableFuture<Long>> futures = List.of( | |
| 581 | + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO())), | |
| 582 | + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO()))); | |
| 583 | + for (CompletableFuture<Long> future : futures) { | |
| 584 | + try { | |
| 585 | + future.get(); | |
| 586 | + } catch (Exception ex) { | |
| 587 | + exceptions.add(ex.getCause()); | |
| 588 | + } | |
| 589 | + } | |
| 590 | + } finally { | |
| 591 | + ReflectionTestUtils.setField(target, "refundMapper", refundMapper); | |
| 592 | + } | |
| 593 | + // 断言:只有一个请求创建退款单,另一个请求提示存在退款中的退款单 | |
| 594 | + assertEquals(1, exceptions.size()); | |
| 595 | + assertEquals(REFUND_HAS_REFUNDING.getCode(), ((ServiceException) exceptions.get(0)).getCode()); | |
| 596 | + assertEquals(1, refundMapper.selectList().size()); | |
| 597 | + verify(client, times(1)).unifiedRefund(any()); | |
| 598 | + } | |
| 599 | + | |
| 600 | + @Test // 调用 unifiedRefund 接口返回失败,但查询确认渠道退款成功 | |
| 601 | + public void testCreateRefund_invokeFailure_querySuccess() { | |
| 602 | + // mock 方法(client) | |
| 603 | + PayClient<?> client = mockPayClient(); | |
| 604 | + mockUnifiedRefundFailure(client); | |
| 605 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", | |
| 606 | + LocalDateTime.now(), invocation.getArgument(1), "success")); | |
| 607 | + | |
| 608 | + // 调用 | |
| 609 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 610 | + // 断言:按查询结果更新为退款成功 | |
| 611 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 612 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); | |
| 613 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); | |
| 614 | + } | |
| 615 | + | |
| 616 | + @Test // 调用 unifiedRefund 接口返回成功,但处理退款结果时发生异常 | |
| 617 | + public void testCreateRefund_invokeSuccess_notifyException() { | |
| 618 | + // mock 方法(client):返回不存在的退款单号,使 notifyRefund 抛出异常 | |
| 619 | + PayClient<?> client = mockPayClient(); | |
| 620 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenReturn(PayRefundRespDTO.successOf( | |
| 621 | + "CHANNEL_R001", LocalDateTime.now(), "NOT_EXISTS", "success")); | |
| 622 | + | |
| 623 | + // 调用:渠道已退款成功,不向调用方抛出异常,避免调用方回滚退款单 | |
| 624 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 625 | + // 断言 | |
| 626 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(refundId).getStatus()); | |
| 627 | + verify(client, never()).getRefund(any(), any()); | |
| 628 | + } | |
| 629 | + | |
| 630 | + @Test // 调用 unifiedRefund 接口返回存在渠道错误,但退款单已被并发更新为成功(例如说:退款回调) | |
| 631 | + public void testCreateRefund_channelErrorWhenConcurrentSuccess() { | |
| 632 | + // mock 方法(client) | |
| 633 | + PayClient<?> client = mockPayClient(); | |
| 634 | + mockUnifiedRefundFailure(client); | |
| 635 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> { | |
| 636 | + // 模拟:退款回调并发更新退款单为退款成功 | |
| 637 | + PayRefundDO refund = refundMapper.selectByNo(invocation.getArgument(1)); | |
| 638 | + refundMapper.updateById(new PayRefundDO().setId(refund.getId()) | |
| 639 | + .setStatus(PayRefundStatusEnum.SUCCESS.getStatus())); | |
| 640 | + return PayRefundRespDTO.failureOf("REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure"); | |
| 641 | + }); | |
| 642 | + | |
| 643 | + // 调用:以最新的退款成功为准,不向调用方误报失败 | |
| 644 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 645 | + // 断言 | |
| 646 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refundMapper.selectById(refundId).getStatus()); | |
| 647 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 648 | + } | |
| 649 | + | |
| 650 | + @Test // 调用 unifiedRefund 接口返回存在渠道错误,通知时发生状态竞争,但退款单仍是待退款 | |
| 651 | + public void testCreateRefund_channelErrorWhenConcurrentWaiting() { | |
| 652 | + PayRefundServiceImpl payRefundServiceImpl = mock(PayRefundServiceImpl.class); | |
| 653 | + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) { | |
| 654 | + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayRefundServiceImpl.class))) | |
| 655 | + .thenReturn(payRefundServiceImpl); | |
| 656 | + doThrow(exception(REFUND_STATUS_IS_NOT_WAITING)) | |
| 657 | + .when(payRefundServiceImpl).notifyRefund(any(PayChannelDO.class), any()); | |
| 658 | + // mock 方法(client) | |
| 659 | + PayClient<?> client = mockPayClient(); | |
| 660 | + mockUnifiedRefundFailure(client); | |
| 661 | + | |
| 662 | + // 调用,并断言异常:无法确认最新状态,抛出原通知异常 | |
| 663 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 664 | + REFUND_STATUS_IS_NOT_WAITING); | |
| 665 | + // 断言 | |
| 666 | + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); | |
| 667 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 668 | + } | |
| 669 | + } | |
| 670 | + | |
| 671 | + @Test // 调用 unifiedRefund 接口返回存在渠道错误,但创建退款通知任务时发生异常 | |
| 672 | + public void testCreateRefund_channelErrorAndNotifyTaskException() { | |
| 673 | + // mock 方法(client) | |
| 674 | + PayClient<?> client = mockPayClient(); | |
| 675 | + mockUnifiedRefundFailure(client); | |
| 676 | + // mock 方法(notify) | |
| 677 | + RuntimeException notifyTaskException = new RuntimeException("模拟创建通知任务异常"); | |
| 678 | + doThrow(notifyTaskException).when(notifyService).createPayNotifyTask(any(), any()); | |
| 679 | + | |
| 680 | + // 调用,并断言异常:抛出真实的通知异常,而不是渠道错误 | |
| 681 | + RuntimeException ex = assertThrows(RuntimeException.class, | |
| 682 | + () -> refundService.createRefund(buildRefundCreateReqDTO())); | |
| 683 | + assertSame(notifyTaskException, ex); | |
| 684 | + // 断言:失败状态随通知事务回滚 | |
| 685 | + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); | |
| 686 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 687 | + assertNull(refund.getChannelErrorCode()); | |
| 688 | + } | |
| 689 | + | |
| 690 | + @Test // 调用 unifiedRefund 接口,返回失败,但不存在渠道错误 | |
| 691 | + public void testCreateRefund_failureWithoutChannelError() { | |
| 692 | + // mock 方法(client) | |
| 693 | + PayClient<?> client = mockPayClient(); | |
| 694 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> | |
| 695 | + PayRefundRespDTO.failureOf(null, null, | |
| 696 | + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "failure")); | |
| 697 | + | |
| 698 | + // 调用:不抛出异常,由退款通知告知调用方 | |
| 699 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 700 | + // 断言 | |
| 701 | + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(refundId).getStatus()); | |
| 702 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); | |
| 703 | + } | |
| 704 | + | |
| 705 | + @Test // 退款失败(例如说:余额不足)后,使用相同的商户退款编号再次发起,退款成功 | |
| 706 | + public void testCreateRefund_retryAfterFailure_success() { | |
| 707 | + // 准备参数:首次发起退款失败 | |
| 708 | + PayClient<?> client = mockPayClient(); | |
| 709 | + mockUnifiedRefundFailure(client); | |
| 710 | + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf( | |
| 711 | + "REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure")); | |
| 712 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 713 | + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); | |
| 714 | + PayRefundDO failureRefund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); | |
| 715 | + // mock 方法(client 再次发起时,退款成功) | |
| 716 | + doAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(), | |
| 717 | + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success")) | |
| 718 | + .when(client).unifiedRefund(any(PayRefundUnifiedReqDTO.class)); | |
| 719 | + | |
| 720 | + // 调用 | |
| 721 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO().setReason("再次退款")); | |
| 722 | + // 断言:复用原退款单及原退款单号,并清空上一次的失败原因 | |
| 723 | + assertEquals(failureRefund.getId(), refundId); | |
| 724 | + assertEquals(1, refundMapper.selectList().size()); | |
| 725 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 726 | + assertEquals(failureRefund.getNo(), refund.getNo()); | |
| 727 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); | |
| 728 | + assertEquals("CHANNEL_R001", refund.getChannelRefundNo()); | |
| 729 | + assertEquals("再次退款", refund.getReason()); | |
| 730 | + assertNull(refund.getChannelErrorCode()); | |
| 731 | + assertNull(refund.getChannelErrorMsg()); | |
| 732 | + // 断言:两次都使用原退款单号向渠道发起退款,并分别通知退款失败、退款成功 | |
| 733 | + verify(client, times(2)).unifiedRefund(argThat(unifiedReqDTO -> | |
| 734 | + failureRefund.getNo().equals(unifiedReqDTO.getOutRefundNo()))); | |
| 735 | + verify(notifyService, times(2)).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId)); | |
| 736 | + } | |
| 737 | + | |
| 738 | + @Test // 退款失败后再次发起,渠道结果未知,保留 WAITING 并清空上一次的渠道结果 | |
| 739 | + public void testCreateRefund_retryAfterFailure_unknown() { | |
| 740 | + // 准备参数:已存在退款失败的退款单 | |
| 741 | + PayClient<?> client = mockPayClient(); | |
| 742 | + PayRefundDO failureRefund = insertFailureRefund(9); | |
| 743 | + // mock 方法(client 调用发生异常,查询也发生异常) | |
| 744 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时")); | |
| 745 | + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询超时")); | |
| 746 | + | |
| 747 | + // 调用 | |
| 748 | + Long refundId = refundService.createRefund(buildRefundCreateReqDTO()); | |
| 749 | + // 断言:复用原退款单号,状态重置为 WAITING,交给退款回调、或者退款轮询 | |
| 750 | + assertEquals(failureRefund.getId(), refundId); | |
| 751 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 752 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus()); | |
| 753 | + assertNull(refund.getChannelRefundNo()); | |
| 754 | + assertNull(refund.getChannelErrorCode()); | |
| 755 | + assertNull(refund.getChannelErrorMsg()); | |
| 756 | + assertNull(refund.getChannelNotifyData()); | |
| 757 | + verify(client).getRefund(eq(failureRefund.getOrderNo()), eq(failureRefund.getNo())); | |
| 758 | + } | |
| 759 | + | |
| 760 | + @Test // 退款失败后再次发起,但退款单已被并发重新发起 | |
| 761 | + public void testCreateRefund_retryAfterFailure_concurrentRetry() { | |
| 762 | + // 准备参数:已存在退款失败的退款单 | |
| 763 | + PayClient<?> client = mockPayClient(); | |
| 764 | + PayRefundDO failureRefund = insertFailureRefund(9); | |
| 765 | + // mock 方法(mapper):校验通过后、更新前,其它请求已将退款单重新发起为 WAITING | |
| 766 | + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper)); | |
| 767 | + doAnswer(invocation -> { | |
| 768 | + refundMapper.updateById(new PayRefundDO().setId(failureRefund.getId()) | |
| 769 | + .setStatus(PayRefundStatusEnum.WAITING.getStatus())); | |
| 770 | + return refundMapper.updateByIdAndStatusAndClearChannelResult(invocation.getArgument(0), | |
| 771 | + invocation.getArgument(1), invocation.getArgument(2)); | |
| 772 | + }).when(refundMapperSpy).updateByIdAndStatusAndClearChannelResult(any(), any(), any()); | |
| 773 | + Object target = AopTestUtils.getTargetObject(refundService); | |
| 774 | + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy); | |
| 775 | + try { | |
| 776 | + // 调用,并断言异常 | |
| 777 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 778 | + REFUND_HAS_REFUNDING); | |
| 779 | + } finally { | |
| 780 | + ReflectionTestUtils.setField(target, "refundMapper", refundMapper); | |
| 781 | + } | |
| 782 | + // 断言:不会再次向渠道发起退款 | |
| 783 | + verify(client, never()).unifiedRefund(any()); | |
| 784 | + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); | |
| 785 | + } | |
| 786 | + | |
| 787 | + @Test // 调用方存在事务,退款失败后调用方事务回滚,退款失败的结果仍需保存,并可使用原退款单号重新发起 | |
| 788 | + public void testCreateRefund_outerTransactionRollback() { | |
| 789 | + // mock 方法(client) | |
| 790 | + PayClient<?> client = mockPayClient(); | |
| 791 | + mockUnifiedRefundFailure(client); | |
| 792 | + PayRefundDO outerRefund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setMerchantRefundId("300")); | |
| 793 | + | |
| 794 | + // 调用:调用方事务中,先写入自己的数据,再发起退款,渠道错误导致调用方事务回滚 | |
| 795 | + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); | |
| 796 | + assertServiceException(() -> transactionTemplate.executeWithoutResult(status -> { | |
| 797 | + refundMapper.insert(outerRefund); | |
| 798 | + refundService.createRefund(buildRefundCreateReqDTO()); | |
| 799 | + }), REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足"); | |
| 800 | + // 断言:调用方事务已回滚,但退款失败的结果(包括退款通知)已保存 | |
| 801 | + assertNull(refundMapper.selectById(outerRefund.getId())); | |
| 802 | + assertRefundFailure("BALANCE_NOT_ENOUGH"); | |
| 803 | + PayRefundDO failureRefund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); | |
| 804 | + | |
| 805 | + // mock 方法(client 再次发起时,退款成功) | |
| 806 | + doAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(), | |
| 807 | + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success")) | |
| 808 | + .when(client).unifiedRefund(any(PayRefundUnifiedReqDTO.class)); | |
| 809 | + // 调用:使用相同的商户退款编号再次发起 | |
| 810 | + Long refundId = transactionTemplate.execute(status -> refundService.createRefund(buildRefundCreateReqDTO())); | |
| 811 | + // 断言:复用原退款单号 | |
| 812 | + assertEquals(failureRefund.getId(), refundId); | |
| 813 | + PayRefundDO refund = refundMapper.selectById(refundId); | |
| 814 | + assertEquals(failureRefund.getNo(), refund.getNo()); | |
| 815 | + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus()); | |
| 816 | + } | |
| 817 | + | |
| 818 | + @Test // 退款失败后再次发起,但支付订单不一致 | |
| 819 | + public void testCreateRefund_retryAfterFailure_orderNotMatch() { | |
| 820 | + // 准备参数:已存在退款失败的退款单,关联其它支付订单 | |
| 821 | + PayClient<?> client = mockPayClient(); | |
| 822 | + PayRefundDO failureRefund = insertFailureRefund(9); | |
| 823 | + refundMapper.updateById(new PayRefundDO().setId(failureRefund.getId()).setOrderId(-1L)); | |
| 824 | + | |
| 825 | + // 调用,并断言异常 | |
| 826 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 827 | + REFUND_CREATE_FAIL_ORDER_NOT_MATCH); | |
| 828 | + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); | |
| 829 | + verify(client, never()).unifiedRefund(any()); | |
| 830 | + } | |
| 831 | + | |
| 832 | + @Test // 退款失败后再次发起,但退款金额不一致 | |
| 833 | + public void testCreateRefund_retryAfterFailure_priceNotMatch() { | |
| 834 | + // 准备参数:已存在退款金额为 5 的退款失败的退款单 | |
| 835 | + PayClient<?> client = mockPayClient(); | |
| 836 | + PayRefundDO failureRefund = insertFailureRefund(5); | |
| 837 | + | |
| 838 | + // 调用,并断言异常 | |
| 839 | + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()), | |
| 840 | + REFUND_CREATE_FAIL_PRICE_NOT_MATCH); | |
| 841 | + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refundMapper.selectById(failureRefund.getId()).getStatus()); | |
| 842 | + verify(client, never()).unifiedRefund(any()); | |
| 382 | 843 | } |
| 383 | 844 | |
| 384 | 845 | @Test |
| ... | ... | @@ -696,4 +1157,59 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 696 | 1157 | assertEquals(count, 0); |
| 697 | 1158 | } |
| 698 | 1159 | |
| 1160 | + private PayClient<?> mockPayClient() { | |
| 1161 | + // mock 方法(app) | |
| 1162 | + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L)); | |
| 1163 | + when(appService.validPayApp(eq("demo"))).thenReturn(app); | |
| 1164 | + // mock 数据(order) | |
| 1165 | + PayOrderDO order = randomPojo(PayOrderDO.class, o -> | |
| 1166 | + o.setStatus(PayOrderStatusEnum.SUCCESS.getStatus()) | |
| 1167 | + .setPrice(10).setRefundPrice(0) | |
| 1168 | + .setChannelId(10L).setChannelCode(PayChannelEnum.ALIPAY_APP.getCode())); | |
| 1169 | + when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order); | |
| 1170 | + // mock 方法(channel) | |
| 1171 | + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setAppId(1L) | |
| 1172 | + .setCode(PayChannelEnum.ALIPAY_APP.getCode())); | |
| 1173 | + when(channelService.validPayChannel(eq(10L))).thenReturn(channel); | |
| 1174 | + // mock 方法(client) | |
| 1175 | + PayClient<?> client = mock(PayClient.class); | |
| 1176 | + when(channelService.getPayClient(eq(10L))).thenReturn(client); | |
| 1177 | + return client; | |
| 1178 | + } | |
| 1179 | + | |
| 1180 | + private static void mockUnifiedRefundFailure(PayClient<?> client) { | |
| 1181 | + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> { | |
| 1182 | + PayRefundUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 1183 | + return PayRefundRespDTO.failureOf("BALANCE_NOT_ENOUGH", "余额不足", | |
| 1184 | + unifiedReqDTO.getOutRefundNo(), "failure"); | |
| 1185 | + }); | |
| 1186 | + } | |
| 1187 | + | |
| 1188 | + private static PayRefundCreateReqDTO buildRefundCreateReqDTO() { | |
| 1189 | + return new PayRefundCreateReqDTO().setAppKey("demo").setUserIp("127.0.0.1") | |
| 1190 | + .setUserId(1L).setUserType(1) | |
| 1191 | + .setMerchantOrderId("100").setMerchantRefundId("200") | |
| 1192 | + .setReason("测试退款").setPrice(9); | |
| 1193 | + } | |
| 1194 | + | |
| 1195 | + /** | |
| 1196 | + * 插入退款失败的退款单,关联 {@link #mockPayClient()} 的支付订单 | |
| 1197 | + */ | |
| 1198 | + private PayRefundDO insertFailureRefund(Integer refundPrice) { | |
| 1199 | + PayOrderDO order = orderService.getOrder(1L, "100"); | |
| 1200 | + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setMerchantRefundId("200") | |
| 1201 | + .setOrderId(order.getId()).setOrderNo(order.getNo()).setRefundPrice(refundPrice) | |
| 1202 | + .setStatus(PayRefundStatusEnum.FAILURE.getStatus()) | |
| 1203 | + .setChannelErrorCode("BALANCE_NOT_ENOUGH").setChannelErrorMsg("余额不足")); | |
| 1204 | + refundMapper.insert(refund); | |
| 1205 | + return refund; | |
| 1206 | + } | |
| 1207 | + | |
| 1208 | + private void assertRefundFailure(String channelErrorCode) { | |
| 1209 | + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200"); | |
| 1210 | + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refund.getStatus()); | |
| 1211 | + assertEquals(channelErrorCode, refund.getChannelErrorCode()); | |
| 1212 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refund.getId())); | |
| 1213 | + } | |
| 1214 | + | |
| 699 | 1215 | } | ... | ... |
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java
| 1 | 1 | package cn.iocoder.yudao.module.pay.service.transfer; |
| 2 | 2 | |
| 3 | 3 | import cn.hutool.extra.spring.SpringUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | |
| 4 | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; |
| 5 | 6 | import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO; |
| 6 | 7 | import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateRespDTO; |
| ... | ... | @@ -9,6 +10,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO; |
| 9 | 10 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 10 | 11 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; |
| 11 | 12 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 13 | +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; | |
| 12 | 14 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; |
| 13 | 15 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 14 | 16 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; |
| ... | ... | @@ -24,14 +26,23 @@ import org.junit.jupiter.api.Test; |
| 24 | 26 | import org.mockito.MockedStatic; |
| 25 | 27 | import org.springframework.context.annotation.Import; |
| 26 | 28 | import org.springframework.test.context.bean.override.mockito.MockitoBean; |
| 29 | +import org.springframework.test.util.AopTestUtils; | |
| 30 | +import org.springframework.test.util.ReflectionTestUtils; | |
| 31 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 32 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 27 | 33 | |
| 28 | 34 | import java.time.LocalDateTime; |
| 35 | +import java.util.ArrayList; | |
| 36 | +import java.util.List; | |
| 37 | +import java.util.Map; | |
| 38 | +import java.util.concurrent.CompletableFuture; | |
| 29 | 39 | |
| 30 | 40 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 31 | 41 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 32 | 42 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 33 | 43 | import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; |
| 34 | 44 | import static org.junit.jupiter.api.Assertions.*; |
| 45 | +import static org.mockito.AdditionalAnswers.delegatesTo; | |
| 35 | 46 | import static org.mockito.ArgumentMatchers.any; |
| 36 | 47 | import static org.mockito.ArgumentMatchers.eq; |
| 37 | 48 | import static org.mockito.Mockito.*; |
| ... | ... | @@ -41,7 +52,7 @@ import static org.mockito.Mockito.*; |
| 41 | 52 | * |
| 42 | 53 | * @author 芋道源码 |
| 43 | 54 | */ |
| 44 | -@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class}) | |
| 55 | +@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class, PayTransferLockRedisDAO.class}) | |
| 45 | 56 | public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 46 | 57 | |
| 47 | 58 | @Resource |
| ... | ... | @@ -50,6 +61,9 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 50 | 61 | @Resource |
| 51 | 62 | private PayTransferMapper transferMapper; |
| 52 | 63 | |
| 64 | + @Resource | |
| 65 | + private PlatformTransactionManager transactionManager; | |
| 66 | + | |
| 53 | 67 | @MockitoBean |
| 54 | 68 | private PayProperties payProperties; |
| 55 | 69 | @MockitoBean |
| ... | ... | @@ -123,6 +137,207 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 123 | 137 | verify(notifyService, never()).createPayNotifyTask(any(), any()); |
| 124 | 138 | } |
| 125 | 139 | |
| 140 | + @Test // 调用 unifiedTransfer 接口发生异常,查询返回关闭(例如说:钱包查询不到转账单) | |
| 141 | + public void testCreateTransfer_unifiedTransferException_queryClosed() { | |
| 142 | + // mock 方法(client) | |
| 143 | + PayClient<?> client = mockPayClient(); | |
| 144 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 145 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 146 | + PayTransferRespDTO.closedOf("ORDER_NOT_EXIST", "转账单不存在", invocation.getArgument(0), "closed")); | |
| 147 | + // 准备参数 | |
| 148 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 149 | + | |
| 150 | + // 调用:一次查询的关闭(可能是渠道转账单尚未生成),不作为最终结果,保留 WAITING 交给转账轮询 | |
| 151 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 152 | + // 断言 | |
| 153 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 154 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); | |
| 155 | + assertNull(dbTransfer.getChannelErrorCode()); | |
| 156 | + verify(client).getTransfer(any()); | |
| 157 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 158 | + } | |
| 159 | + | |
| 160 | + @Test // 调用 unifiedTransfer 接口发生异常,查询返回 WAITING(例如说:支付宝查询不到转账单) | |
| 161 | + public void testCreateTransfer_unifiedTransferException_queryWaiting() { | |
| 162 | + // mock 方法(client) | |
| 163 | + PayClient<?> client = mockPayClient(); | |
| 164 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 165 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 166 | + PayTransferRespDTO.waitingOf(null, invocation.getArgument(0), "waiting")); | |
| 167 | + // 准备参数 | |
| 168 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 169 | + | |
| 170 | + // 调用:结果未知,保留 WAITING 交给转账轮询 | |
| 171 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 172 | + // 断言 | |
| 173 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), transferMapper.selectById(respDTO.getId()).getStatus()); | |
| 174 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 175 | + } | |
| 176 | + | |
| 177 | + @Test // 调用 unifiedTransfer 接口返回关闭,但查询到渠道转账中(例如说:上一次实际已受理) | |
| 178 | + public void testCreateTransfer_closed_queryProcessing() { | |
| 179 | + // mock 方法(client) | |
| 180 | + PayClient<?> client = mockPayClient(); | |
| 181 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 182 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 183 | + return PayTransferRespDTO.closedOf("SYSTEM_ERROR", "系统繁忙", unifiedReqDTO.getOutTransferNo(), "closed"); | |
| 184 | + }); | |
| 185 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 186 | + PayTransferRespDTO.processingOf("CHANNEL_T001", invocation.getArgument(0), "processing")); | |
| 187 | + // 准备参数 | |
| 188 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 189 | + | |
| 190 | + // 调用:渠道实际已受理转账,以查询结果为准,不向调用方误报渠道错误 | |
| 191 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 192 | + // 断言 | |
| 193 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 194 | + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); | |
| 195 | + assertNull(dbTransfer.getChannelErrorCode()); | |
| 196 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 197 | + } | |
| 198 | + | |
| 199 | + @Test // 调用 unifiedTransfer 接口返回关闭,但查询到已有渠道转账单号的待转账(例如说:上一次实际已受理) | |
| 200 | + public void testCreateTransfer_closed_queryWaitingWithChannelTransferNo() { | |
| 201 | + // mock 方法(client) | |
| 202 | + PayClient<?> client = mockPayClient(); | |
| 203 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 204 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 205 | + return PayTransferRespDTO.closedOf("SYSTEM_ERROR", "系统繁忙", unifiedReqDTO.getOutTransferNo(), "closed"); | |
| 206 | + }); | |
| 207 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 208 | + PayTransferRespDTO.waitingOf("CHANNEL_T001", invocation.getArgument(0), "waiting")); | |
| 209 | + // 准备参数 | |
| 210 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 211 | + | |
| 212 | + // 调用:渠道实际已受理转账,以查询结果为准,不向调用方误报渠道错误 | |
| 213 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 214 | + // 断言:仍是 WAITING,并保存渠道转账单号、渠道返回数据 | |
| 215 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 216 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); | |
| 217 | + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo()); | |
| 218 | + assertNotNull(dbTransfer.getChannelNotifyData()); | |
| 219 | + assertNull(dbTransfer.getChannelErrorCode()); | |
| 220 | + verify(notifyService, never()).createPayNotifyTask(any(), any()); | |
| 221 | + } | |
| 222 | + | |
| 223 | + @Test // 待转账的通知晚于转账中到达,不能覆盖转账中的转账单 | |
| 224 | + public void testNotifyTransferWaiting_whenProcessing() { | |
| 225 | + // mock 数据(PayTransferDO) | |
| 226 | + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(10L).setNo("T110") | |
| 227 | + .setStatus(PayTransferStatusEnum.PROCESSING.getStatus()).setChannelTransferNo("WX_TRANSFER_110")); | |
| 228 | + transferMapper.insert(transfer); | |
| 229 | + // 准备参数 | |
| 230 | + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(10L)); | |
| 231 | + | |
| 232 | + // 调用 | |
| 233 | + transferService.notifyTransfer(channel, PayTransferRespDTO.waitingOf("WX_TRANSFER_111", "T110", "waiting")); | |
| 234 | + // 断言:不更新 | |
| 235 | + PayTransferDO dbTransfer = transferMapper.selectById(transfer.getId()); | |
| 236 | + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); | |
| 237 | + assertEquals("WX_TRANSFER_110", dbTransfer.getChannelTransferNo()); | |
| 238 | + assertEquals(transfer.getChannelNotifyData(), dbTransfer.getChannelNotifyData()); | |
| 239 | + } | |
| 240 | + | |
| 241 | + @Test // 并发使用相同的商户转账编号发起转账,只能创建一个转账单,避免重复转账 | |
| 242 | + public void testCreateTransfer_concurrentCreate() throws Exception { | |
| 243 | + // mock 方法(client) | |
| 244 | + PayClient<?> client = mockPayClient(); | |
| 245 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 246 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 247 | + return PayTransferRespDTO.processingOf("CHANNEL_T001", unifiedReqDTO.getOutTransferNo(), "processing"); | |
| 248 | + }); | |
| 249 | + // mock 方法(mapper):查询后等待一段时间,放大 select 后 insert 的时间窗口 | |
| 250 | + PayTransferMapper transferMapperSpy = mock(PayTransferMapper.class, delegatesTo(transferMapper)); | |
| 251 | + doAnswer(invocation -> { | |
| 252 | + PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantOrderId(invocation.getArgument(0), | |
| 253 | + invocation.getArgument(1)); | |
| 254 | + Thread.sleep(300); | |
| 255 | + return transfer; | |
| 256 | + }).when(transferMapperSpy).selectByAppIdAndMerchantOrderId(any(), any()); | |
| 257 | + Object target = AopTestUtils.getTargetObject(transferService); | |
| 258 | + ReflectionTestUtils.setField(target, "transferMapper", transferMapperSpy); | |
| 259 | + List<Throwable> exceptions = new ArrayList<>(); | |
| 260 | + try { | |
| 261 | + // 调用:两个请求并发发起转账 | |
| 262 | + List<CompletableFuture<PayTransferCreateRespDTO>> futures = List.of( | |
| 263 | + CompletableFuture.supplyAsync(() -> transferService.createTransfer(buildTransferCreateReqDTO())), | |
| 264 | + CompletableFuture.supplyAsync(() -> transferService.createTransfer(buildTransferCreateReqDTO()))); | |
| 265 | + for (CompletableFuture<PayTransferCreateRespDTO> future : futures) { | |
| 266 | + try { | |
| 267 | + future.get(); | |
| 268 | + } catch (Exception ex) { | |
| 269 | + exceptions.add(ex.getCause()); | |
| 270 | + } | |
| 271 | + } | |
| 272 | + } finally { | |
| 273 | + ReflectionTestUtils.setField(target, "transferMapper", transferMapper); | |
| 274 | + } | |
| 275 | + // 断言:只有一个请求创建转账单,另一个请求提示转账单不是关闭状态 | |
| 276 | + assertEquals(1, exceptions.size()); | |
| 277 | + assertEquals(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED.getCode(), ((ServiceException) exceptions.get(0)).getCode()); | |
| 278 | + assertEquals(1, transferMapper.selectList().size()); | |
| 279 | + verify(client, times(1)).unifiedTransfer(any()); | |
| 280 | + } | |
| 281 | + | |
| 282 | + @Test // 调用 unifiedTransfer 接口返回关闭,查询返回 WAITING(例如说:支付宝查询不到转账单) | |
| 283 | + public void testCreateTransfer_closed_queryWaiting() { | |
| 284 | + // mock 方法(client) | |
| 285 | + PayClient<?> client = mockPayClient(); | |
| 286 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 287 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 288 | + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在", | |
| 289 | + unifiedReqDTO.getOutTransferNo(), "closed"); | |
| 290 | + }); | |
| 291 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 292 | + PayTransferRespDTO.waitingOf(null, invocation.getArgument(0), "waiting")); | |
| 293 | + // 准备参数 | |
| 294 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 295 | + | |
| 296 | + // 调用,并断言异常:以渠道明确返回的关闭为准 | |
| 297 | + assertServiceException(() -> transferService.createTransfer(reqDTO), | |
| 298 | + PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在"); | |
| 299 | + // 断言 | |
| 300 | + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId()); | |
| 301 | + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), dbTransfer.getStatus()); | |
| 302 | + assertEquals("PAYEE_NOT_EXIST", dbTransfer.getChannelErrorCode()); | |
| 303 | + } | |
| 304 | + | |
| 305 | + @Test // 调用 unifiedTransfer 接口发生异常,但查询确认渠道转账成功 | |
| 306 | + public void testCreateTransfer_unifiedTransferException_querySuccess() { | |
| 307 | + // mock 方法(client) | |
| 308 | + PayClient<?> client = mockPayClient(); | |
| 309 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 310 | + when(client.getTransfer(any())).thenAnswer(invocation -> | |
| 311 | + PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(), | |
| 312 | + invocation.getArgument(0), "success")); | |
| 313 | + // 准备参数 | |
| 314 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 315 | + | |
| 316 | + // 调用:查询确认渠道转账成功 | |
| 317 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 318 | + // 断言:按查询结果落库为成功 | |
| 319 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 320 | + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus()); | |
| 321 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(dbTransfer.getId())); | |
| 322 | + } | |
| 323 | + | |
| 324 | + @Test // 调用 unifiedTransfer 接口发生异常,查询渠道状态也发生异常 | |
| 325 | + public void testCreateTransfer_unifiedTransferException_queryException() { | |
| 326 | + // mock 方法(client) | |
| 327 | + PayClient<?> client = mockPayClient(); | |
| 328 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 329 | + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); | |
| 330 | + // 准备参数 | |
| 331 | + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO(); | |
| 332 | + | |
| 333 | + // 调用:查询失败时保留 WAITING,交给后续轮询补偿 | |
| 334 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO); | |
| 335 | + // 断言 | |
| 336 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 337 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); | |
| 338 | + verify(client).getTransfer(any()); | |
| 339 | + } | |
| 340 | + | |
| 126 | 341 | @Test // 调用 unifiedTransfer 接口,返回 null |
| 127 | 342 | public void testCreateTransfer_nullResponse() { |
| 128 | 343 | // mock 方法(client) |
| ... | ... | @@ -223,6 +438,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 223 | 438 | PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); |
| 224 | 439 | assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); |
| 225 | 440 | assertEquals("package-info-001", dbTransfer.getChannelPackageInfo()); |
| 441 | + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo()); | |
| 226 | 442 | } |
| 227 | 443 | |
| 228 | 444 | @Test // 调用 unifiedTransfer 接口返回成功,但通知转账结果时找不到转账单 |
| ... | ... | @@ -360,6 +576,203 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 360 | 576 | } |
| 361 | 577 | } |
| 362 | 578 | |
| 579 | + @Test // 已存在相同的转账单,且不是转账关闭 | |
| 580 | + public void testCreateTransfer_transferExists() { | |
| 581 | + // mock 方法(client) | |
| 582 | + PayClient<?> client = mockPayClient(); | |
| 583 | + // mock 数据:已存在转账成功的转账单 | |
| 584 | + PayTransferDO transfer = insertClosedTransfer(); | |
| 585 | + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()) | |
| 586 | + .setStatus(PayTransferStatusEnum.SUCCESS.getStatus())); | |
| 587 | + | |
| 588 | + // 调用,并断言异常 | |
| 589 | + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), | |
| 590 | + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); | |
| 591 | + verify(client, never()).unifiedTransfer(any()); | |
| 592 | + } | |
| 593 | + | |
| 594 | + @Test // 转账关闭后再次发起,但转账渠道不一致 | |
| 595 | + public void testCreateTransfer_retryAfterClosed_channelNotMatch() { | |
| 596 | + // mock 方法(client) | |
| 597 | + PayClient<?> client = mockPayClient(); | |
| 598 | + // mock 数据:已存在其它渠道的关闭的转账单 | |
| 599 | + PayTransferDO transfer = insertClosedTransfer(); | |
| 600 | + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()) | |
| 601 | + .setChannelCode(PayChannelEnum.WX_PUB.getCode())); | |
| 602 | + | |
| 603 | + // 调用,并断言异常 | |
| 604 | + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), | |
| 605 | + PAY_TRANSFER_CREATE_CHANNEL_NOT_MATCH); | |
| 606 | + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(transfer.getId()).getStatus()); | |
| 607 | + verify(client, never()).unifiedTransfer(any()); | |
| 608 | + } | |
| 609 | + | |
| 610 | + @Test // 转账关闭后再次发起,但转账金额不一致 | |
| 611 | + public void testCreateTransfer_retryAfterClosed_priceNotMatch() { | |
| 612 | + // mock 方法(client) | |
| 613 | + PayClient<?> client = mockPayClient(); | |
| 614 | + // mock 数据:已存在金额为 50 的关闭的转账单 | |
| 615 | + PayTransferDO transfer = insertClosedTransfer(); | |
| 616 | + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setPrice(50)); | |
| 617 | + | |
| 618 | + // 调用,并断言异常 | |
| 619 | + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), | |
| 620 | + PAY_TRANSFER_CREATE_PRICE_NOT_MATCH); | |
| 621 | + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(transfer.getId()).getStatus()); | |
| 622 | + verify(client, never()).unifiedTransfer(any()); | |
| 623 | + } | |
| 624 | + | |
| 625 | + @Test // 转账关闭后再次发起,渠道结果未知,保留 WAITING 并清空上一次的渠道结果 | |
| 626 | + public void testCreateTransfer_retryAfterClosed_unknown() { | |
| 627 | + // mock 方法(client 调用发生异常,查询也发生异常) | |
| 628 | + PayClient<?> client = mockPayClient(); | |
| 629 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 630 | + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); | |
| 631 | + // mock 数据:已存在关闭的转账单 | |
| 632 | + PayTransferDO closedTransfer = insertClosedTransfer(); | |
| 633 | + | |
| 634 | + // 调用 | |
| 635 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO()); | |
| 636 | + // 断言:复用原转账单号,状态重置为 WAITING,并清空上一次转账的渠道结果 | |
| 637 | + assertEquals(closedTransfer.getId(), respDTO.getId()); | |
| 638 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 639 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); | |
| 640 | + assertNull(dbTransfer.getChannelTransferNo()); | |
| 641 | + assertNull(dbTransfer.getSuccessTime()); | |
| 642 | + assertNull(dbTransfer.getChannelErrorCode()); | |
| 643 | + assertNull(dbTransfer.getChannelErrorMsg()); | |
| 644 | + assertNull(dbTransfer.getChannelNotifyData()); | |
| 645 | + assertNull(dbTransfer.getChannelPackageInfo()); | |
| 646 | + assertEquals("佣金提现", dbTransfer.getSubject()); | |
| 647 | + assertEquals("test@example.com", dbTransfer.getUserAccount()); | |
| 648 | + assertEquals("芋艿", dbTransfer.getUserName()); | |
| 649 | + assertEquals(Map.of("sceneId", "1000"), dbTransfer.getChannelExtras()); | |
| 650 | + assertEquals("http://127.0.0.1/transfer", dbTransfer.getNotifyUrl()); | |
| 651 | + // 断言:渠道编号更新为本次按渠道编码校验的渠道 | |
| 652 | + assertEquals(10L, dbTransfer.getChannelId()); | |
| 653 | + verify(client).unifiedTransfer(argThat(unifiedReqDTO -> | |
| 654 | + closedTransfer.getNo().equals(unifiedReqDTO.getOutTransferNo()))); | |
| 655 | + } | |
| 656 | + | |
| 657 | + @Test // 转账关闭后再次发起,本次请求没有传递可选字段,需要清空上一次的值 | |
| 658 | + public void testCreateTransfer_retryAfterClosed_clearOptionalFields() { | |
| 659 | + // mock 方法(client 调用发生异常,查询也发生异常) | |
| 660 | + PayClient<?> client = mockPayClient(); | |
| 661 | + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时")); | |
| 662 | + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); | |
| 663 | + // mock 方法(app):没有配置转账回调地址 | |
| 664 | + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl(null)); | |
| 665 | + when(appService.validPayApp(eq("demo"))).thenReturn(app); | |
| 666 | + // mock 数据:已存在关闭的转账单 | |
| 667 | + PayTransferDO closedTransfer = insertClosedTransfer(); | |
| 668 | + | |
| 669 | + // 调用:本次请求没有传递收款人姓名、渠道额外参数 | |
| 670 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO() | |
| 671 | + .setUserName(null).setChannelExtras(null)); | |
| 672 | + // 断言:上一次的收款人姓名、渠道额外参数、回调地址已清空 | |
| 673 | + assertEquals(closedTransfer.getId(), respDTO.getId()); | |
| 674 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 675 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); | |
| 676 | + assertNull(dbTransfer.getUserName()); | |
| 677 | + assertNull(dbTransfer.getChannelExtras()); | |
| 678 | + assertNull(dbTransfer.getNotifyUrl()); | |
| 679 | + assertEquals(10L, dbTransfer.getChannelId()); | |
| 680 | + } | |
| 681 | + | |
| 682 | + @Test // 转账关闭后再次发起,但转账单已被并发重新发起 | |
| 683 | + public void testCreateTransfer_retryAfterClosed_concurrentRetry() { | |
| 684 | + // mock 方法(client) | |
| 685 | + PayClient<?> client = mockPayClient(); | |
| 686 | + // mock 数据:已存在关闭的转账单 | |
| 687 | + PayTransferDO closedTransfer = insertClosedTransfer(); | |
| 688 | + // mock 方法(mapper):校验通过后、更新前,其它请求已将转账单重新发起为 WAITING | |
| 689 | + PayTransferMapper transferMapperSpy = mock(PayTransferMapper.class, delegatesTo(transferMapper)); | |
| 690 | + doAnswer(invocation -> { | |
| 691 | + updateTransferStatus(closedTransfer.getNo(), PayTransferStatusEnum.WAITING.getStatus()); | |
| 692 | + return transferMapper.updateByIdAndStatusAndClearChannelResult(invocation.getArgument(0), | |
| 693 | + invocation.getArgument(1), invocation.getArgument(2)); | |
| 694 | + }).when(transferMapperSpy).updateByIdAndStatusAndClearChannelResult(any(), any(), any()); | |
| 695 | + Object target = AopTestUtils.getTargetObject(transferService); | |
| 696 | + ReflectionTestUtils.setField(target, "transferMapper", transferMapperSpy); | |
| 697 | + try { | |
| 698 | + // 调用,并断言异常 | |
| 699 | + assertServiceException(() -> transferService.createTransfer(buildTransferCreateReqDTO()), | |
| 700 | + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); | |
| 701 | + } finally { | |
| 702 | + ReflectionTestUtils.setField(target, "transferMapper", transferMapper); | |
| 703 | + } | |
| 704 | + // 断言:不会再次向渠道发起转账 | |
| 705 | + verify(client, never()).unifiedTransfer(any()); | |
| 706 | + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus()); | |
| 707 | + } | |
| 708 | + | |
| 709 | + @Test // 调用方存在事务,转账关闭后调用方事务回滚,转账关闭的结果仍需保存,并可使用原转账单号重新发起 | |
| 710 | + public void testCreateTransfer_outerTransactionRollback() { | |
| 711 | + // mock 方法(client) | |
| 712 | + PayClient<?> client = mockPayClient(); | |
| 713 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 714 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 715 | + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在", | |
| 716 | + unifiedReqDTO.getOutTransferNo(), "closed"); | |
| 717 | + }); | |
| 718 | + PayTransferDO outerTransfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setMerchantTransferId("2002")); | |
| 719 | + | |
| 720 | + // 调用:调用方事务中,先写入自己的数据,再发起转账,渠道错误导致调用方事务回滚 | |
| 721 | + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); | |
| 722 | + assertServiceException(() -> transactionTemplate.executeWithoutResult(status -> { | |
| 723 | + transferMapper.insert(outerTransfer); | |
| 724 | + transferService.createTransfer(buildTransferCreateReqDTO()); | |
| 725 | + }), PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在"); | |
| 726 | + // 断言:调用方事务已回滚,但转账关闭的结果(包括转账通知)已保存 | |
| 727 | + assertNull(transferMapper.selectById(outerTransfer.getId())); | |
| 728 | + PayTransferDO closedTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, "1001"); | |
| 729 | + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), closedTransfer.getStatus()); | |
| 730 | + assertEquals("PAYEE_NOT_EXIST", closedTransfer.getChannelErrorCode()); | |
| 731 | + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(closedTransfer.getId())); | |
| 732 | + | |
| 733 | + // mock 方法(client 再次发起时,转账成功) | |
| 734 | + doAnswer(invocation -> { | |
| 735 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 736 | + return PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(), | |
| 737 | + unifiedReqDTO.getOutTransferNo(), "success"); | |
| 738 | + }).when(client).unifiedTransfer(any()); | |
| 739 | + // 调用:使用相同的商户转账编号再次发起 | |
| 740 | + PayTransferCreateRespDTO respDTO = transactionTemplate.execute(status -> | |
| 741 | + transferService.createTransfer(buildTransferCreateReqDTO())); | |
| 742 | + // 断言:复用原转账单号 | |
| 743 | + assertEquals(closedTransfer.getId(), respDTO.getId()); | |
| 744 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 745 | + assertEquals(closedTransfer.getNo(), dbTransfer.getNo()); | |
| 746 | + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus()); | |
| 747 | + } | |
| 748 | + | |
| 749 | + @Test // 转账关闭后再次发起,返回转账中及新的 package 信息 | |
| 750 | + public void testCreateTransfer_retryAfterClosed_processing() { | |
| 751 | + // mock 方法(client) | |
| 752 | + PayClient<?> client = mockPayClient(); | |
| 753 | + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { | |
| 754 | + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); | |
| 755 | + PayTransferRespDTO resp = PayTransferRespDTO.processingOf("CHANNEL_T002", | |
| 756 | + unifiedReqDTO.getOutTransferNo(), "processing"); | |
| 757 | + resp.setChannelPackageInfo("package-info-002"); | |
| 758 | + return resp; | |
| 759 | + }); | |
| 760 | + // mock 数据:已存在关闭的转账单 | |
| 761 | + PayTransferDO closedTransfer = insertClosedTransfer(); | |
| 762 | + | |
| 763 | + // 调用 | |
| 764 | + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildTransferCreateReqDTO()); | |
| 765 | + // 断言:使用新的 package 信息,且不残留上一次的失败原因 | |
| 766 | + assertEquals(closedTransfer.getId(), respDTO.getId()); | |
| 767 | + assertEquals("package-info-002", respDTO.getChannelPackageInfo()); | |
| 768 | + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | |
| 769 | + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); | |
| 770 | + assertEquals("package-info-002", dbTransfer.getChannelPackageInfo()); | |
| 771 | + assertNull(dbTransfer.getChannelErrorCode()); | |
| 772 | + assertNull(dbTransfer.getChannelErrorMsg()); | |
| 773 | + assertEquals("CHANNEL_T002", dbTransfer.getChannelTransferNo()); | |
| 774 | + } | |
| 775 | + | |
| 363 | 776 | private PayClient<?> mockPayClient() { |
| 364 | 777 | // mock 方法(app) |
| 365 | 778 | 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 { |
| 375 | 788 | return client; |
| 376 | 789 | } |
| 377 | 790 | |
| 791 | + /** | |
| 792 | + * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果 | |
| 793 | + * | |
| 794 | + * 特殊:渠道编号使用旧的渠道编号,模拟渠道删除重建的情况 | |
| 795 | + */ | |
| 796 | + private PayTransferDO insertClosedTransfer() { | |
| 797 | + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setChannelId(9L) | |
| 798 | + .setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()).setMerchantTransferId("1001").setPrice(100) | |
| 799 | + .setUserName("旧的收款人").setChannelExtras(Map.of("sceneId", "1005")) | |
| 800 | + .setStatus(PayTransferStatusEnum.CLOSED.getStatus()) | |
| 801 | + .setChannelErrorCode("PAYEE_NOT_EXIST").setChannelErrorMsg("收款账号不存在") | |
| 802 | + .setChannelPackageInfo("package-info-001")); | |
| 803 | + transferMapper.insert(transfer); | |
| 804 | + return transfer; | |
| 805 | + } | |
| 806 | + | |
| 378 | 807 | private void updateTransferStatus(String no, Integer status) { |
| 379 | 808 | PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); |
| 380 | 809 | transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status)); |
| ... | ... | @@ -384,7 +813,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 384 | 813 | return new PayTransferCreateReqDTO().setAppKey("demo").setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()) |
| 385 | 814 | .setUserIp("127.0.0.1").setUserId(1L).setUserType(1) |
| 386 | 815 | .setMerchantTransferId("1001").setSubject("佣金提现").setPrice(100) |
| 387 | - .setUserAccount("test@example.com").setUserName("芋艿"); | |
| 816 | + .setUserAccount("test@example.com").setUserName("芋艿").setChannelExtras(Map.of("sceneId", "1000")); | |
| 388 | 817 | } |
| 389 | 818 | |
| 390 | 819 | } | ... | ... |
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java
0 → 100644
| 1 | +package cn.iocoder.yudao.module.pay.service.wallet; | |
| 2 | + | |
| 3 | +import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; | |
| 4 | +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO; | |
| 5 | +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO; | |
| 6 | +import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletMapper; | |
| 7 | +import cn.iocoder.yudao.module.pay.dal.redis.wallet.PayWalletLockRedisDAO; | |
| 8 | +import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum; | |
| 9 | +import cn.iocoder.yudao.module.pay.service.order.PayOrderService; | |
| 10 | +import cn.iocoder.yudao.module.pay.service.refund.PayRefundService; | |
| 11 | +import jakarta.annotation.Resource; | |
| 12 | +import org.junit.jupiter.api.Test; | |
| 13 | +import org.springframework.context.annotation.Import; | |
| 14 | +import org.springframework.test.context.bean.override.mockito.MockitoBean; | |
| 15 | +import org.springframework.transaction.PlatformTransactionManager; | |
| 16 | +import org.springframework.transaction.TransactionDefinition; | |
| 17 | +import org.springframework.transaction.support.TransactionTemplate; | |
| 18 | + | |
| 19 | +import static org.junit.jupiter.api.Assertions.*; | |
| 20 | +import static org.mockito.ArgumentMatchers.any; | |
| 21 | +import static org.mockito.Mockito.when; | |
| 22 | + | |
| 23 | +/** | |
| 24 | + * {@link PayWalletServiceImpl} 的单元测试类 | |
| 25 | + * | |
| 26 | + * @author HUIHUI | |
| 27 | + */ | |
| 28 | +@Import({PayWalletServiceImpl.class, PayWalletLockRedisDAO.class}) | |
| 29 | +public class PayWalletServiceTest extends BaseDbAndRedisUnitTest { | |
| 30 | + | |
| 31 | + @Resource | |
| 32 | + private PayWalletServiceImpl walletService; | |
| 33 | + | |
| 34 | + @Resource | |
| 35 | + private PayWalletMapper walletMapper; | |
| 36 | + | |
| 37 | + @Resource | |
| 38 | + private PlatformTransactionManager transactionManager; | |
| 39 | + | |
| 40 | + @MockitoBean | |
| 41 | + private PayWalletTransactionService walletTransactionService; | |
| 42 | + @MockitoBean | |
| 43 | + private PayOrderService orderService; | |
| 44 | + @MockitoBean | |
| 45 | + private PayRefundService refundService; | |
| 46 | + | |
| 47 | + @Test | |
| 48 | + public void testGetOrCreateWallet_create() { | |
| 49 | + // 调用:首次获取,创建钱包 | |
| 50 | + PayWalletDO wallet = walletService.getOrCreateWallet(1L, 2); | |
| 51 | + // 断言 | |
| 52 | + assertNotNull(wallet.getId()); | |
| 53 | + PayWalletDO dbWallet = walletMapper.selectById(wallet.getId()); | |
| 54 | + assertEquals(1L, dbWallet.getUserId()); | |
| 55 | + assertEquals(2, dbWallet.getUserType()); | |
| 56 | + assertEquals(0, dbWallet.getBalance()); | |
| 57 | + | |
| 58 | + // 调用:再次获取,不重复创建钱包 | |
| 59 | + assertEquals(wallet.getId(), walletService.getOrCreateWallet(1L, 2).getId()); | |
| 60 | + assertEquals(1, walletMapper.selectList().size()); | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test // 调用方存在事务时,首次创建的钱包需要独立提交(例如说:佣金提现审核时,创建钱包后再转账到钱包) | |
| 64 | + public void testGetOrCreateWallet_outerTransaction() { | |
| 65 | + // mock 方法(钱包流水) | |
| 66 | + when(walletTransactionService.createWalletTransaction(any())).thenReturn(new PayWalletTransactionDO()); | |
| 67 | + | |
| 68 | + // 调用:调用方事务中,首次创建钱包;随后不加入调用方事务(同 createTransfer),给钱包增加余额;最后调用方事务回滚 | |
| 69 | + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); | |
| 70 | + TransactionTemplate notSupportedTemplate = new TransactionTemplate(transactionManager); | |
| 71 | + notSupportedTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_NOT_SUPPORTED); | |
| 72 | + Long walletId = transactionTemplate.execute(status -> { | |
| 73 | + PayWalletDO wallet = walletService.getOrCreateWallet(1L, 2); | |
| 74 | + notSupportedTemplate.executeWithoutResult(notSupportedStatus -> walletService.addWalletBalance( | |
| 75 | + wallet.getId(), "T001", PayWalletBizTypeEnum.TRANSFER, 100)); | |
| 76 | + status.setRollbackOnly(); | |
| 77 | + return wallet.getId(); | |
| 78 | + }); | |
| 79 | + // 断言:钱包已独立提交,钱包余额已增加,不受调用方事务回滚影响 | |
| 80 | + PayWalletDO dbWallet = walletMapper.selectById(walletId); | |
| 81 | + assertNotNull(dbWallet); | |
| 82 | + assertEquals(100, dbWallet.getBalance()); | |
| 83 | + } | |
| 84 | + | |
| 85 | +} | ... | ... |
yudao-module-pay/src/test/resources/sql/clean.sql
yudao-module-pay/src/test/resources/sql/create_tables.sql
| ... | ... | @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` ( |
| 180 | 180 | `deleted` bit(1) NOT NULL DEFAULT FALSE, |
| 181 | 181 | PRIMARY KEY ("id") |
| 182 | 182 | ) COMMENT = '转账单'; |
| 183 | + | |
| 184 | +CREATE TABLE IF NOT EXISTS `pay_wallet` ( | |
| 185 | + "id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY, | |
| 186 | + `user_id` bigint(20) NOT NULL, | |
| 187 | + `user_type` tinyint(4) NOT NULL, | |
| 188 | + `balance` int(11) NOT NULL DEFAULT 0, | |
| 189 | + `freeze_price` int(11) NOT NULL DEFAULT 0, | |
| 190 | + `total_expense` int(11) NOT NULL DEFAULT 0, | |
| 191 | + `total_recharge` int(11) NOT NULL DEFAULT 0, | |
| 192 | + `creator` varchar(64) NULL DEFAULT '', | |
| 193 | + `create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, | |
| 194 | + `updater` varchar(64) NULL DEFAULT '', | |
| 195 | + `update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |
| 196 | + `deleted` bit(1) NOT NULL DEFAULT FALSE, | |
| 197 | + PRIMARY KEY ("id") | |
| 198 | +) COMMENT = '会员钱包'; | ... | ... |