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
| @@ -15,6 +15,9 @@ public interface PayRefundApi { | @@ -15,6 +15,9 @@ public interface PayRefundApi { | ||
| 15 | /** | 15 | /** |
| 16 | * 创建退款单 | 16 | * 创建退款单 |
| 17 | * | 17 | * |
| 18 | + * 注意:不加入调用方的事务,退款单、退款结果(包括退款通知)独立提交,调用方的事务回滚时不会回滚。 | ||
| 19 | + * 调用方需要使用相同的 merchantRefundId 重新发起,并通过退款通知处理最终结果;渠道明确失败时,会抛出渠道错误的业务异常 | ||
| 20 | + * | ||
| 18 | * @param reqDTO 创建请求 | 21 | * @param reqDTO 创建请求 |
| 19 | * @return 退款单编号 | 22 | * @return 退款单编号 |
| 20 | */ | 23 | */ |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/PayTransferApi.java
| @@ -15,6 +15,9 @@ public interface PayTransferApi { | @@ -15,6 +15,9 @@ public interface PayTransferApi { | ||
| 15 | /** | 15 | /** |
| 16 | * 创建转账单 | 16 | * 创建转账单 |
| 17 | * | 17 | * |
| 18 | + * 注意:不加入调用方的事务,转账单、转账结果(包括转账通知)独立提交,调用方的事务回滚时不会回滚。 | ||
| 19 | + * 调用方需要使用相同的 merchantTransferId 重新发起,并通过转账通知处理最终结果;渠道明确失败时,会抛出渠道错误的业务异常 | ||
| 20 | + * | ||
| 18 | * @param reqDTO 创建请求 | 21 | * @param reqDTO 创建请求 |
| 19 | * @return 创建结果 | 22 | * @return 创建结果 |
| 20 | */ | 23 | */ |
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,6 +7,7 @@ import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundExportReq | ||
| 7 | import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO; | 7 | import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO; |
| 8 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; | 8 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 9 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | 9 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| 10 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | ||
| 10 | import org.apache.ibatis.annotations.Mapper; | 11 | import org.apache.ibatis.annotations.Mapper; |
| 11 | 12 | ||
| 12 | import java.util.List; | 13 | import java.util.List; |
| @@ -46,6 +47,24 @@ public interface PayRefundMapper extends BaseMapperX<PayRefundDO> { | @@ -46,6 +47,24 @@ public interface PayRefundMapper extends BaseMapperX<PayRefundDO> { | ||
| 46 | .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status)); | 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 | default PageResult<PayRefundDO> selectPage(PayRefundPageReqVO reqVO) { | 68 | default PageResult<PayRefundDO> selectPage(PayRefundPageReqVO reqVO) { |
| 50 | return selectPage(reqVO, new LambdaQueryWrapperX<PayRefundDO>() | 69 | return selectPage(reqVO, new LambdaQueryWrapperX<PayRefundDO>() |
| 51 | .eqIfPresent(PayRefundDO::getAppId, reqVO.getAppId()) | 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 +6,7 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; | ||
| 6 | import cn.iocoder.yudao.module.pay.controller.admin.transfer.vo.PayTransferPageReqVO; | 6 | import cn.iocoder.yudao.module.pay.controller.admin.transfer.vo.PayTransferPageReqVO; |
| 7 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; | 7 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 8 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; | 8 | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| 9 | +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; | ||
| 9 | import org.apache.ibatis.annotations.Mapper; | 10 | import org.apache.ibatis.annotations.Mapper; |
| 10 | 11 | ||
| 11 | import java.util.Collection; | 12 | import java.util.Collection; |
| @@ -26,6 +27,28 @@ public interface PayTransferMapper extends BaseMapperX<PayTransferDO> { | @@ -26,6 +27,28 @@ public interface PayTransferMapper extends BaseMapperX<PayTransferDO> { | ||
| 26 | .eq(PayTransferDO::getStatus, whereStatus)); | 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 | default int updateChannelPackageInfoIfAbsent(Long id, String channelPackageInfo) { | 52 | default int updateChannelPackageInfoIfAbsent(Long id, String channelPackageInfo) { |
| 30 | return update(new PayTransferDO().setChannelPackageInfo(channelPackageInfo), | 53 | return update(new PayTransferDO().setChannelPackageInfo(channelPackageInfo), |
| 31 | new LambdaQueryWrapper<PayTransferDO>() | 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,6 +26,24 @@ public interface RedisKeyConstants { | ||
| 26 | String PAY_WALLET_LOCK = "pay_wallet:lock:%d"; | 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 | * KEY 格式:pay_no:{prefix} | 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,6 +40,9 @@ public interface ErrorCodeConstants { | ||
| 40 | ErrorCode REFUND_EXISTS = new ErrorCode(1_007_006_003, "已经存在退款单"); | 40 | ErrorCode REFUND_EXISTS = new ErrorCode(1_007_006_003, "已经存在退款单"); |
| 41 | ErrorCode REFUND_NOT_FOUND = new ErrorCode(1_007_006_004, "支付退款单不存在"); | 41 | ErrorCode REFUND_NOT_FOUND = new ErrorCode(1_007_006_004, "支付退款单不存在"); |
| 42 | ErrorCode REFUND_STATUS_IS_NOT_WAITING = new ErrorCode(1_007_006_005, "支付退款单不处于待退款"); | 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 | // ========== 钱包模块 1-007-007-000 ========== | 47 | // ========== 钱包模块 1-007-007-000 ========== |
| 45 | ErrorCode WALLET_NOT_FOUND = new ErrorCode(1_007_007_000, "用户钱包不存在"); | 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 | package cn.iocoder.yudao.module.pay.service.refund; | 1 | package cn.iocoder.yudao.module.pay.service.refund; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.collection.CollUtil; | 3 | import cn.hutool.core.collection.CollUtil; |
| 4 | +import cn.hutool.core.util.ObjectUtil; | ||
| 5 | +import cn.hutool.core.util.StrUtil; | ||
| 4 | import cn.hutool.extra.spring.SpringUtil; | 6 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | ||
| 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 9 | +import cn.iocoder.yudao.framework.common.util.date.DateUtils; | ||
| 6 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; | 10 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; |
| 7 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO; | 11 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO; |
| 8 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO; | 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,6 +21,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; | ||
| 17 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; | 21 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 18 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; | 22 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; |
| 19 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; | 23 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 24 | +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; | ||
| 20 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; | 25 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 21 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; | 26 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; |
| 22 | import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; | 27 | import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum; |
| @@ -28,6 +33,7 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService; | @@ -28,6 +33,7 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService; | ||
| 28 | import jakarta.annotation.Resource; | 33 | import jakarta.annotation.Resource; |
| 29 | import lombok.extern.slf4j.Slf4j; | 34 | import lombok.extern.slf4j.Slf4j; |
| 30 | import org.springframework.stereotype.Service; | 35 | import org.springframework.stereotype.Service; |
| 36 | +import org.springframework.transaction.annotation.Propagation; | ||
| 31 | import org.springframework.transaction.annotation.Transactional; | 37 | import org.springframework.transaction.annotation.Transactional; |
| 32 | import org.springframework.validation.annotation.Validated; | 38 | import org.springframework.validation.annotation.Validated; |
| 33 | 39 | ||
| @@ -47,6 +53,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; | @@ -47,6 +53,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; | ||
| 47 | @Validated | 53 | @Validated |
| 48 | public class PayRefundServiceImpl implements PayRefundService { | 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 | @Resource | 61 | @Resource |
| 51 | private PayProperties payProperties; | 62 | private PayProperties payProperties; |
| 52 | 63 | ||
| @@ -54,6 +65,8 @@ public class PayRefundServiceImpl implements PayRefundService { | @@ -54,6 +65,8 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 54 | private PayRefundMapper refundMapper; | 65 | private PayRefundMapper refundMapper; |
| 55 | @Resource | 66 | @Resource |
| 56 | private PayNoRedisDAO noRedisDAO; | 67 | private PayNoRedisDAO noRedisDAO; |
| 68 | + @Resource | ||
| 69 | + private PayRefundLockRedisDAO refundLockRedisDAO; | ||
| 57 | 70 | ||
| 58 | @Resource | 71 | @Resource |
| 59 | private PayOrderService orderService; | 72 | private PayOrderService orderService; |
| @@ -90,6 +103,9 @@ public class PayRefundServiceImpl implements PayRefundService { | @@ -90,6 +103,9 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 90 | } | 103 | } |
| 91 | 104 | ||
| 92 | @Override | 105 | @Override |
| 106 | + // 注意:不加入调用方的事务,退款单、退款结果(包括退款通知)各自独立提交。 | ||
| 107 | + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的退款单,否则无法使用原退款单号重新发起 | ||
| 108 | + @Transactional(propagation = Propagation.NOT_SUPPORTED) | ||
| 93 | public Long createRefund(PayRefundCreateReqDTO reqDTO) { | 109 | public Long createRefund(PayRefundCreateReqDTO reqDTO) { |
| 94 | // 1.1 校验 App | 110 | // 1.1 校验 App |
| 95 | PayAppDO app = appService.validPayApp(reqDTO.getAppKey()); | 111 | PayAppDO app = appService.validPayApp(reqDTO.getAppKey()); |
| @@ -99,31 +115,18 @@ public class PayRefundServiceImpl implements PayRefundService { | @@ -99,31 +115,18 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 99 | PayChannelDO channel = channelService.validPayChannel(order.getChannelId()); | 115 | PayChannelDO channel = channelService.validPayChannel(order.getChannelId()); |
| 100 | PayClient<?> client = channelService.getPayClient(channel.getId()); | 116 | PayClient<?> client = channelService.getPayClient(channel.getId()); |
| 101 | if (client == null) { | 117 | if (client == null) { |
| 102 | - log.error("[refund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); | 118 | + log.error("[createRefund][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); |
| 103 | throw exception(CHANNEL_NOT_FOUND); | 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 | try { | 129 | try { |
| 126 | - // 2.2 向渠道发起退款申请 | ||
| 127 | PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO() | 130 | PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO() |
| 128 | .setPayPrice(order.getPrice()) | 131 | .setPayPrice(order.getPrice()) |
| 129 | .setRefundPrice(reqDTO.getPrice()) | 132 | .setRefundPrice(reqDTO.getPrice()) |
| @@ -131,22 +134,147 @@ public class PayRefundServiceImpl implements PayRefundService { | @@ -131,22 +134,147 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 131 | .setOutRefundNo(refund.getNo()) | 134 | .setOutRefundNo(refund.getNo()) |
| 132 | .setNotifyUrl(genChannelRefundNotifyUrl(channel)) | 135 | .setNotifyUrl(genChannelRefundNotifyUrl(channel)) |
| 133 | .setReason(reqDTO.getReason()); | 136 | .setReason(reqDTO.getReason()); |
| 134 | - PayRefundRespDTO refundRespDTO = client.unifiedRefund(unifiedReqDTO); | ||
| 135 | - // 2.3 处理退款返回 | ||
| 136 | - getSelf().notifyRefund(channel, refundRespDTO); | 137 | + refundRespDTO = client.unifiedRefund(unifiedReqDTO); |
| 137 | } catch (Throwable e) { | 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 | return refund.getId(); | 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 | * @param reqDTO 退款申请信息 | 280 | * @param reqDTO 退款申请信息 |
| @@ -166,12 +294,39 @@ public class PayRefundServiceImpl implements PayRefundService { | @@ -166,12 +294,39 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 166 | if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()) { | 294 | if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()) { |
| 167 | throw exception(REFUND_PRICE_EXCEED); | 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 | if (refundMapper.selectCountByAppIdAndOrderId(appId, order.getId(), | 310 | if (refundMapper.selectCountByAppIdAndOrderId(appId, order.getId(), |
| 171 | PayRefundStatusEnum.WAITING.getStatus()) > 0) { | 311 | PayRefundStatusEnum.WAITING.getStatus()) > 0) { |
| 172 | throw exception(REFUND_HAS_REFUNDING); | 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,7 +364,39 @@ public class PayRefundServiceImpl implements PayRefundService { | ||
| 209 | // 情况二:退款失败 | 364 | // 情况二:退款失败 |
| 210 | if (PayRefundStatusEnum.isFailure(notify.getStatus())) { | 365 | if (PayRefundStatusEnum.isFailure(notify.getStatus())) { |
| 211 | notifyRefundFailure(channel, notify); | 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 | private void notifyRefundSuccess(PayChannelDO channel, PayRefundRespDTO notify) { | 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 +6,7 @@ import cn.hutool.core.util.StrUtil; | ||
| 6 | import cn.hutool.extra.spring.SpringUtil; | 6 | import cn.hutool.extra.spring.SpringUtil; |
| 7 | import cn.iocoder.yudao.framework.common.exception.ServiceException; | 7 | import cn.iocoder.yudao.framework.common.exception.ServiceException; |
| 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 8 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 9 | +import cn.iocoder.yudao.framework.common.util.date.DateUtils; | ||
| 9 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; | 10 | import cn.iocoder.yudao.framework.common.util.json.JsonUtils; |
| 10 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; | 11 | import cn.iocoder.yudao.framework.common.util.object.BeanUtils; |
| 11 | import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; | 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,6 +21,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO; | ||
| 20 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; | 21 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 21 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; | 22 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; |
| 22 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; | 23 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 24 | +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; | ||
| 23 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; | 25 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 24 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; | 26 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; |
| 25 | import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; | 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,6 +31,7 @@ import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService; | ||
| 29 | import jakarta.annotation.Resource; | 31 | import jakarta.annotation.Resource; |
| 30 | import lombok.extern.slf4j.Slf4j; | 32 | import lombok.extern.slf4j.Slf4j; |
| 31 | import org.springframework.stereotype.Service; | 33 | import org.springframework.stereotype.Service; |
| 34 | +import org.springframework.transaction.annotation.Propagation; | ||
| 32 | import org.springframework.transaction.annotation.Transactional; | 35 | import org.springframework.transaction.annotation.Transactional; |
| 33 | 36 | ||
| 34 | import java.util.List; | 37 | import java.util.List; |
| @@ -47,6 +50,11 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -47,6 +50,11 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 47 | 50 | ||
| 48 | private static final String TRANSFER_NO_PREFIX = "T"; | 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 | @Resource | 58 | @Resource |
| 51 | private PayProperties payProperties; | 59 | private PayProperties payProperties; |
| 52 | 60 | ||
| @@ -60,8 +68,14 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -60,8 +68,14 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 60 | private PayNotifyService notifyService; | 68 | private PayNotifyService notifyService; |
| 61 | @Resource | 69 | @Resource |
| 62 | private PayNoRedisDAO noRedisDAO; | 70 | private PayNoRedisDAO noRedisDAO; |
| 71 | + @Resource | ||
| 72 | + private PayTransferLockRedisDAO transferLockRedisDAO; | ||
| 63 | 73 | ||
| 64 | @Override | 74 | @Override |
| 75 | + // 注意:不加入调用方的事务,转账单、转账结果(包括转账通知)各自独立提交。 | ||
| 76 | + // 原因是:调用方的事务回滚(例如说,下面抛出的渠道错误),不能回滚已经向渠道发起的转账单,否则无法使用原转账单号重新发起 | ||
| 77 | + // 特殊:钱包渠道查询不到调用方事务未提交的数据,所以钱包创建时独立提交,见 PayWalletServiceImpl#createWalletIfAbsent 方法 | ||
| 78 | + @Transactional(propagation = Propagation.NOT_SUPPORTED) | ||
| 65 | public PayTransferCreateRespDTO createTransfer(PayTransferCreateReqDTO reqDTO) { | 79 | public PayTransferCreateRespDTO createTransfer(PayTransferCreateReqDTO reqDTO) { |
| 66 | // 1.1 校验 App | 80 | // 1.1 校验 App |
| 67 | PayAppDO payApp = appService.validPayApp(reqDTO.getAppKey()); | 81 | PayAppDO payApp = appService.validPayApp(reqDTO.getAppKey()); |
| @@ -72,35 +86,29 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -72,35 +86,29 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 72 | log.error("[createTransfer][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); | 86 | log.error("[createTransfer][渠道编号({}) 找不到对应的支付客户端]", channel.getId()); |
| 73 | throw exception(CHANNEL_NOT_FOUND); | 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 | PayTransferRespDTO unifiedTransferResp = null; | 96 | PayTransferRespDTO unifiedTransferResp = null; |
| 92 | try { | 97 | try { |
| 93 | - // 3. 调用三方渠道发起转账 | ||
| 94 | PayTransferUnifiedReqDTO transferUnifiedReq = BeanUtils.toBean(reqDTO, PayTransferUnifiedReqDTO.class) | 98 | PayTransferUnifiedReqDTO transferUnifiedReq = BeanUtils.toBean(reqDTO, PayTransferUnifiedReqDTO.class) |
| 95 | .setOutTransferNo(transfer.getNo()) | 99 | .setOutTransferNo(transfer.getNo()) |
| 96 | .setNotifyUrl(genChannelTransferNotifyUrl(channel)); | 100 | .setNotifyUrl(genChannelTransferNotifyUrl(channel)); |
| 97 | unifiedTransferResp = client.unifiedTransfer(transferUnifiedReq); | 101 | unifiedTransferResp = client.unifiedTransfer(transferUnifiedReq); |
| 98 | } catch (Throwable e) { | 102 | } catch (Throwable e) { |
| 99 | - // 注意这里仅打印异常,不进行抛出。 | ||
| 100 | - // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。这个结果,后续转账轮询可以拿到。 | ||
| 101 | - // 或者,使用相同 no 再次发起转账请求 | 103 | + // 注意:这里仅打印异常,不进行抛出。 |
| 104 | + // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。 | ||
| 105 | + // 所以下面会查询一次渠道转账单,查询不到明确结果时,后续通过转账回调、或者转账轮询补偿可以拿到 | ||
| 102 | log.error("[createTransfer][转账编号({}) requestDTO({}) 发生异常]", transfer.getId(), reqDTO, e); | 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 | // 4. 通知转账结果 | 113 | // 4. 通知转账结果 |
| 106 | if (unifiedTransferResp != null) { | 114 | if (unifiedTransferResp != null) { |
| @@ -126,6 +134,74 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -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,23 +235,30 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 159 | return payProperties.getTransferNotifyUrl() + "/" + channel.getId(); | 235 | return payProperties.getTransferNotifyUrl() + "/" + channel.getId(); |
| 160 | } | 236 | } |
| 161 | 237 | ||
| 238 | + /** | ||
| 239 | + * 校验转账单是否可以发起 | ||
| 240 | + * | ||
| 241 | + * @param reqDTO 转账申请信息 | ||
| 242 | + * @param appId 应用编号 | ||
| 243 | + * @return 转账关闭、需要重新发起的转账单;为 null 时,表示需要新建转账单 | ||
| 244 | + */ | ||
| 162 | private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO reqDTO, Long appId) { | 245 | private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO reqDTO, Long appId) { |
| 163 | PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantOrderId(appId, reqDTO.getMerchantTransferId()); | 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 | return transfer; | 262 | return transfer; |
| 180 | } | 263 | } |
| 181 | 264 | ||
| @@ -194,7 +277,38 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -194,7 +277,38 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 194 | if (PayTransferStatusEnum.isProcessing(notify.getStatus())) { | 277 | if (PayTransferStatusEnum.isProcessing(notify.getStatus())) { |
| 195 | notifyTransferProgressing(channel, notify); | 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 | private void notifyTransferProgressing(PayChannelDO channel, PayTransferRespDTO notify) { | 314 | private void notifyTransferProgressing(PayChannelDO channel, PayTransferRespDTO notify) { |
| @@ -216,6 +330,7 @@ public class PayTransferServiceImpl implements PayTransferService { | @@ -216,6 +330,7 @@ public class PayTransferServiceImpl implements PayTransferService { | ||
| 216 | int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), | 330 | int updateCounts = transferMapper.updateByIdAndStatus(transfer.getId(), |
| 217 | PayTransferStatusEnum.WAITING.getStatus(), | 331 | PayTransferStatusEnum.WAITING.getStatus(), |
| 218 | new PayTransferDO().setStatus(PayTransferStatusEnum.PROCESSING.getStatus()) | 332 | new PayTransferDO().setStatus(PayTransferStatusEnum.PROCESSING.getStatus()) |
| 333 | + .setChannelTransferNo(notify.getChannelTransferNo()) | ||
| 219 | .setChannelPackageInfo(notify.getChannelPackageInfo())); | 334 | .setChannelPackageInfo(notify.getChannelPackageInfo())); |
| 220 | if (updateCounts == 0) { | 335 | if (updateCounts == 0) { |
| 221 | PayTransferDO latestTransfer = transferMapper.selectById(transfer.getId()); | 336 | PayTransferDO latestTransfer = transferMapper.selectById(transfer.getId()); |
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java
| 1 | package cn.iocoder.yudao.module.pay.service.wallet; | 1 | package cn.iocoder.yudao.module.pay.service.wallet; |
| 2 | 2 | ||
| 3 | import cn.hutool.core.lang.Assert; | 3 | import cn.hutool.core.lang.Assert; |
| 4 | +import cn.hutool.extra.spring.SpringUtil; | ||
| 4 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | import cn.iocoder.yudao.framework.common.util.date.DateUtils; | 6 | import cn.iocoder.yudao.framework.common.util.date.DateUtils; |
| 6 | import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletPageReqVO; | 7 | import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.wallet.PayWalletPageReqVO; |
| @@ -19,6 +20,7 @@ import lombok.SneakyThrows; | @@ -19,6 +20,7 @@ import lombok.SneakyThrows; | ||
| 19 | import lombok.extern.slf4j.Slf4j; | 20 | import lombok.extern.slf4j.Slf4j; |
| 20 | import org.springframework.context.annotation.Lazy; | 21 | import org.springframework.context.annotation.Lazy; |
| 21 | import org.springframework.stereotype.Service; | 22 | import org.springframework.stereotype.Service; |
| 23 | +import org.springframework.transaction.annotation.Propagation; | ||
| 22 | import org.springframework.transaction.annotation.Transactional; | 24 | import org.springframework.transaction.annotation.Transactional; |
| 23 | 25 | ||
| 24 | import java.time.LocalDateTime; | 26 | import java.time.LocalDateTime; |
| @@ -64,20 +66,37 @@ public class PayWalletServiceImpl implements PayWalletService { | @@ -64,20 +66,37 @@ public class PayWalletServiceImpl implements PayWalletService { | ||
| 64 | if (wallet == null) { | 66 | if (wallet == null) { |
| 65 | // 使用双重检查锁,保证钱包创建并发问题 | 67 | // 使用双重检查锁,保证钱包创建并发问题 |
| 66 | // https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1475/files | 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 | return wallet; | 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 | @Override | 100 | @Override |
| 82 | public PayWalletDO getWallet(Long walletId) { | 101 | public PayWalletDO getWallet(Long walletId) { |
| 83 | return walletMapper.selectById(walletId); | 102 | return walletMapper.selectById(walletId); |
| @@ -245,4 +264,13 @@ public class PayWalletServiceImpl implements PayWalletService { | @@ -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 | package cn.iocoder.yudao.module.pay.service.refund; | 1 | package cn.iocoder.yudao.module.pay.service.refund; |
| 2 | 2 | ||
| 3 | import cn.hutool.extra.spring.SpringUtil; | 3 | import cn.hutool.extra.spring.SpringUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | ||
| 4 | import cn.iocoder.yudao.framework.common.pojo.PageResult; | 5 | import cn.iocoder.yudao.framework.common.pojo.PageResult; |
| 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; | 6 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; |
| 6 | import cn.iocoder.yudao.module.pay.api.refund.dto.PayRefundCreateReqDTO; | 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,6 +13,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderDO; | ||
| 12 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; | 13 | import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO; |
| 13 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; | 14 | import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper; |
| 14 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; | 15 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 16 | +import cn.iocoder.yudao.module.pay.dal.redis.refund.PayRefundLockRedisDAO; | ||
| 15 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; | 17 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; |
| 16 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; | 18 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 17 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; | 19 | import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; |
| @@ -30,9 +32,17 @@ import org.junit.jupiter.api.Test; | @@ -30,9 +32,17 @@ import org.junit.jupiter.api.Test; | ||
| 30 | import org.mockito.MockedStatic; | 32 | import org.mockito.MockedStatic; |
| 31 | import org.springframework.context.annotation.Import; | 33 | import org.springframework.context.annotation.Import; |
| 32 | import org.springframework.test.context.bean.override.mockito.MockitoBean; | 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 | import java.util.List; | 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 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; | 46 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; |
| 37 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; | 47 | import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; |
| 38 | import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; | 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,6 +55,7 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; | ||
| 45 | import static org.assertj.core.api.Assertions.assertThat; | 55 | import static org.assertj.core.api.Assertions.assertThat; |
| 46 | import static org.junit.jupiter.api.Assertions.*; | 56 | import static org.junit.jupiter.api.Assertions.*; |
| 47 | import static org.mockito.ArgumentMatchers.any; | 57 | import static org.mockito.ArgumentMatchers.any; |
| 58 | +import static org.mockito.AdditionalAnswers.delegatesTo; | ||
| 48 | import static org.mockito.ArgumentMatchers.eq; | 59 | import static org.mockito.ArgumentMatchers.eq; |
| 49 | import static org.mockito.Mockito.*; | 60 | import static org.mockito.Mockito.*; |
| 50 | 61 | ||
| @@ -53,7 +64,7 @@ import static org.mockito.Mockito.*; | @@ -53,7 +64,7 @@ import static org.mockito.Mockito.*; | ||
| 53 | * | 64 | * |
| 54 | * @author 芋艿 | 65 | * @author 芋艿 |
| 55 | */ | 66 | */ |
| 56 | -@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class}) | 67 | +@Import({PayRefundServiceImpl.class, PayNoRedisDAO.class, PayRefundLockRedisDAO.class}) |
| 57 | public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | 68 | public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { |
| 58 | 69 | ||
| 59 | @Resource | 70 | @Resource |
| @@ -62,6 +73,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | @@ -62,6 +73,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | ||
| 62 | @Resource | 73 | @Resource |
| 63 | private PayRefundMapper refundMapper; | 74 | private PayRefundMapper refundMapper; |
| 64 | 75 | ||
| 76 | + @Resource | ||
| 77 | + private PlatformTransactionManager transactionManager; | ||
| 78 | + | ||
| 65 | @MockitoBean | 79 | @MockitoBean |
| 66 | private PayProperties payProperties; | 80 | private PayProperties payProperties; |
| 67 | @MockitoBean | 81 | @MockitoBean |
| @@ -332,9 +346,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | @@ -332,9 +346,9 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | ||
| 332 | // mock 方法(client) | 346 | // mock 方法(client) |
| 333 | PayClient<?> client = mock(PayClient.class); | 347 | PayClient<?> client = mock(PayClient.class); |
| 334 | when(channelService.getPayClient(eq(10L))).thenReturn(client); | 348 | when(channelService.getPayClient(eq(10L))).thenReturn(client); |
| 335 | - // mock 数据(refund 已存在) | 349 | + // mock 数据(refund 已存在,且不是退款失败) |
| 336 | PayRefundDO refund = randomPojo(PayRefundDO.class, o -> | 350 | PayRefundDO refund = randomPojo(PayRefundDO.class, o -> |
| 337 | - o.setAppId(1L).setMerchantRefundId("200")); | 351 | + o.setAppId(1L).setMerchantRefundId("200").setStatus(PayRefundStatusEnum.SUCCESS.getStatus())); |
| 338 | refundMapper.insert(refund); | 352 | refundMapper.insert(refund); |
| 339 | 353 | ||
| 340 | // 调用,并断言异常 | 354 | // 调用,并断言异常 |
| @@ -379,6 +393,453 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | @@ -379,6 +393,453 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | ||
| 379 | .containsExactly(order.getId(), order.getNo(), channel.getId(), channel.getCode(), | 393 | .containsExactly(order.getId(), order.getNo(), channel.getId(), channel.getCode(), |
| 380 | app.getRefundNotifyUrl(), order.getChannelOrderNo(), PayRefundStatusEnum.WAITING.getStatus(), | 394 | app.getRefundNotifyUrl(), order.getChannelOrderNo(), PayRefundStatusEnum.WAITING.getStatus(), |
| 381 | order.getPrice(), reqDTO.getPrice()); | 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 | @Test | 845 | @Test |
| @@ -696,4 +1157,59 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | @@ -696,4 +1157,59 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest { | ||
| 696 | assertEquals(count, 0); | 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 | package cn.iocoder.yudao.module.pay.service.transfer; | 1 | package cn.iocoder.yudao.module.pay.service.transfer; |
| 2 | 2 | ||
| 3 | import cn.hutool.extra.spring.SpringUtil; | 3 | import cn.hutool.extra.spring.SpringUtil; |
| 4 | +import cn.iocoder.yudao.framework.common.exception.ServiceException; | ||
| 4 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; | 5 | import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest; |
| 5 | import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO; | 6 | import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO; |
| 6 | import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateRespDTO; | 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,6 +10,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO; | ||
| 9 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; | 10 | import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO; |
| 10 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; | 11 | import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; |
| 11 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; | 12 | import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; |
| 13 | +import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; | ||
| 12 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; | 14 | import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; |
| 13 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; | 15 | import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; |
| 14 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; | 16 | import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; |
| @@ -24,14 +26,23 @@ import org.junit.jupiter.api.Test; | @@ -24,14 +26,23 @@ import org.junit.jupiter.api.Test; | ||
| 24 | import org.mockito.MockedStatic; | 26 | import org.mockito.MockedStatic; |
| 25 | import org.springframework.context.annotation.Import; | 27 | import org.springframework.context.annotation.Import; |
| 26 | import org.springframework.test.context.bean.override.mockito.MockitoBean; | 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 | import java.time.LocalDateTime; | 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 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; | 40 | import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; |
| 31 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; | 41 | import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; |
| 32 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; | 42 | import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; |
| 33 | import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; | 43 | import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; |
| 34 | import static org.junit.jupiter.api.Assertions.*; | 44 | import static org.junit.jupiter.api.Assertions.*; |
| 45 | +import static org.mockito.AdditionalAnswers.delegatesTo; | ||
| 35 | import static org.mockito.ArgumentMatchers.any; | 46 | import static org.mockito.ArgumentMatchers.any; |
| 36 | import static org.mockito.ArgumentMatchers.eq; | 47 | import static org.mockito.ArgumentMatchers.eq; |
| 37 | import static org.mockito.Mockito.*; | 48 | import static org.mockito.Mockito.*; |
| @@ -41,7 +52,7 @@ import static org.mockito.Mockito.*; | @@ -41,7 +52,7 @@ import static org.mockito.Mockito.*; | ||
| 41 | * | 52 | * |
| 42 | * @author 芋道源码 | 53 | * @author 芋道源码 |
| 43 | */ | 54 | */ |
| 44 | -@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class}) | 55 | +@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class, PayTransferLockRedisDAO.class}) |
| 45 | public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | 56 | public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { |
| 46 | 57 | ||
| 47 | @Resource | 58 | @Resource |
| @@ -50,6 +61,9 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | @@ -50,6 +61,9 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | ||
| 50 | @Resource | 61 | @Resource |
| 51 | private PayTransferMapper transferMapper; | 62 | private PayTransferMapper transferMapper; |
| 52 | 63 | ||
| 64 | + @Resource | ||
| 65 | + private PlatformTransactionManager transactionManager; | ||
| 66 | + | ||
| 53 | @MockitoBean | 67 | @MockitoBean |
| 54 | private PayProperties payProperties; | 68 | private PayProperties payProperties; |
| 55 | @MockitoBean | 69 | @MockitoBean |
| @@ -123,6 +137,207 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | @@ -123,6 +137,207 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | ||
| 123 | verify(notifyService, never()).createPayNotifyTask(any(), any()); | 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 | @Test // 调用 unifiedTransfer 接口,返回 null | 341 | @Test // 调用 unifiedTransfer 接口,返回 null |
| 127 | public void testCreateTransfer_nullResponse() { | 342 | public void testCreateTransfer_nullResponse() { |
| 128 | // mock 方法(client) | 343 | // mock 方法(client) |
| @@ -223,6 +438,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | @@ -223,6 +438,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | ||
| 223 | PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); | 438 | PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); |
| 224 | assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); | 439 | assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus()); |
| 225 | assertEquals("package-info-001", dbTransfer.getChannelPackageInfo()); | 440 | assertEquals("package-info-001", dbTransfer.getChannelPackageInfo()); |
| 441 | + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo()); | ||
| 226 | } | 442 | } |
| 227 | 443 | ||
| 228 | @Test // 调用 unifiedTransfer 接口返回成功,但通知转账结果时找不到转账单 | 444 | @Test // 调用 unifiedTransfer 接口返回成功,但通知转账结果时找不到转账单 |
| @@ -360,6 +576,203 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | @@ -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 | private PayClient<?> mockPayClient() { | 776 | private PayClient<?> mockPayClient() { |
| 364 | // mock 方法(app) | 777 | // mock 方法(app) |
| 365 | PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer")); | 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,6 +788,22 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | ||
| 375 | return client; | 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 | private void updateTransferStatus(String no, Integer status) { | 807 | private void updateTransferStatus(String no, Integer status) { |
| 379 | PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); | 808 | PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); |
| 380 | transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status)); | 809 | transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status)); |
| @@ -384,7 +813,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | @@ -384,7 +813,7 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { | ||
| 384 | return new PayTransferCreateReqDTO().setAppKey("demo").setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()) | 813 | return new PayTransferCreateReqDTO().setAppKey("demo").setChannelCode(PayChannelEnum.ALIPAY_PC.getCode()) |
| 385 | .setUserIp("127.0.0.1").setUserId(1L).setUserType(1) | 814 | .setUserIp("127.0.0.1").setUserId(1L).setUserType(1) |
| 386 | .setMerchantTransferId("1001").setSubject("佣金提现").setPrice(100) | 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
| @@ -4,5 +4,6 @@ DELETE FROM pay_order; | @@ -4,5 +4,6 @@ DELETE FROM pay_order; | ||
| 4 | DELETE FROM pay_order_extension; | 4 | DELETE FROM pay_order_extension; |
| 5 | DELETE FROM pay_refund; | 5 | DELETE FROM pay_refund; |
| 6 | DELETE FROM pay_transfer; | 6 | DELETE FROM pay_transfer; |
| 7 | +DELETE FROM pay_wallet; | ||
| 7 | DELETE FROM pay_notify_task; | 8 | DELETE FROM pay_notify_task; |
| 8 | DELETE FROM pay_notify_log; | 9 | DELETE FROM pay_notify_log; |
yudao-module-pay/src/test/resources/sql/create_tables.sql
| @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` ( | @@ -180,3 +180,19 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` ( | ||
| 180 | `deleted` bit(1) NOT NULL DEFAULT FALSE, | 180 | `deleted` bit(1) NOT NULL DEFAULT FALSE, |
| 181 | PRIMARY KEY ("id") | 181 | PRIMARY KEY ("id") |
| 182 | ) COMMENT = '转账单'; | 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 = '会员钱包'; |