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

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>