From 01e0d8458e40f9518b41895984fea19f3caf8e1d Mon Sep 17 00:00:00 2001 From: Kroytz Date: Sun, 19 Jul 2026 12:40:04 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=90=8E=E7=AB=AF=E4=BC=A0?= =?UTF-8?q?=E5=9B=BE=E8=87=AA=E5=8A=A8=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yshop-drink-boot3/yshop-dependencies/pom.xml | 7 + .../yshop-module-infra-biz/pom.xml | 5 + .../framework/file/config/WebpProperties.java | 32 ++++ .../config/YshopFileAutoConfiguration.java | 2 + .../file/core/utils/ImageWebpUtils.java | 158 ++++++++++++++++++ .../infra/service/file/FileServiceImpl.java | 24 +++ .../src/main/resources/application.yaml | 5 + .../pages/components/pages/orders/orders.vue | 2 +- yshop-drink-uniapp-vue3/pages/menu/menu.vue | 3 + 9 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java create mode 100644 yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java diff --git a/yshop-drink-boot3/yshop-dependencies/pom.xml b/yshop-drink-boot3/yshop-dependencies/pom.xml index 61de8ad..5ff8917 100644 --- a/yshop-drink-boot3/yshop-dependencies/pom.xml +++ b/yshop-drink-boot3/yshop-dependencies/pom.xml @@ -61,6 +61,7 @@ 3.10.0 0.1.55 2.9.1 + 0.8.0 2.7.0 3.0.6 @@ -466,6 +467,12 @@ ${tika-core.version} + + com.github.usefulness + webp-imageio + ${webp-imageio.version} + + org.apache.velocity velocity-engine-core diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml index 255c012..8ed6dfd 100644 --- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml +++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/pom.xml @@ -125,6 +125,11 @@ tika-core + + com.github.usefulness + webp-imageio + + diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java new file mode 100644 index 0000000..016e191 --- /dev/null +++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/WebpProperties.java @@ -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; + +} diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java index 0e894a0..1cd616e 100644 --- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java +++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/config/YshopFileAutoConfiguration.java @@ -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 diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java new file mode 100644 index 0000000..aafba1f --- /dev/null +++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/framework/file/core/utils/ImageWebpUtils.java @@ -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 工具类 + *

+ * 基于 webp-imageio(libwebp 的 JNI 封装),将 jpg/png/bmp 等图片转为有损 webp, + * 并按最大宽度等比缩小,以达到最小体积。 + * + * @author yshop + */ +public class ImageWebpUtils { + + /** + * 可转为 webp 的图片 mime 白名单 + *

+ * 排除:image/gif(动图,webp-imageio 不支持写动图)、image/webp(已转换)、image/svg+xml(矢量图) + */ + private static final Set 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 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)); + } + +} diff --git a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java index bee739d..d2979e0 100644 --- a/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java +++ b/yshop-drink-boot3/yshop-module-infra/yshop-module-infra-biz/src/main/java/co/yixiang/yshop/module/infra/service/file/FileServiceImpl.java @@ -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 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); } diff --git a/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml b/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml index 1f69732..63c27ba 100644 --- a/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml +++ b/yshop-drink-boot3/yshop-server/src/main/resources/application.yaml @@ -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 的地址 diff --git a/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue b/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue index 8a4cf29..0992024 100644 --- a/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue +++ b/yshop-drink-uniapp-vue3/pages/components/pages/orders/orders.vue @@ -10,7 +10,7 @@ - + diff --git a/yshop-drink-uniapp-vue3/pages/menu/menu.vue b/yshop-drink-uniapp-vue3/pages/menu/menu.vue index c4d2a80..432cbba 100644 --- a/yshop-drink-uniapp-vue3/pages/menu/menu.vue +++ b/yshop-drink-uniapp-vue3/pages/menu/menu.vue @@ -50,6 +50,7 @@ ⌕ + @@ -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; }