Commit 112cd7528c20941ed91e1105ea667d8f421338d3
1 parent
61ca559f
fix(pay): 支持微信转账超时后重新发起
Showing
4 changed files
with
265 additions
and
11 deletions
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)); | ... | ... |