| | |
| | | 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.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; |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | @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; |
| | | } |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | |
| | | 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); |
| | | } |
| | | |
| | | } |