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,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)); |