diff --git a/fys-admin/src/main/resources/application-test.yml b/fys-admin/src/main/resources/application-test.yml index 8f699b3f..cb8075d7 100644 --- a/fys-admin/src/main/resources/application-test.yml +++ b/fys-admin/src/main/resources/application-test.yml @@ -269,4 +269,4 @@ justauth: server-url: https://demo.gitea.com client-id: 10**********6 client-secret: 1f7d08**********5b7**********29e - redirect-uri: ${justauth.address}/social-callback?source=gitea + redirect-uri: ${justauth.address}/social-callback?source=gitea \ No newline at end of file diff --git a/fys-common/fys-common-core/src/main/java/com/fuyuanshen/common/core/utils/file/ImageCompressUtil.java b/fys-common/fys-common-core/src/main/java/com/fuyuanshen/common/core/utils/file/ImageCompressUtil.java index c72e3e3a..be56b97b 100644 --- a/fys-common/fys-common-core/src/main/java/com/fuyuanshen/common/core/utils/file/ImageCompressUtil.java +++ b/fys-common/fys-common-core/src/main/java/com/fuyuanshen/common/core/utils/file/ImageCompressUtil.java @@ -3,18 +3,43 @@ package com.fuyuanshen.common.core.utils.file; import lombok.extern.slf4j.Slf4j; import javax.imageio.ImageIO; +import javax.imageio.ImageWriteParam; +import javax.imageio.ImageWriter; import java.awt.*; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.util.Iterator; /** * 图片压缩工具类 + * + * @author AprilWind */ @Slf4j public class ImageCompressUtil { + /** + * 默认压缩目标大小(100KB) + */ + private static final int DEFAULT_COMPRESS_SIZE = 100 * 1024; + + /** + * 默认触发压缩的大小(1MB) + */ + private static final int DEFAULT_TRIGGER_SIZE = 1024 * 1024; + + /** + * 压缩图片到指定大小以下(默认100KB) + * + * @param imageData 原始图片数据 + * @return 压缩后的图片数据 + */ + public static byte[] compressImage(byte[] imageData) { + return compressImage(imageData, DEFAULT_COMPRESS_SIZE); + } + /** * 压缩图片到指定大小以下 * @@ -36,24 +61,54 @@ public class ImageCompressUtil { return imageData; } - // 计算压缩比例 - double scale = Math.sqrt((double) maxSize / imageData.length); - // 确保至少压缩到一半大小,避免压缩效果不明显 - scale = Math.max(scale, 0.5); + // 检查图片是否包含透明度 + boolean hasAlpha = hasAlpha(originalImage); + String formatName = hasAlpha ? "png" : "jpg"; - // 压缩图片 - byte[] compressedData = compressImageByScale(originalImage, scale); + // 对于小尺寸PNG图片可跳过压缩以保持图像质量 + if ("png".equals(formatName) && imageData.length <= 2 * maxSize) { + log.debug("PNG图片大小适中({} bytes),跳过压缩", imageData.length); + return imageData; + } + + // 先尝试质量压缩 + byte[] compressedData = compressImageQuality(originalImage, formatName, 0.8f); + + // 如果质量压缩后仍大于目标大小,则进行尺寸压缩 + if (compressedData.length > maxSize) { + // 计算缩放比例 + double scale = Math.sqrt((double) maxSize / compressedData.length); + scale = Math.max(scale, 0.5); // 最小缩放到原来的一半 + + // 尺寸压缩 + compressedData = compressImageByScale(originalImage, scale, formatName); + } // 如果压缩后还是太大,继续压缩 int attempts = 0; while (compressedData.length > maxSize && attempts < 5) { - scale *= 0.8; // 每次缩小20% - compressedData = compressImageByScale(originalImage, scale); + // 优先降低质量 + float quality = Math.max(0.1f, 0.8f - attempts * 0.1f); + compressedData = compressImageQuality(originalImage, formatName, quality); + + // 如果质量压缩不够,再缩小尺寸 + if (compressedData.length > maxSize) { + double scale = 0.9 - attempts * 0.1; // 逐步缩小尺寸 + scale = Math.max(scale, 0.5); + compressedData = compressImageByScale(originalImage, scale, formatName); + } attempts++; } - log.info("图片压缩完成,原始大小: {} bytes, 压缩后大小: {} bytes, 压缩比例: {}", - imageData.length, compressedData.length, String.format("%.2f", scale)); + log.info("图片压缩完成,原始大小: {} bytes, 压缩后大小: {} bytes, 压缩率: {}%", + imageData.length, compressedData.length, + String.format("%.2f", (1.0 - (double) compressedData.length / imageData.length) * 100)); + + // 如果压缩后反而变大了,则使用原始数据 + if (compressedData.length >= imageData.length) { + log.debug("压缩后数据变大,使用原始数据"); + return imageData; + } return compressedData; } catch (Exception e) { @@ -62,16 +117,16 @@ public class ImageCompressUtil { } } - /** * 按比例缩放图片 * * @param originalImage 原始图片 * @param scale 缩放比例 + * @param formatName 图片格式 * @return 缩放后的图片数据 * @throws IOException IO异常 */ - private static byte[] compressImageByScale(BufferedImage originalImage, double scale) throws IOException { + private static byte[] compressImageByScale(BufferedImage originalImage, double scale, String formatName) throws IOException { int width = (int) (originalImage.getWidth() * scale); int height = (int) (originalImage.getHeight() * scale); @@ -79,13 +134,73 @@ public class ImageCompressUtil { Image scaledImage = originalImage.getScaledInstance(width, height, Image.SCALE_SMOOTH); BufferedImage bufferedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D g2d = bufferedImage.createGraphics(); + + // 绘制缩放后的图片 g2d.drawImage(scaledImage, 0, 0, null); g2d.dispose(); - // 输出为JPEG格式 + // 输出为指定格式 ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ImageIO.write(bufferedImage, "jpg", baos); + ImageIO.write(bufferedImage, formatName, baos); return baos.toByteArray(); } -} + /** + * 按质量压缩图片 + * + * @param originalImage 原始图片 + * @param formatName 图片格式 + * @param quality 压缩质量(0.1-1.0) + * @return 压缩后的图片数据 + * @throws IOException IO异常 + */ + private static byte[] compressImageQuality(BufferedImage originalImage, String formatName, float quality) throws IOException { + // 创建压缩参数 + Iterator writers = ImageIO.getImageWritersByFormatName(formatName); + if (!writers.hasNext()) { + log.warn("找不到合适的图片写入器"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(originalImage, formatName, baos); + return baos.toByteArray(); + } + + ImageWriter writer = writers.next(); + ImageWriteParam param = writer.getDefaultWriteParam(); + + // 设置压缩参数 + if (param.canWriteCompressed()) { + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); + param.setCompressionQuality(quality); + } + + // 写入压缩后的图片数据 + ByteArrayOutputStream compressedOutputStream = new ByteArrayOutputStream(); + writer.setOutput(ImageIO.createImageOutputStream(compressedOutputStream)); + writer.write(null, new javax.imageio.IIOImage(originalImage, null, null), param); + writer.dispose(); + + return compressedOutputStream.toByteArray(); + } + + /** + * 检查图片是否包含透明度 + * + * @param image 图片 + * @return 是否包含透明度 + */ + private static boolean hasAlpha(BufferedImage image) { + return image.getType() == BufferedImage.TYPE_4BYTE_ABGR || + image.getType() == BufferedImage.TYPE_INT_ARGB || + image.getColorModel().hasAlpha(); + } + + /** + * 判断图片是否需要压缩(超过1MB) + * + * @param imageData 图片数据 + * @return 是否需要压缩 + */ + public static boolean needCompress(byte[] imageData) { + return imageData.length > DEFAULT_TRIGGER_SIZE; + } +} \ No newline at end of file diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/converter/IgnoreFailedImageConverter.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/converter/IgnoreFailedImageConverter.java index 10238703..60c62d98 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/converter/IgnoreFailedImageConverter.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/converter/IgnoreFailedImageConverter.java @@ -4,6 +4,8 @@ import com.alibaba.excel.converters.Converter; import com.alibaba.excel.metadata.GlobalConfiguration; import com.alibaba.excel.metadata.data.WriteCellData; import com.alibaba.excel.metadata.property.ExcelContentProperty; +import com.fuyuanshen.common.core.utils.file.ImageCompressUtil; +import com.fuyuanshen.common.redis.utils.RedisUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,44 +14,73 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; import java.net.URLConnection; +import java.util.Base64; +import java.util.HashSet; +import java.util.Set; /** * @author: 默苍璃 * @date: 2025-06-0618:56 */ - public class IgnoreFailedImageConverter implements Converter { private static final Logger logger = LoggerFactory.getLogger(IgnoreFailedImageConverter.class); - + // 重试次数 private static final int MAX_RETRIES = 3; // 指数退避初始延迟(毫秒) private static final int INITIAL_DELAY = 1000; + // 图片压缩阈值(1MB) + private static final int COMPRESSION_THRESHOLD = 1024 * 1024; + // 压缩目标大小(100KB) + private static final int COMPRESSION_TARGET = 100 * 1024; + // 用于跟踪本次任务中使用到的URL缓存键 + private static final ThreadLocal> USED_CACHE_KEYS = new ThreadLocal>() { + @Override + protected Set initialValue() { + return new HashSet<>(); + } + }; @Override public Class supportJavaTypeKey() { return URL.class; } + @Override public WriteCellData convertToExcelData(URL value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) { if (value == null) { - logger.debug("图片URL为空"); + logger.info("图片URL为空"); return new WriteCellData<>(new byte[0]); } + String cacheKey = "excel:image:" + value.toString(); + + // 将当前使用的缓存键添加到集合中 + USED_CACHE_KEYS.get().add(cacheKey); + + // 尝试从缓存获取 + String cachedData = RedisUtils.getCacheObject(cacheKey); + if (cachedData != null) { + // 从缓存中读取Base64编码的数据并解码 + byte[] cachedBytes = Base64.getDecoder().decode(cachedData); + logger.info("从缓存获取图片数据: {}, 大小: {} 字节", value, cachedBytes.length); + return new WriteCellData<>(cachedBytes); + } + + // 缓存未命中,从URL加载 // 尝试多次加载图片 for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { - logger.debug("开始加载图片: {}, 尝试次数: {}", value, attempt); + logger.info("开始加载图片: {}, 尝试次数: {}", value, attempt); URLConnection conn = value.openConnection(); // 增加连接和读取超时时间 conn.setConnectTimeout(10000); // 10秒连接超时 conn.setReadTimeout(30000); // 30秒读取超时 - + // 添加User-Agent避免被服务器拦截 conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ExcelExporter/1.0"); - + // 如果是HTTP连接,设置一些额外的属性 if (conn instanceof HttpURLConnection) { HttpURLConnection httpConn = (HttpURLConnection) conn; @@ -58,38 +89,44 @@ public class IgnoreFailedImageConverter implements Converter { httpConn.setUseCaches(false); // 跟随重定向 httpConn.setInstanceFollowRedirects(true); - + // 检查响应码 int responseCode = httpConn.getResponseCode(); if (responseCode != HttpURLConnection.HTTP_OK) { - logger.warn("HTTP响应码异常: {}, URL: {}", responseCode, value); + logger.info("HTTP响应码异常: {}, URL: {}", responseCode, value); if (attempt < MAX_RETRIES) { // 等待后重试 waitForRetry(attempt); continue; } else { + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); } } } long contentLength = conn.getContentLengthLong(); - logger.debug("连接建立成功,图片大小: {} 字节", contentLength); - + logger.info("连接建立成功,图片大小: {} 字节", contentLength); + // 检查内容长度是否有效 if (contentLength == 0) { - logger.warn("图片文件为空: {}", value); + logger.info("图片文件为空: {}", value); if (attempt < MAX_RETRIES) { waitForRetry(attempt); continue; } else { + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); } } - + // 限制图片大小(防止过大文件导致内存问题) if (contentLength > 10 * 1024 * 1024) { // 10MB限制 - logger.warn("图片文件过大 ({} bytes),跳过加载: {}", contentLength, value); + logger.info("图片文件过大 ({} bytes),跳过加载: {}", contentLength, value); + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); } @@ -97,52 +134,98 @@ public class IgnoreFailedImageConverter implements Converter { // byte[] bytes = FileUtils.readInputStream(inputStream, value.toString()); // 替代 FileUtils.readInputStream 的自定义方法 byte[] bytes = readInputStream(inputStream); - + // 检查读取到的数据是否为空 if (bytes == null || bytes.length == 0) { - logger.warn("读取到空的图片数据: {}", value); + logger.info("读取到空的图片数据: {}", value); if (attempt < MAX_RETRIES) { waitForRetry(attempt); continue; } else { + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); } } - - logger.debug("成功读取图片数据,大小: {} 字节", bytes.length); + + // 如果图片大于1MB,则进行压缩 + if (bytes.length > COMPRESSION_THRESHOLD) { + logger.info("图片大小超过1MB ({} bytes),开始压缩", bytes.length); + long beforeCompressSize = bytes.length; + bytes = ImageCompressUtil.compressImage(bytes, COMPRESSION_TARGET); + long afterCompressSize = bytes.length; + logger.info("图片压缩完成,压缩前大小: {} bytes, 压缩后大小: {} bytes, 压缩率: {}%", + beforeCompressSize, afterCompressSize, + String.format("%.2f", (1.0 - (double) afterCompressSize / beforeCompressSize) * 100)); + } + + logger.info("成功读取图片数据,大小: {} 字节", bytes.length); + // 将数据写入缓存,不设置过期时间,使用Base64编码存储 + String encodedData = Base64.getEncoder().encodeToString(bytes); + RedisUtils.setCacheObject(cacheKey, encodedData); return new WriteCellData<>(bytes); } } catch (Exception e) { - logger.warn("图片加载失败: {}, 尝试次数: {}, 原因: {}", value, attempt, e.getMessage(), e); + logger.info("图片加载失败: {}, 尝试次数: {}, 原因: {}", value, attempt, e.getMessage(), e); if (attempt < MAX_RETRIES) { // 等待后重试 waitForRetry(attempt); } else { // 最后一次尝试也失败了 - logger.error("图片加载最终失败,已重试 {} 次: {}", MAX_RETRIES, value, e); + logger.info("图片加载最终失败,已重试 {} 次: {}", MAX_RETRIES, value, e); + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); // 返回空数组而不是 null } } } - + // 所有尝试都失败了 + // 将空数据写入缓存 + RedisUtils.setCacheObject(cacheKey, ""); return new WriteCellData<>(new byte[0]); } + /** + * 清理未使用的缓存 + * 任务结束后调用此方法,删除本次任务中未使用的URL缓存 + */ + public static void cleanUnusedCache() { + Set usedKeys = USED_CACHE_KEYS.get(); + if (usedKeys != null && !usedKeys.isEmpty()) { + // 获取所有图片缓存键 + Iterable allKeys = RedisUtils.keys("excel:image:*"); + if (allKeys != null) { + // 删除未使用的缓存 + for (String key : allKeys) { + if (!usedKeys.contains(key)) { + RedisUtils.deleteObject(key); + logger.info("删除未使用的缓存: {}", key); + } + } + } + // 清理ThreadLocal + USED_CACHE_KEYS.remove(); + } + } + + /** * 等待重试,使用指数退避策略 + * * @param attempt 当前尝试次数 */ private void waitForRetry(int attempt) { try { long delay = (long) INITIAL_DELAY * (1L << (attempt - 1)); // 指数退避 - logger.debug("等待 {} 毫秒后重试...", delay); + logger.info("等待 {} 毫秒后重试...", delay); Thread.sleep(delay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); } } + /** * 替代 FileUtils.readInputStream 的自定义方法 * @@ -159,14 +242,15 @@ public class IgnoreFailedImageConverter implements Converter { while ((bytesRead = inputStream.read(buffer)) != -1) { outputStream.write(buffer, 0, bytesRead); totalBytes += bytesRead; - + // 如果读取的数据过大,提前终止 if (totalBytes > 10 * 1024 * 1024) { // 10MB限制 - logger.warn("读取的图片数据超过10MB限制,提前终止"); + logger.info("读取的图片数据超过10MB限制,提前终止"); break; } } return outputStream.toByteArray(); } + } \ No newline at end of file diff --git a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/DeviceExportService.java b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/DeviceExportService.java index 5133b1cf..00113261 100644 --- a/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/DeviceExportService.java +++ b/fys-modules/fys-equipment/src/main/java/com/fuyuanshen/equipment/service/impl/DeviceExportService.java @@ -83,6 +83,7 @@ public class DeviceExportService { } + /** * 导出设备数据(包含完整设备类型信息) * @@ -183,6 +184,7 @@ public class DeviceExportService { } } + /** * 转换定位方式代码为中文描述 * diff --git a/fys-modules/fys-system/src/main/java/com/fuyuanshen/system/service/impl/SysOssServiceImpl.java b/fys-modules/fys-system/src/main/java/com/fuyuanshen/system/service/impl/SysOssServiceImpl.java index bcadabbd..07bbf1a9 100644 --- a/fys-modules/fys-system/src/main/java/com/fuyuanshen/system/service/impl/SysOssServiceImpl.java +++ b/fys-modules/fys-system/src/main/java/com/fuyuanshen/system/service/impl/SysOssServiceImpl.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fuyuanshen.common.core.utils.file.ImageCompressUtil; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import com.fuyuanshen.common.core.constant.CacheNames; @@ -143,7 +144,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService { lqw.eq(StringUtils.isNotBlank(bo.getFileSuffix()), SysOss::getFileSuffix, bo.getFileSuffix()); lqw.eq(StringUtils.isNotBlank(bo.getUrl()), SysOss::getUrl, bo.getUrl()); lqw.between(params.get("beginCreateTime") != null && params.get("endCreateTime") != null, - SysOss::getCreateTime, params.get("beginCreateTime"), params.get("endCreateTime")); + SysOss::getCreateTime, params.get("beginCreateTime"), params.get("endCreateTime")); lqw.eq(ObjectUtil.isNotNull(bo.getCreateBy()), SysOss::getCreateBy, bo.getCreateBy()); lqw.eq(StringUtils.isNotBlank(bo.getService()), SysOss::getService, bo.getService()); lqw.orderByAsc(SysOss::getOssId); @@ -169,7 +170,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService { @Override public int updateHashById(long ossId, String fileHash) { - return baseMapper.updateHashById(ossId,fileHash); + return baseMapper.updateHashById(ossId, fileHash); } @@ -191,6 +192,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService { storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong); } + /** * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库 * @@ -209,14 +211,22 @@ public class SysOssServiceImpl implements ISysOssService, OssService { OssClient storage = OssFactory.instance(); UploadResult uploadResult; try { - uploadResult = storage.uploadSuffix(file.getBytes(), suffix, file.getContentType()); + byte[] imageData = file.getBytes(); + // 检查是否需要压缩 + if (ImageCompressUtil.needCompress(imageData)) { + // 压缩到100KB以内 + imageData = ImageCompressUtil.compressImage(imageData); + // 使用压缩后的数据 + } + uploadResult = storage.uploadSuffix(imageData, suffix, file.getContentType()); } catch (IOException e) { throw new ServiceException(e.getMessage()); } // 保存文件信息 - return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult,hash); + return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, hash); } + /** * 上传 MultipartFile 到对象存储服务,并保存文件信息到数据库 * @@ -236,7 +246,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService { throw new ServiceException(e.getMessage()); } // 保存文件信息 - return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult,null); + return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, null); } /** @@ -252,11 +262,10 @@ public class SysOssServiceImpl implements ISysOssService, OssService { OssClient storage = OssFactory.instance(); UploadResult uploadResult = storage.uploadSuffix(file, suffix); // 保存文件信息 - return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult,null); + return buildResultEntity(originalfileName, suffix, storage.getConfigKey(), uploadResult, null); } - /** * 上传二进制数据到对象存储服务,并保存文件信息到数据库 * @@ -281,7 +290,7 @@ public class SysOssServiceImpl implements ISysOssService, OssService { uploadResult = storage.uploadSuffix(data, suffix, "image/jpeg"); // 假设是图片类型,可以根据实际需要修改 // 保存文件信息 - return buildResultEntity(fileName, suffix, storage.getConfigKey(), uploadResult,null); + return buildResultEntity(fileName, suffix, storage.getConfigKey(), uploadResult, null); }