You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2191 lines
96 KiB
2191 lines
96 KiB
diff --git a/Dockerfile b/Dockerfile
|
|
new file mode 100644
|
|
index 0000000..899b5f5
|
|
--- /dev/null
|
|
+++ b/Dockerfile
|
|
@@ -0,0 +1,24 @@
|
|
+# crm-backend 部署镜像(含缩略图 Office 渲染所需的 LibreOffice headless)
|
|
+# 构建阶段:Maven 编译打包
|
|
+FROM maven:3.9-eclipse-temurin-17 AS build
|
|
+WORKDIR /build
|
|
+COPY pom.xml .
|
|
+COPY crm-base/pom.xml crm-base/
|
|
+COPY crm-auth/pom.xml crm-auth/
|
|
+COPY crm-file/pom.xml crm-file/
|
|
+COPY crm-app/pom.xml crm-app/
|
|
+# 先拉依赖(利用 Docker 层缓存)
|
|
+RUN mvn dependency:go-offline -q || true
|
|
+COPY . .
|
|
+RUN mvn package -DskipTests -q
|
|
+
|
|
+# 运行阶段:JRE 17 + LibreOffice headless(缩略图 Office 渲染)
|
|
+FROM eclipse-temurin:17-jre
|
|
+# LibreOffice headless 及中文渲染所需字体(--no-install-recommends 控制体积)
|
|
+RUN apt-get update \
|
|
+ && apt-get install -y --no-install-recommends libreoffice fonts-noto-cjk \
|
|
+ && rm -rf /var/lib/apt/lists/*
|
|
+WORKDIR /app
|
|
+COPY --from=build /build/crm-app/target/crm-app*.jar app.jar
|
|
+ENV JAVA_OPTS=""
|
|
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
|
|
diff --git a/crm-file/CONTEXT.md b/crm-file/CONTEXT.md
|
|
index 3f1b91c..6b438ec 100644
|
|
--- a/crm-file/CONTEXT.md
|
|
+++ b/crm-file/CONTEXT.md
|
|
@@ -51,3 +51,31 @@ _Avoid_: 预览链接、kk 地址
|
|
**回源**:
|
|
kkFileView 服务端凭短时效凭证从 MinIO 拉取原始文件用于格式转换的动作。浏览器不直接接触 MinIO。
|
|
_Avoid_: 代理下载、中转
|
|
+
|
|
+**缩略图**:
|
|
+从原文件提取或渲染出的小尺寸静态图片(固定宽度 200px,等比缩放,上限 400px 高,JPEG 格式),用于前端文件列表优先展示以降低弱网加载延迟。点击后再请求原图或完整预览。
|
|
+_Avoid_: 小图、预览图、封面图
|
|
+
|
|
+**缩略图状态**:
|
|
+一个文件缩略图的生命周期阶段:`PENDING`(已入队待生成)、`READY`(已生成可返回)、`FAILED`(重试 3 次后放弃但可由定时任务重试)、`UNSUPPORTED`(文件类型不在支持范围内,永不触发生成)。
|
|
+_Avoid_: 缩略图进度、生成标志
|
|
+
|
|
+**缩略图生成**:
|
|
+将原文件转换为缩略图的动作。图片走 Java 图像缩放;PDF 走 PDFBox 渲染第一页;Office 走 LibreOffice headless 渲染第一页。支持图片(jpg/png/webp/gif/bmp)、PDF、Office(docx/xlsx/pptx),其余类型前置标记 `UNSUPPORTED` 跳过。
|
|
+_Avoid_: 缩略、截图
|
|
+
|
|
+**异步生成**:
|
|
+文件上传完成后由后台任务触发的缩略图生成。与首次请求同步兜底并行,两路通过分布式锁防止重复生成。
|
|
+_Avoid_: 后台截图、延迟生成
|
|
+
|
|
+**同步兜底**:
|
|
+缩略图首次被请求时若后台任务尚未完成,请求线程同步触发生成并等待返回的策略。缩略图不变,后续请求走 MinIO 缓存。
|
|
+_Avoid_: 实时生成、即时渲染
|
|
+
|
|
+**占位图**:
|
|
+缩略图处于 `PENDING`、`FAILED` 或 `UNSUPPORTED` 状态时返回给前端的文件类型图标静态图。前端无感知,始终拿到一张图。
|
|
+_Avoid_: 默认图、fallback图
|
|
+
|
|
+**缩略图对象**:
|
|
+缩略图在 MinIO 中的对象,objectKey 为 `thumbnails/{fileId}.jpg`,与原文件同 bucket。通过后端 API 中转返回,设置 `Cache-Control: max-age=86400` 强缓存头,浏览器不直接接触 MinIO。
|
|
+_Avoid_: 缩略图文件、thumb对象
|
|
diff --git a/crm-file/pom.xml b/crm-file/pom.xml
|
|
index 29e9c63..ddafc8b 100644
|
|
--- a/crm-file/pom.xml
|
|
+++ b/crm-file/pom.xml
|
|
@@ -28,6 +28,12 @@
|
|
<artifactId>minio</artifactId>
|
|
</dependency>
|
|
|
|
+ <!-- PDF 渲染(缩略图第一页) -->
|
|
+ <dependency>
|
|
+ <groupId>org.apache.pdfbox</groupId>
|
|
+ <artifactId>pdfbox</artifactId>
|
|
+ </dependency>
|
|
+
|
|
<dependency>
|
|
<groupId>org.projectlombok</groupId>
|
|
<artifactId>lombok</artifactId>
|
|
diff --git a/crm-file/src/main/java/com/crm/file/api/FileApi.java b/crm-file/src/main/java/com/crm/file/api/FileApi.java
|
|
index 1543634..be9fe3f 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/api/FileApi.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/api/FileApi.java
|
|
@@ -3,6 +3,7 @@ package com.crm.file.api;
|
|
import com.crm.file.domain.dto.FileDownloadDTO;
|
|
import com.crm.file.domain.dto.FileInfoDTO;
|
|
import com.crm.file.domain.dto.MultipartInitDTO;
|
|
+import com.crm.file.domain.dto.ThumbnailDTO;
|
|
|
|
import java.io.InputStream;
|
|
|
|
@@ -69,6 +70,18 @@ public interface FileApi {
|
|
*/
|
|
String getPreviewUrl(String fileId);
|
|
|
|
+ /**
|
|
+ * 获取缩略图(含同步兜底逻辑)
|
|
+ * <p>状态 READY:从 MinIO 拉 thumbnails/{fileId}.jpg 返回二进制 + cacheable=true;
|
|
+ * 状态 UNSUPPORTED/FAILED:返回占位图 + cacheable=false;
|
|
+ * 状态 PENDING:获锁同步生成后重查状态,READY 返回真缩略图;获锁失败则轮询等待,
|
|
+ * 超 sync-wait-timeout 仍 PENDING 返回占位图 + cacheable=false + statusCode=202</p>
|
|
+ *
|
|
+ * @param fileId 文件 ID(字符串),查无抛资源不存在(40401)
|
|
+ * @return 缩略图二进制 + 内容类型 + 缓存策略标记 + HTTP 状态码
|
|
+ */
|
|
+ ThumbnailDTO getThumbnail(String fileId);
|
|
+
|
|
/*-------- 分片上传三段式(init → upload → complete,ADR-0004)--------*/
|
|
|
|
/**
|
|
diff --git a/crm-file/src/main/java/com/crm/file/config/FileProperties.java b/crm-file/src/main/java/com/crm/file/config/FileProperties.java
|
|
index 7e9fb5f..9756934 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/config/FileProperties.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/config/FileProperties.java
|
|
@@ -56,6 +56,8 @@ public class FileProperties {
|
|
private List<String> extBlacklist = List.of(
|
|
"exe", "dll", "bat", "cmd", "sh", "ps1", "jsp", "jspx", "php", "asp", "aspx");
|
|
|
|
+ private Thumbnail thumbnail = new Thumbnail();
|
|
+
|
|
@Data
|
|
public static class Minio {
|
|
|
|
@@ -80,4 +82,35 @@ public class FileProperties {
|
|
/** 预览回源 presigned GET 的有效期 */
|
|
private Duration presignTtl = Duration.ofMinutes(10);
|
|
}
|
|
+
|
|
+ @Data
|
|
+ public static class Thumbnail {
|
|
+
|
|
+ /** 缩略图固定宽度(px),等比缩放 */
|
|
+ private int width = 200;
|
|
+
|
|
+ /** 缩略图等比缩放上限高度(px),防止超长截图 */
|
|
+ private int maxHeight = 400;
|
|
+
|
|
+ /** 输出格式(jpeg/png) */
|
|
+ private String format = "jpeg";
|
|
+
|
|
+ /** 同步兜底等待超时:PENDING 时获锁失败后的轮询上限 */
|
|
+ private Duration syncWaitTimeout = Duration.ofSeconds(10);
|
|
+
|
|
+ /** 最大重试次数,达到后标记 FAILED */
|
|
+ private int retryLimit = 3;
|
|
+
|
|
+ /** FAILED 重试扫描周期(cron),默认每天凌晨 4 点 */
|
|
+ private String retryCron = "0 0 4 * * ?";
|
|
+
|
|
+ /** LibreOffice 可执行文件路径 */
|
|
+ private String libreofficePath = "soffice";
|
|
+
|
|
+ /** LibreOffice 单次转换超时(大文档首页渲染可能较慢) */
|
|
+ private Duration officeConvertTimeout = Duration.ofSeconds(120);
|
|
+
|
|
+ /** 生成分布式锁 TTL:必须大于 officeConvertTimeout,否则慢渲染期间锁过期导致重复生成 */
|
|
+ private Duration lockTtl = Duration.ofSeconds(150);
|
|
+ }
|
|
}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java b/crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java
|
|
index a87c005..3de0474 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/config/SchedulingConfig.java
|
|
@@ -1,13 +1,16 @@
|
|
package com.crm.file.config;
|
|
|
|
import org.springframework.context.annotation.Configuration;
|
|
+import org.springframework.scheduling.annotation.EnableAsync;
|
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
|
|
|
/**
|
|
- * 定时任务开关(项目此前无调度体系,随文件模块引入)
|
|
- * <p>当前唯一任务:孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask}</p>
|
|
+ * 定时任务与异步任务开关(项目此前无调度体系,随文件模块引入)
|
|
+ * <p>定时任务:孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask};
|
|
+ * 异步任务:缩略图生成 {@link com.crm.file.task.ThumbnailGenerationTask}</p>
|
|
*/
|
|
@Configuration
|
|
@EnableScheduling
|
|
+@EnableAsync
|
|
public class SchedulingConfig {
|
|
}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/constant/FileConstants.java b/crm-file/src/main/java/com/crm/file/constant/FileConstants.java
|
|
index 63eee76..c609163 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/constant/FileConstants.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/constant/FileConstants.java
|
|
@@ -11,6 +11,12 @@ public interface FileConstants {
|
|
/** 临时分片在 MinIO 中的对象前缀,完整 key:chunks/{uploadId}/{分片序号} */
|
|
String CHUNK_PREFIX = "chunks/";
|
|
|
|
+ /** 缩略图在 MinIO 中的对象前缀,完整 key:thumbnails/{fileId}.jpg */
|
|
+ String THUMBNAIL_PREFIX = "thumbnails/";
|
|
+
|
|
+ /** 缩略图生成分布式锁在 Redis 中的 key 前缀,完整 key:crm:file:thumbnail:lock:{fileId} */
|
|
+ String THUMBNAIL_LOCK_PREFIX = "crm:file:thumbnail:lock:";
|
|
+
|
|
/*-------- 文件模块业务错误码(62xxx,登记于根 README)--------*/
|
|
|
|
/** 文件大小超限(直传超上限 / 总大小超单文件上限) */
|
|
@@ -30,4 +36,20 @@ public interface FileConstants {
|
|
|
|
/** 分片大小配置低于 5MB 硬下限(配置期防御,非用户操作错误) */
|
|
int CODE_CHUNK_SIZE_MISCONFIGURED = 62006;
|
|
+
|
|
+ /** 缩略图生成失败(渲染引擎异常) */
|
|
+ int CODE_THUMBNAIL_GENERATION_FAILED = 62007;
|
|
+
|
|
+ /** 缩略图同步等待超时(异步任务未完成且同步兜底超时) */
|
|
+ int CODE_THUMBNAIL_SYNC_TIMEOUT = 62008;
|
|
+
|
|
+ /** HTTP 202 Accepted:缩略图仍在生成,前端应稍后重试(信封例外路径专用) */
|
|
+ int CODE_HTTP_ACCEPTED = 202;
|
|
+
|
|
+ /*-------- 缩略图状态常量 --------*/
|
|
+
|
|
+ String THUMBNAIL_STATUS_PENDING = "PENDING";
|
|
+ String THUMBNAIL_STATUS_READY = "READY";
|
|
+ String THUMBNAIL_STATUS_FAILED = "FAILED";
|
|
+ String THUMBNAIL_STATUS_UNSUPPORTED = "UNSUPPORTED";
|
|
}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/controller/FileController.java b/crm-file/src/main/java/com/crm/file/controller/FileController.java
|
|
index a6bb57e..b7e12e7 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/controller/FileController.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/controller/FileController.java
|
|
@@ -5,6 +5,7 @@ import com.crm.file.api.FileApi;
|
|
import com.crm.file.domain.dto.FileDownloadDTO;
|
|
import com.crm.file.domain.dto.FileInfoDTO;
|
|
import com.crm.file.domain.dto.MultipartInitDTO;
|
|
+import com.crm.file.domain.dto.ThumbnailDTO;
|
|
import io.swagger.v3.oas.annotations.Operation;
|
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
import lombok.RequiredArgsConstructor;
|
|
@@ -76,6 +77,20 @@ public class FileController {
|
|
return Result.success(fileApi.getPreviewUrl(fileId));
|
|
}
|
|
|
|
+ @Operation(summary = "缩略图(信封例外:成功返回二进制图片流,失败 JSON 信封)")
|
|
+ @GetMapping("/thumbnail")
|
|
+ public ResponseEntity<byte[]> thumbnail(@RequestParam("fileId") String fileId) {
|
|
+ ThumbnailDTO thumb = fileApi.getThumbnail(fileId);
|
|
+ String cacheControl = thumb.isCacheable()
|
|
+ ? "max-age=86400"
|
|
+ : "no-cache";
|
|
+ return ResponseEntity.status(thumb.getStatusCode())
|
|
+ .header(HttpHeaders.CACHE_CONTROL, cacheControl)
|
|
+ .contentType(MediaType.parseMediaType(thumb.getContentType()))
|
|
+ .contentLength(thumb.getContent().length)
|
|
+ .body(thumb.getContent());
|
|
+ }
|
|
+
|
|
@Operation(summary = "删除(逻辑删除)")
|
|
@PostMapping("/delete")
|
|
public Result<Void> delete(@RequestParam("fileId") String fileId) {
|
|
diff --git a/crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java b/crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java
|
|
index b202393..b7b20c5 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/domain/dto/FileInfoDTO.java
|
|
@@ -29,6 +29,9 @@ public class FileInfoDTO {
|
|
|
|
private LocalDateTime createTime;
|
|
|
|
+ /** 缩略图状态(PENDING/READY/FAILED/UNSUPPORTED),nullable 向后兼容 */
|
|
+ private String thumbnailStatus;
|
|
+
|
|
public static FileInfoDTO from(FileInfo entity) {
|
|
FileInfoDTO dto = new FileInfoDTO();
|
|
dto.setFileId(String.valueOf(entity.getId()));
|
|
@@ -38,6 +41,7 @@ public class FileInfoDTO {
|
|
dto.setBizDomain(entity.getBizDomain());
|
|
dto.setCreatorId(entity.getCreatorId());
|
|
dto.setCreateTime(entity.getCreateTime());
|
|
+ dto.setThumbnailStatus(entity.getThumbnailStatus());
|
|
return dto;
|
|
}
|
|
}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/domain/dto/ThumbnailDTO.java b/crm-file/src/main/java/com/crm/file/domain/dto/ThumbnailDTO.java
|
|
new file mode 100644
|
|
index 0000000..109899c
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/domain/dto/ThumbnailDTO.java
|
|
@@ -0,0 +1,27 @@
|
|
+package com.crm.file.domain.dto;
|
|
+
|
|
+import lombok.AllArgsConstructor;
|
|
+import lombok.Builder;
|
|
+import lombok.Data;
|
|
+
|
|
+/**
|
|
+ * 缩略图返回结果(二进制 + 元数据 + 缓存策略标记)
|
|
+ * <p>cacheable=true 时 Controller 设 Cache-Control: max-age=86400;false 时设 no-cache</p>
|
|
+ */
|
|
+@Data
|
|
+@Builder
|
|
+public class ThumbnailDTO {
|
|
+
|
|
+ /** 缩略图/占位图二进制内容 */
|
|
+ private byte[] content;
|
|
+
|
|
+ /** 内容类型(真缩略图 image/jpeg,占位图 image/png) */
|
|
+ private String contentType;
|
|
+
|
|
+ /** 是否可强缓存:READY=true(max-age=86400),其余=false(no-cache) */
|
|
+ private boolean cacheable;
|
|
+
|
|
+ /** HTTP 状态码:正常 200;同步等待超时(仍 PENDING)返回 202 提示前端稍后重试 */
|
|
+ @Builder.Default
|
|
+ private int statusCode = 200;
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java b/crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java
|
|
index 650eabc..b61764e 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/domain/entity/FileInfo.java
|
|
@@ -50,4 +50,12 @@ public class FileInfo extends BaseEntity {
|
|
@Comment("整文件 hash(秒传占位字段,一期不写入)")
|
|
@Column(name = "file_hash", columnDefinition = "varchar(64) comment '整文件hash(秒传占位)'")
|
|
private String fileHash;
|
|
+
|
|
+ @Comment("缩略图状态:PENDING(待生成)/READY(已生成)/FAILED(重试耗尽)/UNSUPPORTED(类型不支持)")
|
|
+ @Column(name = "thumbnail_status", columnDefinition = "varchar(16) not null default 'PENDING' comment '缩略图状态'")
|
|
+ private String thumbnailStatus = "PENDING";
|
|
+
|
|
+ @Comment("缩略图生成失败重试计数,成功后归零")
|
|
+ @Column(name = "thumbnail_retry_count", columnDefinition = "int not null default 0 comment '缩略图重试计数'")
|
|
+ private Integer thumbnailRetryCount = 0;
|
|
}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/ThumbnailPlaceholderService.java b/crm-file/src/main/java/com/crm/file/service/ThumbnailPlaceholderService.java
|
|
new file mode 100644
|
|
index 0000000..b6b0a37
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/ThumbnailPlaceholderService.java
|
|
@@ -0,0 +1,55 @@
|
|
+package com.crm.file.service;
|
|
+
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.*;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.ByteArrayOutputStream;
|
|
+import java.io.IOException;
|
|
+import java.util.Locale;
|
|
+
|
|
+/**
|
|
+ * 缩略图占位图服务:按文件扩展名动态生成文件类型图标占位图(PNG)
|
|
+ * <p>200x200 灰底,居中显示扩展名文字;非视觉文件、未就绪、生成失败时返回</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+public class ThumbnailPlaceholderService {
|
|
+
|
|
+ private static final int SIZE = 200;
|
|
+ private static final Color BG = new Color(240, 240, 240);
|
|
+ private static final Color TEXT = new Color(120, 120, 120);
|
|
+
|
|
+ /**
|
|
+ * 按扩展名生成占位图;无扩展名或异常时回退通用占位图
|
|
+ */
|
|
+ public byte[] getPlaceholder(String ext) {
|
|
+ String label = (ext == null || ext.isBlank()) ? "FILE" : ext.toUpperCase(Locale.ROOT);
|
|
+ if (label.length() > 8) {
|
|
+ label = label.substring(0, 8);
|
|
+ }
|
|
+ BufferedImage img = new BufferedImage(SIZE, SIZE, BufferedImage.TYPE_INT_RGB);
|
|
+ Graphics2D g = img.createGraphics();
|
|
+ try {
|
|
+ g.setColor(BG);
|
|
+ g.fillRect(0, 0, SIZE, SIZE);
|
|
+ g.setColor(TEXT);
|
|
+ g.setFont(new Font("SansSerif", Font.BOLD, 28));
|
|
+ FontMetrics fm = g.getFontMetrics();
|
|
+ int x = (SIZE - fm.stringWidth(label)) / 2;
|
|
+ int y = (SIZE - fm.getHeight()) / 2 + fm.getAscent();
|
|
+ g.drawString(label, x, y);
|
|
+ } finally {
|
|
+ g.dispose();
|
|
+ }
|
|
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
+ ImageIO.write(img, "png", out);
|
|
+ return out.toByteArray();
|
|
+ } catch (IOException e) {
|
|
+ log.error("占位图生成失败,ext={}", ext, e);
|
|
+ return new byte[0];
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/ThumbnailRenderer.java b/crm-file/src/main/java/com/crm/file/service/ThumbnailRenderer.java
|
|
new file mode 100644
|
|
index 0000000..1842325
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/ThumbnailRenderer.java
|
|
@@ -0,0 +1,30 @@
|
|
+package com.crm.file.service;
|
|
+
|
|
+import java.io.InputStream;
|
|
+
|
|
+/**
|
|
+ * 缩略图渲染器接口:将原始文件渲染为缩略图字节数组
|
|
+ * <p>每种文件类型一个实现(图片/PDF/Office),通过 {@link #supports} 声明自己处理的类型</p>
|
|
+ */
|
|
+public interface ThumbnailRenderer {
|
|
+
|
|
+ /**
|
|
+ * 判断此渲染器是否支持指定的文件类型
|
|
+ *
|
|
+ * @param contentType MIME 类型(可空)
|
|
+ * @param ext 小写扩展名(不带点,可空)
|
|
+ * @return true 表示此渲染器可以处理该文件类型
|
|
+ */
|
|
+ boolean supports(String contentType, String ext);
|
|
+
|
|
+ /**
|
|
+ * 将原始文件流渲染为缩略图字节数组
|
|
+ *
|
|
+ * @param original 原始文件输入流,由调用方负责关闭
|
|
+ * @param ext 小写扩展名(不带点)
|
|
+ * @param width 缩略图固定宽度(px)
|
|
+ * @param maxHeight 等比缩放上限高度(px)
|
|
+ * @return 缩略图字节数组(JPEG 格式)
|
|
+ */
|
|
+ byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception;
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java b/crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java
|
|
index d98d01c..7e2353d 100644
|
|
--- a/crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/impl/FileApiImpl.java
|
|
@@ -11,10 +11,13 @@ import com.crm.file.constant.FileConstants;
|
|
import com.crm.file.domain.dto.FileDownloadDTO;
|
|
import com.crm.file.domain.dto.FileInfoDTO;
|
|
import com.crm.file.domain.dto.MultipartInitDTO;
|
|
+import com.crm.file.domain.dto.ThumbnailDTO;
|
|
import com.crm.file.domain.dto.UploadSession;
|
|
import com.crm.file.domain.entity.FileInfo;
|
|
import com.crm.file.service.IFileInfoService;
|
|
import com.crm.file.service.KkFileViewClient;
|
|
+import com.crm.file.service.ThumbnailPlaceholderService;
|
|
+import com.crm.file.task.ThumbnailGenerationTask;
|
|
import io.minio.ComposeObjectArgs;
|
|
import io.minio.ComposeSource;
|
|
import io.minio.GetObjectArgs;
|
|
@@ -56,6 +59,9 @@ public class FileApiImpl implements FileApi {
|
|
|
|
private static final String DEFAULT_CONTENT_TYPE = "application/octet-stream";
|
|
|
|
+ /** 缩略图同步兜底轮询间隔(获锁失败时等待异步任务完成) */
|
|
+ private static final long THUMBNAIL_POLL_INTERVAL_MS = 500L;
|
|
+
|
|
/** 分片大小硬下限:composeObject 对除末片外分片有 5MB 最小值约束,配置低于此值合并必败 */
|
|
private static final long CHUNK_SIZE_FLOOR = 5L * 1024 * 1024;
|
|
|
|
@@ -65,6 +71,8 @@ public class FileApiImpl implements FileApi {
|
|
private final IdentifierGenerator identifierGenerator;
|
|
private final RedisTemplate<String, Object> redisTemplate;
|
|
private final KkFileViewClient kkFileViewClient;
|
|
+ private final ThumbnailPlaceholderService thumbnailPlaceholderService;
|
|
+ private final ThumbnailGenerationTask thumbnailGenerationTask;
|
|
|
|
@Override
|
|
public FileInfoDTO upload(InputStream inputStream, long size, String originalName,
|
|
@@ -103,7 +111,18 @@ public class FileApiImpl implements FileApi {
|
|
info.setFileSize(size);
|
|
info.setContentType(StringUtils.hasText(contentType) ? contentType : DEFAULT_CONTENT_TYPE);
|
|
info.setBizDomain(bizDomain);
|
|
+ // 缩略图状态:支持类型 PENDING(异步生成),不支持类型 UNSUPPORTED
|
|
+ String effectiveCt = StringUtils.hasText(contentType) ? contentType : DEFAULT_CONTENT_TYPE;
|
|
+ if (thumbnailGenerationTask.isSupported(effectiveCt, extOf(originalName))) {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ } else {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ }
|
|
fileInfoService.save(info);
|
|
+ // 触发异步缩略图生成(不阻塞上传链路)
|
|
+ if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(info.getThumbnailStatus())) {
|
|
+ thumbnailGenerationTask.generate(fileId);
|
|
+ }
|
|
return FileInfoDTO.from(info);
|
|
}
|
|
|
|
@@ -180,6 +199,105 @@ public class FileApiImpl implements FileApi {
|
|
return kkFileViewClient.buildPreviewUrl(presignedUrl);
|
|
}
|
|
|
|
+ @Override
|
|
+ public ThumbnailDTO getThumbnail(String fileId) {
|
|
+ FileInfo info = requireFileInfo(fileId);
|
|
+ String status = info.getThumbnailStatus();
|
|
+
|
|
+ if (FileConstants.THUMBNAIL_STATUS_READY.equals(status)) {
|
|
+ ThumbnailDTO dto = readThumbnail(info.getId());
|
|
+ if (dto != null) {
|
|
+ return dto;
|
|
+ }
|
|
+ } else if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(status)) {
|
|
+ return handlePending(info);
|
|
+ }
|
|
+
|
|
+ // UNSUPPORTED / FAILED / READY读取失败 → 占位图
|
|
+ return placeholderOf(info, 200);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * PENDING 同步兜底:先尝试获锁同步生成;获锁失败(异步任务在跑)则轮询等待
|
|
+ */
|
|
+ private ThumbnailDTO handlePending(FileInfo info) {
|
|
+ if (thumbnailGenerationTask.tryGenerateSync(info.getId())) {
|
|
+ // 获锁成功且生成逻辑已跑完,重查状态定结果
|
|
+ return toThumbnailAfterGeneration(info);
|
|
+ }
|
|
+ // 获锁失败 → 每 POLL_INTERVAL 轮询状态,超 sync-wait-timeout 返回占位图 + 202
|
|
+ long deadline = System.currentTimeMillis()
|
|
+ + fileProperties.getThumbnail().getSyncWaitTimeout().toMillis();
|
|
+ while (true) {
|
|
+ FileInfo fresh = fileInfoService.getById(info.getId());
|
|
+ if (fresh != null && FileConstants.THUMBNAIL_STATUS_READY.equals(fresh.getThumbnailStatus())) {
|
|
+ ThumbnailDTO dto = readThumbnail(fresh.getId());
|
|
+ if (dto != null) {
|
|
+ return dto;
|
|
+ }
|
|
+ return placeholderOf(info, 200);
|
|
+ }
|
|
+ if (fresh == null || !FileConstants.THUMBNAIL_STATUS_PENDING.equals(fresh.getThumbnailStatus())) {
|
|
+ // 终态非 READY(FAILED/UNSUPPORTED/删除)不再等待
|
|
+ return placeholderOf(info, 200);
|
|
+ }
|
|
+ if (System.currentTimeMillis() >= deadline) {
|
|
+ log.info("缩略图同步等待超时,返回占位图 + 202,fileId={}", info.getId());
|
|
+ return placeholderOf(info, FileConstants.CODE_HTTP_ACCEPTED);
|
|
+ }
|
|
+ try {
|
|
+ Thread.sleep(THUMBNAIL_POLL_INTERVAL_MS);
|
|
+ } catch (InterruptedException e) {
|
|
+ Thread.currentThread().interrupt();
|
|
+ return placeholderOf(info, 200);
|
|
+ }
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 同步生成后按最新状态返回:READY → 真缩略图;否则占位图
|
|
+ */
|
|
+ private ThumbnailDTO toThumbnailAfterGeneration(FileInfo info) {
|
|
+ FileInfo fresh = fileInfoService.getById(info.getId());
|
|
+ if (fresh != null && FileConstants.THUMBNAIL_STATUS_READY.equals(fresh.getThumbnailStatus())) {
|
|
+ ThumbnailDTO dto = readThumbnail(fresh.getId());
|
|
+ if (dto != null) {
|
|
+ return dto;
|
|
+ }
|
|
+ }
|
|
+ return placeholderOf(info, 200);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 从 MinIO 读缩略图对象;读取失败返回 null 由调用方回退占位图
|
|
+ */
|
|
+ private ThumbnailDTO readThumbnail(Long id) {
|
|
+ String thumbKey = FileConstants.THUMBNAIL_PREFIX + id + ".jpg";
|
|
+ try (InputStream in = minioClient.getObject(GetObjectArgs.builder()
|
|
+ .bucket(fileProperties.getMinio().getBucket())
|
|
+ .object(thumbKey)
|
|
+ .build())) {
|
|
+ return ThumbnailDTO.builder()
|
|
+ .content(in.readAllBytes())
|
|
+ .contentType("image/jpeg")
|
|
+ .cacheable(true)
|
|
+ .build();
|
|
+ } catch (Exception e) {
|
|
+ log.error("缩略图读取失败,objectKey={},回退占位图", thumbKey, e);
|
|
+ return null;
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private ThumbnailDTO placeholderOf(FileInfo info, int statusCode) {
|
|
+ byte[] placeholder = thumbnailPlaceholderService.getPlaceholder(extOf(info.getOriginalName()));
|
|
+ return ThumbnailDTO.builder()
|
|
+ .content(placeholder)
|
|
+ .contentType("image/png")
|
|
+ .cacheable(false)
|
|
+ .statusCode(statusCode)
|
|
+ .build();
|
|
+ }
|
|
+
|
|
/*-------- 分片上传三段式 --------*/
|
|
|
|
@Override
|
|
@@ -289,7 +407,17 @@ public class FileApiImpl implements FileApi {
|
|
info.setBizDomain(session.getBizDomain());
|
|
// 发起人以 init 时记录为准(strictInsertFill 不覆盖非 null 值)
|
|
info.setCreatorId(session.getCreatorId());
|
|
+ // 缩略图状态:支持类型 PENDING(异步生成),不支持类型 UNSUPPORTED
|
|
+ if (thumbnailGenerationTask.isSupported(info.getContentType(), extOf(session.getOriginalName()))) {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ } else {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ }
|
|
fileInfoService.save(info);
|
|
+ // 触发异步缩略图生成(不阻塞合并链路)
|
|
+ if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(info.getThumbnailStatus())) {
|
|
+ thumbnailGenerationTask.generate(fileId);
|
|
+ }
|
|
|
|
// 成功路径自行清理临时分片与会话;清理失败不影响结果,孤儿分片清理任务兜底
|
|
cleanupChunks(uploadId, session.getTotalChunks());
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/impl/ImageThumbnailRenderer.java b/crm-file/src/main/java/com/crm/file/service/impl/ImageThumbnailRenderer.java
|
|
new file mode 100644
|
|
index 0000000..c6fbd91
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/impl/ImageThumbnailRenderer.java
|
|
@@ -0,0 +1,56 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import com.crm.file.service.ThumbnailRenderer;
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.InputStream;
|
|
+import java.util.Locale;
|
|
+import java.util.Set;
|
|
+
|
|
+/**
|
|
+ * 图片缩略图渲染器:Java ImageIO 读取 → 等比缩放 → JPEG 输出
|
|
+ * <p>支持 jpg/jpeg/png/gif/bmp;webp 需运行时 ImageIO 有对应 reader(插件),
|
|
+ * 不可用时 supports 返回 false 落 UNSUPPORTED,避免生成必然失败被定时任务无限重试</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+public class ImageThumbnailRenderer implements ThumbnailRenderer {
|
|
+
|
|
+ private static final Set<String> SUPPORTED_EXTS = Set.of("jpg", "jpeg", "png", "gif", "bmp");
|
|
+
|
|
+ /** webp 解码能力探测:JDK 内置 ImageIO 无 webp reader,需额外插件才可用 */
|
|
+ private static final boolean WEBP_AVAILABLE = ImageIO.getImageReadersByFormatName("webp").hasNext();
|
|
+
|
|
+ @Override
|
|
+ public boolean supports(String contentType, String ext) {
|
|
+ if (ext != null) {
|
|
+ String e = ext.toLowerCase(Locale.ROOT);
|
|
+ if (SUPPORTED_EXTS.contains(e)) {
|
|
+ return true;
|
|
+ }
|
|
+ if ("webp".equals(e)) {
|
|
+ return WEBP_AVAILABLE;
|
|
+ }
|
|
+ }
|
|
+ if (contentType != null) {
|
|
+ String ct = contentType.toLowerCase(Locale.ROOT);
|
|
+ if (ct.contains("webp")) {
|
|
+ return WEBP_AVAILABLE;
|
|
+ }
|
|
+ return ct.startsWith("image/") && !ct.contains("svg") && !ct.contains("tiff");
|
|
+ }
|
|
+ return false;
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
|
|
+ BufferedImage src = ImageIO.read(original);
|
|
+ if (src == null) {
|
|
+ throw new IllegalStateException("ImageIO 无法解码图片,可能格式不支持");
|
|
+ }
|
|
+ return ThumbnailImageUtils.scaleToJpeg(src, width, maxHeight);
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/impl/OfficeThumbnailRenderer.java b/crm-file/src/main/java/com/crm/file/service/impl/OfficeThumbnailRenderer.java
|
|
new file mode 100644
|
|
index 0000000..3fbd0fb
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/impl/OfficeThumbnailRenderer.java
|
|
@@ -0,0 +1,161 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import com.crm.file.config.FileProperties;
|
|
+import com.crm.file.service.ThumbnailRenderer;
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.IOException;
|
|
+import java.io.InputStream;
|
|
+import java.nio.file.DirectoryStream;
|
|
+import java.nio.file.Files;
|
|
+import java.nio.file.Path;
|
|
+import java.util.ArrayList;
|
|
+import java.util.List;
|
|
+import java.util.Locale;
|
|
+import java.util.Set;
|
|
+import java.util.UUID;
|
|
+import java.util.concurrent.TimeUnit;
|
|
+
|
|
+/**
|
|
+ * Office 缩略图渲染器:LibreOffice headless 将文档转 PNG(第一页)→ 等比缩放 → JPEG 输出
|
|
+ * <p>每次调用使用唯一 UUID 的 UserInstallation profile,避免并发冲突;
|
|
+ * 临时文件(原文件落地 + 转换输出 + profile)在 finally 中清理</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+public class OfficeThumbnailRenderer implements ThumbnailRenderer {
|
|
+
|
|
+ private static final Set<String> SUPPORTED_EXTS = Set.of("docx", "xlsx", "pptx", "doc", "xls", "ppt");
|
|
+
|
|
+ private static final Set<String> OFFICE_CONTENT_PREFIXES = Set.of(
|
|
+ "application/vnd.openxmlformats-officedocument",
|
|
+ "application/msword",
|
|
+ "application/vnd.ms-excel",
|
|
+ "application/vnd.ms-powerpoint");
|
|
+
|
|
+ private final FileProperties fileProperties;
|
|
+
|
|
+ public OfficeThumbnailRenderer(FileProperties fileProperties) {
|
|
+ this.fileProperties = fileProperties;
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ public boolean supports(String contentType, String ext) {
|
|
+ if (ext != null && SUPPORTED_EXTS.contains(ext.toLowerCase(Locale.ROOT))) {
|
|
+ return true;
|
|
+ }
|
|
+ if (contentType != null) {
|
|
+ String ct = contentType.toLowerCase(Locale.ROOT).trim();
|
|
+ return OFFICE_CONTENT_PREFIXES.stream().anyMatch(ct::startsWith);
|
|
+ }
|
|
+ return false;
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
|
|
+ Path workDir = Files.createTempDirectory("crm-thumb-office-");
|
|
+ Path profileDir = null;
|
|
+ try {
|
|
+ // 原文件落地(LibreOffice 只接受文件路径;保留扩展名供其识别格式)
|
|
+ String safeExt = (ext == null || ext.isBlank()) ? "docx" : ext.toLowerCase(Locale.ROOT);
|
|
+ Path inputFile = workDir.resolve("input." + safeExt);
|
|
+ Files.copy(original, inputFile);
|
|
+
|
|
+ Path outDir = Files.createDirectories(workDir.resolve("out"));
|
|
+ profileDir = Files.createDirectories(workDir.resolve("lo-" + UUID.randomUUID()));
|
|
+
|
|
+ runConversion(workDir, inputFile, outDir, profileDir);
|
|
+
|
|
+ Path png = findConvertedPng(outDir);
|
|
+ BufferedImage pageImage = ImageIO.read(png.toFile());
|
|
+ if (pageImage == null) {
|
|
+ throw new IllegalStateException("LibreOffice 输出 PNG 无法解码:" + png.getFileName());
|
|
+ }
|
|
+ return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
|
|
+ } finally {
|
|
+ deleteRecursively(workDir);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 调 soffice --convert-to png;profile 隔离 + 超时保护
|
|
+ */
|
|
+ private void runConversion(Path workDir, Path inputFile, Path outDir, Path profileDir) throws IOException, InterruptedException {
|
|
+ String soffice = fileProperties.getThumbnail().getLibreofficePath();
|
|
+ long timeoutSeconds = fileProperties.getThumbnail().getOfficeConvertTimeout().toSeconds();
|
|
+
|
|
+ List<String> command = new ArrayList<>();
|
|
+ // Windows 下 .cmd/.bat 需经 cmd /c 解释(本机测试场景;Docker/Linux 走 else 分支)
|
|
+ if (isWindowsScript(soffice)) {
|
|
+ command.add("cmd");
|
|
+ command.add("/c");
|
|
+ }
|
|
+ command.add(soffice);
|
|
+ command.add("--headless");
|
|
+ command.add("--norestore");
|
|
+ command.add("-env:UserInstallation=" + profileDir.toUri());
|
|
+ command.add("--convert-to");
|
|
+ command.add("png");
|
|
+ command.add("--outdir");
|
|
+ command.add(outDir.toString());
|
|
+ command.add(inputFile.toString());
|
|
+
|
|
+ Process process = new ProcessBuilder(command)
|
|
+ .redirectErrorStream(true)
|
|
+ // 输出落文件而非同步 readAllBytes:进程挂死不关 stdout 时,readAllBytes 会永久阻塞导致超时保护失效
|
|
+ .redirectOutput(workDir.resolve("soffice.log").toFile())
|
|
+ .start();
|
|
+ boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
|
|
+ if (!finished) {
|
|
+ process.destroyForcibly();
|
|
+ // 等待真正终止,避免与 finally 的临时目录清理竞争
|
|
+ process.waitFor();
|
|
+ throw new IllegalStateException("LibreOffice 转换超时(" + timeoutSeconds + "s)");
|
|
+ }
|
|
+ int exit = process.exitValue();
|
|
+ if (exit != 0) {
|
|
+ String output = Files.exists(workDir.resolve("soffice.log"))
|
|
+ ? Files.readString(workDir.resolve("soffice.log"))
|
|
+ : "";
|
|
+ log.warn("LibreOffice 转换失败,exit={},output={}", exit, output);
|
|
+ throw new IllegalStateException("LibreOffice 转换失败,exit=" + exit);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private boolean isWindowsScript(String executable) {
|
|
+ String lower = executable.toLowerCase(Locale.ROOT);
|
|
+ return lower.endsWith(".cmd") || lower.endsWith(".bat");
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * LibreOffice 输出文件名为 {输入文件主名}.png,容错扫描 outDir 下第一个 PNG
|
|
+ */
|
|
+ private Path findConvertedPng(Path outDir) throws IOException {
|
|
+ try (DirectoryStream<Path> stream = Files.newDirectoryStream(outDir, "*.png")) {
|
|
+ for (Path p : stream) {
|
|
+ return p;
|
|
+ }
|
|
+ }
|
|
+ throw new IllegalStateException("LibreOffice 未产出 PNG 输出");
|
|
+ }
|
|
+
|
|
+ private void deleteRecursively(Path root) {
|
|
+ if (root == null || !Files.exists(root)) {
|
|
+ return;
|
|
+ }
|
|
+ try (var walk = Files.walk(root)) {
|
|
+ walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
|
|
+ try {
|
|
+ Files.deleteIfExists(p);
|
|
+ } catch (IOException ignored) {
|
|
+ // 临时文件清理失败不影响主流程,由系统临时目录兜底
|
|
+ }
|
|
+ });
|
|
+ } catch (IOException e) {
|
|
+ log.warn("Office 缩略图临时目录清理失败:{}", root, e);
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/impl/PdfThumbnailRenderer.java b/crm-file/src/main/java/com/crm/file/service/impl/PdfThumbnailRenderer.java
|
|
new file mode 100644
|
|
index 0000000..bef065c
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/impl/PdfThumbnailRenderer.java
|
|
@@ -0,0 +1,54 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import com.crm.file.service.ThumbnailRenderer;
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.apache.pdfbox.Loader;
|
|
+import org.apache.pdfbox.pdmodel.PDDocument;
|
|
+import org.apache.pdfbox.pdmodel.PDPage;
|
|
+import org.apache.pdfbox.rendering.ImageType;
|
|
+import org.apache.pdfbox.rendering.PDFRenderer;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.InputStream;
|
|
+import java.util.Locale;
|
|
+import java.util.Set;
|
|
+
|
|
+/**
|
|
+ * PDF 缩略图渲染器:PDFBox 渲染第一页为图 → 等比缩放 → JPEG 输出
|
|
+ * <p>DPI 150 兼顾清晰度与性能;仅渲染第一页</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+public class PdfThumbnailRenderer implements ThumbnailRenderer {
|
|
+
|
|
+ /** 渲染 DPI:150 在 200px 缩略图宽度下清晰度足够,且渲染耗时可控 */
|
|
+ private static final float RENDER_DPI = 150f;
|
|
+
|
|
+ private static final Set<String> SUPPORTED_EXTS = Set.of("pdf");
|
|
+
|
|
+ @Override
|
|
+ public boolean supports(String contentType, String ext) {
|
|
+ if (ext != null && SUPPORTED_EXTS.contains(ext.toLowerCase(Locale.ROOT))) {
|
|
+ return true;
|
|
+ }
|
|
+ return contentType != null && "application/pdf".equalsIgnoreCase(contentType.trim());
|
|
+ }
|
|
+
|
|
+ @Override
|
|
+ public byte[] render(InputStream original, String ext, int width, int maxHeight) throws Exception {
|
|
+ byte[] pdfBytes = original.readAllBytes();
|
|
+ try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
|
+ if (doc.getNumberOfPages() == 0) {
|
|
+ throw new IllegalStateException("PDF 无页面内容");
|
|
+ }
|
|
+ // 空白页(无媒体盒)渲染会失败,提前抛出可读异常
|
|
+ PDPage firstPage = doc.getPage(0);
|
|
+ if (firstPage.getMediaBox() == null) {
|
|
+ throw new IllegalStateException("PDF 第一页缺少 MediaBox,无法渲染");
|
|
+ }
|
|
+ BufferedImage pageImage = new PDFRenderer(doc).renderImageWithDPI(0, RENDER_DPI, ImageType.RGB);
|
|
+ return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/service/impl/ThumbnailImageUtils.java b/crm-file/src/main/java/com/crm/file/service/impl/ThumbnailImageUtils.java
|
|
new file mode 100644
|
|
index 0000000..df1ec55
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/service/impl/ThumbnailImageUtils.java
|
|
@@ -0,0 +1,47 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.*;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.ByteArrayOutputStream;
|
|
+
|
|
+/**
|
|
+ * 缩略图共用图像处理工具:等比缩放 + JPEG 输出
|
|
+ * <p>图片/PDF/Office 渲染器将源图(BufferedImage)转缩略图字节的共用路径</p>
|
|
+ */
|
|
+final class ThumbnailImageUtils {
|
|
+
|
|
+ private ThumbnailImageUtils() {
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 等比缩放至固定宽度与上限高度两个约束内(不变形),输出 JPEG 字节数组
|
|
+ *
|
|
+ * @param src 源图(渲染器产出,可能远大于缩略图尺寸)
|
|
+ * @param width 缩略图目标宽度(px)
|
|
+ * @param maxHeight 等比缩放上限高度(px)
|
|
+ * @return JPEG 编码的缩略图字节数组
|
|
+ */
|
|
+ static byte[] scaleToJpeg(BufferedImage src, int width, int maxHeight) throws Exception {
|
|
+ int srcW = src.getWidth();
|
|
+ int srcH = src.getHeight();
|
|
+ // 等比缩放:固定宽度与上限高度两个约束取较小比例,保证不变形
|
|
+ double scale = Math.min((double) width / srcW, (double) maxHeight / srcH);
|
|
+ int dstW = Math.max((int) Math.round(srcW * scale), 1);
|
|
+ int dstH = Math.max((int) Math.round(srcH * scale), 1);
|
|
+
|
|
+ BufferedImage dst = new BufferedImage(dstW, dstH, BufferedImage.TYPE_INT_RGB);
|
|
+ Graphics2D g = dst.createGraphics();
|
|
+ try {
|
|
+ g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
|
|
+ g.drawImage(src, 0, 0, dstW, dstH, null);
|
|
+ } finally {
|
|
+ g.dispose();
|
|
+ }
|
|
+
|
|
+ try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
|
+ ImageIO.write(dst, "jpg", out);
|
|
+ return out.toByteArray();
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/task/ThumbnailGenerationTask.java b/crm-file/src/main/java/com/crm/file/task/ThumbnailGenerationTask.java
|
|
new file mode 100644
|
|
index 0000000..c7828d3
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/task/ThumbnailGenerationTask.java
|
|
@@ -0,0 +1,177 @@
|
|
+package com.crm.file.task;
|
|
+
|
|
+import com.crm.file.config.FileProperties;
|
|
+import com.crm.file.constant.FileConstants;
|
|
+import com.crm.file.domain.entity.FileInfo;
|
|
+import com.crm.file.service.IFileInfoService;
|
|
+import com.crm.file.service.ThumbnailRenderer;
|
|
+import io.minio.GetObjectArgs;
|
|
+import io.minio.MinioClient;
|
|
+import io.minio.PutObjectArgs;
|
|
+import lombok.RequiredArgsConstructor;
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
+import org.springframework.scheduling.annotation.Async;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import java.io.ByteArrayInputStream;
|
|
+import java.io.InputStream;
|
|
+import java.time.Duration;
|
|
+import java.util.List;
|
|
+
|
|
+/**
|
|
+ * 缩略图异步生成任务
|
|
+ * <p>上传完成后由 {@link com.crm.file.service.impl.FileApiImpl} 提交;
|
|
+ * 从 MinIO 拉原文件 → 路由到对应 {@link ThumbnailRenderer} → 缩略图写回 MinIO → 状态置 READY</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+@RequiredArgsConstructor
|
|
+public class ThumbnailGenerationTask {
|
|
+
|
|
+ private final MinioClient minioClient;
|
|
+ private final FileProperties fileProperties;
|
|
+ private final IFileInfoService fileInfoService;
|
|
+ private final List<ThumbnailRenderer> renderers;
|
|
+ private final RedisTemplate<String, Object> redisTemplate;
|
|
+
|
|
+ /**
|
|
+ * 判断文件类型是否支持缩略图生成(上传时调用,决定 PENDING 还是 UNSUPPORTED)
|
|
+ */
|
|
+ public boolean isSupported(String contentType, String ext) {
|
|
+ if (renderers == null || renderers.isEmpty()) {
|
|
+ return false;
|
|
+ }
|
|
+ return renderers.stream().anyMatch(r -> r.supports(contentType, ext));
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 异步生成缩略图(@Async 保证不阻塞上传链路)
|
|
+ *
|
|
+ * @param fileId 文件主键
|
|
+ */
|
|
+ @Async
|
|
+ public void generate(Long fileId) {
|
|
+ generateWithLock(fileId);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 同步兜底:尝试获锁后立即生成(首次请求缩略图时异步任务未完成走此路径)
|
|
+ *
|
|
+ * @param fileId 文件主键
|
|
+ * @return true 表示获锁成功且生成逻辑已执行完毕(最终状态由调用方重查);
|
|
+ * false 表示获锁失败(另有生成正在进行,调用方应轮询等待)
|
|
+ */
|
|
+ public boolean tryGenerateSync(Long fileId) {
|
|
+ return generateWithLock(fileId);
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 分布式锁保护下执行生成:异步任务与同步兜底共用,保证并发只生成一次
|
|
+ */
|
|
+ private boolean generateWithLock(Long fileId) {
|
|
+ String lockKey = FileConstants.THUMBNAIL_LOCK_PREFIX + fileId;
|
|
+ // 锁 TTL 须大于最慢渲染(officeConvertTimeout),否则慢渲染期间锁过期导致重复生成
|
|
+ Duration lockTtl = fileProperties.getThumbnail().getLockTtl();
|
|
+ Boolean acquired;
|
|
+ try {
|
|
+ acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", lockTtl);
|
|
+ } catch (Exception e) {
|
|
+ log.warn("缩略图锁获取异常,跳过本次生成,fileId={}", fileId, e);
|
|
+ return false;
|
|
+ }
|
|
+ if (!Boolean.TRUE.equals(acquired)) {
|
|
+ return false;
|
|
+ }
|
|
+ try {
|
|
+ generateSync(fileId);
|
|
+ return true;
|
|
+ } finally {
|
|
+ redisTemplate.delete(lockKey);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ /**
|
|
+ * 核心生成逻辑(无锁,由 {@link #generateWithLock} 保护)
|
|
+ */
|
|
+ void generateSync(Long fileId) {
|
|
+ FileInfo info = fileInfoService.getById(fileId);
|
|
+ if (info == null) {
|
|
+ log.warn("缩略图生成跳过:文件不存在,fileId={}", fileId);
|
|
+ return;
|
|
+ }
|
|
+ // 已处理过的不重复生成
|
|
+ String status = info.getThumbnailStatus();
|
|
+ if (!FileConstants.THUMBNAIL_STATUS_PENDING.equals(status)) {
|
|
+ log.debug("缩略图生成跳过:状态={},fileId={}", status, fileId);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ String ext = extOf(info.getOriginalName());
|
|
+ ThumbnailRenderer renderer = findRenderer(info.getContentType(), ext);
|
|
+ if (renderer == null) {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ fileInfoService.updateById(info);
|
|
+ log.info("缩略图标记 UNSUPPORTED:fileId={}, ext={}", fileId, ext);
|
|
+ return;
|
|
+ }
|
|
+
|
|
+ String thumbKey = FileConstants.THUMBNAIL_PREFIX + fileId + ".jpg";
|
|
+ try (InputStream original = minioClient.getObject(GetObjectArgs.builder()
|
|
+ .bucket(fileProperties.getMinio().getBucket())
|
|
+ .object(info.getObjectKey())
|
|
+ .build())) {
|
|
+
|
|
+ byte[] thumbnail = renderer.render(original, ext,
|
|
+ fileProperties.getThumbnail().getWidth(),
|
|
+ fileProperties.getThumbnail().getMaxHeight());
|
|
+
|
|
+ minioClient.putObject(PutObjectArgs.builder()
|
|
+ .bucket(fileProperties.getMinio().getBucket())
|
|
+ .object(thumbKey)
|
|
+ .stream(new ByteArrayInputStream(thumbnail), thumbnail.length, -1)
|
|
+ .contentType("image/jpeg")
|
|
+ .build());
|
|
+
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ info.setThumbnailRetryCount(0);
|
|
+ fileInfoService.updateById(info);
|
|
+ log.info("缩略图生成成功:fileId={}, objectKey={}", fileId, thumbKey);
|
|
+
|
|
+ } catch (Exception e) {
|
|
+ log.error("缩略图生成失败:fileId={}, thumbKey={}", fileId, thumbKey, e);
|
|
+ // 失败重试语义(票07):retry_count++,未耗尽保持 PENDING 等下次触发,耗尽置 FAILED
|
|
+ int retryCount = info.getThumbnailRetryCount() == null ? 0 : info.getThumbnailRetryCount();
|
|
+ retryCount++;
|
|
+ info.setThumbnailRetryCount(retryCount);
|
|
+ if (retryCount >= fileProperties.getThumbnail().getRetryLimit()) {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
|
|
+ log.warn("缩略图重试次数耗尽,标记 FAILED:fileId={}, retryCount={}", fileId, retryCount);
|
|
+ } else {
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ }
|
|
+ fileInfoService.updateById(info);
|
|
+ }
|
|
+ }
|
|
+
|
|
+ private ThumbnailRenderer findRenderer(String contentType, String ext) {
|
|
+ if (renderers == null) {
|
|
+ return null;
|
|
+ }
|
|
+ return renderers.stream()
|
|
+ .filter(r -> r.supports(contentType, ext))
|
|
+ .findFirst()
|
|
+ .orElse(null);
|
|
+ }
|
|
+
|
|
+ private String extOf(String originalName) {
|
|
+ if (originalName == null) {
|
|
+ return "";
|
|
+ }
|
|
+ int dot = originalName.lastIndexOf('.');
|
|
+ if (dot < 0 || dot == originalName.length() - 1) {
|
|
+ return "";
|
|
+ }
|
|
+ return originalName.substring(dot + 1).toLowerCase();
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/main/java/com/crm/file/task/ThumbnailRetryTask.java b/crm-file/src/main/java/com/crm/file/task/ThumbnailRetryTask.java
|
|
new file mode 100644
|
|
index 0000000..cdd0bdd
|
|
--- /dev/null
|
|
+++ b/crm-file/src/main/java/com/crm/file/task/ThumbnailRetryTask.java
|
|
@@ -0,0 +1,48 @@
|
|
+package com.crm.file.task;
|
|
+
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
+import com.crm.file.constant.FileConstants;
|
|
+import com.crm.file.domain.entity.FileInfo;
|
|
+import com.crm.file.service.IFileInfoService;
|
|
+import lombok.RequiredArgsConstructor;
|
|
+import lombok.extern.slf4j.Slf4j;
|
|
+import org.springframework.scheduling.annotation.Scheduled;
|
|
+import org.springframework.stereotype.Service;
|
|
+
|
|
+import java.util.List;
|
|
+
|
|
+/**
|
|
+ * 缩略图失败重试定时任务
|
|
+ * <p>扫描 thumbnail_status = FAILED 的记录,重置为 PENDING 并重新提交异步生成;
|
|
+ * LibreOffice 等渲染引擎临时故障恢复后,下个周期自动补齐缩略图。
|
|
+ * 任务自身异常不致命:try-catch 包裹记日志,下个周期继续(复用孤儿分片清理任务模式)</p>
|
|
+ */
|
|
+@Slf4j
|
|
+@Service
|
|
+@RequiredArgsConstructor
|
|
+public class ThumbnailRetryTask {
|
|
+
|
|
+ private final IFileInfoService fileInfoService;
|
|
+ private final ThumbnailGenerationTask thumbnailGenerationTask;
|
|
+
|
|
+ @Scheduled(cron = "${crm.file.thumbnail.retry-cron:0 0 4 * * ?}")
|
|
+ public void retryFailedThumbnails() {
|
|
+ try {
|
|
+ List<FileInfo> failed = fileInfoService.list(new LambdaQueryWrapper<FileInfo>()
|
|
+ .eq(FileInfo::getThumbnailStatus, FileConstants.THUMBNAIL_STATUS_FAILED));
|
|
+ if (failed.isEmpty()) {
|
|
+ return;
|
|
+ }
|
|
+ for (FileInfo info : failed) {
|
|
+ // 重置 PENDING + 重试计数归零(重新获得完整重试额度)后提交异步生成
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ info.setThumbnailRetryCount(0);
|
|
+ fileInfoService.updateById(info);
|
|
+ thumbnailGenerationTask.generate(info.getId());
|
|
+ }
|
|
+ log.info("缩略图失败重试:已重新提交 {} 条 FAILED 记录", failed.size());
|
|
+ } catch (Exception e) {
|
|
+ log.error("缩略图失败重试任务执行异常,下个周期继续", e);
|
|
+ }
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java b/crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java
|
|
index 19336cb..f2133dd 100644
|
|
--- a/crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java
|
|
+++ b/crm-file/src/test/java/com/crm/file/service/impl/FileApiImplTest.java
|
|
@@ -10,10 +10,13 @@ import com.crm.file.constant.FileConstants;
|
|
import com.crm.file.domain.dto.FileDownloadDTO;
|
|
import com.crm.file.domain.dto.FileInfoDTO;
|
|
import com.crm.file.domain.dto.MultipartInitDTO;
|
|
+import com.crm.file.domain.dto.ThumbnailDTO;
|
|
import com.crm.file.domain.dto.UploadSession;
|
|
import com.crm.file.domain.entity.FileInfo;
|
|
import com.crm.file.service.IFileInfoService;
|
|
import com.crm.file.service.KkFileViewClient;
|
|
+import com.crm.file.service.ThumbnailPlaceholderService;
|
|
+import com.crm.file.task.ThumbnailGenerationTask;
|
|
import io.minio.ComposeObjectArgs;
|
|
import io.minio.GetObjectArgs;
|
|
import io.minio.GetObjectResponse;
|
|
@@ -76,6 +79,10 @@ class FileApiImplTest {
|
|
private ValueOperations<String, Object> valueOperations;
|
|
@Mock
|
|
private KkFileViewClient kkFileViewClient;
|
|
+ @Mock
|
|
+ private ThumbnailPlaceholderService thumbnailPlaceholderService;
|
|
+ @Mock
|
|
+ private ThumbnailGenerationTask thumbnailGenerationTask;
|
|
|
|
private FileProperties fileProperties;
|
|
|
|
@@ -87,7 +94,7 @@ class FileApiImplTest {
|
|
fileProperties = new FileProperties();
|
|
fileProperties.getMinio().setBucket("crm");
|
|
fileApi = new FileApiImpl(minioClient, fileProperties, fileInfoService, identifierGenerator,
|
|
- redisTemplate, kkFileViewClient);
|
|
+ redisTemplate, kkFileViewClient, thumbnailPlaceholderService, thumbnailGenerationTask);
|
|
}
|
|
|
|
private InputStream streamOf(String content) {
|
|
@@ -228,6 +235,84 @@ class FileApiImplTest {
|
|
e -> assertThat(e.getCode()).isEqualTo(FileConstants.CODE_FILE_SIZE_EXCEEDED));
|
|
}
|
|
|
|
+ /*-------- 缩略图触发:上传后 PENDING/UNSUPPORTED --------*/
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("上传图片 -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
|
|
+ void upload_image_triggersThumbnailGeneration() throws Exception {
|
|
+ when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
|
|
+ when(thumbnailGenerationTask.isSupported("image/png", "png")).thenReturn(true);
|
|
+
|
|
+ fileApi.upload(streamOf("x"), 1L, "photo.png", "image/png", BIZ_DOMAIN);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).save(saveCap.capture());
|
|
+ assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ verify(thumbnailGenerationTask).generate(FILE_ID);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("上传 PDF -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
|
|
+ void upload_pdf_triggersThumbnailGeneration() throws Exception {
|
|
+ when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
|
|
+ when(thumbnailGenerationTask.isSupported("application/pdf", "pdf")).thenReturn(true);
|
|
+
|
|
+ fileApi.upload(streamOf("x"), 1L, "contract.pdf", "application/pdf", BIZ_DOMAIN);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).save(saveCap.capture());
|
|
+ assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ verify(thumbnailGenerationTask).generate(FILE_ID);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("上传 Office(docx)-> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
|
|
+ void upload_office_triggersThumbnailGeneration() throws Exception {
|
|
+ when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
|
|
+ when(thumbnailGenerationTask.isSupported(any(), org.mockito.ArgumentMatchers.eq("docx"))).thenReturn(true);
|
|
+
|
|
+ fileApi.upload(streamOf("x"), 1L, "report.docx",
|
|
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", BIZ_DOMAIN);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).save(saveCap.capture());
|
|
+ assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ verify(thumbnailGenerationTask).generate(FILE_ID);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("上传非图片(txt)-> isSupported=false -> thumbnail_status=UNSUPPORTED + 不提交异步生成")
|
|
+ void upload_nonImage_marksUnsupported() throws Exception {
|
|
+ when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
|
|
+
|
|
+ fileApi.upload(streamOf("x"), 1L, "data.txt", null, BIZ_DOMAIN);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).save(saveCap.capture());
|
|
+ assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ verify(thumbnailGenerationTask, never()).generate(anyLong());
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("分片合并图片 -> isSupported=true -> thumbnail_status=PENDING + 提交异步生成")
|
|
+ void completeMultipart_image_triggersThumbnailGeneration() throws Exception {
|
|
+ UploadSession session = buildSession();
|
|
+ session.setOriginalName("photo.jpg");
|
|
+ when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
+ when(valueOperations.get(SESSION_KEY)).thenReturn(session);
|
|
+ stubUploadedChunks("chunks/" + UPLOAD_ID + "/1", "chunks/" + UPLOAD_ID + "/2", "chunks/" + UPLOAD_ID + "/3");
|
|
+ when(identifierGenerator.nextId(any())).thenReturn(FILE_ID);
|
|
+ when(minioClient.removeObjects(any(RemoveObjectsArgs.class))).thenReturn(List.of());
|
|
+ when(thumbnailGenerationTask.isSupported(any(), any())).thenReturn(true);
|
|
+
|
|
+ fileApi.completeMultipart(UPLOAD_ID);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> saveCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).save(saveCap.capture());
|
|
+ assertThat(saveCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ verify(thumbnailGenerationTask).generate(FILE_ID);
|
|
+ }
|
|
+
|
|
/*-------- getInfo:详情查询 --------*/
|
|
|
|
@Test
|
|
@@ -396,6 +481,180 @@ class FileApiImplTest {
|
|
verify(minioClient, never()).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class));
|
|
}
|
|
|
|
+ /*-------- getThumbnail:缩略图读路径 --------*/
|
|
+
|
|
+ private static final byte[] THUMB_BYTES = "thumbnail-data".getBytes(StandardCharsets.UTF_8);
|
|
+ private static final byte[] PLACEHOLDER_BYTES = "placeholder-data".getBytes(StandardCharsets.UTF_8);
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("READY 状态 -> 从 MinIO 拉缩略图,返回 image/jpeg + cacheable=true")
|
|
+ void getThumbnail_ready_returnsFromMinio() throws Exception {
|
|
+ FileInfo entity = new FileInfo();
|
|
+ entity.setId(FILE_ID);
|
|
+ entity.setOriginalName("photo.jpg");
|
|
+ entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ ArgumentCaptor<GetObjectArgs> getCap = ArgumentCaptor.forClass(GetObjectArgs.class);
|
|
+ verify(minioClient).getObject(getCap.capture());
|
|
+ assertThat(getCap.getValue().bucket()).isEqualTo("crm");
|
|
+ assertThat(getCap.getValue().object()).isEqualTo(FileConstants.THUMBNAIL_PREFIX + FILE_ID + ".jpg");
|
|
+ assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
|
|
+ assertThat(dto.getContentType()).isEqualTo("image/jpeg");
|
|
+ assertThat(dto.isCacheable()).isTrue();
|
|
+ verify(thumbnailPlaceholderService, never()).getPlaceholder(any());
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("UNSUPPORTED 状态 -> 返回占位图 + cacheable=false,不触碰 MinIO 缩略图路径")
|
|
+ void getThumbnail_unsupported_returnsPlaceholder() {
|
|
+ FileInfo entity = new FileInfo();
|
|
+ entity.setId(FILE_ID);
|
|
+ entity.setOriginalName("data.txt");
|
|
+ entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
|
|
+ when(thumbnailPlaceholderService.getPlaceholder("txt")).thenReturn(PLACEHOLDER_BYTES);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
|
|
+ assertThat(dto.getContentType()).isEqualTo("image/png");
|
|
+ assertThat(dto.isCacheable()).isFalse();
|
|
+ verifyNoInteractions(minioClient);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("FAILED 状态 -> 返回占位图 + cacheable=false")
|
|
+ void getThumbnail_failed_returnsPlaceholder() {
|
|
+ FileInfo entity = new FileInfo();
|
|
+ entity.setId(FILE_ID);
|
|
+ entity.setOriginalName("report.pdf");
|
|
+ entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
|
|
+ when(thumbnailPlaceholderService.getPlaceholder("pdf")).thenReturn(PLACEHOLDER_BYTES);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
|
|
+ assertThat(dto.getContentType()).isEqualTo("image/png");
|
|
+ assertThat(dto.isCacheable()).isFalse();
|
|
+ verifyNoInteractions(minioClient);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("PENDING + 获锁成功同步生成 -> 重查 READY -> 返回真缩略图 + cacheable=true")
|
|
+ void getThumbnail_pending_syncFallback_generatesAndReturns() throws Exception {
|
|
+ FileInfo pending = new FileInfo();
|
|
+ pending.setId(FILE_ID);
|
|
+ pending.setOriginalName("photo.jpg");
|
|
+ pending.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ FileInfo ready = new FileInfo();
|
|
+ ready.setId(FILE_ID);
|
|
+ ready.setOriginalName("photo.jpg");
|
|
+ ready.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ // 首次查 PENDING,同步生成后重查 READY
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(pending, ready);
|
|
+ when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(true);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
|
|
+ assertThat(dto.getContentType()).isEqualTo("image/jpeg");
|
|
+ assertThat(dto.isCacheable()).isTrue();
|
|
+ assertThat(dto.getStatusCode()).isEqualTo(200);
|
|
+ verify(thumbnailGenerationTask).tryGenerateSync(FILE_ID);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("PENDING + 获锁失败 + 轮询超时仍 PENDING -> 占位图 + no-cache + statusCode=202")
|
|
+ void getThumbnail_pending_lockFailed_timeoutReturns202() {
|
|
+ fileProperties.getThumbnail().setSyncWaitTimeout(java.time.Duration.ofMillis(100));
|
|
+ FileInfo entity = new FileInfo();
|
|
+ entity.setId(FILE_ID);
|
|
+ entity.setOriginalName("photo.jpg");
|
|
+ entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
|
|
+ when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(false);
|
|
+ when(thumbnailPlaceholderService.getPlaceholder("jpg")).thenReturn(PLACEHOLDER_BYTES);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
|
|
+ assertThat(dto.getContentType()).isEqualTo("image/png");
|
|
+ assertThat(dto.isCacheable()).isFalse();
|
|
+ assertThat(dto.getStatusCode()).isEqualTo(202);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("PENDING + 获锁失败 + 等待期间异步任务完成(READY)-> 返回真缩略图")
|
|
+ void getThumbnail_pending_lockFailed_readyDuringWait_returnsThumbnail() throws Exception {
|
|
+ fileProperties.getThumbnail().setSyncWaitTimeout(java.time.Duration.ofSeconds(5));
|
|
+ FileInfo pending = new FileInfo();
|
|
+ pending.setId(FILE_ID);
|
|
+ pending.setOriginalName("photo.jpg");
|
|
+ pending.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ FileInfo ready = new FileInfo();
|
|
+ ready.setId(FILE_ID);
|
|
+ ready.setOriginalName("photo.jpg");
|
|
+ ready.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ // 首查 PENDING,轮询时异步任务已完成变 READY
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(pending, ready);
|
|
+ when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(false);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(mockStream.readAllBytes()).thenReturn(THUMB_BYTES);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(THUMB_BYTES);
|
|
+ assertThat(dto.isCacheable()).isTrue();
|
|
+ assertThat(dto.getStatusCode()).isEqualTo(200);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("PENDING + 获锁成功但生成后仍非 READY(生成失败)-> 占位图 + 200")
|
|
+ void getThumbnail_pending_syncGenerateFailed_returnsPlaceholder() {
|
|
+ FileInfo entity = new FileInfo();
|
|
+ entity.setId(FILE_ID);
|
|
+ entity.setOriginalName("photo.jpg");
|
|
+ entity.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(entity);
|
|
+ when(thumbnailGenerationTask.tryGenerateSync(FILE_ID)).thenReturn(true);
|
|
+ when(thumbnailPlaceholderService.getPlaceholder("jpg")).thenReturn(PLACEHOLDER_BYTES);
|
|
+
|
|
+ ThumbnailDTO dto = fileApi.getThumbnail(String.valueOf(FILE_ID));
|
|
+
|
|
+ assertThat(dto.getContent()).isEqualTo(PLACEHOLDER_BYTES);
|
|
+ assertThat(dto.isCacheable()).isFalse();
|
|
+ assertThat(dto.getStatusCode()).isEqualTo(200);
|
|
+ verifyNoInteractions(minioClient);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("缩略图查无(文件不存在) -> 40401")
|
|
+ void getThumbnail_notFound_throwsResourceNotExist() {
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(null);
|
|
+ assertThatThrownBy(() -> fileApi.getThumbnail(String.valueOf(FILE_ID)))
|
|
+ .isInstanceOf(ResourceNotExistException.class);
|
|
+ verifyNoInteractions(minioClient, thumbnailPlaceholderService);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("缩略图 fileId 非法格式 -> 40401")
|
|
+ void getThumbnail_malformedFileId_throwsResourceNotExist() {
|
|
+ assertThatThrownBy(() -> fileApi.getThumbnail("not-a-number"))
|
|
+ .isInstanceOf(ResourceNotExistException.class);
|
|
+ verify(fileInfoService, never()).getById(anyLong());
|
|
+ }
|
|
+
|
|
/*-------- 分片上传三段式 --------*/
|
|
|
|
private static final String UPLOAD_ID = "u-0001";
|
|
diff --git a/crm-file/src/test/java/com/crm/file/service/impl/ImageThumbnailRendererTest.java b/crm-file/src/test/java/com/crm/file/service/impl/ImageThumbnailRendererTest.java
|
|
new file mode 100644
|
|
index 0000000..ac2fc42
|
|
--- /dev/null
|
|
+++ b/crm-file/src/test/java/com/crm/file/service/impl/ImageThumbnailRendererTest.java
|
|
@@ -0,0 +1,99 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
+import org.junit.jupiter.api.Test;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.*;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.ByteArrayInputStream;
|
|
+import java.io.ByteArrayOutputStream;
|
|
+
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
+
|
|
+/**
|
|
+ * {@link ImageThumbnailRenderer} 单元测试(票03):等比缩放不变形、上限高度约束、JPEG 输出、supports 判定
|
|
+ */
|
|
+class ImageThumbnailRendererTest {
|
|
+
|
|
+ private final ImageThumbnailRenderer renderer = new ImageThumbnailRenderer();
|
|
+
|
|
+ /** 内存生成指定尺寸的 PNG 字节流 */
|
|
+ private byte[] pngOf(int w, int h) throws Exception {
|
|
+ BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
|
+ Graphics2D g = img.createGraphics();
|
|
+ try {
|
|
+ g.setColor(Color.BLUE);
|
|
+ g.fillRect(0, 0, w, h);
|
|
+ } finally {
|
|
+ g.dispose();
|
|
+ }
|
|
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
+ ImageIO.write(img, "png", out);
|
|
+ return out.toByteArray();
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("宽图 1000x500 -> 等比缩放至宽 200,高 100,输出 JPEG 可解码")
|
|
+ void render_wideImage_proportionalScale() throws Exception {
|
|
+ byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(1000, 500)), "png", 200, 400);
|
|
+
|
|
+ BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
|
|
+ assertThat(decoded).isNotNull();
|
|
+ assertThat(decoded.getWidth()).isEqualTo(200);
|
|
+ assertThat(decoded.getHeight()).isEqualTo(100);
|
|
+ // JPEG 魔数:FF D8
|
|
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
|
|
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("竖图 100x1000 -> 高度上限 400 约束生效,宽按等比缩至 40,不变形")
|
|
+ void render_tallImage_cappedByMaxHeight() throws Exception {
|
|
+ byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(100, 1000)), "png", 200, 400);
|
|
+
|
|
+ BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
|
|
+ assertThat(decoded).isNotNull();
|
|
+ assertThat(decoded.getHeight()).isEqualTo(400);
|
|
+ assertThat(decoded.getWidth()).isEqualTo(40);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("小图 50x50 -> 仍放大至宽 200(固定宽度语义)")
|
|
+ void render_smallImage_upscaledToWidth() throws Exception {
|
|
+ byte[] thumb = renderer.render(new ByteArrayInputStream(pngOf(50, 50)), "png", 200, 400);
|
|
+
|
|
+ BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
|
|
+ assertThat(decoded.getWidth()).isEqualTo(200);
|
|
+ assertThat(decoded.getHeight()).isEqualTo(200);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("无法解码的流 -> 抛 IllegalStateException")
|
|
+ void render_undecodable_throws() {
|
|
+ byte[] garbage = "not-an-image".getBytes();
|
|
+ assertThatThrownBy(() -> renderer.render(new ByteArrayInputStream(garbage), "png", 200, 400))
|
|
+ .isInstanceOf(IllegalStateException.class);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("supports:图片扩展名/image/* contentType 支持,svg/tiff/非图片不支持")
|
|
+ void supports_typeJudgement() {
|
|
+ assertThat(renderer.supports(null, "jpg")).isTrue();
|
|
+ assertThat(renderer.supports(null, "png")).isTrue();
|
|
+ assertThat(renderer.supports("image/png", null)).isTrue();
|
|
+ assertThat(renderer.supports("image/svg+xml", "svg")).isFalse();
|
|
+ assertThat(renderer.supports("image/tiff", null)).isFalse();
|
|
+ assertThat(renderer.supports("application/pdf", "pdf")).isFalse();
|
|
+ assertThat(renderer.supports(null, null)).isFalse();
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("supports:webp 跟随运行时 ImageIO reader 可用性,不可用时落 UNSUPPORTED 防无限重试")
|
|
+ void supports_webp_followsRuntimeAvailability() {
|
|
+ boolean webpAvailable = ImageIO.getImageReadersByFormatName("webp").hasNext();
|
|
+ assertThat(renderer.supports(null, "webp")).isEqualTo(webpAvailable);
|
|
+ assertThat(renderer.supports("image/webp", null)).isEqualTo(webpAvailable);
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/test/java/com/crm/file/service/impl/OfficeThumbnailRendererTest.java b/crm-file/src/test/java/com/crm/file/service/impl/OfficeThumbnailRendererTest.java
|
|
new file mode 100644
|
|
index 0000000..21db4f2
|
|
--- /dev/null
|
|
+++ b/crm-file/src/test/java/com/crm/file/service/impl/OfficeThumbnailRendererTest.java
|
|
@@ -0,0 +1,112 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import com.crm.file.config.FileProperties;
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
+import org.junit.jupiter.api.Test;
|
|
+import org.junit.jupiter.api.io.TempDir;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.*;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.ByteArrayInputStream;
|
|
+import java.io.ByteArrayOutputStream;
|
|
+import java.io.InputStream;
|
|
+import java.nio.file.Files;
|
|
+import java.nio.file.Path;
|
|
+import java.time.Duration;
|
|
+
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
+
|
|
+/**
|
|
+ * {@link OfficeThumbnailRenderer} 单元测试(票06):supports 判定、LibreOffice 不可用走异常、
|
|
+ * 假 soffice 脚本模拟转换成功验证全链路(落地 → 转换 → 缩放 JPEG → 清理)
|
|
+ */
|
|
+class OfficeThumbnailRendererTest {
|
|
+
|
|
+ @TempDir
|
|
+ Path tempDir;
|
|
+
|
|
+ private OfficeThumbnailRenderer rendererWith(String sofficePath, Duration timeout) {
|
|
+ FileProperties props = new FileProperties();
|
|
+ props.getThumbnail().setLibreofficePath(sofficePath);
|
|
+ props.getThumbnail().setOfficeConvertTimeout(timeout);
|
|
+ return new OfficeThumbnailRenderer(props);
|
|
+ }
|
|
+
|
|
+ private byte[] pngOf(int w, int h) throws Exception {
|
|
+ BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
|
+ Graphics2D g = img.createGraphics();
|
|
+ try {
|
|
+ g.setColor(Color.RED);
|
|
+ g.fillRect(0, 0, w, h);
|
|
+ } finally {
|
|
+ g.dispose();
|
|
+ }
|
|
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
+ ImageIO.write(img, "png", out);
|
|
+ return out.toByteArray();
|
|
+ }
|
|
+
|
|
+ /** 创建假 soffice .cmd 脚本:把预制 PNG 复制到 --outdir(模拟 LibreOffice 转换产物) */
|
|
+ private Path fakeSoffice(Path preparedPng) throws Exception {
|
|
+ Path script = tempDir.resolve("fake-soffice.cmd");
|
|
+ String body = "@echo off\r\n"
|
|
+ + "set OUT=\r\n"
|
|
+ + ":loop\r\n"
|
|
+ + "if \"%~1\"==\"\" goto done\r\n"
|
|
+ + "if \"%~1\"==\"--outdir\" (set OUT=%~2)\r\n"
|
|
+ + "shift\r\n"
|
|
+ + "goto loop\r\n"
|
|
+ + ":done\r\n"
|
|
+ + "copy /y \"" + preparedPng.toAbsolutePath() + "\" \"%OUT%\\input.png\"\r\n"
|
|
+ + "exit /b 0\r\n";
|
|
+ Files.writeString(script, body);
|
|
+ return script;
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("supports:docx/xlsx/pptx/doc/xls/ppt 与 Office contentType 支持,其余不支持")
|
|
+ void supports_typeJudgement() {
|
|
+ OfficeThumbnailRenderer renderer = rendererWith("soffice", Duration.ofSeconds(1));
|
|
+ assertThat(renderer.supports(null, "docx")).isTrue();
|
|
+ assertThat(renderer.supports(null, "xlsx")).isTrue();
|
|
+ assertThat(renderer.supports(null, "pptx")).isTrue();
|
|
+ assertThat(renderer.supports(null, "DOC")).isTrue();
|
|
+ assertThat(renderer.supports("application/vnd.openxmlformats-officedocument.wordprocessingml.document", null)).isTrue();
|
|
+ assertThat(renderer.supports("application/msword", null)).isTrue();
|
|
+ assertThat(renderer.supports("application/pdf", "pdf")).isFalse();
|
|
+ assertThat(renderer.supports("image/png", "png")).isFalse();
|
|
+ assertThat(renderer.supports(null, null)).isFalse();
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("LibreOffice 不可用(可执行文件不存在)-> 抛异常(走失败重试语义,不 crash)")
|
|
+ void render_sofficeMissing_throws() {
|
|
+ OfficeThumbnailRenderer renderer = rendererWith("definitely-not-exist-soffice-xyz", Duration.ofSeconds(5));
|
|
+ InputStream fakeDoc = new ByteArrayInputStream("fake-docx-content".getBytes());
|
|
+
|
|
+ assertThatThrownBy(() -> renderer.render(fakeDoc, "docx", 200, 400))
|
|
+ .isInstanceOf(Exception.class);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("转换成功(假 soffice 产出 PNG)-> 等比缩放输出 JPEG + 临时目录已清理")
|
|
+ void render_conversionSuccess_scalesToJpeg() throws Exception {
|
|
+ // 预制一张 800x600 PNG 作为 LibreOffice 的"转换产物"
|
|
+ Path preparedPng = tempDir.resolve("prepared.png");
|
|
+ Files.write(preparedPng, pngOf(800, 600));
|
|
+ Path script = fakeSoffice(preparedPng);
|
|
+
|
|
+ OfficeThumbnailRenderer renderer = rendererWith(script.toAbsolutePath().toString(), Duration.ofSeconds(30));
|
|
+ byte[] thumb = renderer.render(new ByteArrayInputStream("fake-docx".getBytes()), "docx", 200, 400);
|
|
+
|
|
+ BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
|
|
+ assertThat(decoded).isNotNull();
|
|
+ assertThat(decoded.getWidth()).isEqualTo(200);
|
|
+ assertThat(decoded.getHeight()).isEqualTo(150);
|
|
+ // JPEG 魔数
|
|
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
|
|
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/test/java/com/crm/file/service/impl/PdfThumbnailRendererTest.java b/crm-file/src/test/java/com/crm/file/service/impl/PdfThumbnailRendererTest.java
|
|
new file mode 100644
|
|
index 0000000..f72118e
|
|
--- /dev/null
|
|
+++ b/crm-file/src/test/java/com/crm/file/service/impl/PdfThumbnailRendererTest.java
|
|
@@ -0,0 +1,67 @@
|
|
+package com.crm.file.service.impl;
|
|
+
|
|
+import org.apache.pdfbox.pdmodel.PDDocument;
|
|
+import org.apache.pdfbox.pdmodel.PDPage;
|
|
+import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
+import org.junit.jupiter.api.Test;
|
|
+
|
|
+import javax.imageio.ImageIO;
|
|
+import java.awt.image.BufferedImage;
|
|
+import java.io.ByteArrayInputStream;
|
|
+import java.io.ByteArrayOutputStream;
|
|
+
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
|
+
|
|
+/**
|
|
+ * {@link PdfThumbnailRenderer} 单元测试(票05):第一页渲染 + 等比缩放 + JPEG 输出 + supports 判定
|
|
+ */
|
|
+class PdfThumbnailRendererTest {
|
|
+
|
|
+ private final PdfThumbnailRenderer renderer = new PdfThumbnailRenderer();
|
|
+
|
|
+ /** 内存生成一页 A4 竖版 PDF */
|
|
+ private byte[] a4Pdf() throws Exception {
|
|
+ try (PDDocument doc = new PDDocument()) {
|
|
+ doc.addPage(new PDPage(PDRectangle.A4));
|
|
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
+ doc.save(out);
|
|
+ return out.toByteArray();
|
|
+ }
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("A4 竖版 PDF -> 渲染第一页等比缩放至宽 200,输出 JPEG 可解码")
|
|
+ void render_a4Portrait_scalesToWidth200() throws Exception {
|
|
+ byte[] thumb = renderer.render(new ByteArrayInputStream(a4Pdf()), "pdf", 200, 400);
|
|
+
|
|
+ BufferedImage decoded = ImageIO.read(new ByteArrayInputStream(thumb));
|
|
+ assertThat(decoded).isNotNull();
|
|
+ // A4 竖版(595x842pt)渲染后宽高比约 1:1.414,宽 200 时高约 283,未触及 400 上限
|
|
+ assertThat(decoded.getWidth()).isEqualTo(200);
|
|
+ assertThat(decoded.getHeight()).isBetween(280, 290);
|
|
+ // JPEG 魔数:FF D8
|
|
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
|
|
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("非 PDF 字节流 -> 抛异常(PDFBox 解析失败)")
|
|
+ void render_garbage_throws() {
|
|
+ byte[] garbage = "not-a-pdf".getBytes();
|
|
+ assertThatThrownBy(() -> renderer.render(new ByteArrayInputStream(garbage), "pdf", 200, 400))
|
|
+ .isInstanceOf(Exception.class);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("supports:pdf 扩展名/application/pdf 支持,其余不支持")
|
|
+ void supports_typeJudgement() {
|
|
+ assertThat(renderer.supports(null, "pdf")).isTrue();
|
|
+ assertThat(renderer.supports(null, "PDF")).isTrue();
|
|
+ assertThat(renderer.supports("application/pdf", null)).isTrue();
|
|
+ assertThat(renderer.supports("image/png", "png")).isFalse();
|
|
+ assertThat(renderer.supports("text/plain", "txt")).isFalse();
|
|
+ assertThat(renderer.supports(null, null)).isFalse();
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/test/java/com/crm/file/task/ThumbnailGenerationTaskTest.java b/crm-file/src/test/java/com/crm/file/task/ThumbnailGenerationTaskTest.java
|
|
new file mode 100644
|
|
index 0000000..2824267
|
|
--- /dev/null
|
|
+++ b/crm-file/src/test/java/com/crm/file/task/ThumbnailGenerationTaskTest.java
|
|
@@ -0,0 +1,261 @@
|
|
+package com.crm.file.task;
|
|
+
|
|
+import com.crm.file.config.FileProperties;
|
|
+import com.crm.file.constant.FileConstants;
|
|
+import com.crm.file.domain.entity.FileInfo;
|
|
+import com.crm.file.service.IFileInfoService;
|
|
+import com.crm.file.service.ThumbnailRenderer;
|
|
+import io.minio.GetObjectArgs;
|
|
+import io.minio.GetObjectResponse;
|
|
+import io.minio.MinioClient;
|
|
+import io.minio.PutObjectArgs;
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
+import org.junit.jupiter.api.Test;
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
+import org.mockito.ArgumentCaptor;
|
|
+import org.mockito.Mock;
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
+import org.springframework.data.redis.core.RedisTemplate;
|
|
+import org.springframework.data.redis.core.ValueOperations;
|
|
+
|
|
+import java.io.InputStream;
|
|
+import java.time.Duration;
|
|
+import java.util.List;
|
|
+
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
+import static org.mockito.ArgumentMatchers.anyInt;
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
+import static org.mockito.Mockito.mock;
|
|
+import static org.mockito.Mockito.never;
|
|
+import static org.mockito.Mockito.verify;
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
+import static org.mockito.Mockito.when;
|
|
+
|
|
+/**
|
|
+ * {@link ThumbnailGenerationTask} 单元测试(票03):
|
|
+ * 生成成功写 MinIO + 状态 READY + retry_count 归零;非 PENDING 跳过;无渲染器置 UNSUPPORTED
|
|
+ */
|
|
+@ExtendWith(MockitoExtension.class)
|
|
+class ThumbnailGenerationTaskTest {
|
|
+
|
|
+ private static final Long FILE_ID = 1001L;
|
|
+ private static final byte[] THUMB_BYTES = "thumbnail-data".getBytes();
|
|
+
|
|
+ @Mock
|
|
+ private MinioClient minioClient;
|
|
+ @Mock
|
|
+ private IFileInfoService fileInfoService;
|
|
+ @Mock
|
|
+ private ThumbnailRenderer renderer;
|
|
+ @Mock
|
|
+ private RedisTemplate<String, Object> redisTemplate;
|
|
+ @Mock
|
|
+ private ValueOperations<String, Object> valueOperations;
|
|
+
|
|
+ private FileProperties fileProperties;
|
|
+
|
|
+ private ThumbnailGenerationTask task;
|
|
+
|
|
+ @BeforeEach
|
|
+ void setUp() {
|
|
+ fileProperties = new FileProperties();
|
|
+ fileProperties.getMinio().setBucket("crm");
|
|
+ task = new ThumbnailGenerationTask(minioClient, fileProperties, fileInfoService, List.of(renderer),
|
|
+ redisTemplate);
|
|
+ }
|
|
+
|
|
+ /** 默认打桩:获锁成功 */
|
|
+ private void stubLockAcquired(boolean acquired) {
|
|
+ org.mockito.Mockito.lenient().when(redisTemplate.opsForValue()).thenReturn(valueOperations);
|
|
+ org.mockito.Mockito.lenient().when(valueOperations.setIfAbsent(anyString(), any(), any(Duration.class)))
|
|
+ .thenReturn(acquired);
|
|
+ }
|
|
+
|
|
+ private FileInfo pendingInfo() {
|
|
+ FileInfo info = new FileInfo();
|
|
+ info.setId(FILE_ID);
|
|
+ info.setOriginalName("photo.png");
|
|
+ info.setContentType("image/png");
|
|
+ info.setObjectKey("files/photo.png");
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ info.setThumbnailRetryCount(2);
|
|
+ return info;
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("生成成功 -> 写 MinIO thumbnails/{fileId}.jpg + 状态 READY + retry_count 归零 + 释放锁")
|
|
+ void generate_success_writesMinioAndMarksReady() throws Exception {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(true);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+ when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt())).thenReturn(THUMB_BYTES);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ // 缩略图写入 MinIO:objectKey = thumbnails/{fileId}.jpg,内容类型 image/jpeg
|
|
+ ArgumentCaptor<PutObjectArgs> putCap = ArgumentCaptor.forClass(PutObjectArgs.class);
|
|
+ verify(minioClient).putObject(putCap.capture());
|
|
+ assertThat(putCap.getValue().bucket()).isEqualTo("crm");
|
|
+ assertThat(putCap.getValue().object()).isEqualTo("thumbnails/1001.jpg");
|
|
+ assertThat(putCap.getValue().contentType()).isEqualTo("image/jpeg");
|
|
+
|
|
+ // 状态置 READY + retry_count 归零
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).updateById(updateCap.capture());
|
|
+ assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ assertThat(updateCap.getValue().getThumbnailRetryCount()).isZero();
|
|
+ // 生成完毕后释放锁
|
|
+ verify(redisTemplate).delete("crm:file:thumbnail:lock:1001");
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("获锁失败(另有生成在跑)-> 返回 false,不执行生成逻辑")
|
|
+ void generate_lockNotAcquired_skips() {
|
|
+ stubLockAcquired(false);
|
|
+
|
|
+ assertThat(task.tryGenerateSync(FILE_ID)).isFalse();
|
|
+
|
|
+ verify(fileInfoService, never()).getById(any());
|
|
+ verifyNoInteractions(minioClient);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("并发两路 -> 分布式锁 SET NX 保证只有一路执行生成")
|
|
+ void generate_concurrent_lockEnsuresSingleGeneration() throws Exception {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(true);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+ when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt())).thenReturn(THUMB_BYTES);
|
|
+
|
|
+ assertThat(task.tryGenerateSync(FILE_ID)).isTrue();
|
|
+
|
|
+ // 锁以 NX 语义获取:同一 key 的第二次尝试由 setIfAbsent 返回 false 拦截(打桩模拟)
|
|
+ ArgumentCaptor<String> lockKeyCap = ArgumentCaptor.forClass(String.class);
|
|
+ verify(valueOperations).setIfAbsent(lockKeyCap.capture(), any(), any(Duration.class));
|
|
+ assertThat(lockKeyCap.getValue()).isEqualTo("crm:file:thumbnail:lock:1001");
|
|
+ // 只生成了一次
|
|
+ verify(minioClient).putObject(any(PutObjectArgs.class));
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("文件不存在 -> 静默跳过,不触 MinIO")
|
|
+ void generate_fileNotFound_skips() {
|
|
+ stubLockAcquired(true);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(null);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ verifyNoInteractions(minioClient);
|
|
+ verify(fileInfoService, never()).updateById(any());
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("状态非 PENDING(已 READY)-> 跳过不重复生成")
|
|
+ void generate_alreadyReady_skips() {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_READY);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ verifyNoInteractions(minioClient);
|
|
+ verify(fileInfoService, never()).updateById(any());
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("无匹配渲染器 -> 状态置 UNSUPPORTED")
|
|
+ void generate_noRenderer_marksUnsupported() {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(false);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).updateById(updateCap.capture());
|
|
+ assertThat(updateCap.getValue().getThumbnailStatus())
|
|
+ .isEqualTo(FileConstants.THUMBNAIL_STATUS_UNSUPPORTED);
|
|
+ verifyNoInteractions(minioClient);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("isSupported 委托渲染器列表:任一 supports=true 即支持")
|
|
+ void isSupported_delegatesToRenderers() {
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(true);
|
|
+ assertThat(task.isSupported("image/png", "png")).isTrue();
|
|
+
|
|
+ when(renderer.supports("text/plain", "txt")).thenReturn(false);
|
|
+ assertThat(task.isSupported("text/plain", "txt")).isFalse();
|
|
+ }
|
|
+
|
|
+ /*-------- 票07:失败重试语义 --------*/
|
|
+
|
|
+ private void stubRenderThrows() throws Exception {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(true);
|
|
+ GetObjectResponse mockStream = mock(GetObjectResponse.class);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenReturn(mockStream);
|
|
+ when(renderer.render(any(InputStream.class), anyString(), anyInt(), anyInt()))
|
|
+ .thenThrow(new IllegalStateException("渲染失败"));
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("生成失败 + retry_count(2) < retry-limit(5) -> retry_count++ 保持 PENDING")
|
|
+ void generate_failure_belowLimit_staysPending() throws Exception {
|
|
+ stubRenderThrows();
|
|
+ // 调高 limit,确保 2+1=3 未耗尽
|
|
+ fileProperties.getThumbnail().setRetryLimit(5);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).updateById(updateCap.capture());
|
|
+ assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(3);
|
|
+ assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("生成失败 + 重试耗尽(retry_count 达 retry-limit)-> 置 FAILED")
|
|
+ void generate_failure_limitReached_marksFailed() throws Exception {
|
|
+ stubRenderThrows();
|
|
+ // pendingInfo() 默认 retry_count=2,失败后 +1 = 3 = retry-limit,耗尽
|
|
+ fileProperties.getThumbnail().setRetryLimit(3);
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).updateById(updateCap.capture());
|
|
+ assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(3);
|
|
+ assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_FAILED);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("MinIO 拉原文件异常 -> 同样计入重试,不 crash")
|
|
+ void generate_minioFailure_countsRetry() throws Exception {
|
|
+ stubLockAcquired(true);
|
|
+ FileInfo info = pendingInfo();
|
|
+ info.setThumbnailRetryCount(0);
|
|
+ when(fileInfoService.getById(FILE_ID)).thenReturn(info);
|
|
+ when(renderer.supports("image/png", "png")).thenReturn(true);
|
|
+ when(minioClient.getObject(any(GetObjectArgs.class))).thenThrow(new RuntimeException("MinIO 不可用"));
|
|
+
|
|
+ task.generate(FILE_ID);
|
|
+
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService).updateById(updateCap.capture());
|
|
+ assertThat(updateCap.getValue().getThumbnailRetryCount()).isEqualTo(1);
|
|
+ assertThat(updateCap.getValue().getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ }
|
|
+}
|
|
diff --git a/crm-file/src/test/java/com/crm/file/task/ThumbnailRetryTaskTest.java b/crm-file/src/test/java/com/crm/file/task/ThumbnailRetryTaskTest.java
|
|
new file mode 100644
|
|
index 0000000..d619822
|
|
--- /dev/null
|
|
+++ b/crm-file/src/test/java/com/crm/file/task/ThumbnailRetryTaskTest.java
|
|
@@ -0,0 +1,84 @@
|
|
+package com.crm.file.task;
|
|
+
|
|
+import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
+import com.crm.file.constant.FileConstants;
|
|
+import com.crm.file.domain.entity.FileInfo;
|
|
+import com.crm.file.service.IFileInfoService;
|
|
+import org.junit.jupiter.api.DisplayName;
|
|
+import org.junit.jupiter.api.Test;
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
+import org.mockito.ArgumentCaptor;
|
|
+import org.mockito.InjectMocks;
|
|
+import org.mockito.Mock;
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
+
|
|
+import java.util.List;
|
|
+
|
|
+import static org.assertj.core.api.Assertions.assertThatCode;
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
+import static org.mockito.Mockito.never;
|
|
+import static org.mockito.Mockito.verify;
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
+import static org.mockito.Mockito.when;
|
|
+import static org.assertj.core.api.Assertions.assertThat;
|
|
+
|
|
+/**
|
|
+ * {@link ThumbnailRetryTask} 单元测试(票07):FAILED 记录重置 PENDING + 重新提交;自身异常不 crash
|
|
+ */
|
|
+@ExtendWith(MockitoExtension.class)
|
|
+class ThumbnailRetryTaskTest {
|
|
+
|
|
+ @Mock
|
|
+ private IFileInfoService fileInfoService;
|
|
+ @Mock
|
|
+ private ThumbnailGenerationTask thumbnailGenerationTask;
|
|
+
|
|
+ @InjectMocks
|
|
+ private ThumbnailRetryTask retryTask;
|
|
+
|
|
+ private FileInfo failedInfo(Long id) {
|
|
+ FileInfo info = new FileInfo();
|
|
+ info.setId(id);
|
|
+ info.setOriginalName("photo.png");
|
|
+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_FAILED);
|
|
+ info.setThumbnailRetryCount(3);
|
|
+ return info;
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("扫描 FAILED 记录 -> 重置 PENDING + retry_count 归零 + 提交异步生成")
|
|
+ void retry_resetsAndResubmits() {
|
|
+ when(fileInfoService.list(any(Wrapper.class))).thenReturn(List.of(failedInfo(101L), failedInfo(102L)));
|
|
+
|
|
+ retryTask.retryFailedThumbnails();
|
|
+
|
|
+ ArgumentCaptor<FileInfo> updateCap = ArgumentCaptor.forClass(FileInfo.class);
|
|
+ verify(fileInfoService, org.mockito.Mockito.times(2)).updateById(updateCap.capture());
|
|
+ for (FileInfo updated : updateCap.getAllValues()) {
|
|
+ assertThat(updated.getThumbnailStatus()).isEqualTo(FileConstants.THUMBNAIL_STATUS_PENDING);
|
|
+ assertThat(updated.getThumbnailRetryCount()).isZero();
|
|
+ }
|
|
+ verify(thumbnailGenerationTask).generate(101L);
|
|
+ verify(thumbnailGenerationTask).generate(102L);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("无 FAILED 记录 -> 静默结束,不提交任何任务")
|
|
+ void retry_noFailedRecords_noop() {
|
|
+ when(fileInfoService.list(any(Wrapper.class))).thenReturn(List.of());
|
|
+
|
|
+ retryTask.retryFailedThumbnails();
|
|
+
|
|
+ verify(fileInfoService, never()).updateById(any());
|
|
+ verifyNoInteractions(thumbnailGenerationTask);
|
|
+ }
|
|
+
|
|
+ @Test
|
|
+ @DisplayName("任务自身异常不 crash(查询抛异常被吞掉,下周期继续)")
|
|
+ void retry_queryThrows_swallowed() {
|
|
+ when(fileInfoService.list(any(Wrapper.class))).thenThrow(new RuntimeException("DB 不可用"));
|
|
+
|
|
+ assertThatCode(() -> retryTask.retryFailedThumbnails()).doesNotThrowAnyException();
|
|
+ verifyNoInteractions(thumbnailGenerationTask);
|
|
+ }
|
|
+}
|
|
diff --git a/docs/adr/0013-thumbnail-architecture.md b/docs/adr/0013-thumbnail-architecture.md
|
|
new file mode 100644
|
|
index 0000000..2a848c1
|
|
--- /dev/null
|
|
+++ b/docs/adr/0013-thumbnail-architecture.md
|
|
@@ -0,0 +1,17 @@
|
|
+# 缩略图架构:异步生成 + 同步兜底 + 后端中转缓存
|
|
+
|
|
+文件缩略图覆盖图片、PDF、Office 三类。采用混合生成策略:上传后异步生成 + 首次请求同步兜底,两路加分布式锁防重复。缩略图存 MinIO 同 bucket(`thumbnails/{fileId}.jpg`),通过后端 API 中转返回 + `Cache-Control: max-age=86400` 强缓存,浏览器不直接接触 MinIO。渲染引擎选 LibreOffice headless(Office)+ PDFBox(PDF)+ Java ImageIO(图片),部署在 Docker 镜像中。失败重试 3 次标记 `FAILED`,定时任务扫描重试;非视觉文件前置标记 `UNSUPPORTED` 跳过。
|
|
+
|
|
+## Considered Options
|
|
+
|
|
+- **纯懒生成(首次请求同步)**:无后台任务,第一个请求者等待。简单但首次体验差。
|
|
+- **纯异步生成**:上传后后台任务跑,未就绪时返回占位图。需要前端处理两态,冷数据浪费资源。
|
|
+- **presigned URL 直返**:前端直接从 MinIO 拉缩略图。违反"MinIO 不暴公网"铁则。
|
|
+
|
|
+## Consequences
|
|
+
|
|
+- Docker 镜像体积增加约 400-500MB(LibreOffice headless)。
|
|
+- 需要 Redis 分布式锁(已有 Redis 基础设施)。锁 TTL 可配置(`crm.file.thumbnail.lock-ttl`,默认 150s),必须大于 LibreOffice 转换超时(`office-convert-timeout` 默认 120s),否则慢渲染期间锁过期导致并发重复生成。
|
|
+- webp 依赖运行时 ImageIO reader 可用性探测,无插件时落 `UNSUPPORTED`(避免生成必然失败被定时任务无限重试)。
|
|
+- `FileInfo` 表新增 `thumbnail_status` 和 `thumbnail_retry_count` 字段。
|
|
+- 缩略图不变,缓存命中后零后端负载。但 `FAILED` 重试恢复后 objectKey 不变内容变,浏览器会命中旧占位图缓存。对策:占位图(`PENDING`/`FAILED`/`UNSUPPORTED`)返回时设 `Cache-Control: no-cache`;真缩略图(`READY`)返回时设 `max-age=86400`。占位图不被强缓存,真缩略图一旦就绪即被缓存住。
|
|
diff --git a/pom.xml b/pom.xml
|
|
index 757aa87..adc71c5 100644
|
|
--- a/pom.xml
|
|
+++ b/pom.xml
|
|
@@ -39,6 +39,7 @@
|
|
<easyexcel.version>4.0.3</easyexcel.version>
|
|
<guava.version>33.2.1-jre</guava.version>
|
|
<knife4j.version>4.5.0</knife4j.version>
|
|
+ <pdfbox.version>3.0.3</pdfbox.version>
|
|
</properties>
|
|
|
|
<dependencyManagement>
|
|
@@ -101,6 +102,13 @@
|
|
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
|
|
<version>${knife4j.version}</version>
|
|
</dependency>
|
|
+
|
|
+ <!-- PDF 渲染(缩略图第一页) -->
|
|
+ <dependency>
|
|
+ <groupId>org.apache.pdfbox</groupId>
|
|
+ <artifactId>pdfbox</artifactId>
|
|
+ <version>${pdfbox.version}</version>
|
|
+ </dependency>
|
|
</dependencies>
|
|
</dependencyManagement>
|
|
|
|
|