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,6 +29,7 @@ import com.github.binarywang.wxpay.service.WxPayService;
29 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; 29 import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl;
30 import lombok.extern.slf4j.Slf4j; 30 import lombok.extern.slf4j.Slf4j;
31 31
  32 +import java.io.IOException;
32 import java.time.LocalDateTime; 33 import java.time.LocalDateTime;
33 import java.time.ZoneId; 34 import java.time.ZoneId;
34 import java.util.Map; 35 import java.util.Map;
@@ -485,7 +486,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -485,7 +486,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
485 486
486 // 2.2 创建返回结果 487 // 2.2 创建返回结果
487 String state = response.getState(); 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 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response) 490 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response)
490 .setChannelPackageInfo(response.getPackageInfo()); // 一般情况下,只有 WAIT_USER_CONFIRM 会有! 491 .setChannelPackageInfo(response.getPackageInfo()); // 一般情况下,只有 WAIT_USER_CONFIRM 会有!
491 } 492 }
@@ -494,9 +495,13 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -494,9 +495,13 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
494 response.getOutBillNo(), response); 495 response.getOutBillNo(), response);
495 } 496 }
496 return PayTransferRespDTO.closedOf(state, response.getFailReason(), 497 return PayTransferRespDTO.closedOf(state, response.getFailReason(),
497 - response.getOutBillNo(), response); 498 + response.getOutBillNo(), response).setChannelTransferNo(response.getTransferBillNo());
498 } catch (WxPayException e) { 499 } catch (WxPayException e) {
499 log.error("[doUnifiedTransfer][转账({}) 发起微信支付异常", reqDTO, e); 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 String errorCode = getErrorCode(e); 505 String errorCode = getErrorCode(e);
501 String errorMessage = getErrorMessage(e); 506 String errorMessage = getErrorMessage(e);
502 return PayTransferRespDTO.closedOf(errorCode, errorMessage, 507 return PayTransferRespDTO.closedOf(errorCode, errorMessage,
@@ -511,7 +516,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -511,7 +516,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
511 516
512 // 2. 创建返回结果 517 // 2. 创建返回结果
513 String state = response.getState(); 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 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response); 520 return PayTransferRespDTO.processingOf(response.getTransferBillNo(), response.getOutBillNo(), response);
516 } 521 }
517 if (Objects.equals("SUCCESS", state)) { 522 if (Objects.equals("SUCCESS", state)) {
@@ -519,7 +524,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -519,7 +524,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
519 response.getOutBillNo(), response); 524 response.getOutBillNo(), response);
520 } 525 }
521 return PayTransferRespDTO.closedOf(state, response.getFailReason(), 526 return PayTransferRespDTO.closedOf(state, response.getFailReason(),
522 - response.getOutBillNo(), response); 527 + response.getOutBillNo(), response).setChannelTransferNo(response.getTransferBillNo());
523 } 528 }
524 529
525 @Override 530 @Override
@@ -542,7 +547,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -542,7 +547,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
542 547
543 // 2. 创建返回结果 548 // 2. 创建返回结果
544 String state = result.getState(); 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 return PayTransferRespDTO.processingOf(result.getTransferBillNo(), result.getOutBillNo(), response); 551 return PayTransferRespDTO.processingOf(result.getTransferBillNo(), result.getOutBillNo(), response);
547 } 552 }
548 if (Objects.equals("SUCCESS", state)) { 553 if (Objects.equals("SUCCESS", state)) {
@@ -550,11 +555,22 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC @@ -550,11 +555,22 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
550 result.getOutBillNo(), response); 555 result.getOutBillNo(), response);
551 } 556 }
552 return PayTransferRespDTO.closedOf(state, result.getFailReason(), 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,6 +22,7 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
22 import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; 22 import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper;
23 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; 23 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
24 import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; 24 import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO;
  25 +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
25 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; 26 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
26 import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; 27 import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
27 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; 28 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
@@ -90,7 +91,7 @@ public class PayTransferServiceImpl implements PayTransferService { @@ -90,7 +91,7 @@ public class PayTransferServiceImpl implements PayTransferService {
90 // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账 91 // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账
91 // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单 92 // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单
92 PayTransferDO transfer = transferLockRedisDAO.lock(payApp.getId(), reqDTO.getMerchantTransferId(), 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 // 3.1 调用三方渠道发起转账 96 // 3.1 调用三方渠道发起转账
96 PayTransferRespDTO unifiedTransferResp = null; 97 PayTransferRespDTO unifiedTransferResp = null;
@@ -139,9 +140,11 @@ public class PayTransferServiceImpl implements PayTransferService { @@ -139,9 +140,11 @@ public class PayTransferServiceImpl implements PayTransferService {
139 * @param reqDTO 转账申请信息 140 * @param reqDTO 转账申请信息
140 * @param payApp 支付应用 141 * @param payApp 支付应用
141 * @param channel 支付渠道 142 * @param channel 支付渠道
  143 + * @param client 支付客户端
142 * @return 转账单 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 // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭 148 // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭
146 PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); 149 PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId());
147 150
@@ -154,22 +157,67 @@ public class PayTransferServiceImpl implements PayTransferService { @@ -154,22 +157,67 @@ public class PayTransferServiceImpl implements PayTransferService {
154 .setNotifyUrl(payApp.getTransferNotifyUrl()); 157 .setNotifyUrl(payApp.getTransferNotifyUrl());
155 transferMapper.insert(transfer); 158 transferMapper.insert(transfer);
156 } else { 159 } else {
157 - // 2.2 情况二:存在关闭的转账单,则复用原转账单(包括原转账单号)重新发起,并清空上一次转账的渠道结果  
158 - // 原因是:渠道通过转账单号保证幂等,避免上一次实际受理时重复转账。未校验一致的请求字段,以本次请求为准 160 + // 2.2 情况二:存在关闭的转账单,微信渠道确认原单已终态关闭后生成新单号重新发起
  161 + // 原因是:微信已关闭的转账单不能再次发起;确认原单仍未成功后换号,也能避免重复转账
159 // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号 162 // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号
  163 + String no = getRetryTransferNo(channel, client, transfer);
160 int updateCount = transferMapper.updateByIdAndStatusAndClearChannelResult(transfer.getId(), transfer.getStatus(), 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 .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount()) 166 .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount())
163 .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras()) 167 .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras())
164 .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl())); 168 .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl()));
165 if (updateCount == 0) { // 校验状态,避免并发重新发起转账 169 if (updateCount == 0) { // 校验状态,避免并发重新发起转账
166 throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); 170 throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED);
167 } 171 }
  172 + transfer.setNo(no);
168 } 173 }
169 return transfer; 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,6 +23,7 @@ import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
23 import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService; 23 import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
24 import jakarta.annotation.Resource; 24 import jakarta.annotation.Resource;
25 import org.junit.jupiter.api.Test; 25 import org.junit.jupiter.api.Test;
  26 +import org.mockito.ArgumentCaptor;
26 import org.mockito.MockedStatic; 27 import org.mockito.MockedStatic;
27 import org.springframework.context.annotation.Import; 28 import org.springframework.context.annotation.Import;
28 import org.springframework.test.context.bean.override.mockito.MockitoBean; 29 import org.springframework.test.context.bean.override.mockito.MockitoBean;
@@ -773,6 +774,75 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { @@ -773,6 +774,75 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
773 assertEquals("CHANNEL_T002", dbTransfer.getChannelTransferNo()); 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 private PayClient<?> mockPayClient() { 846 private PayClient<?> mockPayClient() {
777 // mock 方法(app) 847 // mock 方法(app)
778 PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer")); 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,6 +858,18 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
788 return client; 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 * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果 874 * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果
793 * 875 *
@@ -804,6 +886,21 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { @@ -804,6 +886,21 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
804 return transfer; 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 private void updateTransferStatus(String no, Integer status) { 904 private void updateTransferStatus(String no, Integer status) {
808 PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); 905 PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no);
809 transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status)); 906 transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status));