java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot动态图片获取缩略图

SpringBoot动态图片获取缩略图方案

作者:勿忘初心1221

还在为SpringBoot图片缩略图的安全漏洞、内存泄漏和画质失真头疼吗,本文提供一套基于拦截器零侵入的动态缩略方案,支持按需实时生成、双引擎渲染、魔数安全校验和LRU智能缓存,彻底解决木马穿透、CMYK偏色和高并发OOM等生产问题,需要的朋友可以参考下

一、前言

在SpringBoot文件服务、内容管理、商城、后台管理系统等业务中,图片列表缩略预览、详情页原图展示是高频刚需场景。市面上绝大多数开源图片处理方案、自定义缩略工具类,普遍存在大量生产级漏洞与性能隐患:后缀伪造木马文件穿透、目录越权遍历、高并发内存泄漏/OOM、CMYK图片色彩失真、缓存雪崩、画布黑底、参数非法击穿服务等问题。

同时,传统预生成缩略图模式会造成海量磁盘冗余文件,前端CSS缩放存在流量浪费、画质模糊、无安全校验的缺陷,第三方命令行工具存在环境依赖、命令注入风险,完全无法适配企业级生产、私有化涉密部署、高并发业务场景。

本文基于Spring拦截器零侵入架构,落地一套最终迭代、生产稳定的动态图片缩略解决方案。核心是:服务器只存储高清原图,前端通过URL携带w/h参数按需实时生成缩略图,无参数直接返回原图。全程无临时文件落地、无磁盘冗余,兼顾安全性、高性能、高兼容、高可用,适配JPG/PNG/BMP全主流图片格式,可直接无缝接入线上项目。

二、业务应用场景

方案适配前后端分离项目所有图片展示场景,严格区分业务逻辑,互不冲突、适配性拉满:

三、传统方案问题

结合线上项目踩坑经验,之前使用的图片缩略方案(含早期迭代版本)普遍存在以下致命缺陷,也是本方案重点优化解决的核心痛点:

3.1 安全漏洞类问题

3.2 性能与内存类问题

3.3 画质与兼容类问题

3.4 并发与健壮性问题

四、本方案核心优势

本方案基于早期版本全方位迭代升级,修复所有已知缺陷,是目前在使用的SpringBoot动态缩略解决方案:

五、核心技术原理

5.1 双引擎渲染架构

采用双引擎可切换架构,适配不同生产环境,一键开关无缝切换:

5.2 魔数安全校验

摒弃传统粗糙后缀校验,采用文件头部16字节魔数精准匹配,从源头拦截恶意文件:

5.3 内存与并发优化

5.4 画质兼容优化原理

六、项目依赖配置

适配SpringBoot2.x版本,依赖轻量化、无冲突、可直接引入:

<!-- SpringWeb核心依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Hutool工具类(IO、读图兜底、字符串处理) -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.7.16</version>
</dependency>
<!-- 通用工具类 -->
<dependency>
    <groupId>org.apache.commons.lang3</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.20.0</version>
</dependency>
<!-- 核心缩略引擎 -->
<dependency>
      <groupId>net.coobird</groupId>
      <artifactId>thumbnailator</artifactId>
      <version>0.4.20</version>
</dependency>

七、接入配置(MVC拦截器注册)

编写MVC配置类,注册缩略拦截器,精准拦截图片路径,零侵入原有业务:

import com.zhang.interceptor.ThumbnailInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

/**
 * WebMvc全局配置类
 * 1. 注册动态图片缩略拦截器,精准拦截业务图片路径
 * 2. 全覆盖本地静态资源映射
 * 3. 自定义业务文件磁盘路径映射,适配缩略图功能
 * 4. 全局跨域配置、资源缓存规范配置
 * @author Z
 */
@Configuration
public class WebMvcConfig extends WebMvcConfigurationSupport {

    /** 业务文件请求路径前缀(与拦截器保持统一) */
    private static final String BUSINESS_FILE_PATTERN = "/businessFile/**";
    /** 本地业务文件磁盘路径 */
    private static final String BUSINESS_FILE_LOCAL_PATH = "file:E:\\project_files\\file\\businessFile\\";

    /**
     * 注册缩略图拦截器
     * 精准拦截业务图片路径,零侵入其他静态资源
     */
    @Override
    protected void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new ThumbnailInterceptor())
                .addPathPatterns(BUSINESS_FILE_PATTERN)
                // 可根据业务扩展排除路径
                .excludePathPatterns();
    }

    /**
     * 全局静态资源映射
     * 整合系统默认静态资源路径 + 业务磁盘文件路径
     * 规范缓存策略,避免缩略图缓存冲突
     */
    @Override
    protected void addResourceHandlers(ResourceHandlerRegistry registry) {
        // 系统通用静态资源映射(页面、js、css等)
        registry.addResourceHandler("/**")
                .addResourceLocations("classpath:/WEB-INF/")
                .addResourceLocations("classpath:/META-INF/resources/")
                .addResourceLocations("classpath:/resources/")
                .addResourceLocations("classpath:/static/")
                .addResourceLocations("classpath:/public/")
                .addResourceLocations("classpath:/test/")
                // 静态资源短缓存,实时更新
                .setCachePeriod(60);

        // 业务图片文件专属映射(对接缩略拦截器核心路径)
        registry.addResourceHandler(BUSINESS_FILE_PATTERN)
                .addResourceLocations(BUSINESS_FILE_LOCAL_PATH.replace("\\", "/"))
                .setCachePeriod(0);
    }

    /**
     * 全局跨域配置
     * 适配前后端分离项目,解决图片跨域访问问题
     */
    @Override
    protected void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowCredentials(true)
                .allowedOrigins("*")
                .allowedHeaders("*")
                .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                .maxAge(3600);
    }
}

八、功能测试

项目启动后,通过URL直接测试场景功能,开箱即用、无需额外开发:

缩略图(有参w/h):

原图(无参):

九、可配置参数说明

所有核心配置集中在拦截器常量头部,无需修改业务代码,一键适配各类环境:

十、完整工具类源码

import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.img.ImgUtil;
import lombok.Getter;
import net.coobird.thumbnailator.Thumbnails;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.imageio.ImageIO;
import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Color;
import java.awt.AlphaComposite;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.awt.image.ComponentColorModel;
import java.io.BufferedInputStream;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.SimpleDateFormat;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.Locale;
import java.util.Map;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 图片缩略图动态生成拦截器
 * 核心优化:路径防越权、LRU缓存、CMYK色彩修复、流隔离零泄漏、双读图兜底、安全响应头、线程安全时间
 * 业务逻辑:无w/h参数返回原图,带参数实时生成缩略图,无临时文件落地
 * 安全特性:魔数校验、参数多层防御、目录遍历拦截、大小限流、画布限流
 * 性能特性:零内存泄漏、零IO冗余、高并发缓存稳定、低CPU占用
 *
 * @author Z
 */
@Component
public class ThumbnailInterceptor implements HandlerInterceptor {

    private static final Logger LOGGER = LoggerFactory.getLogger(ThumbnailInterceptor.class);

    /** 上传路径前缀 */
    private static final String LOCAL_FILE_PREFIX = "businessFile";
    /** 【单位:秒】图片缓存有效期 365天 */
    private static final long MAX_CACHE_SECOND = 31536000L;
    /** 文件魔数读取字节长度 */
    private static final int MAGIC_HEADER_READ_LEN = 16;
    /** 单张图片最大限制10MB,防止堆内存溢出 */
    private static final long MAX_IMAGE_BYTE_SIZE = 10 * 1024 * 1024L;
    /** 缩略图宽高上限,避免超大画布渲染阻塞 */
    private static final int MAX_THUMB_SIZE = 1200;
    /** 缩略图生成方案开关:true=Thumbnails工具,false=原生Graphics2D */
    private static final boolean USE_THUMBNAILS_TOOL = true;
    /** MIME类型缓存最大容量,LRU淘汰 */
    private static final int MIME_CACHE_MAX_SIZE = 256;

    /**
     * LRU同步缓存:请求路径 -> MIME类型
     * LinkedHashMap重写淘汰规则,超容量移除最久未访问key
     */
    private static final Map<String, String> MIME_CACHE =
            Collections.synchronizedMap(new LinkedHashMap<String, String>(MIME_CACHE_MAX_SIZE, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                    return size() > MIME_CACHE_MAX_SIZE;
                }
            });
    /** MIME缓存读写同步锁,保证原子操作 */
    private static final Object CACHE_LOCK = new Object();
    /** 线程安全GMT时间格式化器,用于Expires响应头 */
    private static final DateTimeFormatter GMT_FORMATTER = DateTimeFormatter
            .ofPattern("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US)
            .withZone(ZoneId.of("GMT"));

    /** 上传文件根路径,懒加载全局单例 */
    private static String UPLOAD_ROOT = null;
    /** 根路径初始化标记,避免重复异常打印 */
    private static boolean INIT_ROOT_FLAG = false;

    /**
     * 图片魔数映射:文件头部十六进制魔数 -> 图片后缀
     * 支持jpg/png/bmp三种主流图片
     */
    public static final Map<String, String> IMG_TYPE_MAP = new ConcurrentHashMap<>();
    /**
     * 魔数排序列表:按魔数字符长度倒序,长魔数优先匹配,避免短前缀误判
     */
    public static final List<Map.Entry<String, String>> SORTED_MAGIC_LIST;

    static {
        // 初始化图片魔数
        IMG_TYPE_MAP.put("FFD8FF", "jpg");
        IMG_TYPE_MAP.put("FFD8", "jpeg");
        IMG_TYPE_MAP.put("89504E47", "png");
        IMG_TYPE_MAP.put("424D228C010000000000", "bmp");
        IMG_TYPE_MAP.put("424D8240090000000000", "bmp");
        IMG_TYPE_MAP.put("424D8E1B030000000000", "bmp");
        IMG_TYPE_MAP.put("424D", "bmp");

        // 按魔数字符长度倒序排序
        SORTED_MAGIC_LIST = new ArrayList<>(IMG_TYPE_MAP.entrySet());
        SORTED_MAGIC_LIST.sort(Comparator.comparingInt(e -> -e.getKey().length()));

        // 加载全局ImageIO解码器,解决部分环境图片读取失败
        ImageIO.scanForPlugins();
    }

    // ====================== 前置拦截入口 ======================
    /**
     * 请求前置拦截核心逻辑
     * 1. 初始化上传根路径
     * 2. 路径解码、非法路径拦截、文件存在/大小校验
     * 3. 参数w/h合法性校验(支持单参数自适应缩放)
     * 4. 文件魔数校验,拦截伪造后缀文件
     * 5. CMYK图片自动转RGB
     * 6. 生成缩略图写入响应流;异常重置缓冲区返回原图
     */
    @Override
    public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
                             @Nullable Object handler) throws Exception {

        // 懒加载上传根路径
        initUploadRoot();

        // 未配置上传根路径,直接放行不处理缩略
        if (StrUtil.isBlank(UPLOAD_ROOT)) {
            return true;
        }

        String requestUrl;
        try {
            // URL解码处理中文文件名
            requestUrl = URLDecoder.decode(request.getServletPath(), StandardCharsets.UTF_8.name());
        } catch (IllegalArgumentException e) {
            LOGGER.warn("URL路径解码失败,非法字符", e);
            return true;
        }

        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("进入缩略拦截器,请求路径:{}", requestUrl);
        }

        // 空路径直接放行
        if (StringUtils.isBlank(requestUrl)) {
            return true;
        }

        // 非图片后缀请求直接放行
        if (!isImageRequest(requestUrl)) {
            return true;
        }

        // 先写入缓存相关响应头
        setImageCacheHeader(request, response, requestUrl);

        String widthParam = request.getParameter("w");
        String heightParam = request.getParameter("h");
        // 无缩放尺寸参数,返回原图
        if (StringUtils.isBlank(widthParam) && StringUtils.isBlank(heightParam)) {
            if (LOGGER.isDebugEnabled()) {
                LOGGER.debug("无w/h缩放参数,直接返回原图,路径url:{}", requestUrl);
            }
            return true;
        }

        // 防御:超长参数字符串,避免大数解析溢出
        if ((widthParam != null && widthParam.length() > 10) || (heightParam != null && heightParam.length() > 10)) {
            LOGGER.warn("宽高参数字符串过长,非法参数 w={},h={}", widthParam, heightParam);
            return true;
        }

        boolean widthValid = true;
        boolean heightValid = true;

        // 宽度参数非空,校验合法性
        if (StringUtils.isNotBlank(widthParam)) {
            try {
                int w = Integer.parseInt(widthParam);
                if (w < 1 || w > MAX_THUMB_SIZE) {
                    widthValid = false;
                }
            } catch (NumberFormatException e) {
                widthValid = false;
            }
        }

        // 高度参数非空,校验合法性
        if (StringUtils.isNotBlank(heightParam)) {
            try {
                int h = Integer.parseInt(heightParam);
                if (h < 1 || h > MAX_THUMB_SIZE) {
                    heightValid = false;
                }
            } catch (NumberFormatException e) {
                heightValid = false;
            }
        }

        // 任意参数非法,直接返回原图
        if (!widthValid || !heightValid) {
            LOGGER.warn("宽高参数不合法,超出范围[1,{}] w={},h={}", MAX_THUMB_SIZE, widthParam, heightParam);
            return true;
        }

        // 解析目标宽高,空参数默认0(用于自适应计算)
        int targetWidth = StringUtils.isNotBlank(widthParam) ? Integer.parseInt(widthParam) : 0;
        int targetHeight = StringUtils.isNotBlank(heightParam) ? Integer.parseInt(heightParam) : 0;

        // 宽高均为0无缩放意义,返回原图
        if (targetWidth <= 0 && targetHeight <= 0) {
            return true;
        }

        // 限制最大值,不限制最小值(保留0用于自适应)
        targetWidth = Math.min(targetWidth, MAX_THUMB_SIZE);
        targetHeight = Math.min(targetHeight, MAX_THUMB_SIZE);

        // 路径安全校验:拦截../目录遍历
        String subPath = requestUrl.replace(LOCAL_FILE_PREFIX, "");
        if (subPath.contains("..")) {
            LOGGER.warn("路径包含非法上级跳转字符,拒绝处理 url:{}", requestUrl);
            return true;
        }
        Path fullFilePath = Paths.get(UPLOAD_ROOT, subPath).normalize();
        Path rootPath = Paths.get(UPLOAD_ROOT).normalize();
        // 校验文件路径严格在上传根目录内,防止越权访问
        if (!fullFilePath.startsWith(rootPath)) {
            LOGGER.warn("文件路径越出上传根目录,拒绝处理 url:{}", requestUrl);
            return true;
        }

        File sourceImageFile = fullFilePath.toFile();
        String imageLocalPath = fullFilePath.toString();

        // 文件不存在/非普通文件直接放行
        if (!sourceImageFile.exists() || !sourceImageFile.isFile()) {
            LOGGER.warn("目标图片文件不存在或非文件,路径:{}", imageLocalPath);
            return true;
        }

        // 超大文件拦截,防止OOM
        long fileLen = sourceImageFile.length();
        if (fileLen > MAX_IMAGE_BYTE_SIZE) {
            LOGGER.warn("图片超出10MB上限,直接返回原图 path={}", imageLocalPath);
            return true;
        }
        // 空文件拦截
        if (fileLen == 0) {
            LOGGER.warn("图片文件为空,返回原图 path={}", imageLocalPath);
            return true;
        }

        // try-with-resources 自动关闭响应输出流,避免句柄泄漏
        try (OutputStream outStream = response.getOutputStream()) {
            String realImgSuffix;
            // 独立流读取魔数,不消耗原图数据流
            try (InputStream magicIs = new BufferedInputStream(Files.newInputStream(sourceImageFile.toPath()))) {
                MagicResult magicResult = getFileMagicSuffix(magicIs);
                realImgSuffix = magicResult.getSuffix();
            }

            // 魔数不匹配,判定为伪造后缀文件,返回原图
            if (StrUtil.isBlank(realImgSuffix) || isNotImageSuffix(realImgSuffix)) {
                LOGGER.info("魔数校验非合法图片,返回原图 url:{}", requestUrl);
                return true;
            }

            BufferedImage originalImage = null;
            // 优先ImageIO读取原图
            try (InputStream imgIs = new BufferedInputStream(Files.newInputStream(sourceImageFile.toPath()))) {
                originalImage = ImageIO.read(imgIs);
            }

            // ImageIO读取失败,使用Hutool兜底读取
            if (originalImage == null) {
                try (InputStream imgIs2 = new BufferedInputStream(Files.newInputStream(sourceImageFile.toPath()))) {
                    originalImage = ImgUtil.read(imgIs2);
                } catch (Exception e) {
                    LOGGER.error("兜底读取图片失败 path={}", imageLocalPath, e);
                    return true;
                }
            }

            // 双读取方案均失败,判定为损坏文件
            if (originalImage == null) {
                LOGGER.error("图片读取失败,文件损坏或格式不支持 path={}", imageLocalPath);
                return true;
            }

            // CMYK四通道JPG自动转为RGB,避免色彩失真
            if ("jpg".equals(realImgSuffix) || "jpeg".equals(realImgSuffix)) {
                ColorModel cm = originalImage.getColorModel();
                if (cm instanceof ComponentColorModel && cm.getNumColorComponents() == 4) {
                    BufferedImage rgbImg = new BufferedImage(originalImage.getWidth(), originalImage.getHeight(), BufferedImage.TYPE_3BYTE_BGR);
                    Graphics2D g = rgbImg.createGraphics();
                    g.setComposite(AlphaComposite.Src);
                    g.drawImage(originalImage, 0, 0, null);
                    g.dispose();
                    originalImage.flush();
                    originalImage = rgbImg;
                }
            }

            // 计算等比例缩放后的实际宽高,禁止放大原图
            int finalW = calcScaleWidth(targetWidth, targetHeight, originalImage);
            int finalH = calcScaleHeight(targetWidth, targetHeight, originalImage);
            finalW = Math.min(finalW, originalImage.getWidth());
            finalH = Math.min(finalH, originalImage.getHeight());

            boolean thumbRenderOk = true;
            try {
                // 匹配原图后缀输出格式
                String outputFormat = "png";
                if ("jpg".equals(realImgSuffix) || "jpeg".equals(realImgSuffix)) {
                    outputFormat = "jpg";
                }
                // 选择缩略生成器
                if (USE_THUMBNAILS_TOOL) {
                    generateThumbByThumbnails(originalImage, finalW, finalH, outStream, outputFormat);
                } else {
                    generateThumbByGraphics2D(originalImage, finalW, finalH, outStream, outputFormat);
                }
                outStream.flush();
            } catch (Exception e) {
                thumbRenderOk = false;
                LOGGER.error("缩略图渲染生成异常", e);
            } finally {
                originalImage.flush();
            }
            // 渲染失败:清空缓冲区,重写缓存头,返回原图
            if (!thumbRenderOk) {
                response.resetBuffer();
                setImageCacheHeader(request, response, requestUrl);
                return true;
            }
        } catch (Exception e) {
            LOGGER.error("响应流读写异常", e);
            return true;
        }

        // 缩略图正常输出,拦截后续静态资源处理
        LOGGER.info("缩略图完成,输出正常!");
        return false;
    }

    /** 后置拦截器,无业务逻辑 */
    @Override
    public void postHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
                           @Nullable Object handler, ModelAndView modelAndView) {
    }

    /** 请求完成回调,无业务逻辑 */
    @Override
    public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response,
                                @Nullable Object handler, @Nullable Exception ex) {
    }

    // ====================== 缓存响应头 ======================
    /**
     * 设置图片缓存、安全响应头
     * Cache-Control长缓存、XSS防护、CSP限制图片加载源
     */
    private void setImageCacheHeader(HttpServletRequest request, HttpServletResponse response, String requestUrl) {
        response.setHeader("Cache-Control", "public, max-age=" + MAX_CACHE_SECOND);
        // 防止浏览器MIME嗅探XSS
        response.setHeader("X-Content-Type-Options", "nosniff");
        // CSP:仅允许本站+内联base64图片,禁止外部图片加载
        response.setHeader("Content-Security-Policy", "default-src 'self'; img-src 'self' data:;");

        // 生成GMT标准过期时间
        String gmtExpireStr;
        try {
            ZonedDateTime expireTime = ZonedDateTime.now().plusSeconds(MAX_CACHE_SECOND);
            gmtExpireStr = GMT_FORMATTER.format(expireTime);
        } catch (Exception e) {
            // 格式化器异常降级使用SimpleDateFormat兜底
            SimpleDateFormat sdfGmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US);
            sdfGmt.setTimeZone(TimeZone.getTimeZone("GMT"));
            gmtExpireStr = sdfGmt.format(new Date(System.currentTimeMillis() + MAX_CACHE_SECOND * 1000));
        }

        response.setHeader("Expires", gmtExpireStr);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());

        // 缓存MIME类型,减少重复获取
        String mime;
        synchronized (CACHE_LOCK) {
            mime = MIME_CACHE.get(requestUrl);
            if (mime == null) {
                String tempMime = null;
                ServletContext context = request.getServletContext();
                if (context != null) {
                    tempMime = context.getMimeType(requestUrl);
                }
                mime = StrUtil.isBlank(tempMime) ? "image/png" : tempMime;
                MIME_CACHE.put(requestUrl, mime);
            }
        }
        response.setContentType(mime);
    }

    // ====================== 请求路径判断 ======================
    /** 判断请求路径是否为图片后缀 */
    private boolean isImageRequest(String requestUrl) {
        String lowerUrl = requestUrl.toLowerCase();
        String[] splitArr = lowerUrl.split("[?#]");
        String purePath = splitArr.length > 0 ? splitArr[0] : "";
        return purePath.endsWith(".jpg")
                || purePath.endsWith(".jpeg")
                || purePath.endsWith(".png")
                || purePath.endsWith(".bmp");
    }

    /** 魔数匹配返回结果 */
    @Getter
    public static class MagicResult {
        private String suffix;
        MagicResult(String suffix) {
            this.suffix = suffix;
        }
    }

    // ====================== 魔数工具(兼容不支持mark的流) ======================
    /**
     * 读取文件头部16字节魔数,识别真实图片格式
     * mark/reset 重置流指针,不消耗原始文件流数据
     */
    public static MagicResult getFileMagicSuffix(InputStream in) {
        try {
            // 标记流起始位置,读取后回滚
            if (in.markSupported()) {
                in.mark(MAGIC_HEADER_READ_LEN);
            }
            // 读取最多16字节
            byte[] temp = IoUtil.readBytes(in, MAGIC_HEADER_READ_LEN);
            // 固定16字节数组,不足补0,避免短文件匹配失效
            byte[] headerBytes = new byte[MAGIC_HEADER_READ_LEN];
            // 复制有效数据,不足部分补0
            System.arraycopy(temp, 0, headerBytes, 0, temp.length);
            // 可回退流则重置指针
            if (in.markSupported()) {
                in.reset();
            }
            String hex = byteArrayToUpperHex(headerBytes);
            String suffix = matchMagicMap(hex);
            return new MagicResult(suffix);
        } catch (Exception e) {
            LOGGER.error("读取文件魔数IO异常", e);
            return new MagicResult(null);
        }
    }

    /** byte数组转大写十六进制字符串,单字节补0 */
    private static String byteArrayToUpperHex(@Nullable byte[] bytes) {
        if (bytes == null || bytes.length == 0) {
            return "";
        }
        StringBuilder sb = new StringBuilder();
        for (byte b : bytes) {
            int val = b & 0xFF;
            String hex = Integer.toHexString(val);
            if (hex.length() == 1) {
                sb.append('0');
            }
            sb.append(hex);
        }
        return sb.toString().toUpperCase();
    }

    /**
     * 魔数匹配逻辑
     * 长魔数优先,严格校验魔数字长,杜绝短前缀误匹配
     */
    private static String matchMagicMap(String fileStreamHexHead) {
        if (LOGGER.isDebugEnabled()) {
            LOGGER.debug("文件魔数字符串:{}", fileStreamHexHead);
        }
        for (Map.Entry<String, String> entry : SORTED_MAGIC_LIST) {
            String magicKey = entry.getKey();
            // 优化:改为完整等长匹配,杜绝前缀误匹配
            if (fileStreamHexHead.startsWith(magicKey) && magicKey.length() <= fileStreamHexHead.length()) {
                return entry.getValue();
            }
        }
        return null;
    }

    /** 忽略大小写前缀匹配工具 */
    public static boolean startWithIgnoreCase(@Nullable CharSequence str, @Nullable CharSequence prefix) {
        if (str == null && prefix == null) {
            return true;
        }
        if (str == null || prefix == null) {
            return false;
        }
        String strLow = str.toString().toLowerCase();
        String preLow = prefix.toString().toLowerCase();
        return strLow.startsWith(preLow);
    }

    /** 判断【是否为合法图片】后缀 */
    public static boolean isImageSuffix(String type) {
        return StrUtil.equalsAnyIgnoreCase(type, "jpg", "jpeg", "png", "bmp");
    }

    /** 判断【是否为不合法图片】后缀 */
    public static boolean isNotImageSuffix(String type) {
        return !isImageSuffix(type);
    }

    // ====================== 等比例缩放计算 ======================
    /** 计算等比例缩放目标宽度,原图不放大 */
    private static int calcScaleWidth(int targetW, int targetH, @NonNull BufferedImage source) {
        if (source == null) {
            LOGGER.error("原图BufferedImage为空");
            return targetW;
        }
        int w = source.getWidth();
        int h = source.getHeight();
        // 防除零异常
        if (w <= 0 || h <= 0) {
            return targetW > 0 ? targetW : targetH;
        }
        double scale = (double) w / h;
        return targetW > 0 ? targetW : (int) Math.floor(targetH * scale);
    }

    /** 计算等比例缩放目标高度,原图不放大 */
    private static int calcScaleHeight(int targetW, int targetH, @NonNull BufferedImage source) {
        if (source == null) {
            LOGGER.error("原图BufferedImage为空");
            return targetH;
        }
        int w = source.getWidth();
        int h = source.getHeight();
        if (w <= 0 || h <= 0) {
            return targetH > 0 ? targetH : targetW;
        }
        double scale = (double) w / h;
        return targetH > 0 ? targetH : (int) Math.floor(targetW / scale);
    }

    // ====================== 两套缩略图生成(新增输出格式参数,修复jpg透明黑底) ======================
    /** Thumbnails工具生成缩略图 */
    private static void generateThumbByThumbnails(BufferedImage originalImage, int w, int h, OutputStream out, String format) throws IOException {
        int imageType = "png".equals(format) ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_3BYTE_BGR;
        Thumbnails.of(originalImage)
                .imageType(imageType)
                .size(w, h)
                .keepAspectRatio(true)
                .outputQuality(0.85)
                .outputFormat(format)
                .toOutputStream(out);
    }

    /** 原生Graphics2D绘制缩略图,无第三方依赖 */
    private static void generateThumbByGraphics2D(BufferedImage originalImage, int w, int h, OutputStream out, String format) throws IOException {
        int imageType = "png".equals(format) ? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_3BYTE_BGR;
        BufferedImage thumbnail = new BufferedImage(w, h, imageType);
        Graphics2D g2d = null;
        try {
            g2d = thumbnail.createGraphics();
            // 抗锯齿、高清插值缩放
            g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
            // PNG透明、JPG白底
            if ("png".equals(format)) {
                g2d.setBackground(new Color(0, 0, 0, 0));
            } else {
                g2d.setBackground(Color.WHITE);
            }
            g2d.clearRect(0, 0, w, h);
            g2d.drawImage(originalImage, 0, 0, w, h, null);
            ImageIO.write(thumbnail, format, out);
        } finally {
            if (g2d != null) {
                g2d.dispose();
            }
            thumbnail.flush();
        }
    }

    /**
     * 懒加载上传根路径,全局仅初始化一次
     * 从Spring配置类读取上传根目录,读取失败置为空
     */
    private static void initUploadRoot() {
        if (INIT_ROOT_FLAG) {
            return;
        }
        INIT_ROOT_FLAG = true;
        try {
            // 【生产适配】此处可替换为yml配置读取,解耦硬编码
            UPLOAD_ROOT = "E:\\project_files\\test-project\\file\\";
        } catch (Exception e) {
            LOGGER.error("初始化上传根路径失败,缩略功能关闭", e);
            UPLOAD_ROOT = "";
        }
    }


    // ====================== 本地测试main ======================
    public static void main(String[] args) {
        // 本地测试强制覆盖上传根路径,跳过Spring上下文获取
        UPLOAD_ROOT = "E:\\project_files\\test-project\\file\\";
        INIT_ROOT_FLAG = true;

        try {
            String mockUploadRoot = "E:\\project_files\\test-project\\file\\";
            String testRequestUrl = "/businessFile/123123.png";
            String widthParam = "400";
            String heightParam = "300";
            String outDir = "E:\\project_files\\test-project\\file\\businessFile" + File.separator;

            // main 内部替换这段路径代码
            String subPath = testRequestUrl.replace(LOCAL_FILE_PREFIX, "");
            // 去掉开头斜杠
            subPath = subPath.startsWith("/") ? subPath.substring(1) : subPath;
            Path fullFilePath = Paths.get(mockUploadRoot, subPath);
            // Windows 手动拼接
            String fullPathStr = mockUploadRoot + File.separator + subPath.replace("/", File.separator);
            File sourceImg = new File(fullPathStr);
            System.out.println("文件是否存在:" + sourceImg.exists());
            System.out.println("是否为文件:" + sourceImg.isFile());
            System.out.println("文件大小 byte:" + sourceImg.length());
            testSingleRead(sourceImg);

            if (!sourceImg.exists() || !sourceImg.isFile()) {
                LOGGER.error("【测试失败】原图不存在 path={}", fullFilePath);
                return;
            }
            System.out.println("浏览器地址:" + testRequestUrl);
            System.out.println("本地文件:" + fullFilePath);

            if (StringUtils.isBlank(widthParam) && StringUtils.isBlank(heightParam)) {
                System.out.println("无缩放参数,模拟详情页,不生成缩略图");
                return;
            }

            int targetWidth, targetHeight;
            try {
                targetWidth = StringUtils.isNotBlank(widthParam) ? Integer.parseInt(widthParam) : 0;
                targetHeight = StringUtils.isNotBlank(heightParam) ? Integer.parseInt(heightParam) : 0;
                // 新增二次校验
                if(targetWidth < 1 || targetWidth > MAX_THUMB_SIZE || targetHeight < 1 || targetHeight > MAX_THUMB_SIZE){
                    LOGGER.warn("宽高数值超出范围 1~{} w={},h={}",MAX_THUMB_SIZE,widthParam,heightParam);
                    return;
                }
            } catch (NumberFormatException e) {
                System.out.println("宽高参数非法,终止测试");
                return;
            }
            if (targetWidth <= 0 && targetHeight <= 0) {
                System.out.println("宽高无缩放需求,终止测试");
                return;
            }
            targetWidth = Math.min(Math.max(targetWidth, 1), MAX_THUMB_SIZE);
            targetHeight = Math.min(Math.max(targetHeight, 1), MAX_THUMB_SIZE);

            BufferedImage originalImage;
            MagicResult magicResult;
            // 独立流读取魔数,不污染图片读取流
            try (InputStream magicIs = new BufferedInputStream(Files.newInputStream(sourceImg.toPath()))) {
                magicResult = getFileMagicSuffix(magicIs);
            }
            System.out.println("魔数识别类型:" + magicResult.getSuffix());
            if (StrUtil.isBlank(magicResult.getSuffix()) || isNotImageSuffix(magicResult.getSuffix())) {
                System.out.println("非图片文件,终止测试");
                return;
            }
            // 全新流从头读取完整图片
            try (InputStream imgIs = new BufferedInputStream(Files.newInputStream(sourceImg.toPath()))) {
                originalImage = ImageIO.read(imgIs);
            }
            // ImageIO读取失败,新开流使用Hutool
            if (null == originalImage) {
                try (InputStream imgIs2 = new BufferedInputStream(Files.newInputStream(sourceImg.toPath()))) {
                    originalImage = ImgUtil.read(imgIs2);
                }
            }

            if (null == originalImage) {
                System.out.println("图片读取失败,文件损坏");
                return;
            }
            System.out.printf("原图尺寸:%d × %d%n", originalImage.getWidth(), originalImage.getHeight());

            int finalW = calcScaleWidth(targetWidth, targetHeight, originalImage);
            int finalH = calcScaleHeight(targetWidth, targetHeight, originalImage);
            finalW = Math.min(finalW, originalImage.getWidth());
            finalH = Math.min(finalH, originalImage.getHeight());
            System.out.printf("缩略图尺寸:%d × %d%n", finalW, finalH);

            File dir = new File(outDir);
            if (!dir.exists()) {
                dir.mkdirs();
            }

            String fmt = "jpg".equals(magicResult.getSuffix()) || "jpeg".equals(magicResult.getSuffix()) ? "jpg" : "png";
            File out1 = new File(outDir + "thumb_test_方案一_Thumbnails." + fmt);
            try (OutputStream outStream1 = new FileOutputStream(out1)) {
                generateThumbByThumbnails(originalImage, finalW, finalH, outStream1, fmt);
            }
            System.out.println("【方案一】生成完成:" + out1.getAbsolutePath());

            File out2 = new File(outDir + "thumb_test_方案二_Graphics2D." + fmt);
            try (OutputStream outStream2 = new FileOutputStream(out2)) {
                generateThumbByGraphics2D(originalImage, finalW, finalH, outStream2, out2, fmt);
            }
            System.out.println("【方案二】生成完成:" + out2.getAbsolutePath());

            originalImage.flush();
            System.out.println("测试完成,线上推荐USE_THUMBNAILS_TOOL=true");
        } catch (Exception e) {
            LOGGER.error("main测试异常", e);
        }
    }

    @SuppressWarnings("unused")
    private static void testSingleRead(File file) {
        System.out.println("===== Hutool 单独读取测试 =====");
        try (InputStream is = Files.newInputStream(file.toPath())) {
            BufferedImage img = ImgUtil.read(is);
            System.out.println("读取成功,图片宽:" + img.getWidth() + " 高:" + img.getHeight());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

十一、总结

本文针对性解决了传统图片处理方案普遍存在的路径越权、文件伪装穿透、内存泄漏、CMYK图片色彩失真、高并发缓存异常、单参数自适应黑屏、渲染降级失效等实际生产问题。方案基于Spring拦截器实现,无需改造原有文件业务代码,可无缝接入各类SpringBoot项目,同时适配常规线上业务、私有化涉密部署、高并发访问等不同落地场景,适配性与兼容性贴合实际项目需求。

该方案依托文件魔数校验、IO流隔离回收、LRU轻量化缓存、双渲染引擎兜底、多层参数安全校验等核心设计,摒弃了传统预生成缩略图的冗余模式,实现按需实时生成缩略图,无临时文件落地、无磁盘资源冗余。相较于早期版本,终版补齐了参数容错、画布渲染、异常降级等短板,优化了内存管控与并发处理能力,有效规避线上故障、降低服务器资源消耗,兼顾安全性、稳定性与实用性。

以上就是SpringBoot动态图片获取缩略图方案的详细内容,更多关于SpringBoot动态图片获取缩略图的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文