SpringBoot + MinIO 实现图片上传并添加水印功能(附完整代码)
本文介绍了基于SpringBoot和MinIO实现图片上传、压缩及添加水印的完整解决方案。系统支持多文件批量上传,自动将图片转换为JPG格式,并为指定模块的图片添加项目名称和时间戳水印,同时进行尺寸压缩。非图片文件直接上传至MinIO存储。实现过程中使用了Thumbnailator进行图片处理,结合MinIO客户端和Apache Commons IO工具类,确保文件安全存储和高效处理。系统还包含文
在项目开发中,图片上传是非常常见的需求,尤其是现场照片类场景,往往需要给图片添加水印(如项目名称、时间戳)以保证图片的溯源性和安全性。本文将基于 SpringBoot + MinIO 实现图片上传、压缩、水印添加的完整流程,同时兼容普通文件上传,适合后端开发新手学习和落地。
一、功能需求分析
本次实现的核心功能:
- 通用文件上传接口,支持多文件批量上传
- 图片文件(png/jpg/gif/bmp 等)自动转换为 jpg 格式,统一处理
- 给指定模块的图片添加项目名称 + 时间戳水印,同时压缩图片尺寸
- 非图片文件直接上传至 MinIO,不做额外处理
- 上传前校验文件大小,防止超大文件上传
- 所有文件存储至 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 作为文件名前缀,避免文件重名
五、 注意事项
- 异常处理:原代码中 log.error(“上传文件失败”, e.getMessage()) 仅打印异常消息,建议改为 log.error(“上传文件失败”, e) 打印完整异常栈,便于排查问题。
- 资源释放:图片处理过程中涉及大量流操作,需确保流关闭(可使用 try-with-resources 语法)。
- 性能优化:批量上传时可考虑异步处理,避免接口响应时间过长。
- 水印样式:可根据实际需求调整水印字体大小、颜色、透明度、位置(如居中、平铺)。
- MinIO 权限:确保 MinIO 服务的访问密钥拥有创建桶、上传文件的权限。
总结
本文核心知识点回顾:
基于 SpringBoot + MinIO 实现通用文件上传,区分图片和普通文件处理逻辑;
利用 Thumbnails 实现图片压缩,Graphics2D 实现水印添加,提升图片处理效率;
关键优化点:文件大小前置校验、按日期分目录存储、雪花算法避免文件名冲突、完整的异常日志记录。
该方案可直接落地到现场照片、工单图片等业务场景,只需根据实际需求调整水印样式、压缩尺寸、模块标识等参数即可。
更多推荐



所有评论(0)