Commit 112cd7528c20941ed91e1105ea667d8f421338d3

Authored by YunaiV
1 parent 61ca559f

fix(pay): 支持微信转账超时后重新发起

yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java
... ... @@ -29,6 +29,7 @@ import com.github.binarywang.wxpay.service.WxPayService;
29 29 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
30 30 import lombok.extern.slf4j.Slf4j;
31 31  
  32 +import java.io.IOException;
32 33 import java.time.LocalDateTime;
33 34 import java.time.ZoneId;
34 35 import java.util.Map;
... ... @@ -485,7 +486,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
485 486  
486 487 // 2.2 创建返回结果
487 488 String state = response.getState();
488   - if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING")) {
  489 + if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING", "CANCELING")) {
489 490 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response)
490 491 .setChannelPackageInfo(response.getPackageInfo()); // 一般情况下,只有 WAIT_USER_CONFIRM 会有!
491 492 }
... ... @@ -494,9 +495,13 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
494 495 response.getOutBillNo(), response);
495 496 }
496 497 return PayTransferRespDTO.closedOf(state, response.getFailReason(),
497   - response.getOutBillNo(), response);
  498 + response.getOutBillNo(), response).setChannelTransferNo(response.getTransferBillNo());
498 499 } catch (WxPayException e) {
499 500 log.error("[doUnifiedTransfer][转账({}) 发起微信支付异常", reqDTO, e);
  501 + // 参考 https://gitee.com/yudaocode/yudao-mall-uniapp/issues/ID0PFR,网络异常不能误判为转账关闭
  502 + if (hasCause(e, IOException.class)) {
  503 + throw e;
  504 + }
500 505 String errorCode = getErrorCode(e);
501 506 String errorMessage = getErrorMessage(e);
502 507 return PayTransferRespDTO.closedOf(errorCode, errorMessage,
... ... @@ -511,7 +516,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
511 516  
512 517 // 2. 创建返回结果
513 518 String state = response.getState();
514   - if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING")) {
  519 + if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING", "CANCELING")) {
515 520 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response);
516 521 }
517 522 if (Objects.equals("SUCCESS", state)) {
... ... @@ -519,7 +524,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
519 524 response.getOutBillNo(), response);
520 525 }
521 526 return PayTransferRespDTO.closedOf(state, response.getFailReason(),
522   - response.getOutBillNo(), response);
  527 + response.getOutBillNo(), response).setChannelTransferNo(response.getTransferBillNo());
523 528 }
524 529  
525 530 @Override
... ... @@ -542,7 +547,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
542 547  
543 548 // 2. 创建返回结果
544 549 String state = result.getState();
545   - if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING")) {
  550 + if (ObjectUtils.equalsAny(state, "ACCEPTED", "PROCESSING", "WAIT_USER_CONFIRM", "TRANSFERING", "CANCELING")) {
546 551 return PayTransferRespDTO.processingOf(result.getTransferBillNo(), result.getOutBillNo(), response);
547 552 }
548 553 if (Objects.equals("SUCCESS", state)) {
... ... @@ -550,11 +555,22 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
550 555 result.getOutBillNo(), response);
551 556 }
552 557 return PayTransferRespDTO.closedOf(state, result.getFailReason(),
553   - result.getOutBillNo(), response);
  558 + result.getOutBillNo(), response).setChannelTransferNo(result.getTransferBillNo());
554 559 }
555 560  
556 561 // ========== 各种工具方法 ==========
557 562  
  563 + private static boolean hasCause(Throwable throwable, Class<? extends Throwable> causeType) {
  564 + Throwable current = throwable;
  565 + while (current != null) {
  566 + if (causeType.isInstance(current)) {
  567 + return true;
  568 + }
  569 + current = current.getCause();
  570 + }
  571 + return false;
  572 + }
  573 +
558 574 /**
559 575 * 组装请求头重的签名信息
560 576 *
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java
... ... @@ -22,6 +22,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
22 22 import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper;
23 23 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
24 24 import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO;
  25 +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
25 26 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
26 27 import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
27 28 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
... ... @@ -90,7 +91,7 @@ public class PayTransferServiceImpl implements PayTransferService {
90 91 // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账
91 92 // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单
92 93 PayTransferDO transfer = transferLockRedisDAO.lock(payApp.getId(), reqDTO.getMerchantTransferId(),
93   - CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel));
  94 + CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel, client));
94 95  
95 96 // 3.1 调用三方渠道发起转账
96 97 PayTransferRespDTO unifiedTransferResp = null;
... ... @@ -139,9 +140,11 @@ public class PayTransferServiceImpl implements PayTransferService {
139 140 * @param reqDTO 转账申请信息
140 141 * @param payApp 支付应用
141 142 * @param channel 支付渠道
  143 + * @param client 支付客户端
142 144 * @return 转账单
143 145 */
144   - private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp, PayChannelDO channel) {
  146 + private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp,
  147 + PayChannelDO channel, PayClient<?> client) {
145 148 // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭
146 149 PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId());
147 150  
... ... @@ -154,22 +157,67 @@ public class PayTransferServiceImpl implements PayTransferService {
154 157 .setNotifyUrl(payApp.getTransferNotifyUrl());
155 158 transferMapper.insert(transfer);
156 159 } else {
157   - // 2.2 情况二:存在关闭的转账单,则复用原转账单(包括原转账单号)重新发起,并清空上一次转账的渠道结果
158   - // 原因是:渠道通过转账单号保证幂等,避免上一次实际受理时重复转账。未校验一致的请求字段,以本次请求为准
  160 + // 2.2 情况二:存在关闭的转账单,微信渠道确认原单已终态关闭后生成新单号重新发起
  161 + // 原因是:微信已关闭的转账单不能再次发起;确认原单仍未成功后换号,也能避免重复转账
159 162 // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号
  163 + String no = getRetryTransferNo(channel, client, transfer);
160 164 int updateCount = transferMapper.updateByIdAndStatusAndClearChannelResult(transfer.getId(), transfer.getStatus(),
161   - new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId())
  165 + new PayTransferDO().setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId())
162 166 .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount())
163 167 .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras())
164 168 .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl()));
165 169 if (updateCount == 0) { // 校验状态,避免并发重新发起转账
166 170 throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED);
167 171 }
  172 + transfer.setNo(no);
168 173 }
169 174 return transfer;
170 175 }
171 176  
172 177 /**
  178 + * 获取重新发起转账使用的转账单号
  179 + *
  180 + * 参考 <a href="https://gitee.com/yudaocode/yudao-mall-uniapp/issues/ID0PFR">佣金提现超时后重新转账</a>
  181 + *
  182 + * @param channel 支付渠道
  183 + * @param client 支付客户端
  184 + * @param transfer 原转账单
  185 + * @return 转账单号
  186 + */
  187 + private String getRetryTransferNo(PayChannelDO channel, PayClient<?> client, PayTransferDO transfer) {
  188 + // 支付宝等渠道通过原转账单号保证幂等,继续复用;仅微信渠道需要确认关闭后更换单号
  189 + if (!PayChannelEnum.isWeixin(channel.getCode())) {
  190 + return transfer.getNo();
  191 + }
  192 + try {
  193 + PayTransferRespDTO queryResp = client.getTransfer(transfer.getNo());
  194 + if (queryResp != null && (PayTransferStatusEnum.isSuccess(queryResp.getStatus())
  195 + || PayTransferStatusEnum.isProcessing(queryResp.getStatus())
  196 + || (PayTransferStatusEnum.isWaiting(queryResp.getStatus())
  197 + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())))) {
  198 + log.error("[getRetryTransferNo][transfer({}) 本地转账单已关闭,但微信渠道状态为({}),禁止重新转账]",
  199 + transfer.getId(), queryResp);
  200 + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED);
  201 + }
  202 + // 只有渠道明确返回关闭且存在渠道转账单号时,才确认原单已终态关闭并更换单号
  203 + if (queryResp != null && PayTransferStatusEnum.isClosed(queryResp.getStatus())
  204 + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())) {
  205 + String newNo = noRedisDAO.generate(TRANSFER_NO_PREFIX);
  206 + log.info("[getRetryTransferNo][transfer({}) 微信渠道转账单已关闭,转账单号由({})更新为({})]",
  207 + transfer.getId(), transfer.getNo(), newNo);
  208 + return newNo;
  209 + }
  210 + } catch (ServiceException ex) {
  211 + throw ex;
  212 + } catch (Throwable ex) {
  213 + // 无法确认渠道最终状态时保留原单号,交给渠道幂等机制处理,避免贸然重复打款
  214 + log.warn("[getRetryTransferNo][transfer({}) 查询微信渠道转账单异常,继续使用原转账单号({})]",
  215 + transfer.getId(), transfer.getNo(), ex);
  216 + }
  217 + return transfer.getNo();
  218 + }
  219 +
  220 + /**
173 221 * 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,确认转账结果
174 222 *
175 223 * 注意:只有查询到渠道已受理转账,才以查询结果为准;查询到的转账关闭,不作为最终结果。
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClientTest.java 0 → 100644
  1 +package cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin;
  2 +
  3 +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
  4 +import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
  5 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO;
  6 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO;
  7 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException;
  8 +import com.github.binarywang.wxpay.bean.transfer.TransferBillsGetResult;
  9 +import com.github.binarywang.wxpay.exception.WxPayException;
  10 +import com.github.binarywang.wxpay.service.TransferService;
  11 +import com.github.binarywang.wxpay.service.WxPayService;
  12 +import org.junit.jupiter.api.BeforeEach;
  13 +import org.junit.jupiter.api.Test;
  14 +import org.springframework.test.util.ReflectionTestUtils;
  15 +
  16 +import java.io.IOException;
  17 +import java.util.Map;
  18 +
  19 +import static cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig.API_VERSION_V3;
  20 +import static org.junit.jupiter.api.Assertions.assertEquals;
  21 +import static org.junit.jupiter.api.Assertions.assertThrows;
  22 +import static org.mockito.ArgumentMatchers.any;
  23 +import static org.mockito.Mockito.mock;
  24 +import static org.mockito.Mockito.verify;
  25 +import static org.mockito.Mockito.when;
  26 +
  27 +/**
  28 + * {@link AbstractWxPayClient} 的单元测试类
  29 + *
  30 + * @author 芋道源码
  31 + */
  32 +public class AbstractWxPayClientTest extends BaseMockitoUnitTest {
  33 +
  34 + private WxLitePayClient client;
  35 + private TransferService transferService;
  36 +
  37 + @BeforeEach
  38 + public void setUp() {
  39 + WxPayClientConfig config = new WxPayClientConfig();
  40 + config.setApiVersion(API_VERSION_V3);
  41 + config.setAppId("wx-app-id");
  42 + client = new WxLitePayClient(1L, config);
  43 + WxPayService wxPayService = mock(WxPayService.class);
  44 + transferService = mock(TransferService.class);
  45 + when(wxPayService.getTransferService()).thenReturn(transferService);
  46 + ReflectionTestUtils.setField(client, "client", wxPayService);
  47 + }
  48 +
  49 + @Test // 查询微信转账关闭时保留渠道转账单号,用于关闭单重试时换号
  50 + public void testGetTransfer_closed() throws Exception {
  51 + TransferBillsGetResult result = new TransferBillsGetResult();
  52 + result.setState("CANCELLED");
  53 + result.setOutBillNo("T001");
  54 + result.setTransferBillNo("WX_TRANSFER_001");
  55 + result.setFailReason("用户超时未确认");
  56 + when(transferService.getBillsByOutBillNo("T001")).thenReturn(result);
  57 +
  58 + PayTransferRespDTO resp = client.getTransfer("T001");
  59 +
  60 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), resp.getStatus());
  61 + assertEquals("WX_TRANSFER_001", resp.getChannelTransferNo());
  62 + }
  63 +
  64 + @Test // 微信取消中的转账仍处于处理中
  65 + public void testGetTransfer_canceling() throws Exception {
  66 + TransferBillsGetResult result = new TransferBillsGetResult();
  67 + result.setState("CANCELING");
  68 + result.setOutBillNo("T001");
  69 + result.setTransferBillNo("WX_TRANSFER_001");
  70 + when(transferService.getBillsByOutBillNo("T001")).thenReturn(result);
  71 +
  72 + PayTransferRespDTO resp = client.getTransfer("T001");
  73 +
  74 + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), resp.getStatus());
  75 + }
  76 +
  77 + @Test // 发起微信转账发生 IO 异常时保留未知状态,不误判为关闭
  78 + public void testUnifiedTransfer_ioException() throws Exception {
  79 + when(transferService.transferBills(any())).thenThrow(new WxPayException("timeout",
  80 + new IOException("connect timeout")));
  81 +
  82 + assertThrows(PayClientException.class, () -> client.unifiedTransfer(buildTransferUnifiedReqDTO()));
  83 + verify(transferService).transferBills(any());
  84 + }
  85 +
  86 + private static PayTransferUnifiedReqDTO buildTransferUnifiedReqDTO() {
  87 + return new PayTransferUnifiedReqDTO().setUserIp("127.0.0.1").setOutTransferNo("T001")
  88 + .setPrice(100).setSubject("佣金提现").setUserAccount("openid")
  89 + .setNotifyUrl("http://127.0.0.1/transfer")
  90 + .setChannelExtras(Map.of("sceneId", "1000", "sceneReportInfos", "[]"));
  91 + }
  92 +
  93 +}
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java
... ... @@ -23,6 +23,7 @@ import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
23 23 import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
24 24 import jakarta.annotation.Resource;
25 25 import org.junit.jupiter.api.Test;
  26 +import org.mockito.ArgumentCaptor;
26 27 import org.mockito.MockedStatic;
27 28 import org.springframework.context.annotation.Import;
28 29 import org.springframework.test.context.bean.override.mockito.MockitoBean;
... ... @@ -773,6 +774,75 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
773 774 assertEquals("CHANNEL_T002", dbTransfer.getChannelTransferNo());
774 775 }
775 776  
  777 + @Test // 微信转账关闭后再次发起,确认渠道已终态关闭后使用新的转账单号
  778 + public void testCreateTransfer_retryAfterClosed_weixinClosed() {
  779 + PayClient<?> client = mockWeixinPayClient();
  780 + when(client.getTransfer(any())).thenAnswer(invocation ->
  781 + PayTransferRespDTO.closedOf("CANCELLED", "用户超时未确认", invocation.getArgument(0), "closed")
  782 + .setChannelTransferNo("WX_TRANSFER_001"));
  783 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  784 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  785 + return PayTransferRespDTO.successOf("WX_TRANSFER_002", LocalDateTime.now(),
  786 + unifiedReqDTO.getOutTransferNo(), "success");
  787 + });
  788 + PayTransferDO closedTransfer = insertClosedWeixinTransfer();
  789 +
  790 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildWeixinTransferCreateReqDTO());
  791 +
  792 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  793 + assertEquals(closedTransfer.getId(), respDTO.getId());
  794 + assertNotEquals(closedTransfer.getNo(), dbTransfer.getNo());
  795 + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus());
  796 + verify(client).getTransfer(eq(closedTransfer.getNo()));
  797 + ArgumentCaptor<PayTransferUnifiedReqDTO> captor = ArgumentCaptor.forClass(PayTransferUnifiedReqDTO.class);
  798 + verify(client).unifiedTransfer(captor.capture());
  799 + assertEquals(dbTransfer.getNo(), captor.getValue().getOutTransferNo());
  800 + }
  801 +
  802 + @Test // 微信转账关闭后再次发起,但渠道查询仍为处理中,不允许生成新转账
  803 + public void testCreateTransfer_retryAfterClosed_weixinProcessing() {
  804 + PayClient<?> client = mockWeixinPayClient();
  805 + when(client.getTransfer(any())).thenAnswer(invocation ->
  806 + PayTransferRespDTO.processingOf("WX_TRANSFER_001", invocation.getArgument(0), "processing"));
  807 + PayTransferDO closedTransfer = insertClosedWeixinTransfer();
  808 +
  809 + assertServiceException(() -> transferService.createTransfer(buildWeixinTransferCreateReqDTO()),
  810 + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED);
  811 +
  812 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus());
  813 + verify(client, never()).unifiedTransfer(any());
  814 + }
  815 +
  816 + @Test // 微信转账关闭后再次发起,但渠道查询已成功,不允许再次转账
  817 + public void testCreateTransfer_retryAfterClosed_weixinSuccess() {
  818 + PayClient<?> client = mockWeixinPayClient();
  819 + when(client.getTransfer(any())).thenAnswer(invocation ->
  820 + PayTransferRespDTO.successOf("WX_TRANSFER_001", LocalDateTime.now(),
  821 + invocation.getArgument(0), "success"));
  822 + PayTransferDO closedTransfer = insertClosedWeixinTransfer();
  823 +
  824 + assertServiceException(() -> transferService.createTransfer(buildWeixinTransferCreateReqDTO()),
  825 + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED);
  826 +
  827 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus());
  828 + verify(client, never()).unifiedTransfer(any());
  829 + }
  830 +
  831 + @Test // 微信转账关闭后再次发起,但渠道查询异常,继续复用原单号并保留待确认状态
  832 + public void testCreateTransfer_retryAfterClosed_weixinQueryException() {
  833 + PayClient<?> client = mockWeixinPayClient();
  834 + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时"));
  835 + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟发起超时"));
  836 + PayTransferDO closedTransfer = insertClosedWeixinTransfer();
  837 +
  838 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildWeixinTransferCreateReqDTO());
  839 +
  840 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  841 + assertEquals(closedTransfer.getNo(), dbTransfer.getNo());
  842 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  843 + verify(client, times(2)).getTransfer(eq(closedTransfer.getNo()));
  844 + }
  845 +
776 846 private PayClient<?> mockPayClient() {
777 847 // mock 方法(app)
778 848 PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer"));
... ... @@ -788,6 +858,18 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
788 858 return client;
789 859 }
790 860  
  861 + private PayClient<?> mockWeixinPayClient() {
  862 + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer"));
  863 + when(appService.validPayApp(eq("demo"))).thenReturn(app);
  864 + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setAppId(1L)
  865 + .setCode(PayChannelEnum.WX_LITE.getCode()));
  866 + when(channelService.validPayChannel(eq(1L), eq(PayChannelEnum.WX_LITE.getCode()))).thenReturn(channel);
  867 + when(payProperties.getTransferNotifyUrl()).thenReturn("http://127.0.0.1");
  868 + PayClient<?> client = mock(PayClient.class);
  869 + when(channelService.getPayClient(eq(10L))).thenReturn(client);
  870 + return client;
  871 + }
  872 +
791 873 /**
792 874 * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果
793 875 *
... ... @@ -804,6 +886,21 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
804 886 return transfer;
805 887 }
806 888  
  889 + private PayTransferDO insertClosedWeixinTransfer() {
  890 + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setChannelId(10L)
  891 + .setChannelCode(PayChannelEnum.WX_LITE.getCode()).setMerchantTransferId("1001").setPrice(100)
  892 + .setUserName("旧的收款人").setChannelExtras(Map.of("sceneId", "1005"))
  893 + .setStatus(PayTransferStatusEnum.CLOSED.getStatus()).setChannelTransferNo("WX_TRANSFER_001")
  894 + .setChannelErrorCode("CANCELLED").setChannelErrorMsg("用户超时未确认")
  895 + .setChannelPackageInfo("package-info-001"));
  896 + transferMapper.insert(transfer);
  897 + return transfer;
  898 + }
  899 +
  900 + private static PayTransferCreateReqDTO buildWeixinTransferCreateReqDTO() {
  901 + return buildTransferCreateReqDTO().setChannelCode(PayChannelEnum.WX_LITE.getCode());
  902 + }
  903 +
807 904 private void updateTransferStatus(String no, Integer status) {
808 905 PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no);
809 906 transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status));
... ...