diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/dal/redis/order/PrintOrderRedisDAO.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/dal/redis/order/PrintOrderRedisDAO.java index 1dfac2e..61f17be 100644 --- a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/dal/redis/order/PrintOrderRedisDAO.java +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/dal/redis/order/PrintOrderRedisDAO.java @@ -15,20 +15,50 @@ import static co.yixiang.yshop.module.order.dal.redis.RedisKeyConstants.YSHOP_OR public class PrintOrderRedisDAO { private static final long TTL_HOURS = 24; + private static final String LEGACY_PRINTED = "1"; + private static final String PRINTING = "PRINTING"; + private static final String PRINTED = "PRINTED"; + private static final String FAILED = "FAILED"; @Resource private StringRedisTemplate stringRedisTemplate; /** - * @return true 表示首次标记成功,可以打印;false 表示已打印过 + * @return true 表示首次标记成功,可以打印;false 表示已有打印任务 */ - public boolean tryMarkPrinted(String orderId) { + public boolean tryStartPrinting(String orderId) { String redisKey = formatKey(orderId); Boolean ok = stringRedisTemplate.opsForValue() - .setIfAbsent(redisKey, "1", TTL_HOURS, TimeUnit.HOURS); + .setIfAbsent(redisKey, PRINTING, TTL_HOURS, TimeUnit.HOURS); return Boolean.TRUE.equals(ok); } + /** + * 标记云端已经成功接收打印订单。 + */ + public void markPrinted(String orderId) { + setState(orderId, PRINTED); + } + + /** + * 标记指定次数的自动重试已经全部失败。 + */ + public void markFailed(String orderId) { + setState(orderId, FAILED); + } + + /** + * 兼容升级前值为 1 的打印幂等记录。 + */ + public boolean isPrinted(String orderId) { + String state = stringRedisTemplate.opsForValue().get(formatKey(orderId)); + return PRINTED.equals(state) || LEGACY_PRINTED.equals(state); + } + + private void setState(String orderId, String state) { + stringRedisTemplate.opsForValue().set(formatKey(orderId), state, TTL_HOURS, TimeUnit.HOURS); + } + private static String formatKey(String orderId) { return String.format(YSHOP_ORDER_PRINT_KEY.getKeyTemplate(), orderId); } diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunConfiguration.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunConfiguration.java index 274c2ef..696332c 100644 --- a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunConfiguration.java +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunConfiguration.java @@ -1,9 +1,23 @@ package co.yixiang.yshop.module.order.print.xpyun; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(XpYunProperties.class) public class XpYunConfiguration { + + public static final String PRINT_RETRY_TASK_SCHEDULER = "xpYunPrintRetryTaskScheduler"; + + @Bean(name = PRINT_RETRY_TASK_SCHEDULER, destroyMethod = "shutdown") + public ThreadPoolTaskScheduler xpYunPrintRetryTaskScheduler() { + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(4); + taskScheduler.setThreadNamePrefix("xpyun-print-retry-"); + taskScheduler.setRemoveOnCancelPolicy(true); + taskScheduler.setWaitForTasksToCompleteOnShutdown(false); + return taskScheduler; + } } diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetryScheduler.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetryScheduler.java new file mode 100644 index 0000000..2589d23 --- /dev/null +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetryScheduler.java @@ -0,0 +1,51 @@ +package co.yixiang.yshop.module.order.print.xpyun; + +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static co.yixiang.yshop.module.order.print.xpyun.XpYunConfiguration.PRINT_RETRY_TASK_SCHEDULER; + +/** + * 芯烨云打印失败重试调度器。 + */ +@Component +@Slf4j +public class XpYunPrintRetryScheduler { + + static final List RETRY_DELAYS = List.of( + Duration.ofSeconds(30), + Duration.ofSeconds(60), + Duration.ofSeconds(120), + Duration.ofSeconds(180), + Duration.ofSeconds(300)); + + @Resource(name = PRINT_RETRY_TASK_SCHEDULER) + private TaskScheduler taskScheduler; + + /** + * @param retryIndex 从 0 开始的重试序号 + * @return true 表示已安排重试;false 表示已没有剩余重试次数 + */ + public boolean schedule(String orderId, int retryIndex, Runnable retryTask) { + if (retryIndex < 0 || retryIndex >= RETRY_DELAYS.size()) { + return false; + } + Duration delay = RETRY_DELAYS.get(retryIndex); + Instant executeAt = Instant.now().plus(delay); + taskScheduler.schedule(retryTask, executeAt); + log.warn("[schedule][orderId({}) 第 {}/{} 次重试将在 {} 秒后执行]", + orderId, retryIndex + 1, RETRY_DELAYS.size(), delay.toSeconds()); + return true; + } + + public int retryCount() { + return RETRY_DELAYS.size(); + } + +} diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsynStoreOrderServiceImpl.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsynStoreOrderServiceImpl.java index e3cce8a..9eb59c9 100644 --- a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsynStoreOrderServiceImpl.java +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsynStoreOrderServiceImpl.java @@ -18,6 +18,7 @@ import co.yixiang.yshop.module.order.dal.redis.order.PrintOrderRedisDAO; import co.yixiang.yshop.module.order.enums.OrderLogEnum; import co.yixiang.yshop.module.order.print.xpyun.OrderReceiptBuilder; import co.yixiang.yshop.module.order.print.xpyun.XpYunPrintClient; +import co.yixiang.yshop.module.order.print.xpyun.XpYunPrintRetryScheduler; import co.yixiang.yshop.module.order.print.xpyun.XpYunPrintResponse; import co.yixiang.yshop.module.order.service.storeorder.dto.OrderTimeDataDto; import co.yixiang.yshop.module.product.service.storeproduct.StoreProductService; @@ -68,6 +69,8 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService { private XpYunPrintClient xpYunPrintClient; @Resource private OrderReceiptBuilder orderReceiptBuilder; + @Resource + private XpYunPrintRetryScheduler xpYunPrintRetryScheduler; /** * 获取某个用户的订单统计数据 @@ -248,7 +251,10 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService { @Async public void printOrderAsync(String orderId) { try { - printOrder(orderId, false); + PrintAttemptResult result = printOrderInternal(orderId, false, true); + if (result == PrintAttemptResult.RETRYABLE_FAILURE) { + schedulePrintRetry(orderId, 0); + } } catch (Exception e) { log.error("[printOrderAsync][orderId({}) 自动打印失败]", orderId, e); } @@ -256,47 +262,56 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService { @Override public void printOrder(String orderId, boolean force) { + printOrderInternal(orderId, force, !force); + } + + private PrintAttemptResult printOrderInternal(String orderId, boolean force, boolean firstAutomaticAttempt) { if (StrUtil.isBlank(orderId)) { if (force) { throw exception(PARAM_ERROR); } - return; + return PrintAttemptResult.STOPPED; } if (!xpYunPrintClient.isReady()) { failOrSkip(force, STORE_ORDER_PRINT_DISABLED, "芯烨云未启用或凭证未配置"); - return; + return PrintAttemptResult.STOPPED; } StoreOrderDO order = storeOrderMapper.selectOne(new LambdaQueryWrapper() .eq(StoreOrderDO::getOrderId, orderId)); if (order == null) { failOrSkip(force, STORE_ORDER_NOT_EXISTS, "订单不存在"); - return; + return PrintAttemptResult.STOPPED; } if (!OrderInfoEnum.PAY_STATUS_1.getValue().equals(order.getPaid())) { failOrSkip(force, STORE_ORDER_PRINT_NOT_PAID, "订单未支付"); - return; + return PrintAttemptResult.STOPPED; } String orderType = order.getOrderType(); if (!OrderLogEnum.ORDER_TAKE_IN.getValue().equals(orderType) && !OrderLogEnum.ORDER_TAKE_OUT.getValue().equals(orderType)) { failOrSkip(force, STORE_ORDER_PRINT_TYPE_NOT_SUPPORT, "订单类型不支持打印:" + orderType); - return; + return PrintAttemptResult.STOPPED; } if (order.getShopId() == null) { failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "订单无门店"); - return; + return PrintAttemptResult.STOPPED; } StoreShopDO shop = storeShopMapper.selectById(order.getShopId()); if (shop == null || StrUtil.isBlank(shop.getUniprintId())) { failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "门店未配置打印机SN"); - return; + return PrintAttemptResult.STOPPED; } // 校验通过后再做幂等,避免空 SN / 未启用误占 key - if (!force && !printOrderRedisDAO.tryMarkPrinted(orderId)) { - log.info("[printOrder][orderId({}) 已打印过,跳过]", orderId); - return; + if (!force && firstAutomaticAttempt && !printOrderRedisDAO.tryStartPrinting(orderId)) { + log.info("[printOrder][orderId({}) 已存在打印任务,跳过]", orderId); + return PrintAttemptResult.STOPPED; + } + // 人工补打成功后会写入 PRINTED,尚未执行的自动重试应立即停止。 + if (!force && !firstAutomaticAttempt && printOrderRedisDAO.isPrinted(orderId)) { + log.info("[printOrder][orderId({}) 已打印成功,停止重试]", orderId); + return PrintAttemptResult.STOPPED; } List items = storeOrderCartInfoMapper.selectList( @@ -312,8 +327,11 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService { throw exception(STORE_ORDER_PRINT_FAILED, msg); } log.error("[printOrder][orderId({}) 打印失败] {}", orderId, msg); + return PrintAttemptResult.RETRYABLE_FAILURE; } else { log.info("[printOrder][orderId({}) 打印成功] data={}", orderId, resp.getData()); + markPrintedQuietly(orderId); + return PrintAttemptResult.SUCCESS; } } catch (co.yixiang.yshop.framework.common.exception.ServiceException e) { throw e; @@ -322,9 +340,69 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService { throw exception(STORE_ORDER_PRINT_FAILED, e.getMessage()); } log.error("[printOrder][orderId({}) 打印异常]", orderId, e); + return PrintAttemptResult.RETRYABLE_FAILURE; } } + private void schedulePrintRetry(String orderId, int retryIndex) { + try { + boolean scheduled = xpYunPrintRetryScheduler.schedule(orderId, retryIndex, + () -> executePrintRetry(orderId, retryIndex)); + if (!scheduled) { + finishPrintRetries(orderId); + } + } catch (Exception e) { + log.error("[schedulePrintRetry][orderId({}) 安排打印重试失败]", orderId, e); + markFailedQuietly(orderId); + } + } + + private void executePrintRetry(String orderId, int retryIndex) { + try { + PrintAttemptResult result = printOrderInternal(orderId, false, false); + if (result == PrintAttemptResult.RETRYABLE_FAILURE) { + if (retryIndex + 1 < xpYunPrintRetryScheduler.retryCount()) { + schedulePrintRetry(orderId, retryIndex + 1); + } else { + finishPrintRetries(orderId); + } + } + } catch (Exception e) { + log.error("[executePrintRetry][orderId({}) 第 {} 次打印重试异常,停止后续重试]", + orderId, retryIndex + 1, e); + markFailedQuietly(orderId); + } + } + + private void finishPrintRetries(String orderId) { + markFailedQuietly(orderId); + log.error("[finishPrintRetries][orderId({}) 打印初次请求及 {} 次重试均失败,不再重试]", + orderId, xpYunPrintRetryScheduler.retryCount()); + } + + private void markPrintedQuietly(String orderId) { + try { + printOrderRedisDAO.markPrinted(orderId); + } catch (Exception e) { + // 云端已成功接单,Redis 状态写入失败不能触发再次打印,否则可能重复出票。 + log.error("[markPrintedQuietly][orderId({}) 写入打印成功状态失败]", orderId, e); + } + } + + private void markFailedQuietly(String orderId) { + try { + printOrderRedisDAO.markFailed(orderId); + } catch (Exception e) { + log.error("[markFailedQuietly][orderId({}) 写入打印失败状态失败]", orderId, e); + } + } + + private enum PrintAttemptResult { + SUCCESS, + RETRYABLE_FAILURE, + STOPPED + } + private void failOrSkip(boolean force, co.yixiang.yshop.framework.common.exception.ErrorCode errorCode, String logMsg) { diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsyncStoreOrderService.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsyncStoreOrderService.java index e8831b2..7a5556f 100644 --- a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsyncStoreOrderService.java +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/main/java/co/yixiang/yshop/module/order/service/storeorder/AsyncStoreOrderService.java @@ -19,7 +19,7 @@ public interface AsyncStoreOrderService { void getOrderTimeData(); /** - * 支付成功后异步打印小票(失败只记日志,不抛异常) + * 支付成功后异步打印小票(瞬时故障按 30/60/120/180/300 秒重试) * * @param orderId 业务订单号 */ diff --git a/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/test/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetrySchedulerTest.java b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/test/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetrySchedulerTest.java new file mode 100644 index 0000000..9890cf0 --- /dev/null +++ b/yshop-drink-boot3/yshop-module-mall/yshop-module-order-biz/src/test/java/co/yixiang/yshop/module/order/print/xpyun/XpYunPrintRetrySchedulerTest.java @@ -0,0 +1,70 @@ +package co.yixiang.yshop.module.order.print.xpyun; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.scheduling.TaskScheduler; + +import java.lang.reflect.Field; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class XpYunPrintRetrySchedulerTest { + + @Mock + private TaskScheduler taskScheduler; + + private XpYunPrintRetryScheduler retryScheduler; + + @BeforeEach + void setUp() throws ReflectiveOperationException { + retryScheduler = new XpYunPrintRetryScheduler(); + Field field = XpYunPrintRetryScheduler.class.getDeclaredField("taskScheduler"); + field.setAccessible(true); + field.set(retryScheduler, taskScheduler); + } + + @Test + void shouldUseConfiguredRetryDelays() { + assertEquals(List.of( + Duration.ofSeconds(30), + Duration.ofSeconds(60), + Duration.ofSeconds(120), + Duration.ofSeconds(180), + Duration.ofSeconds(300)), XpYunPrintRetryScheduler.RETRY_DELAYS); + assertEquals(5, retryScheduler.retryCount()); + } + + @Test + void shouldScheduleRetryAtConfiguredTime() { + int retryIndex = 2; + Instant before = Instant.now(); + + assertTrue(retryScheduler.schedule("order-1", retryIndex, () -> { })); + + ArgumentCaptor executeAtCaptor = ArgumentCaptor.forClass(Instant.class); + verify(taskScheduler).schedule(any(Runnable.class), executeAtCaptor.capture()); + Duration actualDelay = Duration.between(before, executeAtCaptor.getValue()); + assertTrue(actualDelay.compareTo(Duration.ofSeconds(120)) >= 0); + assertTrue(actualDelay.compareTo(Duration.ofSeconds(121)) < 0); + } + + @Test + void shouldStopAfterConfiguredRetries() { + assertFalse(retryScheduler.schedule("order-1", retryScheduler.retryCount(), () -> { })); + verify(taskScheduler, never()).schedule(any(Runnable.class), any(Instant.class)); + } + +}