This commit is contained in:
2025-11-03 11:54:11 +08:00
parent 5f75603532
commit 6b36f0ee52
9 changed files with 741 additions and 2 deletions

View File

@@ -0,0 +1,88 @@
package cn.van.business.controller;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 图片访问控制器
* 用于访问转换后的jpg图片
*
* @author System
*/
@Slf4j
@RestController
@RequestMapping("/images")
public class ImageController {
/**
* 图片存储根目录
*/
@Value("${image.convert.storage-path:${java.io.tmpdir}/comment-images}")
private String storagePath;
/**
* 获取转换后的图片
*
* @param filename 文件名通常是MD5值.jpg
* @return 图片文件
*/
@GetMapping("/{filename:.+}")
public ResponseEntity<Resource> getImage(@PathVariable String filename) {
try {
// 安全检查:防止路径遍历攻击
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
log.warn("非法的文件名请求: {}", filename);
return ResponseEntity.badRequest().build();
}
Path filePath = Paths.get(storagePath, filename);
File file = filePath.toFile();
if (!file.exists() || !file.isFile()) {
log.warn("图片文件不存在: {}", filePath);
return ResponseEntity.notFound().build();
}
// 检查文件是否在存储目录内(防止路径遍历)
Path storageDir = Paths.get(storagePath).toAbsolutePath().normalize();
Path resolvedPath = filePath.toAbsolutePath().normalize();
if (!resolvedPath.startsWith(storageDir)) {
log.warn("非法的文件路径访问: {}", resolvedPath);
return ResponseEntity.badRequest().build();
}
Resource resource = new FileSystemResource(file);
// 判断文件类型
String contentType = Files.probeContentType(filePath);
if (contentType == null) {
// 如果无法探测默认使用image/jpeg
contentType = MediaType.IMAGE_JPEG_VALUE;
}
return ResponseEntity.ok()
.contentType(MediaType.parseMediaType(contentType))
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + filename + "\"")
.body(resource);
} catch (Exception e) {
log.error("获取图片失败: {}", filename, e);
return ResponseEntity.internalServerError().build();
}
}
}

View File

@@ -2,6 +2,7 @@ package cn.van.business.controller.jd;
import cn.van.business.model.pl.TaobaoComment;
import cn.van.business.repository.TaobaoCommentRepository;
import cn.van.business.service.ImageConvertService;
import cn.van.business.util.JDProductService;
import cn.van.business.util.JDScheduleJob;
import cn.van.business.util.JDUtil;
@@ -36,14 +37,16 @@ public class JDInnerController {
private final JDScheduleJob jdScheduleJob;
private final CommentRepository commentRepository;
private final TaobaoCommentRepository taobaoCommentRepository;
private final ImageConvertService imageConvertService;
@Autowired
public JDInnerController(JDProductService jdProductService, JDUtil jdUtil, JDScheduleJob jdScheduleJob, CommentRepository commentRepository, TaobaoCommentRepository taobaoCommentRepository) {
public JDInnerController(JDProductService jdProductService, JDUtil jdUtil, JDScheduleJob jdScheduleJob, CommentRepository commentRepository, TaobaoCommentRepository taobaoCommentRepository, ImageConvertService imageConvertService) {
this.jdProductService = jdProductService;
this.jdUtil = jdUtil;
this.jdScheduleJob = jdScheduleJob;
this.commentRepository = commentRepository;
this.taobaoCommentRepository = taobaoCommentRepository;
this.imageConvertService = imageConvertService;
}
@PostMapping("/generatePromotionContent")
@@ -192,7 +195,10 @@ public class JDInnerController {
JSONObject item = new JSONObject();
item.put("commentText", commentToUse.getCommentText());
item.put("images", parsePictureUrls(commentToUse.getPictureUrls()));
// 解析图片URL并转换webp格式为jpg
List<String> imageUrls = parsePictureUrls(commentToUse.getPictureUrls());
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
item.put("images", convertedImageUrls);
JSONArray arr = new JSONArray();
arr.add(item);

View File

@@ -0,0 +1,67 @@
package cn.van.business.model.pl;
import jakarta.persistence.*;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 图片转换记录实体类
* 用于记录webp格式图片转换为jpg格式的映射关系
*
* @author System
* @version 1.0
*/
@Entity
@Table(name = "image_conversions", indexes = {
@Index(name = "idx_original_url", columnList = "originalUrl"),
@Index(name = "idx_converted_url", columnList = "convertedUrl")
})
@Data
public class ImageConversion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 原始webp图片URL
*/
@Column(name = "original_url", nullable = false, length = 2048, unique = true)
private String originalUrl;
/**
* 转换后的jpg图片URL或本地路径
*/
@Column(name = "converted_url", nullable = false, length = 2048)
private String convertedUrl;
/**
* 转换时间
*/
@Column(name = "converted_at", nullable = false)
private LocalDateTime convertedAt;
/**
* 文件大小(字节)
*/
@Column(name = "file_size")
private Long fileSize;
/**
* 创建时间
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
if (convertedAt == null) {
convertedAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,33 @@
package cn.van.business.repository;
import cn.van.business.model.pl.ImageConversion;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 图片转换记录Repository
*
* @author System
*/
@Repository
public interface ImageConversionRepository extends JpaRepository<ImageConversion, Long> {
/**
* 根据原始URL查找转换记录
*
* @param originalUrl 原始webp图片URL
* @return 转换记录
*/
Optional<ImageConversion> findByOriginalUrl(String originalUrl);
/**
* 检查是否已存在转换记录
*
* @param originalUrl 原始webp图片URL
* @return 是否存在
*/
boolean existsByOriginalUrl(String originalUrl);
}

View File

@@ -0,0 +1,334 @@
package cn.van.business.service;
import cn.van.business.model.pl.ImageConversion;
import cn.van.business.repository.ImageConversionRepository;
import cn.hutool.core.io.FileUtil;
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 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 initialized = false;
/**
* 初始化WebP支持
* webp-imageio库在类加载时会通过ServiceLoader自动注册到ImageIO
*/
static synchronized void init() {
if (initialized) {
return;
}
// 检查是否支持webp格式
try {
Iterator<ImageReader> readers = ImageIO.getImageReadersByFormatName("webp");
if (readers.hasNext()) {
log.debug("WebP图片格式支持已启用");
} else {
log.warn("未检测到WebP图片格式支持webp转换可能失败。请确认webp-imageio依赖已正确加载");
}
} catch (Exception e) {
log.warn("检查WebP支持时出错: {}", e.getMessage());
}
initialized = true;
}
}
/**
* 图片转换服务类
* 负责将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<String> convertImageUrls(List<String> imageUrls) {
if (imageUrls == null || imageUrls.isEmpty()) {
return Collections.emptyList();
}
List<String> convertedUrls = new ArrayList<>();
for (String imageUrl : imageUrls) {
if (StrUtil.isBlank(imageUrl)) {
continue;
}
try {
String convertedUrl = convertImageUrl(imageUrl);
convertedUrls.add(convertedUrl);
} catch (Exception e) {
log.warn("图片转换失败使用原URL: {}, 错误: {}", imageUrl, e.getMessage());
// 转换失败时使用原URL
convertedUrls.add(imageUrl);
}
}
return convertedUrls;
}
/**
* 转换单个图片URL
*
* @param originalUrl 原始图片URL
* @return 转换后的图片URL如果不需要转换则返回原URL
*/
private String convertImageUrl(String originalUrl) {
if (StrUtil.isBlank(originalUrl)) {
return originalUrl;
}
// 检查是否为webp格式
if (!isWebpFormat(originalUrl)) {
return originalUrl;
}
// 检查是否已转换
Optional<ImageConversion> existing = imageConversionRepository.findByOriginalUrl(originalUrl);
if (existing.isPresent()) {
ImageConversion conversion = existing.get();
log.debug("使用缓存的转换结果: {} -> {}", originalUrl, conversion.getConvertedUrl());
return conversion.getConvertedUrl();
}
// 执行转换
try {
String convertedUrl = performConversion(originalUrl);
log.info("图片转换成功: {} -> {}", originalUrl, convertedUrl);
return convertedUrl;
} catch (Exception e) {
log.error("图片转换失败: {}, 错误: {}", originalUrl, e.getMessage(), e);
throw e;
}
}
/**
* 检查URL是否为webp格式
*
* @param url 图片URL
* @return 是否为webp格式
*/
private boolean isWebpFormat(String url) {
if (StrUtil.isBlank(url)) {
return false;
}
// 检查URL中是否包含.webp
String lowerUrl = url.toLowerCase();
// 检查URL参数或路径中是否包含webp
return lowerUrl.contains(".webp") ||
lowerUrl.contains("format=webp") ||
lowerUrl.contains("?webp") ||
lowerUrl.contains("&webp");
}
/**
* 执行图片转换
*
* @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支持
WebPImageIO.init();
// 尝试使用ImageIO读取
BufferedImage tempImage = null;
try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) {
tempImage = ImageIO.read(bais);
}
if (tempImage != null) {
bufferedImage = tempImage;
} else {
// 如果ImageIO无法读取尝试使用WebP特定的读取器
Iterator<ImageReader> 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();
}
}
if (bufferedImage == null) {
throw new IOException("无法读取webp图片格式请确认已添加webp-imageio依赖");
}
}
// 如果是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.core.util.HashUtil.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());
// 不抛出异常,因为转换本身已成功
}
}
}

View File

@@ -88,3 +88,14 @@ resilience4j.ratelimiter:
timeoutDuration: 0 # 立即失败模式
registerHealthIndicator: true
# 图片转换配置
image:
convert:
# 图片存储路径转换后的jpg图片存储目录
storage-path: ${user.home}/comment-images
# 图片访问基础URL如果配置转换后的图片将通过此URL访问
# 例如: http://your-domain.com/images 或 http://localhost:6666/images
# 如果为空,则返回本地文件路径
# 建议配置为http://your-domain:6666/images 使用ImageController提供HTTP访问
base-url: