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 @@ 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..683afb3 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,17 @@ public interface FileApi { */ String getPreviewUrl(String fileId); + /** + * 鑾峰彇缂╃暐鍥撅紙鍚悓姝ュ厹搴曢€昏緫锛?+ *

鐘舵€?READY锛氫粠 MinIO 鎷?thumbnails/{fileId}.jpg 杩斿洖浜岃繘鍒?+ cacheable=true锛?+ * 鐘舵€?UNSUPPORTED/FAILED锛氳繑鍥炲崰浣嶅浘 + cacheable=false锛?+ * 鐘舵€?PENDING锛氳繑鍥炲崰浣嶅浘 + cacheable=false锛堝悓姝ュ厹搴曞湪 Ticket 04 瀹炵幇锛?/p> + * + * @param fileId 鏂囦欢 ID锛堝瓧绗︿覆锛夛紝鏌ユ棤鎶涜祫婧愪笉瀛樺湪锛?0401锛?+ * @return 缂╃暐鍥句簩杩涘埗 + 鍐呭绫诲瀷 + 缂撳瓨绛栫暐鏍囪 + */ + 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..8ef849d 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,32 @@ 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); + } } 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锛歝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 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/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/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锛團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/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锛坉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 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 + 鑾烽攣澶辫触 + 绛夊緟鏈熼棿寮傛浠诲姟瀹屾垚锛圧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/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} +