新增后端传图自动压缩
This commit is contained in:
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>
|
||||
+32
@@ -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
@@ -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
|
||||
|
||||
+158
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+24
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user