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.

757 lines
70 KiB

1 month ago
��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_: �h�RM羕�p4^��rNk f�gCo
**e��p.|**:
kkFileView ȓ�]�Y�~��V-�_�i��X�V�twO�| MinIO ��
YG_X��qP�“�V"k"�$1,|͓Nq!}^g�2](��R�Ycm�n� �P{yYtX�j�m�]?m���04^Yt?MinIO��? _Avoid_: `mG��`�m-[Gm��wOQ�^g?+
+**C%�fe�?*:
+`m�^+^“�V"k��,aG_��(h��̓�d�V(��R�vOp�T��ȕk� xOXm��V}e��T~u9pɅ�[ 200px��\�t�Y�e ���d}�mGZ�j 400px BiHj}JPEG ͓Nq!}�Y}"�$1,|S��]l�“�V"kR�Di0|m:j�S^p�fZ0`m�0�jcm�^%`b�Y^gɅ"kig�q� �PcPQ����`P��]���Y�P+^e��p�W9p~\�f�h�RM熒?+_Avoid_: Op�_Xm���Oi�YtXXm��xO�vȕ 2Xm
+
+**C%�fe�gR�Y��?*:
+�m� �mE��g`mAR #��0Xm(��RSe[�Ʌ�aȓ�qjZ�x0}`PENDING`�X�QO��0�i�[oTSe��6a}��cNREADY`�X�Q"��q�WY��Q~e��p}��cNFAILED`�%XxV�t?3 Z3 �`���`}cm�Ur_"�^S~uÓ5gbcT���xV�t�f}��cNUNSUPPORTED`�X�g`mAR��h�)[ {f�&1ne��}O�[e�Qg4U�~\Hh�m�]Y� bSe��6a}��?+_Avoid_: C%�fe�PcX~4d:�� zOSe��-a#rG�?+
+**C%�fe�gRSe��?*:
+Op�U+^“�V"k^g�2]�my� #��0Xm(��R�Ycm�n� �PXm��Vˆ Java e��`QB%�e�xlDF �t?PDFBox Z�SKq�~���z$i�x1}Office �t?LibreOffice headless Z�SKq�~���z$i�p� �Pne��xOXm��V}jpg/png/webp/gif/bmp�Y� 3NDF��1Nffice�IWocx/xlsx/pptx�Y}O�5g�}�~��7pS��]�u͓�V�� `UNSUPPORTED` �t�[C~��?+_Avoid_: C%�f��yO�We�?+
+**�[�P�"��q�W**:
+“�V"k�mAZ6}9p~\�WZ��^qeZ��^t_`m���YYt@�B_(��R #��0Xm"��q�W���P {�h(h���t �0wZ�~\�O��n3|���`��|\���t�� 3lC~R��U�z�[�_#eÕ�SᲕ�]2�"��q�W��?+_Avoid_: Z��^t_��D�Xm��xO"kig�qSe��?+
+**Z�~\�O��n3|**:
+C%�fe�C���Z�f��t �0wÓ�`�ZZ��^t_`m���YOp-lmn9p~\�W��\���Y�P�V�~*[�`�Y�0Y� bSe��,a߂�~
Y�}ig�em(��R�t#��0� �P #��0Xm�m�]I_�}\�`a����Y�Pˆ MinIO �d�t��?+_Avoid_: 9p�pi"��q�W��xOF]Ó5���̓?+
+**W��r�}e�?*:
+C%�fe��`)�\m?`PENDING`��cNFAILED` ��?`UNSUPPORTED` �5�� yOiig�emk�X�~��k“�V"k�~��7pe��p#rȕk� xOXm���P�X�~��h���qaq�}\P�X[R����z�[�rXm��?+_Avoid_: �i?j{�e��O� kNallbacke�?+
+**C%�fe��`���t?*:
+C%�fe��`jn MinIO �m`��k5pEx����[bjectKey �m?`thumbnails/{fileId}.jpg`�|\ {X��q�g`m���` bucket���P� 3lC~Z��^l� API �ma�Fmig�em��\��?`Cache-Control: max-age=86400` �[y�&}p:jT0�~\{yYtX�j�m�]?m���04^Yt?MinIO��?+_Avoid_: C%�fe��p�g`m��� |Nhumb5pEx��
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 Z�SKq�"X #��0Xm�~���z$i�x} -->
+ <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..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);
+ /**
+ * ~��\G_C%�fe��d}Z����`�Y�0�S4d�f� f�}�?+ * <p>�5�� ?READY�+l�| MinIO ��?thumbnails/{fileId}.jpg ig�em\m�\X~R�?+ cacheable=true�?+ * �5�� ?UNSUPPORTED/FAILED�3lQ~e��p0]cm�]Xm + cacheable=false�?+ * �5�� ?PENDING�3lQ~e��p0]cm�]Xm + cacheable=false�X�`�Y�0�S4d�fjn Ticket 04 9p�pG^�?/p>
+ *
+ * @param fileId “�V"k ID�X�t�~?����Y}̓�0�h���mkygZ*a {p:jjn�?0401�?+ * @return C%�fe��S)|ig�m�W + P�mT���~��7p + �d�t�~+h�f͓�V��
+ */
+ ThumbnailDTO getThumbnail(String fileId);
+
/*-------- R��U�X�mAZ6}�m Y���[�_}init +�?upload +�?complete�I[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<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,32 @@ public class FileProperties {
/** �h�RM�e��p.| presigned GET (��RAn��Xan */
private Duration presignTtl = Duration.ofMinutes(10);
}
+
+ @Data
+ public static class Thumbnail {
+
+ /** C%�fe��`Pm9p,l��4d<�}px�Y}�~ Y.vB%�e */
+ private int width = 200;
+
+ /** C%�fe�gR�t�Y�e ���S{ĕ0a�s4d<�}px�Y}Õ�S��ttT�f��D�Xm */
+ private int maxHeight = 400;
+
+ /** Hg�d�V͓Nq!}�VWpeg/png�?*/
+ private String format = "jpeg";
+
+ /** Z�~\�O��n3|�~
Y�}�tnTi�EkENDING Ó�`O^��xOQ0�t�0�`(��R�u�tm�{ĕ?*/
+ private Duration syncWaitTimeout = Duration.ofSeconds(10);
+
+ /** ȓ� �oGxV�t�f����.^}Hg�`�WZ��^#r�t?FAILED */
+ private int retryLimit = 3;
+
+ /** FAILED ���]/v����?_[�&1an�HWron�Y}�i?j{��Y�_I0Q�~\�j 4 �?*/
+ private String retryCron = "0 0 4 * * ?";
+
+ /** LibreOffice Y���Xt~\�g`m�`�w�[?*/
+ private String libreofficePath = "soffice";
+
+ /** LibreOffice W��f��^g�2]�tnTi�XG0“�V0�h-h 0Z�SKqY��XQHg�Q�S�?*/
+ 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;
/**
- * 9p-li`m���Y�[� O�y{}$i-W0m�Y�0�XÓ�r�v4d?��}�~O}ŕ�_�g`m5�g�@i)}O��0}
- * <p>0��d�X^���z`m���Y�,l�N�8Y Y���W��V�z�?{@link com.crm.file.task.OrphanChunkCleanupTask}</p>
+ * 9p-li`m���Y�m�^}�Y�0bcT�3 }O�y{}$i-W0m�Y�0�XÓ�r�v4d?��}�~O}ŕ�_�g`m5�g�@i)}O��0}
+ * <p>9p-li`m���Y�,l�N�8Y Y���W��V�z�?{@link com.crm.file.task.OrphanChunkCleanupTask}�?+ * �[�P�`m���Y�1l #��0Xm"��q�W {@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 {
/** �mX[iR��U�Xf�?MinIO �m`��k5pEx��S��]}�}\lu��?key�]khunks/{uploadId}/{R��U�X4d�__} */
String CHUNK_PREFIX = "chunks/";
+ /** C%�fe��`jn MinIO �m`��k5pEx��S��]}�}\lu��?key�ukhumbnails/{fileId}.jpg */
+ String THUMBNAIL_PREFIX = "thumbnails/";
+
+ /** C%�fe�gRSe��,a�W/u�Q!}��xOjn Redis �m`��k key S��]}�}\lu��?key�]krm:file:thumbnail:lock:{fileId} */
+ String THUMBNAIL_LOCK_PREFIX = "crm:file:thumbnail:lock:";
+
/*-------- “�V"k�Y3 ao�m,l�Y��k��.��O}62xxx��\�j�t��,|͓?README�?-------*/
/** “�V"k�oC�v�ttT�j�"X?m|m�r�y�mGZ�j / ����G0Op�_�yW��f�g`m5g{ĕ6a} */
@@ -30,4 +36,20 @@ public interface FileConstants {
/** R��U�X�oC�v���]�ucm�^,| 5MB �~��{ĕ6a}���]�uȓ�q�i�[&}ȕ�pde�� �7dcm�nJe�t$�} */
int CODE_CHUNK_SIZE_MISCONFIGURED = 62006;
+
+ /** C%�fe�gRSe��,aQ0�t�0}Z�SKq�[�f8d�[�P6r�?*/
+ int CODE_THUMBNAIL_GENERATION_FAILED = 62007;
+
+ /** C%�fe��`�`�Y�0�t�[pT�yÓ��}�[�P�`m���YȓD�lu��*a{Z�~\�O��n3|�tnTi�?*/
+ int CODE_THUMBNAIL_SYNC_TIMEOUT = 62008;
+
+ /** HTTP 202 Accepted�1l #��0Xm`m�]jn"��q�W�}\�X�~�2|�~�]�`���]/v�X�OOpwO�}�o,h�w�[�R{"���} */
+ int CODE_HTTP_ACCEPTED = 202;
+
+ /*-------- C%�fe�gR�Y��xO6r��?--------*/
+
+ 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 = "C%�fe��d}�m3 �vn*[;��-l�WT��qQ~e��p)|ig�m�We�gR�X4Z�O}�o���� JSON �m3 �v�?)
+ @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 = "R��r�j�%X� f�}R��r�j�?)
@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;
+ /** C%�fe�gR�Y���O}PENDING/READY/FAILED/UNSUPPORTED�Y}nullable Z�b�`O�Nq�� */
+ 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("��X[�g`m?hash�"XW�|m�r0]cm�]�tZ�x}�m� ȓ�q {P�k�S�?)
@Column(name = "file_hash", columnDefinition = "varchar(64) comment '��X[�g`m�yash(�~�c6}W��r�})'")
private String fileHash;
+
+ @Comment("C%�fe�gR�Y���O0}PENDING(�[oTSe��?/READY(�[,�Se��?/FAILED(���]/vp�@iVe)/UNSUPPORTED(�~��7p�m�]ne��?")
+ @Column(name = "thumbnail_status", columnDefinition = "varchar(16) not null default 'PENDING' comment 'C%�fe�gR�Y��?")
+ private String thumbnailStatus = "PENDING";
+
+ @Comment("C%�fe�gRSe��,aQ0�t�0xV�t�fx���.^}��,a�YZ��^�}ƕ?)
+ @Column(name = "thumbnail_retry_count", columnDefinition = "int not null default 0 comment 'C%�fe�C�xV�t�fx���?")
+ 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";
+ /** C%�fe��`�`�Y�0�S4d�f�u�t$2�hŕ�e}~��p#e�o����ÓAR�t�[mT}�Y�0bcT�3 lu��6a} */
+ private static final long THUMBNAIL_POLL_INTERVAL_MS = 500L;
+
/** R��U�X�oC�v�~��{ĕ6a0}composeObject 5pR_�jȓ���X�o'h�W��VAn 5MB ȓ� Op�_� ���[ɓ�q}���]�ucm�^,|�Y�0� Nq�`����@~�t?*/
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);
+ // C%�fe�gR�Y���O0}����[�~��7p PENDING�X}�Y�0Se��6a}�|\ {����[�~��7p 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);
+ // Yt@�B_�[�P�C%�fe�gRSe��6a}�m�]j�o�p{|m�r|d�t$�}
+ 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�t��G_�o���� +�?W��r�}e�?+ return placeholderOf(info, 200);
+ }
+
+ /**
+ * PENDING Z�~\�O��n3|�,l�SOp�o/v~��p#eZ�~\�"��q�W��mO^��xOQ0�t�0}�[�P�`m���Yf�(1�z�
Y�W^g�����~
Y�}
+ */
+ private ThumbnailDTO handlePending(FileInfo info) {
+ if (thumbnailGenerationTask.tryGenerateSync(info.getId())) {
+ // ~��p#e��,a�Y�m�eSe��0a� f�}�[6��z9p�\}���]aq�5�� xO~u�dIp
+ return toThumbnailAfterGeneration(info);
+ }
+ // ~��p#e�o���� +�?�Y?POLL_INTERVAL ^g�����5�� �O}�t?sync-wait-timeout ig�emW��r�}e�?+ 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())) {
+ // X� �Ojo READY�WAILED/UNSUPPORTED/R��r�j� Y {P��]�t�[?+ return placeholderOf(info, 200);
+ }
+ if (System.currentTimeMillis() >= deadline) {
+ log.info("C%�fe��`�`�Y�0�t�[pT�y��}ig�emW��r�}e�?+ 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);
+ }
+ }
+ }
+
+ /**
+ * Z�~\�"��q�WZ��^�[ȓ� “nb�Y��}OQ~e��p0}READY +�?*��q #��0Xm��m�`R�k0]cm�]Xm
+ */
+ 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);
+ }
+
+ /**
+ * `m?MinIO �td� #��0Xm5pEx����m��Y�'hQ0�t�0Q~e�?null "����v"�&1�ge��p� � W��r�}e�?+ */
+ 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("C%�fe�Pc��Y�'hQ0�t�0}objectKey={}�}\m��� W��r�}e�?, 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();
+ }
+
/*-------- R��U�X�mAZ6}�m Y���[?--------*/
@Override
@@ -289,7 +407,17 @@ public class FileApiImpl implements FileApi {
info.setBizDomain(session.getBizDomain());
// Y�#bcc\mp��N init �`��0��f�Q��U}strictInsertFill �m�]��)�-hjo null J�wx}
info.setCreatorId(session.getCreatorId());
+ // C%�fe�gR�Y���O0}����[�~��7p PENDING�X}�Y�0Se��6a}�|\ {����[�~��7p 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);
+ // Yt@�B_�[�P�C%�fe�gRSe��6a}�m�]j�o�p�`��X|d�t$�}
+ if (FileConstants.THUMBNAIL_STATUS_PENDING.equals(info.getThumbnailStatus())) {
+ thumbnailGenerationTask.generate(fileId);
+ }
// ��,a�Y�t��}w�G��ZoT�`�mX[iR��U�X�m�^0}�t�o1}ZoT�`�o��打m�]VY]��]�|˓�n}p�0�RR��U�XZoT�``m���YO��n3|
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<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));
}
+ /*-------- C%�fe�PcY�/b0}�mAZ6}Z�?PENDING/UNSUPPORTED --------*/
+
+ @Test
+ @DisplayName("�mAZ6}e�gR�X -> isSupported=true -> thumbnail_status=PENDING + ��*a&l�[�P�"��q�W")
+ 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("�mAZ6} PDF -> isSupported=true -> thumbnail_status=PENDING + ��*a&l�[�P�"��q�W")
+ 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("�mAZ6} Office�IWocx�?> isSupported=true -> thumbnail_status=PENDING + ��*a&l�[�P�"��q�W")
+ 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("�mAZ6}ȕ�pXm��V}txt�?> isSupported=false -> thumbnail_status=UNSUPPORTED + �m�]A_\m�0}�Y�0Se��?)
+ 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("R��U�XZ�X߂e�gR�X -> isSupported=true -> thumbnail_status=PENDING + ��*a&l�[�P�"��q�W")
+ 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�3l�ᯓnTaq�t?--------*/
@Test
@@ -396,6 +481,180 @@ class FileApiImplTest {
verify(minioClient, never()).getPresignedObjectUrl(any(GetPresignedObjectUrlArgs.class));
}
+ /*-------- getThumbnail�1l #��0Xm�tf�w�[?--------*/
+
+ 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 �5�� ?-> `m?MinIO �� Y #��0Xm��\Q~e�?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 �5�� ?-> ig�emW��r�}e�?+ cacheable=false�|\ {Yt>�k� MinIO C%�fe�Pc�w�[?)
+ 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 �5�� ?-> ig�emW��r�}e�?+ 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 + ~��p#e��,a�YZ�~\�"��q�W -> ���]aq READY -> ig�em*��q #��0Xm + 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);
+ // �h(h��̓?PENDING�}\�`�Y�0Se��,a�`���]aq 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 + ~��p#e�o���� + ^g�����tnTi`m?PENDING -> W��r�}e�?+ 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 + ~��p#e�o���� + �~
Y�}ȓ�q�h�[�P�`m���Y9p~\�W�'WEADY�?> ig�em*��q #��0Xm")
+ 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);
+ // �h(haq PENDING��\�u�t!2i�[�P�`m���Y�[�clu��,aI_ 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 + ~��p#e��,a�Ycm�USe��,a�``m�]jo READY�"XSe��,aQ0�t�0}-> W��r�}e�?+ 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("C%�fe��paqÓ�r}“�V"k�m�]�tf���} -> 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("C%�fe�?fileId ȕ�pvx͓Nq!} -> 40401")
+ void getThumbnail_malformedFileId_throwsResourceNotExist() {
+ assertThatThrownBy(() -> fileApi.getThumbnail("not-a-number"))
+ .isInstanceOf(ResourceNotExistException.class);
+ verify(fileInfoService, never()).getById(anyLong());
+ }
+
/*-------- R��U�X�mAZ6}�m Y���[?--------*/
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 @@
<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 Z�SKq�"X #��0Xm�~���z$i�x} -->
+ <dependency>
+ <groupId>org.apache.pdfbox</groupId>
+ <artifactId>pdfbox</artifactId>
+ <version>${pdfbox.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>