Commit dbcea51d28e46933b4c5668b8416d7d98b693c5e

Authored by YunaiV
1 parent 112cd752

fix(pay): safely reuse payment order extensions on resubmit

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<PayOrderExtensionDO
29 29 PayOrderExtensionDO::getStatus, status);
30 30 }
31 31  
  32 + default PayOrderExtensionDO selectLastByOrderIdAndChannelIdAndStatus(Long orderId, Long channelId, Integer status) {
  33 + return selectLastOne(new LambdaQueryWrapper<PayOrderExtensionDO>()
  34 + .eq(PayOrderExtensionDO::getOrderId, orderId)
  35 + .eq(PayOrderExtensionDO::getChannelId, channelId)
  36 + .eq(PayOrderExtensionDO::getStatus, status)
  37 + .orderByAsc(PayOrderExtensionDO::getId));
  38 + }
  39 +
32 40 default List<PayOrderExtensionDO> selectListByStatusAndCreateTimeGe(Integer status, LocalDateTime minCreateTime) {
33 41 return selectList(new LambdaQueryWrapper<PayOrderExtensionDO>()
34 42 .eq(PayOrderExtensionDO::getStatus, status)
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/RedisKeyConstants.java
... ... @@ -44,6 +44,15 @@ public interface RedisKeyConstants {
44 44 String PAY_TRANSFER_LOCK = "pay_transfer:lock:%d:%s";
45 45  
46 46 /**
  47 + * 支付订单提交的分布式锁
  48 + *
  49 + * KEY 格式:pay_order:lock:%d // 参数为支付订单编号
  50 + * VALUE 数据格式:HASH // RLock.class:Redisson 的 Lock 锁,使用 Hash 数据结构
  51 + * 过期时间:不固定
  52 + */
  53 + String PAY_ORDER_LOCK = "pay_order:lock:%d";
  54 +
  55 + /**
47 56 * 支付序号的缓存
48 57 *
49 58 * KEY 格式:pay_no:{prefix}
... ...
yudao-module-pay/src/main/java/cn/iocoder/yudao/module/pay/dal/redis/order/PayOrderLockRedisDAO.java 0 → 100644
  1 +package cn.iocoder.yudao.module.pay.dal.redis.order;
  2 +
  3 +import jakarta.annotation.Resource;
  4 +import org.redisson.api.RLock;
  5 +import org.redisson.api.RedissonClient;
  6 +import org.springframework.stereotype.Repository;
  7 +
  8 +import java.util.concurrent.TimeUnit;
  9 +import java.util.function.Supplier;
  10 +
  11 +import static cn.iocoder.yudao.module.pay.dal.redis.RedisKeyConstants.PAY_ORDER_LOCK;
  12 +
  13 +/**
  14 + * 支付订单提交的锁 Redis DAO
  15 + *
  16 + * @author HUIHUI
  17 + */
  18 +@Repository
  19 +public class PayOrderLockRedisDAO {
  20 +
  21 + @Resource
  22 + private RedissonClient redissonClient;
  23 +
  24 + public <V> V lock(Long orderId, Long timeoutMillis, Supplier<V> supplier) {
  25 + String lockKey = formatKey(orderId);
  26 + RLock lock = redissonClient.getLock(lockKey);
  27 + try {
  28 + lock.lock(timeoutMillis, TimeUnit.MILLISECONDS);
  29 + // 执行逻辑
  30 + return supplier.get();
  31 + } finally {
  32 + lock.unlock();
  33 + }
  34 + }
  35 +
  36 + private static String formatKey(Long orderId) {
  37 + return String.format(PAY_ORDER_LOCK, orderId);
  38 + }
  39 +
  40 +}
... ...
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;
5 5 import cn.hutool.core.util.StrUtil;
6 6 import cn.hutool.extra.spring.SpringUtil;
7 7 import cn.iocoder.yudao.framework.common.pojo.PageResult;
  8 +import cn.iocoder.yudao.framework.common.util.date.DateUtils;
8 9 import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils;
9 10 import cn.iocoder.yudao.framework.common.util.number.MoneyUtils;
10 11 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;
24 25 import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper;
25 26 import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper;
26 27 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
  28 +import cn.iocoder.yudao.module.pay.dal.redis.order.PayOrderLockRedisDAO;
  29 +import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
27 30 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
28 31 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
29 32 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
... ... @@ -57,6 +60,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
57 60 @Slf4j
58 61 public class PayOrderServiceImpl implements PayOrderService {
59 62  
  63 + /**
  64 + * 提交支付订单的锁超时时间,单位:毫秒
  65 + */
  66 + private static final long SUBMIT_LOCK_TIMEOUT_MILLIS = 120 * DateUtils.SECOND_MILLIS;
  67 +
60 68 @Resource
61 69 private PayProperties payProperties;
62 70  
... ... @@ -66,6 +74,8 @@ public class PayOrderServiceImpl implements PayOrderService {
66 74 private PayOrderExtensionMapper orderExtensionMapper;
67 75 @Resource
68 76 private PayNoRedisDAO noRedisDAO;
  77 + @Resource
  78 + private PayOrderLockRedisDAO orderLockRedisDAO;
69 79  
70 80 @Resource
71 81 private PayAppService appService;
... ... @@ -140,30 +150,45 @@ public class PayOrderServiceImpl implements PayOrderService {
140 150  
141 151 @Override // 注意,这里不能添加事务注解,避免调用支付渠道失败时,将 PayOrderExtensionDO 回滚了
142 152 public PayOrderSubmitRespVO submitOrder(PayOrderSubmitReqVO reqVO, String userIp) {
  153 + // 微信渠道会复用待支付的支付订单拓展,需加锁避免并发提交时重复创建
  154 + if (isOrderExtensionReusable(reqVO.getChannelCode())) {
  155 + return orderLockRedisDAO.lock(reqVO.getId(), SUBMIT_LOCK_TIMEOUT_MILLIS,
  156 + () -> submitOrder0(reqVO, userIp));
  157 + }
  158 + // 其它渠道每次提交都新建支付订单拓展,保持原有逻辑
  159 + return submitOrder0(reqVO, userIp);
  160 + }
  161 +
  162 + private PayOrderSubmitRespVO submitOrder0(PayOrderSubmitReqVO reqVO, String userIp) {
143 163 // 1.1 获得 PayOrderDO ,并校验其是否存在
144 164 PayOrderDO order = validateOrderCanSubmit(reqVO.getId());
145 165 // 1.32 校验支付渠道是否有效
146 166 PayChannelDO channel = validateChannelCanSubmit(order.getAppId(), reqVO.getChannelCode());
147 167 PayClient<?> client = channelService.getPayClient(channel.getId());
148 168  
149   - // 2. 插入 PayOrderExtensionDO
150   - String no = noRedisDAO.generate(payProperties.getOrderNoPrefix());
151   - PayOrderExtensionDO orderExtension = PayOrderConvert.INSTANCE.convert(reqVO, userIp)
152   - .setOrderId(order.getId()).setNo(no)
153   - .setChannelId(channel.getId()).setChannelCode(channel.getCode())
154   - .setStatus(PayOrderStatusEnum.WAITING.getStatus());
155   - orderExtensionMapper.insert(orderExtension);
  169 + // 2. 获得 PayOrderExtensionDO:优先复用待支付的拓展单,避免重复创建渠道订单
  170 + PayOrderExtensionDO orderExtension = getReusableOrderExtension(order, channel, reqVO, userIp);
  171 + boolean reused = orderExtension != null;
  172 + if (!reused) {
  173 + orderExtension = createOrderExtension(order, channel, reqVO, userIp);
  174 + }
156 175  
157   - // 3. 调用三方接口
  176 + // 3.1 调用三方接口
158 177 PayOrderUnifiedReqDTO unifiedOrderReqDTO = PayOrderConvert.INSTANCE.convert2(reqVO, userIp)
159 178 // 商户相关的字段
160 179 .setOutTradeNo(orderExtension.getNo()) // 注意,此处使用的是 PayOrderExtensionDO.no 属性!
  180 + .setUserIp(orderExtension.getUserIp()) // 复用时,需保持与原渠道订单的参数一致
161 181 .setSubject(order.getSubject()).setBody(order.getBody())
162 182 .setNotifyUrl(genChannelOrderNotifyUrl(channel))
163 183 .setReturnUrl(reqVO.getReturnUrl())
164 184 // 订单相关字段
165 185 .setPrice(order.getPrice()).setExpireTime(order.getExpireTime());
166 186 PayOrderRespDTO unifiedOrderResp = client.unifiedOrder(unifiedOrderReqDTO);
  187 + // 3.2 复用的拓展单下单失败时,以渠道订单的真实状态为准,决定是否新建拓展单重试
  188 + if (reused && unifiedOrderResp != null && PayOrderStatusEnum.isClosed(unifiedOrderResp.getStatus())) {
  189 + unifiedOrderResp = retryUnifiedOrder(order, channel, client, reqVO, userIp,
  190 + orderExtension, unifiedOrderReqDTO, unifiedOrderResp);
  191 + }
167 192  
168 193 // 4. 如果调用直接支付成功,则直接更新支付单状态为成功。例如说:付款码支付,免密支付时,就直接验证支付成功
169 194 if (unifiedOrderResp != null) {
... ... @@ -186,6 +211,163 @@ public class PayOrderServiceImpl implements PayOrderService {
186 211 return PayOrderConvert.INSTANCE.convert(order, unifiedOrderResp);
187 212 }
188 213  
  214 + /**
  215 + * 创建支付订单拓展
  216 + *
  217 + * @param order 支付订单
  218 + * @param channel 支付渠道
  219 + * @param reqVO 支付订单提交参数
  220 + * @param userIp 用户 IP
  221 + * @return 支付订单拓展
  222 + */
  223 + private PayOrderExtensionDO createOrderExtension(PayOrderDO order, PayChannelDO channel,
  224 + PayOrderSubmitReqVO reqVO, String userIp) {
  225 + String no = noRedisDAO.generate(payProperties.getOrderNoPrefix());
  226 + PayOrderExtensionDO orderExtension = PayOrderConvert.INSTANCE.convert(reqVO, userIp)
  227 + .setOrderId(order.getId()).setNo(no)
  228 + .setChannelId(channel.getId()).setChannelCode(channel.getCode())
  229 + .setStatus(PayOrderStatusEnum.WAITING.getStatus());
  230 + orderExtensionMapper.insert(orderExtension);
  231 + return orderExtension;
  232 + }
  233 +
  234 + /**
  235 + * 获得可复用的支付订单拓展
  236 + *
  237 + * 需满足:同渠道、同 channelExtras、未下单失败过的待支付拓展单,且创建后支付订单未被修改(例如说:改价)
  238 + *
  239 + * @param order 支付订单
  240 + * @param channel 支付渠道
  241 + * @param reqVO 提交请求
  242 + * @param userIp 用户 IP
  243 + * @return 支付订单拓展;不可复用时,返回 null
  244 + */
  245 + private PayOrderExtensionDO getReusableOrderExtension(PayOrderDO order, PayChannelDO channel,
  246 + PayOrderSubmitReqVO reqVO, String userIp) {
  247 + if (!isOrderExtensionReusable(channel.getCode())) {
  248 + return null;
  249 + }
  250 + PayOrderExtensionDO orderExtension = orderExtensionMapper.selectLastByOrderIdAndChannelIdAndStatus(
  251 + order.getId(), channel.getId(), PayOrderStatusEnum.WAITING.getStatus());
  252 + if (orderExtension == null
  253 + || ObjectUtil.notEqual(orderExtension.getChannelExtras(), reqVO.getChannelExtras())) {
  254 + return null;
  255 + }
  256 + // 已记录渠道结果(复用下单失败过)的拓展单,不再复用,避免每次提交都重复失败
  257 + if (StrUtil.isNotEmpty(orderExtension.getChannelNotifyData())) {
  258 + return null;
  259 + }
  260 + // 微信 H5 支付会校验拉起支付的用户 IP 与下单 IP 一致,IP 变化(例如说:切换网络)时不能复用
  261 + if (PayChannelEnum.WX_WAP.getCode().equals(channel.getCode())
  262 + && ObjectUtil.notEqual(orderExtension.getUserIp(), userIp)) {
  263 + return null;
  264 + }
  265 + // 拓展单创建后,支付订单被修改过(例如说:改价),渠道订单参数可能不一致,不能复用
  266 + if (isOrderUpdatedAfter(order, orderExtension.getCreateTime())) {
  267 + return null;
  268 + }
  269 + return orderExtension;
  270 + }
  271 +
  272 + /**
  273 + * 判断支付订单在指定时间(含)之后,是否可能被修改过
  274 + *
  275 + * 注意:数据库时间精度为秒,同一秒内无法区分修改与拓展单创建的先后(例如说:创建订单、创建拓展单、改价都在同一秒),
  276 + * 所以修改时间需严格早于拓展单的创建时间,同一秒内统一按已修改处理,避免改价后误复用。
  277 + * 代价是:支付订单与拓展单在同一秒内创建时,再次提交会新建一次拓展单
  278 + *
  279 + * @param order 支付订单
  280 + * @param time 拓展单的创建时间
  281 + * @return 是否可能被修改过
  282 + */
  283 + private static boolean isOrderUpdatedAfter(PayOrderDO order, LocalDateTime time) {
  284 + if (order.getUpdateTime() == null || time == null) {
  285 + return true;
  286 + }
  287 + return !order.getUpdateTime().isBefore(time);
  288 + }
  289 +
  290 + /**
  291 + * 复用的支付订单拓展下单失败时,查询渠道订单的真实状态后,再决定是否重试
  292 + *
  293 + * 原因是:微信下单失败(已支付、系统异常、参数不一致等),都会被转换成 CLOSED 状态,不能直接认为渠道订单已关闭。
  294 + * 只有渠道订单确认关闭后,才新建拓展单重试,保证同一时刻最多只有一个可支付的复用渠道订单
  295 + *
  296 + * @return 下单结果
  297 + */
  298 + private PayOrderRespDTO retryUnifiedOrder(PayOrderDO order, PayChannelDO channel, PayClient<?> client,
  299 + PayOrderSubmitReqVO reqVO, String userIp,
  300 + PayOrderExtensionDO orderExtension,
  301 + PayOrderUnifiedReqDTO unifiedOrderReqDTO,
  302 + PayOrderRespDTO unifiedOrderResp) {
  303 + // 1. 查询渠道订单的真实状态
  304 + PayOrderRespDTO channelOrderResp = null;
  305 + try {
  306 + channelOrderResp = client.getOrder(orderExtension.getNo());
  307 + } catch (Exception ex) {
  308 + log.error("[retryUnifiedOrder][orderExtension({}) 查询渠道订单异常,下单失败({})]",
  309 + orderExtension.getId(), toJsonString(unifiedOrderResp), ex);
  310 + }
  311 + Integer channelStatus = channelOrderResp != null ? channelOrderResp.getStatus() : null;
  312 + // 1.1 渠道订单已支付(例如说:回调延迟),按支付成功处理
  313 + if (PayOrderStatusEnum.isSuccess(channelStatus)) {
  314 + log.warn("[retryUnifiedOrder][orderExtension({}) 渠道订单已支付,可能是回调延迟]", orderExtension.getId());
  315 + return channelOrderResp;
  316 + }
  317 + // 2. 渠道订单未确认关闭(待支付、已退款、状态未知或查询异常),先主动关闭,避免新旧渠道订单都可支付,导致重复支付
  318 + if (!PayOrderStatusEnum.isClosed(channelStatus)) {
  319 + PayOrderRespDTO closeResp = null;
  320 + try {
  321 + closeResp = client.closeOrder(orderExtension.getNo());
  322 + } catch (Exception ex) {
  323 + log.error("[retryUnifiedOrder][orderExtension({}) 关闭渠道订单异常]", orderExtension.getId(), ex);
  324 + }
  325 + Integer closeStatus = closeResp != null ? closeResp.getStatus() : null;
  326 + // 2.1 关闭时发现已支付,按支付成功处理
  327 + if (PayOrderStatusEnum.isSuccess(closeStatus)) {
  328 + log.warn("[retryUnifiedOrder][orderExtension({}) 关闭时发现渠道订单已支付]", orderExtension.getId());
  329 + return closeResp;
  330 + }
  331 + // 2.2 无法确认已关闭,不新建拓展单,提示渠道错误;原拓展单保持可复用,用户再次提交时重新确认
  332 + if (!PayOrderStatusEnum.isClosed(closeStatus)) {
  333 + log.warn("[retryUnifiedOrder][orderExtension({}) 渠道状态({}) 关闭结果({}),下单失败({})]",
  334 + orderExtension.getId(), channelStatus, toJsonString(closeResp), toJsonString(unifiedOrderResp));
  335 + throw exception(PAY_ORDER_SUBMIT_CHANNEL_ERROR, unifiedOrderResp.getChannelErrorCode(),
  336 + unifiedOrderResp.getChannelErrorMsg());
  337 + }
  338 + }
  339 +
  340 + // 3. 记录原拓展单的下单失败结果,后续提交不再复用它
  341 + // 注意:本地拓展单保持待支付状态,最终状态交由回调、同步、过期任务处理,确保渠道订单实际支付成功时仍可正常处理
  342 + int updateCounts = orderExtensionMapper.updateByIdAndStatus(orderExtension.getId(),
  343 + PayOrderStatusEnum.WAITING.getStatus(), PayOrderExtensionDO.builder()
  344 + .channelErrorCode(unifiedOrderResp.getChannelErrorCode())
  345 + .channelErrorMsg(unifiedOrderResp.getChannelErrorMsg())
  346 + .channelNotifyData(toJsonString(unifiedOrderResp)).build());
  347 + if (updateCounts == 0) { // 校验状态,必须是待支付;例如说:并发回调已支付
  348 + throw exception(PAY_ORDER_EXTENSION_STATUS_IS_NOT_WAITING);
  349 + }
  350 + // 4. 渠道订单已关闭,新建拓展单重试
  351 + log.info("[retryUnifiedOrder][orderExtension({}) 渠道订单已关闭,新建拓展单重试]", orderExtension.getId());
  352 + PayOrderExtensionDO newOrderExtension = createOrderExtension(order, channel, reqVO, userIp);
  353 + unifiedOrderReqDTO.setOutTradeNo(newOrderExtension.getNo()).setUserIp(newOrderExtension.getUserIp());
  354 + return client.unifiedOrder(unifiedOrderReqDTO);
  355 + }
  356 +
  357 + /**
  358 + * 判断支付渠道是否可复用待支付的支付订单拓展
  359 + *
  360 + * 1. 微信付款码:authCode 只能使用一次,不可复用
  361 + * 2. 支付宝、钱包等其它渠道:保持每次提交新建
  362 + *
  363 + * @param channelCode 渠道编码
  364 + * @return 是否可复用
  365 + */
  366 + private static boolean isOrderExtensionReusable(String channelCode) {
  367 + return PayChannelEnum.isWeixin(channelCode)
  368 + && ObjectUtil.notEqual(PayChannelEnum.WX_BAR.getCode(), channelCode);
  369 + }
  370 +
189 371 private PayOrderDO validateOrderCanSubmit(Long id) {
190 372 PayOrderDO order = orderMapper.selectById(id);
191 373 if (order == null) { // 是否存在
... ...
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;
15 15 import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderExtensionMapper;
16 16 import cn.iocoder.yudao.module.pay.dal.mysql.order.PayOrderMapper;
17 17 import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
  18 +import cn.iocoder.yudao.module.pay.dal.redis.order.PayOrderLockRedisDAO;
18 19 import cn.iocoder.yudao.module.pay.enums.PayChannelEnum;
19 20 import cn.iocoder.yudao.module.pay.enums.notify.PayNotifyTypeEnum;
20 21 import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
21 22 import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
22 23 import cn.iocoder.yudao.module.pay.framework.pay.core.client.PayClient;
23 24 import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderRespDTO;
  25 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.order.PayOrderUnifiedReqDTO;
  26 +import cn.iocoder.yudao.module.pay.framework.pay.core.client.exception.PayClientException;
24 27 import cn.iocoder.yudao.module.pay.framework.pay.core.enums.PayOrderDisplayModeEnum;
25 28 import cn.iocoder.yudao.module.pay.service.app.PayAppService;
26 29 import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
... ... @@ -34,7 +37,9 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
34 37  
35 38 import java.time.Duration;
36 39 import java.time.LocalDateTime;
  40 +import java.util.ArrayList;
37 41 import java.util.List;
  42 +import java.util.Map;
38 43  
39 44 import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.*;
40 45 import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
... ... @@ -46,6 +51,7 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
46 51 import static org.assertj.core.api.Assertions.assertThat;
47 52 import static org.junit.jupiter.api.Assertions.assertEquals;
48 53 import static org.junit.jupiter.api.Assertions.assertNotNull;
  54 +import static org.junit.jupiter.api.Assertions.assertNull;
49 55 import static org.mockito.ArgumentMatchers.*;
50 56 import static org.mockito.Mockito.*;
51 57  
... ... @@ -54,7 +60,7 @@ import static org.mockito.Mockito.*;
54 60 *
55 61 * @author 芋艿
56 62 */
57   -@Import({PayOrderServiceImpl.class, PayNoRedisDAO.class})
  63 +@Import({PayOrderServiceImpl.class, PayNoRedisDAO.class, PayOrderLockRedisDAO.class})
58 64 public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
59 65  
60 66 @Resource
... ... @@ -443,6 +449,632 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
443 449 }
444 450  
445 451 @Test
  452 + public void testSubmitOrder_wechatReuseWaitingExtension() {
  453 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  454 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  455 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  456 + .thenReturn(payOrderServiceImpl);
  457 +
  458 + // mock 数据(order、orderExtension)
  459 + Map<String, String> channelExtras = Map.of("openid", "openid");
  460 + PayOrderDO order = insertSubmitOrder();
  461 + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  462 + // 准备参数
  463 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  464 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  465 + // mock 方法(app、channel、client)
  466 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  467 + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn(
  468 + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null));
  469 + PayOrderRespDTO unifiedOrderResp = PayOrderRespDTO.waitingOf(
  470 + PayOrderDisplayModeEnum.APP.getMode(), "pay-content", orderExtension.getNo(), null);
  471 + when(client.unifiedOrder(any())).thenAnswer(invocation -> {
  472 + PayOrderUnifiedReqDTO request = invocation.getArgument(0);
  473 + assertEquals(orderExtension.getNo(), request.getOutTradeNo());
  474 + assertEquals(orderExtension.getUserIp(), request.getUserIp());
  475 + return unifiedOrderResp;
  476 + });
  477 +
  478 + // 调用
  479 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString());
  480 + // 断言:复用原拓展单,不新增记录
  481 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1);
  482 + assertEquals("pay-content", result.getDisplayContent());
  483 + verify(client).unifiedOrder(any());
  484 + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(unifiedOrderResp));
  485 + }
  486 + }
  487 +
  488 + @Test
  489 + public void testSubmitOrder_wechatReuseClosed_thenCreateNewExtension() {
  490 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  491 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  492 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  493 + .thenReturn(payOrderServiceImpl);
  494 +
  495 + // mock 数据(order、orderExtension)
  496 + Map<String, String> channelExtras = Map.of("openid", "openid");
  497 + PayOrderDO order = insertSubmitOrder();
  498 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  499 + // 准备参数
  500 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  501 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  502 + // mock 方法(app、channel、client):原渠道订单已关闭,新渠道订单待支付
  503 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  504 + when(client.getOrder(anyString())).thenAnswer(invocation ->
  505 + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null));
  506 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  507 + PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null));
  508 + PayOrderRespDTO closedResp = PayOrderRespDTO.closedOf("ORDERCLOSED", "订单已关闭", oldExtension.getNo(), null);
  509 + PayOrderRespDTO waitingResp = PayOrderRespDTO.waitingOf(
  510 + PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null);
  511 + List<String> outTradeNos = new ArrayList<>();
  512 + when(client.unifiedOrder(any())).thenAnswer(invocation -> {
  513 + PayOrderUnifiedReqDTO request = invocation.getArgument(0);
  514 + outTradeNos.add(request.getOutTradeNo());
  515 + return oldExtension.getNo().equals(request.getOutTradeNo()) ? closedResp : waitingResp;
  516 + });
  517 +
  518 + // 调用
  519 + orderService.submitOrder(reqVO, randomString());
  520 + // 断言:原拓展单保持待支付,并记录下单失败结果;新建拓展单发起支付
  521 + List<PayOrderExtensionDO> extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  522 + assertThat(extensions).hasSize(2);
  523 + PayOrderExtensionDO dbOldExtension = orderExtensionMapper.selectById(oldExtension.getId());
  524 + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), dbOldExtension.getStatus());
  525 + assertEquals("ORDERCLOSED", dbOldExtension.getChannelErrorCode());
  526 + assertEquals(toJsonString(closedResp), dbOldExtension.getChannelNotifyData());
  527 + PayOrderExtensionDO newExtension = extensions.stream()
  528 + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow();
  529 + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo());
  530 + // 断言:只处理新拓展单的下单结果,不能用原拓展单的失败结果更新状态
  531 + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(waitingResp));
  532 + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), same(closedResp));
  533 + verify(client, never()).closeOrder(anyString());
  534 +
  535 + // 再次调用:复用新拓展单,不再使用原拓展单下单
  536 + orderService.submitOrder(reqVO, randomString());
  537 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  538 + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo(), newExtension.getNo());
  539 + }
  540 + }
  541 +
  542 + @Test
  543 + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeThenCreateNewExtension() {
  544 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  545 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  546 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  547 + .thenReturn(payOrderServiceImpl);
  548 +
  549 + // mock 数据(order、orderExtension)
  550 + Map<String, String> channelExtras = Map.of("openid", "openid");
  551 + PayOrderDO order = insertSubmitOrder();
  552 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  553 + // 准备参数
  554 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  555 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  556 + // mock 方法(app、channel、client):原拓展单下单失败,渠道订单仍待支付,主动关闭成功
  557 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  558 + when(client.getOrder(anyString())).thenAnswer(invocation ->
  559 + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null));
  560 + when(client.closeOrder(eq(oldExtension.getNo()))).thenReturn(
  561 + PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null));
  562 + List<String> outTradeNos = new ArrayList<>();
  563 + when(client.unifiedOrder(any())).thenAnswer(invocation -> {
  564 + PayOrderUnifiedReqDTO request = invocation.getArgument(0);
  565 + outTradeNos.add(request.getOutTradeNo());
  566 + return oldExtension.getNo().equals(request.getOutTradeNo())
  567 + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null)
  568 + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null);
  569 + });
  570 +
  571 + // 第一次调用:先关闭原渠道订单,再新建拓展单发起支付
  572 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString());
  573 + assertEquals("pay-content", result.getDisplayContent());
  574 + verify(client).closeOrder(eq(oldExtension.getNo()));
  575 + // 断言:原拓展单保持待支付,并记录下单失败结果;新建拓展单发起支付
  576 + List<PayOrderExtensionDO> extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  577 + assertThat(extensions).hasSize(2);
  578 + PayOrderExtensionDO dbOldExtension = orderExtensionMapper.selectById(oldExtension.getId());
  579 + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), dbOldExtension.getStatus());
  580 + assertEquals("SYSTEMERROR", dbOldExtension.getChannelErrorCode());
  581 + assertNotNull(dbOldExtension.getChannelNotifyData());
  582 + PayOrderExtensionDO newExtension = extensions.stream()
  583 + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow();
  584 + // 第二次调用:复用新拓展单
  585 + orderService.submitOrder(reqVO, randomString());
  586 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  587 + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), newExtension.getNo(), newExtension.getNo());
  588 + verify(client).closeOrder(anyString());
  589 + }
  590 + }
  591 +
  592 + @Test
  593 + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeFail() {
  594 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  595 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  596 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  597 + .thenReturn(payOrderServiceImpl);
  598 +
  599 + // mock 数据(order、orderExtension)
  600 + Map<String, String> channelExtras = Map.of("openid", "openid");
  601 + PayOrderDO order = insertSubmitOrder();
  602 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  603 + // 准备参数
  604 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  605 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  606 + // mock 方法(app、channel、client):原拓展单下单失败,渠道订单仍待支付,第一次关闭失败(例如说:用户支付中),第二次关闭成功
  607 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  608 + when(client.getOrder(anyString())).thenAnswer(invocation ->
  609 + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null));
  610 + when(client.closeOrder(eq(oldExtension.getNo())))
  611 + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null))
  612 + .thenReturn(PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null));
  613 + List<String> outTradeNos = new ArrayList<>();
  614 + when(client.unifiedOrder(any())).thenAnswer(invocation -> {
  615 + PayOrderUnifiedReqDTO request = invocation.getArgument(0);
  616 + outTradeNos.add(request.getOutTradeNo());
  617 + return oldExtension.getNo().equals(request.getOutTradeNo())
  618 + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null)
  619 + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null);
  620 + });
  621 +
  622 + // 第一次调用,并断言异常
  623 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()),
  624 + PAY_ORDER_SUBMIT_CHANNEL_ERROR, "SYSTEMERROR", "系统错误");
  625 + // 断言:无法确认原渠道订单已关闭,不新建拓展单,也不记录下单失败结果,避免存在两个可支付的渠道订单
  626 + List<PayOrderExtensionDO> extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  627 + assertThat(extensions).hasSize(1);
  628 + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), extensions.get(0).getStatus());
  629 + assertNull(extensions.get(0).getChannelNotifyData());
  630 + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), any(PayOrderRespDTO.class));
  631 +
  632 + // 第二次调用:仍复用原拓展单,关闭成功后,再新建拓展单发起支付
  633 + PayOrderSubmitRespVO result = orderService.submitOrder(reqVO, randomString());
  634 + assertEquals("pay-content", result.getDisplayContent());
  635 + extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  636 + assertThat(extensions).hasSize(2);
  637 + PayOrderExtensionDO newExtension = extensions.stream()
  638 + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow();
  639 + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), oldExtension.getNo(), newExtension.getNo());
  640 + verify(client, times(2)).closeOrder(eq(oldExtension.getNo()));
  641 + }
  642 + }
  643 +
  644 + @Test
  645 + public void testSubmitOrder_wechatReuseFail_channelWaiting_closeFoundPaid() {
  646 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  647 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  648 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  649 + .thenReturn(payOrderServiceImpl);
  650 +
  651 + // mock 数据(order、orderExtension)
  652 + Map<String, String> channelExtras = Map.of("openid", "openid");
  653 + PayOrderDO order = insertSubmitOrder();
  654 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  655 + // 准备参数
  656 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  657 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  658 + // mock 方法(app、channel、client):原拓展单下单失败,查询时待支付,关闭时发现已支付
  659 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  660 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  661 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  662 + PayOrderRespDTO successResp = PayOrderRespDTO.successOf(randomString(), randomString(),
  663 + LocalDateTime.now(), oldExtension.getNo(), null);
  664 + when(client.closeOrder(eq(oldExtension.getNo()))).thenReturn(successResp);
  665 + when(client.unifiedOrder(any())).thenReturn(
  666 + PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", oldExtension.getNo(), null));
  667 +
  668 + // 调用
  669 + orderService.submitOrder(reqVO, randomString());
  670 + // 断言:不新建拓展单,按渠道已支付结果处理
  671 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1);
  672 + verify(client).unifiedOrder(any());
  673 + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(successResp));
  674 + }
  675 + }
  676 +
  677 + @Test
  678 + public void testSubmitOrder_wechatReuseFail_channelSuccess() {
  679 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  680 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  681 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  682 + .thenReturn(payOrderServiceImpl);
  683 +
  684 + // mock 数据(order、orderExtension)
  685 + Map<String, String> channelExtras = Map.of("openid", "openid");
  686 + PayOrderDO order = insertSubmitOrder();
  687 + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  688 + // 准备参数
  689 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  690 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  691 + // mock 方法(app、channel、client):提交校验时待支付,下单时已支付(回调延迟)
  692 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  693 + PayOrderRespDTO successResp = PayOrderRespDTO.successOf(randomString(), randomString(),
  694 + LocalDateTime.now(), orderExtension.getNo(), null);
  695 + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn(
  696 + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null), successResp);
  697 + when(client.unifiedOrder(any())).thenReturn(
  698 + PayOrderRespDTO.closedOf("ORDERPAID", "订单已支付", orderExtension.getNo(), null));
  699 +
  700 + // 调用
  701 + orderService.submitOrder(reqVO, randomString());
  702 + // 断言:不新建拓展单,按渠道已支付结果处理
  703 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1);
  704 + verify(client).unifiedOrder(any());
  705 + verify(payOrderServiceImpl).notifyOrder(any(PayChannelDO.class), same(successResp));
  706 + }
  707 + }
  708 +
  709 + @Test
  710 + public void testSubmitOrder_wechatOrderUpdated_notReuse() {
  711 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  712 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  713 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  714 + .thenReturn(payOrderServiceImpl);
  715 +
  716 + // mock 数据(order、orderExtension):拓展单创建后,支付订单被改价
  717 + Map<String, String> channelExtras = Map.of("openid", "openid");
  718 + PayOrderDO order = insertSubmitOrder();
  719 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  720 + PayOrderDO updateObj = new PayOrderDO().setId(order.getId()).setPrice(order.getPrice() + 1);
  721 + updateObj.setUpdateTime(LocalDateTime.now());
  722 + orderMapper.updateById(updateObj);
  723 + // 准备参数
  724 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  725 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  726 + // mock 方法(app、channel、client)
  727 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  728 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  729 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  730 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()))))
  731 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null));
  732 +
  733 + // 调用
  734 + orderService.submitOrder(reqVO, randomString());
  735 + // 断言:新建拓展单
  736 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  737 + verify(client).unifiedOrder(any());
  738 + }
  739 + }
  740 +
  741 + @Test
  742 + public void testSubmitOrder_wechatChannelExtrasChanged_notReuse() {
  743 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  744 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  745 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  746 + .thenReturn(payOrderServiceImpl);
  747 +
  748 + // mock 数据(order、orderExtension)
  749 + PayOrderDO order = insertSubmitOrder();
  750 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB,
  751 + Map.of("openid", "old-openid"));
  752 + // 准备参数
  753 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  754 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(Map.of("openid", "new-openid")));
  755 + // mock 方法(app、channel、client)
  756 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  757 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  758 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  759 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()))))
  760 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null));
  761 +
  762 + // 调用
  763 + orderService.submitOrder(reqVO, randomString());
  764 + // 断言:新建拓展单
  765 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  766 + verify(client).unifiedOrder(any());
  767 + }
  768 + }
  769 +
  770 + @Test
  771 + public void testSubmitOrder_wechatOrderUpdatedSameSecond_notReuse() {
  772 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  773 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  774 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  775 + .thenReturn(payOrderServiceImpl);
  776 +
  777 + // mock 数据(order、orderExtension):拓展单创建后,同一秒内支付订单被改价,数据库时间相等
  778 + Map<String, String> channelExtras = Map.of("openid", "openid");
  779 + LocalDateTime time = LocalDateTime.now().withNano(0).minusMinutes(1);
  780 + PayOrderDO order = insertSubmitOrder(time.minusMinutes(10), time);
  781 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB,
  782 + channelExtras, time);
  783 + // 准备参数
  784 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  785 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  786 + // mock 方法(app、channel、client)
  787 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  788 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  789 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  790 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()))))
  791 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null));
  792 +
  793 + // 调用
  794 + orderService.submitOrder(reqVO, randomString());
  795 + // 断言:无法区分先后,按已改价处理,新建拓展单
  796 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  797 + verify(client).unifiedOrder(any());
  798 + }
  799 + }
  800 +
  801 + @Test
  802 + public void testSubmitOrder_wechatNewOrderSameSecond_notReuse() {
  803 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  804 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  805 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  806 + .thenReturn(payOrderServiceImpl);
  807 +
  808 + // mock 数据(order、orderExtension):支付订单与拓展单在同一秒内创建,数据库时间相等(可能同一秒内被改价)
  809 + Map<String, String> channelExtras = Map.of("openid", "openid");
  810 + LocalDateTime time = LocalDateTime.now().withNano(0).minusMinutes(1);
  811 + PayOrderDO order = insertSubmitOrder(time, time);
  812 + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB,
  813 + channelExtras, time);
  814 + // 准备参数
  815 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  816 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  817 + // mock 方法(app、channel、client)
  818 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  819 + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn(
  820 + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null));
  821 + when(client.unifiedOrder(argThat(request -> !orderExtension.getNo().equals(request.getOutTradeNo()))))
  822 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null));
  823 +
  824 + // 调用
  825 + orderService.submitOrder(reqVO, randomString());
  826 + // 断言:同一秒内无法排除改价,按已修改处理,新建拓展单
  827 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  828 + verify(client).unifiedOrder(any());
  829 + }
  830 + }
  831 +
  832 + @Test
  833 + public void testSubmitOrder_wechatReuseFail_queryException() {
  834 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  835 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  836 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  837 + .thenReturn(payOrderServiceImpl);
  838 +
  839 + // mock 数据(order、orderExtension)
  840 + Map<String, String> channelExtras = Map.of("openid", "openid");
  841 + PayOrderDO order = insertSubmitOrder();
  842 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  843 + // 准备参数
  844 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  845 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  846 + // mock 方法(app、channel、client):提交校验时待支付,下单失败后查询、关闭渠道订单都异常,之后恢复正常
  847 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  848 + when(client.getOrder(anyString())).thenAnswer(invocation ->
  849 + PayOrderRespDTO.waitingOf(null, null, invocation.getArgument(0), null));
  850 + when(client.getOrder(eq(oldExtension.getNo())))
  851 + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null))
  852 + .thenThrow(new PayClientException(new RuntimeException("网络超时")))
  853 + .thenReturn(PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  854 + when(client.closeOrder(eq(oldExtension.getNo())))
  855 + .thenThrow(new PayClientException(new RuntimeException("网络超时")))
  856 + .thenReturn(PayOrderRespDTO.closedOf(null, null, oldExtension.getNo(), null));
  857 + List<String> outTradeNos = new ArrayList<>();
  858 + when(client.unifiedOrder(any())).thenAnswer(invocation -> {
  859 + PayOrderUnifiedReqDTO request = invocation.getArgument(0);
  860 + outTradeNos.add(request.getOutTradeNo());
  861 + return oldExtension.getNo().equals(request.getOutTradeNo())
  862 + ? PayOrderRespDTO.closedOf("SYSTEMERROR", "系统错误", request.getOutTradeNo(), null)
  863 + : PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null);
  864 + });
  865 +
  866 + // 第一次调用,并断言异常:查询、关闭异常转换为业务异常
  867 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()),
  868 + PAY_ORDER_SUBMIT_CHANNEL_ERROR, "SYSTEMERROR", "系统错误");
  869 + // 断言:渠道状态未知,不新建拓展单,也不记录下单失败结果
  870 + List<PayOrderExtensionDO> extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  871 + assertThat(extensions).hasSize(1);
  872 + assertEquals(PayOrderStatusEnum.WAITING.getStatus(), extensions.get(0).getStatus());
  873 + assertNull(extensions.get(0).getChannelNotifyData());
  874 + verify(payOrderServiceImpl, never()).notifyOrder(any(PayChannelDO.class), any(PayOrderRespDTO.class));
  875 +
  876 + // 第二次调用:渠道恢复后,关闭原渠道订单,再新建拓展单发起支付
  877 + orderService.submitOrder(reqVO, randomString());
  878 + extensions = orderExtensionMapper.selectListByOrderId(order.getId());
  879 + assertThat(extensions).hasSize(2);
  880 + PayOrderExtensionDO newExtension = extensions.stream()
  881 + .filter(extension -> !extension.getId().equals(oldExtension.getId())).findFirst().orElseThrow();
  882 + assertThat(outTradeNos).containsExactly(oldExtension.getNo(), oldExtension.getNo(), newExtension.getNo());
  883 + }
  884 + }
  885 +
  886 + @Test
  887 + public void testSubmitOrder_wechatReuseFail_extensionPaidConcurrently() {
  888 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  889 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  890 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  891 + .thenReturn(payOrderServiceImpl);
  892 +
  893 + // mock 数据(order、orderExtension)
  894 + Map<String, String> channelExtras = Map.of("openid", "openid");
  895 + PayOrderDO order = insertSubmitOrder();
  896 + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_PUB, channelExtras);
  897 + // 准备参数
  898 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  899 + .setChannelCode(PayChannelEnum.WX_PUB.getCode()).setChannelExtras(channelExtras));
  900 + // mock 方法(app、channel、client):下单失败后,渠道订单已关闭,但原拓展单被并发回调更新为已支付
  901 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_PUB);
  902 + when(client.getOrder(eq(orderExtension.getNo())))
  903 + .thenReturn(PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null))
  904 + .thenAnswer(invocation -> {
  905 + orderExtensionMapper.updateById(new PayOrderExtensionDO().setId(orderExtension.getId())
  906 + .setStatus(PayOrderStatusEnum.SUCCESS.getStatus()));
  907 + return PayOrderRespDTO.closedOf(null, null, orderExtension.getNo(), null);
  908 + });
  909 + when(client.unifiedOrder(any())).thenReturn(
  910 + PayOrderRespDTO.closedOf("ORDERCLOSED", "订单已关闭", orderExtension.getNo(), null));
  911 +
  912 + // 调用,并断言异常
  913 + assertServiceException(() -> orderService.submitOrder(reqVO, randomString()),
  914 + PAY_ORDER_EXTENSION_STATUS_IS_NOT_WAITING);
  915 + // 断言:不新建拓展单
  916 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1);
  917 + verify(client).unifiedOrder(any());
  918 + }
  919 + }
  920 +
  921 + @Test
  922 + public void testSubmitOrder_wechatWapUserIpChanged_notReuse() {
  923 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  924 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  925 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  926 + .thenReturn(payOrderServiceImpl);
  927 +
  928 + // mock 数据(order、orderExtension)
  929 + Map<String, String> channelExtras = Map.of("key", "value");
  930 + PayOrderDO order = insertSubmitOrder();
  931 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_WAP, channelExtras);
  932 + // 准备参数:用户 IP 变化(例如说:切换网络)
  933 + String userIp = "10.0.0.1";
  934 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  935 + .setChannelCode(PayChannelEnum.WX_WAP.getCode()).setChannelExtras(channelExtras));
  936 + // mock 方法(app、channel、client)
  937 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_WAP);
  938 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  939 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  940 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo())
  941 + && userIp.equals(request.getUserIp()))))
  942 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.URL.getMode(), "pay-url", null, null));
  943 +
  944 + // 调用
  945 + orderService.submitOrder(reqVO, userIp);
  946 + // 断言:新建拓展单,使用新的用户 IP 下单
  947 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  948 + verify(client).unifiedOrder(any());
  949 + }
  950 + }
  951 +
  952 + @Test
  953 + public void testSubmitOrder_wechatWapUserIpSame_reuse() {
  954 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  955 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  956 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  957 + .thenReturn(payOrderServiceImpl);
  958 +
  959 + // mock 数据(order、orderExtension)
  960 + Map<String, String> channelExtras = Map.of("key", "value");
  961 + PayOrderDO order = insertSubmitOrder();
  962 + PayOrderExtensionDO orderExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_WAP, channelExtras);
  963 + // 准备参数
  964 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  965 + .setChannelCode(PayChannelEnum.WX_WAP.getCode()).setChannelExtras(channelExtras));
  966 + // mock 方法(app、channel、client)
  967 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_WAP);
  968 + when(client.getOrder(eq(orderExtension.getNo()))).thenReturn(
  969 + PayOrderRespDTO.waitingOf(null, null, orderExtension.getNo(), null));
  970 + when(client.unifiedOrder(argThat(request -> orderExtension.getNo().equals(request.getOutTradeNo()))))
  971 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.URL.getMode(), "pay-url", null, null));
  972 +
  973 + // 调用
  974 + orderService.submitOrder(reqVO, orderExtension.getUserIp());
  975 + // 断言:用户 IP 一致,复用原拓展单
  976 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(1);
  977 + verify(client).unifiedOrder(any());
  978 + }
  979 + }
  980 +
  981 + @Test
  982 + public void testSubmitOrder_alipayNotReuse() {
  983 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  984 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  985 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  986 + .thenReturn(payOrderServiceImpl);
  987 +
  988 + // mock 数据(order、orderExtension)
  989 + Map<String, String> channelExtras = Map.of("key", "value");
  990 + PayOrderDO order = insertSubmitOrder();
  991 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.ALIPAY_APP, channelExtras);
  992 + // 准备参数
  993 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  994 + .setChannelCode(PayChannelEnum.ALIPAY_APP.getCode()).setChannelExtras(channelExtras));
  995 + // mock 方法(app、channel、client)
  996 + PayClient<?> client = mockSubmitClient(PayChannelEnum.ALIPAY_APP);
  997 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  998 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  999 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()))))
  1000 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.APP.getMode(), "pay-content", null, null));
  1001 +
  1002 + // 调用
  1003 + orderService.submitOrder(reqVO, randomString());
  1004 + // 断言:支付宝保持每次新建拓展单
  1005 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  1006 + verify(client).unifiedOrder(any());
  1007 + }
  1008 + }
  1009 +
  1010 + @Test
  1011 + public void testSubmitOrder_wechatBarNotReuse() {
  1012 + PayOrderServiceImpl payOrderServiceImpl = mock(PayOrderServiceImpl.class);
  1013 + try (MockedStatic<SpringUtil> springUtilMockedStatic = mockStatic(SpringUtil.class)) {
  1014 + springUtilMockedStatic.when(() -> SpringUtil.getBean(eq(PayOrderServiceImpl.class)))
  1015 + .thenReturn(payOrderServiceImpl);
  1016 +
  1017 + // mock 数据(order、orderExtension)
  1018 + Map<String, String> channelExtras = Map.of("authCode", "134567890123456789");
  1019 + PayOrderDO order = insertSubmitOrder();
  1020 + PayOrderExtensionDO oldExtension = insertSubmitOrderExtension(order, PayChannelEnum.WX_BAR, channelExtras);
  1021 + // 准备参数
  1022 + PayOrderSubmitReqVO reqVO = randomPojo(PayOrderSubmitReqVO.class, o -> o.setId(order.getId())
  1023 + .setChannelCode(PayChannelEnum.WX_BAR.getCode()).setChannelExtras(channelExtras));
  1024 + // mock 方法(app、channel、client)
  1025 + PayClient<?> client = mockSubmitClient(PayChannelEnum.WX_BAR);
  1026 + when(client.getOrder(eq(oldExtension.getNo()))).thenReturn(
  1027 + PayOrderRespDTO.waitingOf(null, null, oldExtension.getNo(), null));
  1028 + when(client.unifiedOrder(argThat(request -> !oldExtension.getNo().equals(request.getOutTradeNo()))))
  1029 + .thenReturn(PayOrderRespDTO.waitingOf(PayOrderDisplayModeEnum.BAR_CODE.getMode(), "", null, null));
  1030 +
  1031 + // 调用
  1032 + orderService.submitOrder(reqVO, randomString());
  1033 + // 断言:微信付款码的 authCode 只能使用一次,保持每次新建拓展单
  1034 + assertThat(orderExtensionMapper.selectListByOrderId(order.getId())).hasSize(2);
  1035 + verify(client).unifiedOrder(any());
  1036 + }
  1037 + }
  1038 +
  1039 + private PayOrderDO insertSubmitOrder() {
  1040 + LocalDateTime time = LocalDateTime.now().minusMinutes(10);
  1041 + return insertSubmitOrder(time, time);
  1042 + }
  1043 +
  1044 + private PayOrderDO insertSubmitOrder(LocalDateTime createTime, LocalDateTime updateTime) {
  1045 + PayOrderDO order = randomPojo(PayOrderDO.class, o -> o.setStatus(PayOrderStatusEnum.WAITING.getStatus())
  1046 + .setAppId(1L).setExpireTime(addTime(Duration.ofDays(1))));
  1047 + order.setCreateTime(createTime).setUpdateTime(updateTime);
  1048 + orderMapper.insert(order);
  1049 + return order;
  1050 + }
  1051 +
  1052 + private PayOrderExtensionDO insertSubmitOrderExtension(PayOrderDO order, PayChannelEnum channelEnum,
  1053 + Map<String, String> channelExtras) {
  1054 + return insertSubmitOrderExtension(order, channelEnum, channelExtras, LocalDateTime.now().minusMinutes(5));
  1055 + }
  1056 +
  1057 + private PayOrderExtensionDO insertSubmitOrderExtension(PayOrderDO order, PayChannelEnum channelEnum,
  1058 + Map<String, String> channelExtras, LocalDateTime createTime) {
  1059 + PayOrderExtensionDO orderExtension = randomPojo(PayOrderExtensionDO.class, o -> o.setOrderId(order.getId())
  1060 + .setChannelId(10L).setChannelCode(channelEnum.getCode())
  1061 + .setStatus(PayOrderStatusEnum.WAITING.getStatus()).setChannelExtras(channelExtras)
  1062 + .setChannelErrorCode(null).setChannelErrorMsg(null).setChannelNotifyData(null)
  1063 + .setCreateTime(createTime));
  1064 + orderExtensionMapper.insert(orderExtension);
  1065 + return orderExtension;
  1066 + }
  1067 +
  1068 + private PayClient<?> mockSubmitClient(PayChannelEnum channelEnum) {
  1069 + when(appService.validPayApp(eq(1L))).thenReturn(randomPojo(PayAppDO.class, o -> o.setId(1L)));
  1070 + PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setId(10L).setCode(channelEnum.getCode()));
  1071 + when(channelService.validPayChannel(eq(1L), eq(channelEnum.getCode()))).thenReturn(channel);
  1072 + PayClient<?> client = mock(PayClient.class);
  1073 + when(channelService.getPayClient(eq(10L))).thenReturn(client);
  1074 + return client;
  1075 + }
  1076 +
  1077 + @Test
446 1078 public void testValidateOrderActuallyPaid_dbPaid() {
447 1079 // 准备参数
448 1080 Long id = randomLongId();
... ...