From 31721b4e91cde406263c4802055adfd32670e5f2 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 16 Aug 2026 07:52:48 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(file):=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EC=99=80=20HWP=20PDF=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EC=97=B0=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + compose.demo.yml | 2 + docs/ai-runtime-contract.md | 2 + docs/api-documentation.md | 1 + docs/file-preview.md | 81 ++++++++ .../http/AiRuntimeHttpConfiguration.java | 16 ++ .../http/AiRuntimeProperties.java | 24 +++ .../DisabledDocumentPreviewConverter.java | 16 ++ .../http/RemoteDocumentPreviewConverter.java | 193 ++++++++++++++++++ .../server/file/api/FileController.java | 57 +++++- .../DocumentPreviewConversionException.java | 30 +++ .../application/DocumentPreviewSource.java | 25 +++ .../file/application/FilePreviewResult.java | 20 ++ .../file/application/FilePreviewService.java | 104 ++++++++++ .../file/application/error/FileErrorCode.java | 3 + .../port/DocumentPreviewConverter.java | 8 + src/main/resources/application.yaml | 2 + .../http/AiRuntimePropertiesTest.java | 1 + ...eDocumentPreviewConverterWireMockTest.java | 107 ++++++++++ .../file/FileSecurityIntegrationTest.java | 62 ++++++ .../application/FilePreviewServiceTest.java | 127 ++++++++++++ 21 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 docs/file-preview.md create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/DisabledDocumentPreviewConverter.java create mode 100644 src/main/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverter.java create mode 100644 src/main/java/com/fowoco/server/file/application/DocumentPreviewConversionException.java create mode 100644 src/main/java/com/fowoco/server/file/application/DocumentPreviewSource.java create mode 100644 src/main/java/com/fowoco/server/file/application/FilePreviewResult.java create mode 100644 src/main/java/com/fowoco/server/file/application/FilePreviewService.java create mode 100644 src/main/java/com/fowoco/server/file/application/port/DocumentPreviewConverter.java create mode 100644 src/test/java/com/fowoco/server/aiintegration/infrastructure/http/RemoteDocumentPreviewConverterWireMockTest.java create mode 100644 src/test/java/com/fowoco/server/file/application/FilePreviewServiceTest.java 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 89ce3af6..16067d4e 100644 --- a/docs/ai-runtime-contract.md +++ b/docs/ai-runtime-contract.md @@ -376,6 +376,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..48bb7a50 --- /dev/null +++ b/docs/file-preview.md @@ -0,0 +1,81 @@ +# 문서 미리보기와 합성 데이터 품질 확인 + +## 한눈에 보기 + +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에 남긴다. 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)); + } +} From ee0d47ef5ded052e1e75cf43716fd920e228c526 Mon Sep 17 00:00:00 2001 From: hywznn Date: Sun, 16 Aug 2026 12:04:12 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(file):=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EB=B3=80=ED=99=98=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/file-preview.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/file-preview.md b/docs/file-preview.md index 48bb7a50..b15716b5 100644 --- a/docs/file-preview.md +++ b/docs/file-preview.md @@ -79,3 +79,21 @@ PR #183의 합성 문서가 병합된 뒤 아래 항목을 문서별로 확인 품질이 부족한 문서는 원본 생성 규칙을 수정하고, 수정 전후 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가 +통과한 뒤 활성화한다.