云
3 小时以前 a7cf1913c376b53f487f0ef8dfff084c5bc010a7
feat(mes): 支持二维码导出任务的分段下载功能

- 修改 MesProQrExportTaskService 的 download 方法支持 Range 请求头参数
- 更新临时 ZIP 文件保留时间从 10 分钟调整为 60 分钟以支持分段下载
- 优化 QrCodeImageUtils 中的图片写入逻辑,使用内存缓存避免磁盘缓存
- 修正 MesProBagCodeGenerateReqVO 中托盘数字段的描述文案
- 添加数据库脚本将计量单位类型的"千克"统一替换为"袋"
- 新增分段下载测试用例验证 Range 请求的正确性
- 新增二维码图片压缩包写入功能的单元测试
已添加3个文件
已修改13个文件
284 ■■■■■ 文件已修改
sql/mysql/mdm_unit_measure_type_bag.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/unit/vo/MdmUnitMeasureSaveReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/unit/MdmUnitMeasureDO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateReqVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateRespVO.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/MesProQrExportTaskController.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportProperties.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportRunner.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskService.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskServiceImpl.java 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtilsZipTest.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskDownloadTest.java 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mdm_unit_measure_type_bag.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,5 @@
-- è®¡é‡å•位类型由“N千克”改为“N袋”。数字表示每托袋数,重量仍由 weight å­—段表示。
-- å¯é‡å¤æ‰§è¡Œï¼›åªä¿®æ”¹ç¬¦åˆè¯¥æ ¼å¼çš„类型,不改单位名称及重量。
UPDATE mdm_unit_measure
SET type = CONCAT(LEFT(type, CHAR_LENGTH(type) - 2), '袋')
WHERE type REGEXP '^[0-9]{1,5}千克$';
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/controller/admin/unit/vo/MdmUnitMeasureSaveReqVO.java
@@ -18,7 +18,7 @@
    @Schema(description = "单位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "个")
    private String name;
    @Schema(description = "类型(48千克/100千克/吨),非必填;袋码生成依赖该字段,为空时无法生成袋码", example = "48千克")
    @Schema(description = "类型(48袋/100袋/吨),非必填;袋码生成依赖该字段,为空时无法生成袋码", example = "48袋")
    private String type;
    @Schema(description = "重量(kg)", example = "25")
yudao-module-mdm/src/main/java/cn/iocoder/yudao/module/mdm/dal/dataobject/unit/MdmUnitMeasureDO.java
@@ -42,7 +42,7 @@
    /**
     * ç±»åž‹
     *
     * å¯é€‰å€¼ï¼š48千克、100千克、吨
     * å¯é€‰å€¼ï¼š48袋、100袋、吨
     *
     * éžå¿…å¡«ï¼›MES è¢‹ç ç”Ÿæˆä¾èµ–该字段换算每托袋数,为空时无法生成袋码
     */
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateReqVO.java
@@ -13,7 +13,7 @@
    @NotNull(message = "品种不能为空")
    private Long itemId;
    @Schema(description = "托盘数(品种主单位类型为 48千克/100千克 æ—¶å¿…填,袋码数 = æ¯æ‰˜è¢‹æ•° Ã— æ‰˜ç›˜æ•°ï¼‰", example = "5")
    @Schema(description = "托盘数(品种主单位类型为 48袋/100袋 æ—¶å¿…填,袋码数 = æ¯æ‰˜è¢‹æ•° Ã— æ‰˜ç›˜æ•°ï¼‰", example = "5")
    @Positive(message = "托盘数必须大于 0")
    private Integer palletCount;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateRespVO.java
@@ -12,7 +12,7 @@
    @Schema(description = "生成组 UUID")
    private String uuid;
    @Schema(description = "品种主单位类型", example = "48千克")
    @Schema(description = "品种主单位类型", example = "48袋")
    private String type;
    @Schema(description = "每托盘袋数(类型为 å¨ æ—¶ä¸º 0)", example = "48")
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/qrexport/MesProQrExportTaskController.java
@@ -100,11 +100,13 @@
    }
    @GetMapping("/task/download")
    @Operation(summary = "下载二维码导出产物(下载完成后系统立即删除临时文件)")
    @Operation(summary = "下载二维码导出产物(支持 Range åˆ†æ®µä¸‹è½½ï¼‰")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize(ANY_EXPORT_PERMISSION)
    public void download(@RequestParam("id") Long id, HttpServletResponse response) throws IOException {
        taskService.download(id, response);
    public void download(@RequestParam("id") Long id,
                         @RequestHeader(value = "Range", required = false) String range,
                         HttpServletResponse response) throws IOException {
        taskService.download(id, range, response);
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -861,7 +861,7 @@
    ErrorCode PRO_BAG_CODE_REPLACE_ALREADY_REPLACED = new ErrorCode(1_040_752_009, "袋码已完成替换,不允许重复替换");
    ErrorCode PRO_BAG_CODE_REPLACE_STATUS_INVALID = new ErrorCode(1_040_752_010, "袋码状态不允许破损替换");
    ErrorCode PRO_BAG_CODE_REPLACE_PALLET_INVALID = new ErrorCode(1_040_752_011, "袋码原托盘不存在或已作废,不允许继承");
    ErrorCode PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED = new ErrorCode(1_040_752_012, "品种主计量单位未配置类型,仅支持 48千克/100千克/吨");
    ErrorCode PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED = new ErrorCode(1_040_752_012, "品种主计量单位未配置类型,仅支持 48袋/100袋/吨");
    ErrorCode PRO_BAG_CODE_PALLET_COUNT_REQUIRED = new ErrorCode(1_040_752_013, "该品种按托盘生成袋码,托盘数必须大于 0");
    ErrorCode PRO_BAG_CODE_BIND_BATCH_ITEM_MISMATCH = new ErrorCode(1_040_752_014, "录入批次的袋码必须属于同一品种");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java
@@ -9,6 +9,9 @@
import lombok.extern.slf4j.Slf4j;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontMetrics;
@@ -17,6 +20,7 @@
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.FilterOutputStream;
import java.util.List;
import java.util.Set;
import java.util.zip.ZipEntry;
@@ -138,9 +142,25 @@
    public static void writeZipEntry(ZipOutputStream zip, QrLabelInfo label) throws IOException {
        BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
        zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
        ImageIO.write(image, "png", zip);
        try {
            // ImageIO.write(OutputStream) å¯èƒ½ä¸ºæ¯å¼ å›¾ç‰‡åˆ›å»ºç£ç›˜ç¼“存;大批量导出改用内存缓存。
            // åŒ…装流的 close ä»… flush,不能关闭外层 ZIP。
            ImageWriter writer = ImageIO.getImageWritersByFormatName("png").next();
            try (ImageOutputStream imageOut = new MemoryCacheImageOutputStream(new FilterOutputStream(zip) {
                @Override
                public void close() throws IOException {
                    flush();
                }
            })) {
                writer.setOutput(imageOut);
                writer.write(image);
            } finally {
                writer.dispose();
            }
        } finally {
        zip.closeEntry();
    }
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrexport/MesProQrExportProperties.java
@@ -72,10 +72,10 @@
    /**
     * ä¸´æ—¶ ZIP çš„保留时间(分钟)
     * <p>
     * æ­£å¸¸ä¸‹è½½å®ŒæˆåŽç«‹å³åˆ é™¤ï¼›ä»…在「下载中断」或「生成完成后始终未下载」时保留一小段时间,
     * åˆ°æœŸç”±æ¸…理任务删除。否则每次中断都要重新生成上万张标签,或者上 GB çš„临时文件一直占着磁盘。
     * å®ŒæˆåŽä¿ç•™ä¸€æ®µæ—¶é—´ä¾›åˆ†æ®µä¸‹è½½å’Œå¤±è´¥é‡è¯•,到期由清理任务删除。
     * é¿å…ç½‘络中断后重新生成上万张标签,也避免临时文件长期占用磁盘。
     */
    private int tempFileRetentionMinutes = 10;
    private int tempFileRetentionMinutes = 60;
    /**
     * è§£æžä¸´æ—¶ç›®å½•,未配置时回退到系统临时目录
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java
@@ -83,9 +83,9 @@
     */
    private static final String UNIT_TYPE_TON = "吨";
    /**
     * è®¡é‡å•位类型「N千克」:每托盘装 N ä¸ªè¢‹ç 
     * è®¡é‡å•位类型「N袋」:每托盘装 N ä¸ªè¢‹ç 
     */
    private static final Pattern KG_TYPE_PATTERN = Pattern.compile("^(\\d{1,5})千克$");
    private static final Pattern BAG_TYPE_PATTERN = Pattern.compile("^(\\d{1,5})袋$");
    @Resource
    private MesCodeRedisDAO codeRedisDao;
@@ -99,9 +99,9 @@
        MesMdmItemDO item = mdmItemService.validateItemExists(generateReqVO.getItemId());
        String type = getUnitType(item);
        // 2. æŒ‰ç±»åž‹æ¢ç®—袋码数量:N千克 = æ¯æ‰˜ N è¢‹ Ã— æ‰˜ç›˜æ•°ï¼›å¨ = ç›´æŽ¥è¾“入袋码数(无托盘)
        // 2. æŒ‰ç±»åž‹æ¢ç®—袋码数量:N袋 = æ¯æ‰˜ N è¢‹ Ã— æ‰˜ç›˜æ•°ï¼›å¨ = ç›´æŽ¥è¾“入袋码数(无托盘)
        boolean ton = UNIT_TYPE_TON.equals(type);
        int perPallet = ton ? 0 : parseKgPerPallet(type);
        int perPallet = ton ? 0 : parseBagsPerPallet(type);
        int totalCount;
        int palletCount = 0;
        if (ton) {
@@ -190,7 +190,7 @@
    }
    /**
     * è¯»å–品种主计量单位的类型(48千克/100千克/吨),未配置或无法识别时报错
     * è¯»å–品种主计量单位的类型(48袋/100袋/吨),未配置或无法识别时报错
     */
    private String getUnitType(MesMdmItemDO item) {
        String type = null;
@@ -203,17 +203,17 @@
        if (type == null) {
            throw exception(PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED);
        }
        if (!UNIT_TYPE_TON.equals(type) && !KG_TYPE_PATTERN.matcher(type).matches()) {
        if (!UNIT_TYPE_TON.equals(type) && !BAG_TYPE_PATTERN.matcher(type).matches()) {
            throw exception(PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED);
        }
        return type;
    }
    /**
     * è§£æžã€ŒN千克」类型得出每托盘袋数
     * è§£æžã€ŒN袋」类型得出每托盘袋数
     */
    private int parseKgPerPallet(String type) {
        Matcher matcher = KG_TYPE_PATTERN.matcher(type);
    private int parseBagsPerPallet(String type) {
        Matcher matcher = BAG_TYPE_PATTERN.matcher(type);
        if (!matcher.matches()) {
            throw exception(PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED);
        }
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportRunner.java
@@ -22,6 +22,7 @@
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.Deflater;
import java.util.zip.ZipOutputStream;
/**
@@ -111,7 +112,9 @@
        int written = 0;
        int pageNo = 1;
        try (OutputStream fileOut = new FileOutputStream(target);
             ZipOutputStream zip = new ZipOutputStream(new BufferedOutputStream(fileOut))) {
             ZipOutputStream zip = new ZipOutputStream(new BufferedOutputStream(fileOut, 256 * 1024))) {
            // æ¯å¼  PNG å·²åŽ‹ç¼©ï¼Œå†åš ZIP deflate ä¼šé‡å¤æ¶ˆè€— CPU;保留 ZIP å®¹å™¨å’Œé€å¼ æ–‡ä»¶ç»“构。
            zip.setLevel(Deflater.NO_COMPRESSION);
            while (true) {
                List<QrLabelInfo> page = source.fetchPage(pageNo, PAGE_SIZE);
                if (page.isEmpty()) {
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskService.java
@@ -56,13 +56,13 @@
    /**
     * ä¸‹è½½å¯¼å‡ºäº§ç‰©
     * <p>
     * ä¸‹è½½æˆåŠŸåŽç«‹å³åˆ é™¤ä¸´æ—¶ ZIP(避免上 GB çš„临时文件堆积);下载中断则保留至清理任务删除,
     * ä»¥ä¾¿è°ƒç”¨æ–¹åœ¨çŸ­æ—¶é—´å†…重试而不必重新生成。
     * æ”¯æŒ Range åˆ†æ®µä¸‹è½½ï¼›ä¸´æ—¶ ZIP ä¿ç•™è‡³æ¸…理任务删除,以便传输中断后续传。
     *
     * @param id       ä»»åŠ¡ç¼–å·
     * @param range    HTTP Range è¯·æ±‚头,可为空
     * @param response å“åº”
     */
    void download(Long id, HttpServletResponse response) throws IOException;
    void download(Long id, String range, HttpServletResponse response) throws IOException;
    /**
     * æ¸…理超期的临时 ZIP æ–‡ä»¶
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);
        }
        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"));
        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);
            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);
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtilsZipTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,41 @@
package cn.iocoder.yudao.module.mes.framework.qrcode;
import cn.hutool.extra.qrcode.QrCodeUtil;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
class QrCodeImageUtilsZipTest {
    @Test
    void writesMultipleReadablePngEntries() throws Exception {
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ZipOutputStream zip = new ZipOutputStream(bytes)) {
            QrCodeImageUtils.writeZipEntry(zip, QrLabelInfo.builder()
                    .fileName("1234567890123456").code("1234567890123456").build());
            QrCodeImageUtils.writeZipEntry(zip, QrLabelInfo.builder()
                    .fileName("1234567890123457").code("1234567890123457").build());
        }
        try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(bytes.toByteArray()))) {
            assertEquals("1234567890123456.png", zip.getNextEntry().getName());
            var first = ImageIO.read(zip);
            assertNotNull(first);
            assertEquals("1234567890123456", QrCodeUtil.decode(first));
            assertEquals("1234567890123457.png", zip.getNextEntry().getName());
            var second = ImageIO.read(zip);
            assertNotNull(second);
            assertEquals("1234567890123457", QrCodeUtil.decode(second));
            assertNull(zip.getNextEntry());
        }
    }
}
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/qrexport/MesProQrExportTaskDownloadTest.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
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 org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.lang.reflect.Proxy;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class MesProQrExportTaskDownloadTest {
    @TempDir
    Path tempDir;
    @Test
    void rangeDownloadReturnsExactBytesAndKeepsFileForRetry() throws Exception {
        byte[] content = "0123456789".getBytes();
        Path file = tempDir.resolve("export.zip");
        Files.write(file, content);
        MesProQrExportTaskDO task = MesProQrExportTaskDO.builder()
                .id(13L).taskName("test").status(MesProQrExportTaskStatusEnum.FINISHED.getStatus())
                .filePath(file.toString()).build();
        MesProQrExportTaskMapper mapper = (MesProQrExportTaskMapper) Proxy.newProxyInstance(
                getClass().getClassLoader(), new Class[]{MesProQrExportTaskMapper.class},
                (proxy, method, args) -> {
                    if (method.getName().equals("selectById")) {
                        return task;
                    }
                    throw new UnsupportedOperationException(method.getName());
                });
        MesProQrExportTaskServiceImpl service = new MesProQrExportTaskServiceImpl();
        ReflectionTestUtils.setField(service, "taskMapper", mapper);
        MockHttpServletResponse partial = new MockHttpServletResponse();
        service.download(13L, "bytes=3-6", partial);
        assertEquals(206, partial.getStatus());
        assertEquals("bytes 3-6/10", partial.getHeader("Content-Range"));
        assertEquals("4", partial.getHeader("Content-Length"));
        assertArrayEquals("3456".getBytes(), partial.getContentAsByteArray());
        MockHttpServletResponse invalid = new MockHttpServletResponse();
        service.download(13L, "bytes=10-11", invalid);
        assertEquals(416, invalid.getStatus());
        assertEquals("bytes */10", invalid.getHeader("Content-Range"));
        MockHttpServletResponse full = new MockHttpServletResponse();
        service.download(13L, null, full);
        assertEquals(200, full.getStatus());
        assertArrayEquals(content, full.getContentAsByteArray());
        assertTrue(Files.exists(file));
    }
}
yudao-server/src/main/resources/application.yaml
@@ -28,8 +28,8 @@
    max-concurrent-tasks: 1
    # å•个后台任务最长运行时间(分钟),超时自动取消并清理
    max-runtime-minutes: 30
    # ä¸´æ—¶ ZIP ä¿ç•™æ—¶é—´ï¼ˆåˆ†é’Ÿï¼‰ã€‚下载完成后立即删除,此处仅兜底「下载中断/始终未下载」的残留
    temp-file-retention-minutes: 10
    # ä¸´æ—¶ ZIP ä¿ç•™æ—¶é—´ï¼ˆåˆ†é’Ÿï¼‰ã€‚分段下载及中断重试期间保留,到期后清理
    temp-file-retention-minutes: 60
    # ä¸´æ—¶ ZIP ç›®å½•,留空则使用 ${java.io.tmpdir}/mes-qr-export
    temp-dir: ''