package cn.van.business.service; import cn.van.business.model.pl.ImageConversion; import cn.van.business.repository.ImageConversionRepository; import cn.hutool.core.util.StrUtil; import cn.hutool.http.HttpUtil; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import net.coobird.thumbnailator.Thumbnails; import javax.imageio.ImageIO; import javax.imageio.ImageReader; import javax.imageio.stream.ImageInputStream; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.*; /** * WebP图片IO支持工具类 * 尝试使用可用的方式读取webp图片 */ class WebPImageIO { private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(WebPImageIO.class); private static volatile boolean webpSupported = false; private static volatile boolean checked = false; /** * 检查是否支持webp格式 */ static synchronized boolean isWebPSupported() { if (checked) { return webpSupported; } try { Iterator readers = ImageIO.getImageReadersByFormatName("webp"); webpSupported = readers.hasNext(); if (webpSupported) { log.info("WebP图片格式支持已启用"); } else { log.warn("未检测到WebP图片格式支持,webp图片转换将被跳过"); } } catch (Exception e) { log.warn("检查WebP支持时出错: {}", e.getMessage()); webpSupported = false; } checked = true; return webpSupported; } } /** * 图片转换服务类 * 负责将webp格式的图片转换为jpg格式,并缓存转换结果 * * @author System */ @Slf4j @Service public class ImageConvertService { @Autowired private ImageConversionRepository imageConversionRepository; /** * 图片存储根目录,默认使用系统临时目录 */ @Value("${image.convert.storage-path:${java.io.tmpdir}/comment-images}") private String storagePath; /** * 图片访问基础URL,用于生成转换后图片的访问地址 * 如果为空,则返回本地文件路径 */ @Value("${image.convert.base-url:}") private String baseUrl; /** * 转换图片URL列表,将webp格式转换为jpg * * @param imageUrls 原始图片URL列表 * @return 转换后的图片URL列表(webp已转换为jpg,其他格式保持不变) */ public List convertImageUrls(List imageUrls) { if (imageUrls == null || imageUrls.isEmpty()) { return Collections.emptyList(); } log.info("开始转换图片URL列表,共{}张图片", imageUrls.size()); List convertedUrls = new ArrayList<>(); int successCount = 0; int skipCount = 0; for (String imageUrl : imageUrls) { if (StrUtil.isBlank(imageUrl)) { continue; } log.debug("处理图片URL: {}", imageUrl); try { String convertedUrl = convertImageUrl(imageUrl); if (!convertedUrl.equals(imageUrl)) { successCount++; log.debug("图片转换成功: {} -> {}", imageUrl, convertedUrl); } else { skipCount++; log.debug("图片无需转换(非webp格式): {}", imageUrl); } convertedUrls.add(convertedUrl); } catch (Exception e) { // 转换失败时使用原URL,不中断流程 skipCount++; log.warn("图片转换失败,使用原URL: {}. 错误: {}", imageUrl, e.getMessage()); convertedUrls.add(imageUrl); } } log.info("图片URL转换完成,共{}张,成功转换{}张,跳过/失败{}张", imageUrls.size(), successCount, skipCount); return convertedUrls; } /** * 转换单个图片URL * * @param originalUrl 原始图片URL * @return 转换后的图片URL(如果不需要转换则返回原URL) * @throws Exception 转换过程中的异常(调用方会捕获并返回原URL) */ private String convertImageUrl(String originalUrl) throws Exception { if (StrUtil.isBlank(originalUrl)) { return originalUrl; } // 规范化URL:处理协议相对URL(//开头) String normalizedUrl = normalizeUrl(originalUrl); // 检查是否为webp格式 if (!isWebpFormat(normalizedUrl)) { return originalUrl; // 返回原URL,保持一致性 } // 检查系统是否支持webp转换 if (!WebPImageIO.isWebPSupported()) { log.warn("系统不支持webp格式,跳过转换: {}", normalizedUrl); throw new IOException("系统不支持webp格式转换"); } // 使用规范化后的URL进行缓存查询和转换 // 检查是否已转换(使用规范化URL作为key) Optional existing = imageConversionRepository.findByOriginalUrl(normalizedUrl); if (existing.isPresent()) { ImageConversion conversion = existing.get(); log.debug("使用缓存的转换结果: {} -> {}", normalizedUrl, conversion.getConvertedUrl()); return conversion.getConvertedUrl(); } // 执行转换(使用规范化URL) log.info("开始转换webp图片: {}", normalizedUrl); String convertedUrl = performConversion(normalizedUrl); log.info("图片转换成功: {} -> {}", normalizedUrl, convertedUrl); return convertedUrl; } /** * 规范化URL:处理协议相对URL等特殊情况 * * @param url 原始URL * @return 规范化后的URL */ private String normalizeUrl(String url) { if (StrUtil.isBlank(url)) { return url; } // 清理特殊字符(如零宽字符) String cleanUrl = url.trim().replaceAll("[\\u200B-\\u200D\\uFEFF]", ""); // 处理协议相对URL(//开头) if (cleanUrl.startsWith("//")) { cleanUrl = "https:" + cleanUrl; log.debug("转换协议相对URL: {} -> {}", url, cleanUrl); } return cleanUrl; } /** * 检查URL是否为webp格式 * * @param url 图片URL * @return 是否为webp格式 */ private boolean isWebpFormat(String url) { if (StrUtil.isBlank(url)) { return false; } // 清理URL中的特殊字符(如零宽字符) String cleanUrl = url.trim().replaceAll("[\\u200B-\\u200D\\uFEFF]", ""); // 检查URL中是否包含.webp扩展名(不区分大小写) String lowerUrl = cleanUrl.toLowerCase(); // 检查URL参数或路径中是否包含webp boolean isWebp = lowerUrl.contains(".webp") || lowerUrl.contains("format=webp") || lowerUrl.contains("?webp") || lowerUrl.contains("&webp"); if (isWebp) { log.debug("检测到webp格式图片: {}", cleanUrl); } return isWebp; } /** * 执行图片转换 * * @param originalUrl 原始webp图片URL * @return 转换后的jpg图片URL或路径 */ private String performConversion(String originalUrl) throws IOException { // 确保存储目录存在 Path storageDir = Paths.get(storagePath); if (!Files.exists(storageDir)) { Files.createDirectories(storageDir); } // 生成转换后的文件名(基于原URL的MD5) String fileName = generateFileName(originalUrl); Path outputPath = storageDir.resolve(fileName); // 如果转换后的文件已存在,直接返回 if (Files.exists(outputPath)) { String convertedUrl = generateConvertedUrl(fileName); // 如果数据库中没有记录,保存记录 if (!imageConversionRepository.existsByOriginalUrl(originalUrl)) { saveConversionRecord(originalUrl, convertedUrl, Files.size(outputPath)); } return convertedUrl; } // 下载原始图片 byte[] imageData = downloadImage(originalUrl); if (imageData == null || imageData.length == 0) { throw new IOException("下载图片失败或图片数据为空: " + originalUrl); } // 转换图片格式 BufferedImage bufferedImage = null; try { // 检查是否支持webp格式 boolean webpSupported = WebPImageIO.isWebPSupported(); if (webpSupported) { // 如果支持webp,尝试使用ImageIO读取 try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) { bufferedImage = ImageIO.read(bais); } // 如果ImageIO无法读取,尝试使用WebP特定的读取器 if (bufferedImage == null) { Iterator readers = ImageIO.getImageReadersByFormatName("webp"); if (readers.hasNext()) { ImageReader reader = readers.next(); try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData); ImageInputStream iis = ImageIO.createImageInputStream(bais)) { reader.setInput(iis); bufferedImage = reader.read(0); } finally { reader.dispose(); } } } } else { // 如果不支持webp,尝试使用Thumbnailator转换 // 注意:Thumbnailator内部也使用ImageIO,所以通常也无法处理webp try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) { bufferedImage = Thumbnails.of(bais) .scale(1.0) .asBufferedImage(); } catch (Exception e) { log.debug("Thumbnailator无法读取webp图片: {}", e.getMessage()); // 如果无法转换,抛出异常,后续会返回原URL throw new IOException("当前系统不支持webp格式转换。图片将保持原格式返回。", e); } } if (bufferedImage == null) { throw new IOException("无法读取webp图片格式。当前系统不支持webp格式转换。"); } // 如果是RGBA格式,转换为RGB if (bufferedImage.getType() == BufferedImage.TYPE_4BYTE_ABGR || bufferedImage.getType() == BufferedImage.TYPE_INT_ARGB) { BufferedImage rgbImage = new BufferedImage( bufferedImage.getWidth(), bufferedImage.getHeight(), BufferedImage.TYPE_INT_RGB ); rgbImage.createGraphics().drawImage(bufferedImage, 0, 0, null); bufferedImage = rgbImage; } // 保存为jpg格式 boolean success = ImageIO.write(bufferedImage, "jpg", outputPath.toFile()); if (!success) { throw new IOException("无法写入jpg格式"); } String convertedUrl = generateConvertedUrl(fileName); long fileSize = Files.size(outputPath); // 保存转换记录 saveConversionRecord(originalUrl, convertedUrl, fileSize); log.info("图片转换完成: {} -> {} (大小: {} bytes)", originalUrl, convertedUrl, fileSize); return convertedUrl; } finally { if (bufferedImage != null) { bufferedImage.flush(); } } } /** * 下载图片 * * @param url 图片URL * @return 图片字节数组 */ private byte[] downloadImage(String url) { try { return HttpUtil.downloadBytes(url); } catch (Exception e) { log.error("下载图片失败: {}, 错误: {}", url, e.getMessage()); throw new RuntimeException("下载图片失败: " + e.getMessage(), e); } } /** * 生成文件名(基于原URL的MD5) * * @param originalUrl 原始URL * @return 文件名(不含扩展名) */ private String generateFileName(String originalUrl) { // 使用URL的MD5作为文件名,避免重复和特殊字符问题 String md5 = cn.hutool.crypto.digest.DigestUtil.md5Hex(originalUrl); return md5 + ".jpg"; } /** * 生成转换后的URL * * @param fileName 文件名 * @return 转换后的URL或本地路径 */ private String generateConvertedUrl(String fileName) { if (StrUtil.isNotBlank(baseUrl)) { // 如果配置了baseUrl,返回HTTP访问地址 return baseUrl.endsWith("/") ? baseUrl + fileName : baseUrl + "/" + fileName; } else { // 否则返回本地文件路径 return Paths.get(storagePath, fileName).toString(); } } /** * 保存转换记录 * * @param originalUrl 原始URL * @param convertedUrl 转换后的URL * @param fileSize 文件大小 */ private void saveConversionRecord(String originalUrl, String convertedUrl, long fileSize) { ImageConversion conversion = new ImageConversion(); conversion.setOriginalUrl(originalUrl); conversion.setConvertedUrl(convertedUrl); conversion.setFileSize(fileSize); conversion.setConvertedAt(LocalDateTime.now()); try { imageConversionRepository.save(conversion); } catch (Exception e) { log.warn("保存转换记录失败: {}, 错误: {}", originalUrl, e.getMessage()); // 不抛出异常,因为转换本身已成功 } } }