Commit d2cd9ffe31b877bcdaca77b5baa26287f8a41d8d

Authored by YunaiV
1 parent 85e621ea

fix(pay): 支持结果未知的退款使用原单号安全重试

- 查询渠道后按冷却期原子占位,保留未知退款 WAITING
- 防止旧轮询失败覆盖新尝试,修复同秒重试令牌回退
- 保证钱包退款流水幂等,规避正常入口的范围锁冲突
- 补充微信、支付宝、钱包及退款服务回归测试

关联 #1290,替代 #1301 的实现方案
Showing 18 changed files with 1684 additions and 112 deletions
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/refund/PayRefundMapper.java
... ... @@ -6,10 +6,15 @@ import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
6 6 import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundExportReqVO;
7 7 import cn.iocoder.yudao.module.pay.controller.admin.refund.vo.PayRefundPageReqVO;
8 8 import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
  9 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
9 10 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
10 11 import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
11 12 import org.apache.ibatis.annotations.Mapper;
  13 +import org.apache.ibatis.annotations.Options;
  14 +import org.apache.ibatis.annotations.Param;
  15 +import org.apache.ibatis.annotations.Select;
12 16  
  17 +import java.time.LocalDateTime;
13 18 import java.util.List;
14 19  
15 20 @Mapper
... ... @@ -42,12 +47,60 @@ public interface PayRefundMapper extends BaseMapperX<PayRefundDO> {
42 47 return selectOne(PayRefundDO::getNo, no);
43 48 }
44 49  
  50 + /**
  51 + * 获得退款单,不使用 MyBatis 一级缓存,用于查询退款单的最新状态
  52 + *
  53 + * 原因是:createRefund 不加入事务(NOT_SUPPORTED),但 Spring 事务同步仍然生效,同一次调用复用同一个 SqlSession,
  54 + * 重复查询会命中一级缓存,查询不到其它事务(例如说,退款回调)已提交的更新
  55 + *
  56 + * @param id 退款单编号
  57 + * @return 退款单
  58 + */
  59 + @Select("SELECT * FROM pay_refund WHERE id = #{id} AND deleted = 0")
  60 + @Options(flushCache = Options.FlushCachePolicy.TRUE)
  61 + PayRefundDO selectLatestById(@Param("id") Long id);
  62 +
45 63 default int updateByIdAndStatus(Long id, Integer status, PayRefundDO update) {
46 64 return update(update, new LambdaQueryWrapper<PayRefundDO>()
47 65 .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status));
48 66 }
49 67  
50 68 /**
  69 + * 更新退款单,并校验状态、更新时间
  70 + *
  71 + * 用于更新退款结果:退款单的更新时间变化,说明已被并发更新、或者已是其它退款尝试(例如说,退款失败后重新发起),则不更新
  72 + *
  73 + * @param id 退款单编号
  74 + * @param status 原状态
  75 + * @param updateTime 原更新时间
  76 + * @param update 更新对象
  77 + * @return 更新数量
  78 + */
  79 + default int updateByIdAndStatusAndUpdateTime(Long id, Integer status, LocalDateTime updateTime, PayRefundDO update) {
  80 + return update(update, new LambdaQueryWrapper<PayRefundDO>()
  81 + .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, status)
  82 + .eq(PayRefundDO::getUpdateTime, updateTime));
  83 + }
  84 +
  85 + /**
  86 + * 刷新结果未知的退款单的更新时间,用于重新发起退款前的占位
  87 + *
  88 + * 条件:待退款、渠道未受理(没有渠道退款单号)、并且更新时间早于 maxUpdateTime(超过重新发起的间隔)。
  89 + * 不会清空渠道结果:如果已被退款回调写入渠道退款单号、或者更新为最终状态,则更新失败
  90 + *
  91 + * @param id 退款单编号
  92 + * @param maxUpdateTime 最大更新时间
  93 + * @return 更新数量
  94 + */
  95 + default int updateUpdateTimeByIdAndWaiting(Long id, LocalDateTime maxUpdateTime) {
  96 + return update(null, new LambdaUpdateWrapper<PayRefundDO>()
  97 + .set(PayRefundDO::getUpdateTime, LocalDateTime.now())
  98 + .eq(PayRefundDO::getId, id).eq(PayRefundDO::getStatus, PayRefundStatusEnum.WAITING.getStatus())
  99 + .and(w -> w.isNull(PayRefundDO::getChannelRefundNo).or().eq(PayRefundDO::getChannelRefundNo, ""))
  100 + .lt(PayRefundDO::getUpdateTime, maxUpdateTime));
  101 + }
  102 +
  103 + /**
51 104 * 更新退款单,并清空上一次退款的渠道结果
52 105 *
53 106 * 用于退款失败后重新发起退款,避免新的退款结果残留上一次的失败原因、渠道退款单号等
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/wallet/PayWalletTransactionMapper.java
... ... @@ -60,6 +60,11 @@ public interface PayWalletTransactionMapper extends BaseMapperX&lt;PayWalletTransac
60 60 PayWalletTransactionDO::getBizType, bizType);
61 61 }
62 62  
  63 + default PayWalletTransactionDO selectByBizForUpdate(String bizId, Integer bizType) {
  64 + return selectOneForUpdate(PayWalletTransactionDO::getBizId, bizId,
  65 + PayWalletTransactionDO::getBizType, bizType);
  66 + }
  67 +
63 68 }
64 69  
65 70  
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/alipay/AbstractAlipayPayClient.java
... ... @@ -204,8 +204,12 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient&lt;AlipayPa
204 204 return PayRefundRespDTO.failureOf(response.getSubCode(), response.getSubMsg(), reqDTO.getOutRefundNo(), response);
205 205 }
206 206 // 2.2 创建返回结果
207   - // 支付宝只要退款调用返回 success,就认为退款成功,不需要回调。具体可见 parseNotify 方法的说明。
208   - // 另外,支付宝没有退款单号,所以不用设置
  207 + // 支付宝退款没有回调,具体可见 parseNotify 方法的说明。另外,支付宝没有退款单号,所以不用设置
  208 + // 特殊:code=10000 只代表退款请求成功,fund_change=Y 才代表退款成功;fund_change=N 或者没有返回时,需要查询退款确认,所以返回结果未知
  209 + // 参见 https://aipay.alipay.com/docs/subscribe/api-list/other-basic-interfaces/alipay-trade-refund.html 注意事项
  210 + if (!Objects.equals(response.getFundChange(), "Y")) {
  211 + return PayRefundRespDTO.waitingOf(null, reqDTO.getOutRefundNo(), response);
  212 + }
209 213 return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()),
210 214 reqDTO.getOutRefundNo(), response);
211 215 }
... ... @@ -216,7 +220,7 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient&lt;AlipayPa
216 220 // ① 部分退款:是会有回调,但是它回调的是订单状态的同步回调,不是退款订单的回调
217 221 // ② 全部退款:Wap 支付有订单状态的同步回调,但是 PC/扫码又没有
218 222 // 所以,这里在解析时,即使是退款导致的订单状态同步,我们也忽略不做为“退款同步”,而是订单的回调。
219   - // 实际上,支付宝退款只要发起成功,就可以认为退款成功,不需要等待回调。
  223 + // 实际上,支付宝退款不需要等待回调,以发起退款返回的 fund_change、或者查询退款的结果为准。
220 224 throw new UnsupportedOperationException("支付宝无退款回调");
221 225 }
222 226  
... ... @@ -239,11 +243,10 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient&lt;AlipayPa
239 243 response = client.execute(request);
240 244 }
241 245 if (!response.isSuccess()) {
242   - // 明确不存在的情况,应该就是失败,可进行关闭
243   - if (ObjectUtils.equalsAny(response.getSubCode(), "TRADE_NOT_EXIST", "ACQ.TRADE_NOT_EXIST")) {
244   - return PayRefundRespDTO.failureOf(outRefundNo, response);
245   - }
246   - // 可能存在“ACQ.SYSTEM_ERROR”系统错误等情况,所以返回 WAIT 继续等待
  246 + // 查询失败,不代表退款失败,返回结果未知,继续等待。例如说:
  247 + // ① “ACQ.SYSTEM_ERROR”系统错误
  248 + // ② “ACQ.TRADE_NOT_EXIST”交易不存在:官方建议确认外部订单号、交易号是否正确,是交易维度的错误,不是退款单明确失败的状态
  249 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
247 250 return PayRefundRespDTO.waitingOf(null, outRefundNo, response);
248 251 }
249 252 // 2.2 创建返回结果
... ... @@ -251,6 +254,7 @@ public abstract class AbstractAlipayPayClient extends AbstractPayClient&lt;AlipayPa
251 254 return PayRefundRespDTO.successOf(null, LocalDateTimeUtil.of(response.getGmtRefundPay()),
252 255 outRefundNo, response);
253 256 }
  257 + // 没有退款数据(refund_status):可能退款请求未收到、或者尚未处理完成,返回结果未知,不能作为退款失败
254 258 return PayRefundRespDTO.waitingOf(null, outRefundNo, response);
255 259 }
256 260  
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/wallet/WalletPayClient.java
... ... @@ -180,25 +180,29 @@ public class WalletPayClient extends AbstractPayClient&lt;NonePayClientConfig&gt; {
180 180 PayRefundDO payRefund = refundService.getRefundByNo(outRefundNo);
181 181 // 支付退款单不存在, 返回退款失败状态
182 182 if (payRefund == null) {
183   - return PayRefundRespDTO.failureOf(String.valueOf(REFUND_NOT_FOUND), REFUND_NOT_FOUND.getMsg(),
  183 + return PayRefundRespDTO.failureOf(String.valueOf(REFUND_NOT_FOUND.getCode()), REFUND_NOT_FOUND.getMsg(),
184 184 outRefundNo, "");
185 185 }
  186 + // 退款成功:以已提交的钱包退款流水为准,不依赖退款单的状态
  187 + // 例如说:钱包已退款入账,但更新退款单失败,退款单仍是 WAITING。关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
  188 + PayWalletTransactionDO walletTransaction = walletTransactionService.getWalletTransaction(
  189 + String.valueOf(payRefund.getId()), PayWalletBizTypeEnum.PAYMENT_REFUND);
  190 + if (walletTransaction != null) {
  191 + return PayRefundRespDTO.successOf(walletTransaction.getNo(), walletTransaction.getCreateTime(),
  192 + outRefundNo, walletTransaction);
  193 + }
186 194 // 退款失败
187 195 if (PayRefundStatusEnum.isFailure(payRefund.getStatus())) {
188 196 return PayRefundRespDTO.failureOf(payRefund.getChannelErrorCode(), payRefund.getChannelErrorMsg(),
189 197 outRefundNo, "");
190 198 }
191   - // 退款成功
  199 + // 退款成功,但钱包流水不存在,为无效状态
192 200 if (PayRefundStatusEnum.isSuccess(payRefund.getStatus())) {
193   - PayWalletTransactionDO walletTransaction = walletTransactionService.getWalletTransaction(
194   - String.valueOf(payRefund.getId()), PayWalletBizTypeEnum.PAYMENT_REFUND);
195   - Assert.notNull(walletTransaction, "支付退款单 {} 钱包流水不能为空", outRefundNo);
196   - return PayRefundRespDTO.successOf(walletTransaction.getNo(), walletTransaction.getCreateTime(),
197   - outRefundNo, walletTransaction);
  201 + log.error("[doGetRefund] 支付退款单 {} 的钱包流水不存在", outRefundNo);
  202 + throw new IllegalStateException(String.format("支付退款单[%s] 钱包流水不能为空", outRefundNo));
198 203 }
199   - // 其它状态为无效状态
200   - log.error("[doGetRefund] 支付退款单 {} 的状态不正确", outRefundNo);
201   - throw new IllegalStateException(String.format("支付退款单[%s] 状态不正确", outRefundNo));
  204 + // 待退款,但钱包流水不存在:可能未退款、或者退款事务尚未提交,返回结果未知(没有渠道退款单号),不能作为退款失败
  205 + return PayRefundRespDTO.waitingOf(null, outRefundNo, "");
202 206 }
203 207  
204 208 @Override
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java
... ... @@ -297,6 +297,14 @@ public abstract class AbstractWxPayClient extends AbstractPayClient&lt;WxPayClientC
297 297 throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion()));
298 298 }
299 299 } catch (WxPayException e) {
  300 + // 网络异常、系统错误时,退款可能已受理,不能误判为退款失败:网络异常抛出,系统错误返回结果未知,后续查询确认、或者使用原退款单号重新发起
  301 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
  302 + if (hasCause(e, IOException.class)) {
  303 + throw e;
  304 + }
  305 + if (ObjectUtils.equalsAny(e.getErrCode(), "SYSTEMERROR", "SYSTEM_ERROR")) {
  306 + return PayRefundRespDTO.waitingOf(null, reqDTO.getOutRefundNo(), e.getXmlString());
  307 + }
300 308 String errorCode = getErrorCode(e);
301 309 String errorMessage = getErrorMessage(e);
302 310 return PayRefundRespDTO.failureOf(errorCode, errorMessage,
... ... @@ -395,11 +403,10 @@ public abstract class AbstractWxPayClient extends AbstractPayClient&lt;WxPayClientC
395 403 throw new IllegalArgumentException(String.format("未知的 API 版本(%s)", config.getApiVersion()));
396 404 }
397 405 } catch (WxPayException e) {
  406 + // 查询不到退款单:可能退款请求未受理、或者尚未生成退款单,返回结果未知,不能作为退款失败
  407 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
398 408 if (ObjectUtils.equalsAny(e.getErrCode(), "REFUNDNOTEXIST", "RESOURCE_NOT_EXISTS")) {
399   - String errorCode = getErrorCode(e);
400   - String errorMessage = getErrorMessage(e);
401   - return PayRefundRespDTO.failureOf(errorCode, errorMessage,
402   - outRefundNo, e.getXmlString());
  409 + return PayRefundRespDTO.waitingOf(null, outRefundNo, e.getXmlString());
403 410 }
404 411 throw e;
405 412 }
... ... @@ -420,8 +427,8 @@ public abstract class AbstractWxPayClient extends AbstractPayClient&lt;WxPayClientC
420 427 }
421 428 WxPayRefundQueryResult.RefundRecord refund = CollUtil.findOne(response.getRefundRecords(),
422 429 record -> record.getOutRefundNo().equals(outRefundNo));
423   - if (refund == null) {
424   - return PayRefundRespDTO.failureOf(outRefundNo, response);
  430 + if (refund == null) { // 查询不到退款单,返回结果未知
  431 + return PayRefundRespDTO.waitingOf(null, outRefundNo, response);
425 432 }
426 433 switch (refund.getRefundStatus()) {
427 434 case "SUCCESS":
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundService.java
... ... @@ -33,6 +33,16 @@ public interface PayRefundService {
33 33 PayRefundDO getRefundByNo(String no);
34 34  
35 35 /**
  36 + * 获得退款订单,并锁定(FOR UPDATE)
  37 + *
  38 + * 注意:需要在事务中调用,锁持有到事务提交
  39 + *
  40 + * @param id 编号
  41 + * @return 退款订单
  42 + */
  43 + PayRefundDO getRefundForUpdate(Long id);
  44 +
  45 + /**
36 46 * 获得指定应用的退款数量
37 47 *
38 48 * @param appId 应用编号
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java
... ... @@ -37,6 +37,8 @@ import org.springframework.transaction.annotation.Propagation;
37 37 import org.springframework.transaction.annotation.Transactional;
38 38 import org.springframework.validation.annotation.Validated;
39 39  
  40 +import java.time.Duration;
  41 +import java.time.LocalDateTime;
40 42 import java.util.List;
41 43  
42 44 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
... ... @@ -58,6 +60,15 @@ public class PayRefundServiceImpl implements PayRefundService {
58 60 */
59 61 private static final long CREATE_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS;
60 62  
  63 + /**
  64 + * 结果未知(WAITING)的退款单,距离上一次发起退款的最小间隔,超过后才允许重新发起
  65 + *
  66 + * 注意:只用于避免频繁、并发地重新发起,不作为退款失败的依据。重新发起依赖渠道对原退款单号、原退款金额的幂等
  67 + *
  68 + * @see <a href="https://github.com/YunaiV/ruoyi-vue-pro/issues/1290">#1290</a>
  69 + */
  70 + private static final Duration RETRY_WAITING_INTERVAL = Duration.ofMinutes(10);
  71 +
61 72 @Resource
62 73 private PayProperties payProperties;
63 74  
... ... @@ -88,6 +99,11 @@ public class PayRefundServiceImpl implements PayRefundService {
88 99 }
89 100  
90 101 @Override
  102 + public PayRefundDO getRefundForUpdate(Long id) {
  103 + return refundMapper.selectOneForUpdate(PayRefundDO::getId, id);
  104 + }
  105 +
  106 + @Override
91 107 public Long getRefundCountByAppId(Long appId) {
92 108 return refundMapper.selectCountByAppId(appId);
93 109 }
... ... @@ -118,66 +134,179 @@ public class PayRefundServiceImpl implements PayRefundService {
118 134 log.error("[createRefund][渠道编号({}) 找不到对应的支付客户端]", channel.getId());
119 135 throw exception(CHANNEL_NOT_FOUND);
120 136 }
121   - // 2. 加锁,创建退款单、或者重新发起退款失败的退款单
  137 + // 2. 情况一:存在结果未知(WAITING)的退款单,先查询渠道退款单,必要时使用原退款单号重新发起
  138 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290、https://github.com/YunaiV/ruoyi-vue-pro/pull/1301
  139 + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(app.getId(), reqDTO.getMerchantRefundId());
  140 + if (refund != null && PayRefundStatusEnum.WAITING.getStatus().equals(refund.getStatus())) {
  141 + return retryWaitingRefund(reqDTO, order, channel, client, refund);
  142 + }
  143 +
  144 + // 3. 情况二:加锁,创建退款单、或者重新发起退款失败的退款单
122 145 // 原因是:同一支付订单串行创建退款单,避免 select 后 insert 时,并发请求重复创建退款单,导致重复退款
123 146 // 注意:createRefund 不加入调用方的事务,退款单立即提交,所以释放锁后,其它请求可以查询到该退款单
124   - PayRefundDO refund = refundLockRedisDAO.lock(order.getId(), CREATE_LOCK_TIMEOUT_MILLIS,
  147 + refund = refundLockRedisDAO.lock(order.getId(), CREATE_LOCK_TIMEOUT_MILLIS,
125 148 () -> createOrRetryRefund(reqDTO, app, order));
  149 + // 4. 调用三方渠道发起退款,并通知退款结果
  150 + invokeRefund(channel, client, order, refund, reqDTO.getReason(), false);
  151 + return refund.getId();
  152 + }
  153 +
  154 + /**
  155 + * 重新发起结果未知(WAITING)的退款单
  156 + *
  157 + * 例如说:发起退款发生异常,并且查询渠道退款单没有明确结果,退款单保留 WAITING。此时,商户使用相同的商户退款编号再次发起:
  158 + * 1. 先查询渠道退款单:退款成功,则同步退款结果;渠道已受理(有渠道退款单号),则继续等待退款回调、或者退款轮询
  159 + * 2. 结果仍未知,并且超过重新发起的间隔,则原子刷新 update_time 占位后,使用原退款单号、原退款金额重新发起
  160 + *
  161 + * 注意:结果未知时,不会将退款单更新为退款失败,避免误判后业务错误处理(例如说,钱包充值退款失败时解冻余额)
  162 + *
  163 + * @param reqDTO 退款申请信息
  164 + * @param order 支付订单
  165 + * @param channel 支付渠道
  166 + * @param client 支付客户端
  167 + * @param refund 结果未知的退款单
  168 + * @return 退款单编号
  169 + * @see <a href="https://github.com/YunaiV/ruoyi-vue-pro/issues/1290">#1290</a>
  170 + */
  171 + private Long retryWaitingRefund(PayRefundCreateReqDTO reqDTO, PayOrderDO order, PayChannelDO channel,
  172 + PayClient<?> client, PayRefundDO refund) {
  173 + // 1.1 校验参数是否一致
  174 + validateRefundConsistent(refund, reqDTO, order);
  175 + // 1.2 渠道已受理退款,继续等待退款回调、或者退款轮询,不重新发起
  176 + if (StrUtil.isNotEmpty(refund.getChannelRefundNo())) {
  177 + throw exception(REFUND_HAS_REFUNDING);
  178 + }
  179 +
  180 + // 2. 查询渠道退款单:退款成功、或者渠道已受理,以查询结果为准,不重新发起
  181 + // 注意:查询到的退款失败、或者查询不到,不作为最终结果。原因是:不能证明原退款请求失败,所以不通知退款失败,交给下面重新发起
  182 + PayRefundRespDTO queryRespDTO = null;
  183 + try {
  184 + queryRespDTO = client.getRefund(refund.getOrderNo(), refund.getNo());
  185 + } catch (Throwable e) {
  186 + log.warn("[retryWaitingRefund][退款编号({}) 查询渠道退款单发生异常]", refund.getId(), e);
  187 + }
  188 + if (isRefundAccepted(queryRespDTO)) {
  189 + getSelf().notifyRefund(channel, queryRespDTO);
  190 + if (PayRefundStatusEnum.isSuccess(queryRespDTO.getStatus())) {
  191 + return refund.getId();
  192 + }
  193 + throw exception(REFUND_HAS_REFUNDING);
  194 + }
126 195  
127   - // 3.1 调用三方渠道发起退款
  196 + // 3. 原子刷新 update_time 占位:仍是 WAITING、渠道未受理,并且超过重新发起的间隔,保证只有一个请求重新发起
  197 + // 注意:createRefund 不加入调用方的事务,占位立即提交,即使调用方的事务回滚,也不会被撤销,避免短时间内重复发起
  198 + int updateCount = refundMapper.updateUpdateTimeByIdAndWaiting(refund.getId(),
  199 + LocalDateTime.now().minus(RETRY_WAITING_INTERVAL));
  200 + if (updateCount == 0) { // 未超过重新发起的间隔、或者已被并发更新(例如说,退款回调、其它请求重新发起)
  201 + return validateRetryWaitingRefundConcurrent(refund.getId());
  202 + }
  203 + // 4.1 占位后,重新查询退款单,作为本次退款尝试的快照
  204 + // 注意:占位不代表退款结果,占位后仍可能被退款回调更新(例如说,退款成功、写入渠道退款单号),此时以最新状态为准,不再重新发起
  205 + PayRefundDO latestRefund = refundMapper.selectLatestById(refund.getId());
  206 + if (latestRefund == null || !PayRefundStatusEnum.WAITING.getStatus().equals(latestRefund.getStatus())
  207 + || StrUtil.isNotEmpty(latestRefund.getChannelRefundNo())) {
  208 + return validateRetryWaitingRefundConcurrent(refund.getId());
  209 + }
  210 + // 4.2 使用原退款单号、原退款金额、原退款原因重新发起,保证与原退款请求一致,依赖渠道幂等,避免重复退款
  211 + invokeRefund(channel, client, order, latestRefund, latestRefund.getReason(), true);
  212 + return refund.getId();
  213 + }
  214 +
  215 + /**
  216 + * 重新发起结果未知的退款单时,退款单已被并发更新、或者未超过重新发起的间隔,以最新的退款单为准
  217 + *
  218 + * @param id 退款单编号
  219 + * @return 退款成功时,返回退款单编号;否则,抛出退款处理中的异常
  220 + */
  221 + private Long validateRetryWaitingRefundConcurrent(Long id) {
  222 + PayRefundDO latestRefund = refundMapper.selectLatestById(id);
  223 + if (latestRefund != null && PayRefundStatusEnum.isSuccess(latestRefund.getStatus())) {
  224 + return id;
  225 + }
  226 + throw exception(REFUND_HAS_REFUNDING);
  227 + }
  228 +
  229 + /**
  230 + * 调用三方渠道发起退款,并通知退款结果
  231 + *
  232 + * @param channel 支付渠道
  233 + * @param client 支付客户端
  234 + * @param order 支付订单
  235 + * @param refund 退款单;需要是发起前查询的最新退款单,它的 updateTime 用于避免旧的退款结果,更新新的退款尝试
  236 + * @param reason 退款原因
  237 + * @param retryWaiting 是否为重新发起结果未知的退款单
  238 + */
  239 + private void invokeRefund(PayChannelDO channel, PayClient<?> client, PayOrderDO order, PayRefundDO refund,
  240 + String reason, boolean retryWaiting) {
  241 + // 1.1 调用三方渠道发起退款
128 242 PayRefundRespDTO refundRespDTO = null;
  243 + PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO()
  244 + .setPayPrice(order.getPrice())
  245 + .setRefundPrice(refund.getRefundPrice())
  246 + .setOutTradeNo(order.getNo())
  247 + .setOutRefundNo(refund.getNo())
  248 + .setNotifyUrl(genChannelRefundNotifyUrl(channel))
  249 + .setReason(reason);
129 250 try {
130   - PayRefundUnifiedReqDTO unifiedReqDTO = new PayRefundUnifiedReqDTO()
131   - .setPayPrice(order.getPrice())
132   - .setRefundPrice(reqDTO.getPrice())
133   - .setOutTradeNo(order.getNo())
134   - .setOutRefundNo(refund.getNo())
135   - .setNotifyUrl(genChannelRefundNotifyUrl(channel))
136   - .setReason(reqDTO.getReason());
137 251 refundRespDTO = client.unifiedRefund(unifiedReqDTO);
138 252 } catch (Throwable e) {
139 253 // 注意:这里仅打印异常,不进行抛出。
140 254 // 原因是:虽然调用支付渠道进行退款发生异常(网络请求超时),实际退款成功。
141 255 // 所以下面会查询一次渠道退款单,查询不到明确结果时,后续通过退款回调、或者退款轮询补偿可以拿到
142   - log.error("[createRefund][退款编号({}) requestDTO({}) 发生异常]", refund.getId(), reqDTO, e);
  256 + log.error("[invokeRefund][退款编号({}) unifiedReqDTO({}) 发生异常]", refund.getId(), unifiedReqDTO, e);
143 257 }
144   - // 3.2 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,避免误判
  258 + // 1.2 发起退款没有返回结果、返回失败、或者返回结果未知(例如说,支付宝 fund_change 不为 Y)时,查询一次渠道退款单,避免误判
145 259 // 关联知识星球帖子:https://t.zsxq.com/JRLye、https://t.zsxq.com/gDETH
146   - if (refundRespDTO == null || PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) {
  260 + if (refundRespDTO == null || PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())
  261 + || isRefundUnknown(refundRespDTO)) {
147 262 refundRespDTO = getRefundAfterUnifiedRefund(client, refund, refundRespDTO);
148 263 }
149 264  
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   - // 特殊:如果已被并发更新为成功,则以最新状态为准,避免旧的失败结果误报
  265 + // 2. 通知退款结果
  266 + if (refundRespDTO == null) {
  267 + return;
  268 + }
  269 + if (PayRefundStatusEnum.isFailure(refundRespDTO.getStatus())) {
  270 + // 2.1 情况一:退款失败
  271 + // 特殊:重新发起结果未知的退款单时,本次返回失败(例如说,渠道对重复请求的报错),不能证明原退款请求失败,
  272 + // 所以不更新为退款失败、不通知业务,保留 WAITING 交给退款回调、退款轮询、或者人工核对后再次发起
  273 + if (retryWaiting) {
  274 + log.warn("[invokeRefund][退款编号({}) 重新发起结果未知的退款单,返回失败({}),保留 WAITING]",
  275 + refund.getId(), refundRespDTO);
  276 + // 特殊:如果已被并发更新为成功(例如说,退款回调),则以最新状态为准,避免误报
  277 + PayRefundDO latestRefund = refundMapper.selectLatestById(refund.getId());
165 278 if (StrUtil.isNotEmpty(refundRespDTO.getChannelErrorCode())
166   - && (latestRefund == null || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) {
  279 + && (latestRefund == null || !PayRefundStatusEnum.isSuccess(latestRefund.getStatus()))) {
167 280 throw exception(REFUND_SUBMIT_CHANNEL_ERROR, refundRespDTO.getChannelErrorCode(),
168 281 refundRespDTO.getChannelErrorMsg());
169 282 }
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   - }
  283 + return;
  284 + }
  285 + PayRefundDO latestRefund = null;
  286 + try {
  287 + getSelf().notifyRefund(channel, refundRespDTO, refund.getUpdateTime());
  288 + } catch (ServiceException ex) {
  289 + // 由于退款回调、退款轮询可能同时更新退款单,导致存在并发更新问题,此时以最新的退款状态为准
  290 + latestRefund = validateRefundNotifyConcurrent(refund.getId(), ex);
  291 + log.warn("[invokeRefund][refund({}) channel({}) 退款结果({}) 通知时发生并发更新,最新状态为({})]",
  292 + refund.getId(), channel.getId(), refundRespDTO, latestRefund.getStatus(), ex);
  293 + }
  294 + // 如有渠道错误码,则抛出业务异常,提示用户
  295 + // 特殊:如果已被并发更新为成功,则以最新状态为准,避免旧的失败结果误报
  296 + if (StrUtil.isNotEmpty(refundRespDTO.getChannelErrorCode())
  297 + && (latestRefund == null || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) {
  298 + throw exception(REFUND_SUBMIT_CHANNEL_ERROR, refundRespDTO.getChannelErrorCode(),
  299 + refundRespDTO.getChannelErrorMsg());
  300 + }
  301 + } else {
  302 + // 2.2 情况二:退款成功、或者退款中
  303 + // 注意:这里仅打印异常,不进行抛出。原因是:渠道可能已经退款成功,抛出会导致调用方回滚,后续通过退款回调、或者退款轮询补偿
  304 + try {
  305 + getSelf().notifyRefund(channel, refundRespDTO);
  306 + } catch (Throwable e) {
  307 + log.error("[invokeRefund][退款编号({}) 退款结果({}) 通知时发生异常]", refund.getId(), refundRespDTO, e);
178 308 }
179 309 }
180   - return refund.getId();
181 310 }
182 311  
183 312 /**
... ... @@ -186,7 +315,7 @@ public class PayRefundServiceImpl implements PayRefundService {
186 315 * @param reqDTO 退款申请信息
187 316 * @param app 支付应用
188 317 * @param order 支付订单
189   - * @return 退款单
  318 + * @return 退款单;重新查询的最新退款单,作为本次退款尝试的快照
190 319 */
191 320 private PayRefundDO createOrRetryRefund(PayRefundCreateReqDTO reqDTO, PayAppDO app, PayOrderDO order) {
192 321 // 1. 校验退款单是否可以发起:不存在,或者之前退款失败
... ... @@ -209,22 +338,26 @@ public class PayRefundServiceImpl implements PayRefundService {
209 338 } else {
210 339 // 2.2 情况二:存在退款失败的退款单,则复用原退款单(包括原退款单号)重新发起,并清空上一次退款的渠道结果
211 340 // 原因是:微信、支付宝等渠道要求退款失败后重新提交时,使用原商户退款单号,避免上一次实际受理时重复退款。未校验一致的请求字段,以本次请求为准
  341 + PayRefundDO updateObj = new PayRefundDO().setStatus(PayRefundStatusEnum.WAITING.getStatus())
  342 + .setReason(reqDTO.getReason()).setUserIp(reqDTO.getUserIp())
  343 + .setNotifyUrl(app.getRefundNotifyUrl());
  344 + // 注意:update_time 需要大于退款失败时的 update_time(至少 1 秒,兼容 datetime 秒级精度),用于区分新旧退款尝试,
  345 + // 避免旧的查询结果(例如说,退款轮询查询到的退款失败),更新新的退款尝试,详见 notifyRefund 方法的 waitingUpdateTime 参数
  346 + updateObj.setUpdateTime(DateUtils.max(LocalDateTime.now(), refund.getUpdateTime().plusSeconds(1)));
212 347 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()));
  348 + updateObj);
216 349 if (updateCount == 0) { // 校验状态,避免并发重新发起退款
217 350 throw exception(REFUND_HAS_REFUNDING);
218 351 }
219 352 }
220   - return refund;
  353 + return refundMapper.selectById(refund.getId());
221 354 }
222 355  
223 356 /**
224   - * 发起退款没有返回结果、或者返回失败时,查询一次渠道退款单,确认退款结果
  357 + * 发起退款没有返回结果、返回失败、或者返回结果未知时,查询一次渠道退款单,确认退款结果
225 358 *
226 359 * 注意:只有查询到渠道已受理退款,才以查询结果为准;查询到的退款失败,不作为最终结果。
227   - * 原因是:一次查询可能存在渠道退款单尚未生成、或者结果尚未同步的情况,例如说微信、钱包查询不存在的退款单时返回失败
  360 + * 原因是:一次查询可能存在渠道退款单尚未生成、或者结果尚未同步的情况,例如说微信查询不存在的退款单
228 361 *
229 362 * @param client 支付客户端
230 363 * @param refund 退款单
... ... @@ -239,18 +372,40 @@ public class PayRefundServiceImpl implements PayRefundService {
239 372 log.warn("[getRefundAfterUnifiedRefund][退款编号({}) 查询渠道退款单发生异常]", refund.getId(), e);
240 373 }
241 374 // 情况一:查询到退款成功、或者已有渠道退款单号的退款中,说明渠道实际已受理退款,以查询结果为准
242   - if (queryRespDTO != null && (PayRefundStatusEnum.isSuccess(queryRespDTO.getStatus())
243   - || (PayRefundStatusEnum.WAITING.getStatus().equals(queryRespDTO.getStatus())
244   - && StrUtil.isNotEmpty(queryRespDTO.getChannelRefundNo())))) {
  375 + if (isRefundAccepted(queryRespDTO)) {
245 376 return queryRespDTO;
246 377 }
247 378 // 情况二:其它情况,以发起退款的结果为准
248 379 // ① 发起退款发生异常:返回 null,保留 WAITING 交给退款回调、或者退款轮询补偿
249   - // ② 发起退款明确返回失败:以渠道明确返回的失败为准。原因是:支付宝查询不存在的退款单时返回 WAITING、钱包查询 WAITING 退款单时抛出异常,退款轮询无法补偿
  380 + // ② 发起退款返回结果未知:保留 WAITING 交给退款回调、退款轮询、或者商户使用原退款单号重新发起
  381 + // ③ 发起退款明确返回失败:以渠道明确返回的失败为准。原因是:查询不存在的退款单时(例如说,微信、支付宝)返回结果未知,退款轮询无法补偿
250 382 return unifiedRespDTO;
251 383 }
252 384  
253 385 /**
  386 + * 是否渠道已受理退款:退款成功、或者已有渠道退款单号的退款中
  387 + *
  388 + * @param respDTO 退款结果
  389 + * @return 是否已受理
  390 + */
  391 + private static boolean isRefundAccepted(PayRefundRespDTO respDTO) {
  392 + return respDTO != null && (PayRefundStatusEnum.isSuccess(respDTO.getStatus())
  393 + || (PayRefundStatusEnum.WAITING.getStatus().equals(respDTO.getStatus())
  394 + && StrUtil.isNotEmpty(respDTO.getChannelRefundNo())));
  395 + }
  396 +
  397 + /**
  398 + * 是否退款结果未知:退款中,但没有渠道退款单号,例如说,支付宝 fund_change 不为 Y、或者查询不到退款单
  399 + *
  400 + * @param respDTO 退款结果
  401 + * @return 是否未知
  402 + */
  403 + private static boolean isRefundUnknown(PayRefundRespDTO respDTO) {
  404 + return PayRefundStatusEnum.WAITING.getStatus().equals(respDTO.getStatus())
  405 + && StrUtil.isEmpty(respDTO.getChannelRefundNo());
  406 + }
  407 +
  408 + /**
254 409 * 校验退款结果通知时的并发更新
255 410 *
256 411 * 只有状态竞争导致的通知失败,并且退款单已被并发更新为成功或失败时,才返回最新的退款单;
... ... @@ -266,7 +421,7 @@ public class PayRefundServiceImpl implements PayRefundService {
266 421 throw ex;
267 422 }
268 423 // 2. 校验最新状态为成功或失败
269   - PayRefundDO latestRefund = refundMapper.selectById(id);
  424 + PayRefundDO latestRefund = refundMapper.selectLatestById(id);
270 425 if (latestRefund == null || !(PayRefundStatusEnum.isSuccess(latestRefund.getStatus())
271 426 || PayRefundStatusEnum.isFailure(latestRefund.getStatus()))) {
272 427 throw ex;
... ... @@ -320,13 +475,24 @@ public class PayRefundServiceImpl implements PayRefundService {
320 475 throw exception(REFUND_EXISTS);
321 476 }
322 477 // 校验参数是否一致
  478 + validateRefundConsistent(refund, reqDTO, order);
  479 + return refund;
  480 + }
  481 +
  482 + /**
  483 + * 校验再次发起退款时,原退款单与本次请求的支付订单、退款金额是否一致
  484 + *
  485 + * @param refund 原退款单
  486 + * @param reqDTO 退款申请信息
  487 + * @param order 支付订单
  488 + */
  489 + private void validateRefundConsistent(PayRefundDO refund, PayRefundCreateReqDTO reqDTO, PayOrderDO order) {
323 490 if (ObjectUtil.notEqual(refund.getOrderId(), order.getId())) {
324 491 throw exception(REFUND_CREATE_FAIL_ORDER_NOT_MATCH);
325 492 }
326 493 if (ObjectUtil.notEqual(refund.getRefundPrice(), reqDTO.getPrice())) {
327 494 throw exception(REFUND_CREATE_FAIL_PRICE_NOT_MATCH);
328 495 }
329   - return refund;
330 496 }
331 497  
332 498 /**
... ... @@ -356,6 +522,21 @@ public class PayRefundServiceImpl implements PayRefundService {
356 522 // 注意,如果是方法内调用该方法,需要通过 getSelf().notifyRefund(channel, notify) 调用,否则事务不生效
357 523 @Transactional(rollbackFor = Exception.class)
358 524 public void notifyRefund(PayChannelDO channel, PayRefundRespDTO notify) {
  525 + notifyRefund(channel, notify, null);
  526 + }
  527 +
  528 + /**
  529 + * 通知并更新订单的退款结果
  530 + *
  531 + * @param channel 支付渠道
  532 + * @param notify 通知
  533 + * @param waitingUpdateTime 查询(发起)退款前,待退款的退款单的更新时间;非空时,只有退款单的更新时间未变化,才更新为退款失败。
  534 + * 原因是:退款失败后,可以使用原退款单号重新发起(新的退款尝试),旧的退款失败结果不能关闭新的退款尝试。
  535 + * 退款成功不需要校验:同一退款单号的退款成功,与哪次退款尝试无关
  536 + */
  537 + // 注意,如果是方法内调用该方法,需要通过 getSelf().notifyRefund(channel, notify, waitingUpdateTime) 调用,否则事务不生效
  538 + @Transactional(rollbackFor = Exception.class)
  539 + public void notifyRefund(PayChannelDO channel, PayRefundRespDTO notify, LocalDateTime waitingUpdateTime) {
359 540 // 情况一:退款成功
360 541 if (PayRefundStatusEnum.isSuccess(notify.getStatus())) {
361 542 notifyRefundSuccess(channel, notify);
... ... @@ -363,7 +544,7 @@ public class PayRefundServiceImpl implements PayRefundService {
363 544 }
364 545 // 情况二:退款失败
365 546 if (PayRefundStatusEnum.isFailure(notify.getStatus())) {
366   - notifyRefundFailure(channel, notify);
  547 + notifyRefundFailure(channel, notify, waitingUpdateTime);
367 548 return;
368 549 }
369 550 // 情况三:退款中
... ... @@ -390,9 +571,13 @@ public class PayRefundServiceImpl implements PayRefundService {
390 571 return;
391 572 }
392 573 // 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) { // 已被并发更新为最终状态,以最新状态为准
  574 + // 注意:update_time 作为退款尝试的令牌,不能回退,并且校验读后未被并发更新,详见 notifyRefundFailure 方法
  575 + PayRefundDO updateRefundObj = new PayRefundDO()
  576 + .setChannelRefundNo(notify.getChannelRefundNo()).setChannelNotifyData(toJsonString(notify));
  577 + updateRefundObj.setUpdateTime(DateUtils.max(LocalDateTime.now(), refund.getUpdateTime()));
  578 + int updateCounts = refundMapper.updateByIdAndStatusAndUpdateTime(refund.getId(), refund.getStatus(),
  579 + refund.getUpdateTime(), updateRefundObj);
  580 + if (updateCounts == 0) { // 已被并发更新(例如说,最终状态、占位重新发起),以最新状态为准
396 581 log.info("[notifyRefundWaiting][退款订单({}) 已被并发更新,无需更新渠道退款单号]", refund.getId());
397 582 return;
398 583 }
... ... @@ -433,7 +618,7 @@ public class PayRefundServiceImpl implements PayRefundService {
433 618 refund.getId());
434 619 }
435 620  
436   - private void notifyRefundFailure(PayChannelDO channel, PayRefundRespDTO notify) {
  621 + private void notifyRefundFailure(PayChannelDO channel, PayRefundRespDTO notify, LocalDateTime waitingUpdateTime) {
437 622 // 1.1 查询 PayRefundDO
438 623 PayRefundDO refund = refundMapper.selectByAppIdAndNo(
439 624 channel.getAppId(), notify.getOutRefundNo());
... ... @@ -448,13 +633,20 @@ public class PayRefundServiceImpl implements PayRefundService {
448 633 throw exception(REFUND_STATUS_IS_NOT_WAITING);
449 634 }
450 635 // 1.2 更新 PayRefundDO
  636 + // 校验状态,必须是等待状态;并且校验更新时间:如果传入查询(发起)退款前的更新时间,需要是同一次退款尝试,避免关闭重新发起的新退款尝试;
  637 + // 否则,需要是上面查询到的更新时间,避免读后更新期间,退款单被并发更新(例如说,占位重新发起)
  638 + // 注意:update_time 作为退款尝试的令牌,不能回退(重新发起时 update_time 可能大于当前时间),否则同一秒内再次失败、重新发起时,
  639 + // 新的退款尝试会复用旧的令牌,导致旧的退款失败结果关闭新的退款尝试。关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
  640 + LocalDateTime expectedUpdateTime = waitingUpdateTime != null ? waitingUpdateTime : refund.getUpdateTime();
451 641 PayRefundDO updateRefundObj = new PayRefundDO()
452 642 .setChannelRefundNo(notify.getChannelRefundNo())
453 643 .setStatus(PayRefundStatusEnum.FAILURE.getStatus())
454 644 .setChannelNotifyData(toJsonString(notify))
455 645 .setChannelErrorCode(notify.getChannelErrorCode()).setChannelErrorMsg(notify.getChannelErrorMsg());
456   - int updateCounts = refundMapper.updateByIdAndStatus(refund.getId(), refund.getStatus(), updateRefundObj);
457   - if (updateCounts == 0) { // 校验状态,必须是等待状态
  646 + updateRefundObj.setUpdateTime(DateUtils.max(LocalDateTime.now(), expectedUpdateTime));
  647 + int updateCounts = refundMapper.updateByIdAndStatusAndUpdateTime(refund.getId(), refund.getStatus(),
  648 + expectedUpdateTime, updateRefundObj);
  649 + if (updateCounts == 0) {
458 650 throw exception(REFUND_STATUS_IS_NOT_WAITING);
459 651 }
460 652 log.info("[notifyRefundFailure][退款订单({}) 更新为退款失败]", refund.getId());
... ... @@ -495,7 +687,10 @@ public class PayRefundServiceImpl implements PayRefundService {
495 687 }
496 688 PayRefundRespDTO respDTO = payClient.getRefund(refund.getOrderNo(), refund.getNo());
497 689 // 1.2 回调退款结果
498   - notifyRefund(refund.getChannelId(), respDTO);
  690 + // 注意:传入查询前的 updateTime,避免查询期间退款单已失败、并使用原退款单号重新发起时,旧的退款失败结果关闭新的退款尝试
  691 + PayChannelDO channel = channelService.validPayChannel(refund.getChannelId());
  692 + TenantUtils.execute(channel.getTenantId(),
  693 + () -> getSelf().notifyRefund(channel, respDTO, refund.getUpdateTime()));
499 694  
500 695 // 2. 如果同步到,则返回 true
501 696 return PayRefundStatusEnum.isSuccess(respDTO.getStatus())
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletService.java
... ... @@ -50,6 +50,8 @@ public interface PayWalletService {
50 50 /**
51 51 * 钱包订单支付退款
52 52 *
  53 + * 幂等:同一退款单已退款到钱包时,返回原退款流水,不会重复增加余额
  54 + *
53 55 * @param outRefundNo 外部退款号
54 56 * @param refundPrice 退款金额
55 57 * @param reason 退款原因
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceImpl.java
1 1 package cn.iocoder.yudao.module.pay.service.wallet;
2 2  
3 3 import cn.hutool.core.lang.Assert;
  4 +import cn.hutool.core.util.ObjectUtil;
4 5 import cn.hutool.extra.spring.SpringUtil;
5 6 import cn.iocoder.yudao.framework.common.pojo.PageResult;
6 7 import cn.iocoder.yudao.framework.common.util.date.DateUtils;
... ... @@ -20,6 +21,7 @@ import lombok.SneakyThrows;
20 21 import lombok.extern.slf4j.Slf4j;
21 22 import org.springframework.context.annotation.Lazy;
22 23 import org.springframework.stereotype.Service;
  24 +import org.springframework.transaction.annotation.Isolation;
23 25 import org.springframework.transaction.annotation.Propagation;
24 26 import org.springframework.transaction.annotation.Transactional;
25 27  
... ... @@ -121,41 +123,49 @@ public class PayWalletServiceImpl implements PayWalletService {
121 123 }
122 124  
123 125 @Override
124   - @Transactional(rollbackFor = Exception.class)
  126 + // 注意:新建事务时,使用读已提交(READ_COMMITTED),避免下面当前读(FOR UPDATE)不存在的退款流水时,MySQL 可重复读下加间隙锁,
  127 + // 导致不同退款单并发退款到钱包时死锁;加入调用方的已有事务时,沿用调用方的隔离级别,仍由当前读、退款单行锁保证只退款一次
  128 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
  129 + @Transactional(rollbackFor = Exception.class, isolation = Isolation.READ_COMMITTED)
125 130 public PayWalletTransactionDO orderRefund(String outRefundNo, Integer refundPrice, String reason) {
126 131 // 1.1 判断退款单是否存在
127 132 PayRefundDO payRefund = refundService.getRefundByNo(outRefundNo);
128 133 if (payRefund == null) {
129 134 throw exception(REFUND_NOT_FOUND);
130 135 }
131   - // 1.2 校验是否可以退款
132   - Long walletId = validateWalletCanRefund(payRefund.getId(), payRefund.getChannelOrderNo());
133   - PayWalletDO wallet = walletMapper.selectById(walletId);
134   - Assert.notNull(wallet, "钱包 {} 不存在", walletId);
135   -
136   - // 2. 增加余额
137   - return addWalletBalance(walletId, String.valueOf(payRefund.getId()), PAYMENT_REFUND, refundPrice);
138   - }
139   -
140   - /**
141   - * 校验是否能退款
142   - *
143   - * @param refundId 支付退款单 id
144   - * @param walletPayNo 钱包支付 no
145   - */
146   - private Long validateWalletCanRefund(Long refundId, String walletPayNo) {
147   - // 1. 校验钱包支付交易存在
148   - PayWalletTransactionDO walletTransaction = walletTransactionService.getWalletTransactionByNo(walletPayNo);
149   - if (walletTransaction == null) {
150   - throw exception(WALLET_TRANSACTION_NOT_FOUND);
  136 + // 1.2 锁定退款单(FOR UPDATE),锁持有到事务提交,保证同一退款单串行退款到钱包
  137 + // 原因是:同一退款单可能使用原退款单号重新发起(例如说,结果未知后重新发起),新旧调用可能重叠,需要保证只退款到钱包一次
  138 + // 关联 https://github.com/YunaiV/ruoyi-vue-pro/issues/1290
  139 + Long refundId = payRefund.getId();
  140 + payRefund = refundService.getRefundForUpdate(refundId);
  141 + if (payRefund == null) {
  142 + throw exception(REFUND_NOT_FOUND);
151 143 }
152   - // 2. 校验退款是否存在
153   - PayWalletTransactionDO refundTransaction = walletTransactionService.getWalletTransaction(
  144 + // 1.3 已存在退款流水,说明已退款到钱包,直接返回原流水,避免重复退款
  145 + // 注意:使用当前读(FOR UPDATE)查询退款流水,不能使用快照读。原因是:调用方可能存在事务,MySQL 可重复读下,
  146 + // 快照读可能读不到其它事务(例如说,等待退款单锁期间)已提交的退款流水,导致重复退款
  147 + PayWalletTransactionDO refundTransaction = walletTransactionService.getWalletTransactionForUpdate(
154 148 String.valueOf(refundId), PAYMENT_REFUND);
155 149 if (refundTransaction != null) {
156   - throw exception(WALLET_REFUND_EXIST);
  150 + if (ObjectUtil.notEqual(refundTransaction.getPrice(), refundPrice)) {
  151 + log.error("[orderRefund][退款单({}) 已存在退款流水({}),但退款金额({})不一致]",
  152 + refundId, refundTransaction.getId(), refundPrice);
  153 + throw exception(WALLET_REFUND_EXIST);
  154 + }
  155 + return refundTransaction;
157 156 }
158   - return walletTransaction.getWalletId();
  157 + // 1.4 校验钱包支付交易存在
  158 + PayWalletTransactionDO walletTransaction = walletTransactionService.getWalletTransactionByNo(
  159 + payRefund.getChannelOrderNo());
  160 + if (walletTransaction == null) {
  161 + throw exception(WALLET_TRANSACTION_NOT_FOUND);
  162 + }
  163 + Long walletId = walletTransaction.getWalletId();
  164 + PayWalletDO wallet = walletMapper.selectById(walletId);
  165 + Assert.notNull(wallet, "钱包 {} 不存在", walletId);
  166 +
  167 + // 2. 增加余额
  168 + return addWalletBalance(walletId, String.valueOf(refundId), PAYMENT_REFUND, refundPrice);
159 169 }
160 170  
161 171 @Override
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletTransactionService.java
... ... @@ -61,6 +61,18 @@ public interface PayWalletTransactionService {
61 61 PayWalletTransactionDO getWalletTransaction(String bizId, PayWalletBizTypeEnum type);
62 62  
63 63 /**
  64 + * 获取钱包流水,并锁定(FOR UPDATE)
  65 + *
  66 + * 当前读:可以查询到其它事务已提交的钱包流水,不受 MySQL 可重复读下一致性视图的影响。
  67 + * 注意:需要在事务中调用,锁持有到事务提交
  68 + *
  69 + * @param bizId 业务编号
  70 + * @param type 业务类型
  71 + * @return 钱包流水
  72 + */
  73 + PayWalletTransactionDO getWalletTransactionForUpdate(String bizId, PayWalletBizTypeEnum type);
  74 +
  75 + /**
64 76 * 获得钱包流水统计
65 77 *
66 78 * @param userId 用户编号
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletTransactionServiceImpl.java
... ... @@ -85,6 +85,11 @@ public class PayWalletTransactionServiceImpl implements PayWalletTransactionServ
85 85 }
86 86  
87 87 @Override
  88 + public PayWalletTransactionDO getWalletTransactionForUpdate(String bizId, PayWalletBizTypeEnum type) {
  89 + return payWalletTransactionMapper.selectByBizForUpdate(bizId, type.getType());
  90 + }
  91 +
  92 + @Override
88 93 public AppPayWalletTransactionSummaryRespVO getWalletTransactionSummary(Long userId, Integer userType, LocalDateTime[] createTime) {
89 94 PayWalletDO wallet = payWalletService.getOrCreateWallet(userId, userType);
90 95 return new AppPayWalletTransactionSummaryRespVO()
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/alipay/AbstractAlipayClientTest.java
... ... @@ -15,7 +15,9 @@ import com.alipay.api.AlipayApiException;
15 15 import com.alipay.api.DefaultAlipayClient;
16 16 import com.alipay.api.DefaultSigner;
17 17 import com.alipay.api.domain.AlipayTradeRefundModel;
  18 +import com.alipay.api.request.AlipayTradeFastpayRefundQueryRequest;
18 19 import com.alipay.api.request.AlipayTradeRefundRequest;
  20 +import com.alipay.api.response.AlipayTradeFastpayRefundQueryResponse;
19 21 import com.alipay.api.response.AlipayTradeRefundResponse;
20 22 import lombok.Setter;
21 23 import org.junit.jupiter.api.BeforeEach;
... ... @@ -30,6 +32,7 @@ import java.util.Date;
30 32 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
31 33 import static cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.alipay.AlipayPayClientConfig.MODE_PUBLIC_KEY;
32 34 import static org.junit.jupiter.api.Assertions.*;
  35 +import static org.mockito.ArgumentMatchers.any;
33 36 import static org.mockito.ArgumentMatchers.argThat;
34 37 import static org.mockito.Mockito.when;
35 38  
... ... @@ -86,6 +89,7 @@ public abstract class AbstractAlipayClientTest extends BaseMockitoUnitTest {
86 89 AlipayTradeRefundResponse response = randomPojo(AlipayTradeRefundResponse.class, o -> {
87 90 o.setSubCode("");
88 91 o.setGmtRefundPay(refundTime);
  92 + o.setFundChange("Y");
89 93 });
90 94 when(defaultAlipayClient.execute(argThat((ArgumentMatcher<AlipayTradeRefundRequest>) request -> {
91 95 assertInstanceOf(AlipayTradeRefundModel.class, request.getBizModel());
... ... @@ -194,6 +198,128 @@ public abstract class AbstractAlipayClientTest extends BaseMockitoUnitTest {
194 198 }
195 199  
196 200 @Test
  201 + @DisplayName("支付宝 Client 统一退款:fund_change 为 N,结果未知")
  202 + public void testUnifiedRefund_fundChangeN() throws AlipayApiException {
  203 + testUnifiedRefund_fundChangeNotY("N");
  204 + }
  205 +
  206 + @Test
  207 + @DisplayName("支付宝 Client 统一退款:没有返回 fund_change,结果未知")
  208 + public void testUnifiedRefund_fundChangeNull() throws AlipayApiException {
  209 + testUnifiedRefund_fundChangeNotY(null);
  210 + }
  211 +
  212 + private void testUnifiedRefund_fundChangeNotY(String fundChange) throws AlipayApiException {
  213 + // mock 方法:code=10000(没有 sub_code),只代表退款请求成功
  214 + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse();
  215 + response.setCode("10000");
  216 + response.setFundChange(fundChange);
  217 + when(defaultAlipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response);
  218 + // 准备请求参数
  219 + String outRefundNo = randomString();
  220 + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> {
  221 + o.setOutRefundNo(outRefundNo);
  222 + o.setNotifyUrl(randomURL());
  223 + o.setPayPrice(100);
  224 + o.setRefundPrice(10);
  225 + });
  226 +
  227 + // 调用
  228 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO);
  229 + // 断言:返回结果未知(没有渠道退款单号),需要查询退款确认
  230 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  231 + assertEquals(outRefundNo, resp.getOutRefundNo());
  232 + assertNull(resp.getChannelRefundNo());
  233 + assertNull(resp.getSuccessTime());
  234 + }
  235 +
  236 + @Test
  237 + @DisplayName("支付宝 Client 统一退款:系统错误,结果未知")
  238 + public void testUnifiedRefund_systemError() throws AlipayApiException {
  239 + // mock 方法
  240 + AlipayTradeRefundResponse response = new AlipayTradeRefundResponse();
  241 + response.setSubCode("ACQ.SYSTEM_ERROR");
  242 + when(defaultAlipayClient.execute(any(AlipayTradeRefundRequest.class))).thenReturn(response);
  243 + // 准备请求参数
  244 + PayRefundUnifiedReqDTO refundReqDTO = randomPojo(PayRefundUnifiedReqDTO.class, o -> {
  245 + o.setNotifyUrl(randomURL());
  246 + o.setPayPrice(100);
  247 + o.setRefundPrice(10);
  248 + });
  249 +
  250 + // 调用
  251 + PayRefundRespDTO resp = client.unifiedRefund(refundReqDTO);
  252 + // 断言
  253 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  254 + assertNull(resp.getChannelRefundNo());
  255 + }
  256 +
  257 + @Test
  258 + @DisplayName("支付宝 Client 查询退款:退款成功")
  259 + public void testGetRefund_success() throws AlipayApiException {
  260 + // mock 方法
  261 + Date refundTime = randomDate();
  262 + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse();
  263 + response.setCode("10000");
  264 + response.setRefundStatus("REFUND_SUCCESS");
  265 + response.setGmtRefundPay(refundTime);
  266 + when(defaultAlipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response);
  267 +
  268 + // 调用
  269 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  270 + // 断言
  271 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), resp.getStatus());
  272 + assertEquals("R001", resp.getOutRefundNo());
  273 + assertEquals(LocalDateTimeUtil.of(refundTime), resp.getSuccessTime());
  274 + }
  275 +
  276 + @Test
  277 + @DisplayName("支付宝 Client 查询退款:没有退款数据(refund_status),结果未知")
  278 + public void testGetRefund_noRefundStatus() throws AlipayApiException {
  279 + // mock 方法:code=10000,但没有返回 refund_status
  280 + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse();
  281 + response.setCode("10000");
  282 + when(defaultAlipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response);
  283 +
  284 + // 调用
  285 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  286 + // 断言:不能作为退款失败
  287 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  288 + assertEquals("R001", resp.getOutRefundNo());
  289 + assertNull(resp.getChannelRefundNo());
  290 + }
  291 +
  292 + @Test
  293 + @DisplayName("支付宝 Client 查询退款:系统错误,结果未知")
  294 + public void testGetRefund_systemError() throws AlipayApiException {
  295 + // mock 方法
  296 + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse();
  297 + response.setSubCode("ACQ.SYSTEM_ERROR");
  298 + when(defaultAlipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response);
  299 +
  300 + // 调用
  301 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  302 + // 断言
  303 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  304 + assertNull(resp.getChannelRefundNo());
  305 + }
  306 +
  307 + @Test
  308 + @DisplayName("支付宝 Client 查询退款:交易不存在,结果未知")
  309 + public void testGetRefund_tradeNotExist() throws AlipayApiException {
  310 + // mock 方法:交易维度的错误,不是退款单明确失败的状态
  311 + AlipayTradeFastpayRefundQueryResponse response = new AlipayTradeFastpayRefundQueryResponse();
  312 + response.setSubCode("ACQ.TRADE_NOT_EXIST");
  313 + when(defaultAlipayClient.execute(any(AlipayTradeFastpayRefundQueryRequest.class))).thenReturn(response);
  314 +
  315 + // 调用
  316 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  317 + // 断言:不能作为退款失败
  318 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  319 + assertNull(resp.getChannelRefundNo());
  320 + }
  321 +
  322 + @Test
197 323 @DisplayName("支付宝 Client 统一下单:参数校验不通过")
198 324 public void testUnifiedOrder_paramInvalidate() {
199 325 // 准备请求参数
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/wallet/WalletPayClientTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.wallet;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
  5 +import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO;
  6 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
  7 +import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
  8 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO;
  9 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException;
  10 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.NonePayClientConfig;
  11 +import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
  12 +import cn.iocoder.yudao.module.pay.service.wallet.PayWalletService;
  13 +import cn.iocoder.yudao.module.pay.service.wallet.PayWalletTransactionService;
  14 +import org.junit.jupiter.api.BeforeEach;
  15 +import org.junit.jupiter.api.Test;
  16 +import org.mockito.Mock;
  17 +import org.springframework.test.util.ReflectionTestUtils;
  18 +
  19 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  20 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.REFUND_NOT_FOUND;
  21 +import static org.junit.jupiter.api.Assertions.*;
  22 +import static org.mockito.ArgumentMatchers.eq;
  23 +import static org.mockito.Mockito.when;
  24 +
  25 +/**
  26 + * {@link WalletPayClient} 的单元测试类
  27 + *
  28 + * @author 芋道源码
  29 + */
  30 +public class WalletPayClientTest extends BaseMockitoUnitTest {
  31 +
  32 + private WalletPayClient client;
  33 +
  34 + @Mock
  35 + private PayWalletService walletService;
  36 + @Mock
  37 + private PayWalletTransactionService walletTransactionService;
  38 + @Mock
  39 + private PayRefundService refundService;
  40 +
  41 + @BeforeEach
  42 + public void setUp() {
  43 + client = new WalletPayClient(1L, new NonePayClientConfig());
  44 + ReflectionTestUtils.setField(client, "wallService", walletService);
  45 + ReflectionTestUtils.setField(client, "walletTransactionService", walletTransactionService);
  46 + ReflectionTestUtils.setField(client, "refundService", refundService);
  47 + }
  48 +
  49 + @Test // 已退款到钱包,但退款单仍是 WAITING(例如说:退款入账后,更新退款单失败):以钱包退款流水为准,返回退款成功
  50 + public void testGetRefund_waitingWithTransaction() {
  51 + // mock 方法
  52 + PayRefundDO refund = mockRefund(PayRefundStatusEnum.WAITING.getStatus());
  53 + PayWalletTransactionDO transaction = mockRefundTransaction(refund);
  54 +
  55 + // 调用
  56 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  57 + // 断言
  58 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), resp.getStatus());
  59 + assertEquals(transaction.getNo(), resp.getChannelRefundNo());
  60 + assertEquals("R001", resp.getOutRefundNo());
  61 + }
  62 +
  63 + @Test // 已退款到钱包,但退款单是退款失败:以钱包退款流水为准,返回退款成功
  64 + public void testGetRefund_failureWithTransaction() {
  65 + // mock 方法
  66 + PayRefundDO refund = mockRefund(PayRefundStatusEnum.FAILURE.getStatus());
  67 + mockRefundTransaction(refund);
  68 +
  69 + // 调用
  70 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  71 + // 断言
  72 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), resp.getStatus());
  73 + }
  74 +
  75 + @Test // 退款单是 WAITING,钱包退款流水不存在:可能未退款、或者退款事务尚未提交,返回结果未知,不作为退款失败
  76 + public void testGetRefund_waitingWithoutTransaction() {
  77 + // mock 方法
  78 + mockRefund(PayRefundStatusEnum.WAITING.getStatus());
  79 +
  80 + // 调用
  81 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  82 + // 断言
  83 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  84 + assertNull(resp.getChannelRefundNo());
  85 + assertEquals("R001", resp.getOutRefundNo());
  86 + }
  87 +
  88 + @Test // 退款单是退款失败,钱包退款流水不存在:返回退款失败
  89 + public void testGetRefund_failureWithoutTransaction() {
  90 + // mock 方法
  91 + PayRefundDO refund = mockRefund(PayRefundStatusEnum.FAILURE.getStatus());
  92 +
  93 + // 调用
  94 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  95 + // 断言
  96 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), resp.getStatus());
  97 + assertEquals(refund.getChannelErrorCode(), resp.getChannelErrorCode());
  98 + assertEquals(refund.getChannelErrorMsg(), resp.getChannelErrorMsg());
  99 + }
  100 +
  101 + @Test // 退款单是退款成功,但钱包退款流水不存在:无效状态,抛出异常
  102 + public void testGetRefund_successWithoutTransaction() {
  103 + // mock 方法
  104 + mockRefund(PayRefundStatusEnum.SUCCESS.getStatus());
  105 +
  106 + // 调用,并断言异常
  107 + assertThrows(PayClientException.class, () -> client.getRefund("P001", "R001"));
  108 + }
  109 +
  110 + @Test // 退款单不存在:返回退款失败,错误码为数字编码
  111 + public void testGetRefund_refundNotFound() {
  112 + // 调用
  113 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  114 + // 断言
  115 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), resp.getStatus());
  116 + assertEquals(String.valueOf(REFUND_NOT_FOUND.getCode()), resp.getChannelErrorCode());
  117 + assertEquals(REFUND_NOT_FOUND.getMsg(), resp.getChannelErrorMsg());
  118 + }
  119 +
  120 + private PayRefundDO mockRefund(Integer status) {
  121 + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setId(100L).setNo("R001").setStatus(status));
  122 + when(refundService.getRefundByNo(eq("R001"))).thenReturn(refund);
  123 + return refund;
  124 + }
  125 +
  126 + private PayWalletTransactionDO mockRefundTransaction(PayRefundDO refund) {
  127 + PayWalletTransactionDO transaction = randomPojo(PayWalletTransactionDO.class);
  128 + when(walletTransactionService.getWalletTransaction(eq(String.valueOf(refund.getId())),
  129 + eq(PayWalletBizTypeEnum.PAYMENT_REFUND))).thenReturn(transaction);
  130 + return transaction;
  131 + }
  132 +
  133 +}
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClientTest.java
1 1 package cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin;
2 2  
3 3 import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
4 5 import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
  6 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundRespDTO;
  7 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.refund.PayRefundUnifiedReqDTO;
5 8 import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO;
6 9 import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO;
7 10 import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException;
  11 +import com.github.binarywang.wxpay.bean.request.WxPayRefundQueryV3Request;
  12 +import com.github.binarywang.wxpay.bean.request.WxPayRefundV3Request;
  13 +import com.github.binarywang.wxpay.bean.result.WxPayRefundQueryV3Result;
8 14 import com.github.binarywang.wxpay.bean.transfer.TransferBillsGetResult;
9 15 import com.github.binarywang.wxpay.exception.WxPayException;
10 16 import com.github.binarywang.wxpay.service.TransferService;
... ... @@ -18,8 +24,10 @@ import java.util.Map;
18 24  
19 25 import static cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig.API_VERSION_V3;
20 26 import static org.junit.jupiter.api.Assertions.assertEquals;
  27 +import static org.junit.jupiter.api.Assertions.assertNull;
21 28 import static org.junit.jupiter.api.Assertions.assertThrows;
22 29 import static org.mockito.ArgumentMatchers.any;
  30 +import static org.mockito.Mockito.lenient;
23 31 import static org.mockito.Mockito.mock;
24 32 import static org.mockito.Mockito.verify;
25 33 import static org.mockito.Mockito.when;
... ... @@ -32,6 +40,7 @@ import static org.mockito.Mockito.when;
32 40 public class AbstractWxPayClientTest extends BaseMockitoUnitTest {
33 41  
34 42 private WxLitePayClient client;
  43 + private WxPayService wxPayService;
35 44 private TransferService transferService;
36 45  
37 46 @BeforeEach
... ... @@ -40,9 +49,9 @@ public class AbstractWxPayClientTest extends BaseMockitoUnitTest {
40 49 config.setApiVersion(API_VERSION_V3);
41 50 config.setAppId("wx-app-id");
42 51 client = new WxLitePayClient(1L, config);
43   - WxPayService wxPayService = mock(WxPayService.class);
  52 + wxPayService = mock(WxPayService.class);
44 53 transferService = mock(TransferService.class);
45   - when(wxPayService.getTransferService()).thenReturn(transferService);
  54 + lenient().when(wxPayService.getTransferService()).thenReturn(transferService); // 退款相关的测试不使用
46 55 ReflectionTestUtils.setField(client, "client", wxPayService);
47 56 }
48 57  
... ... @@ -83,6 +92,102 @@ public class AbstractWxPayClientTest extends BaseMockitoUnitTest {
83 92 verify(transferService).transferBills(any());
84 93 }
85 94  
  95 + @Test // 发起微信退款发生 IO 异常:退款可能已受理,抛出异常,不误判为退款失败
  96 + public void testUnifiedRefund_ioException() throws Exception {
  97 + // mock 方法
  98 + when(wxPayService.refundV3(any(WxPayRefundV3Request.class))).thenThrow(new WxPayException("timeout",
  99 + new IOException("read timeout")));
  100 +
  101 + // 调用,并断言异常
  102 + assertThrows(PayClientException.class, () -> client.unifiedRefund(buildRefundUnifiedReqDTO()));
  103 + }
  104 +
  105 + @Test // 发起微信退款返回系统错误:退款可能已受理,返回结果未知,不误判为退款失败
  106 + public void testUnifiedRefund_systemError() throws Exception {
  107 + // mock 方法
  108 + when(wxPayService.refundV3(any(WxPayRefundV3Request.class))).thenThrow(WxPayException.newBuilder()
  109 + .errCode("SYSTEM_ERROR").errCodeDes("系统错误").build());
  110 +
  111 + // 调用
  112 + PayRefundRespDTO resp = client.unifiedRefund(buildRefundUnifiedReqDTO());
  113 + // 断言
  114 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  115 + assertNull(resp.getChannelRefundNo());
  116 + assertEquals("R001", resp.getOutRefundNo());
  117 + }
  118 +
  119 + @Test // 发起微信退款返回业务错误(例如说:余额不足):明确的退款失败
  120 + public void testUnifiedRefund_businessError() throws Exception {
  121 + // mock 方法
  122 + when(wxPayService.refundV3(any(WxPayRefundV3Request.class))).thenThrow(WxPayException.newBuilder()
  123 + .errCode("NOT_ENOUGH").errCodeDes("余额不足").build());
  124 +
  125 + // 调用
  126 + PayRefundRespDTO resp = client.unifiedRefund(buildRefundUnifiedReqDTO());
  127 + // 断言
  128 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), resp.getStatus());
  129 + assertEquals("NOT_ENOUGH", resp.getChannelErrorCode());
  130 + assertEquals("余额不足", resp.getChannelErrorMsg());
  131 + }
  132 +
  133 + @Test // 查询微信退款单不存在(例如说:退款请求未受理、或者尚未生成退款单):返回结果未知,不作为退款失败
  134 + public void testGetRefund_notExists() throws Exception {
  135 + // mock 方法
  136 + when(wxPayService.refundQueryV3(any(WxPayRefundQueryV3Request.class))).thenThrow(WxPayException.newBuilder()
  137 + .errCode("RESOURCE_NOT_EXISTS").errCodeDes("退款单不存在").build());
  138 +
  139 + // 调用
  140 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  141 + // 断言
  142 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  143 + assertNull(resp.getChannelRefundNo());
  144 + assertEquals("R001", resp.getOutRefundNo());
  145 + }
  146 +
  147 + @Test // 查询微信退款单处理中:渠道已受理,返回渠道退款单号
  148 + public void testGetRefund_processing() throws Exception {
  149 + // mock 方法
  150 + WxPayRefundQueryV3Result result = new WxPayRefundQueryV3Result();
  151 + result.setStatus("PROCESSING");
  152 + result.setRefundId("WX_REFUND_001");
  153 + when(wxPayService.refundQueryV3(any(WxPayRefundQueryV3Request.class))).thenReturn(result);
  154 +
  155 + // 调用
  156 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  157 + // 断言
  158 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), resp.getStatus());
  159 + assertEquals("WX_REFUND_001", resp.getChannelRefundNo());
  160 + }
  161 +
  162 + @Test // 查询微信退款单已关闭:明确的退款失败
  163 + public void testGetRefund_closed() throws Exception {
  164 + // mock 方法
  165 + WxPayRefundQueryV3Result result = new WxPayRefundQueryV3Result();
  166 + result.setStatus("CLOSED");
  167 + when(wxPayService.refundQueryV3(any(WxPayRefundQueryV3Request.class))).thenReturn(result);
  168 +
  169 + // 调用
  170 + PayRefundRespDTO resp = client.getRefund("P001", "R001");
  171 + // 断言
  172 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), resp.getStatus());
  173 + }
  174 +
  175 + @Test // 查询微信退款单发生其它错误(例如说:系统错误):抛出异常,不作为退款失败
  176 + public void testGetRefund_systemError() throws Exception {
  177 + // mock 方法
  178 + when(wxPayService.refundQueryV3(any(WxPayRefundQueryV3Request.class))).thenThrow(WxPayException.newBuilder()
  179 + .errCode("SYSTEM_ERROR").errCodeDes("系统错误").build());
  180 +
  181 + // 调用,并断言异常
  182 + assertThrows(PayClientException.class, () -> client.getRefund("P001", "R001"));
  183 + }
  184 +
  185 + private static PayRefundUnifiedReqDTO buildRefundUnifiedReqDTO() {
  186 + return new PayRefundUnifiedReqDTO().setOutTradeNo("P001").setOutRefundNo("R001")
  187 + .setReason("测试退款").setPayPrice(100).setRefundPrice(10)
  188 + .setNotifyUrl("http://127.0.0.1/refund");
  189 + }
  190 +
86 191 private static PayTransferUnifiedReqDTO buildTransferUnifiedReqDTO() {
87 192 return new PayTransferUnifiedReqDTO().setUserIp("127.0.0.1").setOutTransferNo("T001")
88 193 .setPrice(100).setSubject("佣金提现").setUserAccount("openid")
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceTest.java
... ... @@ -29,18 +29,29 @@ import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
29 29 import jakarta.annotation.Resource;
30 30 import org.junit.jupiter.api.BeforeEach;
31 31 import org.junit.jupiter.api.Test;
  32 +import org.mockito.InOrder;
32 33 import org.mockito.MockedStatic;
33 34 import org.springframework.context.annotation.Import;
34 35 import org.springframework.test.context.bean.override.mockito.MockitoBean;
35 36 import org.springframework.test.util.AopTestUtils;
36 37 import org.springframework.test.util.ReflectionTestUtils;
37 38 import org.springframework.transaction.PlatformTransactionManager;
  39 +import org.springframework.transaction.TransactionDefinition;
38 40 import org.springframework.transaction.support.TransactionTemplate;
39 41  
40 42 import java.time.LocalDateTime;
41 43 import java.util.ArrayList;
  44 +import java.util.Arrays;
42 45 import java.util.List;
43 46 import java.util.concurrent.CompletableFuture;
  47 +import java.util.concurrent.CountDownLatch;
  48 +import java.util.concurrent.ExecutionException;
  49 +import java.util.concurrent.ExecutorService;
  50 +import java.util.concurrent.Executors;
  51 +import java.util.concurrent.Future;
  52 +import java.util.concurrent.TimeUnit;
  53 +import java.util.concurrent.atomic.AtomicBoolean;
  54 +import java.util.concurrent.atomic.AtomicReference;
44 55  
45 56 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
46 57 import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
... ... @@ -597,6 +608,60 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
597 608 verify(client, times(1)).unifiedRefund(any());
598 609 }
599 610  
  611 + @Test // 并发使用相同的商户退款编号发起退款,锁外都查询不到退款单;前者退款成功后,后者加锁,在锁内重新查询到退款单,只能创建一个退款单
  612 + // 注意:锁内需要重新查询数据库,不能复用锁外查询的结果(例如说,MyBatis 一级缓存)
  613 + public void testCreateRefund_concurrentCreateAfterSuccess() throws Exception {
  614 + // mock 方法(client 退款成功)
  615 + PayClient<?> client = mockPayClient();
  616 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.successOf(
  617 + "CHANNEL_R001", LocalDateTime.now(), ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success"));
  618 + // mock 方法(mapper):两个请求锁外都查询不到退款单后,第一个请求继续;第二个请求等待第一个请求完成后,再继续
  619 + CountDownLatch bothMissing = new CountDownLatch(2);
  620 + CountDownLatch firstFinished = new CountDownLatch(1);
  621 + AtomicReference<Thread> secondThread = new AtomicReference<>();
  622 + ThreadLocal<Boolean> outerQueried = ThreadLocal.withInitial(() -> false);
  623 + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper));
  624 + doAnswer(invocation -> {
  625 + PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(invocation.getArgument(0),
  626 + invocation.getArgument(1));
  627 + if (!outerQueried.get()) { // 锁外的查询
  628 + outerQueried.set(true);
  629 + assertNull(refund);
  630 + bothMissing.countDown();
  631 + assertTrue(bothMissing.await(10, TimeUnit.SECONDS));
  632 + if (Thread.currentThread() == secondThread.get()) {
  633 + assertTrue(firstFinished.await(10, TimeUnit.SECONDS));
  634 + }
  635 + }
  636 + return refund;
  637 + }).when(refundMapperSpy).selectByAppIdAndMerchantRefundId(any(), any());
  638 + Object target = AopTestUtils.getTargetObject(refundService);
  639 + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy);
  640 + ExecutorService executor = Executors.newFixedThreadPool(2);
  641 + Long refundId;
  642 + Throwable exception;
  643 + try {
  644 + // 调用:两个请求并发发起退款
  645 + Future<Long> future01 = executor.submit(() -> refundService.createRefund(buildRefundCreateReqDTO()));
  646 + Future<Long> future02 = executor.submit(() -> {
  647 + secondThread.set(Thread.currentThread());
  648 + return refundService.createRefund(buildRefundCreateReqDTO());
  649 + });
  650 + refundId = future01.get(10, TimeUnit.SECONDS);
  651 + firstFinished.countDown();
  652 + exception = assertThrows(ExecutionException.class, () -> future02.get(10, TimeUnit.SECONDS)).getCause();
  653 + } finally {
  654 + firstFinished.countDown();
  655 + executor.shutdownNow();
  656 + ReflectionTestUtils.setField(target, "refundMapper", refundMapper);
  657 + }
  658 + // 断言:第二个请求提示退款单已存在,只创建一个退款单,只发起一次退款
  659 + assertEquals(REFUND_EXISTS.getCode(), ((ServiceException) exception).getCode());
  660 + assertEquals(1, refundMapper.selectList().size());
  661 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refundMapper.selectById(refundId).getStatus());
  662 + verify(client, times(1)).unifiedRefund(any());
  663 + }
  664 +
600 665 @Test // 调用 unifiedRefund 接口返回失败,但查询确认渠道退款成功
601 666 public void testCreateRefund_invokeFailure_querySuccess() {
602 667 // mock 方法(client)
... ... @@ -647,6 +712,28 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
647 712 verify(notifyService, never()).createPayNotifyTask(any(), any());
648 713 }
649 714  
  715 + @Test // 调用 unifiedRefund 接口返回存在渠道错误,但退款回调已在其它事务中并发更新为成功:需要查询到最新状态,不能命中 MyBatis 一级缓存
  716 + public void testCreateRefund_channelErrorWhenConcurrentSuccessNotify() {
  717 + // mock 方法(client)
  718 + PayClient<?> client = mockPayClient();
  719 + PayChannelDO channel = channelService.validPayChannel(10L);
  720 + mockUnifiedRefundFailure(client);
  721 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  722 + // 模拟:退款回调在其它事务中,并发更新为退款成功
  723 + refundService.notifyRefund(channel, PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(),
  724 + invocation.getArgument(1), "success"));
  725 + return PayRefundRespDTO.failureOf("REFUND_NOT_EXISTS", "退款单不存在", invocation.getArgument(1), "failure");
  726 + });
  727 +
  728 + // 调用:以最新的退款成功为准,不向调用方误报失败
  729 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  730 + // 断言
  731 + PayRefundDO refund = refundMapper.selectById(refundId);
  732 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus());
  733 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  734 + verify(notifyService, times(1)).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId));
  735 + }
  736 +
650 737 @Test // 调用 unifiedRefund 接口返回存在渠道错误,通知时发生状态竞争,但退款单仍是待退款
651 738 public void testCreateRefund_channelErrorWhenConcurrentWaiting() {
652 739 PayRefundServiceImpl payRefundServiceImpl = mock(PayRefundServiceImpl.class);
... ... @@ -654,7 +741,7 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
654 741 springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayRefundServiceImpl.class)))
655 742 .thenReturn(payRefundServiceImpl);
656 743 doThrow(exception(REFUND_STATUS_IS_NOT_WAITING))
657   - .when(payRefundServiceImpl).notifyRefund(any(PayChannelDO.class), any());
  744 + .when(payRefundServiceImpl).notifyRefund(any(PayChannelDO.class), any(), any());
658 745 // mock 方法(client)
659 746 PayClient<?> client = mockPayClient();
660 747 mockUnifiedRefundFailure(client);
... ... @@ -755,6 +842,8 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
755 842 assertNull(refund.getChannelErrorMsg());
756 843 assertNull(refund.getChannelNotifyData());
757 844 verify(client).getRefund(eq(failureRefund.getOrderNo()), eq(failureRefund.getNo()));
  845 + // 断言:update_time 大于退款失败时的 update_time,用于区分新旧退款尝试
  846 + assertTrue(refund.getUpdateTime().isAfter(failureRefund.getUpdateTime()));
758 847 }
759 848  
760 849 @Test // 退款失败后再次发起,但退款单已被并发重新发起
... ... @@ -842,6 +931,345 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
842 931 verify(client, never()).unifiedRefund(any());
843 932 }
844 933  
  934 + @Test // 调用 unifiedRefund 接口返回结果未知(例如说:支付宝 fund_change 不为 Y),查询确认渠道退款成功
  935 + public void testCreateRefund_invokeUnknown_querySuccess() {
  936 + // mock 方法(client)
  937 + PayClient<?> client = mockPayClient();
  938 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  939 + null, ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "unknown"));
  940 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf(null,
  941 + LocalDateTime.now(), invocation.getArgument(1), "success"));
  942 +
  943 + // 调用
  944 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  945 + // 断言:按查询结果更新为退款成功
  946 + PayRefundDO refund = refundMapper.selectById(refundId);
  947 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus());
  948 + verify(client).getRefund(eq(refund.getOrderNo()), eq(refund.getNo()));
  949 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId));
  950 + }
  951 +
  952 + @Test // 调用 unifiedRefund 接口返回结果未知(例如说:支付宝 fund_change 不为 Y),查询也没有退款数据:保留 WAITING,不通知
  953 + public void testCreateRefund_invokeUnknown_queryUnknown() {
  954 + // mock 方法(client)
  955 + PayClient<?> client = mockPayClient();
  956 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  957 + null, ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "unknown"));
  958 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  959 + null, invocation.getArgument(1), "unknown"));
  960 +
  961 + // 调用
  962 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  963 + // 断言
  964 + PayRefundDO refund = refundMapper.selectById(refundId);
  965 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  966 + assertNull(refund.getChannelRefundNo());
  967 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  968 + }
  969 +
  970 + @Test // 结果未知(WAITING)的退款单,使用相同的商户退款编号再次发起:查询不到结果,使用原退款单号、原退款金额重新发起
  971 + public void testCreateRefund_retryWaiting_success() {
  972 + // mock 方法(client)
  973 + PayClient<?> client = mockPayClient();
  974 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  975 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  976 + null, invocation.getArgument(1), "unknown"));
  977 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.successOf(
  978 + "CHANNEL_R001", LocalDateTime.now(), ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "success"));
  979 +
  980 + // 调用
  981 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO().setReason("再次退款"));
  982 + // 断言:复用原退款单,退款成功
  983 + assertEquals(unknownRefund.getId(), refundId);
  984 + assertEquals(1, refundMapper.selectList().size());
  985 + PayRefundDO refund = refundMapper.selectById(refundId);
  986 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus());
  987 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  988 + assertEquals("原退款原因", refund.getReason());
  989 + // 断言:先查询渠道退款单,再使用原退款单号、原退款金额、原退款原因重新发起
  990 + InOrder inOrder = inOrder(client);
  991 + inOrder.verify(client).getRefund(eq(unknownRefund.getOrderNo()), eq(unknownRefund.getNo()));
  992 + inOrder.verify(client).unifiedRefund(argThat(unifiedReqDTO -> unknownRefund.getNo().equals(unifiedReqDTO.getOutRefundNo())
  993 + && Integer.valueOf(9).equals(unifiedReqDTO.getRefundPrice()) && "原退款原因".equals(unifiedReqDTO.getReason())));
  994 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId));
  995 + }
  996 +
  997 + @Test // 结果未知的退款单再次发起,查询到渠道退款成功:同步退款结果,不重新发起
  998 + public void testCreateRefund_retryWaiting_querySuccess() {
  999 + // mock 方法(client)
  1000 + PayClient<?> client = mockPayClient();
  1001 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1002 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.successOf("CHANNEL_R001",
  1003 + LocalDateTime.now(), invocation.getArgument(1), "success"));
  1004 +
  1005 + // 调用
  1006 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  1007 + // 断言
  1008 + assertEquals(unknownRefund.getId(), refundId);
  1009 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refundMapper.selectById(refundId).getStatus());
  1010 + verify(client, never()).unifiedRefund(any());
  1011 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId));
  1012 + }
  1013 +
  1014 + @Test // 结果未知的退款单再次发起,查询到渠道已受理(退款中):保存渠道退款单号,继续等待,不重新发起
  1015 + public void testCreateRefund_retryWaiting_queryProcessing() {
  1016 + // mock 方法(client)
  1017 + PayClient<?> client = mockPayClient();
  1018 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1019 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  1020 + "CHANNEL_R001", invocation.getArgument(1), "processing"));
  1021 +
  1022 + // 调用,并断言异常
  1023 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1024 + REFUND_HAS_REFUNDING);
  1025 + // 断言
  1026 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1027 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1028 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  1029 + verify(client, never()).unifiedRefund(any());
  1030 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1031 + }
  1032 +
  1033 + @Test // 结果未知的退款单再次发起,查询到退款失败:不能证明原退款请求失败,不更新为退款失败,使用原退款单号重新发起;
  1034 + // 渠道明确的退款失败,仍由退款轮询(或者退款回调)收敛为退款失败
  1035 + public void testCreateRefund_retryWaiting_queryFailure() {
  1036 + // mock 方法(client)
  1037 + PayClient<?> client = mockPayClient();
  1038 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1039 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf(
  1040 + "CLOSED", "退款关闭", invocation.getArgument(1), "failure"));
  1041 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时"));
  1042 +
  1043 + // 调用
  1044 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  1045 + // 断言:保留 WAITING,使用原退款单号重新发起,不通知退款失败
  1046 + assertEquals(unknownRefund.getId(), refundId);
  1047 + PayRefundDO refund = refundMapper.selectById(refundId);
  1048 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1049 + assertNull(refund.getChannelErrorCode());
  1050 + verify(client).unifiedRefund(argThat(unifiedReqDTO -> unknownRefund.getNo().equals(unifiedReqDTO.getOutRefundNo())));
  1051 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1052 +
  1053 + // 调用:退款轮询,查询到退款失败(同一次退款尝试)
  1054 + assertEquals(1, refundService.syncRefund());
  1055 + // 断言:更新为退款失败
  1056 + assertRefundFailure("CLOSED");
  1057 + }
  1058 +
  1059 + @Test // 结果未知的退款单再次发起,但已有渠道退款单号(渠道已受理):不查询、不重新发起
  1060 + public void testCreateRefund_retryWaiting_hasChannelRefundNo() {
  1061 + // mock 方法(client)
  1062 + PayClient<?> client = mockPayClient();
  1063 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1064 + refundMapper.updateById(new PayRefundDO().setId(unknownRefund.getId()).setChannelRefundNo("CHANNEL_R001"));
  1065 +
  1066 + // 调用,并断言异常
  1067 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1068 + REFUND_HAS_REFUNDING);
  1069 + // 断言
  1070 + verify(client, never()).getRefund(any(), any());
  1071 + verify(client, never()).unifiedRefund(any());
  1072 + }
  1073 +
  1074 + @Test // 结果未知的退款单再次发起,未超过重新发起的间隔:不重新发起,也不刷新 update_time
  1075 + public void testCreateRefund_retryWaiting_withinInterval() {
  1076 + // mock 方法(client)
  1077 + PayClient<?> client = mockPayClient();
  1078 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusMinutes(1).withNano(0));
  1079 +
  1080 + // 调用,并断言异常
  1081 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1082 + REFUND_HAS_REFUNDING);
  1083 + // 断言
  1084 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1085 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1086 + assertEquals(unknownRefund.getUpdateTime(), refund.getUpdateTime());
  1087 + verify(client).getRefund(eq(unknownRefund.getOrderNo()), eq(unknownRefund.getNo()));
  1088 + verify(client, never()).unifiedRefund(any());
  1089 + }
  1090 +
  1091 + @Test // 结果未知的退款单再次发起,但支付订单不一致
  1092 + public void testCreateRefund_retryWaiting_orderNotMatch() {
  1093 + // mock 方法(client)
  1094 + PayClient<?> client = mockPayClient();
  1095 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1096 + refundMapper.updateById(new PayRefundDO().setId(unknownRefund.getId()).setOrderId(-1L));
  1097 +
  1098 + // 调用,并断言异常
  1099 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1100 + REFUND_CREATE_FAIL_ORDER_NOT_MATCH);
  1101 + // 断言
  1102 + verify(client, never()).getRefund(any(), any());
  1103 + verify(client, never()).unifiedRefund(any());
  1104 + }
  1105 +
  1106 + @Test // 结果未知的退款单再次发起,但退款金额不一致
  1107 + public void testCreateRefund_retryWaiting_priceNotMatch() {
  1108 + // mock 方法(client)
  1109 + PayClient<?> client = mockPayClient();
  1110 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1111 + refundMapper.updateById(new PayRefundDO().setId(unknownRefund.getId()).setRefundPrice(5));
  1112 +
  1113 + // 调用,并断言异常
  1114 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1115 + REFUND_CREATE_FAIL_PRICE_NOT_MATCH);
  1116 + // 断言
  1117 + verify(client, never()).getRefund(any(), any());
  1118 + verify(client, never()).unifiedRefund(any());
  1119 + }
  1120 +
  1121 + @Test // 结果未知的退款单重新发起,结果仍未知:保留 WAITING、不通知;占位独立提交,调用方事务回滚不撤销,间隔内不会再次发起
  1122 + public void testCreateRefund_retryWaiting_unknownWhenOuterTransactionRollback() {
  1123 + // mock 方法(client)
  1124 + PayClient<?> client = mockPayClient();
  1125 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1126 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时"));
  1127 + when(client.getRefund(any(), any())).thenThrow(new RuntimeException("模拟查询超时"));
  1128 +
  1129 + // 调用:调用方事务中再次发起,随后调用方事务回滚
  1130 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
  1131 + transactionTemplate.executeWithoutResult(status -> {
  1132 + refundService.createRefund(buildRefundCreateReqDTO());
  1133 + status.setRollbackOnly();
  1134 + });
  1135 + // 断言:保留 WAITING,不更新为退款失败、不通知
  1136 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1137 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1138 + assertNull(refund.getChannelErrorCode());
  1139 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1140 + // 断言:已刷新 update_time 占位,不受调用方事务回滚影响;使用原退款单号重新发起
  1141 + assertTrue(refund.getUpdateTime().isAfter(LocalDateTime.now().minusMinutes(1)));
  1142 + verify(client).unifiedRefund(argThat(unifiedReqDTO -> unknownRefund.getNo().equals(unifiedReqDTO.getOutRefundNo())));
  1143 +
  1144 + // 调用:间隔内再次发起,并断言异常
  1145 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1146 + REFUND_HAS_REFUNDING);
  1147 + // 断言:不会再次向渠道发起退款
  1148 + verify(client, times(1)).unifiedRefund(any());
  1149 + }
  1150 +
  1151 + @Test // 结果未知的退款单重新发起,渠道返回失败:不能证明原退款请求失败,保留 WAITING、不通知,提示渠道错误
  1152 + public void testCreateRefund_retryWaiting_resendFailure() {
  1153 + // mock 方法(client)
  1154 + PayClient<?> client = mockPayClient();
  1155 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1156 + mockUnifiedRefundFailure(client);
  1157 +
  1158 + // 调用,并断言异常
  1159 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1160 + REFUND_SUBMIT_CHANNEL_ERROR, "BALANCE_NOT_ENOUGH", "余额不足");
  1161 + // 断言
  1162 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1163 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1164 + assertNull(refund.getChannelErrorCode());
  1165 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1166 + }
  1167 +
  1168 + @Test // 结果未知的退款单重新发起,渠道返回失败,但退款回调已并发更新为退款成功:以最新状态为准,不提示渠道错误
  1169 + public void testCreateRefund_retryWaiting_resendFailureWhenConcurrentSuccess() {
  1170 + // mock 方法(client)
  1171 + PayClient<?> client = mockPayClient();
  1172 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1173 + PayChannelDO channel = channelService.validPayChannel(10L);
  1174 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> {
  1175 + // 模拟:原退款请求的退款回调,并发更新为退款成功
  1176 + refundService.notifyRefund(channel, PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(),
  1177 + unknownRefund.getNo(), "success"));
  1178 + return PayRefundRespDTO.failureOf("INVALID_REQUEST", "退款单号重复",
  1179 + ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "failure");
  1180 + });
  1181 +
  1182 + // 调用
  1183 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  1184 + // 断言
  1185 + assertEquals(unknownRefund.getId(), refundId);
  1186 + PayRefundDO refund = refundMapper.selectById(refundId);
  1187 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refund.getStatus());
  1188 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  1189 + verify(notifyService, times(1)).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refundId));
  1190 + }
  1191 +
  1192 + @Test // 结果未知的退款单再次发起,查询后、占位前,退款回调写入渠道退款单号:占位失败,不重新发起,保留渠道退款单号
  1193 + public void testCreateRefund_retryWaiting_concurrentCallbackWaiting() {
  1194 + // mock 方法(client)
  1195 + PayClient<?> client = mockPayClient();
  1196 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1197 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  1198 + // 模拟:退款回调并发写入渠道退款单号。保持原 update_time,只验证渠道退款单号的占位条件
  1199 + PayRefundDO updateObj = new PayRefundDO().setId(unknownRefund.getId()).setChannelRefundNo("CHANNEL_R001");
  1200 + updateObj.setUpdateTime(unknownRefund.getUpdateTime());
  1201 + refundMapper.updateById(updateObj);
  1202 + return PayRefundRespDTO.waitingOf(null, invocation.getArgument(1), "unknown");
  1203 + });
  1204 +
  1205 + // 调用,并断言异常
  1206 + assertServiceException(() -> refundService.createRefund(buildRefundCreateReqDTO()),
  1207 + REFUND_HAS_REFUNDING);
  1208 + // 断言:不清空渠道退款单号,不刷新 update_time
  1209 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1210 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1211 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  1212 + assertEquals(unknownRefund.getUpdateTime(), refund.getUpdateTime());
  1213 + verify(client, never()).unifiedRefund(any());
  1214 + }
  1215 +
  1216 + @Test // 结果未知的退款单再次发起,查询后、占位前,退款回调更新为退款成功:占位失败,以最新的退款成功为准
  1217 + public void testCreateRefund_retryWaiting_concurrentCallbackSuccess() {
  1218 + // mock 方法(client)
  1219 + PayClient<?> client = mockPayClient();
  1220 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1221 + PayChannelDO channel = channelService.validPayChannel(10L);
  1222 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  1223 + // 模拟:退款回调并发更新为退款成功
  1224 + refundService.notifyRefund(channel, PayRefundRespDTO.successOf("CHANNEL_R001", LocalDateTime.now(),
  1225 + unknownRefund.getNo(), "success"));
  1226 + return PayRefundRespDTO.waitingOf(null, invocation.getArgument(1), "unknown");
  1227 + });
  1228 +
  1229 + // 调用
  1230 + Long refundId = refundService.createRefund(buildRefundCreateReqDTO());
  1231 + // 断言
  1232 + assertEquals(unknownRefund.getId(), refundId);
  1233 + assertEquals(PayRefundStatusEnum.SUCCESS.getStatus(), refundMapper.selectById(refundId).getStatus());
  1234 + verify(client, never()).unifiedRefund(any());
  1235 + }
  1236 +
  1237 + @Test // 结果未知的退款单,并发再次发起:只有一个请求占位成功、重新发起,避免重复发起
  1238 + public void testCreateRefund_retryWaiting_concurrentRetry() throws Exception {
  1239 + // mock 方法(client):两个请求都查询完成后,再继续占位,放大并发窗口
  1240 + PayClient<?> client = mockPayClient();
  1241 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1242 + CountDownLatch queryLatch = new CountDownLatch(2);
  1243 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  1244 + queryLatch.countDown();
  1245 + queryLatch.await(5, TimeUnit.SECONDS);
  1246 + return PayRefundRespDTO.waitingOf(null, invocation.getArgument(1), "unknown");
  1247 + });
  1248 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenAnswer(invocation -> PayRefundRespDTO.waitingOf(
  1249 + "CHANNEL_R001", ((PayRefundUnifiedReqDTO) invocation.getArgument(0)).getOutRefundNo(), "processing"));
  1250 +
  1251 + // 调用:两个请求并发再次发起
  1252 + List<CompletableFuture<Long>> futures = Arrays.asList(
  1253 + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO())),
  1254 + CompletableFuture.supplyAsync(() -> refundService.createRefund(buildRefundCreateReqDTO())));
  1255 + List<Throwable> exceptions = new ArrayList<>();
  1256 + for (CompletableFuture<Long> future : futures) {
  1257 + try {
  1258 + future.get();
  1259 + } catch (Exception ex) {
  1260 + exceptions.add(ex.getCause());
  1261 + }
  1262 + }
  1263 + // 断言:只有一个请求重新发起,另一个请求提示退款处理中
  1264 + assertEquals(1, exceptions.size());
  1265 + assertEquals(REFUND_HAS_REFUNDING.getCode(), ((ServiceException) exceptions.get(0)).getCode());
  1266 + verify(client, times(2)).getRefund(any(), any());
  1267 + verify(client, times(1)).unifiedRefund(any());
  1268 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1269 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1270 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  1271 + }
  1272 +
845 1273 @Test
846 1274 public void testCreateRefund_invokeSuccess() {
847 1275 PayRefundServiceImpl payRefundServiceImpl = mock(PayRefundServiceImpl.class);
... ... @@ -1157,6 +1585,160 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
1157 1585 assertEquals(count, 0);
1158 1586 }
1159 1587  
  1588 + @Test // 退款轮询:查询到退款失败,并且查询期间退款单未变化(同一次退款尝试),更新为退款失败
  1589 + public void testSyncRefund_failureWhenSameAttempt() {
  1590 + // mock 方法(client)
  1591 + PayClient<?> client = mockPayClient();
  1592 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1593 + when(client.getRefund(any(), any())).thenAnswer(invocation -> PayRefundRespDTO.failureOf(
  1594 + "CLOSED", "退款关闭", invocation.getArgument(1), "failure"));
  1595 +
  1596 + // 调用
  1597 + int count = refundService.syncRefund();
  1598 + // 断言
  1599 + assertEquals(1, count);
  1600 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1601 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refund.getStatus());
  1602 + assertEquals("CLOSED", refund.getChannelErrorCode());
  1603 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refund.getId()));
  1604 + }
  1605 +
  1606 + @Test // 退款轮询:查询期间,退款单已被重新发起(新的退款尝试),旧的查询结果(退款失败)不能关闭新的退款尝试
  1607 + public void testSyncRefund_failureWhenRetried() {
  1608 + // mock 方法(client)
  1609 + PayClient<?> client = mockPayClient();
  1610 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1611 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  1612 + // 模拟:查询期间,商户再次发起,占位后使用原退款单号重新发起
  1613 + assertEquals(1, refundMapper.updateUpdateTimeByIdAndWaiting(unknownRefund.getId(), LocalDateTime.now()));
  1614 + return PayRefundRespDTO.failureOf("CLOSED", "退款关闭", invocation.getArgument(1), "failure");
  1615 + });
  1616 +
  1617 + // 调用
  1618 + int count = refundService.syncRefund();
  1619 + // 断言:保留 WAITING,不通知
  1620 + assertEquals(0, count);
  1621 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1622 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1623 + assertNull(refund.getChannelErrorCode());
  1624 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1625 + }
  1626 +
  1627 + @Test // 退款轮询:查询期间,同一秒内退款失败、再次重新发起,旧的查询结果(退款失败)不能关闭新的退款尝试
  1628 + public void testSyncRefund_failureWhenRetriedInSameSecond() {
  1629 + // mock 方法(client)
  1630 + PayClient<?> client = mockPayClient();
  1631 + PayChannelDO channel = channelService.validPayChannel(10L);
  1632 + // 准备参数:已存在退款失败的退款单
  1633 + LocalDateTime now = LocalDateTime.now().withNano(0);
  1634 + PayRefundDO failureRefund = insertFailureRefund(9);
  1635 + PayRefundDO updateObj = new PayRefundDO().setId(failureRefund.getId()).setChannelId(10L);
  1636 + updateObj.setUpdateTime(now);
  1637 + refundMapper.updateById(updateObj);
  1638 + // mock 方法(client 发起退款发生异常;轮询查询期间,第一次重新发起的退款失败回调到达,并且商户在同一秒内第二次重新发起)
  1639 + when(client.unifiedRefund(any(PayRefundUnifiedReqDTO.class))).thenThrow(new RuntimeException("模拟网络超时"));
  1640 + AtomicBoolean polling = new AtomicBoolean(false);
  1641 + when(client.getRefund(any(), any())).thenAnswer(invocation -> {
  1642 + if (!polling.compareAndSet(true, false)) {
  1643 + return PayRefundRespDTO.waitingOf(null, invocation.getArgument(1), "unknown");
  1644 + }
  1645 + refundService.notifyRefund(channel, PayRefundRespDTO.failureOf("CLOSED", "第一次重新发起失败",
  1646 + invocation.getArgument(1), "failure"));
  1647 + assertEquals(failureRefund.getId(), refundService.createRefund(buildRefundCreateReqDTO()));
  1648 + return PayRefundRespDTO.failureOf("CLOSED", "第一次重新发起失败", invocation.getArgument(1), "failure");
  1649 + });
  1650 +
  1651 + // 固定当前时间,模拟同一秒内发生
  1652 + try (MockedStatic<LocalDateTime> clock = mockStatic(LocalDateTime.class, CALLS_REAL_METHODS)) {
  1653 + clock.when(LocalDateTime::now).thenReturn(now);
  1654 + // 调用:第一次重新发起,结果未知
  1655 + assertEquals(failureRefund.getId(), refundService.createRefund(buildRefundCreateReqDTO()));
  1656 + LocalDateTime firstUpdateTime = refundMapper.selectById(failureRefund.getId()).getUpdateTime();
  1657 + // 调用:退款轮询
  1658 + polling.set(true);
  1659 + int count = refundService.syncRefund();
  1660 +
  1661 + // 断言:第二次重新发起的退款单,保留 WAITING,并且 update_time 大于第一次重新发起时的 update_time
  1662 + assertFalse(polling.get());
  1663 + assertEquals(0, count);
  1664 + PayRefundDO refund = refundMapper.selectById(failureRefund.getId());
  1665 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1666 + assertNull(refund.getChannelErrorCode());
  1667 + assertTrue(refund.getUpdateTime().isAfter(firstUpdateTime));
  1668 + // 断言:只通知第一次重新发起的退款失败
  1669 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.REFUND.getType()), eq(refund.getId()));
  1670 + }
  1671 + }
  1672 +
  1673 + @Test // 退款回调:读取退款单后、更新前,退款单已被重新发起占位,不能关闭新的退款尝试
  1674 + public void testNotifyRefundFailure_concurrentRetry() {
  1675 + // mock 方法(client)
  1676 + mockPayClient();
  1677 + PayChannelDO channel = channelService.validPayChannel(10L);
  1678 + PayRefundDO unknownRefund = insertUnknownRefund(LocalDateTime.now().minusHours(1).withNano(0));
  1679 + // mock 方法(mapper):读取退款单后,其它请求(独立事务)占位重新发起
  1680 + PayRefundMapper refundMapperSpy = mock(PayRefundMapper.class, delegatesTo(refundMapper));
  1681 + TransactionTemplate otherTransaction = new TransactionTemplate(transactionManager);
  1682 + otherTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
  1683 + doAnswer(invocation -> {
  1684 + PayRefundDO refund = refundMapper.selectByAppIdAndNo(invocation.getArgument(0), invocation.getArgument(1));
  1685 + Integer updateCount = otherTransaction.execute(status ->
  1686 + refundMapper.updateUpdateTimeByIdAndWaiting(unknownRefund.getId(), LocalDateTime.now()));
  1687 + assertEquals(1, updateCount);
  1688 + return refund;
  1689 + }).when(refundMapperSpy).selectByAppIdAndNo(any(), any());
  1690 + Object target = AopTestUtils.getTargetObject(refundService);
  1691 + ReflectionTestUtils.setField(target, "refundMapper", refundMapperSpy);
  1692 + try {
  1693 + // 调用,并断言异常:渠道会重试退款回调
  1694 + assertServiceException(() -> refundService.notifyRefund(channel, PayRefundRespDTO.failureOf("CLOSED",
  1695 + "退款关闭", unknownRefund.getNo(), "failure")), REFUND_STATUS_IS_NOT_WAITING);
  1696 + } finally {
  1697 + ReflectionTestUtils.setField(target, "refundMapper", refundMapper);
  1698 + }
  1699 + // 断言:保留占位后的 WAITING,不通知
  1700 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1701 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1702 + assertTrue(refund.getUpdateTime().isAfter(unknownRefund.getUpdateTime()));
  1703 + assertNull(refund.getChannelErrorCode());
  1704 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  1705 + }
  1706 +
  1707 + @Test // 退款回调:退款失败时,update_time 作为退款尝试的令牌,不能回退(重新发起时 update_time 可能大于当前时间)
  1708 + public void testNotifyRefundFailure_notRollbackUpdateTime() {
  1709 + // mock 方法(client)
  1710 + mockPayClient();
  1711 + PayChannelDO channel = channelService.validPayChannel(10L);
  1712 + LocalDateTime updateTime = LocalDateTime.now().plusHours(1).withNano(0);
  1713 + PayRefundDO unknownRefund = insertUnknownRefund(updateTime);
  1714 +
  1715 + // 调用
  1716 + refundService.notifyRefund(channel, PayRefundRespDTO.failureOf("CLOSED", "退款关闭",
  1717 + unknownRefund.getNo(), "failure"));
  1718 + // 断言
  1719 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1720 + assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refund.getStatus());
  1721 + assertEquals(updateTime, refund.getUpdateTime());
  1722 + }
  1723 +
  1724 + @Test // 退款回调:渠道已受理(退款中)时,update_time 作为退款尝试的令牌,不能回退
  1725 + public void testNotifyRefundWaiting_notRollbackUpdateTime() {
  1726 + // mock 方法(client)
  1727 + mockPayClient();
  1728 + PayChannelDO channel = channelService.validPayChannel(10L);
  1729 + LocalDateTime updateTime = LocalDateTime.now().plusHours(1).withNano(0);
  1730 + PayRefundDO unknownRefund = insertUnknownRefund(updateTime);
  1731 +
  1732 + // 调用
  1733 + refundService.notifyRefund(channel, PayRefundRespDTO.waitingOf("CHANNEL_R001",
  1734 + unknownRefund.getNo(), "processing"));
  1735 + // 断言
  1736 + PayRefundDO refund = refundMapper.selectById(unknownRefund.getId());
  1737 + assertEquals(PayRefundStatusEnum.WAITING.getStatus(), refund.getStatus());
  1738 + assertEquals("CHANNEL_R001", refund.getChannelRefundNo());
  1739 + assertEquals(updateTime, refund.getUpdateTime());
  1740 + }
  1741 +
1160 1742 private PayClient<?> mockPayClient() {
1161 1743 // mock 方法(app)
1162 1744 PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
... ... @@ -1205,6 +1787,21 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
1205 1787 return refund;
1206 1788 }
1207 1789  
  1790 + /**
  1791 + * 插入结果未知(WAITING,并且没有渠道退款单号)的退款单,关联 {@link #mockPayClient()} 的支付订单
  1792 + */
  1793 + private PayRefundDO insertUnknownRefund(LocalDateTime updateTime) {
  1794 + PayOrderDO order = orderService.getOrder(1L, "100");
  1795 + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setAppId(1L).setMerchantRefundId("200")
  1796 + .setOrderId(order.getId()).setOrderNo(order.getNo()).setChannelId(10L)
  1797 + .setRefundPrice(9).setReason("原退款原因")
  1798 + .setStatus(PayRefundStatusEnum.WAITING.getStatus()).setChannelRefundNo(null).setSuccessTime(null)
  1799 + .setChannelErrorCode(null).setChannelErrorMsg(null));
  1800 + refund.setUpdateTime(updateTime);
  1801 + refundMapper.insert(refund);
  1802 + return refund;
  1803 + }
  1804 +
1208 1805 private void assertRefundFailure(String channelErrorCode) {
1209 1806 PayRefundDO refund = refundMapper.selectByAppIdAndMerchantRefundId(1L, "200");
1210 1807 assertEquals(PayRefundStatusEnum.FAILURE.getStatus(), refund.getStatus());
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletServiceTest.java
1 1 package cn.iocoder.yudao.module.pay.service.wallet;
2 2  
3 3 import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest;
  4 +import cn.iocoder.yudao.module.pay.dal.dataobject.refund.PayRefundDO;
4 5 import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletDO;
5 6 import cn.iocoder.yudao.module.pay.dal.dataobject.wallet.PayWalletTransactionDO;
  7 +import cn.iocoder.yudao.module.pay.dal.mysql.refund.PayRefundMapper;
6 8 import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletMapper;
  9 +import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletTransactionMapper;
7 10 import cn.iocoder.yudao.module.pay.dal.redis.wallet.PayWalletLockRedisDAO;
  11 +import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
8 12 import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
9 13 import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
10 14 import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
  15 +import cn.iocoder.yudao.module.pay.service.wallet.bo.WalletTransactionCreateReqBO;
11 16 import jakarta.annotation.Resource;
12 17 import org.junit.jupiter.api.Test;
13 18 import org.springframework.context.annotation.Import;
  19 +import org.springframework.jdbc.core.JdbcTemplate;
  20 +import org.springframework.jdbc.datasource.DataSourceUtils;
14 21 import org.springframework.test.context.bean.override.mockito.MockitoBean;
15 22 import org.springframework.transaction.PlatformTransactionManager;
16 23 import org.springframework.transaction.TransactionDefinition;
  24 +import org.springframework.transaction.support.TransactionSynchronizationManager;
17 25 import org.springframework.transaction.support.TransactionTemplate;
18 26  
  27 +import javax.sql.DataSource;
  28 +import java.sql.Connection;
  29 +import java.util.ArrayList;
  30 +import java.util.Arrays;
  31 +import java.util.List;
  32 +import java.util.concurrent.CountDownLatch;
  33 +import java.util.concurrent.ExecutorService;
  34 +import java.util.concurrent.Executors;
  35 +import java.util.concurrent.Future;
  36 +import java.util.concurrent.TimeUnit;
  37 +
  38 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
  39 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
  40 +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
  41 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.REFUND_NOT_FOUND;
  42 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.WALLET_REFUND_EXIST;
19 43 import static org.junit.jupiter.api.Assertions.*;
20 44 import static org.mockito.ArgumentMatchers.any;
21   -import static org.mockito.Mockito.when;
  45 +import static org.mockito.Mockito.*;
22 46  
23 47 /**
24 48 * {@link PayWalletServiceImpl} 的单元测试类
... ... @@ -33,9 +57,15 @@ public class PayWalletServiceTest extends BaseDbAndRedisUnitTest {
33 57  
34 58 @Resource
35 59 private PayWalletMapper walletMapper;
  60 + @Resource
  61 + private PayWalletTransactionMapper walletTransactionMapper;
  62 + @Resource
  63 + private PayRefundMapper refundMapper;
36 64  
37 65 @Resource
38 66 private PlatformTransactionManager transactionManager;
  67 + @Resource
  68 + private DataSource dataSource;
39 69  
40 70 @MockitoBean
41 71 private PayWalletTransactionService walletTransactionService;
... ... @@ -82,4 +112,260 @@ public class PayWalletServiceTest extends BaseDbAndRedisUnitTest {
82 112 assertEquals(100, dbWallet.getBalance());
83 113 }
84 114  
  115 + @Test
  116 + public void testOrderRefund_success() {
  117 + // mock 数据
  118 + PayRefundDO refund = insertWalletRefund();
  119 + // mock 方法
  120 + mockRefundAndWalletTransaction();
  121 +
  122 + // 调用
  123 + PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
  124 + // 断言
  125 + assertEquals(30, transaction.getPrice());
  126 + assertEquals(String.valueOf(refund.getId()), transaction.getBizId());
  127 + assertEquals(PayWalletBizTypeEnum.PAYMENT_REFUND.getType(), transaction.getBizType());
  128 + assertEquals(130, walletMapper.selectById(transaction.getWalletId()).getBalance());
  129 + }
  130 +
  131 + @Test // 已退款到钱包(例如说:退款入账后,更新退款单失败,退款单仍是 WAITING),使用原退款单号重新发起:返回原退款流水,不重复增加余额
  132 + public void testOrderRefund_refundExists() {
  133 + // mock 数据
  134 + PayRefundDO refund = insertWalletRefund();
  135 + PayWalletTransactionDO refundTransaction = insertRefundTransaction(refund, 30);
  136 + // mock 方法
  137 + mockRefundAndWalletTransaction();
  138 +
  139 + // 调用
  140 + PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
  141 + // 断言
  142 + assertEquals(refundTransaction.getId(), transaction.getId());
  143 + assertEquals(100, walletMapper.selectById(refundTransaction.getWalletId()).getBalance());
  144 + verify(walletTransactionService, never()).createWalletTransaction(any());
  145 + }
  146 +
  147 + @Test // 已退款到钱包,但退款金额不一致
  148 + public void testOrderRefund_refundExistsAndPriceNotMatch() {
  149 + // mock 数据
  150 + PayRefundDO refund = insertWalletRefund();
  151 + PayWalletTransactionDO refundTransaction = insertRefundTransaction(refund, 30);
  152 + // mock 方法
  153 + mockRefundAndWalletTransaction();
  154 +
  155 + // 调用,并断言异常
  156 + assertServiceException(() -> walletService.orderRefund(refund.getNo(), 20, "测试退款"),
  157 + WALLET_REFUND_EXIST);
  158 + // 断言
  159 + assertEquals(100, walletMapper.selectById(refundTransaction.getWalletId()).getBalance());
  160 + verify(walletTransactionService, never()).createWalletTransaction(any());
  161 + }
  162 +
  163 + @Test
  164 + public void testOrderRefund_refundNotFound() {
  165 + // mock 方法
  166 + mockRefundAndWalletTransaction();
  167 +
  168 + // 调用,并断言异常
  169 + assertServiceException(() -> walletService.orderRefund("R404", 30, "测试退款"),
  170 + REFUND_NOT_FOUND);
  171 + }
  172 +
  173 + @Test // 调用方存在事务,退款到钱包后调用方事务回滚:退款流水、余额一起回滚,再次发起时只增加一次余额
  174 + public void testOrderRefund_outerTransactionRollback() {
  175 + // mock 数据
  176 + PayRefundDO refund = insertWalletRefund();
  177 + Long walletId = walletTransactionMapper.selectByNo("W001").getWalletId();
  178 + // mock 方法
  179 + mockRefundAndWalletTransaction();
  180 +
  181 + // 调用:调用方事务中退款到钱包,随后调用方事务回滚
  182 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
  183 + transactionTemplate.executeWithoutResult(status -> {
  184 + walletService.orderRefund(refund.getNo(), 30, "测试退款");
  185 + status.setRollbackOnly();
  186 + });
  187 + // 断言:退款流水、余额一起回滚
  188 + assertNull(walletTransactionMapper.selectByBiz(String.valueOf(refund.getId()),
  189 + PayWalletBizTypeEnum.PAYMENT_REFUND.getType()));
  190 + assertEquals(100, walletMapper.selectById(walletId).getBalance());
  191 +
  192 + // 调用:再次退款到钱包
  193 + PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
  194 + // 断言:只记录一条退款流水,只增加一次余额
  195 + assertEquals(transaction.getId(), walletTransactionMapper.selectByBiz(String.valueOf(refund.getId()),
  196 + PayWalletBizTypeEnum.PAYMENT_REFUND.getType()).getId());
  197 + assertEquals(130, walletMapper.selectById(walletId).getBalance());
  198 + }
  199 +
  200 + @Test // 并发使用同一退款单退款到钱包(例如说:结果未知的退款单重新发起,与原退款请求重叠),并且调用方存在事务:
  201 + // 后者在退款单的行锁上等待,前者提交后,当前读到已提交的退款流水,返回原流水,只增加一次余额
  202 + public void testOrderRefund_concurrent() throws Exception {
  203 + // mock 数据
  204 + PayRefundDO refund = insertWalletRefund();
  205 + // mock 方法
  206 + mockRefundAndWalletTransaction();
  207 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
  208 + CountDownLatch firstWritten = new CountDownLatch(1);
  209 + CountDownLatch firstCommit = new CountDownLatch(1);
  210 + ExecutorService executor = Executors.newFixedThreadPool(2);
  211 + try {
  212 + // 调用:第一个请求在调用方事务中退款到钱包,写入退款流水后,暂不提交
  213 + Future<PayWalletTransactionDO> future01 = executor.submit(() -> transactionTemplate.execute(status -> {
  214 + PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
  215 + firstWritten.countDown();
  216 + awaitLatch(firstCommit);
  217 + return transaction;
  218 + }));
  219 + assertTrue(firstWritten.await(10, TimeUnit.SECONDS));
  220 + // 调用:第二个请求在调用方事务中,先查询退款流水(不存在),再退款到钱包,在退款单的行锁上等待
  221 + Future<PayWalletTransactionDO> future02 = executor.submit(() -> transactionTemplate.execute(status -> {
  222 + assertNull(walletTransactionMapper.selectByBiz(String.valueOf(refund.getId()),
  223 + PayWalletBizTypeEnum.PAYMENT_REFUND.getType()));
  224 + return walletService.orderRefund(refund.getNo(), 30, "测试退款");
  225 + }));
  226 + // 断言:第二个请求确实在等待锁,然后提交第一个请求
  227 + assertTrue(waitLockWaiting());
  228 + firstCommit.countDown();
  229 + PayWalletTransactionDO transaction01 = future01.get(10, TimeUnit.SECONDS);
  230 + PayWalletTransactionDO transaction02 = future02.get(10, TimeUnit.SECONDS);
  231 +
  232 + // 断言:返回同一条退款流水,只记录一条退款流水,只增加一次余额
  233 + assertEquals(transaction01.getId(), transaction02.getId());
  234 + verify(walletTransactionService, times(1)).createWalletTransaction(any());
  235 + assertEquals(1L, walletTransactionMapper.selectCount(PayWalletTransactionDO::getBizType,
  236 + PayWalletBizTypeEnum.PAYMENT_REFUND.getType()));
  237 + assertEquals(130, walletMapper.selectById(transaction01.getWalletId()).getBalance());
  238 + } finally {
  239 + firstCommit.countDown();
  240 + executor.shutdownNow();
  241 + }
  242 + }
  243 +
  244 + @Test // 新建事务时,使用读已提交,避免当前读不存在的退款流水时加间隙锁;加入调用方的已有事务时,沿用调用方的隔离级别
  245 + // 注意:H2 无法模拟 MySQL 可重复读下的间隙锁,这里只校验事务的隔离级别,间隙锁死锁需要 MySQL 实库验证
  246 + public void testOrderRefund_isolation() {
  247 + // mock 数据
  248 + PayRefundDO refund = insertWalletRefund();
  249 + // mock 方法:锁定退款单时,记录当前事务声明、实际连接的隔离级别
  250 + mockRefundAndWalletTransaction();
  251 + List<Integer> declaredIsolationLevels = new ArrayList<>();
  252 + List<Integer> connectionIsolationLevels = new ArrayList<>();
  253 + when(refundService.getRefundForUpdate(any())).thenAnswer(invocation -> {
  254 + declaredIsolationLevels.add(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel());
  255 + connectionIsolationLevels.add(DataSourceUtils.getConnection(dataSource).getTransactionIsolation());
  256 + return refundMapper.selectOneForUpdate(PayRefundDO::getId, invocation.getArgument(0));
  257 + });
  258 +
  259 + // 调用:不存在调用方事务,新建事务退款到钱包
  260 + PayWalletTransactionDO transaction = walletService.orderRefund(refund.getNo(), 30, "测试退款");
  261 + // 调用:调用方事务(可重复读)中,再次退款到钱包
  262 + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
  263 + transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
  264 + PayWalletTransactionDO transaction02 = transactionTemplate.execute(status ->
  265 + walletService.orderRefund(refund.getNo(), 30, "测试退款"));
  266 +
  267 + // 断言:新建事务使用读已提交;加入调用方事务时,不覆盖调用方的可重复读
  268 + assertEquals(Arrays.asList(Connection.TRANSACTION_READ_COMMITTED, Connection.TRANSACTION_REPEATABLE_READ),
  269 + declaredIsolationLevels);
  270 + assertEquals(Arrays.asList(Connection.TRANSACTION_READ_COMMITTED, Connection.TRANSACTION_REPEATABLE_READ),
  271 + connectionIsolationLevels);
  272 + // 断言:返回同一条退款流水,只增加一次余额
  273 + assertNotNull(transaction02);
  274 + assertEquals(transaction.getId(), transaction02.getId());
  275 + verify(walletTransactionService, times(1)).createWalletTransaction(any());
  276 + assertEquals(130, walletMapper.selectById(transaction.getWalletId()).getBalance());
  277 + }
  278 +
  279 + /**
  280 + * 插入钱包、钱包支付流水、退款单:钱包余额为 100
  281 + */
  282 + private PayRefundDO insertWalletRefund() {
  283 + PayWalletDO wallet = new PayWalletDO().setUserId(1L).setUserType(2)
  284 + .setBalance(100).setFreezePrice(0).setTotalExpense(30).setTotalRecharge(130);
  285 + walletMapper.insert(wallet);
  286 + PayWalletTransactionDO payTransaction = randomPojo(PayWalletTransactionDO.class, o -> {
  287 + o.setNo("W001");
  288 + o.setWalletId(wallet.getId());
  289 + o.setBizType(PayWalletBizTypeEnum.PAYMENT.getType());
  290 + o.setPrice(-30);
  291 + });
  292 + walletTransactionMapper.insert(payTransaction);
  293 + PayRefundDO refund = randomPojo(PayRefundDO.class, o -> o.setNo("R001").setChannelOrderNo("W001")
  294 + .setRefundPrice(30).setStatus(PayRefundStatusEnum.WAITING.getStatus()));
  295 + refundMapper.insert(refund);
  296 + return refund;
  297 + }
  298 +
  299 + /**
  300 + * 插入已退款到钱包的退款流水(未增加余额,只用于校验不会重复退款)
  301 + */
  302 + private PayWalletTransactionDO insertRefundTransaction(PayRefundDO refund, Integer price) {
  303 + PayWalletTransactionDO payTransaction = walletTransactionMapper.selectByNo("W001");
  304 + PayWalletTransactionDO refundTransaction = randomPojo(PayWalletTransactionDO.class, o -> {
  305 + o.setWalletId(payTransaction.getWalletId());
  306 + o.setBizType(PayWalletBizTypeEnum.PAYMENT_REFUND.getType());
  307 + o.setBizId(String.valueOf(refund.getId()));
  308 + o.setPrice(price);
  309 + });
  310 + walletTransactionMapper.insert(refundTransaction);
  311 + return refundTransaction;
  312 + }
  313 +
  314 + /**
  315 + * mock 退款单、钱包流水的方法:使用数据库的真实查询,校验锁定退款单、查询退款流水的顺序
  316 + */
  317 + private void mockRefundAndWalletTransaction() {
  318 + when(refundService.getRefundByNo(any())).thenAnswer(invocation ->
  319 + refundMapper.selectByNo(invocation.getArgument(0)));
  320 + when(refundService.getRefundForUpdate(any())).thenAnswer(invocation ->
  321 + refundMapper.selectOneForUpdate(PayRefundDO::getId, invocation.getArgument(0)));
  322 + when(walletTransactionService.getWalletTransactionByNo(any())).thenAnswer(invocation ->
  323 + walletTransactionMapper.selectByNo(invocation.getArgument(0)));
  324 + when(walletTransactionService.getWalletTransactionForUpdate(any(), any())).thenAnswer(invocation ->
  325 + walletTransactionMapper.selectByBizForUpdate(invocation.getArgument(0),
  326 + ((PayWalletBizTypeEnum) invocation.getArgument(1)).getType()));
  327 + when(walletTransactionService.createWalletTransaction(any())).thenAnswer(invocation ->
  328 + createWalletTransaction(invocation.getArgument(0)));
  329 + }
  330 +
  331 + /**
  332 + * 等待存在锁等待的数据库会话,基于 H2 的 INFORMATION_SCHEMA.SESSIONS 表的 BLOCKER_ID 字段
  333 + *
  334 + * @return 是否存在
  335 + */
  336 + private boolean waitLockWaiting() throws InterruptedException {
  337 + JdbcTemplate jdbcTemplate = new JdbcTemplate(dataSource);
  338 + for (int i = 0; i < 500; i++) {
  339 + Integer count = jdbcTemplate.queryForObject(
  340 + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SESSIONS WHERE BLOCKER_ID IS NOT NULL", Integer.class);
  341 + if (count != null && count > 0) {
  342 + return true;
  343 + }
  344 + Thread.sleep(10);
  345 + }
  346 + return false;
  347 + }
  348 +
  349 + private static void awaitLatch(CountDownLatch latch) {
  350 + try {
  351 + assertTrue(latch.await(10, TimeUnit.SECONDS));
  352 + } catch (InterruptedException e) {
  353 + Thread.currentThread().interrupt();
  354 + throw new IllegalStateException(e);
  355 + }
  356 + }
  357 +
  358 + private PayWalletTransactionDO createWalletTransaction(WalletTransactionCreateReqBO bo) {
  359 + PayWalletTransactionDO transaction = new PayWalletTransactionDO();
  360 + transaction.setNo(randomString());
  361 + transaction.setWalletId(bo.getWalletId());
  362 + transaction.setBizType(bo.getBizType());
  363 + transaction.setBizId(bo.getBizId());
  364 + transaction.setTitle(bo.getTitle());
  365 + transaction.setPrice(bo.getPrice());
  366 + transaction.setBalance(bo.getBalance());
  367 + walletTransactionMapper.insert(transaction);
  368 + return transaction;
  369 + }
  370 +
85 371 }
... ...
yudao-module-pay/src/test/resources/sql/clean.sql
... ... @@ -5,5 +5,6 @@ DELETE FROM pay_order_extension;
5 5 DELETE FROM pay_refund;
6 6 DELETE FROM pay_transfer;
7 7 DELETE FROM pay_wallet;
  8 +DELETE FROM pay_wallet_transaction;
8 9 DELETE FROM pay_notify_task;
9 10 DELETE FROM pay_notify_log;
... ...
yudao-module-pay/src/test/resources/sql/create_tables.sql
... ... @@ -196,3 +196,20 @@ CREATE TABLE IF NOT EXISTS `pay_wallet` (
196 196 `deleted` bit(1) NOT NULL DEFAULT FALSE,
197 197 PRIMARY KEY ("id")
198 198 ) COMMENT = '会员钱包';
  199 +
  200 +CREATE TABLE IF NOT EXISTS `pay_wallet_transaction` (
  201 + "id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
  202 + `no` varchar(64) NOT NULL,
  203 + `wallet_id` bigint(20) NOT NULL,
  204 + `biz_type` tinyint(4) NOT NULL,
  205 + `biz_id` varchar(64) NOT NULL,
  206 + `title` varchar(128) NOT NULL,
  207 + `price` int(11) NOT NULL,
  208 + `balance` int(11) NOT NULL,
  209 + `creator` varchar(64) NULL DEFAULT '',
  210 + `create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
  211 + `updater` varchar(64) NULL DEFAULT '',
  212 + `update_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  213 + `deleted` bit(1) NOT NULL DEFAULT FALSE,
  214 + PRIMARY KEY ("id")
  215 +) COMMENT = '会员钱包流水';
... ...