diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/order/PayOrderExtensionMapper.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/order/PayOrderExtensionMapper.java index eebcd0e..9bc8be1 100755 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/order/PayOrderExtensionMapper.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/mysql/order/PayOrderExtensionMapper.java @@ -29,6 +29,14 @@ public interface PayOrderExtensionMapper extends BaseMapperX() + .eq(PayOrderExtensionDO::getOrderId, orderId) + .eq(PayOrderExtensionDO::getChannelId, channelId) + .eq(PayOrderExtensionDO::getStatus, status) + .orderByAsc(PayOrderExtensionDO::getId)); + } + default List selectListByStatusAndCreateTimeGe(Integer status, LocalDateTime minCreateTime) { return selectList(new LambdaQueryWrapper() .eq(PayOrderExtensionDO::getStatus, status) diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java index 1c98e39..8c43345 100644 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java @@ -44,6 +44,15 @@ public interface RedisKeyConstants { String PAY_TRANSFER_LOCK = "pay_transfer:lock:%d:%s"; /** + * 支付订单提交的分布式锁 + * + * KEY 格式:pay_order:lock:%d // 参数为支付订单编号 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构 + * 过期时间:不固定 + */ + String PAY_ORDER_LOCK = "pay_order:lock:%d"; + + /** * 支付序号的缓存 * * KEY 格式:pay_no:{prefix} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/order/PayOrderLockRedisDAO.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/order/PayOrderLockRedisDAO.java new file mode 100644 index 0000000..8228e15 --- /dev/null +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/order/PayOrderLockRedisDAO.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.pay.dal.redis.order; + +import jakarta.annotation.Resource; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_ORDER_LOCK; + +/** + * 支付订单提交的锁 Redis DAO + * + * @author HUIHUI + */ +@Repository +public class PayOrderLockRedisDAO { + + @Resource + private RedissonClient redissonClient; + + public V lock(Long orderId, Long timeoutMillis, Supplier supplier) { + String lockKey = formatKey(orderId); + RLock lock = redissonClient.getLock(lockKey); + try { + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS); + // 执行逻辑 + return supplier.get(); + } finally { + lock.unlock(); + } + } + + private static String formatKey(Long orderId) { + return String.format(PAY_ORDER_LOCK, orderId); + } + +} diff --git a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceImpl.java b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceImpl.java index 6d12827..f6e4bd5 100755 --- a/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceImpl.java +++ b/yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.extra.spring.SpringUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.date.DateUtils; import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; import cn.iocoder.yudao.framework.common.util.number.MoneyUtils; import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; @@ -24,6 +25,8 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO; import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper; import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.order.PayOrderLockRedisDAO; +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; @@ -57,6 +60,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; @Slf4j public class PayOrderServiceImpl implements PayOrderService { + /** + * 提交支付订单的锁超时时间,单位:毫秒 + */ + private static final long SUBMIT_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS; + @Resource private PayProperties payProperties; @@ -66,6 +74,8 @@ public class PayOrderServiceImpl implements PayOrderService { private PayOrderExtensionMapper orderExtensionMapper; @Resource private PayNoRedisDAO noRedisDAO; + @Resource + private PayOrderLockRedisDAO orderLockRedisDAO; @Resource private PayAppService appService; @@ -140,30 +150,45 @@ public class PayOrderServiceImpl implements PayOrderService { @Override // 注意,这里不能添加事务注解,避免调用支付渠道失败时,将 PayOrderExtensionDO 回滚了 public PayOrderSubmitRespVO submitOrder(PayOrderSubmitReqVO reqVO, String userIp) { + // 微信渠道会复用待支付的支付订单拓展,需加锁避免并发提交时重复创建 + if (isOrderExtensionReusable(reqVO.getChannelCode())) { + return orderLockRedisDAO.lock(reqVO.getId(), SUBMIT_LOCK_TIMEOUT_MILLIS, + () -> submitOrder0(reqVO, userIp)); + } + // 其它渠道每次提交都新建支付订单拓展,保持原有逻辑 + return submitOrder0(reqVO, userIp); + } + + private PayOrderSubmitRespVO submitOrder0(PayOrderSubmitReqVO reqVO, String userIp) { // 1.1 获得 PayOrderDO ,并校验其是否存在 PayOrderDO order = validateOrderCanSubmit(reqVO.getId()); // 1.32 校验支付渠道是否有效 PayChannelDO channel = validateChannelCanSubmit(order.getAppId(), reqVO.getChannelCode()); PayClient client = channelService.getPayClient(channel.getId()); - // 2. 插入 PayOrderExtensionDO - String no = noRedisDAO.generate(payProperties.getOrderNoPrefix()); - PayOrderExtensionDO orderExtension = PayOrderConvert.INSTANCE.convert(reqVO, userIp) - .setOrderId(order.getId()).setNo(no) - .setChannelId(channel.getId()).setChannelCode(channel.getCode()) - .setStatus(PayOrderStatusEnum.WAITING.getStatus()); - orderExtensionMapper.insert(orderExtension); + // 2. 获得 PayOrderExtensionDO:优先复用待支付的拓展单,避免重复创建渠道订单 + PayOrderExtensionDO orderExtension = getReusableOrderExtension(order, channel, reqVO, userIp); + boolean reused = orderExtension != null; + if (!reused) { + orderExtension = createOrderExtension(order, channel, reqVO, userIp); + } - // 3. 调用三方接口 + // 3.1 调用三方接口 PayOrderUnifiedReqDTO unifiedOrderReqDTO = PayOrderConvert.INSTANCE.convert2(reqVO, userIp) // 商户相关的字段 .setOutTradeNo(orderExtension.getNo()) // 注意,此处使用的是 PayOrderExtensionDO.no 属性! + .setUserIp(orderExtension.getUserIp()) // 复用时,需保持与原渠道订单的参数一致 .setSubject(order.getSubject()).setBody(order.getBody()) .setNotifyUrl(genChannelOrderNotifyUrl(channel)) .setReturnUrl(reqVO.getReturnUrl()) // 订单相关字段 .setPrice(order.getPrice()).setExpireTime(order.getExpireTime()); PayOrderRespDTO unifiedOrderResp = client.unifiedOrder(unifiedOrderReqDTO); + // 3.2 复用的拓展单下单失败时,以渠道订单的真实状态为准,决定是否新建拓展单重试 + if (reused && unifiedOrderResp != null && PayOrderStatusEnum.isClosed(unifiedOrderResp.getStatus())) { + unifiedOrderResp = retryUnifiedOrder(order, channel, client, reqVO, userIp, + orderExtension, unifiedOrderReqDTO, unifiedOrderResp); + } // 4. 如果调用直接支付成功,则直接更新支付单状态为成功。例如说:付款码支付,免密支付时,就直接验证支付成功 if (unifiedOrderResp != null) { @@ -186,6 +211,163 @@ public class PayOrderServiceImpl implements PayOrderService { return PayOrderConvert.INSTANCE.convert(order, unifiedOrderResp); } + /** + * 创建支付订单拓展 + * + * @param order 支付订单 + * @param channel 支付渠道 + * @param reqVO 支付订单提交参数 + * @param userIp 用户 IP + * @return 支付订单拓展 + */ + private PayOrderExtensionDO createOrderExtension(PayOrderDO order, PayChannelDO channel, + PayOrderSubmitReqVO reqVO, String userIp) { + String no = noRedisDAO.generate(payProperties.getOrderNoPrefix()); + PayOrderExtensionDO orderExtension = PayOrderConvert.INSTANCE.convert(reqVO, userIp) + .setOrderId(order.getId()).setNo(no) + .setChannelId(channel.getId()).setChannelCode(channel.getCode()) + .setStatus(PayOrderStatusEnum.WAITING.getStatus()); + orderExtensionMapper.insert(orderExtension); + return orderExtension; + } + + /** + * 获得可复用的支付订单拓展 + * + * 需满足:同渠道、同 channelExtras、未下单失败过的待支付拓展单,且创建后支付订单未被修改(例如说:改价) + * + * @param order 支付订单 + * @param channel 支付渠道 + * @param reqVO 提交请求 + * @param userIp 用户 IP + * @return 支付订单拓展;不可复用时,返回 null + */ + private PayOrderExtensionDO getReusableOrderExtension(PayOrderDO order, PayChannelDO channel, + PayOrderSubmitReqVO reqVO, String userIp) { + if (!isOrderExtensionReusable(channel.getCode())) { + return null; + } + PayOrderExtensionDO orderExtension = orderExtensionMapper.selectLastByOrderIdAndChannelIdAndStatus( + order.getId(), channel.getId(), PayOrderStatusEnum.WAITING.getStatus()); + if (orderExtension == null + || ObjectUtil.notEqual(orderExtension.getChannelExtras(), reqVO.getChannelExtras())) { + return null; + } + // 已记录渠道结果(复用下单失败过)的拓展单,不再复用,避免每次提交都重复失败 + if (StrUtil.isNotEmpty(orderExtension.getChannelNotifyData())) { + return null; + } + // 微信 H5 支付会校验拉起支付的用户 IP 与下单 IP 一致,IP 变化(例如说:切换网络)时不能复用 + if (PayChannelEnum.WX_WAP.getCode().equals(channel.getCode()) + && ObjectUtil.notEqual(orderExtension.getUserIp(), userIp)) { + return null; + } + // 拓展单创建后,支付订单被修改过(例如说:改价),渠道订单参数可能不一致,不能复用 + if (isOrderUpdatedAfter(order, orderExtension.getCreateTime())) { + return null; + } + return orderExtension; + } + + /** + * 判断支付订单在指定时间(含)之后,是否可能被修改过 + * + * 注意:数据库时间精度为秒,同一秒内无法区分修改与拓展单创建的先后(例如说:创建订单、创建拓展单、改价都在同一秒), + * 所以修改时间需严格早于拓展单的创建时间,同一秒内统一按已修改处理,避免改价后误复用。 + * 代价是:支付订单与拓展单在同一秒内创建时,再次提交会新建一次拓展单 + * + * @param order 支付订单 + * @param time 拓展单的创建时间 + * @return 是否可能被修改过 + */ + private static boolean isOrderUpdatedAfter(PayOrderDO order, LocalDateTime time) { + if (order.getUpdateTime() == null || time == null) { + return true; + } + return !order.getUpdateTime().isBefore(time); + } + + /** + * 复用的支付订单拓展下单失败时,查询渠道订单的真实状态后,再决定是否重试 + * + * 原因是:微信下单失败(已支付、系统异常、参数不一致等),都会被转换成 CLOSED 状态,不能直接认为渠道订单已关闭。 + * 只有渠道订单确认关闭后,才新建拓展单重试,保证同一时刻最多只有一个可支付的复用渠道订单 + * + * @return 下单结果 + */ + private PayOrderRespDTO retryUnifiedOrder(PayOrderDO order, PayChannelDO channel, PayClient client, + PayOrderSubmitReqVO reqVO, String userIp, + PayOrderExtensionDO orderExtension, + PayOrderUnifiedReqDTO unifiedOrderReqDTO, + PayOrderRespDTO unifiedOrderResp) { + // 1. 查询渠道订单的真实状态 + PayOrderRespDTO channelOrderResp = null; + try { + channelOrderResp = client.getOrder(orderExtension.getNo()); + } catch (Exception ex) { + log.error("[retryUnifiedOrder][orderExtension({}) 查询渠道订单异常,下单失败({})]", + orderExtension.getId(), toJsonString(unifiedOrderResp), ex); + } + Integer channelStatus = channelOrderResp != null ? channelOrderResp.getStatus() : null; + // 1.1 渠道订单已支付(例如说:回调延迟),按支付成功处理 + if (PayOrderStatusEnum.isSuccess(channelStatus)) { + log.warn("[retryUnifiedOrder][orderExtension({}) 渠道订单已支付,可能是回调延迟]", orderExtension.getId()); + return channelOrderResp; + } + // 2. 渠道订单未确认关闭(待支付、已退款、状态未知或查询异常),先主动关闭,避免新旧渠道订单都可支付,导致重复支付 + if (!PayOrderStatusEnum.isClosed(channelStatus)) { + PayOrderRespDTO closeResp = null; + try { + closeResp = client.closeOrder(orderExtension.getNo()); + } catch (Exception ex) { + log.error("[retryUnifiedOrder][orderExtension({}) 关闭渠道订单异常]", orderExtension.getId(), ex); + } + Integer closeStatus = closeResp != null ? closeResp.getStatus() : null; + // 2.1 关闭时发现已支付,按支付成功处理 + if (PayOrderStatusEnum.isSuccess(closeStatus)) { + log.warn("[retryUnifiedOrder][orderExtension({}) 关闭时发现渠道订单已支付]", orderExtension.getId()); + return closeResp; + } + // 2.2 无法确认已关闭,不新建拓展单,提示渠道错误;原拓展单保持可复用,用户再次提交时重新确认 + if (!PayOrderStatusEnum.isClosed(closeStatus)) { + log.warn("[retryUnifiedOrder][orderExtension({}) 渠道状态({}) 关闭结果({}),下单失败({})]", + orderExtension.getId(), channelStatus, toJsonString(closeResp), toJsonString(unifiedOrderResp)); + throw exception(PAY_ORDER_SUBMIT_CHANNEL_ERROR, unifiedOrderResp.getChannelErrorCode(), + unifiedOrderResp.getChannelErrorMsg()); + } + } + + // 3. 记录原拓展单的下单失败结果,后续提交不再复用它 + // 注意:本地拓展单保持待支付状态,最终状态交由回调、同步、过期任务处理,确保渠道订单实际支付成功时仍可正常处理 + int updateCounts = orderExtensionMapper.updateByIdAndStatus(orderExtension.getId(), + PayOrderStatusEnum.WAITING.getStatus(), PayOrderExtensionDO.builder() + .channelErrorCode(unifiedOrderResp.getChannelErrorCode()) + .channelErrorMsg(unifiedOrderResp.getChannelErrorMsg()) + .channelNotifyData(toJsonString(unifiedOrderResp)).build()); + if (updateCounts == 0) { // 校验状态,必须是待支付;例如说:并发回调已支付 + throw exception(PAY_ORDER_EXTENSION_STATUS_IS_NOT_WAITING); + } + // 4. 渠道订单已关闭,新建拓展单重试 + log.info("[retryUnifiedOrder][orderExtension({}) 渠道订单已关闭,新建拓展单重试]", orderExtension.getId()); + PayOrderExtensionDO newOrderExtension = createOrderExtension(order, channel, reqVO, userIp); + unifiedOrderReqDTO.setOutTradeNo(newOrderExtension.getNo()).setUserIp(newOrderExtension.getUserIp()); + return client.unifiedOrder(unifiedOrderReqDTO); + } + + /** + * 判断支付渠道是否可复用待支付的支付订单拓展 + * + * 1. 微信付款码:authCode 只能使用一次,不可复用 + * 2. 支付宝、钱包等其它渠道:保持每次提交新建 + * + * @param channelCode 渠道编码 + * @return 是否可复用 + */ + private static boolean isOrderExtensionReusable(String channelCode) { + return PayChannelEnum.isWeixin(channelCode) + && ObjectUtil.notEqual(PayChannelEnum.WX_BAR.getCode(), channelCode); + } + private PayOrderDO validateOrderCanSubmit(Long id) { PayOrderDO order = orderMapper.selectById(id); if (order == null) { // 是否存在 diff --git a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceTest.java b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceTest.java index 6481707..2b9907e 100755 --- a/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceTest.java +++ b/yudao-module-pay/src/test/java/cn/iocoder/yudao/module/pay/service/order/PayOrderServiceTest.java @@ -15,12 +15,15 @@ import cn.iocoder.yudao.module.pay.dal.dataobject.order.PayOrderExtensionDO; import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper; import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper; import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO; +import cn.iocoder.yudao.module.pay.dal.redis.order.PayOrderLockRedisDAO; import cn.iocoder.yudao.module.pay.enums.PayChannelEnum; import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum; import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum; import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties; import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient; import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderRespDTO; +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO; +import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException; import cn.iocoder.yudao.module.pay.framework.pay.core.enums.PayOrderDisplayModeEnum; import cn.iocoder.yudao.module.pay.service.app.PayAppService; import cn.iocoder.yudao.module.pay.service.channel.PayChannelService; @@ -34,7 +37,9 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean; import java.time.Duration; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.*; import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString; @@ -46,6 +51,7 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -54,7 +60,7 @@ import static org.mockito.Mockito.*; * * @author 芋艿 */ -@Import({PayOrderServiceImpl.class, PayNoRedisDAO.class}) +@Import({PayOrderServiceImpl.class, PayNoRedisDAO.class, PayOrderLockRedisDAO.class}) public class PayOrderServiceTest extends BaseDbAndRedisUnitTest { @Resource @@ -443,6 +449,632 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest { } @Test + public void testSubmitOrder_wechatReuseWaitingExtension() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null)); + PayOrderRespDTO unifiedOrderResp = PayOrderRespDTO.waitingOf( + PayOrderDisplayModeEnum.APP.getMode(), "pay-content", orderExtension.getNo(), null); + when(client.unifiedOrder(any())).thenAnswer(invocation -> { + PayOrderUnifiedReqDTO request = invocation.getArgument(0); + assertEquals(orderExtension.getNo(), request.getOutTradeNo()); + assertEquals(orderExtension.getUserIp(), request.getUserIp()); + return unifiedOrderResp; + }); + + // 调用 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString()); + // 断言:复用原拓展单,不新增记录 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1); + assertEquals("pay-content", result.getDisplayContent()); + verify(client).unifiedOrder(any()); + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(unifiedOrderResp)); + } + } + + @Test + public void testSubmitOrder_wechatReuseClosed_thenCreateNewExtension() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):原渠道订单已关闭,新渠道订单待支付 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(anyString())).thenAnswer(invocation -> + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null)); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null)); + PayOrderRespDTO closedResp = PayOrderRespDTO.closedOf("ORDERCLOSED", "订单已关闭", oldExtension.getNo(), null); + PayOrderRespDTO waitingResp = PayOrderRespDTO.waitingOf( + PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null); + List outTradeNos = new ArrayList<>(); + when(client.unifiedOrder(any())).thenAnswer(invocation -> { + PayOrderUnifiedReqDTO request = invocation.getArgument(0); + outTradeNos.add(request.getOutTradeNo()); + return oldExtension.getNo().equals(request.getOutTradeNo()) ? closedResp : waitingResp; + }); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:原拓展单保持待支付,并记录下单失败结果;新建拓展单发起支付 + List extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(2); + PayOrderExtensionDO dbOldExtension = orderExtensionMapper.selectById(oldExtension.getId()); + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), dbOldExtension.getStatus()); + assertEquals("ORDERCLOSED", dbOldExtension.getChannelErrorCode()); + assertEquals(toJsonString(closedResp), dbOldExtension.getChannelNotifyData()); + PayOrderExtensionDO newExtension = extensions.stream() + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow(); + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo()); + // 断言:只处理新拓展单的下单结果,不能用原拓展单的失败结果更新状态 + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(waitingResp)); + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), same(closedResp)); + verify(client, never()).closeOrder(anyString()); + + // 再次调用:复用新拓展单,不再使用原拓展单下单 + orderService.submitOrder(reqVO, randomString()); + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo(), newExtension.getNo()); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeThenCreateNewExtension() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):原拓展单下单失败,渠道订单仍待支付,主动关闭成功 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(anyString())).thenAnswer(invocation -> + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null)); + when(client.closeOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null)); + List outTradeNos = new ArrayList<>(); + when(client.unifiedOrder(any())).thenAnswer(invocation -> { + PayOrderUnifiedReqDTO request = invocation.getArgument(0); + outTradeNos.add(request.getOutTradeNo()); + return oldExtension.getNo().equals(request.getOutTradeNo()) + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null) + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null); + }); + + // 第一次调用:先关闭原渠道订单,再新建拓展单发起支付 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString()); + assertEquals("pay-content", result.getDisplayContent()); + verify(client).closeOrder(eq(oldExtension.getNo())); + // 断言:原拓展单保持待支付,并记录下单失败结果;新建拓展单发起支付 + List extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(2); + PayOrderExtensionDO dbOldExtension = orderExtensionMapper.selectById(oldExtension.getId()); + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), dbOldExtension.getStatus()); + assertEquals("SYSTEMERROR", dbOldExtension.getChannelErrorCode()); + assertNotNull(dbOldExtension.getChannelNotifyData()); + PayOrderExtensionDO newExtension = extensions.stream() + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow(); + // 第二次调用:复用新拓展单 + orderService.submitOrder(reqVO, randomString()); + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo(), newExtension.getNo()); + verify(client).closeOrder(anyString()); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeFail() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):原拓展单下单失败,渠道订单仍待支付,第一次关闭失败(例如说:用户支付中),第二次关闭成功 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(anyString())).thenAnswer(invocation -> + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null)); + when(client.closeOrder(eq(oldExtension.getNo()))) + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)) + .thenReturn(PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null)); + List outTradeNos = new ArrayList<>(); + when(client.unifiedOrder(any())).thenAnswer(invocation -> { + PayOrderUnifiedReqDTO request = invocation.getArgument(0); + outTradeNos.add(request.getOutTradeNo()); + return oldExtension.getNo().equals(request.getOutTradeNo()) + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null) + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null); + }); + + // 第一次调用,并断言异常 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()), + PAY_ORDER_SUBMIT_CHANNEL_ERROR, "SYSTEMERROR", "系统错误"); + // 断言:无法确认原渠道订单已关闭,不新建拓展单,也不记录下单失败结果,避免存在两个可支付的渠道订单 + List extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(1); + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), extensions.get(0).getStatus()); + assertNull(extensions.get(0).getChannelNotifyData()); + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), any(PayOrderRespDTO.class)); + + // 第二次调用:仍复用原拓展单,关闭成功后,再新建拓展单发起支付 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString()); + assertEquals("pay-content", result.getDisplayContent()); + extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(2); + PayOrderExtensionDO newExtension = extensions.stream() + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow(); + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), oldExtension.getNo(), newExtension.getNo()); + verify(client, times(2)).closeOrder(eq(oldExtension.getNo())); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeFoundPaid() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):原拓展单下单失败,查询时待支付,关闭时发现已支付 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + PayOrderRespDTO successResp = PayOrderRespDTO.successOf(randomString(), randomString(), + LocalDateTime.now(), oldExtension.getNo(), null); + when(client.closeOrder(eq(oldExtension.getNo()))).thenReturn(successResp); + when(client.unifiedOrder(any())).thenReturn( + PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", oldExtension.getNo(), null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:不新建拓展单,按渠道已支付结果处理 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1); + verify(client).unifiedOrder(any()); + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(successResp)); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_channelSuccess() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):提交校验时待支付,下单时已支付(回调延迟) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + PayOrderRespDTO successResp = PayOrderRespDTO.successOf(randomString(), randomString(), + LocalDateTime.now(), orderExtension.getNo(), null); + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null), successResp); + when(client.unifiedOrder(any())).thenReturn( + PayOrderRespDTO.closedOf("ORDERPAID", "订单已支付", orderExtension.getNo(), null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:不新建拓展单,按渠道已支付结果处理 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1); + verify(client).unifiedOrder(any()); + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(successResp)); + } + } + + @Test + public void testSubmitOrder_wechatOrderUpdated_notReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension):拓展单创建后,支付订单被改价 + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + PayOrderDO updateObj = new PayOrderDO().setId(order.getId()).setPrice(order.getPrice() + 1); + updateObj.setUpdateTime(LocalDateTime.now()); + orderMapper.updateById(updateObj); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatChannelExtrasChanged_notReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, + Map.of("openid", "old-openid")); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(Map.of("openid", "new-openid"))); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatOrderUpdatedSameSecond_notReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension):拓展单创建后,同一秒内支付订单被改价,数据库时间相等 + Map channelExtras = Map.of("openid", "openid"); + LocalDateTime time = LocalDateTime.now().withNano(0).minusMinutes(1); + PayOrderDO order = insertSubmitOrder(time.minusMinutes(10), time); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, + channelExtras, time); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:无法区分先后,按已改价处理,新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatNewOrderSameSecond_notReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension):支付订单与拓展单在同一秒内创建,数据库时间相等(可能同一秒内被改价) + Map channelExtras = Map.of("openid", "openid"); + LocalDateTime time = LocalDateTime.now().withNano(0).minusMinutes(1); + PayOrderDO order = insertSubmitOrder(time, time); + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, + channelExtras, time); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !orderExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:同一秒内无法排除改价,按已修改处理,新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_queryException() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):提交校验时待支付,下单失败后查询、关闭渠道订单都异常,之后恢复正常 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(anyString())).thenAnswer(invocation -> + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null)); + when(client.getOrder(eq(oldExtension.getNo()))) + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)) + .thenThrow(new PayClientException(new RuntimeException("网络超时"))) + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.closeOrder(eq(oldExtension.getNo()))) + .thenThrow(new PayClientException(new RuntimeException("网络超时"))) + .thenReturn(PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null)); + List outTradeNos = new ArrayList<>(); + when(client.unifiedOrder(any())).thenAnswer(invocation -> { + PayOrderUnifiedReqDTO request = invocation.getArgument(0); + outTradeNos.add(request.getOutTradeNo()); + return oldExtension.getNo().equals(request.getOutTradeNo()) + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null) + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null); + }); + + // 第一次调用,并断言异常:查询、关闭异常转换为业务异常 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()), + PAY_ORDER_SUBMIT_CHANNEL_ERROR, "SYSTEMERROR", "系统错误"); + // 断言:渠道状态未知,不新建拓展单,也不记录下单失败结果 + List extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(1); + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), extensions.get(0).getStatus()); + assertNull(extensions.get(0).getChannelNotifyData()); + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), any(PayOrderRespDTO.class)); + + // 第二次调用:渠道恢复后,关闭原渠道订单,再新建拓展单发起支付 + orderService.submitOrder(reqVO, randomString()); + extensions = orderExtensionMapper.selectListByOrderId(order.getId()); + assertThat(extensions).hasSize(2); + PayOrderExtensionDO newExtension = extensions.stream() + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow(); + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), oldExtension.getNo(), newExtension.getNo()); + } + } + + @Test + public void testSubmitOrder_wechatReuseFail_extensionPaidConcurrently() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("openid", "openid"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client):下单失败后,渠道订单已关闭,但原拓展单被并发回调更新为已支付 + PayClient client = mockSubmitClient(PayChannelEnum.WX_PUB); + when(client.getOrder(eq(orderExtension.getNo()))) + .thenReturn(PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null)) + .thenAnswer(invocation -> { + orderExtensionMapper.updateById(new PayOrderExtensionDO().setId(orderExtension.getId()) + .setStatus(PayOrderStatusEnum.SUCCESS.getStatus())); + return PayOrderRespDTO.closedOf(null, null, orderExtension.getNo(), null); + }); + when(client.unifiedOrder(any())).thenReturn( + PayOrderRespDTO.closedOf("ORDERCLOSED", "订单已关闭", orderExtension.getNo(), null)); + + // 调用,并断言异常 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()), + PAY_ORDER_EXTENSION_STATUS_IS_NOT_WAITING); + // 断言:不新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatWapUserIpChanged_notReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("key", "value"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_WAP, channelExtras); + // 准备参数:用户 IP 变化(例如说:切换网络) + String userIp = "10.0.0.1"; + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_WAP.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_WAP); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()) + && userIp.equals(request.getUserIp())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.URL.getMode(), "pay-url", null, null)); + + // 调用 + orderService.submitOrder(reqVO, userIp); + // 断言:新建拓展单,使用新的用户 IP 下单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatWapUserIpSame_reuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("key", "value"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_WAP, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_WAP.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_WAP); + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> orderExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.URL.getMode(), "pay-url", null, null)); + + // 调用 + orderService.submitOrder(reqVO, orderExtension.getUserIp()); + // 断言:用户 IP 一致,复用原拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_alipayNotReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("key", "value"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.ALIPAY_APP, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.ALIPAY_APP.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.ALIPAY_APP); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:支付宝保持每次新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + @Test + public void testSubmitOrder_wechatBarNotReuse() { + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class); + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class))) + .thenReturn(payOrderServiceImpl); + + // mock 数据(order、orderExtension) + Map channelExtras = Map.of("authCode", "134567890123456789"); + PayOrderDO order = insertSubmitOrder(); + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_BAR, channelExtras); + // 准备参数 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId()) + .setChannelCode(PayChannelEnum.WX_BAR.getCode()).setChannelExtras(channelExtras)); + // mock 方法(app、channel、client) + PayClient client = mockSubmitClient(PayChannelEnum.WX_BAR); + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn( + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null)); + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())))) + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.BAR_CODE.getMode(), "", null, null)); + + // 调用 + orderService.submitOrder(reqVO, randomString()); + // 断言:微信付款码的 authCode 只能使用一次,保持每次新建拓展单 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2); + verify(client).unifiedOrder(any()); + } + } + + private PayOrderDO insertSubmitOrder() { + LocalDateTime time = LocalDateTime.now().minusMinutes(10); + return insertSubmitOrder(time, time); + } + + private PayOrderDO insertSubmitOrder(LocalDateTime createTime, LocalDateTime updateTime) { + PayOrderDO order = randomPojo(PayOrderDO.class, o -> o.setStatus(PayOrderStatusEnum.WAITING.getStatus()) + .setAppId(1L).setExpireTime(addTime(Duration.ofDays(1)))); + order.setCreateTime(createTime).setUpdateTime(updateTime); + orderMapper.insert(order); + return order; + } + + private PayOrderExtensionDO insertSubmitOrderExtension(PayOrderDO order, PayChannelEnum channelEnum, + Map channelExtras) { + return insertSubmitOrderExtension(order, channelEnum, channelExtras, LocalDateTime.now().minusMinutes(5)); + } + + private PayOrderExtensionDO insertSubmitOrderExtension(PayOrderDO order, PayChannelEnum channelEnum, + Map channelExtras, LocalDateTime createTime) { + PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class, o -> o.setOrderId(order.getId()) + .setChannelId(10L).setChannelCode(channelEnum.getCode()) + .setStatus(PayOrderStatusEnum.WAITING.getStatus()).setChannelExtras(channelExtras) + .setChannelErrorCode(null).setChannelErrorMsg(null).setChannelNotifyData(null) + .setCreateTime(createTime)); + orderExtensionMapper.insert(orderExtension); + return orderExtension; + } + + private PayClient mockSubmitClient(PayChannelEnum channelEnum) { + when(appService.validPayApp(eq(1L))).thenReturn(randomPojo(PayAppDO.class, o -> o.setId(1L))); + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setCode(channelEnum.getCode())); + when(channelService.validPayChannel(eq(1L), eq(channelEnum.getCode()))).thenReturn(channel); + PayClient client = mock(PayClient.class); + when(channelService.getPayClient(eq(10L))).thenReturn(client); + return client; + } + + @Test public void testValidateOrderActuallyPaid_dbPaid() { // 准备参数 Long id = randomLongId();