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 @@ minio + + + org.apache.pdfbox + pdfbox + + org.projectlombok lombok 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); + /** + * 获取缩略图(含同步兜底逻辑) + *

状态 READY:从 MinIO 拉 thumbnails/{fileId}.jpg 返回二进制 + cacheable=true; + * 状态 UNSUPPORTED/FAILED:返回占位图 + cacheable=false; + * 状态 PENDING:获锁同步生成后重查状态,READY 返回真缩略图;获锁失败则轮询等待, + * 超 sync-wait-timeout 仍 PENDING 返回占位图 + cacheable=false + statusCode=202

+ * + * @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 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; /** - * 定时任务开关(项目此前无调度体系,随文件模块引入) - *

当前唯一任务:孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask}

+ * 定时任务与异步任务开关(项目此前无调度体系,随文件模块引入) + *

定时任务:孤儿临时分片清理 {@link com.crm.file.task.OrphanChunkCleanupTask}; + * 异步任务:缩略图生成 {@link com.crm.file.task.ThumbnailGenerationTask}

*/ @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 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 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; + +/** + * 缩略图返回结果(二进制 + 元数据 + 缓存策略标记) + *

cacheable=true 时 Controller 设 Cache-Control: max-age=86400;false 时设 no-cache

+ */ +@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) + *

200x200 灰底,居中显示扩展名文字;非视觉文件、未就绪、生成失败时返回

+ */ +@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; + +/** + * 缩略图渲染器接口:将原始文件渲染为缩略图字节数组 + *

每种文件类型一个实现(图片/PDF/Office),通过 {@link #supports} 声明自己处理的类型

+ */ +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 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 输出 + *

支持 jpg/jpeg/png/gif/bmp;webp 需运行时 ImageIO 有对应 reader(插件), + * 不可用时 supports 返回 false 落 UNSUPPORTED,避免生成必然失败被定时任务无限重试

+ */ +@Slf4j +@Service +public class ImageThumbnailRenderer implements ThumbnailRenderer { + + private static final Set 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 输出 + *

每次调用使用唯一 UUID 的 UserInstallation profile,避免并发冲突; + * 临时文件(原文件落地 + 转换输出 + profile)在 finally 中清理

+ */ +@Slf4j +@Service +public class OfficeThumbnailRenderer implements ThumbnailRenderer { + + private static final Set SUPPORTED_EXTS = Set.of("docx", "xlsx", "pptx", "doc", "xls", "ppt"); + + private static final Set 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 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 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 输出 + *

DPI 150 兼顾清晰度与性能;仅渲染第一页

+ */ +@Slf4j +@Service +public class PdfThumbnailRenderer implements ThumbnailRenderer { + + /** 渲染 DPI:150 在 200px 缩略图宽度下清晰度足够,且渲染耗时可控 */ + private static final float RENDER_DPI = 150f; + + private static final Set 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 输出 + *

图片/PDF/Office 渲染器将源图(BufferedImage)转缩略图字节的共用路径

+ */ +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; + +/** + * 缩略图异步生成任务 + *

上传完成后由 {@link com.crm.file.service.impl.FileApiImpl} 提交; + * 从 MinIO 拉原文件 → 路由到对应 {@link ThumbnailRenderer} → 缩略图写回 MinIO → 状态置 READY

+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ThumbnailGenerationTask { + + private final MinioClient minioClient; + private final FileProperties fileProperties; + private final IFileInfoService fileInfoService; + private final List renderers; + private final RedisTemplate 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; + +/** + * 缩略图失败重试定时任务 + *

扫描 thumbnail_status = FAILED 的记录,重置为 PENDING 并重新提交异步生成; + * LibreOffice 等渲染引擎临时故障恢复后,下个周期自动补齐缩略图。 + * 任务自身异常不致命:try-catch 包裹记日志,下个周期继续(复用孤儿分片清理任务模式)

+ */ +@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 failed = fileInfoService.list(new LambdaQueryWrapper() + .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 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 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 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 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 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 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 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 redisTemplate; + @Mock + private ValueOperations 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 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 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 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 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 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 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 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 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 @@ 4.0.3 33.2.1-jre 4.5.0 + 3.0.3 @@ -101,6 +102,13 @@ knife4j-openapi3-jakarta-spring-boot-starter ${knife4j.version}
+ + + + org.apache.pdfbox + pdfbox + ${pdfbox.version} +