package cn.iocoder.yudao.module.mes.service.pro.qrexport; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.module.mes.dal.dataobject.pro.qrexport.MesProQrExportTaskDO; import cn.iocoder.yudao.module.mes.dal.mysql.pro.qrexport.MesProQrExportTaskMapper; import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportBizTypeEnum; import cn.iocoder.yudao.module.mes.enums.pro.MesProQrExportTaskStatusEnum; import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties; import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportTaskRegistry; import cn.iocoder.yudao.module.mes.framework.qrexport.SystemCpuLoadMonitor; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import org.springframework.stereotype.Service; import org.springframework.util.StreamUtils; import org.springframework.validation.annotation.Validated; import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.Files; import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.RejectedExecutionException; import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_NOT_EXISTS; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BATCH_NOT_EXISTS; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_PALLET_NOT_EXISTS; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_SERVER_BUSY; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_BUSY; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_FILE_MISSING; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_NOT_EXISTS; import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_QR_EXPORT_TASK_STATUS_INVALID; /** * MES 二维码导出任务 Service 实现 */ @Slf4j @Service @Validated public class MesProQrExportTaskServiceImpl implements MesProQrExportTaskService { /** * 非终态状态集合 */ private static final List UNFINISHED_STATUSES = List.of( MesProQrExportTaskStatusEnum.PENDING.getStatus(), MesProQrExportTaskStatusEnum.RUNNING.getStatus()); /** * 终态状态集合 */ private static final List FINAL_STATUSES = List.of( MesProQrExportTaskStatusEnum.FINISHED.getStatus(), MesProQrExportTaskStatusEnum.CANCELED.getStatus(), MesProQrExportTaskStatusEnum.FAILED.getStatus(), MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus()); @Resource private MesProQrExportTaskMapper taskMapper; @Resource private MesProQrLabelSourceFactory sourceFactory; @Resource private MesProQrExportRunner runner; @Resource private MesProQrExportProperties properties; @Resource private SystemCpuLoadMonitor cpuMonitor; @Resource private MesProQrExportTaskRegistry registry; @Resource(name = "mesQrExportExecutor") private ThreadPoolTaskExecutor executor; @Override public long countLabels(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) { return sourceFactory.count(bizType, uuid, batchId); } @Override public MesProQrExportTaskDO submit(MesProQrExportBizTypeEnum bizType, String uuid, Long batchId) { // 1. 解析数据源并统计数量:参数不合法或业务数据不存在时,在此直接抛出而不落任务 MesProQrLabelSource source = sourceFactory.create(bizType, uuid, batchId); long total = source.count(); if (total <= 0) { throw exception(resolveEmptyErrorCode(bizType)); } // 2. 并发闸门:同一时间只跑一个大批量任务;小批量同步导出不走这里,因此不会被挡住 if (!registry.tryAcquire()) { throw exception(PRO_QR_EXPORT_TASK_BUSY, resolveRunningProgress()); } // 一旦任务被交给执行器,并发许可就转由执行线程在收尾时归还;此前的任何失败都必须在这里归还 boolean handedOver = false; try { // 3. CPU 闸门:服务器已经很忙时不再叠加导出负载 assertCpuAllowSubmit(); // 4. 落任务 MesProQrExportTaskDO task = MesProQrExportTaskDO.builder() .bizType(bizType.getType()) .uuid(uuid) .batchId(batchId) .taskName(source.zipName()) .status(MesProQrExportTaskStatusEnum.PENDING.getStatus()) .totalCount((int) total) .finishedCount(0) .build(); taskMapper.insert(task); // 5. 先注册运行态再投递:避免「已落库但未注册」的窗口被清理任务误回收 registry.register(task.getId()); handedOver = true; try { executor.execute(() -> runner.run(task.getId(), source)); } catch (RejectedExecutionException e) { registry.unregister(task.getId()); handedOver = false; taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(), null, null, "导出线程繁忙,请稍后重试", LocalDateTime.now()); throw exception(PRO_QR_EXPORT_TASK_BUSY, 0); } return task; } finally { if (!handedOver) { registry.release(); } } } @Override public MesProQrExportTaskDO getTask(Long id) { MesProQrExportTaskDO task = taskMapper.selectById(id); if (task == null) { throw exception(PRO_QR_EXPORT_TASK_NOT_EXISTS); } return task; } @Override public void cancel(Long id) { MesProQrExportTaskDO task = getTask(id); if (isFinalStatus(task.getStatus())) { throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID); } if (registry.requestCancel(id)) { // 生成线程会自行收尾:删除半成品文件并置为已取消 return; } // 不在本进程运行(进程重启后的残留任务):直接收尾 runner.deleteTaskFile(task); taskMapper.updateStatus(id, MesProQrExportTaskStatusEnum.CANCELED.getStatus(), null, null, null, LocalDateTime.now()); } @Override public void download(Long id, HttpServletResponse response) throws IOException { MesProQrExportTaskDO task = getTask(id); if (!MesProQrExportTaskStatusEnum.FINISHED.getStatus().equals(task.getStatus())) { throw exception(PRO_QR_EXPORT_TASK_STATUS_INVALID); } File file = resolveTaskFile(task); response.setContentType("application/zip"); response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(task.getTaskName() + ".zip")); boolean success = false; try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { StreamUtils.copy(in, response.getOutputStream()); response.getOutputStream().flush(); success = true; } finally { // 仅在完整传输后删除;中断时保留,交由清理任务在保留期后删除,便于短时间内重试 if (success) { deleteAfterDownload(task, file); } } } @Override public int cleanupExpiredFiles() { LocalDateTime deadline = LocalDateTime.now() .minusMinutes(Math.max(1, properties.getTempFileRetentionMinutes())); List list = taskMapper.selectExpiredFinalList(FINAL_STATUSES, deadline); int count = 0; for (MesProQrExportTaskDO task : list) { if (StrUtil.isBlank(task.getFilePath())) { continue; } runner.deleteTaskFile(task); taskMapper.clearFilePath(task.getId()); count++; } return count; } @Override public int recycleStaleTasks() { List list = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES); int count = 0; for (MesProQrExportTaskDO task : list) { if (registry.isRunning(task.getId())) { continue; } runner.deleteTaskFile(task); taskMapper.clearFilePath(task.getId()); taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.FAILED.getStatus(), null, null, "任务因服务重启或异常中断而终止,请重新导出", LocalDateTime.now()); count++; } return count; } // ==================== 私有方法 ==================== /** * 校验系统 CPU 是否允许提交大批量导出 *

* 取不到 CPU 指标时按放行处理:不能因为读不到监控值就阻断业务。 */ private void assertCpuAllowSubmit() { if (!cpuMonitor.isAvailable()) { return; } double load = cpuMonitor.getCpuLoad(); double threshold = properties.getCpuRejectThreshold(); if (load >= threshold) { throw exception(PRO_QR_EXPORT_SERVER_BUSY, Math.round(load * 100), Math.round(threshold * 100)); } } /** * 数据为空时的错误码:按业务类型给出更准确的提示 */ private cn.iocoder.yudao.framework.common.exception.ErrorCode resolveEmptyErrorCode( MesProQrExportBizTypeEnum bizType) { return switch (bizType) { case PALLET_LABEL, PALLET_BUSINESS -> PRO_PALLET_NOT_EXISTS; case BATCH_LABEL, BATCH_BUSINESS -> PRO_BATCH_NOT_EXISTS; default -> PRO_BAG_CODE_NOT_EXISTS; }; } /** * 正在运行任务的进度百分比,用于「已有任务进行中」的提示 */ private int resolveRunningProgress() { List tasks = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES); return tasks.stream().findFirst().map(this::calcProgress).orElse(0); } private int calcProgress(MesProQrExportTaskDO task) { Integer total = task.getTotalCount(); if (total == null || total <= 0) { return 0; } int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount(); return (int) Math.min(100L, (long) finished * 100 / total); } private boolean isFinalStatus(Integer status) { return status != null && FINAL_STATUSES.contains(status); } /** * 定位任务的临时 ZIP,文件缺失时顺手清空路径避免后续反复失败 */ private File resolveTaskFile(MesProQrExportTaskDO task) { String filePath = task.getFilePath(); if (StrUtil.isBlank(filePath)) { throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING); } File file = new File(filePath); if (!file.isFile()) { taskMapper.clearFilePath(task.getId()); throw exception(PRO_QR_EXPORT_TASK_FILE_MISSING); } return file; } /** * 下载完成即删除临时文件:单次导出可达上 GB,留着会迅速把磁盘吃满 */ private void deleteAfterDownload(MesProQrExportTaskDO task, File file) { try { Files.deleteIfExists(file.toPath()); } catch (IOException e) { log.warn("[deleteAfterDownload][删除临时文件失败 {}]", file.getAbsolutePath(), e); } taskMapper.clearFilePath(task.getId()); taskMapper.updateStatus(task.getId(), MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus(), null, null, null, null); } }