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.

2125 lines
180 KiB

1 month ago
��diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..899b5f5
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,24 @@
+# crm-backend ��'1�����nQ�X�`C%�fe�?Office Z�SKq��� Ǖ� (�?LibreOffice headless�?+# ˓�RÕ5����Akaven ,h'v���d�[
+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/
+# O�X�Zn�o�y�X�W"�?Docker ^p�P&}pHj}
+RUN mvn dependency:go-offline -q || true
+COPY . .
+RUN mvn package -DskipTests -q
+
+# ig/a�Õ5����=kRE 17 + LibreOffice headless�"X #��0Xm Office Z�SKq�?+FROM eclipse-temurin:17-jre
+# LibreOffice headless Y�AZQ�“�V��̓�d�XǕ� p>i�}�?-no-install-recommends ��C�Wcm�d�?+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_: �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..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);
+ /**
+ * ~��\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�3lO^��xO�`�Y�0Se��,a�`���]aq�5�� �O}READY ig�em*��q #��0Xm��mO^��xOQ0�t�0�W^g�����~
Y�}�?+ * �t?sync-wait-timeout `m?PENDING ig�emW��r�}e�?+ cacheable=false + statusCode=202</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�� + HTTP �5�� zOr
+ */
+ 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..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 {
/** �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);
+
+ /** "��q�WR��U�z�[�_#e TTL�,l@~$i��G0\m?officeConvertTimeout�}\�`R�k�SZ�SKqȓ�q�h��}OC~ȓ�q��w��dxV�o�]Se��?*/
+ 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;
/**
- * 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/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;
+
+/**
+ * C%�fe�PcQ~e��p�|˓�n}\m�\X~R�?+ O��Q�f��?+ �d�t�~+h�f͓�V���?+ * <p>cacheable=true Ó?Controller �t?Cache-Control: max-age=86400��lalse Ó�`�� no-cache</p>
+ */
+@Data
+@Builder
+public class ThumbnailDTO {
+
+ /** C%�fe�?W��r�}e��S)|ig�m�WP�mT�� */
+ private byte[] content;
+
+ /** P�mT���~��7p�"XanC%�fe�?image/jpeg�}\0]cm�]Xm image/png�?*/
+ private String contentType;
+
+ /** �`Y���]�d�t�JkEADY=true�bWax-age=86400�Y}O�5g�}=false�cWo-cache�?*/
+ private boolean cacheable;
+
+ /** HTTP �5�� zOr�-l�/u?200��m�`�Y�0�t�[pT�y��}`m?PENDING�YQ~e�?202 ��.aZ0S��]l��~�]�`���]/v */
+ @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("��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/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;
+
+/**
+ * C%�fe��`0]cm�]Xmȓ�]�Y�-l�[“�V"k��A%MwZ��]�Y��zOSe��-a�g`mAR��h�*[Xm͓�V0]cm�]Xm�%WNG�?+ * <p>200x200 �g3|�}\3w�m_� j�~�p�X^p�f�`“�V�t��mjoYt�UN�“�V"k��yOmnOp��NS��zOSe��,aQ0�t�0iig�em</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);
+
+ /**
+ * �� Y�X^p�f�`"��q�WW��r�}e��d1}�r�X^p�f�`��'h}/uJTie��p� � ��1ldeW��r�}e�?+ */
+ 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("W��r�}e�gRSe��,aQ0�t�0}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;
+
+/**
+ * C%�fe��p��̓�d�j���0[_�,l�vX��qP�“�V"kZ�SKq�my� #��0XmpDi�Y��nb�|
+ * <p>�Y�_R�“�V"k�~��7p�m� �mD��u�.^}e�gR�X/PDF/Office�Y}��3lC~ {@link #supports} �oHr�iw�D�A~�o�R�`(��R��h�?/p>
+ */
+public interface ThumbnailRenderer {
+
+ /**
+ * R��0�g�Y�0��̓�d�jē��`����[���V~u(��R�g`mAR��h�?+ *
+ * @param contentType MIME �~��7p�Xr_�~4s}
+ * @param ext Op�_SU��A%MwZ��]}�m�]+u�xv}Y��%�?+ * @return true t'1Z0�Y�0��̓�d�jY���N�o�R�`�t�0�g`mAR��h�?+ */
+ boolean supports(String contentType, String ext);
+
+ /**
+ * Op�U+^�o+[�g`m5�fyZ�SKq�my� #��0XmpDi�Y��nb�|
+ *
+ * @param original X��qP�“�V"kHg�d�S4Z�O}"����v"�&1�g�t�q�wO��b�h
+ * @param ext Op�_SU��A%MwZ��]}�m�]+u�xv}
+ * @param width C%�fe��`Pm9p,l��4d<�}px�?+ * @param maxHeight �~ Y.vB%�e�mGZ�jBi:j�[�gWx�?+ * @return C%�fe��`�tz��P�f�R}JPEG ͓Nq!}�?+ */
+ 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";
+ /** 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/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;
+
+/**
+ * e�gR�XC%�fe��p��̓�d�j�=kava ImageIO �t��G_ +�?�~ Y.vB%�e +�?JPEG Hg�d�V
+ * <p>����[ jpg/jpeg/png/gif/bmp��lebp Ǖ� ig/a�Ó?ImageIO ȓ
Y��4d?reader�XC_`m��}�?+ * �m�]r_"�&1i supports ig�em false ��?UNSUPPORTED��\)OO��]Se��,a@~���Q0�t�0f�9p-li`m���Y�r�j���]/v</p>
+ */
+@Slf4j
+@Service
+public class ImageThumbnailRenderer implements ThumbnailRenderer {
+
+ private static final Set<String> SUPPORTED_EXTS = Set.of("jpg", "jpeg", "png", "gif", "bmp");
+
+ /** webp YtG�rs�Ʌ�Y��!2ty�=kDK P�oT�u ImageIO Ó?webp reader��\6n�h�o;滓�c"k���]r_"�?*/
+ 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 �rvxYtG�re�gR�X�}\r_s���xr�[�_ {����[");
+ }
+ 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 C%�fe��p��̓�d�j�@kibreOffice headless Op�U�g�YH�Fm PNG�"X���m� $i�x}+�?�~ Y.vB%�e +�?JPEG Hg�d�V
+ * <p>�Y�_���t�Qdecm��de^���z UUID (�?UserInstallation profile��\)OO��]߂Y�bU�~�O1}
+ * �mX[i“�V"k�X+^“�V"k��Ʌtn + ^g�2]Hg�d�V + profile�
Yjn finally �m_��z�?/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 {
+ // X��q�g`m�`�`f�.^}LibreOffice Y�E�4^Y�Ai�g`m�`�w�[�R1}�m�o�f��A%MwZ��]�}O��`vR���xr�[�_}
+ 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 Hg�d�V PNG Ó�rvxYtG�r�? + png.getFileName());
+ }
+ return ThumbnailImageUtils.scaleToJpeg(pageImage, width, maxHeight);
+ } finally {
+ deleteRecursively(workDir);
+ }
+ }
+
+ /**
+ * �t?soffice --convert-to png��lrofile ŕ�e�� + �tnTi�m�o�Y
+ */
+ 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 �m?.cmd/.bat Ǖ� ?cmd /c YtI�tV�Xpnȓ�pty�t�f�nœ$�1}Docker/Linux �t?else R��Une�?+ 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)
+ // Hg�d�V�����g`m�`� �\joZ�~\� readAllBytes�3lX~�~+[�[�Y�� {O�?stdout Ó��}readAllBytes |m-lHh�mtTj�o�p��w��x�yÓ5gZ~���0Q0��?+ .redirectOutput(workDir.resolve("soffice.log").toFile())
+ .start();
+ boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS);
+ if (!finished) {
+ process.destroyForcibly();
+ // �~
Y�}*��q�X���\)OO��] { finally (��R YÓAR0m0��f�z��U�s\m?+ process.waitFor();
+ throw new IllegalStateException("LibreOffice ^g�2]�tnTi�? + 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 ^g�2]�o������[xit={}��[utput={}", exit, output);
+ throw new IllegalStateException("LibreOffice ^g�2]�o������[xit=" + exit);
+ }
+ }
+
+ private boolean isWindowsScript(String executable) {
+ String lower = executable.toLowerCase(Locale.ROOT);
+ return lower.endsWith(".cmd") || lower.endsWith(".bat");
+ }
+
+ /**
+ * LibreOffice Hg�d�V“�V"kZ��]� {Hg�d�S“�V"k�m���`}.png�}\�῕k�X��?outDir �m,[���m� �m?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 ȓC���Q�?PNG Hg�d�V");
+ }
+
+ 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) {
+ // �mX[i“�V"kZoT�`�o��打m�]VY]��]�[4ZzO�%��\qe�~d��|�mX[i)����}O��n3|
+ }
+ });
+ } catch (IOException e) {
+ log.warn("Office C%�fe��S YÓAR0m0��f�z��UQ0�t�00}{}", 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 C%�fe��p��̓�d�j�EkDFBox Z�SKq�~���z$ixQ�e�?+�?�~ Y.vB%�e +�?JPEG Hg�d�V
+ * <p>DPI 150 O�xY0ZnT�j4d?� {��FXQ��m�|Z�SKq�~���z$i?/p>
+ */
+@Slf4j
+@Service
+public class PdfThumbnailRenderer implements ThumbnailRenderer {
+
+ /** Z�SKq DPI�?50 f�?200px C%�fe��`��4d?�{ZnT�j4dA��Q�o�q}�m�e��̓�d� AiiY��6^ */
+ 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 Ó�r 0ȕ 24U9p?);
+ }
+ // �~y��j$i�x}Ó�r_scm�dm� Y��̓�d0}�o�����~\A_S��]�YQ��Tr_�t��}/u?+ PDPage firstPage = doc.getPage(0);
+ if (firstPage.getMediaBox() == null) {
+ throw new IllegalStateException("PDF �~���z$i5u�]Op?MediaBox�~\�h Z�f��̓?);
+ }
+ 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;
+
+/**
+ * C%�fe��`�S"�%1XmM��_)���UOO��Q0}�~ Y.vB%�e + JPEG Hg�d�V
+ * <p>e�gR�X/PDF/Office Z�SKqc�%1�vgZ,aXm�WufferedImage�YFmC%�fe��`�tz��P�kO���de�t��}</p>
+ */
+final class ThumbnailImageUtils {
+
+ private ThumbnailImageUtils() {
+ }
+
+ /**
+ * �~ Y.vB%�ew��QPm9p,l��4d?� {�mGZ�jBi:j�[�m�0ܑ�~=�koP��T}�m�]I_0�n�}��\�}Q�?JPEG pDi�Y��nb�|
+ *
+ * @param src gZ,aXm�X��̓�d�j\mC�V�}\r_s�PY~�oB,|C%�fe��`f5pi�}
+ * @param width C%�fe�gR0m͓�V��4d<�}px�?+ * @param maxHeight �~ Y.vB%�e�mGZ�jBi:j�[�gWx�?+ * @return JPEG +hr(��R #��0XmpDi�Y��nb�|
+ */
+ static byte[] scaleToJpeg(BufferedImage src, int width, int maxHeight) throws Exception {
+ int srcW = src.getWidth();
+ int srcH = src.getHeight();
+ // �~ Y.vB%�e�,lPm9p,l��4d?� {�mGZ�jBi:j�[�m�0ܑ�~=�koY�,h�}Op�_.vn6[}�m�o v�m�]I_0�?+ 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;
+
+/**
+ * C%�fe��`}�Y�0Se��*abcT�?+ * <p>�mAZ6}9p~\�WZ��^qe {@link com.crm.file.service.impl.FileApiImpl} ��*a&l�?+ * `m?MinIO ��
Y+^“�V"k +�?�t�qeR�g��4d?{@link ThumbnailRenderer} +�?C%�fe��`SUe�?MinIO +�?�5�� zO�u 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;
+
+ /**
+ * R��0�g“�V"k�~��7pē��`����[C%�fe�gRSe��6a}�mAZ6}Ó�`�v"���}P��Q~u PENDING ig;j�i UNSUPPORTED�?+ */
+ public boolean isSupported(String contentType, String ext) {
+ if (renderers == null || renderers.isEmpty()) {
+ return false;
+ }
+ return renderers.stream().anyMatch(r -> r.supports(contentType, ext));
+ }
+
+ /**
+ * �[�P�"��q�WC%�fe��d}@Async �m�o v�m�]j�o�p{|m�r|d�t$�}
+ *
+ * @param fileId “�V"k�mZZme
+ */
+ @Async
+ public void generate(Long fileId) {
+ generateWithLock(fileId);
+ }
+
+ /**
+ * Z�~\�O��n3|�,l�v�t�fO^��xO�`�~*[F]"��q�W�%X��Z����Y�P #��0Xm��}�Y�0bcT�!mn9p~\�W�tHr��t��}�?+ *
+ * @param fileId “�V"k�mZZme
+ * @return true t'1Z0~��p#e��,a�Y�m�eSe��0a� f�}�[�S�Xt}\lu�Y�f}ȓ� "X�Y��zOqe�t�Qde“R_xV̓�0}�?+ * false t'1Z0~��p#e�o�����X__ȓ YSe��-a�f�(1X~t�\}�t�Qde“pt2|^g�����~
Y�}�?+ */
+ public boolean tryGenerateSync(Long fileId) {
+ return generateWithLock(fileId);
+ }
+
+ /**
+ * R��U�z�[�_#e�m�o�Y�m+[�Xt�\Se��6a0}�[�P�`m���Y�m�^�`�Y�0�S4d�f�S"���}�m�o v����B_Y�F�Se��*a�zZ?+ */
+ private boolean generateWithLock(Long fileId) {
+ String lockKey = FileConstants.THUMBNAIL_LOCK_PREFIX + fileId;
+ // ��?TTL $i��G0\m�^6n��!2��̓�d}officeConvertTimeout�Y}Z�@��W��!2��̓�dan•�d#eig�Van5pp��V���]2�"��q�W
+ Duration lockTtl = fileProperties.getThumbnail().getLockTtl();
+ Boolean acquired;
+ try {
+ acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", lockTtl);
+ } catch (Exception e) {
+ log.warn("C%�fe�C�#e~��\G_�[�P6r��\�pig�VpnZ�Se��6a}fileId={}", fileId, e);
+ return false;
+ }
+ if (!Boolean.TRUE.equals(acquired)) {
+ return false;
+ }
+ try {
+ generateSync(fileId);
+ return true;
+ } finally {
+ redisTemplate.delete(lockKey);
+ }
+ }
+
+ /**
+ * ͓?z>~"��q�W��f�}�X�h���O}"�?{@link #generateWithLock} �m�o�Y�?+ */
+ void generateSync(Long fileId) {
+ FileInfo info = fileInfoService.getById(fileId);
+ if (info == null) {
+ log.warn("C%�fe�gRSe��/a�pig�V0}“�V"k�m�]�tf���}fileId={}", fileId);
+ return;
+ }
+ // �[�c)���UC~(��R {���]2�"��q�W
+ String status = info.getThumbnailStatus();
+ if (!FileConstants.THUMBNAIL_STATUS_PENDING.equals(status)) {
+ log.debug("C%�fe�gRSe��/a�pig�V0}�5�� ?{}��[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("C%�fe��p#r�t?UNSUPPORTED�`kileId={}, 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("C%�fe�gRSe��-a�WT��q0}fileId={}, objectKey={}", fileId, thumbKey);
+
+ } catch (Exception e) {
+ log.error("C%�fe�gRSe��,aQ0�t�00}fileId={}, thumbKey={}", fileId, thumbKey, e);
+ // �o��扲��]/v�t]��{�"X�007�Y0}retry_count++�~\mnp�@iVe�m�o�[ PENDING �~ Y{Z�Y�/b}p�@iVe?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("C%�fe�C�xV�t�f�����b� @iVe�~\#r�t?FAILED�`kileId={}, 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;
+
+/**
+ * C%�fe��`Q0�t�0xV�t�f~uÓ5gbcT�?+ * <p>����?_ thumbnail_status = FAILED (��R��0��f}���]�u�m?PENDING ��XxV“HrA_\m�0}�Y�0Se��6a1}
+ * LibreOffice �~ Y��̓�d)}���^ YÓ5�`fŕ�n�N�o�]�`�|\{�mD��aȓ�q�VT�(1��i.a #��0Xm��?+ * `m���Yw�G����[�P6r�m�]�V[��e0}try-catch V�pT��tHr�hG�Ki}�m)[ܑ[�&1anE;u�X2�"�%1�N��W�W��V�z��UbcT�!�[�_}</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) {
+ // ���]�u PENDING + ���]/v�t!�f0��cBm�%XxV“�bO^�[@ilu���dxV�t�f��4d<�}Z��^A_\m�0}�Y�0Se��?+ info.setThumbnailStatus(FileConstants.THUMBNAIL_STATUS_PENDING);
+ info.setThumbnailRetryCount(0);
+ fileInfoService.updateById(info);
+ thumbnailGenerationTask.generate(info.getId());
+ }
+ log.info("C%�fe��`Q0�t�0xV�t�f0}�[�gxV“HrA_\m?{} ɓ?FAILED �tg�}", failed.size());
+ } catch (Exception e) {
+ log.error("C%�fe��`Q0�t�0xV�t�fbcT�!�Xt}\}/ui�}�m)[ܑ[�&1anE;u", 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));
}
+ /*-------- 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/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} W��f�S4Z-[/v�"X�003�Y0}�~ Y.vB%�e�m�]I_0�l�� wO{ĕ0a�s4d>��[ɓ�q� #NPEG Hg�d�V��{Nupports R��0~u
+ */
+class ImageThumbnailRendererTest {
+
+ private final ImageThumbnailRenderer renderer = new ImageThumbnailRenderer();
+
+ /** P�mT�t"��q�W���V~uOp�T��(�?PNG pDi�Y4Z?*/
+ 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("9pɅXm 1000x500 -> �~ Y.vB%�ew��Q�� 200��\�s 100��\�}Q�?JPEG Y��.�?)
+ 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 �i�e�f�6kF D8
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
+ }
+
+ @Test
+ @DisplayName("�~'hXm 100x1000 -> Bi:j�[�mGZ�j 400 �~=�ko"��qef�}\�ḓ Y�t�Y�e w�?40�|\ {Y�:j0�")
+ 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("Op�_Xm 50x50 -> `m�]�e�oF�V9p?200�XPm9p,l��4dA����mY}")
+ 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("�rvxYtG�r(��Rfy -> ��?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�,lXm��V�X^p�f�`/image/* contentType ����[��[vg/tiff/ȕ�pXm��V {����[")
+ 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�xkebp �t�q�kig/a�Ó?ImageIO reader Y��de��M}�m�]r_"�&1i��?UNSUPPORTED Õ�S�hĕ0axV�t?)
+ 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} W��f�S4Z-[/v�"X�006�Y0}supports R��0~u��)NibreOffice �m�]r_"�(1ˆ�[�P6r��?+ * K�?soffice t�-lpn�Y!�Z^g�2]��,a�Y`i�\ vO�)1|d�t$�}��Ʌtn +�?^g�2] +�?B%�e JPEG +�?ZoT�`�?+ */
+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();
+ }
+
+ /** R��mK�?soffice .cmd t�-lpn�-l�Y�h�R�W PNG �o�]�WR�?--outdir�X��?LibreOffice ^g�2]\mE�X�?*/
+ 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�^kocx/xlsx/pptx/doc/xls/ppt �m?Office contentType ����[�}\�Scmk {����[")
+ 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 �m�]r_"���}Y���Xt~\�g`m5g {p:jjn�?> ���m}/ui�}�tgQ0�t�0xV�t�f���mY}�m?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("^g�2]��,a�Y�X�N soffice \mC�V PNG�?> �~ Y.vB%�eHg�d�V JPEG + �mX[i)����}�[�S�z�?)
+ void render_conversionSuccess_scalesToJpeg() throws Exception {
+ // �h�R�W�m� �[?800x600 PNG cm�n� LibreOffice (�?^g�2]\mE�X"
+ 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 �i�e�f
+ 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} W��f�S4Z-[/v�"X�005�Y0}�~���z$i�W��̓?+ �~ Y.vB%�e + JPEG Hg�d�V + supports R��0~u
+ */
+class PdfThumbnailRendererTest {
+
+ private final PdfThumbnailRenderer renderer = new PdfThumbnailRenderer();
+
+ /** P�mT�t"��q�W�m� $i?A4 �~+h�X 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 �~+h�X PDF -> Z�SKq�~���z$i5u�t�Y�e ��Pc�V9p?200��\�}Q�?JPEG Y��.�?)
+ 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 �~+h�X�?95x842pt� Y��̓�d�`9p�N�s�Y�e�[ 1:1.414�}\�� 200 ÓX�s�~?283�~\mnYt@�7_ 400 �mGZ�j
+ assertThat(decoded.getWidth()).isEqualTo(200);
+ assertThat(decoded.getHeight()).isBetween(280, 290);
+ // JPEG �i�e�f�6kF D8
+ assertThat(thumb[0] & 0xFF).isEqualTo(0xFF);
+ assertThat(thumb[1] & 0xFF).isEqualTo(0xD8);
+ }
+
+ @Test
+ @DisplayName("ȕ?PDF pDi�Y4Z?-> ���m}/ui�}PDFBox YtF�=p�o�����?)
+ 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�qkdf ��A%MwZ�?application/pdf ����[�}\�Scmk {����[")
+ 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} W��f�S4Z-[/v�"X�003�Y0}
+ * "��q�W��,a�YP�?MinIO + �5�� ?READY + retry_count 0��cBm��mjo PENDING �t�[C~��m�hZ�SKqc�'1�u 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);
+ }
+
+ /** �i?j{ᵓ�d0�3lO^��yO�WT�?*/
+ 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(""��q�W��,a�Y -> P�?MinIO thumbnails/{fileId}.jpg + �5�� ?READY + retry_count 0��cBm + ��CZ�e��?)
+ 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);
+
+ // C%�fe��`SUO�?MinIO�pkbjectKey = thumbnails/{fileId}.jpg�}\4U9p-W��h�?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");
+
+ // �5�� zO�u READY + retry_count 0��cBm
+ 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();
+ // "��q�W9p~\/vZ��^tV��C�#e
+ verify(redisTemplate).delete("crm:file:thumbnail:lock:1001");
+ }
+
+ @Test
+ @DisplayName("~��p#e�o�����X__ȓ YSe��,ajn�t/b}-> ig�em false�|\ {��F�"��q�W��f�}")
+ void generate_lockNotAcquired_skips() {
+ stubLockAcquired(false);
+
+ assertThat(task.tryGenerateSync(FILE_ID)).isFalse();
+
+ verify(fileInfoService, never()).getById(any());
+ verifyNoInteractions(minioClient);
+ }
+
+ @Test
+ @DisplayName("����B_�m�0�w -> R��U�z�[�_#e SET NX �m�o vY�E�An�m� �t��Xt�\Se��?)
+ 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();
+
+ // ��wO�N NX �t]��{~��\G_�,l�`�m� key (��R��\m~\��Op�o/v"�?setIfAbsent ig�em false ��=��W�X�X�YB%���q}
+ 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");
+ // Y�F�Se��*a!|�m� Z?+ verify(minioClient).putObject(any(PutObjectArgs.class));
+ }
+
+ @Test
+ @DisplayName("“�V"k�m�]�tf�?-> ȕk�|�t�[C~�|\ {Yt?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("�5�� �Ojo PENDING�X�Q READY�?> �t�[C~�m�]xV�o�]Se��?)
+ 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("Ó�r.\���]��̓�d�j -> �5�� zO�u 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 �o�e�XZ�SKqc�%1�Wt��0}`m���z supports=true W�k�ne��?)
+ 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�,lQ0�t�0xV�t�f���m?--------*/
+
+ 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("Z�SKq�o����"));
+ }
+
+ @Test
+ @DisplayName(""��q�W�o���� + retry_count(2) < retry-limit(5) -> retry_count++ �m�o�[ PENDING")
+ void generate_failure_belowLimit_staysPending() throws Exception {
+ stubRenderThrows();
+ // �t�Q�s limit��\ �m?2+1=3 ȓG�� @iVe
+ 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(""��q�W�o���� + ���]/vp�@iVe�nWetry_count Hg?retry-limit�?> ?FAILED")
+ void generate_failure_limitReached_marksFailed() throws Exception {
+ stubRenderThrows();
+ // pendingInfo() �i?j{� retry_count=2�}\Q0�t�0�` +1 = 3 = retry-limit��\� @iVe
+ 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 ��
Y+^“�V"k�[�P6r -> Z�~\qr�t3 �S���]/v�|\ { 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 �m�]r_"�?));
+
+ 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} W��f�S4Z-[/v�"X�007�Y0}FAILED �tg�}���]�u PENDING + ���]�g��*a&l��m�V엢�}/uP� { 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 �tg�} -> ���]�u PENDING + retry_count 0��cBm + ��*a&l�[�P�"��q�W")
+ 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 �tg�} -> ȕk�|�dko�|\ {��*a&l`m���}`m���Y")
+ void retry_noFailedRecords_noop() {
+ when(fileInfoService.list(any(Wrapper.class))).thenReturn(List.of());
+
+ retryTask.retryFailedThumbnails();
+
+ verify(fileInfoService, never()).updateById(any());
+ verifyNoInteractions(thumbnailGenerationTask);
+ }
+
+ @Test
+ @DisplayName("`m���Yw�G����[�P6r�m?crash�Xaq�t!2�Y�[�P6rt���`��Y}�m*[�aȓ�q7bh�}")
+ void retry_queryThrows_swallowed() {
+ when(fileInfoService.list(any(Wrapper.class))).thenThrow(new RuntimeException("DB �m�]r_"�?));
+
+ 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 Z�SKq�"X #��0Xm�~���z$i�x} -->
+ <dependency>
+ <groupId>org.apache.pdfbox</groupId>
+ <artifactId>pdfbox</artifactId>
+ <version>${pdfbox.version}</version>
+ </dependency>
</dependencies>
</dependencyManagement>