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.
 
 
 
 
 
 

2111 lines
180 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锛?+# 鏋勫缓闃舵锛歁aven 缂栬瘧鎵撳寘
+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
+
+# 杩愯闃舵锛欽RE 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_: 棰勮閾炬帴銆乲k 鍦板潃
**鍥炴簮**:
kkFileView 鏈嶅姟绔嚟鐭椂鏁堝嚟璇佷粠 MinIO 鎷夊彇鍘熷鏂囦欢鐢ㄤ簬鏍煎紡杞崲鐨勫姩浣溿€傛祻瑙堝櫒涓嶇洿鎺ユ帴瑙?MinIO銆? _Avoid_: 浠g悊涓嬭浇銆佷腑杞?+
+**缂╃暐鍥?*:
+浠庡師鏂囦欢鎻愬彇鎴栨覆鏌撳嚭鐨勫皬灏哄闈欐€佸浘鐗囷紙鍥哄畾瀹藉害 200px锛岀瓑姣旂缉鏀撅紝涓婇檺 400px 楂橈紝JPEG 鏍煎紡锛夛紝鐢ㄤ簬鍓嶇鏂囦欢鍒楄〃浼樺厛灞曠ず浠ラ檷浣庡急缃戝姞杞藉欢杩熴€傜偣鍑诲悗鍐嶈姹傚師鍥炬垨瀹屾暣棰勮銆?+_Avoid_: 灏忓浘銆侀瑙堝浘銆佸皝闈㈠浘
+
+**缂╃暐鍥剧姸鎬?*:
+涓€涓枃浠剁缉鐣ュ浘鐨勭敓鍛藉懆鏈熼樁娈碉細`PENDING`锛堝凡鍏ラ槦寰呯敓鎴愶級銆乣READY`锛堝凡鐢熸垚鍙繑鍥烇級銆乣FAILED`锛堥噸璇?3 娆″悗鏀惧純浣嗗彲鐢卞畾鏃朵换鍔¢噸璇曪級銆乣UNSUPPORTED`锛堟枃浠剁被鍨嬩笉鍦ㄦ敮鎸佽寖鍥村唴锛屾案涓嶈Е鍙戠敓鎴愶級銆?+_Avoid_: 缂╃暐鍥捐繘搴︺€佺敓鎴愭爣蹇?+
+**缂╃暐鍥剧敓鎴?*:
+灏嗗師鏂囦欢杞崲涓虹缉鐣ュ浘鐨勫姩浣溿€傚浘鐗囪蛋 Java 鍥惧儚缂╂斁锛汸DF 璧?PDFBox 娓叉煋绗竴椤碉紱Office 璧?LibreOffice headless 娓叉煋绗竴椤点€傛敮鎸佸浘鐗囷紙jpg/png/webp/gif/bmp锛夈€丳DF銆丱ffice锛坉ocx/xlsx/pptx锛夛紝鍏朵綑绫诲瀷鍓嶇疆鏍囪 `UNSUPPORTED` 璺宠繃銆?+_Avoid_: 缂╃暐銆佹埅鍥?+
+**寮傛鐢熸垚**:
+鏂囦欢涓婁紶瀹屾垚鍚庣敱鍚庡彴浠诲姟瑙﹀彂鐨勭缉鐣ュ浘鐢熸垚銆備笌棣栨璇锋眰鍚屾鍏滃簳骞惰锛屼袱璺€氳繃鍒嗗竷寮忛攣闃叉閲嶅鐢熸垚銆?+_Avoid_: 鍚庡彴鎴浘銆佸欢杩熺敓鎴?+
+**鍚屾鍏滃簳**:
+缂╃暐鍥鹃娆¤璇锋眰鏃惰嫢鍚庡彴浠诲姟灏氭湭瀹屾垚锛岃姹傜嚎绋嬪悓姝ヨЕ鍙戠敓鎴愬苟绛夊緟杩斿洖鐨勭瓥鐣ャ€傜缉鐣ュ浘涓嶅彉锛屽悗缁姹傝蛋 MinIO 缂撳瓨銆?+_Avoid_: 瀹炴椂鐢熸垚銆佸嵆鏃舵覆鏌?+
+**鍗犱綅鍥?*:
+缂╃暐鍥惧浜?`PENDING`銆乣FAILED` 鎴?`UNSUPPORTED` 鐘舵€佹椂杩斿洖缁欏墠绔殑鏂囦欢绫诲瀷鍥炬爣闈欐€佸浘銆傚墠绔棤鎰熺煡锛屽缁堟嬁鍒颁竴寮犲浘銆?+_Avoid_: 榛樿鍥俱€乫allback鍥?+
+**缂╃暐鍥惧璞?*:
+缂╃暐鍥惧湪 MinIO 涓殑瀵硅薄锛宱bjectKey 涓?`thumbnails/{fileId}.jpg`锛屼笌鍘熸枃浠跺悓 bucket銆傞€氳繃鍚庣 API 涓浆杩斿洖锛岃缃?`Cache-Control: max-age=86400` 寮虹紦瀛樺ご锛屾祻瑙堝櫒涓嶇洿鎺ユ帴瑙?MinIO銆?+_Avoid_: 缂╃暐鍥炬枃浠躲€乼humb瀵硅薄
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锛堝瓧绗︿覆锛夛紝鏌ユ棤鎶涜祫婧愪笉瀛樺湪锛?0401锛?+ * @return 缂╃暐鍥句簩杩涘埗 + 鍐呭绫诲瀷 + 缂撳瓨绛栫暐鏍囪 + HTTP 鐘舵€佺爜
+ */
+ ThumbnailDTO getThumbnail(String fileId);
+
/*-------- 鍒嗙墖涓婁紶涓夋寮忥紙init 鈫?upload 鈫?complete锛孉DR-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;
+
+ /** 杈撳嚭鏍煎紡锛坖peg/png锛?*/
+ private String format = "jpeg";
+
+ /** 鍚屾鍏滃簳绛夊緟瓒呮椂锛歅ENDING 鏃惰幏閿佸け璐ュ悗鐨勮疆璇笂闄?*/
+ private Duration syncWaitTimeout = Duration.ofSeconds(10);
+
+ /** 鏈€澶ч噸璇曟鏁帮紝杈惧埌鍚庢爣璁?FAILED */
+ private int retryLimit = 3;
+
+ /** FAILED 閲嶈瘯鎵弿鍛ㄦ湡锛坈ron锛夛紝榛樿姣忓ぉ鍑屾櫒 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锛歝hunks/{uploadId}/{鍒嗙墖搴忓彿} */
String CHUNK_PREFIX = "chunks/";
+ /** 缂╃暐鍥惧湪 MinIO 涓殑瀵硅薄鍓嶇紑锛屽畬鏁?key锛歵humbnails/{fileId}.jpg */
+ String THUMBNAIL_PREFIX = "thumbnails/";
+
+ /** 缂╃暐鍥剧敓鎴愬垎甯冨紡閿佸湪 Redis 涓殑 key 鍓嶇紑锛屽畬鏁?key锛歝rm: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锛沠alse 鏃惰 no-cache</p>
+ */
+@Data
+@Builder
+public class ThumbnailDTO {
+
+ /** 缂╃暐鍥?鍗犱綅鍥句簩杩涘埗鍐呭 */
+ private byte[] content;
+
+ /** 鍐呭绫诲瀷锛堢湡缂╃暐鍥?image/jpeg锛屽崰浣嶅浘 image/png锛?*/
+ private String contentType;
+
+ /** 鏄惁鍙己缂撳瓨锛歊EADY=true锛坢ax-age=86400锛夛紝鍏朵綑=false锛坣o-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 '鏁存枃浠秇ash(绉掍紶鍗犱綅)'")
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;
+
+/**
+ * 缂╃暐鍥惧崰浣嶅浘鏈嶅姟锛氭寜鏂囦欢鎵╁睍鍚嶅姩鎬佺敓鎴愭枃浠剁被鍨嬪浘鏍囧崰浣嶅浘锛圥NG锛?+ * <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 绛夋瘮缂╂斁涓婇檺楂樺害锛坧x锛?+ * @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锛團AILED/UNSUPPORTED/鍒犻櫎锛変笉鍐嶇瓑寰?+ return placeholderOf(info, 200);
+ }
+ if (System.currentTimeMillis() >= deadline) {
+ log.info("缂╃暐鍥惧悓姝ョ瓑寰呰秴鏃讹紝杩斿洖鍗犱綅鍥?+ 202锛宖ileId={}", 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;
+
+/**
+ * 鍥剧墖缂╃暐鍥炬覆鏌撳櫒锛欽ava ImageIO 璇诲彇 鈫?绛夋瘮缂╂斁 鈫?JPEG 杈撳嚭
+ * <p>鏀寔 jpg/jpeg/png/gif/bmp锛泈ebp 闇€杩愯鏃?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 瑙g爜鑳藉姏鎺㈡祴锛欽DK 鍐呯疆 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 鏃犳硶瑙g爜鍥剧墖锛屽彲鑳芥牸寮忎笉鏀寔");
+ }
+ 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 缂╃暐鍥炬覆鏌撳櫒锛歀ibreOffice headless 灏嗘枃妗h浆 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 鏃犳硶瑙g爜锛? + png.getFileName());
+ }
+ return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
+ } finally {
+ deleteRecursively(workDir);
+ }
+ }
+
+ /**
+ * 璋?soffice --convert-to png锛沺rofile 闅旂 + 瓒呮椂淇濇姢
+ */
+ 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 瑙i噴锛堟湰鏈烘祴璇曞満鏅紱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 杞崲澶辫触锛宔xit={}锛宱utput={}", exit, output);
+ throw new IllegalStateException("LibreOffice 杞崲澶辫触锛宔xit=" + 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 缂╃暐鍥炬覆鏌撳櫒锛歅DFBox 娓叉煋绗竴椤典负鍥?鈫?绛夋瘮缂╂斁 鈫?JPEG 杈撳嚭
+ * <p>DPI 150 鍏奸【娓呮櫚搴︿笌鎬ц兘锛涗粎娓叉煋绗竴椤?/p>
+ */
+@Slf4j
+@Service
+public class PdfThumbnailRenderer implements ThumbnailRenderer {
+
+ /** 娓叉煋 DPI锛?50 鍦?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 娓叉煋鍣ㄥ皢婧愬浘锛圔ufferedImage锛夎浆缂╃暐鍥惧瓧鑺傜殑鍏辩敤璺緞</p>
+ */
+final class ThumbnailImageUtils {
+
+ private ThumbnailImageUtils() {
+ }
+
+ /**
+ * 绛夋瘮缂╂斁鑷冲浐瀹氬搴︿笌涓婇檺楂樺害涓や釜绾︽潫鍐咃紙涓嶅彉褰級锛岃緭鍑?JPEG 瀛楄妭鏁扮粍
+ *
+ * @param src 婧愬浘锛堟覆鏌撳櫒浜у嚭锛屽彲鑳借繙澶т簬缂╃暐鍥惧昂瀵革級
+ * @param width 缂╃暐鍥剧洰鏍囧搴︼紙px锛?+ * @param maxHeight 绛夋瘮缂╂斁涓婇檺楂樺害锛坧x锛?+ * @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("缂╃暐鍥剧敓鎴愯烦杩囷細鐘舵€?{}锛宖ileId={}", 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锛歠ileId={}, 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锛歠ileId={}, 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 鍖呰9璁版棩蹇楋紝涓嬩釜鍛ㄦ湡缁х画锛堝鐢ㄥ鍎垮垎鐗囨竻鐞嗕换鍔℃ā寮忥級</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锛坉ocx锛?> 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 + 鑾烽攣澶辫触 + 绛夊緟鏈熼棿寮傛浠诲姟瀹屾垚锛圧EADY锛?> 杩斿洖鐪熺缉鐣ュ浘")
+ 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锛夛細绛夋瘮缂╂斁涓嶅彉褰€佷笂闄愰珮搴︾害鏉熴€丣PEG 杈撳嚭銆乻upports 鍒ゅ畾
+ */
+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 榄旀暟锛欶F 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("鏃犳硶瑙g爜鐨勬祦 -> 鎶?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 鏀寔锛宻vg/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锛歸ebp 璺熼殢杩愯鏃?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 鍒ゅ畾銆丩ibreOffice 涓嶅彲鐢ㄨ蛋寮傚父銆?+ * 鍋?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锛歞ocx/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 绔栫増锛?95x842pt锛夋覆鏌撳悗瀹介珮姣旂害 1:1.414锛屽 200 鏃堕珮绾?283锛屾湭瑙﹀強 400 涓婇檺
+ assertThat(decoded.getWidth()).isEqualTo(200);
+ assertThat(decoded.getHeight()).isBetween(280, 290);
+ // JPEG 榄旀暟锛欶F D8
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
+ }
+
+ @Test
+ @DisplayName("闈?PDF 瀛楄妭娴?-> 鎶涘紓甯革紙PDFBox 瑙f瀽澶辫触锛?)
+ 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锛歱df 鎵╁睍鍚?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锛歰bjectKey = 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();
+ }
+
+ /*-------- 绁?7锛氬け璐ラ噸璇曡涔?--------*/
+
+ 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("鐢熸垚澶辫触 + 閲嶈瘯鑰楀敖锛坮etry_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/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>