在项目开发中,图片上传是非常常见的需求,尤其是现场照片类场景,往往需要给图片添加水印(如项目名称、时间戳)以保证图片的溯源性和安全性。本文将基于 SpringBoot + MinIO 实现图片上传、压缩、水印添加的完整流程,同时兼容普通文件上传,适合后端开发新手学习和落地。

一、功能需求分析

本次实现的核心功能:

  1. 通用文件上传接口,支持多文件批量上传
  2. 图片文件(png/jpg/gif/bmp 等)自动转换为 jpg 格式,统一处理
  3. 给指定模块的图片添加项目名称 + 时间戳水印,同时压缩图片尺寸
  4. 非图片文件直接上传至 MinIO,不做额外处理
  5. 上传前校验文件大小,防止超大文件上传
  6. 所有文件存储至 MinIO,记录文件元信息到数据库

二、核心依赖准备

Maven依赖

<!-- 图片处理(压缩+水印) -->
<dependency>
    <groupId>net.coobird</groupId>
    <artifactId>thumbnailator</artifactId>
    <version>0.4.19</version>
</dependency>

<!-- MinIO客户端依赖(如果用到MinIO下载场景) -->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
</dependency>

<!-- Apache Commons IO 工具类 -->
<dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.15.1</version>
</dependency>
<!-- imageio 依赖 -->
 <dependency>
            <groupId>org.sejda.imageio</groupId>
            <artifactId>webp-imageio</artifactId>
            <version>0.1.6</version>
        </dependency>

三、核心代码实现

1. 工具类补充

package com.tydt.core.utils.file;

import com.itl.core.constant.Constants;
import com.itl.core.exception.CustomException;
import com.itl.core.exception.file.FileNameLengthLimitExceededException;
import com.itl.core.exception.file.FileSizeLimitExceededException;
import com.itl.core.exception.file.InvalidExtensionException;
import com.itl.core.utils.DateUtils;
import com.itl.core.utils.StringUtils;
import com.itl.core.utils.security.Md5Utils;
import com.itl.core.config.ItlConfig;
import org.apache.commons.io.FilenameUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;

/**
 * 文件上传工具类
 */
public class FileUploadUtils
{
    /**
     * 读取配置文件中的文件大小限制
     */
    @Value("${spring.servlet.multipart.max-file-size}")
    private static String MAX_FILE_SIZE="600MB";


    private static String getMaxFileSizeConfig()
    {
        return "";
    }

    /**
     * 默认的文件名最大长度 100
     */
    public static final int DEFAULT_FILE_NAME_LENGTH = 100;

    /**
     * 默认上传的地址
     */
    private static String defaultBaseDir = TydtConfig.getProfile();

    private static int counter = 0;

    public static void setDefaultBaseDir(String defaultBaseDir)
    {
        FileUploadUtils.defaultBaseDir = defaultBaseDir;
    }

    public static String getDefaultBaseDir()
    {
        return defaultBaseDir;
    }

    /**
     * 以默认配置进行文件上传
     *
     * @param file 上传的文件
     * @return 文件名称
     * @throws Exception
     */
    public static final String upload(MultipartFile file) throws IOException
    {
        try
        {
            return upload(getDefaultBaseDir(), file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
        }
        catch (Exception e)
        {
            throw new IOException(e.getMessage(), e);
        }
    }

    /**
     * 根据文件路径上传
     *
     * @param baseDir 相对应用的基目录
     * @param file 上传的文件
     * @return 文件名称
     * @throws IOException
     */
    public static final String upload(String baseDir, MultipartFile file) throws IOException
    {
        try
        {
            return upload(baseDir, file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
        }
        catch (Exception e)
        {
            throw new IOException(e.getMessage(), e);
        }
    }

    /**
     * 文件上传
     *
     * @param baseDir 相对应用的基目录
     * @param file 上传的文件
     * @param allowedExtension 上传文件类型
     * @return 返回上传成功的文件名
     * @throws FileSizeLimitExceededException 如果超出最大大小
     * @throws FileNameLengthLimitExceededException 文件名太长
     * @throws IOException 比如读写文件出错时
     * @throws InvalidExtensionException 文件校验异常
     */
    public static final String upload(String baseDir, MultipartFile file, String[] allowedExtension)
            throws FileSizeLimitExceededException, IOException, FileNameLengthLimitExceededException,
            InvalidExtensionException
    {
        int fileNamelength = file.getOriginalFilename().length();
        if (fileNamelength > FileUploadUtils.DEFAULT_FILE_NAME_LENGTH)
        {
            throw new FileNameLengthLimitExceededException(FileUploadUtils.DEFAULT_FILE_NAME_LENGTH);
        }

        assertAllowed(file, allowedExtension);

        String fileName = extractFilename(file);

        File desc = getAbsoluteFile(baseDir, fileName);
        file.transferTo(desc);
        String pathFileName = getPathFileName(baseDir, fileName);
        return pathFileName;
    }

    /**
     * 编码文件名
     */
    public static final String extractFilename(MultipartFile file)
    {
        String fileName = file.getOriginalFilename();
        String extension = getExtension(file);
        fileName = DateUtils.datePath() + "/" + encodingFilename(fileName) + "." + extension;
        return fileName;
    }

    private static final File getAbsoluteFile(String uploadDir, String fileName) throws IOException
    {
        File desc = new File(uploadDir + File.separator + fileName);

        if (!desc.getParentFile().exists())
        {
            desc.getParentFile().mkdirs();
        }
        if (!desc.exists())
        {
            desc.createNewFile();
        }
        return desc;
    }

    private static final String getPathFileName(String uploadDir, String fileName) throws IOException
    {
        int dirLastIndex = TydtConfig.getProfile().length() + 1;
        String currentDir = StringUtils.substring(uploadDir, dirLastIndex);
        String pathFileName = Constants.RESOURCE_PREFIX + "/" + currentDir + "/" + fileName;
        return pathFileName;
    }

    /**
     * 编码文件名
     */
    private static final String encodingFilename(String fileName)
    {
        fileName = fileName.replace("_", " ");
        fileName = Md5Utils.hash(fileName + System.nanoTime() + counter++);
        return fileName;
    }

    /**
     * 文件大小校验
     *
     * @param file 上传的文件
     * @return
     * @throws FileSizeLimitExceededException 如果超出最大大小
     * @throws InvalidExtensionException
     */
    public static final void assertAllowed(MultipartFile file, String[] allowedExtension)
            throws FileSizeLimitExceededException, InvalidExtensionException
    {
        long size = file.getSize();
        //获取系统配置的单文件大小限制
        long maxFileSize = getMaxFileSize();
        if (maxFileSize != -1 && size > maxFileSize)
        {
            //异常中转换为MB
            throw new FileSizeLimitExceededException(maxFileSize/1024/1024);
        }

        String fileName = file.getOriginalFilename();
        String extension = getExtension(file);
        if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension))
        {
            if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION)
            {
                throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension,
                        fileName);
            }
            else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION)
            {
                throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension,
                        fileName);
            }
            else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION)
            {
                throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension,
                        fileName);
            }
            else
            {
                throw new InvalidExtensionException(allowedExtension, extension, fileName);
            }
        }

    }

    /**
     * 获取系统配置的单文件大小限制
     * @return
     */
    public static long getMaxFileSize()
    {
        Long maxFileSizeNum = Long.parseLong(MAX_FILE_SIZE.replaceAll("[a-zA-z]", ""));
        String maxFileSizeUnit = MAX_FILE_SIZE.replaceAll("[\\d+\\.]", "");
        if (maxFileSizeUnit.equalsIgnoreCase("B"))
        {
            return maxFileSizeNum;
        }
        else if (maxFileSizeUnit.equalsIgnoreCase("KB"))
        {
            return maxFileSizeNum*1024;
        }
        else if (maxFileSizeUnit.equalsIgnoreCase("MB"))
        {
            return maxFileSizeNum*1024*1024;
        }
        else if (maxFileSizeUnit.equalsIgnoreCase("GB"))
        {
            return maxFileSizeNum*1024*1024*1024;
        }
        else
        {
            throw new CustomException("不支持的单位,请使用B,KB,MB,GB为单位进行配置!");
        }
    }

    /**
     * 判断MIME类型是否是允许的MIME类型
     *
     * @param extension
     * @param allowedExtension
     * @return
     */
    public static final boolean isAllowedExtension(String extension, String[] allowedExtension)
    {
        for (String str : allowedExtension)
        {
            if (str.equalsIgnoreCase(extension))
            {
                return true;
            }
        }
        return false;
    }

    /**
     * 获取文件名的后缀
     *
     * @param file 表单文件
     * @return 后缀名
     */
    public static final String getExtension(MultipartFile file)
    {
        String extension = FilenameUtils.getExtension(file.getOriginalFilename());
        if (StringUtils.isEmpty(extension))
        {
            extension = MimeTypeUtils.getExtension(file.getContentType());
        }
        return extension;
    }
}

2. 核心业务代码(完整)

import cn.hutool.core.date.DateUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.ObjectUtil;
import io.minio.ObjectWriteResponse;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import net.coobird.thumbnailator.Thumbnails;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 文件上传控制器
 */
@RestController
@Api(tags = "文件上传接口")
public class FileUploadController {
    private static final Logger log = LoggerFactory.getLogger(FileUploadController.class);

    @Autowired
    private MinioConfig minioConfig;

    @Autowired
    private MinioTemplate minioTemplate; // 需自行实现 MinIO 操作模板类

    @Autowired
    private FileService fileService; // 文件元信息存储服务

    /**
     * 通用附件上传接口
     * @param file 上传的文件数组
     * @param userVo 当前登录用户
     * @param moduleType 模块类型(photo 表示图片,其他为普通文件)
     * @param projectName 项目名称(图片水印用)
     * @return 上传结果
     */
    @ApiOperation("通用附件上传")
    @PostMapping("/upload")
    @ApiImplicitParams({
            @ApiImplicitParam(name = "file", value = "上传文件", required = true, dataType = "MultipartFile", allowMultiple = true),
            @ApiImplicitParam(name = "moduleType", value = "模块类型(photo=图片)", required = false),
            @ApiImplicitParam(name = "projectName", value = "项目名称(图片水印用)", required = false)
    })
    public AjaxResult uploadFile(@RequestParam("file") MultipartFile[] file, 
                                 @Login UserVo userVo, 
                                 String moduleType, 
                                 String projectName) {
        String bucketName = minioConfig.getBucketName();
        List<UploadFilePO> fileList = new ArrayList<>();

        // 1. 校验文件大小
        Boolean maxFileSizeFlag = false;
        long maxFileSize = FileUploadUtils.getMaxFileSize();
        for (MultipartFile multipartFile : file) {
            long size = multipartFile.getSize();
            if (maxFileSize != -1 && size > maxFileSize) {
                maxFileSizeFlag = true;
                break;
            }
        }
        if (maxFileSizeFlag) {
            throw new FileSizeLimitExceededException(maxFileSize/1024/1024);
        }

        try {
            // 2. 检查 MinIO 存储桶是否存在,不存在则创建
            boolean bucketExists = minioTemplate.bucketExists(bucketName);
            if (!bucketExists) {
                minioTemplate.makeBucket(bucketName);
            }

            // 3. 处理每个文件上传
            for (MultipartFile multipartFile : file) {
                fileList.add(handleFileUpload(multipartFile, userVo, moduleType, projectName, bucketName));
            }

            // 4. 保存文件元信息到数据库
            fileService.uploadFile(fileList);
        } catch (Exception e) {
            log.error("上传文件失败", e); // 注意:此处建议打印完整异常栈,而非仅message
            throw new CustomException("上传文件失败");
        }
        return AjaxResult.success(fileList);
    }

    /**
     * 处理单个文件上传(核心逻辑)
     */
    private UploadFilePO handleFileUpload(MultipartFile multipartFile, UserVo userVo, String moduleType, String projectName, String bucketName) {
        String filename = multipartFile.getOriginalFilename();
        Long id = IdUtil.getSnowflakeNextId(); // 修正:Hutool 最新版方法名
        String serverFilename = id + StrUtil.UNDERLINE + filename;
        UploadFilePO po = new UploadFilePO();

        // 基础元信息设置
        po.setCreateDate(new Date());
        po.setCreateUserId(userVo.getUserId());
        po.setCreateUserName(userVo.getNickName());
        String filenameExtension = FileUtil.extName(filename); // 修正:Hutool 工具类

        // 图片文件特殊处理(添加水印+压缩)
        if (Constants.DICT_SUCCESS.equals(moduleType)) {
            // 正则匹配图片格式,统一转换为 jpg
            Pattern pattern = Pattern.compile("\\.(png|jpe?g|gif|bmp)$", Pattern.CASE_INSENSITIVE);
            Matcher matcher = pattern.matcher(serverFilename);
            serverFilename = matcher.replaceAll(".jpg");
            po.setFileName(serverFilename);
            
            // 构建文件存储路径(按日期分目录)
            String filePath = StrUtil.format("files/{}/{}", DateUtil.format(new Date(), "yyyy-MM-dd"), serverFilename);
            po.setFilePath(filePath);

            // 处理原始文件名后缀
            Matcher matcher2 = pattern.matcher(filename);
            filename = matcher2.replaceAll(".jpg");
            po.setRealName(filename);
            po.setFileType("jpg");

            try {
                // 读取图片流
                BufferedImage originalImage = ImageIO.read(multipartFile.getInputStream());
                if (originalImage == null) {
                    log.error("图片解析失败 - 文件名: {}, 大小: {} bytes, 类型: {}",
                            multipartFile.getOriginalFilename(),
                            multipartFile.getSize(),
                            multipartFile.getContentType());
                    throw new CustomException("上传的文件不是有效的图片或格式不支持");
                }

                ByteArrayOutputStream baos = new ByteArrayOutputStream();
                if (ObjectUtil.isNotEmpty(projectName)) {
                    // 添加水印
                    BufferedImage watermarkedImage = addWatermark(originalImage, projectName, DateUtils.getTime());
                    // 压缩图片并输出到流
                    Thumbnails.of(watermarkedImage)
                            .size(3808, 1900)
                            .outputFormat("jpg")
                            .toOutputStream(baos);
                } else {
                    // 无项目名称,仅压缩
                    Thumbnails.of(originalImage)
                            .size(1904, 950)
                            .outputFormat("jpg")
                            .toOutputStream(baos);
                }

                // 上传至 MinIO
                InputStream compressedInputStream = new ByteArrayInputStream(baos.toByteArray());
                ObjectWriteResponse objectWriteResponse = minioTemplate.putObject(
                        compressedInputStream, bucketName, filename, po.getFilePath(), userVo);
                po.setVersionId(objectWriteResponse.versionId());

                // 关闭流
                baos.close();
                compressedInputStream.close();
            } catch (IOException e) {
                log.error("压缩或上传图片失败", e);
                throw new CustomException("压缩或上传图片失败");
            }
        } else {
            // 普通文件直接上传
            po.setFileName(serverFilename);
            po.setFilePath(StrUtil.format("files/{}/{}", DateUtil.format(new Date(), "yyyy-MM-dd"), serverFilename));
            po.setRealName(filename);
            if (StrUtil.isNotBlank(filenameExtension)) {
                po.setFileType(filenameExtension);
            }

            try {
                ObjectWriteResponse objectWriteResponse = minioTemplate.putObject(
                        multipartFile.getInputStream(), bucketName, filename, po.getFilePath(), userVo);
                po.setVersionId(objectWriteResponse.versionId());
            } catch (IOException e) {
                log.error("上传文件失败", e);
                throw new CustomException("上传文件失败");
            }
        }
        return po;
    }

    /**
     * 给图片添加水印(项目名称+时间戳)
     */
    private BufferedImage addWatermark(BufferedImage originalImage, String projectName, String timestamp) {
        int width = originalImage.getWidth();
        int height = originalImage.getHeight();
        // 创建带透明通道的图片
        BufferedImage watermarkedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g2d = watermarkedImage.createGraphics();

        // 1. 绘制原始图片
        g2d.drawImage(originalImage, 0, 0, null);

        // 2. 设置水印样式
        g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); // 抗锯齿
        Font font = new Font("宋体", Font.BOLD, 30);
        g2d.setFont(font);
        g2d.setColor(Color.WHITE);
        // 可选:添加水印背景,提升可读性
        g2d.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.8f)); // 水印透明度

        // 3. 计算水印位置(左下角)
        FontMetrics metrics = g2d.getFontMetrics(font);
        int textWidthProject = metrics.stringWidth(projectName);
        int textHeight = metrics.getHeight();
        int textWidthTimestamp = metrics.stringWidth(timestamp);

        int xProject = 10; // 左间距
        int yProject = height - textHeight - 10; // 下间距(项目名称)
        int yTimestamp = yProject + textHeight; // 时间戳在项目名称下方

        // 4. 绘制水印文字
        g2d.drawString(projectName, xProject, yProject);
        g2d.drawString(timestamp, xProject, yTimestamp);

        // 释放资源
        g2d.dispose();
        return watermarkedImage;
    }
}

3. 缺失代码提醒
示例中用到了以下自定义类 / 接口,需要根据你项目实际情况修改:
MinioTemplate:MinIO 操作模板类(封装 bucket 检查、创建、文件上传等方法)
FileService:文件元信息存储服务(实现 uploadFile 方法,将 UploadFilePO 保存到数据库)
UploadFilePO:文件元信息实体类(包含 fileId、fileName、filePath 等字段)
UserVo:用户信息 VO 类(包含 userId、nickName 等字段)
@Login:自定义登录用户注解(用于获取当前登录用户)

四、关键功能解析

1. 文件大小校验
遍历所有上传文件,提前校验文件大小是否超过配置的最大值,避免后续处理浪费资源。
2. 图片格式统一处理
通过正则表达式将 png/gif/bmp 等格式统一转换为 jpg,减少后续处理的兼容性问题。
3. 水印添加逻辑
使用 Graphics2D 绘制水印,支持设置字体、颜色、透明度
水印位置固定在图片左下角,分为两行(项目名称 + 时间戳)
开启抗锯齿,提升水印文字的显示效果
4. 图片压缩
基于 Thumbnails 工具类,根据是否有项目名称,设置不同的压缩尺寸,同时统一输出为 jpg 格式。
5. MinIO 存储
自动检查存储桶是否存在,不存在则创建
按日期分目录存储文件,避免单目录文件过多
生成雪花算法 ID 作为文件名前缀,避免文件重名

五、 注意事项

  1. 异常处理:原代码中 log.error(“上传文件失败”, e.getMessage()) 仅打印异常消息,建议改为 log.error(“上传文件失败”, e) 打印完整异常栈,便于排查问题。
  2. 资源释放:图片处理过程中涉及大量流操作,需确保流关闭(可使用 try-with-resources 语法)。
  3. 性能优化:批量上传时可考虑异步处理,避免接口响应时间过长。
  4. 水印样式:可根据实际需求调整水印字体大小、颜色、透明度、位置(如居中、平铺)。
  5. MinIO 权限:确保 MinIO 服务的访问密钥拥有创建桶、上传文件的权限。

总结
本文核心知识点回顾:
基于 SpringBoot + MinIO 实现通用文件上传,区分图片和普通文件处理逻辑;
利用 Thumbnails 实现图片压缩,Graphics2D 实现水印添加,提升图片处理效率;
关键优化点:文件大小前置校验、按日期分目录存储、雪花算法避免文件名冲突、完整的异常日志记录。
该方案可直接落地到现场照片、工单图片等业务场景,只需根据实际需求调整水印样式、压缩尺寸、模块标识等参数即可。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐