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
* 任务一旦开始就必须跑完,只靠提交时拒绝挡不住「提交时 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()));
}
}