package cn.iocoder.yudao.module.mes.service.pro.qrexport; 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.MesProQrExportTaskStatusEnum; import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils; import cn.iocoder.yudao.module.mes.framework.qrcode.QrLabelInfo; 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 lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; import java.nio.file.Files; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.zip.ZipOutputStream; /** * 二维码导出任务执行器 *

* 在后台线程内把标签逐张写入临时 ZIP,并做三件事:回写进度、响应取消、在 CPU 高水位时让出 CPU。 *

* 这里刻意不一次性装配全部标签,而是按页取、逐张写:单批次可达成千上万张,一次性装配会让堆占用 * 随导出量线性增长,量级再往上就会 OOM。 */ @Slf4j @Component public class MesProQrExportRunner { /** * 每次向 ZIP 取数的页大小 */ private static final int PAGE_SIZE = 500; @Resource private MesProQrExportTaskMapper taskMapper; @Resource private MesProQrExportProperties properties; @Resource private SystemCpuLoadMonitor cpuMonitor; @Resource private MesProQrExportTaskRegistry registry; /** * 执行导出任务(由线程池调用,勿在请求线程内直接调用) * * @param taskId 任务编号 * @param source 标签数据源 */ public void run(Long taskId, MesProQrLabelSource source) { // 取消标记在提交时已注册,这里只取用;取不到说明注册环节被绕过,补一个以保证取消可用 AtomicBoolean cancelFlag = registry.get(taskId); if (cancelFlag == null) { cancelFlag = registry.register(taskId); } File target = null; try { taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.RUNNING.getStatus(), null, null, null, null); target = resolveTargetFile(taskId); int written = writeZip(target, source, taskId, cancelFlag); if (cancelFlag.get()) { deleteQuietly(target); taskMapper.updateProgress(taskId, written); taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(), null, null, null, LocalDateTime.now()); log.info("[run][导出任务 {} 已取消,已生成 {} 张]", taskId, written); return; } // 进度是每若干张回写一次,收尾时必须补一次真实的已生成张数, // 否则任务已完成却停在 83% 这类中间值上,界面与记录都会误导使用者 taskMapper.updateProgress(taskId, written); taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FINISHED.getStatus(), target.getAbsolutePath(), target.length(), null, LocalDateTime.now()); log.info("[run][导出任务 {} 生成完成,共 {} 张,文件 {} 字节]", taskId, written, target.length()); } catch (QrExportCancelledException e) { deleteQuietly(target); taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.CANCELED.getStatus(), null, null, null, LocalDateTime.now()); log.info("[run][导出任务 {} 已取消]", taskId); } catch (Exception e) { deleteQuietly(target); taskMapper.updateStatus(taskId, MesProQrExportTaskStatusEnum.FAILED.getStatus(), null, null, truncateError(e), LocalDateTime.now()); log.error("[run][导出任务 {} 生成失败]", taskId, e); } finally { registry.unregister(taskId); registry.release(); } } /** * 逐页取标签、逐张写入 ZIP * * @return 实际写入的标签张数 */ private int writeZip(File target, MesProQrLabelSource source, Long taskId, AtomicBoolean cancelFlag) throws IOException { long deadline = System.currentTimeMillis() + Duration.ofMinutes(Math.max(1, properties.getMaxRuntimeMinutes())).toMillis(); int written = 0; int pageNo = 1; try (OutputStream fileOut = new FileOutputStream(target); ZipOutputStream zip = new ZipOutputStream(new BufferedOutputStream(fileOut))) { while (true) { List page = source.fetchPage(pageNo, PAGE_SIZE); if (page.isEmpty()) { break; } for (QrLabelInfo label : page) { // 取消检查放在每张之前:原子读开销可忽略,换来取消的及时响应 if (cancelFlag.get()) { throw new QrExportCancelledException(); } QrCodeImageUtils.writeZipEntry(zip, label); written++; if (written % Math.max(1, properties.getProgressCheckInterval()) == 0) { afterEachInterval(taskId, written, deadline); } } pageNo++; } zip.finish(); } return written; } /** * 每生成若干张后的例行动作:回写进度、检查运行超时、按 CPU 水位让出 CPU */ private void afterEachInterval(Long taskId, int written, long deadline) throws IOException { taskMapper.updateProgress(taskId, written); if (System.currentTimeMillis() > deadline) { throw new IOException("导出任务超过最长运行时间(" + properties.getMaxRuntimeMinutes() + " 分钟)"); } throttleIfCpuBusy(); } /** * CPU 高于高水位时让出 CPU *

* 任务一旦开始就必须跑完,只靠提交时拒绝挡不住「提交时 CPU 低、跑起来把同机业务拖卡」, * 因此生成过程中持续让路:导出变慢是可以接受的,业务页面卡顿不可接受。 */ private void throttleIfCpuBusy() { if (!cpuMonitor.isAvailable()) { return; } double load = cpuMonitor.getCpuLoad(); if (load < properties.getCpuThrottleThreshold()) { return; } try { Thread.sleep(Math.max(1L, properties.getCpuThrottleSleepMillis())); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new QrExportCancelledException(); } } /** * 解析临时文件路径,并确保目录存在 */ private File resolveTargetFile(Long taskId) throws IOException { File dir = new File(properties.resolveTempDir()); if (!dir.exists() && !dir.mkdirs() && !dir.exists()) { throw new IOException("无法创建二维码导出临时目录:" + dir.getAbsolutePath()); } // 文件名只用任务编号,避免业务码值中的特殊字符进入文件系统 return new File(dir, "qr-export-" + taskId + ".zip"); } private void deleteQuietly(File file) { if (file == null || !file.exists()) { return; } try { Files.deleteIfExists(file.toPath()); } catch (IOException e) { log.warn("[deleteQuietly][删除临时文件失败 {}]", file.getAbsolutePath(), e); } } private String truncateError(Exception e) { String message = e.getMessage(); if (message == null || message.isBlank()) { message = e.getClass().getSimpleName(); } return message.length() > 500 ? message.substring(0, 500) : message; } /** * 取消信号:生成过程中检测到取消时抛出,由 {@link #run} 统一收尾 */ static class QrExportCancelledException extends RuntimeException { QrExportCancelledException() { super("导出任务已取消"); } } /** * 供清理任务复用:删除任务遗留的临时文件 */ void deleteTaskFile(MesProQrExportTaskDO task) { if (task.getFilePath() == null || task.getFilePath().isBlank()) { return; } deleteQuietly(new File(task.getFilePath())); } }