云
13 小时以前 a7cf1913c376b53f487f0ef8dfff084c5bc010a7
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java
@@ -14,7 +14,6 @@
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;
@@ -22,10 +21,13 @@
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.io.OutputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.RejectedExecutionException;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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;
@@ -60,6 +62,11 @@
            MesProQrExportTaskStatusEnum.CANCELED.getStatus(),
            MesProQrExportTaskStatusEnum.FAILED.getStatus(),
            MesProQrExportTaskStatusEnum.DOWNLOADED.getStatus());
    private static final Pattern BYTE_RANGE = Pattern.compile("^bytes=(\\d+)-(\\d*)$");
    /** 防止定时清理任务删除正在传输的 ZIP。 */
    private final ConcurrentHashMap<Long, Integer> activeDownloads = new ConcurrentHashMap<>();
    @Resource
    private MesProQrExportTaskMapper taskMapper;
@@ -156,25 +163,74 @@
    }
    @Override
    public void download(Long id, HttpServletResponse response) throws IOException {
    public void download(Long id, String range, 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);
        activeDownloads.compute(id, (key, count) -> count == null ? 1 : count + 1);
        try {
            File file = resolveTaskFile(task);
            long fileSize = file.length();
            long[] bounds = resolveRange(range, fileSize);
            response.setHeader("Accept-Ranges", "bytes");
            response.setHeader("X-Accel-Buffering", "no");
            response.setHeader("Cache-Control", "private, no-store");
            response.setHeader("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges");
            if (bounds == null) {
                response.setStatus(416);
                response.setHeader("Content-Range", "bytes */" + fileSize);
                return;
            }
            long start = bounds[0];
            long end = bounds[1];
            long length = end - start + 1;
            response.setContentType("application/zip");
            response.setHeader("Content-Disposition",
                    "attachment;filename=" + HttpUtils.encodeUtf8(task.getTaskName() + ".zip"));
            if (range != null && !range.isBlank()) {
                response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
                response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileSize);
            }
            response.setContentLengthLong(length);
            try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
                in.skipNBytes(start);
                OutputStream out = response.getOutputStream();
                byte[] buffer = new byte[64 * 1024];
                long remaining = length;
                while (remaining > 0) {
                    int read = in.read(buffer, 0, (int) Math.min(buffer.length, remaining));
                    if (read < 0) {
                        throw new IOException("二维码导出文件在下载过程中被截断");
                    }
                    out.write(buffer, 0, read);
                    remaining -= read;
                }
                out.flush();
            }
        } finally {
            activeDownloads.computeIfPresent(id, (key, count) -> count > 1 ? count - 1 : null);
        }
    }
    /** 只支持单个闭区间;不合法的 Range 返回 416,避免误传整包。 */
    private long[] resolveRange(String range, long fileSize) {
        if (range == null || range.isBlank()) {
            return new long[]{0, fileSize - 1};
        }
        Matcher matcher = BYTE_RANGE.matcher(range);
        if (!matcher.matches() || fileSize <= 0) {
            return null;
        }
        try {
            long start = Long.parseLong(matcher.group(1));
            long end = matcher.group(2).isEmpty() ? fileSize - 1 : Long.parseLong(matcher.group(2));
            if (start >= fileSize || end < start) {
                return null;
            }
            return new long[]{start, Math.min(end, fileSize - 1)};
        } catch (NumberFormatException e) {
            return null;
        }
    }
@@ -185,6 +241,9 @@
        List<MesProQrExportTaskDO> list = taskMapper.selectExpiredFinalList(FINAL_STATUSES, deadline);
        int count = 0;
        for (MesProQrExportTaskDO task : list) {
            if (activeDownloads.containsKey(task.getId())) {
                continue;
            }
            if (StrUtil.isBlank(task.getFilePath())) {
                continue;
            }
@@ -278,20 +337,6 @@
            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);
    }
}