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;
|
|
/**
|
* 二维码导出任务执行器
|
* <p>
|
* 在后台线程内把标签逐张写入临时 ZIP,并做三件事:回写进度、响应取消、在 CPU 高水位时让出 CPU。
|
* <p>
|
* 这里刻意不一次性装配全部标签,而是按页取、逐张写:单批次可达成千上万张,一次性装配会让堆占用
|
* 随导出量线性增长,量级再往上就会 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<QrLabelInfo> 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
|
* <p>
|
* 任务一旦开始就必须跑完,只靠提交时拒绝挡不住「提交时 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()));
|
}
|
|
}
|