1
This commit is contained in:
170
doc/图片转换功能说明.md
Normal file
170
doc/图片转换功能说明.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# 评论图片WebP转JPG功能说明
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
评论模块中的图片如果是webp格式,会自动转换为jpg格式。转换后的图片会被缓存,下次不需要再次转换。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
1. **自动检测**:自动检测图片URL中的webp格式
|
||||||
|
2. **格式转换**:将webp格式图片转换为jpg格式
|
||||||
|
3. **结果缓存**:转换结果存储在数据库中,避免重复转换
|
||||||
|
4. **文件存储**:转换后的jpg图片保存到本地目录
|
||||||
|
5. **HTTP访问**:通过ImageController提供HTTP访问接口
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
在 `application.yml` 中配置:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image:
|
||||||
|
convert:
|
||||||
|
# 图片存储路径(转换后的jpg图片存储目录)
|
||||||
|
storage-path: ${user.home}/comment-images
|
||||||
|
# 图片访问基础URL(如果配置,转换后的图片将通过此URL访问)
|
||||||
|
# 例如: http://your-domain.com/images 或 http://localhost:6666/images
|
||||||
|
# 如果为空,则返回本地文件路径
|
||||||
|
base-url: http://localhost:6666/images
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置项说明
|
||||||
|
|
||||||
|
- **storage-path**:转换后的jpg图片存储目录,默认使用 `${user.home}/comment-images`
|
||||||
|
- **base-url**:图片访问的基础URL,如果配置,转换后的图片URL将使用此地址;如果不配置,则返回本地文件路径
|
||||||
|
|
||||||
|
## 数据库表
|
||||||
|
|
||||||
|
需要执行以下SQL创建图片转换记录表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 执行 sql/image_conversions.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
表结构:
|
||||||
|
- `id`:主键ID
|
||||||
|
- `original_url`:原始webp图片URL(唯一索引)
|
||||||
|
- `converted_url`:转换后的jpg图片URL或路径
|
||||||
|
- `converted_at`:转换时间
|
||||||
|
- `file_size`:文件大小(字节)
|
||||||
|
- `created_at`:创建时间
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
1. **图片URL解析**:从评论数据中解析出图片URL列表
|
||||||
|
2. **格式检测**:检查URL中是否包含webp格式标识
|
||||||
|
3. **缓存查询**:查询数据库,检查是否已转换过
|
||||||
|
4. **格式转换**(如果未转换过):
|
||||||
|
- 下载原始webp图片
|
||||||
|
- 使用webp-imageio库读取webp格式
|
||||||
|
- 转换为BufferedImage
|
||||||
|
- 保存为jpg格式到本地目录
|
||||||
|
- 保存转换记录到数据库
|
||||||
|
5. **返回结果**:返回转换后的图片URL(或原URL,如果不是webp)
|
||||||
|
|
||||||
|
## 依赖库
|
||||||
|
|
||||||
|
项目已添加以下依赖:
|
||||||
|
|
||||||
|
1. **Thumbnailator** (0.4.20):图片处理库
|
||||||
|
2. **webp-imageio** (0.1.0):WebP格式支持库
|
||||||
|
|
||||||
|
## API接口
|
||||||
|
|
||||||
|
### 图片访问接口
|
||||||
|
|
||||||
|
**GET** `/images/{filename}`
|
||||||
|
|
||||||
|
访问转换后的jpg图片文件。
|
||||||
|
|
||||||
|
**参数:**
|
||||||
|
- `filename`:文件名(通常是MD5值.jpg)
|
||||||
|
|
||||||
|
**返回:**
|
||||||
|
- 成功:图片文件(Content-Type: image/jpeg)
|
||||||
|
- 失败:404 Not Found 或 400 Bad Request
|
||||||
|
|
||||||
|
**安全特性:**
|
||||||
|
- 防止路径遍历攻击
|
||||||
|
- 文件路径验证
|
||||||
|
- 仅允许访问存储目录内的文件
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 前端调用
|
||||||
|
|
||||||
|
评论生成接口返回的图片URL列表已经过转换处理:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"commentText": "评论内容",
|
||||||
|
"images": [
|
||||||
|
"http://localhost:6666/images/abc123.jpg",
|
||||||
|
"http://localhost:6666/images/def456.jpg"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 后端调用
|
||||||
|
|
||||||
|
图片转换服务会自动集成到评论生成流程中:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 在 JDInnerController.commentGenerate 方法中
|
||||||
|
List<String> imageUrls = parsePictureUrls(commentToUse.getPictureUrls());
|
||||||
|
List<String> convertedImageUrls = imageConvertService.convertImageUrls(imageUrls);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **首次转换**:首次转换webp图片时,需要下载图片并转换,可能耗时较长
|
||||||
|
2. **存储空间**:转换后的jpg图片会占用磁盘空间,建议定期清理旧文件
|
||||||
|
3. **网络依赖**:转换过程需要下载原始图片,确保网络连接正常
|
||||||
|
4. **WebP支持**:确保webp-imageio库正确加载,否则转换会失败
|
||||||
|
5. **并发处理**:多个请求同时转换同一张图片时,可能会导致重复转换(建议后续优化为加锁机制)
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 问题1:转换失败,提示"无法读取webp图片格式"
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
1. 确认 `webp-imageio` 依赖已正确添加到pom.xml
|
||||||
|
2. 确认依赖已成功下载(检查Maven本地仓库)
|
||||||
|
3. 检查日志中的WebP支持检测信息
|
||||||
|
|
||||||
|
### 问题2:图片无法访问(404)
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
1. 检查 `storage-path` 配置是否正确
|
||||||
|
2. 确认图片文件已成功转换并保存
|
||||||
|
3. 检查文件权限
|
||||||
|
4. 如果配置了 `base-url`,确认URL是否正确
|
||||||
|
|
||||||
|
### 问题3:转换后的图片URL是本地路径
|
||||||
|
|
||||||
|
**解决方案:**
|
||||||
|
在 `application.yml` 中配置 `base-url`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
image:
|
||||||
|
convert:
|
||||||
|
base-url: http://your-domain:6666/images
|
||||||
|
```
|
||||||
|
|
||||||
|
## 性能优化建议
|
||||||
|
|
||||||
|
1. **异步转换**:对于大量图片,可以考虑异步转换
|
||||||
|
2. **CDN加速**:将转换后的图片上传到CDN,提供更快的访问速度
|
||||||
|
3. **定期清理**:定期清理长时间未使用的转换图片
|
||||||
|
4. **缓存预热**:提前转换常用的图片
|
||||||
|
|
||||||
|
## 后续优化
|
||||||
|
|
||||||
|
1. 添加转换任务队列,支持批量转换
|
||||||
|
2. 添加转换状态监控和统计
|
||||||
|
3. 支持其他图片格式转换(如png、gif等)
|
||||||
|
4. 添加图片压缩功能,减小文件大小
|
||||||
|
|
||||||
12
pom.xml
12
pom.xml
@@ -113,6 +113,18 @@
|
|||||||
<artifactId>httpclient</artifactId>
|
<artifactId>httpclient</artifactId>
|
||||||
<version>4.5.13</version>
|
<version>4.5.13</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<!-- 图片处理库 Thumbnailator -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.coobird</groupId>
|
||||||
|
<artifactId>thumbnailator</artifactId>
|
||||||
|
<version>0.4.20</version>
|
||||||
|
</dependency>
|
||||||
|
<!-- WebP图片格式支持 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.gotson</groupId>
|
||||||
|
<artifactId>webp-imageio</artifactId>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
18
sql/image_conversions.sql
Normal file
18
sql/image_conversions.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- 图片转换记录表
|
||||||
|
-- 用于存储webp格式图片转换为jpg格式的映射关系
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `image_conversions` (
|
||||||
|
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||||
|
`original_url` VARCHAR(2048) NOT NULL COMMENT '原始webp图片URL',
|
||||||
|
`converted_url` VARCHAR(2048) NOT NULL COMMENT '转换后的jpg图片URL或本地路径',
|
||||||
|
`converted_at` DATETIME NOT NULL COMMENT '转换时间',
|
||||||
|
`file_size` BIGINT(20) DEFAULT NULL COMMENT '文件大小(字节)',
|
||||||
|
`created_at` DATETIME NOT NULL COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_original_url` (`original_url`(255)),
|
||||||
|
KEY `idx_original_url` (`original_url`(255)),
|
||||||
|
KEY `idx_converted_url` (`converted_url`(255)),
|
||||||
|
KEY `idx_converted_at` (`converted_at`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='图片转换记录表';
|
||||||
|
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package cn.van.business.controller.jd;
|
|||||||
|
|
||||||
import cn.van.business.model.pl.TaobaoComment;
|
import cn.van.business.model.pl.TaobaoComment;
|
||||||
import cn.van.business.repository.TaobaoCommentRepository;
|
import cn.van.business.repository.TaobaoCommentRepository;
|
||||||
|
import cn.van.business.service.ImageConvertService;
|
||||||
import cn.van.business.util.JDProductService;
|
import cn.van.business.util.JDProductService;
|
||||||
import cn.van.business.util.JDScheduleJob;
|
import cn.van.business.util.JDScheduleJob;
|
||||||
import cn.van.business.util.JDUtil;
|
import cn.van.business.util.JDUtil;
|
||||||
@@ -36,14 +37,16 @@ public class JDInnerController {
|
|||||||
private final JDScheduleJob jdScheduleJob;
|
private final JDScheduleJob jdScheduleJob;
|
||||||
private final CommentRepository commentRepository;
|
private final CommentRepository commentRepository;
|
||||||
private final TaobaoCommentRepository taobaoCommentRepository;
|
private final TaobaoCommentRepository taobaoCommentRepository;
|
||||||
|
private final ImageConvertService imageConvertService;
|
||||||
|
|
||||||
@Autowired
|
@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.jdProductService = jdProductService;
|
||||||
this.jdUtil = jdUtil;
|
this.jdUtil = jdUtil;
|
||||||
this.jdScheduleJob = jdScheduleJob;
|
this.jdScheduleJob = jdScheduleJob;
|
||||||
this.commentRepository = commentRepository;
|
this.commentRepository = commentRepository;
|
||||||
this.taobaoCommentRepository = taobaoCommentRepository;
|
this.taobaoCommentRepository = taobaoCommentRepository;
|
||||||
|
this.imageConvertService = imageConvertService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/generatePromotionContent")
|
@PostMapping("/generatePromotionContent")
|
||||||
@@ -192,7 +195,10 @@ public class JDInnerController {
|
|||||||
|
|
||||||
JSONObject item = new JSONObject();
|
JSONObject item = new JSONObject();
|
||||||
item.put("commentText", commentToUse.getCommentText());
|
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();
|
JSONArray arr = new JSONArray();
|
||||||
arr.add(item);
|
arr.add(item);
|
||||||
|
|||||||
67
src/main/java/cn/van/business/model/pl/ImageConversion.java
Normal file
67
src/main/java/cn/van/business/model/pl/ImageConversion.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
334
src/main/java/cn/van/business/service/ImageConvertService.java
Normal file
334
src/main/java/cn/van/business/service/ImageConvertService.java
Normal 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());
|
||||||
|
// 不抛出异常,因为转换本身已成功
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -88,3 +88,14 @@ resilience4j.ratelimiter:
|
|||||||
timeoutDuration: 0 # 立即失败模式
|
timeoutDuration: 0 # 立即失败模式
|
||||||
registerHealthIndicator: true
|
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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user