新增后端传图自动压缩

This commit is contained in:
Kroytz committed 2026-07-19 12:40:04 +08:00
1 parent 700db68d19
commit 01e0d8458e
9 files changed
+237 -1

No files matched your search

@@ -61,6 +61,7 @@
<commons-net.version>3.10.0</commons-net.version>
<jsch.version>0.1.55</jsch.version>
<tika-core.version>2.9.1</tika-core.version>
<webp-imageio.version>0.8.0</webp-imageio.version>
<ip2region.version>2.7.0</ip2region.version>
<bizlog-sdk.version>3.0.6</bizlog-sdk.version>
<!-- 三方云服务相关 -->
@@ -466,6 +467,12 @@
<version>${tika-core.version}</version>
</dependency>
<dependency>
<groupId>com.github.usefulness</groupId>
<artifactId>webp-imageio</artifactId> <!-- 图片转 webp 压缩(自带多平台本地库) -->
<version>${webp-imageio.version}</version>
</dependency>
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
@@ -125,6 +125,11 @@
<artifactId>tika-core</artifactId> <!-- 文件客户端:文件类型的识别 -->
</dependency>
<dependency>
<groupId>com.github.usefulness</groupId>
<artifactId>webp-imageio</artifactId> <!-- 文件客户端:图片自动转 webp 压缩 -->
</dependency>
</dependencies>
</project>
@@ -0,0 +1,32 @@
package co.yixiang.yshop.module.infra.framework.file.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
/**
* 图片转 WebP 压缩的配置项
*
* @author yshop
*/
@ConfigurationProperties(prefix = "yshop.file.webp")
@Validated
@Data
public class WebpProperties {
/**
* 是否开启图片自动转 webp 压缩
*/
private boolean enable = true;
/**
* 有损压缩质量,取值 0~1,越小体积越小
*/
private float quality = 0.75f;
/**
* 最大宽度(像素),超过则等比缩小;<= 0 表示不限制
*/
private int maxWidth = 2048;
}
@@ -2,6 +2,7 @@ package co.yixiang.yshop.module.infra.framework.file.config;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClientFactory;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClientFactoryImpl;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,6 +12,7 @@ import org.springframework.context.annotation.Configuration;
* @author yshop
*/
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(WebpProperties.class)
public class YshopFileAutoConfiguration {
@Bean
@@ -0,0 +1,158 @@
package co.yixiang.yshop.module.infra.framework.file.core.utils;
import cn.hutool.core.util.StrUtil;
import com.luciad.imageio.webp.CompressionType;
import com.luciad.imageio.webp.WebPWriteParam;
import lombok.SneakyThrows;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageReader;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageInputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.Transparency;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Iterator;
import java.util.Set;
/**
* 图片转 WebP 工具类
* <p>
* 基于 webp-imageio(libwebp 的 JNI 封装),将 jpg/png/bmp 等图片转为有损 webp,
* 并按最大宽度等比缩小,以达到最小体积。
*
* @author yshop
*/
public class ImageWebpUtils {
/**
* 可转为 webp 的图片 mime 白名单
* <p>
* 排除:image/gif(动图,webp-imageio 不支持写动图)、image/webp(已转换)、image/svg+xml(矢量图)
*/
private static final Set<String> CONVERTIBLE_TYPES = Set.of(
"image/jpeg", "image/jpg", "image/png", "image/bmp");
/**
* 判断指定 mime 类型的图片是否可转 webp
*
* @param mimeType mime 类型
* @return 是否可转
*/
public static boolean isConvertible(String mimeType) {
return StrUtil.isNotBlank(mimeType) && CONVERTIBLE_TYPES.contains(mimeType.toLowerCase());
}
/**
* 将图片字节转为有损 webp
*
* @param content 原始图片字节
* @param quality 有损质量 0~1,越小体积越小
* @param maxWidth 最大宽度(像素),超过则等比缩小;<= 0 表示不限制
* @return webp 字节
*/
@SneakyThrows
public static byte[] convertToWebp(byte[] content, float quality, int maxWidth) {
BufferedImage image = readImage(content);
BufferedImage scaled = scaleIfNeeded(image, maxWidth);
return encodeWebp(scaled, quality);
}
/**
* 将文件名/路径的扩展名替换为 .webp,保留目录前缀;无扩展名时追加 .webp
*
* @param fileName 文件名或路径,可为空
* @return 替换后的文件名
*/
public static String replaceExtToWebp(String fileName) {
if (StrUtil.isBlank(fileName)) {
return fileName;
}
// 兼容 Windows 与 Linux 路径分隔符,保留目录前缀
int slashIdx = Math.max(fileName.lastIndexOf('/'), fileName.lastIndexOf('\\'));
String dir = slashIdx >= 0 ? fileName.substring(0, slashIdx + 1) : "";
String name = slashIdx >= 0 ? fileName.substring(slashIdx + 1) : fileName;
if (StrUtil.isBlank(name)) {
return dir;
}
int dotIdx = name.lastIndexOf('.');
// dotIdx <= 0 排除隐藏文件(如 ".gitignore")和无扩展名的情况,二者均直接追加 .webp
String mainName = dotIdx > 0 ? name.substring(0, dotIdx) : name;
return dir + mainName + ".webp";
}
/**
* 读取图片字节为 BufferedImage
*/
private static BufferedImage readImage(byte[] content) throws Exception {
try (ImageInputStream iis = ImageIO.createImageInputStream(new ByteArrayInputStream(content))) {
Iterator<ImageReader> readers = ImageIO.getImageReaders(iis);
if (!readers.hasNext()) {
throw new IllegalArgumentException("无法识别的图片格式");
}
ImageReader reader = readers.next();
try {
reader.setInput(iis);
return reader.read(0);
} finally {
reader.dispose();
}
}
}
/**
* 按最大宽度等比缩小;未超限或限制关闭时原样返回
*/
private static BufferedImage scaleIfNeeded(BufferedImage src, int maxWidth) {
if (maxWidth <= 0 || src.getWidth() <= maxWidth) {
return src;
}
int newWidth = maxWidth;
int newHeight = Math.round((float) src.getHeight() * maxWidth / src.getWidth());
// 保留原图透明通道,避免 PNG 透明区域被压成黑底
int imageType = src.getTransparency() != Transparency.OPAQUE
? BufferedImage.TYPE_INT_ARGB : BufferedImage.TYPE_INT_RGB;
BufferedImage scaled = new BufferedImage(newWidth, newHeight, imageType);
Graphics2D g = scaled.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(src, 0, 0, newWidth, newHeight, null);
} finally {
g.dispose();
}
return scaled;
}
/**
* 有损 webp 编码
*/
private static byte[] encodeWebp(BufferedImage image, float quality) throws Exception {
ImageWriter writer = ImageIO.getImageWritersByMIMEType("image/webp").next();
try {
WebPWriteParam writeParam = new WebPWriteParam(writer.getLocale());
writeParam.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
writeParam.setCompressionType(CompressionType.Lossy);
writeParam.setCompressionQuality(clamp(quality, 0f, 1f));
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (MemoryCacheImageOutputStream ios = new MemoryCacheImageOutputStream(out)) {
writer.setOutput(ios);
writer.write(null, new IIOImage(image, null, null), writeParam);
}
return out.toByteArray();
} finally {
writer.dispose();
}
}
private static float clamp(float value, float min, float max) {
return Math.max(min, Math.min(max, value));
}
}
@@ -5,9 +5,11 @@ import cn.hutool.core.util.StrUtil;
import co.yixiang.yshop.framework.common.pojo.PageResult;
import co.yixiang.yshop.framework.common.util.io.FileUtils;
import co.yixiang.yshop.framework.common.util.object.BeanUtils;
import co.yixiang.yshop.module.infra.framework.file.config.WebpProperties;
import co.yixiang.yshop.module.infra.framework.file.core.client.FileClient;
import co.yixiang.yshop.module.infra.framework.file.core.client.s3.FilePresignedUrlRespDTO;
import co.yixiang.yshop.module.infra.framework.file.core.utils.FileTypeUtils;
import co.yixiang.yshop.module.infra.framework.file.core.utils.ImageWebpUtils;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FilePageReqVO;
import co.yixiang.yshop.module.infra.controller.admin.file.vo.file.FilePresignedUrlRespVO;
@@ -15,6 +17,7 @@ import co.yixiang.yshop.module.infra.dal.dataobject.file.FileDO;
import co.yixiang.yshop.module.infra.dal.mysql.file.FileMapper;
import jakarta.annotation.Resource;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import static co.yixiang.yshop.framework.common.exception.util.ServiceExceptionUtil.exception;
@@ -26,6 +29,7 @@ import static co.yixiang.yshop.module.infra.enums.ErrorCodeConstants.FILE_NOT_EX
* @author yshop
*/
@Service
@Slf4j
public class FileServiceImpl implements FileService {
@Resource
@@ -34,6 +38,9 @@ public class FileServiceImpl implements FileService {
@Resource
private FileMapper fileMapper;
@Resource
private WebpProperties webpProperties;
@Override
public PageResult<FileDO> getFilePage(FilePageReqVO pageReqVO) {
return fileMapper.selectPage(pageReqVO);
@@ -44,6 +51,23 @@ public class FileServiceImpl implements FileService {
public String createFile(String name, String path, byte[] content) {
// 计算默认的 path 名
String type = FileTypeUtils.getMineType(content, name);
// 图片自动转 webp 压缩,确保体积最小
if (webpProperties.isEnable() && ImageWebpUtils.isConvertible(type)) {
try {
byte[] webp = ImageWebpUtils.convertToWebp(content, webpProperties.getQuality(), webpProperties.getMaxWidth());
// 仅当转换后体积更小时才采用,避免极小原图转换后反而变大
if (webp.length < content.length) {
content = webp;
type = "image/webp";
name = ImageWebpUtils.replaceExtToWebp(name);
path = ImageWebpUtils.replaceExtToWebp(path);
}
} catch (Exception e) {
log.warn("[createFile] 转 webp 失败,保留原图上传: {}", e.getMessage());
}
}
if (StrUtil.isEmpty(path)) {
path = FileUtils.generatePath(content, name);
}
@@ -149,6 +149,11 @@ yshop:
version: 3.0.0
base-package: co.yixiang.yshop
isActive: true
file: # 图片上传相关
webp:
enable: true # 图片自动转 webp 压缩开关
quality: 0.75 # 有损压缩质量 0~1,越小体积越小
max-width: 2048 # 最大宽度(像素),超过等比缩小;0 表示不限制
web:
admin-ui:
url: http://dashboard.yshop.yixiang.co # Admin 管理后台 UI 的地址
@@ -10,7 +10,7 @@
<uv-tabs :list="tabList" :current="current" @change="change" keyName="name" :scrollable="false"></uv-tabs>
</view>
<view class="orders-page__list d-flex flex-column w-100">
<view class="orders-item" v-for="(item, index) in orders" :key="index" @tap="detail(item.orderId)">
<view class="orders-item" v-for="(item, index) in orders" :key="index">
<list-cell :hover="false">
<view class="w-100 d-flex align-items-center">
<view class="flex-fill d-flex flex-column">
@@ -50,6 +50,7 @@
<view class="menu-search">
<text class="iconsearch">⌕</text>
<input v-model="keyword" placeholder="搜索菜品、饮品" confirm-type="search" />
<button v-if="keyword" class="clear-search" aria-label="清空搜索" @tap="keyword = ''">×</button>
</view>
<!-- #ifdef H5 -->
@@ -1496,6 +1497,8 @@ page {
.menu-search { display: flex; align-items: center; height: 78rpx; margin: 22rpx 0; padding: 0 24rpx; border: 2rpx solid #eee9e5; border-radius: 42rpx; color: #9d9d9d; background: #fff; }
.menu-search input { flex: 1; margin-left: 14rpx; font-size: 28rpx; }
.menu-search .iconsearch { font-size: 40rpx; line-height: 1; }
.menu-search .clear-search { width: 48rpx; height: 48rpx; margin: 0; padding: 0; border-radius: 50%; color: #999; font-size: 34rpx; line-height: 44rpx; background: #f1efed; }
.menu-search .clear-search::after { border: 0; }
.content { height: calc(100vh - 440rpx) !important; }
.menus { width: 156rpx; border-radius: 24rpx 0 0 24rpx; background: #fffaf5; }