diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java index 48c2d10..acc042c 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClient.java +++ b/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; import com.github.binarywang.wxpay.service.impl.WxPayServiceImpl; import lombok.extern.slf4j.Slf4j; +import java.io.IOException; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Map; @@ -485,7 +486,7 @@ public abstract class AbstractWxPayClient extends AbstractPayClient causeType) { + Throwable current = throwable; + while (current != null) { + if (causeType.isInstance(current)) { + return true; + } + current = current.getCause(); + } + return false; + } + /** * 组装请求头重的签名信息 * diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java index 48fbd62..e0374ed 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java +++ b/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; import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; import cn.iocoder.yudao.module.pay.dal.redis.transfer.PayTransferLockRedisDAO; +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; @@ -90,7 +91,7 @@ public class PayTransferServiceImpl implements PayTransferService { // 原因是:同一商户转账单串行创建转账单,避免 select 后 insert 时,并发请求重复创建转账单,导致重复转账 // 注意:createTransfer 不加入调用方的事务,转账单立即提交,所以释放锁后,其它请求可以查询到该转账单 PayTransferDO transfer = transferLockRedisDAO.lock(payApp.getId(), reqDTO.getMerchantTransferId(), - CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel)); + CREATE_LOCK_TIMEOUT_MILLIS, () -> createOrRetryTransfer(reqDTO, payApp, channel, client)); // 3.1 调用三方渠道发起转账 PayTransferRespDTO unifiedTransferResp = null; @@ -139,9 +140,11 @@ public class PayTransferServiceImpl implements PayTransferService { * @param reqDTO 转账申请信息 * @param payApp 支付应用 * @param channel 支付渠道 + * @param client 支付客户端 * @return 转账单 */ - private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp, PayChannelDO channel) { + private PayTransferDO createOrRetryTransfer(PayTransferCreateReqDTO reqDTO, PayAppDO payApp, + PayChannelDO channel, PayClient client) { // 1. 校验转账单是否可以发起:不存在,或者之前转账关闭 PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId()); @@ -154,22 +157,67 @@ public class PayTransferServiceImpl implements PayTransferService { .setNotifyUrl(payApp.getTransferNotifyUrl()); transferMapper.insert(transfer); } else { - // 2.2 情况二:存在关闭的转账单,则复用原转账单(包括原转账单号)重新发起,并清空上一次转账的渠道结果 - // 原因是:渠道通过转账单号保证幂等,避免上一次实际受理时重复转账。未校验一致的请求字段,以本次请求为准 + // 2.2 情况二:存在关闭的转账单,微信渠道确认原单已终态关闭后生成新单号重新发起 + // 原因是:微信已关闭的转账单不能再次发起;确认原单仍未成功后换号,也能避免重复转账 // 特殊:渠道编号以本次按渠道编码校验的渠道为准,避免渠道删除重建后,转账轮询、转账回调仍使用旧的渠道编号 + String no = getRetryTransferNo(channel, client, transfer); int updateCount = transferMapper.updateByIdAndStatusAndClearChannelResult(transfer.getId(), transfer.getStatus(), - new PayTransferDO().setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId()) + new PayTransferDO().setNo(no).setStatus(PayTransferStatusEnum.WAITING.getStatus()).setChannelId(channel.getId()) .setSubject(reqDTO.getSubject()).setUserAccount(reqDTO.getUserAccount()) .setUserName(reqDTO.getUserName()).setChannelExtras(reqDTO.getChannelExtras()) .setUserIp(reqDTO.getUserIp()).setNotifyUrl(payApp.getTransferNotifyUrl())); if (updateCount == 0) { // 校验状态,避免并发重新发起转账 throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); } + transfer.setNo(no); } return transfer; } /** + * 获取重新发起转账使用的转账单号 + * + * 参考 佣金提现超时后重新转账 + * + * @param channel 支付渠道 + * @param client 支付客户端 + * @param transfer 原转账单 + * @return 转账单号 + */ + private String getRetryTransferNo(PayChannelDO channel, PayClient client, PayTransferDO transfer) { + // 支付宝等渠道通过原转账单号保证幂等,继续复用;仅微信渠道需要确认关闭后更换单号 + if (!PayChannelEnum.isWeixin(channel.getCode())) { + return transfer.getNo(); + } + try { + PayTransferRespDTO queryResp = client.getTransfer(transfer.getNo()); + if (queryResp != null && (PayTransferStatusEnum.isSuccess(queryResp.getStatus()) + || PayTransferStatusEnum.isProcessing(queryResp.getStatus()) + || (PayTransferStatusEnum.isWaiting(queryResp.getStatus()) + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())))) { + log.error("[getRetryTransferNo][transfer({}) 本地转账单已关闭,但微信渠道状态为({}),禁止重新转账]", + transfer.getId(), queryResp); + throw exception(PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + } + // 只有渠道明确返回关闭且存在渠道转账单号时,才确认原单已终态关闭并更换单号 + if (queryResp != null && PayTransferStatusEnum.isClosed(queryResp.getStatus()) + && StrUtil.isNotEmpty(queryResp.getChannelTransferNo())) { + String newNo = noRedisDAO.generate(TRANSFER_NO_PREFIX); + log.info("[getRetryTransferNo][transfer({}) 微信渠道转账单已关闭,转账单号由({})更新为({})]", + transfer.getId(), transfer.getNo(), newNo); + return newNo; + } + } catch (ServiceException ex) { + throw ex; + } catch (Throwable ex) { + // 无法确认渠道最终状态时保留原单号,交给渠道幂等机制处理,避免贸然重复打款 + log.warn("[getRetryTransferNo][transfer({}) 查询微信渠道转账单异常,继续使用原转账单号({})]", + transfer.getId(), transfer.getNo(), ex); + } + return transfer.getNo(); + } + + /** * 发起转账没有返回结果、或者返回关闭时,查询一次渠道转账单,确认转账结果 * * 注意:只有查询到渠道已受理转账,才以查询结果为准;查询到的转账关闭,不作为最终结果。 diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClientTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClientTest.java new file mode 100644 index 0000000..172ae94 --- /dev/null +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/framework/pay/core/client/impl/weixin/AbstractWxPayClientTest.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum; +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO; +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO; +import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException; +import com.github.binarywang.wxpay.bean.transfer.TransferBillsGetResult; +import com.github.binarywang.wxpay.exception.WxPayException; +import com.github.binarywang.wxpay.service.TransferService; +import com.github.binarywang.wxpay.service.WxPayService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.util.Map; + +import static cn.iocoder.yudao.module.pay.framework.pay.core.client.impl.weixin.WxPayClientConfig.API_VERSION_V3; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link AbstractWxPayClient} 的单元测试类 + * + * @author 芋道源码 + */ +public class AbstractWxPayClientTest extends BaseMockitoUnitTest { + + private WxLitePayClient client; + private TransferService transferService; + + @BeforeEach + public void setUp() { + WxPayClientConfig config = new WxPayClientConfig(); + config.setApiVersion(API_VERSION_V3); + config.setAppId("wx-app-id"); + client = new WxLitePayClient(1L, config); + WxPayService wxPayService = mock(WxPayService.class); + transferService = mock(TransferService.class); + when(wxPayService.getTransferService()).thenReturn(transferService); + ReflectionTestUtils.setField(client, "client", wxPayService); + } + + @Test // 查询微信转账关闭时保留渠道转账单号,用于关闭单重试时换号 + public void testGetTransfer_closed() throws Exception { + TransferBillsGetResult result = new TransferBillsGetResult(); + result.setState("CANCELLED"); + result.setOutBillNo("T001"); + result.setTransferBillNo("WX_TRANSFER_001"); + result.setFailReason("用户超时未确认"); + when(transferService.getBillsByOutBillNo("T001")).thenReturn(result); + + PayTransferRespDTO resp = client.getTransfer("T001"); + + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), resp.getStatus()); + assertEquals("WX_TRANSFER_001", resp.getChannelTransferNo()); + } + + @Test // 微信取消中的转账仍处于处理中 + public void testGetTransfer_canceling() throws Exception { + TransferBillsGetResult result = new TransferBillsGetResult(); + result.setState("CANCELING"); + result.setOutBillNo("T001"); + result.setTransferBillNo("WX_TRANSFER_001"); + when(transferService.getBillsByOutBillNo("T001")).thenReturn(result); + + PayTransferRespDTO resp = client.getTransfer("T001"); + + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), resp.getStatus()); + } + + @Test // 发起微信转账发生 IO 异常时保留未知状态,不误判为关闭 + public void testUnifiedTransfer_ioException() throws Exception { + when(transferService.transferBills(any())).thenThrow(new WxPayException("timeout", + new IOException("connect timeout"))); + + assertThrows(PayClientException.class, () -> client.unifiedTransfer(buildTransferUnifiedReqDTO())); + verify(transferService).transferBills(any()); + } + + private static PayTransferUnifiedReqDTO buildTransferUnifiedReqDTO() { + return new PayTransferUnifiedReqDTO().setUserIp("127.0.0.1").setOutTransferNo("T001") + .setPrice(100).setSubject("佣金提现").setUserAccount("openid") + .setNotifyUrl("http://127.0.0.1/transfer") + .setChannelExtras(Map.of("sceneId", "1000", "sceneReportInfos", "[]")); + } + +} diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java index e889f93..373cc46 100644 --- a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java +++ b/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; import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService; import jakarta.annotation.Resource; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.springframework.context.annotation.Import; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -773,6 +774,75 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { assertEquals("CHANNEL_T002", dbTransfer.getChannelTransferNo()); } + @Test // 微信转账关闭后再次发起,确认渠道已终态关闭后使用新的转账单号 + public void testCreateTransfer_retryAfterClosed_weixinClosed() { + PayClient client = mockWeixinPayClient(); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.closedOf("CANCELLED", "用户超时未确认", invocation.getArgument(0), "closed") + .setChannelTransferNo("WX_TRANSFER_001")); + when(client.unifiedTransfer(any())).thenAnswer(invocation -> { + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0); + return PayTransferRespDTO.successOf("WX_TRANSFER_002", LocalDateTime.now(), + unifiedReqDTO.getOutTransferNo(), "success"); + }); + PayTransferDO closedTransfer = insertClosedWeixinTransfer(); + + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildWeixinTransferCreateReqDTO()); + + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(closedTransfer.getId(), respDTO.getId()); + assertNotEquals(closedTransfer.getNo(), dbTransfer.getNo()); + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus()); + verify(client).getTransfer(eq(closedTransfer.getNo())); + ArgumentCaptor captor = ArgumentCaptor.forClass(PayTransferUnifiedReqDTO.class); + verify(client).unifiedTransfer(captor.capture()); + assertEquals(dbTransfer.getNo(), captor.getValue().getOutTransferNo()); + } + + @Test // 微信转账关闭后再次发起,但渠道查询仍为处理中,不允许生成新转账 + public void testCreateTransfer_retryAfterClosed_weixinProcessing() { + PayClient client = mockWeixinPayClient(); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.processingOf("WX_TRANSFER_001", invocation.getArgument(0), "processing")); + PayTransferDO closedTransfer = insertClosedWeixinTransfer(); + + assertServiceException(() -> transferService.createTransfer(buildWeixinTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus()); + verify(client, never()).unifiedTransfer(any()); + } + + @Test // 微信转账关闭后再次发起,但渠道查询已成功,不允许再次转账 + public void testCreateTransfer_retryAfterClosed_weixinSuccess() { + PayClient client = mockWeixinPayClient(); + when(client.getTransfer(any())).thenAnswer(invocation -> + PayTransferRespDTO.successOf("WX_TRANSFER_001", LocalDateTime.now(), + invocation.getArgument(0), "success")); + PayTransferDO closedTransfer = insertClosedWeixinTransfer(); + + assertServiceException(() -> transferService.createTransfer(buildWeixinTransferCreateReqDTO()), + PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED); + + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), transferMapper.selectById(closedTransfer.getId()).getStatus()); + verify(client, never()).unifiedTransfer(any()); + } + + @Test // 微信转账关闭后再次发起,但渠道查询异常,继续复用原单号并保留待确认状态 + public void testCreateTransfer_retryAfterClosed_weixinQueryException() { + PayClient client = mockWeixinPayClient(); + when(client.getTransfer(any())).thenThrow(new RuntimeException("模拟查询超时")); + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟发起超时")); + PayTransferDO closedTransfer = insertClosedWeixinTransfer(); + + PayTransferCreateRespDTO respDTO = transferService.createTransfer(buildWeixinTransferCreateReqDTO()); + + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId()); + assertEquals(closedTransfer.getNo(), dbTransfer.getNo()); + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus()); + verify(client, times(2)).getTransfer(eq(closedTransfer.getNo())); + } + private PayClient mockPayClient() { // mock 方法(app) 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 { return client; } + private PayClient mockWeixinPayClient() { + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer")); + when(appService.validPayApp(eq("demo"))).thenReturn(app); + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setAppId(1L) + .setCode(PayChannelEnum.WX_LITE.getCode())); + when(channelService.validPayChannel(eq(1L), eq(PayChannelEnum.WX_LITE.getCode()))).thenReturn(channel); + when(payProperties.getTransferNotifyUrl()).thenReturn("http://127.0.0.1"); + PayClient client = mock(PayClient.class); + when(channelService.getPayClient(eq(10L))).thenReturn(client); + return client; + } + /** * 插入关闭的转账单,与 {@link #buildTransferCreateReqDTO()} 的转账请求一致,并残留上一次转账的渠道结果 * @@ -804,6 +886,21 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest { return transfer; } + private PayTransferDO insertClosedWeixinTransfer() { + PayTransferDO transfer = randomPojo(PayTransferDO.class, o -> o.setAppId(1L).setChannelId(10L) + .setChannelCode(PayChannelEnum.WX_LITE.getCode()).setMerchantTransferId("1001").setPrice(100) + .setUserName("旧的收款人").setChannelExtras(Map.of("sceneId", "1005")) + .setStatus(PayTransferStatusEnum.CLOSED.getStatus()).setChannelTransferNo("WX_TRANSFER_001") + .setChannelErrorCode("CANCELLED").setChannelErrorMsg("用户超时未确认") + .setChannelPackageInfo("package-info-001")); + transferMapper.insert(transfer); + return transfer; + } + + private static PayTransferCreateReqDTO buildWeixinTransferCreateReqDTO() { + return buildTransferCreateReqDTO().setChannelCode(PayChannelEnum.WX_LITE.getCode()); + } + private void updateTransferStatus(String no, Integer status) { PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no); transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status));