Commit ed24181e710e62d7b65f9357073516d99771f9ac

Authored by YunaiV
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&lt;PayTransferDO&gt; { @@ -26,6 +27,28 @@ public interface PayTransferMapper extends BaseMapperX&lt;PayTransferDO&gt; {
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 = '会员钱包';