Commit c8236e2514162efbd3284794b7d7c821c258865c

Authored by YunaiV
1 parent 5d67f1fe

fix(pay): 优化转账结果通知与渠道错误处理

yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/enums/ErrorCodeConstants.java
... ... @@ -71,6 +71,7 @@ public interface ErrorCodeConstants {
71 71 ErrorCode PAY_TRANSFER_CREATE_FAIL_STATUS_NOT_CLOSED = new ErrorCode(1_007_009_004, "转账发起失败,原因:已经存在相同的转账单,且状态不是已关闭");
72 72 ErrorCode PAY_TRANSFER_NOTIFY_FAIL_STATUS_IS_NOT_WAITING = new ErrorCode(1_007_009_006, "通知转账结果失败,原因:转账单不处于待转账");
73 73 ErrorCode PAY_TRANSFER_NOTIFY_FAIL_STATUS_NOT_WAITING_OR_PROCESSING = new ErrorCode(1_007_009_007, "通知转账结果失败,原因:转账单不处于待转账或转账中");
  74 + ErrorCode PAY_TRANSFER_SUBMIT_CHANNEL_ERROR = new ErrorCode(1_007_009_008, "发起转账报错,错误码:{},错误提示:{}");
74 75  
75 76 // ========== 示例订单 1-007-900-000 ==========
76 77 ErrorCode DEMO_ORDER_NOT_FOUND = new ErrorCode(1_007_900_000, "示例订单不存在");
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java
... ... @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
4 4 import cn.hutool.core.util.ObjectUtil;
5 5 import cn.hutool.core.util.StrUtil;
6 6 import cn.hutool.extra.spring.SpringUtil;
  7 +import cn.iocoder.yudao.framework.common.exception.ServiceException;
7 8 import cn.iocoder.yudao.framework.common.pojo.PageResult;
8 9 import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
9 10 import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
... ... @@ -35,8 +36,6 @@ import java.util.List;
35 36 import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
36 37 import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
37 38  
38   -// TODO @jason:等彻底实现完,单测写写;
39   -
40 39 /**
41 40 * 转账 Service 实现类
42 41 *
... ... @@ -96,19 +95,61 @@ public class PayTransferServiceImpl implements PayTransferService {
96 95 .setOutTransferNo(transfer.getNo())
97 96 .setNotifyUrl(genChannelTransferNotifyUrl(channel));
98 97 unifiedTransferResp = client.unifiedTransfer(transferUnifiedReq);
99   - // 4. 通知转账结果
100   - getSelf().notifyTransfer(channel, unifiedTransferResp);
101 98 } catch (Throwable e) {
102 99 // 注意这里仅打印异常,不进行抛出。
103 100 // 原因是:虽然调用支付渠道进行转账发生异常(网络请求超时),实际转账成功。这个结果,后续转账轮询可以拿到。
104 101 // 或者,使用相同 no 再次发起转账请求
105 102 log.error("[createTransfer][转账编号({}) requestDTO({}) 发生异常]", transfer.getId(), reqDTO, e);
106 103 }
  104 +
  105 + // 4. 通知转账结果
  106 + if (unifiedTransferResp != null) {
  107 + PayTransferDO latestTransfer = null;
  108 + try {
  109 + getSelf().notifyTransfer(channel, unifiedTransferResp);
  110 + } catch (ServiceException ex) {
  111 + // 由于转账回调、转账轮询可能同时更新转账单,导致存在并发更新问题,此时以最新的转账状态为准
  112 + latestTransfer = validateTransferNotifyConcurrent(transfer.getId(), ex);
  113 + log.warn("[createTransfer][transfer({}) channel({}) 转账结果({}) 通知时发生并发更新,最新状态为({})]",
  114 + transfer.getId(), channel.getId(), unifiedTransferResp, latestTransfer.getStatus(), ex);
  115 + }
  116 + // 如有渠道错误码,则抛出业务异常,提示用户
  117 + // 特殊:如果已被并发更新为成功,则以最新状态为准,避免旧的关闭结果误报
  118 + if (StrUtil.isNotEmpty(unifiedTransferResp.getChannelErrorCode())
  119 + && (latestTransfer == null || PayTransferStatusEnum.isClosed(latestTransfer.getStatus()))) {
  120 + throw exception(PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, unifiedTransferResp.getChannelErrorCode(),
  121 + unifiedTransferResp.getChannelErrorMsg());
  122 + }
  123 + }
107 124 return new PayTransferCreateRespDTO().setId(transfer.getId())
108 125 .setChannelPackageInfo(unifiedTransferResp != null ? unifiedTransferResp.getChannelPackageInfo() : null);
109 126 }
110 127  
111 128 /**
  129 + * 校验转账结果通知时的并发更新
  130 + *
  131 + * 只有状态竞争导致的通知失败,并且转账单已被并发更新为成功或关闭时,才返回最新的转账单;
  132 + * 否则,直接抛出原异常,避免掩盖真实的通知失败
  133 + *
  134 + * @param id 转账单编号
  135 + * @param ex 通知转账结果时的业务异常
  136 + * @return 最新的转账单
  137 + */
  138 + private PayTransferDO validateTransferNotifyConcurrent(Long id, ServiceException ex) {
  139 + // 1. 校验是否为状态竞争
  140 + if (ObjectUtil.notEqual(ex.getCode(), PAY_TRANSFER_NOTIFY_FAIL_STATUS_IS_NOT_WAITING.getCode())
  141 + && ObjectUtil.notEqual(ex.getCode(), PAY_TRANSFER_NOTIFY_FAIL_STATUS_NOT_WAITING_OR_PROCESSING.getCode())) {
  142 + throw ex;
  143 + }
  144 + // 2. 校验最新状态为成功或关闭
  145 + PayTransferDO latestTransfer = transferMapper.selectById(id);
  146 + if (latestTransfer == null || !PayTransferStatusEnum.isSuccessOrClosed(latestTransfer.getStatus())) {
  147 + throw ex;
  148 + }
  149 + return latestTransfer;
  150 + }
  151 +
  152 + /**
112 153 * 根据支付渠道的编码,生成支付渠道的回调地址
113 154 *
114 155 * @param channel 支付渠道
... ...
yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceTest.java
1 1 package cn.iocoder.yudao.module.pay.service.transfer;
2 2  
  3 +import cn.hutool.extra.spring.SpringUtil;
3 4 import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest;
  5 +import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateReqDTO;
  6 +import cn.iocoder.yudao.module.pay.api.transfer.dto.PayTransferCreateRespDTO;
  7 +import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
4 8 import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
5 9 import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
6 10 import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper;
7 11 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
  12 +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
  13 +import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
8 14 import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
9 15 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
  16 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient;
10 17 import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO;
  18 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferUnifiedReqDTO;
11 19 import cn.iocoder.yudao.module.pay.service.app.PayAppService;
12 20 import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
13 21 import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
14 22 import jakarta.annotation.Resource;
15 23 import org.junit.jupiter.api.Test;
  24 +import org.mockito.MockedStatic;
16 25 import org.springframework.context.annotation.Import;
17 26 import org.springframework.test.context.bean.override.mockito.MockitoBean;
18 27  
  28 +import java.time.LocalDateTime;
  29 +
  30 +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
19 31 import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
20   -import static org.junit.jupiter.api.Assertions.assertEquals;
  32 +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
  33 +import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
  34 +import static org.junit.jupiter.api.Assertions.*;
  35 +import static org.mockito.ArgumentMatchers.any;
  36 +import static org.mockito.ArgumentMatchers.eq;
  37 +import static org.mockito.Mockito.*;
21 38  
22 39 /**
23 40 * {@link PayTransferServiceImpl} 的单元测试类
... ... @@ -88,4 +105,286 @@ public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
88 105 assertEquals("package-info-110", dbTransfer.getChannelPackageInfo());
89 106 }
90 107  
  108 + @Test // 调用 unifiedTransfer 接口,发生异常(例如说:网络超时)
  109 + public void testCreateTransfer_unifiedTransferException() {
  110 + // mock 方法(client)
  111 + PayClient<?> client = mockPayClient();
  112 + when(client.unifiedTransfer(any())).thenThrow(new RuntimeException("模拟网络超时"));
  113 + // 准备参数
  114 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  115 +
  116 + // 调用:未知结果不抛出异常,保留 WAITING 等待轮询
  117 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  118 + // 断言
  119 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  120 + assertNotNull(dbTransfer);
  121 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  122 + assertNull(respDTO.getChannelPackageInfo());
  123 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  124 + }
  125 +
  126 + @Test // 调用 unifiedTransfer 接口,返回 null
  127 + public void testCreateTransfer_nullResponse() {
  128 + // mock 方法(client)
  129 + PayClient<?> client = mockPayClient();
  130 + when(client.unifiedTransfer(any())).thenReturn(null);
  131 + // 准备参数
  132 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  133 +
  134 + // 调用
  135 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  136 + // 断言
  137 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  138 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  139 + assertNull(respDTO.getChannelPackageInfo());
  140 + }
  141 +
  142 + @Test // 调用 unifiedTransfer 接口,返回存在渠道错误
  143 + public void testCreateTransfer_channelError() {
  144 + // mock 方法(client)
  145 + PayClient<?> client = mockPayClient();
  146 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  147 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  148 + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在",
  149 + unifiedReqDTO.getOutTransferNo(), "closed");
  150 + });
  151 + // 准备参数
  152 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  153 +
  154 + // 调用,并断言异常
  155 + assertServiceException(() -> transferService.createTransfer(reqDTO),
  156 + PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在");
  157 + // 断言:关闭状态已落库
  158 + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId());
  159 + assertNotNull(dbTransfer);
  160 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), dbTransfer.getStatus());
  161 + assertEquals("PAYEE_NOT_EXIST", dbTransfer.getChannelErrorCode());
  162 + assertEquals("收款账号不存在", dbTransfer.getChannelErrorMsg());
  163 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(dbTransfer.getId()));
  164 + }
  165 +
  166 + @Test // 调用 unifiedTransfer 接口,返回关闭,但不存在渠道错误
  167 + public void testCreateTransfer_closedWithoutChannelError() {
  168 + // mock 方法(client)
  169 + PayClient<?> client = mockPayClient();
  170 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  171 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  172 + return PayTransferRespDTO.closedOf(null, null, unifiedReqDTO.getOutTransferNo(), "closed");
  173 + });
  174 + // 准备参数
  175 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  176 +
  177 + // 调用
  178 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  179 + // 断言
  180 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  181 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), dbTransfer.getStatus());
  182 + }
  183 +
  184 + @Test // 调用 unifiedTransfer 接口,返回成功
  185 + public void testCreateTransfer_success() {
  186 + // mock 方法(client)
  187 + PayClient<?> client = mockPayClient();
  188 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  189 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  190 + return PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(),
  191 + unifiedReqDTO.getOutTransferNo(), "success");
  192 + });
  193 + // 准备参数
  194 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  195 +
  196 + // 调用
  197 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  198 + // 断言
  199 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  200 + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus());
  201 + assertEquals("CHANNEL_T001", dbTransfer.getChannelTransferNo());
  202 + verify(notifyService).createPayNotifyTask(eq(PayNotifyTypeEnum.TRANSFER.getType()), eq(dbTransfer.getId()));
  203 + }
  204 +
  205 + @Test // 调用 unifiedTransfer 接口,返回转账中
  206 + public void testCreateTransfer_processing() {
  207 + // mock 方法(client)
  208 + PayClient<?> client = mockPayClient();
  209 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  210 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  211 + PayTransferRespDTO resp = PayTransferRespDTO.processingOf("CHANNEL_T001",
  212 + unifiedReqDTO.getOutTransferNo(), "processing");
  213 + resp.setChannelPackageInfo("package-info-001");
  214 + return resp;
  215 + });
  216 + // 准备参数
  217 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  218 +
  219 + // 调用
  220 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  221 + // 断言
  222 + assertEquals("package-info-001", respDTO.getChannelPackageInfo());
  223 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  224 + assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus());
  225 + assertEquals("package-info-001", dbTransfer.getChannelPackageInfo());
  226 + }
  227 +
  228 + @Test // 调用 unifiedTransfer 接口返回成功,但通知转账结果时找不到转账单
  229 + public void testCreateTransfer_notifyTransferNotFound() {
  230 + // mock 方法(client):返回不存在的转账单号,使 notifyTransfer 抛出异常
  231 + PayClient<?> client = mockPayClient();
  232 + when(client.unifiedTransfer(any())).thenReturn(PayTransferRespDTO.successOf("CHANNEL_T001",
  233 + LocalDateTime.now(), "NOT_EXISTS", "success"));
  234 + // 准备参数
  235 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  236 +
  237 + // 调用,并断言异常:非并发更新的通知异常,不能被吞掉
  238 + assertServiceException(() -> transferService.createTransfer(reqDTO), PAY_TRANSFER_NOT_FOUND);
  239 + // 断言
  240 + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId());
  241 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  242 + }
  243 +
  244 + @Test // 调用 unifiedTransfer 接口返回存在渠道错误,但创建转账通知任务时发生异常
  245 + public void testCreateTransfer_channelErrorAndNotifyTaskException() {
  246 + // mock 方法(client)
  247 + PayClient<?> client = mockPayClient();
  248 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  249 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  250 + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在",
  251 + unifiedReqDTO.getOutTransferNo(), "closed");
  252 + });
  253 + // mock 方法(notify)
  254 + RuntimeException notifyTaskException = new RuntimeException("模拟创建通知任务异常");
  255 + doThrow(notifyTaskException).when(notifyService).createPayNotifyTask(any(), any());
  256 + // 准备参数
  257 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  258 +
  259 + // 调用,并断言异常:抛出真实的通知异常,而不是渠道错误
  260 + RuntimeException ex = assertThrows(RuntimeException.class, () -> transferService.createTransfer(reqDTO));
  261 + assertSame(notifyTaskException, ex);
  262 + // 断言:关闭状态随通知事务回滚
  263 + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId());
  264 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  265 + assertNull(dbTransfer.getChannelErrorCode());
  266 + }
  267 +
  268 + @Test // 调用 unifiedTransfer 接口返回存在渠道错误,但转账单已被并发更新为成功
  269 + public void testCreateTransfer_channelErrorWhenConcurrentSuccess() {
  270 + // mock 方法(client):返回前,转账回调已经将转账单更新为成功
  271 + PayClient<?> client = mockPayClient();
  272 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  273 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  274 + updateTransferStatus(unifiedReqDTO.getOutTransferNo(), PayTransferStatusEnum.SUCCESS.getStatus());
  275 + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在",
  276 + unifiedReqDTO.getOutTransferNo(), "closed");
  277 + });
  278 + // 准备参数
  279 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  280 +
  281 + // 调用:以最新的成功状态为准,不误报渠道错误
  282 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  283 + // 断言
  284 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  285 + assertEquals(PayTransferStatusEnum.SUCCESS.getStatus(), dbTransfer.getStatus());
  286 + assertNull(dbTransfer.getChannelErrorCode());
  287 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  288 + }
  289 +
  290 + @Test // 调用 unifiedTransfer 接口返回成功,但转账单已被并发更新为关闭
  291 + public void testCreateTransfer_successWhenConcurrentClosed() {
  292 + // mock 方法(client):返回前,转账轮询已经将转账单更新为关闭
  293 + PayClient<?> client = mockPayClient();
  294 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  295 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  296 + updateTransferStatus(unifiedReqDTO.getOutTransferNo(), PayTransferStatusEnum.CLOSED.getStatus());
  297 + return PayTransferRespDTO.successOf("CHANNEL_T001", LocalDateTime.now(),
  298 + unifiedReqDTO.getOutTransferNo(), "success");
  299 + });
  300 + // 准备参数
  301 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  302 +
  303 + // 调用:以最新的关闭状态为准
  304 + PayTransferCreateRespDTO respDTO = transferService.createTransfer(reqDTO);
  305 + // 断言
  306 + PayTransferDO dbTransfer = transferMapper.selectById(respDTO.getId());
  307 + assertEquals(PayTransferStatusEnum.CLOSED.getStatus(), dbTransfer.getStatus());
  308 + verify(notifyService, never()).createPayNotifyTask(any(), any());
  309 + }
  310 +
  311 + @Test // 调用 unifiedTransfer 接口返回存在渠道错误,通知时发生状态竞争,且转账单已被并发更新为关闭
  312 + public void testCreateTransfer_channelErrorWhenConcurrentClosed() {
  313 + PayTransferServiceImpl transferServiceImpl = mock(PayTransferServiceImpl.class);
  314 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  315 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayTransferServiceImpl.class)))
  316 + .thenReturn(transferServiceImpl);
  317 + doThrow(exception(PAY_TRANSFER_NOTIFY_FAIL_STATUS_NOT_WAITING_OR_PROCESSING))
  318 + .when(transferServiceImpl).notifyTransfer(any(PayChannelDO.class), any());
  319 + // mock 方法(client):返回前,转账轮询已经将转账单更新为关闭
  320 + PayClient<?> client = mockPayClient();
  321 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  322 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  323 + updateTransferStatus(unifiedReqDTO.getOutTransferNo(), PayTransferStatusEnum.CLOSED.getStatus());
  324 + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在",
  325 + unifiedReqDTO.getOutTransferNo(), "closed");
  326 + });
  327 + // 准备参数
  328 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  329 +
  330 + // 调用,并断言异常:已确认关闭,提示渠道错误
  331 + assertServiceException(() -> transferService.createTransfer(reqDTO),
  332 + PAY_TRANSFER_SUBMIT_CHANNEL_ERROR, "PAYEE_NOT_EXIST", "收款账号不存在");
  333 + }
  334 + }
  335 +
  336 + @Test // 调用 unifiedTransfer 接口返回存在渠道错误,通知时发生状态竞争,但转账单仍是待转账
  337 + public void testCreateTransfer_channelErrorWhenConcurrentWaiting() {
  338 + PayTransferServiceImpl transferServiceImpl = mock(PayTransferServiceImpl.class);
  339 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  340 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayTransferServiceImpl.class)))
  341 + .thenReturn(transferServiceImpl);
  342 + doThrow(exception(PAY_TRANSFER_NOTIFY_FAIL_STATUS_NOT_WAITING_OR_PROCESSING))
  343 + .when(transferServiceImpl).notifyTransfer(any(PayChannelDO.class), any());
  344 + // mock 方法(client)
  345 + PayClient<?> client = mockPayClient();
  346 + when(client.unifiedTransfer(any())).thenAnswer(invocation -> {
  347 + PayTransferUnifiedReqDTO unifiedReqDTO = invocation.getArgument(0);
  348 + return PayTransferRespDTO.closedOf("PAYEE_NOT_EXIST", "收款账号不存在",
  349 + unifiedReqDTO.getOutTransferNo(), "closed");
  350 + });
  351 + // 准备参数
  352 + PayTransferCreateReqDTO reqDTO = buildTransferCreateReqDTO();
  353 +
  354 + // 调用,并断言异常:无法确认最新状态,抛出原通知异常
  355 + assertServiceException(() -> transferService.createTransfer(reqDTO),
  356 + PAY_TRANSFER_NOTIFY_FAIL_STATUS_NOT_WAITING_OR_PROCESSING);
  357 + // 断言
  358 + PayTransferDO dbTransfer = transferMapper.selectByAppIdAndMerchantOrderId(1L, reqDTO.getMerchantTransferId());
  359 + assertEquals(PayTransferStatusEnum.WAITING.getStatus(), dbTransfer.getStatus());
  360 + }
  361 + }
  362 +
  363 + private PayClient<?> mockPayClient() {
  364 + // mock 方法(app)
  365 + PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setTransferNotifyUrl("http://127.0.0.1/transfer"));
  366 + when(appService.validPayApp(eq("demo"))).thenReturn(app);
  367 + // mock 方法(channel)
  368 + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setAppId(1L)
  369 + .setCode(PayChannelEnum.ALIPAY_PC.getCode()));
  370 + when(channelService.validPayChannel(eq(1L), eq(PayChannelEnum.ALIPAY_PC.getCode()))).thenReturn(channel);
  371 + when(payProperties.getTransferNotifyUrl()).thenReturn("http://127.0.0.1");
  372 + // mock 方法(client)
  373 + PayClient<?> client = mock(PayClient.class);
  374 + when(channelService.getPayClient(eq(10L))).thenReturn(client);
  375 + return client;
  376 + }
  377 +
  378 + private void updateTransferStatus(String no, Integer status) {
  379 + PayTransferDO transfer = transferMapper.selectByAppIdAndNo(1L, no);
  380 + transferMapper.updateById(new PayTransferDO().setId(transfer.getId()).setStatus(status));
  381 + }
  382 +
  383 + private static PayTransferCreateReqDTO buildTransferCreateReqDTO() {
  384 + return new PayTransferCreateReqDTO().setAppKey("demo").setChannelCode(PayChannelEnum.ALIPAY_PC.getCode())
  385 + .setUserIp("127.0.0.1").setUserId(1L).setUserType(1)
  386 + .setMerchantTransferId("1001").setSubject("佣金提现").setPrice(100)
  387 + .setUserAccount("test@example.com").setUserName("芋艿");
  388 + }
  389 +
91 390 }
... ...