diff --git a/.env.example b/.env.example
index 9f3285ab..7a57cb01 100644
--- a/.env.example
+++ b/.env.example
@@ -32,6 +32,8 @@ AI_RUNTIME_ENABLED=false
AI_RUNTIME_ENDPOINT=http://127.0.0.1:8000/internal/v1/analyses
AI_RUNTIME_RENEWAL_ENDPOINT=http://127.0.0.1:8000/internal/v1/workflows/renewal/run
AI_DOCUMENT_GENERATION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/generate
+AI_DOCUMENT_CONVERSION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/convert
+AI_DOCUMENT_CONVERSION_TIMEOUT=60s
# AI_RUNTIME_SERVICE_CREDENTIAL=
AI_RUNTIME_CONNECT_TIMEOUT=2s
AI_RUNTIME_OVERALL_TIMEOUT=240s
diff --git a/compose.demo.yml b/compose.demo.yml
index 31739165..afb2a262 100644
--- a/compose.demo.yml
+++ b/compose.demo.yml
@@ -50,6 +50,8 @@ services:
SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT: ${SPRING_MAIL_PROPERTIES_MAIL_SMTP_WRITETIMEOUT:-5000}
AI_RUNTIME_ENABLED: ${AI_RUNTIME_ENABLED:-false}
AI_RUNTIME_ENDPOINT: ${AI_RUNTIME_ENDPOINT:-http://host.docker.internal:8000/internal/v1/analyses}
+ AI_DOCUMENT_CONVERSION_ENDPOINT: ${AI_DOCUMENT_CONVERSION_ENDPOINT:-http://host.docker.internal:8000/api/v1/documents/convert}
+ AI_DOCUMENT_CONVERSION_TIMEOUT: ${AI_DOCUMENT_CONVERSION_TIMEOUT:-60s}
AI_RUNTIME_SERVICE_CREDENTIAL: ${AI_RUNTIME_SERVICE_CREDENTIAL:-}
WORKER_PORTAL_BASE_URL: ${WORKER_PORTAL_BASE_URL:-http://localhost:5173}
WORKER_LINK_SMS_PROVIDER: ${WORKER_LINK_SMS_PROVIDER:-none}
diff --git a/docs/ai-runtime-contract.md b/docs/ai-runtime-contract.md
index 31b39f15..7f2e03f5 100644
--- a/docs/ai-runtime-contract.md
+++ b/docs/ai-runtime-contract.md
@@ -381,6 +381,8 @@ AI_RUNTIME_SERVICE_CREDENTIAL=<배포 환경 Secret>
| `AI_RUNTIME_OVERALL_TIMEOUT` | `15s` | 연결·요청·응답 수신 전체의 Server 상한 |
| `AI_RUNTIME_MAX_RESPONSE_BYTES` | `1048576` | 응답을 메모리에 받기 전 적용하는 최대 크기 |
| `AI_DOCUMENT_GENERATION_ENDPOINT` | `http://127.0.0.1:8000/api/v1/documents/generate` | Renewal 문서 생성 API |
+| `AI_DOCUMENT_CONVERSION_ENDPOINT` | `http://127.0.0.1:8000/api/v1/documents/convert` | HWP·HWPX를 PDF 미리보기로 변환하는 API |
+| `AI_DOCUMENT_CONVERSION_TIMEOUT` | `60s` | 사용자 요청 안에서 문서 변환을 기다리는 최대 시간 |
| `AI_DOCUMENT_GENERATION_MAX_RESPONSE_BYTES` | `20971520` | 생성 파일을 메모리에 받기 전 적용하는 최대 크기 |
| `AI_RUNTIME_MAX_CONCURRENT_CALLS` | `8` | Server 한 인스턴스가 동시에 보내는 최대 호출 수 |
| `AI_RUNTIME_CIRCUIT_BREAKER_FAILURE_THRESHOLD` | `5` | 연속 장애 후 호출을 잠시 막는 기준 |
diff --git a/docs/api-documentation.md b/docs/api-documentation.md
index a98f312b..7e22ae8e 100644
--- a/docs/api-documentation.md
+++ b/docs/api-documentation.md
@@ -6,6 +6,7 @@ JSON을 읽기 쉬운 화면으로 변환한 문서입니다.
- 팀 공유 사이트:
- OpenAPI JSON:
- 로컬 Swagger UI:
+- 파일 미리보기·합성 문서 확인: [file-preview.md](file-preview.md)
## 어떤 문서인가요?
diff --git a/docs/file-preview.md b/docs/file-preview.md
new file mode 100644
index 00000000..b15716b5
--- /dev/null
+++ b/docs/file-preview.md
@@ -0,0 +1,99 @@
+# 문서 미리보기와 합성 데이터 품질 확인
+
+## 한눈에 보기
+
+HR 담당자가 문서함에서 파일을 내려받기 전에 내용을 확인할 수 있도록 다음 API를 사용한다.
+
+```http
+GET /api/v1/files/{fileId}/preview
+Authorization: Bearer
+```
+
+| 원본 형식 | Server 동작 | 응답 형식 |
+| --- | --- | --- |
+| PDF | 원본을 브라우저에 바로 표시 | `application/pdf` |
+| JPG·PNG·WEBP | 원본을 브라우저에 바로 표시 | 원본 이미지 MIME |
+| HWP·HWPX | AI 문서 변환 API로 PDF 변환 | `application/pdf` |
+| 그 외 형식 | 미리보기 거부 | `415 FILE_PREVIEW_UNSUPPORTED` |
+
+원본 다운로드는 기존 `GET /api/v1/files/{fileId}/content`를 그대로 사용한다. 미리보기는
+원본 파일이나 DB 레코드를 변경하지 않고 필요할 때만 변환하므로 별도 Flyway Migration이 없다.
+
+## 처리 흐름
+
+```text
+Client 미리보기 클릭
+→ Server가 JWT 역할과 companyId 확인
+→ stored_file과 저장소 원본 조회
+→ PDF·이미지는 inline 반환
+→ HWP·HWPX는 AI /api/v1/documents/convert 호출
+→ PDF signature와 응답 크기 검증
+→ Client에 inline PDF 반환
+```
+
+다른 사업장의 fileId는 파일 존재 여부가 드러나지 않도록 `404`로 처리한다. 응답에는
+`Cache-Control: no-store`, `Content-Disposition: inline`, `X-Content-Type-Options: nosniff`를
+적용한다. AI URL·인증 토큰·오류 본문은 Client 응답과 일반 로그에 노출하지 않는다.
+
+## 로컬에서 확인하기
+
+PDF와 이미지 미리보기는 AI 없이 확인할 수 있다. HWP·HWPX 미리보기까지 확인하려면 AI
+문서 변환 서버와 LibreOffice 변환 기능이 실행 중이어야 한다.
+
+```dotenv
+AI_RUNTIME_ENABLED=true
+AI_DOCUMENT_CONVERSION_ENDPOINT=http://127.0.0.1:8000/api/v1/documents/convert
+AI_DOCUMENT_CONVERSION_TIMEOUT=60s
+AI_RUNTIME_SERVICE_CREDENTIAL=
+```
+
+1. AI와 Server를 실행한다.
+2. 에서 로그인한다.
+3. 반환된 Access Token을 `Authorize`에 입력한다.
+4. `GET /api/v1/files/{fileId}/preview`에 문서의 `fileId`를 넣어 실행한다.
+5. `200`, `Content-Type: application/pdf`, `Content-Disposition: inline`을 확인한다.
+
+AI가 꺼져 있어도 PDF·이미지 미리보기는 정상 동작한다. 이 상태에서 HWP·HWPX를 요청하면
+무한 대기나 빈 파일 대신 `503 FILE_PREVIEW_UNAVAILABLE`을 반환한다.
+
+## 오류 기준
+
+| HTTP | 의미 | 담당자 행동 |
+| --- | --- | --- |
+| 404 | 파일이 없거나 다른 사업장 파일 | 올바른 문서인지 확인 |
+| 415 | 지원하지 않는 형식 | 원본 다운로드 사용 |
+| 422 | 손상됐거나 변환할 수 없는 HWP·HWPX | 원본 파일 교체·재생성 |
+| 503 | AI 변환 기능 비활성 또는 장애 | AI·LibreOffice 설정 확인 후 재시도 |
+
+## 합성 문서 품질 후속 확인
+
+PR #183의 합성 문서가 병합된 뒤 아래 항목을 문서별로 확인한다. 이 작업은 Preview API와
+분리하여 진행해 다른 팀의 Seed 파일 변경과 충돌하지 않게 한다.
+
+- Worker DB의 표시 이름·국적·체류기간과 문서 본문·메타데이터가 일치하는가?
+- 텍스트 잘림, 빈 페이지, 겹침, 깨진 글꼴이 없는가?
+- 실존 개인정보·기관 직인·공식 문서로 오인할 요소가 없고 `DEMO`·`NOT VALID` 표시가 있는가?
+- OCR로 읽을 핵심 칸을 워터마크가 가리지 않는가?
+- PDF·이미지는 Preview API에서 열리고 HWP·HWPX는 PDF로 변환되는가?
+- 여권번호·외국인등록번호 같은 값이 일반 로그와 오류 응답에 남지 않는가?
+
+품질이 부족한 문서는 원본 생성 규칙을 수정하고, 수정 전후 Preview 화면과 확인한 필드 목록을
+#186에 남긴다.
+
+## 2026-08-16 실제 변환 확인 결과
+
+최신 main과 AI main의 문서 변환 코드를 사용해 합성 Template 4개를 실제 LibreOffice 엔진으로
+변환했다. Server의 HTTP 계약과 오류 처리는 정상이나, 아래 Provider 품질 문제가 확인되어
+HWP·HWPX 미리보기는 아직 데모 활성화 대상이 아니다.
+
+| 입력 파일 | 실제 결과 | 판정 |
+| --- | --- | --- |
+| `employment-contract-template.hwp` | PDF 생성, 136,363 bytes, 60페이지 | 깨진 문자와 비정상 페이지 수로 사용 불가 |
+| `employment-contract-template.hwpx` | `source file could not be loaded` | 변환 실패 |
+| `employment-extension-template.hwpx` | `source file could not be loaded` | 변환 실패 |
+| `integrated-application-template.hwpx` | `source file could not be loaded` | 변환 실패 |
+
+검증 명령과 환경은 macOS, LibreOffice headless, AI `HwpToPdfConverter`·`HwpxToPdfConverter`를
+사용했다. 변환 실패 상태에서는 Server가 원본이나 DB를 변경하지 않고 422 또는 503으로
+종료한다. 실제 변환 기능은 AI의 HWP/HWPX 렌더링 경로가 보완되고 동일 fixture Smoke Test가
+통과한 뒤 활성화한다.
diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java
index e0008084..2e9f5a8b 100644
--- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java
+++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeHttpConfiguration.java
@@ -7,6 +7,7 @@
import com.fowoco.server.aiintegration.application.validation.RenewalRuntimeContractValidator;
import com.fowoco.server.aiintegration.application.validation.ValidatingAiRuntimeClient;
import com.fowoco.server.aiintegration.application.validation.ValidatingRenewalRuntimeClient;
+import com.fowoco.server.file.application.port.DocumentPreviewConverter;
import java.net.http.HttpClient;
import java.time.Clock;
import java.time.Duration;
@@ -112,6 +113,21 @@ public DocumentGenerationClient documentGenerationClient(
);
}
+ @Bean
+ public DocumentPreviewConverter documentPreviewConverter(AiRuntimeProperties properties) {
+ if (!properties.isEnabled()) {
+ return new DisabledDocumentPreviewConverter();
+ }
+ properties.validateEnabledConfiguration();
+ return new RemoteDocumentPreviewConverter(
+ properties.getDocumentConversionEndpoint(),
+ properties.authorizationHeader(),
+ properties.getDocumentConversionTimeout(),
+ properties.getMaxDocumentResponseBytes(),
+ createHttpClient(properties.getConnectTimeout())
+ );
+ }
+
static HttpClient createHttpClient(Duration connectTimeout) {
return HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_1_1)
diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java
index b02bfec2..67e98b15 100644
--- a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java
+++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimeProperties.java
@@ -17,9 +17,11 @@ public final class AiRuntimeProperties implements AiRuntimeDeadlinePolicy {
private URI endpoint = URI.create("http://127.0.0.1:8000/internal/v1/analyses");
private URI renewalEndpoint = URI.create("http://127.0.0.1:8000/internal/v1/workflows/renewal/run");
private URI documentGenerationEndpoint = URI.create("http://127.0.0.1:8000/api/v1/documents/generate");
+ private URI documentConversionEndpoint = URI.create("http://127.0.0.1:8000/api/v1/documents/convert");
private String serviceCredential;
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration overallTimeout = Duration.ofMinutes(4);
+ private Duration documentConversionTimeout = Duration.ofSeconds(60);
private int maxResponseBytes = 1_048_576;
private int maxDocumentResponseBytes = MAX_DOCUMENT_RESPONSE_BYTES;
private int maxConcurrentCalls = 8;
@@ -58,6 +60,14 @@ public void setDocumentGenerationEndpoint(URI documentGenerationEndpoint) {
this.documentGenerationEndpoint = requireHttpEndpoint(documentGenerationEndpoint);
}
+ public URI getDocumentConversionEndpoint() {
+ return documentConversionEndpoint;
+ }
+
+ public void setDocumentConversionEndpoint(URI documentConversionEndpoint) {
+ this.documentConversionEndpoint = requireHttpEndpoint(documentConversionEndpoint);
+ }
+
public void setServiceCredential(String serviceCredential) {
this.serviceCredential = serviceCredential;
}
@@ -91,6 +101,18 @@ public int getMaxResponseBytes() {
return maxResponseBytes;
}
+ public Duration getDocumentConversionTimeout() {
+ return documentConversionTimeout;
+ }
+
+ public void setDocumentConversionTimeout(Duration documentConversionTimeout) {
+ Duration validated = requirePositive(documentConversionTimeout, "documentConversionTimeout");
+ if (validated.compareTo(MAX_OVERALL_TIMEOUT) > 0) {
+ throw new IllegalArgumentException("documentConversionTimeout must not exceed 5m");
+ }
+ this.documentConversionTimeout = validated;
+ }
+
public void setMaxResponseBytes(int maxResponseBytes) {
if (maxResponseBytes < MIN_RESPONSE_BYTES || maxResponseBytes > MAX_RESPONSE_BYTES) {
throw new IllegalArgumentException("maxResponseBytes must be between 1 KiB and 10 MiB");
@@ -159,9 +181,11 @@ void validateEnabledConfiguration() {
requireHttpEndpoint(endpoint);
requireHttpEndpoint(renewalEndpoint);
requireHttpEndpoint(documentGenerationEndpoint);
+ requireHttpEndpoint(documentConversionEndpoint);
authorizationHeader();
requirePositive(connectTimeout, "connectTimeout");
requirePositive(overallTimeout, "overallTimeout");
+ requirePositive(documentConversionTimeout, "documentConversionTimeout");
}
private static URI requireHttpEndpoint(URI value) {
diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledDocumentPreviewConverter.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledDocumentPreviewConverter.java
new file mode 100644
index 00000000..bde76c8f
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledDocumentPreviewConverter.java
@@ -0,0 +1,16 @@
+package com.fowoco.server.aiintegration.infrastructure.http;
+
+import com.fowoco.server.file.application.DocumentPreviewConversionException;
+import com.fowoco.server.file.application.DocumentPreviewSource;
+import com.fowoco.server.file.application.port.DocumentPreviewConverter;
+
+final class DisabledDocumentPreviewConverter implements DocumentPreviewConverter {
+
+ @Override
+ public byte[] convertToPdf(DocumentPreviewSource source) {
+ throw new DocumentPreviewConversionException(
+ DocumentPreviewConversionException.Reason.UNAVAILABLE,
+ "Document preview conversion is disabled."
+ );
+ }
+}
diff --git a/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverter.java b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverter.java
new file mode 100644
index 00000000..ca6066d7
--- /dev/null
+++ b/src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverter.java
@@ -0,0 +1,193 @@
+package com.fowoco.server.aiintegration.infrastructure.http;
+
+import com.fowoco.server.file.application.DocumentPreviewConversionException;
+import com.fowoco.server.file.application.DocumentPreviewSource;
+import com.fowoco.server.file.application.port.DocumentPreviewConverter;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.net.http.HttpTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+
+/** HWP·HWPX 원본을 Agent 문서 변환 API에 보내 PDF 미리보기로 변환합니다. */
+final class RemoteDocumentPreviewConverter implements DocumentPreviewConverter {
+
+ private static final byte[] PDF_SIGNATURE = "%PDF-".getBytes(StandardCharsets.US_ASCII);
+
+ private final URI endpoint;
+ private final String authorizationHeader;
+ private final Duration overallTimeout;
+ private final int maxResponseBytes;
+ private final HttpClient httpClient;
+
+ RemoteDocumentPreviewConverter(
+ URI endpoint,
+ String authorizationHeader,
+ Duration overallTimeout,
+ int maxResponseBytes,
+ HttpClient httpClient
+ ) {
+ this.endpoint = Objects.requireNonNull(endpoint);
+ this.authorizationHeader = requireText(authorizationHeader, "authorizationHeader");
+ this.overallTimeout = requirePositive(overallTimeout, "overallTimeout");
+ if (maxResponseBytes < 1) {
+ throw new IllegalArgumentException("maxResponseBytes must be positive");
+ }
+ this.maxResponseBytes = maxResponseBytes;
+ this.httpClient = Objects.requireNonNull(httpClient);
+ }
+
+ @Override
+ public byte[] convertToPdf(DocumentPreviewSource source) {
+ Objects.requireNonNull(source);
+ try {
+ String boundary = "fowoco-preview-" + UUID.randomUUID();
+ HttpRequest request = HttpRequest.newBuilder(endpoint)
+ .timeout(overallTimeout)
+ .header("Authorization", authorizationHeader)
+ .header("Accept", "application/pdf")
+ .header("Content-Type", "multipart/form-data; boundary=" + boundary)
+ .POST(multipart(source, boundary))
+ .build();
+ HttpResponse response = execute(request);
+ if (response.statusCode() < 200 || response.statusCode() >= 300) {
+ throw classifyStatus(response.statusCode());
+ }
+ if (!isPdf(response.body())) {
+ throw invalid("Document preview response is not a PDF.");
+ }
+ return response.body();
+ } catch (DocumentPreviewConversionException exception) {
+ throw exception;
+ } catch (RuntimeException exception) {
+ throw unavailable("Document preview transport failed.", exception);
+ }
+ }
+
+ private HttpRequest.BodyPublisher multipart(DocumentPreviewSource source, String boundary) {
+ byte[] preamble = ("--" + boundary + "\r\n"
+ + "Content-Disposition: form-data; name=\"file\"; filename=\""
+ + safeFileName(source.fileName()) + "\"\r\n"
+ + "Content-Type: " + safeMimeType(source.mimeType()) + "\r\n\r\n")
+ .getBytes(StandardCharsets.UTF_8);
+ byte[] epilogue = ("\r\n--" + boundary + "\r\n"
+ + "Content-Disposition: form-data; name=\"target_format\"\r\n\r\n"
+ + "pdf\r\n--" + boundary + "--\r\n")
+ .getBytes(StandardCharsets.UTF_8);
+ return HttpRequest.BodyPublishers.concat(
+ HttpRequest.BodyPublishers.ofByteArray(preamble),
+ HttpRequest.BodyPublishers.ofByteArray(source.content()),
+ HttpRequest.BodyPublishers.ofByteArray(epilogue)
+ );
+ }
+
+ private HttpResponse execute(HttpRequest request) {
+ CompletableFuture> future = httpClient.sendAsync(
+ request,
+ new LimitedByteArrayBodyHandler(maxResponseBytes)
+ );
+ try {
+ return future.get(overallTimeout.toMillis(), TimeUnit.MILLISECONDS);
+ } catch (TimeoutException exception) {
+ future.cancel(true);
+ throw unavailable("Document preview conversion timed out.", exception);
+ } catch (InterruptedException exception) {
+ future.cancel(true);
+ Thread.currentThread().interrupt();
+ throw unavailable("Document preview conversion was interrupted.", exception);
+ } catch (ExecutionException exception) {
+ Throwable cause = unwrap(exception.getCause());
+ if (cause instanceof HttpTimeoutException || cause instanceof TimeoutException) {
+ throw unavailable("Document preview conversion timed out.", cause);
+ }
+ if (cause instanceof LimitedByteArrayBodyHandler.ResponseTooLargeException) {
+ throw unavailable("Document preview response is too large.", cause);
+ }
+ throw unavailable("Document preview transport failed.", cause);
+ }
+ }
+
+ private DocumentPreviewConversionException classifyStatus(int status) {
+ if (status == 400 || status == 404 || status == 409 || status == 413 || status == 415 || status == 422) {
+ return invalid("Document preview request was rejected.");
+ }
+ return unavailable("Document preview conversion is unavailable.");
+ }
+
+ private boolean isPdf(byte[] content) {
+ if (content.length < PDF_SIGNATURE.length) {
+ return false;
+ }
+ for (int index = 0; index < PDF_SIGNATURE.length; index++) {
+ if (content[index] != PDF_SIGNATURE[index]) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private String safeFileName(String fileName) {
+ String baseName = fileName.replace('\\', '/');
+ baseName = baseName.substring(baseName.lastIndexOf('/') + 1);
+ return baseName.replace("\r", "").replace("\n", "").replace("\"", "");
+ }
+
+ private String safeMimeType(String mimeType) {
+ String normalized = mimeType.replace("\r", "").replace("\n", "").strip();
+ return normalized.isBlank() ? "application/octet-stream" : normalized;
+ }
+
+ private Throwable unwrap(Throwable throwable) {
+ Throwable current = throwable;
+ while ((current instanceof ExecutionException
+ || current instanceof java.util.concurrent.CompletionException)
+ && current.getCause() != null) {
+ current = current.getCause();
+ }
+ return current;
+ }
+
+ private DocumentPreviewConversionException invalid(String message) {
+ return new DocumentPreviewConversionException(
+ DocumentPreviewConversionException.Reason.INVALID_DOCUMENT,
+ message
+ );
+ }
+
+ private DocumentPreviewConversionException unavailable(String message) {
+ return new DocumentPreviewConversionException(
+ DocumentPreviewConversionException.Reason.UNAVAILABLE,
+ message
+ );
+ }
+
+ private DocumentPreviewConversionException unavailable(String message, Throwable cause) {
+ return new DocumentPreviewConversionException(
+ DocumentPreviewConversionException.Reason.UNAVAILABLE,
+ message,
+ cause
+ );
+ }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value;
+ }
+
+ private static Duration requirePositive(Duration value, String field) {
+ if (value == null || value.isZero() || value.isNegative()) {
+ throw new IllegalArgumentException(field + " must be positive");
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/com/fowoco/server/file/api/FileController.java b/src/main/java/com/fowoco/server/file/api/FileController.java
index 7c0bed2f..3283dad3 100644
--- a/src/main/java/com/fowoco/server/file/api/FileController.java
+++ b/src/main/java/com/fowoco/server/file/api/FileController.java
@@ -7,6 +7,8 @@
import com.fowoco.server.common.web.RequestMetadata;
import com.fowoco.server.file.application.FileCreateCommand;
import com.fowoco.server.file.application.FileDownloadResult;
+import com.fowoco.server.file.application.FilePreviewResult;
+import com.fowoco.server.file.application.FilePreviewService;
import com.fowoco.server.file.application.FileService;
import com.fowoco.server.file.domain.StoredFile;
import io.swagger.v3.oas.annotations.Operation;
@@ -45,10 +47,16 @@
public class FileController {
private final FileService fileService;
+ private final FilePreviewService filePreviewService;
private final ActorContextProvider actorContextProvider;
- public FileController(FileService fileService, ActorContextProvider actorContextProvider) {
+ public FileController(
+ FileService fileService,
+ FilePreviewService filePreviewService,
+ ActorContextProvider actorContextProvider
+ ) {
this.fileService = fileService;
+ this.filePreviewService = filePreviewService;
this.actorContextProvider = actorContextProvider;
}
@@ -152,6 +160,53 @@ public ResponseEntity download(
.body(new InputStreamResource(result.content()));
}
+ @Operation(
+ operationId = "previewFile",
+ summary = "파일 미리보기",
+ description = "PDF와 이미지는 브라우저에서 바로 표시하고 HWP·HWPX는 AI 문서 변환기를 통해 PDF로 반환합니다. "
+ + "원본 파일은 변경하지 않으며 다른 사업장의 파일은 404로 응답합니다."
+ )
+ @ApiResponses({
+ @ApiResponse(
+ responseCode = "200",
+ description = "미리보기 성공. HWP·HWPX는 application/pdf로 반환",
+ content = @Content(
+ mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE,
+ schema = @Schema(type = "string", format = "binary")
+ )
+ ),
+ @ApiResponse(responseCode = "401", ref = "#/components/responses/Unauthorized"),
+ @ApiResponse(responseCode = "403", ref = "#/components/responses/Forbidden"),
+ @ApiResponse(responseCode = "404", ref = "#/components/responses/NotFound"),
+ @ApiResponse(responseCode = "415", ref = "#/components/responses/UnsupportedMediaType"),
+ @ApiResponse(responseCode = "422", ref = "#/components/responses/UnprocessableEntity"),
+ @ApiResponse(responseCode = "503", description = "HWP·HWPX 변환 서비스를 사용할 수 없음")
+ })
+ @GetMapping("/{fileId}/preview")
+ @PreAuthorize("hasAnyRole('ADMIN', 'HR', 'VIEWER')")
+ public ResponseEntity preview(
+ @Parameter(description = "미리보기할 파일 ID") @PathVariable UUID fileId,
+ HttpServletRequest servletRequest
+ ) {
+ ActorContext actor = actorContextProvider.requireCurrentActor();
+ FilePreviewResult result = filePreviewService.preview(
+ fileId,
+ actor,
+ RequestMetadata.from(servletRequest)
+ );
+ ContentDisposition disposition = ContentDisposition.inline()
+ .filename(result.fileName(), StandardCharsets.UTF_8)
+ .build();
+
+ return ResponseEntity.ok()
+ .contentType(parseMediaType(result.mimeType()))
+ .contentLength(result.size())
+ .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
+ .header("X-Content-Type-Options", "nosniff")
+ .cacheControl(CacheControl.noStore())
+ .body(new InputStreamResource(result.content()));
+ }
+
private MediaType parseMediaType(String mimeType) {
try {
return MediaType.parseMediaType(mimeType);
diff --git a/src/main/java/com/fowoco/server/file/application/DocumentPreviewConversionException.java b/src/main/java/com/fowoco/server/file/application/DocumentPreviewConversionException.java
new file mode 100644
index 00000000..9775f64d
--- /dev/null
+++ b/src/main/java/com/fowoco/server/file/application/DocumentPreviewConversionException.java
@@ -0,0 +1,30 @@
+package com.fowoco.server.file.application;
+
+import java.util.Objects;
+
+/**
+ * 문서 미리보기 변환 실패를 외부 Provider의 세부 구현과 분리해 표현합니다.
+ */
+public final class DocumentPreviewConversionException extends RuntimeException {
+
+ private final Reason reason;
+
+ public DocumentPreviewConversionException(Reason reason, String safeMessage) {
+ super(safeMessage);
+ this.reason = Objects.requireNonNull(reason, "reason must not be null");
+ }
+
+ public DocumentPreviewConversionException(Reason reason, String safeMessage, Throwable cause) {
+ super(safeMessage, cause);
+ this.reason = Objects.requireNonNull(reason, "reason must not be null");
+ }
+
+ public Reason reason() {
+ return reason;
+ }
+
+ public enum Reason {
+ INVALID_DOCUMENT,
+ UNAVAILABLE
+ }
+}
diff --git a/src/main/java/com/fowoco/server/file/application/DocumentPreviewSource.java b/src/main/java/com/fowoco/server/file/application/DocumentPreviewSource.java
new file mode 100644
index 00000000..5571f59f
--- /dev/null
+++ b/src/main/java/com/fowoco/server/file/application/DocumentPreviewSource.java
@@ -0,0 +1,25 @@
+package com.fowoco.server.file.application;
+
+import java.util.Objects;
+
+public record DocumentPreviewSource(String fileName, String mimeType, byte[] content) {
+
+ public DocumentPreviewSource {
+ if (fileName == null || fileName.isBlank()) {
+ throw new IllegalArgumentException("fileName must not be blank");
+ }
+ if (mimeType == null || mimeType.isBlank()) {
+ throw new IllegalArgumentException("mimeType must not be blank");
+ }
+ Objects.requireNonNull(content, "content must not be null");
+ if (content.length == 0) {
+ throw new IllegalArgumentException("content must not be empty");
+ }
+ content = content.clone();
+ }
+
+ @Override
+ public byte[] content() {
+ return content.clone();
+ }
+}
diff --git a/src/main/java/com/fowoco/server/file/application/FilePreviewResult.java b/src/main/java/com/fowoco/server/file/application/FilePreviewResult.java
new file mode 100644
index 00000000..5d885d82
--- /dev/null
+++ b/src/main/java/com/fowoco/server/file/application/FilePreviewResult.java
@@ -0,0 +1,20 @@
+package com.fowoco.server.file.application;
+
+import java.io.InputStream;
+import java.util.Objects;
+
+public record FilePreviewResult(String fileName, String mimeType, long size, InputStream content) {
+
+ public FilePreviewResult {
+ if (fileName == null || fileName.isBlank()) {
+ throw new IllegalArgumentException("fileName must not be blank");
+ }
+ if (mimeType == null || mimeType.isBlank()) {
+ throw new IllegalArgumentException("mimeType must not be blank");
+ }
+ if (size < 1) {
+ throw new IllegalArgumentException("size must be positive");
+ }
+ Objects.requireNonNull(content, "content must not be null");
+ }
+}
diff --git a/src/main/java/com/fowoco/server/file/application/FilePreviewService.java b/src/main/java/com/fowoco/server/file/application/FilePreviewService.java
new file mode 100644
index 00000000..2916b2ea
--- /dev/null
+++ b/src/main/java/com/fowoco/server/file/application/FilePreviewService.java
@@ -0,0 +1,104 @@
+package com.fowoco.server.file.application;
+
+import com.fowoco.server.auth.application.ActorContext;
+import com.fowoco.server.common.error.ApiException;
+import com.fowoco.server.common.web.RequestMetadata;
+import com.fowoco.server.file.application.error.FileErrorCode;
+import com.fowoco.server.file.application.port.DocumentPreviewConverter;
+import com.fowoco.server.file.domain.StoredFile;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+import org.springframework.stereotype.Service;
+
+@Service
+public class FilePreviewService {
+
+ private static final String PDF_MIME_TYPE = "application/pdf";
+ private static final Set INLINE_MIME_TYPES = Set.of(
+ PDF_MIME_TYPE,
+ "image/jpeg",
+ "image/png",
+ "image/webp"
+ );
+
+ private final FileService fileService;
+ private final DocumentPreviewConverter documentPreviewConverter;
+
+ public FilePreviewService(FileService fileService, DocumentPreviewConverter documentPreviewConverter) {
+ this.fileService = fileService;
+ this.documentPreviewConverter = documentPreviewConverter;
+ }
+
+ public FilePreviewResult preview(
+ UUID fileId,
+ ActorContext actor,
+ RequestMetadata requestMetadata
+ ) {
+ FileDownloadResult download = fileService.download(fileId, actor, requestMetadata);
+ StoredFile storedFile = download.storedFile();
+ String normalizedMimeType = normalizeMimeType(storedFile.mimeType());
+
+ if (INLINE_MIME_TYPES.contains(normalizedMimeType)) {
+ return new FilePreviewResult(
+ storedFile.name(),
+ normalizedMimeType,
+ storedFile.size(),
+ download.content()
+ );
+ }
+ if (!isConvertibleDocument(storedFile.name())) {
+ closeQuietly(download.content());
+ throw new ApiException(FileErrorCode.FILE_PREVIEW_UNSUPPORTED);
+ }
+
+ try (InputStream content = download.content()) {
+ byte[] converted = documentPreviewConverter.convertToPdf(new DocumentPreviewSource(
+ storedFile.name(),
+ storedFile.mimeType(),
+ content.readAllBytes()
+ ));
+ return new FilePreviewResult(
+ pdfFileName(storedFile.name()),
+ PDF_MIME_TYPE,
+ converted.length,
+ new ByteArrayInputStream(converted)
+ );
+ } catch (DocumentPreviewConversionException exception) {
+ if (exception.reason() == DocumentPreviewConversionException.Reason.INVALID_DOCUMENT) {
+ throw new ApiException(FileErrorCode.FILE_PREVIEW_INVALID);
+ }
+ throw new ApiException(FileErrorCode.FILE_PREVIEW_UNAVAILABLE);
+ } catch (IOException exception) {
+ throw new ApiException(FileErrorCode.FILE_PREVIEW_UNAVAILABLE);
+ }
+ }
+
+ private boolean isConvertibleDocument(String fileName) {
+ String normalized = fileName.toLowerCase(Locale.ROOT);
+ return normalized.endsWith(".hwp") || normalized.endsWith(".hwpx");
+ }
+
+ private String pdfFileName(String originalName) {
+ int extensionStart = originalName.lastIndexOf('.');
+ String baseName = extensionStart > 0 ? originalName.substring(0, extensionStart) : originalName;
+ return baseName + ".pdf";
+ }
+
+ private String normalizeMimeType(String mimeType) {
+ int parameterStart = mimeType.indexOf(';');
+ String value = parameterStart >= 0 ? mimeType.substring(0, parameterStart) : mimeType;
+ return value.strip().toLowerCase(Locale.ROOT);
+ }
+
+ private void closeQuietly(InputStream inputStream) {
+ try {
+ inputStream.close();
+ } catch (IOException ignored) {
+ // 미지원 형식 응답이 원본 Stream 정리 실패에 의해 달라지지 않게 합니다.
+ }
+ }
+}
diff --git a/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java
index ff7696e4..fb905fb7 100644
--- a/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java
+++ b/src/main/java/com/fowoco/server/file/application/error/FileErrorCode.java
@@ -6,6 +6,9 @@
public enum FileErrorCode implements ApiErrorCode {
FILE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "파일 크기가 허용 범위를 초과했습니다."),
UNSUPPORTED_FILE_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."),
+ FILE_PREVIEW_UNSUPPORTED(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "미리보기를 지원하지 않는 파일 형식입니다."),
+ FILE_PREVIEW_INVALID(HttpStatus.UNPROCESSABLE_ENTITY, "문서를 PDF 미리보기로 변환할 수 없습니다."),
+ FILE_PREVIEW_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "문서 미리보기 변환 서비스를 사용할 수 없습니다."),
FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "파일을 찾을 수 없습니다.");
private final HttpStatus status;
diff --git a/src/main/java/com/fowoco/server/file/application/port/DocumentPreviewConverter.java b/src/main/java/com/fowoco/server/file/application/port/DocumentPreviewConverter.java
new file mode 100644
index 00000000..6f4c1d0d
--- /dev/null
+++ b/src/main/java/com/fowoco/server/file/application/port/DocumentPreviewConverter.java
@@ -0,0 +1,8 @@
+package com.fowoco.server.file.application.port;
+
+import com.fowoco.server.file.application.DocumentPreviewSource;
+
+public interface DocumentPreviewConverter {
+
+ byte[] convertToPdf(DocumentPreviewSource source);
+}
diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml
index 8131912e..59e19ce7 100644
--- a/src/main/resources/application.yaml
+++ b/src/main/resources/application.yaml
@@ -74,6 +74,8 @@ app:
endpoint: ${AI_RUNTIME_ENDPOINT:http://127.0.0.1:8000/internal/v1/analyses}
renewal-endpoint: ${AI_RUNTIME_RENEWAL_ENDPOINT:http://127.0.0.1:8000/internal/v1/workflows/renewal/run}
document-generation-endpoint: ${AI_DOCUMENT_GENERATION_ENDPOINT:http://127.0.0.1:8000/api/v1/documents/generate}
+ document-conversion-endpoint: ${AI_DOCUMENT_CONVERSION_ENDPOINT:http://127.0.0.1:8000/api/v1/documents/convert}
+ document-conversion-timeout: ${AI_DOCUMENT_CONVERSION_TIMEOUT:60s}
service-credential: ${AI_RUNTIME_SERVICE_CREDENTIAL:}
connect-timeout: ${AI_RUNTIME_CONNECT_TIMEOUT:2s}
overall-timeout: ${AI_RUNTIME_OVERALL_TIMEOUT:240s}
diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java
index 0069a89d..b9e9e35e 100644
--- a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java
+++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/AiRuntimePropertiesTest.java
@@ -14,6 +14,7 @@ void usesFourMinuteDeadlineByDefault() {
assertThat(properties.getOverallTimeout()).isEqualTo(Duration.ofMinutes(4));
assertThat(properties.attemptDeadlineMs()).isEqualTo(240_000L);
+ assertThat(properties.getDocumentConversionTimeout()).isEqualTo(Duration.ofSeconds(60));
}
@Test
diff --git a/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverterWireMockTest.java b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverterWireMockTest.java
new file mode 100644
index 00000000..9978af28
--- /dev/null
+++ b/src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverterWireMockTest.java
@@ -0,0 +1,107 @@
+package com.fowoco.server.aiintegration.infrastructure.http;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
+import static com.github.tomakehurst.wiremock.client.WireMock.containing;
+import static com.github.tomakehurst.wiremock.client.WireMock.equalTo;
+import static com.github.tomakehurst.wiremock.client.WireMock.post;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fowoco.server.file.application.DocumentPreviewConversionException;
+import com.fowoco.server.file.application.DocumentPreviewSource;
+import com.github.tomakehurst.wiremock.WireMockServer;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class RemoteDocumentPreviewConverterWireMockTest {
+
+ private static final String PATH = "/api/v1/documents/convert";
+ private WireMockServer wireMock;
+
+ @BeforeEach
+ void start() {
+ wireMock = new WireMockServer(wireMockConfig().dynamicPort());
+ wireMock.start();
+ }
+
+ @AfterEach
+ void stop() {
+ wireMock.stop();
+ }
+
+ @Test
+ void sendsSourceAndPdfTargetAsMultipartAndReturnsPdf() {
+ byte[] pdf = "%PDF-1.7 converted".getBytes(StandardCharsets.US_ASCII);
+ wireMock.stubFor(post(urlEqualTo(PATH))
+ .withHeader("Authorization", equalTo("Bearer preview-test-token"))
+ .withHeader("Accept", equalTo("application/pdf"))
+ .withHeader("Content-Type", containing("multipart/form-data; boundary="))
+ .withRequestBody(containing("name=\"file\"; filename=\"contract.hwp\""))
+ .withRequestBody(containing("hwp-source"))
+ .withRequestBody(containing("name=\"target_format\""))
+ .withRequestBody(containing("pdf"))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/pdf")
+ .withBody(pdf)));
+
+ byte[] result = client().convertToPdf(new DocumentPreviewSource(
+ "contract.hwp",
+ "application/octet-stream",
+ "hwp-source".getBytes(StandardCharsets.UTF_8)
+ ));
+
+ assertThat(result).isEqualTo(pdf);
+ }
+
+ @Test
+ void rejectsSuccessfulResponseWithoutPdfSignature() {
+ wireMock.stubFor(post(urlEqualTo(PATH))
+ .willReturn(aResponse()
+ .withStatus(200)
+ .withHeader("Content-Type", "application/pdf")
+ .withBody("not-a-pdf")));
+
+ assertThatThrownBy(() -> client().convertToPdf(new DocumentPreviewSource(
+ "contract.hwpx",
+ "application/hwp+zip",
+ "hwpx-source".getBytes(StandardCharsets.UTF_8)
+ )))
+ .isInstanceOfSatisfying(DocumentPreviewConversionException.class, exception ->
+ assertThat(exception.reason())
+ .isEqualTo(DocumentPreviewConversionException.Reason.INVALID_DOCUMENT)
+ );
+ }
+
+ @Test
+ void mapsUnprocessableResponseToInvalidDocument() {
+ wireMock.stubFor(post(urlEqualTo(PATH)).willReturn(aResponse().withStatus(422)));
+
+ assertThatThrownBy(() -> client().convertToPdf(new DocumentPreviewSource(
+ "broken.hwp",
+ "application/octet-stream",
+ "broken".getBytes(StandardCharsets.UTF_8)
+ )))
+ .isInstanceOfSatisfying(DocumentPreviewConversionException.class, exception ->
+ assertThat(exception.reason())
+ .isEqualTo(DocumentPreviewConversionException.Reason.INVALID_DOCUMENT)
+ );
+ }
+
+ private RemoteDocumentPreviewConverter client() {
+ return new RemoteDocumentPreviewConverter(
+ URI.create(wireMock.baseUrl() + PATH),
+ "Bearer preview-test-token",
+ Duration.ofSeconds(5),
+ 20 * 1_024 * 1_024,
+ HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build()
+ );
+ }
+}
diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java
index 84ba04bc..09037aa8 100644
--- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java
+++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java
@@ -282,6 +282,60 @@ void sameCompanyUserCanDownloadFileAndDownloadIsAudited() throws Exception {
)).isEqualTo(1);
}
+ @Test
+ void sameCompanyUserCanPreviewPdfInline() throws Exception {
+ String token = accessToken(login(HR_A_EMAIL));
+ byte[] content = "%PDF-1.7 preview".getBytes(StandardCharsets.US_ASCII);
+ HttpResponse uploadResponse = uploadFile(
+ token, "근로계약서.pdf", "application/pdf", content, "GENERAL"
+ );
+ UUID fileId = UUID.fromString(JsonPath.read(uploadResponse.body(), "$.file_id"));
+
+ HttpResponse previewResponse = previewFile(fileId, token);
+
+ assertThat(previewResponse.statusCode()).isEqualTo(200);
+ assertThat(previewResponse.body()).isEqualTo(content);
+ assertThat(previewResponse.headers().firstValue(HttpHeaders.CONTENT_TYPE)).contains("application/pdf");
+ assertThat(previewResponse.headers().firstValue(HttpHeaders.CONTENT_DISPOSITION))
+ .hasValueSatisfying(value -> assertThat(value).contains("inline").contains("filename*="));
+ assertThat(previewResponse.headers().firstValue(HttpHeaders.CACHE_CONTROL)).contains("no-store");
+ }
+
+ @Test
+ void hwpPreviewReturnsServiceUnavailableWhenAiRuntimeIsDisabled() throws Exception {
+ String token = accessToken(login(HR_A_EMAIL));
+ HttpResponse uploadResponse = uploadFile(
+ token,
+ "contract.hwp",
+ "application/octet-stream",
+ buildValidHwpOleFile(),
+ "GENERAL"
+ );
+ UUID fileId = UUID.fromString(JsonPath.read(uploadResponse.body(), "$.file_id"));
+
+ HttpResponse previewResponse = previewFile(fileId, token);
+
+ assertThat(previewResponse.statusCode()).isEqualTo(503);
+ }
+
+ @Test
+ void otherCompanyCannotPreviewFile() throws Exception {
+ String companyAToken = accessToken(login(HR_A_EMAIL));
+ HttpResponse uploadResponse = uploadFile(
+ companyAToken,
+ "note.pdf",
+ "application/pdf",
+ "%PDF-1.7 company A".getBytes(StandardCharsets.US_ASCII),
+ "GENERAL"
+ );
+ UUID fileId = UUID.fromString(JsonPath.read(uploadResponse.body(), "$.file_id"));
+ String companyBToken = accessToken(login(HR_B_EMAIL));
+
+ HttpResponse response = previewFile(fileId, companyBToken);
+
+ assertThat(response.statusCode()).isEqualTo(404);
+ }
+
@Test
void downloadExposesContentDispositionToAllowedBrowserOrigin() throws Exception {
String token = accessToken(login(HR_A_EMAIL));
@@ -382,6 +436,14 @@ private HttpResponse downloadFile(UUID fileId, String token) throws Exce
return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
}
+ private HttpResponse previewFile(UUID fileId, String token) throws Exception {
+ HttpRequest request = HttpRequest.newBuilder(uri("/api/v1/files/" + fileId + "/preview"))
+ .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
+ .GET()
+ .build();
+ return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+ }
+
private void writePart(
java.io.ByteArrayOutputStream out, String name, String filename, String mimeType, byte[] content
diff --git a/src/test/java/com/fowoco/server/file/application/FilePreviewServiceTest.java b/src/test/java/com/fowoco/server/file/application/FilePreviewServiceTest.java
new file mode 100644
index 00000000..e074eb09
--- /dev/null
+++ b/src/test/java/com/fowoco/server/file/application/FilePreviewServiceTest.java
@@ -0,0 +1,127 @@
+package com.fowoco.server.file.application;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.fowoco.server.auth.application.ActorContext;
+import com.fowoco.server.auth.domain.UserRole;
+import com.fowoco.server.common.error.ApiException;
+import com.fowoco.server.common.web.RequestMetadata;
+import com.fowoco.server.file.application.error.FileErrorCode;
+import com.fowoco.server.file.application.port.DocumentPreviewConverter;
+import com.fowoco.server.file.domain.ScanStatus;
+import com.fowoco.server.file.domain.StoredFile;
+import java.io.ByteArrayInputStream;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Set;
+import java.util.UUID;
+import org.junit.jupiter.api.Test;
+
+class FilePreviewServiceTest {
+
+ private static final UUID FILE_ID = UUID.fromString("10000000-0000-0000-0000-000000000001");
+ private static final UUID COMPANY_ID = UUID.fromString("20000000-0000-0000-0000-000000000001");
+ private static final ActorContext ACTOR = new ActorContext(
+ UUID.fromString("30000000-0000-0000-0000-000000000001"),
+ COMPANY_ID,
+ Set.of(UserRole.HR)
+ );
+ private static final RequestMetadata REQUEST_METADATA = new RequestMetadata("request-1", "trace-1");
+
+ private final FileService fileService = mock(FileService.class);
+ private final DocumentPreviewConverter converter = mock(DocumentPreviewConverter.class);
+ private final FilePreviewService service = new FilePreviewService(fileService, converter);
+
+ @Test
+ void returnsPdfInlineWithoutCallingConverter() throws Exception {
+ byte[] original = "%PDF-1.7 original".getBytes(StandardCharsets.US_ASCII);
+ when(fileService.download(FILE_ID, ACTOR, REQUEST_METADATA))
+ .thenReturn(download("contract.pdf", "application/pdf", original));
+
+ FilePreviewResult result = service.preview(FILE_ID, ACTOR, REQUEST_METADATA);
+
+ assertThat(result.fileName()).isEqualTo("contract.pdf");
+ assertThat(result.mimeType()).isEqualTo("application/pdf");
+ assertThat(result.size()).isEqualTo(original.length);
+ assertThat(result.content().readAllBytes()).isEqualTo(original);
+ verify(converter, never()).convertToPdf(org.mockito.ArgumentMatchers.any());
+ }
+
+ @Test
+ void convertsHwpToPdfAndChangesPreviewFileName() throws Exception {
+ byte[] original = "hwp-source".getBytes(StandardCharsets.UTF_8);
+ byte[] converted = "%PDF-1.7 converted".getBytes(StandardCharsets.US_ASCII);
+ when(fileService.download(FILE_ID, ACTOR, REQUEST_METADATA))
+ .thenReturn(download("표준근로계약서.hwp", "application/octet-stream", original));
+ when(converter.convertToPdf(org.mockito.ArgumentMatchers.any())).thenReturn(converted);
+
+ FilePreviewResult result = service.preview(FILE_ID, ACTOR, REQUEST_METADATA);
+
+ assertThat(result.fileName()).isEqualTo("표준근로계약서.pdf");
+ assertThat(result.mimeType()).isEqualTo("application/pdf");
+ assertThat(result.content().readAllBytes()).isEqualTo(converted);
+ verify(converter).convertToPdf(org.mockito.ArgumentMatchers.argThat(source ->
+ source.fileName().equals("표준근로계약서.hwp")
+ && java.util.Arrays.equals(source.content(), original)
+ ));
+ }
+
+ @Test
+ void mapsInvalidDocumentConversionToUnprocessableEntity() {
+ when(fileService.download(FILE_ID, ACTOR, REQUEST_METADATA))
+ .thenReturn(download(
+ "broken.hwpx",
+ "application/hwp+zip",
+ "invalid".getBytes(StandardCharsets.UTF_8)
+ ));
+ when(converter.convertToPdf(org.mockito.ArgumentMatchers.any()))
+ .thenThrow(new DocumentPreviewConversionException(
+ DocumentPreviewConversionException.Reason.INVALID_DOCUMENT,
+ "invalid document"
+ ));
+
+ assertThatThrownBy(() -> service.preview(FILE_ID, ACTOR, REQUEST_METADATA))
+ .isInstanceOfSatisfying(ApiException.class, exception ->
+ assertThat(exception.errorCode()).isEqualTo(FileErrorCode.FILE_PREVIEW_INVALID)
+ );
+ }
+
+ @Test
+ void rejectsUnsupportedPreviewTypeWithoutCallingConverter() {
+ when(fileService.download(FILE_ID, ACTOR, REQUEST_METADATA))
+ .thenReturn(download(
+ "archive.zip",
+ "application/zip",
+ "zip".getBytes(StandardCharsets.UTF_8)
+ ));
+
+ assertThatThrownBy(() -> service.preview(FILE_ID, ACTOR, REQUEST_METADATA))
+ .isInstanceOfSatisfying(ApiException.class, exception ->
+ assertThat(exception.errorCode()).isEqualTo(FileErrorCode.FILE_PREVIEW_UNSUPPORTED)
+ );
+ verify(converter, never()).convertToPdf(org.mockito.ArgumentMatchers.any());
+ }
+
+ private FileDownloadResult download(String name, String mimeType, byte[] content) {
+ StoredFile storedFile = new StoredFile(
+ FILE_ID,
+ COMPANY_ID,
+ name,
+ mimeType,
+ content.length,
+ "GENERAL",
+ null,
+ null,
+ FILE_ID.toString(),
+ ScanStatus.NOT_SCANNED,
+ false,
+ Instant.parse("2026-08-16T00:00:00Z")
+ );
+ return new FileDownloadResult(storedFile, new ByteArrayInputStream(content));
+ }
+}