Java 图片压缩处理

0 阅读4分钟

1 处理思路

1.1 引入maven

<!-- 图片压缩 -->
<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.4.21</version>
</dependency>

<!-- 图片压缩 ImageIO 增强(强烈建议加) -->
<dependency>
    <groupId>com.twelvemonkeys.imageio</groupId>
    <artifactId>imageio-jpeg</artifactId>
    <version>3.13.1</version>
</dependency>
<dependency>
    <groupId>com.github.gotson</groupId>
    <artifactId>webp-imageio</artifactId>
    <version>0.2.2</version>
</dependency>

1.2 处理思路

配置压缩配置 -> 压缩接口 -> 实现压缩工具类 -> 业务中调用

2 处理代码

2.1 封装图片压缩

/**
 * 2.1.1 图片压缩配置
 */
@Data
public class ImageCompressConfig {
    private int maxWidth;       // 宽高
    private int maxHeight;      // 宽高
    private long targetSize;    // 目标大小(字节)
    private float quality;      // 压缩质量
    private String format;      // jpg / png / webp

    public static ImageCompressConfig of200KB() {
        ImageCompressConfig config = new ImageCompressConfig();
        config.maxWidth = 800;
        config.maxHeight = 800;
        config.targetSize = 200 * 1024;
        config.quality = 0.85f;
        config.format = "jpg"; // 👉 可以改 webp
        return config;
    }

    public static ImageCompressConfig of200KBWebp() {
        ImageCompressConfig config = new ImageCompressConfig();
        config.setMaxWidth(800);
        config.setMaxHeight(800);
        config.setTargetSize(200 * 1024);
        config.setQuality(0.85f);
        config.setFormat("webp"); // 🔥
        return config;
    }
}


/**
 * 2.1.2 图片压缩接口
 */
public interface ImageCompressor {
    void compress(File src, File dest, ImageCompressConfig config) throws Exception;
}


------- 实现接口方法
import lombok.extern.slf4j.Slf4j;
import net.coobird.thumbnailator.Thumbnails;

import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;

/**
 * 2.1.3 图片压缩工具类
 * @author hubiao
 */
@Slf4j
public class ThumbnailatorCompressor implements ImageCompressor {

    @Override
    public void compress(File src, File dest, ImageCompressConfig config) throws Exception {

        log.info("开始压缩图片:{},目标格式:{}", src.getAbsolutePath(), config.getFormat());

        String format = config.getFormat().toLowerCase();

        // WebP 格式需要特殊处理
        if ("webp".equals(format)) {
            compressWebP(src, dest, config);
        } else {
            compressWithThumbnailator(src, dest, config);
        }
    }

    /**
     * WebP 压缩实现
     */
    private void compressWebP(File src, File dest, ImageCompressConfig config) throws Exception {
        log.info("使用 WebP 压缩: {}", src.getName());

        // 1. 使用 Thumbnailator 先处理图片尺寸和质量(输出为 JPG 临时文件)
        File tempFile = File.createTempFile("temp_", ".jpg");
        try {
            float quality = config.getQuality();
            int width = config.getMaxWidth();
            int height = config.getMaxHeight();

            // 首次压缩到临时 JPG 文件
            Thumbnails.of(src)
                    .size(width, height)
                    .outputQuality(quality)
                    .outputFormat("jpg")
                    .toFile(tempFile);

            // 2. 循环调整质量
            while (tempFile.length() > config.getTargetSize() && quality > 0.3f) {
                quality -= 0.05f;
                Thumbnails.of(src)
                        .size(width, height)
                        .outputQuality(quality)
                        .outputFormat("jpg")
                        .toFile(tempFile);
            }

            // 3. 兜底:降分辨率
            while (tempFile.length() > config.getTargetSize() && width > 300) {
                width -= 100;
                height -= 100;
                Thumbnails.of(src)
                        .useExifOrientation(true)
                        .size(width, height)
                        .outputQuality(quality)
                        .outputFormat("jpg")
                        .toFile(tempFile);
            }

            // 4. 读取临时图片并转换为 WebP
            BufferedImage image = ImageIO.read(tempFile);
            if (image == null) {
                throw new IOException("无法读取临时图片");
            }

            // 5. 写入 WebP 格式(现在 webp-imageio 会处理)
            boolean written = ImageIO.write(image, "webp", dest);
            if (!written) {
                // 尝试其他格式名称
                written = ImageIO.write(image, "WEBP", dest);
                if (!written) {
                    throw new IOException("WebP Writer 不可用,请检查 webp-imageio 依赖");
                }
            }

            log.info("WebP 压缩完成: {} -> {} bytes", dest.getName(), dest.length());

        } finally {
            if (tempFile.exists()) {
                tempFile.delete();
            }
        }
    }

    /**
     * 非 WebP 格式使用 Thumbnailator 直接压缩
     */
    private void compressWithThumbnailator(File src, File dest, ImageCompressConfig config) throws Exception {
        float quality = config.getQuality();
        String format = config.getFormat();

        // 初次压缩
        Thumbnails.of(src)
                .size(config.getMaxWidth(), config.getMaxHeight())
                .outputQuality(quality)
                .outputFormat(format)
                .toFile(dest);

        // 控制文件大小
        while (dest.length() > config.getTargetSize() && quality > 0.3f) {
            quality -= 0.05f;
            Thumbnails.of(src)
                    .size(config.getMaxWidth(), config.getMaxHeight())
                    .outputQuality(quality)
                    .outputFormat(format)
                    .toFile(dest);
        }

        // 兜底:降分辨率
        int width = config.getMaxWidth();
        int height = config.getMaxHeight();

        while (dest.length() > config.getTargetSize() && width > 300) {
            width -= 100;
            height -= 100;
            Thumbnails.of(src)
                    .useExifOrientation(true)
                    .size(width, height)
                    .outputQuality(quality)
                    .outputFormat(format)
                    .toFile(dest);
        }

        log.info("压缩完成: {} -> {} bytes", dest.getName(), dest.length());
    }
}




/**
 * 图片压缩服务
 */
@Service
@Slf4j
public class ImageCompressService {

    private final ImageCompressor compressor;

    public ImageCompressService() {
        // 👉 当前用 Thumbnailator,未来可以切换
        this.compressor = new ThumbnailatorCompressor();
    }

    public void compress(File src, File dest) throws Exception {
        log.info("开始压缩图片:{}", src.getAbsolutePath());
        compress(src, dest, ImageCompressConfig.of200KBWebp());
    }

    public void compress(File src, File dest, ImageCompressConfig config) throws Exception {

        // 1. 安全校验(非常重要)
        BufferedImage image = ImageIO.read(src);
        if (image == null) {
            throw new RuntimeException("非法图片文件");
        }

        // 2. 执行压缩
        compressor.compress(src, dest, config);
    }
}

2.2 业务中使用封装图片压缩

/**
 * 商品条码 - 上传图片压缩文件
 */
@PreAuthorize("@ss.hasPermi('product:product:add')")
@Log(title = "商品", businessType = BusinessType.INSERT)
@PostMapping("/uploadImageZipByCode")
public AjaxResult uploadImageZipByCode(MultipartFile zipFile) throws Exception
{
        Long tenantId = SecurityUtils.getTenantId();

        // 0. 校验文件
        if (zipFile == null || zipFile.isEmpty()) {
            throw new ServiceException("请选择 zip 文件");
        }
        // 1. 文件大小校验
        if (zipFile.getSize() > 100 * 1024 * 1024) {
            throw new ServiceException("ZIP不能超过100MB");
        }
        // 2. 提取文件扩展名
        String extension = FileUploadUtils.getExtension(zipFile);
        System.out.println("文件扩展名: " + extension);
        if (!FileUploadUtils.isAllowedExtension(extension, MimeTypeUtils.ZIP_EXTENSION)) {
            throw new ServiceException("只能上传 zip 文件");
        }
        // 3. 基础路径
        String basePath = FileUploadUtils.getDefaultBaseDir();
        // 4. 临时目录(防止并发覆盖)
        String tempDirPath = basePath + "/temp/" + System.currentTimeMillis();
        File tempDir = new File(tempDirPath);
        tempDir.mkdirs();
        // 5. 将 zip 落盘
        String filename = Optional.ofNullable(zipFile.getOriginalFilename()).orElse("upload.zip");
        File tempZipFile = new File(tempDir, filename); // 临时文件名

        try {
            log.info("开始上传文件: " + tempZipFile.getAbsolutePath());

            // 1. 保存ZIP
            zipFile.transferTo(tempZipFile);

            log.info("文件保存成功: " + tempZipFile.getAbsolutePath());

            // 2. 🔥 安全解压(核心)
            unzipSafe(tempZipFile, tempDir);

            log.info("文件解压成功: " + tempZipFile.getAbsolutePath());

            // 3 解压后的文件列表 - 获取图片
            File[] files = tempDir.listFiles(pathname ->
                    pathname.isFile() &&
                            (pathname.getName().endsWith(".jpg") ||
                                    pathname.getName().endsWith(".png") ||
                                    pathname.getName().endsWith(".jpeg") ||
                                    pathname.getName().endsWith(".webp"))
            );

            if (files == null || files.length == 0) {
                // FileUtil.del(tempDir);
                throw new ServiceException("ZIP 中未找到图片文件");
            }

            log.info("文件列表: " + files.length);

            int total = files.length;
            int success = 0;
            int fail = 0;
            List<ImageUploadError> failList = new ArrayList<>();

            // 4. 正式存储路径(按月份)
            String realDirPath = basePath + "/productImage/" + DateUtils.monthPath();
            File realDir = new File(realDirPath);
            realDir.mkdirs();

            // 6. 循环处理每张图片
            for (File img : files) {
                log.info("处理图片: " + img.getAbsolutePath());

                String fullName = img.getName();
                String productCode = fullName.substring(0, fullName.lastIndexOf(".")); // SP001

                ProductSkuVo sku = productSkuService.selectSkuBySkuCode(productCode, tenantId);
                if (sku == null) {
                    fail++;
                    ImageUploadError error = new ImageUploadError();
                    error.setSkuCode(productCode);
                    error.setErrorMessage("商品条码不存在");
                    error.setFileName(fullName);
                    failList.add(error);
                    continue;
                }
                // 删除老照片
                FileUploadUtils.deleteFile(sku.getSkuImage());

                // 保存到最终目录
                String newFileName = productCode + img.getName().substring(img.getName().lastIndexOf("."));
                File dest = new File(realDir, newFileName);

                try {
                    // FileUtil.copy(img, dest, true);
                    // 压缩图片并保存
                    imageCompressService.compress(img, dest);
                } catch (Exception e) {
                    log.info("图片压缩失败: " + e.getMessage());
                    fail++;
                    ImageUploadError error = new ImageUploadError();
                    error.setSkuCode(productCode);
                    error.setErrorMessage(e.getMessage());
                    error.setFileName(fullName);
                    failList.add(error);
                    continue;
                }

                // 获取相对路径,包含租户信息
                String relativePath = "t_" + tenantId + "/productImage/" + DateUtils.monthPath() + "/" + newFileName;
                String imageUrl = "/profile/" + relativePath;
                productService.updateImage(sku, imageUrl, tenantId);
                log.info("图片保存成功: " + imageUrl);
                success++;
            }

            // 热更新sku
            productServiceImpl.refreshCacheByTenant(tenantId);

            // 返回结果
            AjaxResult ajax = AjaxResult.success();
            ajax.put("total", total);
            ajax.put("success", success);
            ajax.put("fail", fail);
            ajax.put("failList", failList);
            return ajax;

        }  finally {
            log.info("文件处理完成,清理临时路径: " + tempDir.getAbsolutePath());
            // 🔥 无论成功失败都清理
            FileUtil.del(tempDir);
        }


}

// 🔥 解压缩 + 防止恶意路径写入覆盖系统文件
private void unzipSafe(File zipFile, File targetDir) throws IOException {

    String basePath = targetDir.getCanonicalPath();
    int count = 0;

    log.info("开始解压: " + zipFile.getAbsolutePath());

    try (ZipFile zip = new ZipFile(zipFile, Charset.defaultCharset())) {
        // 获取ZIP条目
        Enumeration<? extends ZipEntry> entries = zip.entries();

        while (entries.hasMoreElements()) {
            // 🔥 防止 ZIP 炸弹
            if (++count > 2000) {
                throw new ServiceException("ZIP文件数量不允许超过2000个");
            }
            // 🔥 忽略大文件
            ZipEntry entry = entries.nextElement();
            // 限制单文件大小 5M
            if (entry.getSize() > 5 * 1024 * 1024) {
                continue; // 或直接拒绝
            }

            // 构造输出文件路径
            File outFile = new File(targetDir, entry.getName());

            // 🔥 核心防御:路径穿越校验
            String canonicalPath = outFile.getCanonicalPath();
            if (!canonicalPath.startsWith(basePath + File.separator)) {
                throw new ServiceException("非法ZIP文件(路径穿越攻击)");
            }

            // 创建目录
            if (entry.isDirectory()) {
                outFile.mkdirs();
                continue;
            }

            // 创建父目录
            outFile.getParentFile().mkdirs();

            // 写文件
            try (InputStream in = zip.getInputStream(entry); OutputStream out = new FileOutputStream(outFile)) {

                byte[] buffer = new byte[8192];
                int len;
                while ((len = in.read(buffer)) != -1) {
                    out.write(buffer, 0, len);
                }
            }
        }
    }
}