feat: 云打印重试

This commit is contained in:
Kroytz committed 2026-08-02 22:09:25 +08:00
1 parent 2b15925482
commit 82d68f4a92
6 files changed
+258 -15

No files matched your search

@@ -15,20 +15,50 @@ import static co.yixiang.yshop.module.order.dal.redis.RedisKeyConstants.YSHOP_OR
public class PrintOrderRedisDAO { public class PrintOrderRedisDAO {
private static final long TTL_HOURS = 24; 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 @Resource
private StringRedisTemplate stringRedisTemplate; private StringRedisTemplate stringRedisTemplate;
/** /**
* @return true 表示首次标记成功,可以打印;false 表示已打印过 * @return true 表示首次标记成功,可以打印;false 表示已有打印任务
*/ */
public boolean tryMarkPrinted(String orderId) { public boolean tryStartPrinting(String orderId) {
String redisKey = formatKey(orderId); String redisKey = formatKey(orderId);
Boolean ok = stringRedisTemplate.opsForValue() Boolean ok = stringRedisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", TTL_HOURS, TimeUnit.HOURS); .setIfAbsent(redisKey, PRINTING, TTL_HOURS, TimeUnit.HOURS);
return Boolean.TRUE.equals(ok); 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) { private static String formatKey(String orderId) {
return String.format(YSHOP_ORDER_PRINT_KEY.getKeyTemplate(), orderId); return String.format(YSHOP_ORDER_PRINT_KEY.getKeyTemplate(), orderId);
} }
@@ -1,9 +1,23 @@
package co.yixiang.yshop.module.order.print.xpyun; package co.yixiang.yshop.module.order.print.xpyun;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration(proxyBeanMethods = false) @Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(XpYunProperties.class) @EnableConfigurationProperties(XpYunProperties.class)
public class XpYunConfiguration { 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;
}
} }
@@ -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<Duration> 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();
}
}
@@ -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.enums.OrderLogEnum;
import co.yixiang.yshop.module.order.print.xpyun.OrderReceiptBuilder; 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.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.print.xpyun.XpYunPrintResponse;
import co.yixiang.yshop.module.order.service.storeorder.dto.OrderTimeDataDto; import co.yixiang.yshop.module.order.service.storeorder.dto.OrderTimeDataDto;
import co.yixiang.yshop.module.product.service.storeproduct.StoreProductService; import co.yixiang.yshop.module.product.service.storeproduct.StoreProductService;
@@ -68,6 +69,8 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
private XpYunPrintClient xpYunPrintClient; private XpYunPrintClient xpYunPrintClient;
@Resource @Resource
private OrderReceiptBuilder orderReceiptBuilder; private OrderReceiptBuilder orderReceiptBuilder;
@Resource
private XpYunPrintRetryScheduler xpYunPrintRetryScheduler;
/** /**
* 获取某个用户的订单统计数据 * 获取某个用户的订单统计数据
@@ -248,7 +251,10 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
@Async @Async
public void printOrderAsync(String orderId) { public void printOrderAsync(String orderId) {
try { try {
printOrder(orderId, false); PrintAttemptResult result = printOrderInternal(orderId, false, true);
if (result == PrintAttemptResult.RETRYABLE_FAILURE) {
schedulePrintRetry(orderId, 0);
}
} catch (Exception e) { } catch (Exception e) {
log.error("[printOrderAsync][orderId({}) 自动打印失败]", orderId, e); log.error("[printOrderAsync][orderId({}) 自动打印失败]", orderId, e);
} }
@@ -256,47 +262,56 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
@Override @Override
public void printOrder(String orderId, boolean force) { 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 (StrUtil.isBlank(orderId)) {
if (force) { if (force) {
throw exception(PARAM_ERROR); throw exception(PARAM_ERROR);
} }
return; return PrintAttemptResult.STOPPED;
} }
if (!xpYunPrintClient.isReady()) { if (!xpYunPrintClient.isReady()) {
failOrSkip(force, STORE_ORDER_PRINT_DISABLED, "芯烨云未启用或凭证未配置"); failOrSkip(force, STORE_ORDER_PRINT_DISABLED, "芯烨云未启用或凭证未配置");
return; return PrintAttemptResult.STOPPED;
} }
StoreOrderDO order = storeOrderMapper.selectOne(new LambdaQueryWrapper<StoreOrderDO>() StoreOrderDO order = storeOrderMapper.selectOne(new LambdaQueryWrapper<StoreOrderDO>()
.eq(StoreOrderDO::getOrderId, orderId)); .eq(StoreOrderDO::getOrderId, orderId));
if (order == null) { if (order == null) {
failOrSkip(force, STORE_ORDER_NOT_EXISTS, "订单不存在"); failOrSkip(force, STORE_ORDER_NOT_EXISTS, "订单不存在");
return; return PrintAttemptResult.STOPPED;
} }
if (!OrderInfoEnum.PAY_STATUS_1.getValue().equals(order.getPaid())) { if (!OrderInfoEnum.PAY_STATUS_1.getValue().equals(order.getPaid())) {
failOrSkip(force, STORE_ORDER_PRINT_NOT_PAID, "订单未支付"); failOrSkip(force, STORE_ORDER_PRINT_NOT_PAID, "订单未支付");
return; return PrintAttemptResult.STOPPED;
} }
String orderType = order.getOrderType(); String orderType = order.getOrderType();
if (!OrderLogEnum.ORDER_TAKE_IN.getValue().equals(orderType) if (!OrderLogEnum.ORDER_TAKE_IN.getValue().equals(orderType)
&& !OrderLogEnum.ORDER_TAKE_OUT.getValue().equals(orderType)) { && !OrderLogEnum.ORDER_TAKE_OUT.getValue().equals(orderType)) {
failOrSkip(force, STORE_ORDER_PRINT_TYPE_NOT_SUPPORT, "订单类型不支持打印:" + orderType); failOrSkip(force, STORE_ORDER_PRINT_TYPE_NOT_SUPPORT, "订单类型不支持打印:" + orderType);
return; return PrintAttemptResult.STOPPED;
} }
if (order.getShopId() == null) { if (order.getShopId() == null) {
failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "订单无门店"); failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "订单无门店");
return; return PrintAttemptResult.STOPPED;
} }
StoreShopDO shop = storeShopMapper.selectById(order.getShopId()); StoreShopDO shop = storeShopMapper.selectById(order.getShopId());
if (shop == null || StrUtil.isBlank(shop.getUniprintId())) { if (shop == null || StrUtil.isBlank(shop.getUniprintId())) {
failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "门店未配置打印机SN"); failOrSkip(force, STORE_ORDER_PRINT_SN_EMPTY, "门店未配置打印机SN");
return; return PrintAttemptResult.STOPPED;
} }
// 校验通过后再做幂等,避免空 SN / 未启用误占 key // 校验通过后再做幂等,避免空 SN / 未启用误占 key
if (!force && !printOrderRedisDAO.tryMarkPrinted(orderId)) { if (!force && firstAutomaticAttempt && !printOrderRedisDAO.tryStartPrinting(orderId)) {
log.info("[printOrder][orderId({}) 已打印过,跳过]", orderId); log.info("[printOrder][orderId({}) 已存在打印任务,跳过]", orderId);
return; return PrintAttemptResult.STOPPED;
}
// 人工补打成功后会写入 PRINTED,尚未执行的自动重试应立即停止。
if (!force && !firstAutomaticAttempt && printOrderRedisDAO.isPrinted(orderId)) {
log.info("[printOrder][orderId({}) 已打印成功,停止重试]", orderId);
return PrintAttemptResult.STOPPED;
} }
List<StoreOrderCartInfoDO> items = storeOrderCartInfoMapper.selectList( List<StoreOrderCartInfoDO> items = storeOrderCartInfoMapper.selectList(
@@ -312,8 +327,11 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
throw exception(STORE_ORDER_PRINT_FAILED, msg); throw exception(STORE_ORDER_PRINT_FAILED, msg);
} }
log.error("[printOrder][orderId({}) 打印失败] {}", orderId, msg); log.error("[printOrder][orderId({}) 打印失败] {}", orderId, msg);
return PrintAttemptResult.RETRYABLE_FAILURE;
} else { } else {
log.info("[printOrder][orderId({}) 打印成功] data={}", orderId, resp.getData()); log.info("[printOrder][orderId({}) 打印成功] data={}", orderId, resp.getData());
markPrintedQuietly(orderId);
return PrintAttemptResult.SUCCESS;
} }
} catch (co.yixiang.yshop.framework.common.exception.ServiceException e) { } catch (co.yixiang.yshop.framework.common.exception.ServiceException e) {
throw e; throw e;
@@ -322,9 +340,69 @@ public class AsynStoreOrderServiceImpl implements AsyncStoreOrderService {
throw exception(STORE_ORDER_PRINT_FAILED, e.getMessage()); throw exception(STORE_ORDER_PRINT_FAILED, e.getMessage());
} }
log.error("[printOrder][orderId({}) 打印异常]", orderId, e); 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, private void failOrSkip(boolean force,
co.yixiang.yshop.framework.common.exception.ErrorCode errorCode, co.yixiang.yshop.framework.common.exception.ErrorCode errorCode,
String logMsg) { String logMsg) {
@@ -19,7 +19,7 @@ public interface AsyncStoreOrderService {
void getOrderTimeData(); void getOrderTimeData();
/** /**
* 支付成功后异步打印小票(失败只记日志,不抛异常) * 支付成功后异步打印小票(瞬时故障按 30/60/120/180/300 秒重试)
* *
* @param orderId 业务订单号 * @param orderId 业务订单号
*/ */
@@ -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<Instant> 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));
}
}