From 5b86d8f224622daf94d4c3a4f13df3e908d7f31b Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sat, 7 Feb 2026 21:46:56 -0600 Subject: [PATCH 01/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20AI=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20JSON=20=EC=9D=91=EB=8B=B5=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 변경된 prompt의 gradingListScores에 따른 파서 수정 - 파싱 실패시 재시도 로직 추가 --- .../agent/impl/SpringAiSectionGradeAgent.java | 87 ++++++++++--------- .../aireport/util/AiReportResponseParser.java | 12 ++- 2 files changed, 58 insertions(+), 41 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java index a94619b..5767513 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -20,6 +20,8 @@ @RequiredArgsConstructor public class SpringAiSectionGradeAgent implements SectionGradeAgent { + private static final int MAX_RETRIES = 3; + private final SectionType sectionType; private final ChatClient.Builder chatClientBuilder; private final ReportPromptProvider reportPromptProvider; @@ -40,47 +42,54 @@ public SectionGradingResult gradeSection(String sectionContent) { return SectionGradingResult.failure(getSectionType(), "Circuit breaker is OPEN"); } - try { - Prompt prompt = reportPromptProvider.createSectionGradingPrompt( - getSectionType(), - sectionContent); - - ChatClient chatClient = chatClientBuilder.build(); - - // SectionType의 tag만 사용 - String filter = buildFilterExpression(); - QuestionAnswerAdvisor qaAdvisor = advisorProvider - .getQuestionAnswerAdvisor(0.6, 3, filter); - SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - - String llmResponse = chatClient - .prompt(prompt) - .options(ChatOptions.builder() - .temperature(0.0) - .topP(0.1) - .build()) - .advisors(qaAdvisor, slAdvisor) - .call() - .content(); - - // 섹션별 응답 파싱 - SectionGradingResult result = parseSectionResult(llmResponse); - - if (result.success()) { - circuitBreaker.recordSuccess(getSectionType()); - } else { - circuitBreaker.recordFailure(getSectionType()); + Prompt prompt = reportPromptProvider.createSectionGradingPrompt( + getSectionType(), + sectionContent); + + ChatClient chatClient = chatClientBuilder.build(); + String filter = buildFilterExpression(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, filter); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + Exception lastException = null; + SectionGradingResult lastFailureResult = null; + + for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + try { + String llmResponse = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + + SectionGradingResult result = parseSectionResult(llmResponse); + + if (result.success()) { + circuitBreaker.recordSuccess(getSectionType()); + log.info("[{}] 채점 완료: score={}, filter={}", getSectionType(), result.score(), filter); + return result; + } + + lastFailureResult = result; + log.warn("[{}] 채점 실패 (시도 {}/{}): 파싱 결과 유효하지 않음", getSectionType(), attempt, MAX_RETRIES); + + } catch (Exception e) { + lastException = e; + log.warn("[{}] 채점 실패 (시도 {}/{}): {}", getSectionType(), attempt, MAX_RETRIES, e.getMessage()); } - - log.info("[{}] 채점 완료: score={}, filter={}", - getSectionType(), result.score(), filter); - return result; - - } catch (Exception e) { - circuitBreaker.recordFailure(getSectionType()); - log.error("[{}] 채점 실패", getSectionType(), e); - return SectionGradingResult.failure(getSectionType(), e.getMessage()); } + + circuitBreaker.recordFailure(getSectionType()); + String errorMessage = lastException != null + ? "파싱 실패: " + lastException.getMessage() + : (lastFailureResult != null ? lastFailureResult.errorMessage() : "모든 재시도 실패"); + log.error("[{}] 채점 최종 실패 ({}회 시도)", getSectionType(), MAX_RETRIES); + return SectionGradingResult.failure(getSectionType(), errorMessage); } private String buildFilterExpression() { diff --git a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java index c03d8d5..ff27dd9 100644 --- a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -446,10 +446,18 @@ private List parseSectionScores(JsonN for (JsonNode sectionScoreNode : node) { try { String sectionType = sectionScoreNode.path("sectionType").asText(""); - String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); + JsonNode gradingListNode = sectionScoreNode.path("gradingListScores"); + String gradingListScores; + + // gradingListScores가 JSON 배열(ArrayNode)인 경우와 문자열(TextNode)인 경우 모두 처리 + if (gradingListNode.isArray()) { + gradingListScores = objectMapper.writeValueAsString(gradingListNode); + } else { + gradingListScores = gradingListNode.asText("[]"); + } // gradingListScores가 유효한 JSON 문자열인지 검증 - if (!gradingListScores.equals("[]")) { + if (!gradingListScores.equals("[]") && !gradingListScores.isEmpty()) { try { // JSON 배열 형식인지 확인 if (!gradingListScores.trim().startsWith("[")) { From ef9f9a155fa35ae75bfcd9f45fe090aa3ec3da2d Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sat, 7 Feb 2026 22:35:40 -0600 Subject: [PATCH 02/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20QAAdvisor?= =?UTF-8?q?=EC=9D=98=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=94=84=EB=A1=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=ED=85=9C=ED=94=8C=EB=A6=BF=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=98=EC=97=AC=20=EC=BB=A8=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EA=B0=80=20=EC=97=86=EC=96=B4=EB=8F=84=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../report/provider/SpringAiAdvisorProvider.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java index 16c742a..832ff35 100644 --- a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -4,8 +4,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.template.st.StTemplateRenderer; import org.springframework.ai.vectorstore.SearchRequest; import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.beans.factory.annotation.Value; import org.springframework.core.Ordered; import org.springframework.stereotype.Service; @@ -16,6 +19,9 @@ public class SpringAiAdvisorProvider { private final VectorStore vectorStore; + @Value("${prompt.report.qa-advisor.template}") + private String qaAdvisorTemplate; + public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){ SearchRequest.Builder builder = SearchRequest.builder() .similarityThreshold(similarityThreshold) @@ -26,10 +32,15 @@ public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold } SearchRequest searchRequest = builder.build(); + PromptTemplate promptTemplate = PromptTemplate.builder() + .renderer(StTemplateRenderer.builder().startDelimiterToken('{').endDelimiterToken('}').build()) + .template(qaAdvisorTemplate) + .build(); return QuestionAnswerAdvisor .builder(vectorStore) .searchRequest(searchRequest) + .promptTemplate(promptTemplate) .build(); } From af2e621a81c8215ef8d6430812428eac3227db7b Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 17 Feb 2026 01:34:29 -0600 Subject: [PATCH 03/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20Image=C2=B7PDF?= =?UTF-8?q?=20=EA=B4=80=EB=A0=A8=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20shared=20=EC=9D=B8=ED=94=84=EB=9D=BC=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20->=20=EA=B4=80=EB=A0=A8=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/ocr/ClovaOcrProvider.java | 6 +- .../webapi/BackofficeImageController.java | 2 +- ...inessPlanBusinessPlanImageController.java} | 8 +-- .../swagger/BusinessPlanImageApiDoc.java} | 8 +-- .../pdf}/PdfDownloadClient.java | 12 ++-- .../storage/NcpPresignedUrlProvider.java | 4 +- .../aireport/required/PdfDownloadPort.java | 6 ++ .../required/PresignedUrlProviderPort.java | 10 +++ .../ExpertApplicationCommandService.java | 69 +++++++++++-------- .../required/PdfDownloadPort.java | 6 ++ .../ocr/ClovaOcrProviderTest.java | 35 +++++----- .../spellcheck}/DaumSpellCheckerHttpTest.java | 3 +- .../DaumSpellCheckerLogicTest.java | 3 +- ...ssPlanImageControllerIntegrationTest.java} | 29 ++++---- .../PdfDownloadClientIntegrationTest.java | 27 ++++---- .../pdf}/PdfDownloadClientTest.java | 35 +++++----- .../NcpPresignedUrlProviderUnitTest.java | 2 +- ...34\352\260\200\354\235\264\353\223\234.md" | 5 ++ 18 files changed, 154 insertions(+), 116 deletions(-) rename src/main/java/starlight/adapter/{aireport/webapi/ImageController.java => businessplan/webapi/BusinessPlanBusinessPlanImageController.java} (81%) rename src/main/java/starlight/adapter/{aireport/webapi/swagger/ImageApiDoc.java => businessplan/webapi/swagger/BusinessPlanImageApiDoc.java} (93%) rename src/main/java/starlight/adapter/{aireport/infrastructure/ocr/infra => shared/infrastructure/pdf}/PdfDownloadClient.java (86%) create mode 100644 src/main/java/starlight/application/aireport/required/PdfDownloadPort.java create mode 100644 src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java create mode 100644 src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java rename src/test/java/starlight/{application/member/required => adapter/businessplan/spellcheck}/DaumSpellCheckerHttpTest.java (97%) rename src/test/java/starlight/{application/member/required => adapter/businessplan/spellcheck}/DaumSpellCheckerLogicTest.java (94%) rename src/test/java/starlight/adapter/{aireport/infrastructure/webapi/ImageControllerIntegrationTest.java => businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java} (83%) rename src/test/java/starlight/adapter/{aireport/infrastructure/ocr/infra => shared/infrastructure/pdf}/PdfDownloadClientIntegrationTest.java (83%) rename src/test/java/starlight/adapter/{aireport/infrastructure/ocr/infra => shared/infrastructure/pdf}/PdfDownloadClientTest.java (87%) rename src/test/java/starlight/adapter/{aireport => shared}/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java (99%) diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java index 5b31869..fe8b2a9 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java @@ -5,11 +5,11 @@ import org.springframework.stereotype.Service; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.aireport.required.PdfDownloadPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; @@ -23,7 +23,7 @@ public class ClovaOcrProvider implements OcrProviderPort { private static final int MAX_PAGES_PER_REQUEST = 10; private final ClovaOcrClient clovaOcrClient; - private final PdfDownloadClient pdfDownloadClient; + private final PdfDownloadPort pdfDownloadPort; /** * 지정한 PDF URL을 전체 페이지 OCR 처리한 뒤, 단일 응답으로 병합해 반환한다. @@ -40,7 +40,7 @@ public class ClovaOcrProvider implements OcrProviderPort { */ @Override public OcrResponse ocrPdfByUrl(String pdfUrl) { - byte[] pdfBytes = pdfDownloadClient.downloadPdfFromUrl(pdfUrl); + byte[] pdfBytes = pdfDownloadPort.downloadFromUrl(pdfUrl); List chunks = PdfUtils.splitByPageLimit(pdfBytes, MAX_PAGES_PER_REQUEST); diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 56ff7b4..59b9bca 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -14,7 +14,7 @@ import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; -import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.application.backoffice.image.required.PresignedUrlProviderPort; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java similarity index 81% rename from src/main/java/starlight/adapter/aireport/webapi/ImageController.java rename to src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java index 19302ff..be5a2e3 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.webapi; +package starlight.adapter.businessplan.webapi; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; @@ -6,14 +6,14 @@ import org.springframework.web.bind.annotation.*; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import starlight.application.aireport.required.PresignedUrlProviderPort; -import starlight.adapter.aireport.webapi.swagger.ImageApiDoc; +import starlight.adapter.businessplan.webapi.swagger.BusinessPlanImageApiDoc; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @RestController -@RequestMapping("/v1/images") +@RequestMapping("/v1/business-plans/images") @RequiredArgsConstructor -public class ImageController implements ImageApiDoc { +public class BusinessPlanBusinessPlanImageController implements BusinessPlanImageApiDoc { private final PresignedUrlProviderPort presignedUrlReader; diff --git a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java similarity index 93% rename from src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java rename to src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java index c1e3221..3fd424c 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/swagger/ImageApiDoc.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanImageApiDoc.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.webapi.swagger; +package starlight.adapter.businessplan.webapi.swagger; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -17,7 +17,7 @@ import starlight.shared.apiPayload.response.ApiResponse; @Tag(name = "UTIL", description = "유틸리티 API") -public interface ImageApiDoc { +public interface BusinessPlanImageApiDoc { @Operation( summary = "Presigned URL 발급", @@ -46,7 +46,7 @@ public interface ImageApiDoc { ) ) }) - @GetMapping(value = "/v1/image/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + @GetMapping(value = "/v1/business-plans/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( @AuthenticationPrincipal AuthenticatedMember authenticatedMember, @io.swagger.v3.oas.annotations.Parameter(description = "파일명", required = true) @RequestParam String fileName @@ -75,7 +75,7 @@ ApiResponse getPresignedUrl( ) ) }) - @PostMapping("/v1/images/upload-url/public") + @PostMapping("/v1/business-plans/images/upload-url/public") ApiResponse finalizePublic( @io.swagger.v3.oas.annotations.Parameter(description = "S3 Object URL", required = true) @RequestParam String objectUrl ); diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java similarity index 86% rename from src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java rename to src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java index 48f7f53..b30e6f4 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClient.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; @@ -7,18 +7,19 @@ import org.springframework.web.client.RestClient; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; - import java.net.URI; + @Slf4j @Component -public class PdfDownloadClient { +public class PdfDownloadClient implements starlight.application.aireport.required.PdfDownloadPort, + starlight.application.expertApplication.required.PdfDownloadPort { private static final int MAX_PDF_BYTES = 30 * 1024 * 1024; // 30MB까지 허용 private final RestClient pdfDownloadClient; - public PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient downloadClient) { + PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient downloadClient) { this.pdfDownloadClient = downloadClient; } @@ -36,7 +37,8 @@ public PdfDownloadClient(@Qualifier("pdfDownloadRestClient") RestClient download * - PDF_TOO_LARGE : 허용 최대 크기 초과 * - PDF_DOWNLOAD_ERROR : 네트워크/HTTP/기타 예외 전반 */ - public byte[] downloadPdfFromUrl(String url) { + @Override + public byte[] downloadFromUrl(String url) { try { ResponseEntity entity = pdfDownloadClient.get() .uri(URI.create(url)) diff --git a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 06de310..7624786 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -12,7 +12,6 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; -import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -24,7 +23,8 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements PresignedUrlProviderPort { +public class NcpPresignedUrlProvider implements starlight.application.aireport.required.PresignedUrlProviderPort, + starlight.application.backoffice.image.required.PresignedUrlProviderPort { private final S3Client ncpS3Client; private final S3Presigner ncpS3Presigner; diff --git a/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java b/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java new file mode 100644 index 0000000..2f70bf6 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/PdfDownloadPort.java @@ -0,0 +1,6 @@ +package starlight.application.aireport.required; + +public interface PdfDownloadPort { + + byte[] downloadFromUrl(String url); +} diff --git a/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java b/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java new file mode 100644 index 0000000..8fefbd2 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/image/required/PresignedUrlProviderPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.image.required; + +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +public interface PresignedUrlProviderPort { + + PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); + + String makePublic(String objectUrl); +} diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index da6e409..31eaf06 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java @@ -12,6 +12,7 @@ import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; import starlight.application.expertApplication.required.ExpertLookupPort; import starlight.application.expertApplication.required.ExpertApplicationQueryPort; +import starlight.application.expertApplication.required.PdfDownloadPort; import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; @@ -36,6 +37,7 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommand private final ExpertApplicationQueryPort applicationQueryPort; private final ApplicationEventPublisher eventPublisher; private final ExpertReportUseCase expertReportUseCase; + private final PdfDownloadPort pdfDownloadPort; private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; @@ -46,17 +48,33 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommand @Override @Transactional public void requestFeedback(Long expertId, Long planId, MultipartFile file, String menteeName) { - try { + BusinessPlan plan = planQuery.findByIdOrThrow(planId); + + final byte[] fileBytes; + final String filename; + + if (plan.isPdfBased()) { + fileBytes = pdfDownloadPort.downloadFromUrl(plan.getPdfUrl()); + filename = generateFilenameForPdfPlan(plan, menteeName); + } else { validateFile(file); + try { + fileBytes = file.getBytes(); + } catch (IOException e) { + log.error("Failed to read file. planId={}, expertId={}", planId, expertId, e); + throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR); + } + filename = generateFilename(file, plan, menteeName); + } - BusinessPlan plan = planQuery.findByIdOrThrow(planId); + try { Expert expert = expertLookupPort.findByIdOrThrow(expertId); plan.updateStatus(PlanStatus.EXPERT_MATCHED); registerApplicationRecord(expertId, planId); - publishEmailEvent(expert, plan, file, menteeName); + publishEmailEvent(expert, plan, fileBytes, filename, menteeName); } catch (ExpertApplicationException | BusinessPlanException | ExpertException e) { throw e; } catch (Exception e) { @@ -96,33 +114,30 @@ private String generateFilename(MultipartFile file, BusinessPlan plan, String me return originalFilename; } + return generateFilenameForPdfPlan(plan, menteeName); + } + + private String generateFilenameForPdfPlan(BusinessPlan plan, String menteeName) { return String.format("[사업계획서]%s_%s.pdf", plan.getTitle(), menteeName); } - protected void publishEmailEvent(Expert expert, BusinessPlan plan, MultipartFile file, String menteeName) { - try { - byte[] fileBytes = file.getBytes(); - String filename = generateFilename(file, plan, menteeName); - String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); - - FeedbackRequestInput event = FeedbackRequestInput.of( - expert.getEmail(), - expert.getName(), - menteeName, - plan.getTitle(), - LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE), - feedbackUrl, - fileBytes, - filename - ); - - log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId()); - - eventPublisher.publishEvent(event); - } catch (IOException e) { - log.error("Failed to read file. planId={}, expertId={}", plan.getId(), expert.getId(), e); - throw new ExpertApplicationException(ExpertApplicationErrorType.FILE_READ_ERROR); - } + protected void publishEmailEvent(Expert expert, BusinessPlan plan, byte[] fileBytes, String filename, String menteeName) { + String feedbackUrl = buildFeedbackRequestUrl(expert.getId(), plan.getId()); + + FeedbackRequestInput event = FeedbackRequestInput.of( + expert.getEmail(), + expert.getName(), + menteeName, + plan.getTitle(), + LocalDate.now().plusDays(FEEDBACK_DEADLINE_DAYS).format(DateTimeFormatter.ISO_DATE), + feedbackUrl, + fileBytes, + filename + ); + + log.info("[EMAIL] publishing FeedbackRequestEvent expertId={}, planId={}", expert.getId(), plan.getId()); + + eventPublisher.publishEvent(event); } private String buildFeedbackRequestUrl(Long expertId, Long planId) { diff --git a/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java b/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java new file mode 100644 index 0000000..8237848 --- /dev/null +++ b/src/main/java/starlight/application/expertApplication/required/PdfDownloadPort.java @@ -0,0 +1,6 @@ +package starlight.application.expertApplication.required; + +public interface PdfDownloadPort { + + byte[] downloadFromUrl(String url); +} diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java index 1a15560..db9306e 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProviderTest.java @@ -8,14 +8,13 @@ import org.mockito.Mock; import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; -import starlight.adapter.aireport.infrastructure.ocr.ClovaOcrProvider; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; import starlight.adapter.aireport.infrastructure.ocr.infra.ClovaOcrClient; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import starlight.adapter.aireport.infrastructure.ocr.util.OcrResponseMerger; import starlight.adapter.aireport.infrastructure.ocr.util.OcrTextExtractor; import starlight.adapter.aireport.infrastructure.ocr.util.PdfUtils; +import starlight.application.aireport.required.PdfDownloadPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.List; @@ -33,7 +32,7 @@ class ClovaOcrProviderTest { private ClovaOcrClient clovaOcrClient; @Mock - private PdfDownloadClient pdfDownloadClient; + private PdfDownloadPort pdfDownloadPort; @InjectMocks private ClovaOcrProvider clovaOcrProvider; @@ -57,7 +56,7 @@ void ocrPdfByUrl_Success_SingleChunk() { byte[] chunk = "chunk1".getBytes(); OcrResponse expectedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -73,7 +72,7 @@ void ocrPdfByUrl_Success_SingleChunk() { // then assertThat(result).isEqualTo(expectedResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk); } } @@ -86,7 +85,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { byte[] chunk2 = "chunk2".getBytes(); OcrResponse mergedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -103,7 +102,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { // then assertThat(result).isEqualTo(mergedResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk1); verify(clovaOcrClient).recognizePdfBytes(chunk2); } @@ -113,7 +112,7 @@ void ocrPdfByUrl_Success_MultipleChunks() { @DisplayName("PDF 다운로드 실패 시 예외 전파") void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)) .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); // when & then @@ -121,7 +120,7 @@ void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } @@ -129,7 +128,7 @@ void ocrPdfByUrl_ThrowsException_WhenDownloadFails() { @DisplayName("PDF 분할 실패 시 예외 전파") void ocrPdfByUrl_ThrowsException_WhenSplitFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) @@ -140,7 +139,7 @@ void ocrPdfByUrl_ThrowsException_WhenSplitFails() { .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_SPLIT_ERROR); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } } @@ -151,7 +150,7 @@ void ocrPdfByUrl_ThrowsException_WhenOcrFails() { // given byte[] chunk = "chunk1".getBytes(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class)) { pdfUtilsMock.when(() -> PdfUtils.splitByPageLimit(testPdfBytes, 10)) @@ -176,7 +175,7 @@ void ocrPdfTextByUrl_Success() { OcrResponse ocrResponse = OcrResponse.createEmpty(); String expectedText = "Extracted text content"; - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class); @@ -195,7 +194,7 @@ void ocrPdfTextByUrl_Success() { // then assertThat(result).isEqualTo(expectedText); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verify(clovaOcrClient).recognizePdfBytes(chunk); } } @@ -204,7 +203,7 @@ void ocrPdfTextByUrl_Success() { @DisplayName("텍스트 추출 중 OCR 실패 시 예외 전파") void ocrPdfTextByUrl_ThrowsException_WhenOcrFails() { // given - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)) + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)) .thenThrow(new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR)); // when & then @@ -219,7 +218,7 @@ void ocrPdfByUrl_WithEmptyChunks() { // given OcrResponse emptyResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { @@ -234,7 +233,7 @@ void ocrPdfByUrl_WithEmptyChunks() { // then assertThat(result).isEqualTo(emptyResponse); - verify(pdfDownloadClient).downloadPdfFromUrl(TEST_PDF_URL); + verify(pdfDownloadPort).downloadFromUrl(TEST_PDF_URL); verifyNoInteractions(clovaOcrClient); } } @@ -247,7 +246,7 @@ void ocrPdfByUrl_ExactlyTwoChunks() { byte[] chunk2 = "chunk2".getBytes(); OcrResponse mergedResponse = OcrResponse.createEmpty(); - when(pdfDownloadClient.downloadPdfFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); + when(pdfDownloadPort.downloadFromUrl(TEST_PDF_URL)).thenReturn(testPdfBytes); try (MockedStatic pdfUtilsMock = mockStatic(PdfUtils.class); MockedStatic mergerMock = mockStatic(OcrResponseMerger.class)) { diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java similarity index 97% rename from src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java rename to src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java index 359575e..42960ce 100644 --- a/src/test/java/starlight/application/member/required/DaumSpellCheckerHttpTest.java +++ b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerHttpTest.java @@ -1,4 +1,4 @@ -package starlight.application.member.required; +package starlight.adapter.businessplan.spellcheck; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,7 +16,6 @@ import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; -import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; diff --git a/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java similarity index 94% rename from src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java rename to src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java index 97ceed6..a3a04fb 100644 --- a/src/test/java/starlight/application/member/required/DaumSpellCheckerLogicTest.java +++ b/src/test/java/starlight/adapter/businessplan/spellcheck/DaumSpellCheckerLogicTest.java @@ -1,4 +1,4 @@ -package starlight.application.member.required; +package starlight.adapter.businessplan.spellcheck; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -8,7 +8,6 @@ import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.client.RestClient; -import starlight.adapter.businessplan.spellcheck.DaumSpellChecker; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java similarity index 83% rename from src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java rename to src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java index 80891a0..521fcc1 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.webapi; +package starlight.adapter.businessplan.webapi; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,7 +11,6 @@ import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; -import starlight.adapter.aireport.webapi.ImageController; import starlight.adapter.member.auth.security.auth.AuthDetails; import starlight.adapter.member.auth.security.filter.JwtFilter; import starlight.application.aireport.required.PresignedUrlProviderPort; @@ -27,7 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest( - controllers = ImageController.class, + controllers = BusinessPlanBusinessPlanImageController.class, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { JwtFilter.class, @@ -36,8 +35,8 @@ } ) @AutoConfigureMockMvc(addFilters = false) -@DisplayName("ImageController 통합 테스트") -class ImageControllerIntegrationTest { +@DisplayName("BusinessPlanImageController 통합 테스트") +class BusinessPlanImageControllerIntegrationTest { @Autowired private MockMvc mockMvc; @@ -57,7 +56,7 @@ private AuthDetails createMockAuthDetails(Long memberId) { } // @Test -// @DisplayName("GET /v1/images/upload-url - Presigned URL 조회 성공") +// @DisplayName("GET /v1/business-plans/images/upload-url - Presigned URL 조회 성공") // @WithMockUser // (선택) user(...)와 중복이면 제거 가능 // void getPresignedUrl_Success() throws Exception { // // given @@ -70,7 +69,7 @@ private AuthDetails createMockAuthDetails(Long memberId) { // given(presignedUrlProvider.getPreSignedUrl(userId, fileName)).willReturn(response); // // // when & then -// mockMvc.perform(get("/v1/images/upload-url") +// mockMvc.perform(get("/v1/business-plans/images/upload-url") // .with(user(createMockAuthDetails(userId))) // .param("fileName", fileName) // .contentType(MediaType.APPLICATION_JSON)) @@ -84,10 +83,10 @@ private AuthDetails createMockAuthDetails(Long memberId) { // } @Test - @DisplayName("GET /v1/images/upload-url - fileName 누락 시 400 에러") + @DisplayName("GET /v1/business-plans/images/upload-url - fileName 누락 시 400 에러") void getPresignedUrl_MissingFileName() throws Exception { // when & then - mockMvc.perform(get("/v1/images/upload-url") + mockMvc.perform(get("/v1/business-plans/images/upload-url") .param("userId", "1") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) @@ -97,14 +96,14 @@ void getPresignedUrl_MissingFileName() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - 이미지 공개 처리 성공") + @DisplayName("POST /v1/business-plans/images/upload-url/public - 이미지 공개 처리 성공") void finalizePublic_Success() throws Exception { // given String objectUrl = "https://test-bucket.kr.object.ncloudstorage.com/1/test-image.jpg"; given(presignedUrlProvider.makePublic(objectUrl)).willReturn(objectUrl); // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .param("objectUrl", objectUrl) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) @@ -116,10 +115,10 @@ void finalizePublic_Success() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - objectUrl 누락 시 400 에러") + @DisplayName("POST /v1/business-plans/images/upload-url/public - objectUrl 누락 시 400 에러") void finalizePublic_MissingObjectUrl() throws Exception { // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) .andExpect(status().isBadRequest()); @@ -128,7 +127,7 @@ void finalizePublic_MissingObjectUrl() throws Exception { } @Test - @DisplayName("POST /v1/images/upload-url/public - 잘못된 URL 형식으로 예외 발생") + @DisplayName("POST /v1/business-plans/images/upload-url/public - 잘못된 URL 형식으로 예외 발생") void finalizePublic_InvalidUrl() throws Exception { // given String invalidUrl = "invalid-url"; @@ -136,7 +135,7 @@ void finalizePublic_InvalidUrl() throws Exception { .willThrow(new IllegalArgumentException("잘못된 URL 형식")); // when & then - mockMvc.perform(post("/v1/images/upload-url/public") + mockMvc.perform(post("/v1/business-plans/images/upload-url/public") .param("objectUrl", invalidUrl) .contentType(MediaType.APPLICATION_JSON)) .andDo(print()) diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java similarity index 83% rename from src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java index 660b198..b87b1c1 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientIntegrationTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -11,7 +11,6 @@ import org.springframework.web.client.RestClient; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import java.io.IOException; import java.time.Duration; @@ -50,7 +49,7 @@ void tearDown() throws IOException { @Test @DisplayName("실제 HTTP 요청으로 PDF 다운로드 성공") - void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { + void downloadFromUrl_RealHttpRequest_Success() throws InterruptedException { // given byte[] expectedBytes = "PDF content".getBytes(); mockWebServer.enqueue(new MockResponse() @@ -61,7 +60,7 @@ void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { String url = baseUrl + "test.pdf"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).isEqualTo(expectedBytes); @@ -74,7 +73,7 @@ void downloadPdfFromUrl_RealHttpRequest_Success() throws InterruptedException { @Test @DisplayName("404 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_Returns404_ThrowsException() { + void downloadFromUrl_Returns404_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(404) @@ -83,14 +82,14 @@ void downloadPdfFromUrl_Returns404_ThrowsException() { String url = baseUrl + "notfound.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("500 응답 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_Returns500_ThrowsException() { + void downloadFromUrl_Returns500_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(500) @@ -99,14 +98,14 @@ void downloadPdfFromUrl_Returns500_ThrowsException() { String url = baseUrl + "error.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("빈 응답 본문인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_EmptyBody_ThrowsException() { + void downloadFromUrl_EmptyBody_ThrowsException() { // given mockWebServer.enqueue(new MockResponse() .setResponseCode(200) @@ -115,14 +114,14 @@ void downloadPdfFromUrl_EmptyBody_ThrowsException() { String url = baseUrl + "empty.pdf"; // when & then - assertThatThrownBy(() -> pdfDownloadClient.downloadPdfFromUrl(url)) + assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("쿼리 파라미터가 포함된 URL 처리") - void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { + void downloadFromUrl_WithQueryParams_Success() throws InterruptedException { // given byte[] expectedBytes = "PDF with params".getBytes(); mockWebServer.enqueue(new MockResponse() @@ -132,7 +131,7 @@ void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { String url = baseUrl + "test.pdf?token=abc123&expires=2025-12-31"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).isEqualTo(expectedBytes); @@ -144,7 +143,7 @@ void downloadPdfFromUrl_WithQueryParams_Success() throws InterruptedException { @Test @DisplayName("큰 PDF 파일 다운로드 성공 (10MB)") - void downloadPdfFromUrl_LargeFile_Success() { + void downloadFromUrl_LargeFile_Success() { // given byte[] largeBytes = new byte[10 * 1024 * 1024]; // 10MB mockWebServer.enqueue(new MockResponse() @@ -154,7 +153,7 @@ void downloadPdfFromUrl_LargeFile_Success() { String url = baseUrl + "large.pdf"; // when - byte[] result = pdfDownloadClient.downloadPdfFromUrl(url); + byte[] result = pdfDownloadClient.downloadFromUrl(url); // then assertThat(result).hasSize(10 * 1024 * 1024); diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java similarity index 87% rename from src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java index 55f6aad..cc40f4c 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/ocr/infra/PdfDownloadClientTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.ocr.infra; +package starlight.adapter.shared.infrastructure.pdf; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -7,7 +7,6 @@ import org.springframework.web.client.RestClient; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; -import starlight.adapter.aireport.infrastructure.ocr.infra.PdfDownloadClient; import java.net.URI; @@ -41,7 +40,7 @@ void setUp() { @Test @DisplayName("정상적인 PDF 다운로드 성공") - void downloadPdfFromUrl_Success() { + void downloadFromUrl_Success() { // given ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -51,7 +50,7 @@ void downloadPdfFromUrl_Success() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(TEST_URL); // then assertThat(result).isEqualTo(testPdfBytes); @@ -61,7 +60,7 @@ void downloadPdfFromUrl_Success() { @Test @DisplayName("빈 응답인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenResponseIsEmpty() { + void downloadFromUrl_ThrowsException_WhenResponseIsEmpty() { // given ResponseEntity responseEntity = ResponseEntity.ok(new byte[0]); @@ -71,14 +70,14 @@ void downloadPdfFromUrl_ThrowsException_WhenResponseIsEmpty() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("응답 Body가 null인 경우 PDF_EMPTY_RESPONSE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenResponseBodyIsNull() { + void downloadFromUrl_ThrowsException_WhenResponseBodyIsNull() { // given ResponseEntity responseEntity = ResponseEntity.ok().build(); @@ -88,14 +87,14 @@ void downloadPdfFromUrl_ThrowsException_WhenResponseBodyIsNull() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); } @Test @DisplayName("PDF 크기가 30MB를 초과하면 PDF_TOO_LARGE 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenPdfIsTooLarge() { + void downloadFromUrl_ThrowsException_WhenPdfIsTooLarge() { // given byte[] largePdfBytes = createPdfBytes(31 * 1024 * 1024); // 31MB ResponseEntity responseEntity = ResponseEntity.ok(largePdfBytes); @@ -106,14 +105,14 @@ void downloadPdfFromUrl_ThrowsException_WhenPdfIsTooLarge() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_TOO_LARGE); } @Test @DisplayName("정확히 30MB인 PDF는 정상 다운로드") - void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { + void downloadFromUrl_Success_WhenPdfIsExactly30MB() { // given byte[] exactSizePdfBytes = createPdfBytes(30 * 1024 * 1024); // 정확히 30MB ResponseEntity responseEntity = ResponseEntity.ok(exactSizePdfBytes); @@ -124,7 +123,7 @@ void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(TEST_URL); // then assertThat(result).isEqualTo(exactSizePdfBytes); @@ -132,21 +131,21 @@ void downloadPdfFromUrl_Success_WhenPdfIsExactly30MB() { @Test @DisplayName("네트워크 예외 발생 시 PDF_DOWNLOAD_ERROR 예외 발생") - void downloadPdfFromUrl_ThrowsException_WhenNetworkError() { + void downloadFromUrl_ThrowsException_WhenNetworkError() { // given when(pdfDownloadClient.get()).thenReturn(requestHeadersUriSpec); when(requestHeadersUriSpec.uri(any(URI.class))).thenReturn(requestHeadersUriSpec); when(requestHeadersUriSpec.retrieve()).thenThrow(new RuntimeException("Network error")); // when & then - assertThatThrownBy(() -> pdfDownloadClientInstance.downloadPdfFromUrl(TEST_URL)) + assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) .isInstanceOf(OcrException.class) .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); } @Test @DisplayName("특수문자가 포함된 URL도 정상 처리") - void downloadPdfFromUrl_Success_WithEncodedUrl() { + void downloadFromUrl_Success_WithEncodedUrl() { // given String encodedUrl = "https://example.com/test%20file.pdf?param=value&signed=abc123"; ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -157,7 +156,7 @@ void downloadPdfFromUrl_Success_WithEncodedUrl() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(encodedUrl); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(encodedUrl); // then assertThat(result).isEqualTo(testPdfBytes); @@ -166,7 +165,7 @@ void downloadPdfFromUrl_Success_WithEncodedUrl() { @Test @DisplayName("프리사인드 URL도 정상 처리") - void downloadPdfFromUrl_Success_WithPresignedUrl() { + void downloadFromUrl_Success_WithPresignedUrl() { // given String presignedUrl = "https://s3.amazonaws.com/bucket/file.pdf?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=xxx"; ResponseEntity responseEntity = ResponseEntity.ok(testPdfBytes); @@ -177,7 +176,7 @@ void downloadPdfFromUrl_Success_WithPresignedUrl() { when(responseSpec.toEntity(byte[].class)).thenReturn(responseEntity); // when - byte[] result = pdfDownloadClientInstance.downloadPdfFromUrl(presignedUrl); + byte[] result = pdfDownloadClientInstance.downloadFromUrl(presignedUrl); // then assertThat(result).isEqualTo(testPdfBytes); diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java similarity index 99% rename from src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java rename to src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 2b3d8c4..9d9c351 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.storage; +package starlight.adapter.shared.infrastructure.storage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; diff --git "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" index f45704e..a072bce 100644 --- "a/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" +++ "b/\352\260\234\353\260\234\352\260\200\354\235\264\353\223\234.md" @@ -76,3 +76,8 @@ ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'` + +## 어댑터 레이어 shared 규칙 +- adapter/shared/*(및 PDF·이미지 등 공용 인프라 어댑터)는 Lookup 포트가 아닌 직접 포트만 사용한다. +- 즉, PDF·사진(이미지) 등 여러 도메인에서 쓰는 기능은: application/pdf, application/storage 같은 공용 application 도메인을 두지 않는다. +- 사용하는 application 도메인마다 해당 도메인의 required에 직접 포트(예: PdfDownloadPort, PresignedUrlProviderPort)를 둔다. From a815b00b16a3757563d878ab2fa70d58f2428e26 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 17 Feb 2026 02:28:28 -0600 Subject: [PATCH 04/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PDF: 공유 계층 예외 도입 (PdfDownloadException, PdfDownloadErrorType) - PdfDownloadClient가 aireport OcrException 의존 제거 - PDF URL 저장 시 검증 - 커스텀 Validation @ValidPdfUrl 추가 (ValidPdfUrlValidator) - BusinessPlan/AiReport 요청 DTO에 @ValidPdfUrl 적용 - 저장 전 downloadFromUrl로 접근 가능 여부 검증 (INVALID_PDF_URL) - 전문가 신청: businessPlan에 pdfUrl 있으면 해당 URL에서 PDF 다운로드 후 전송 - AI 리포트: SpringAiSectionGradeAgent 재시도 지수 백오프, 마지막 시도 실패 메시지로 통일 - 이미지/PDF 인프라: PresignedUrlProviderPort 분리, NcpPresignedUrlProvider 정리 - ValidImageFileName/Validator를 adapter.shared.webapi.validation으로 이동 - BusinessPlanErrorType에 INVALID_PDF_URL 추가 - BusinessPlanImageController 등 관련 테스트·API Doc 반영 --- .../agent/impl/SpringAiSectionGradeAgent.java | 21 +++++++++++------ .../dto/AiReportCreateWithPdfRequest.java | 2 ++ .../webapi/BackofficeImageController.java | 2 +- .../webapi/swagger/BackofficeImageApiDoc.java | 2 +- ....java => BusinessPlanImageController.java} | 7 +++--- .../dto/BusinessPlanCreateWithPdfRequest.java | 2 ++ .../infrastructure/pdf/PdfDownloadClient.java | 17 +++++++------- .../pdf/exception/PdfDownloadErrorType.java | 18 +++++++++++++++ .../pdf/exception/PdfDownloadException.java | 14 +++++++++++ .../storage/NcpPresignedUrlProvider.java | 3 ++- .../webapi/validation/ValidImageFileName.java | 2 +- .../ValidImageFileNameValidator.java | 2 +- .../shared/webapi/validation/ValidPdfUrl.java | 23 +++++++++++++++++++ .../validation/ValidPdfUrlValidator.java | 23 +++++++++++++++++++ .../required/PresignedUrlProviderPort.java | 2 +- .../exception/BusinessPlanErrorType.java | 1 + ...essPlanImageControllerIntegrationTest.java | 4 ++-- 17 files changed, 119 insertions(+), 26 deletions(-) rename src/main/java/starlight/adapter/businessplan/webapi/{BusinessPlanBusinessPlanImageController.java => BusinessPlanImageController.java} (80%) create mode 100644 src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java create mode 100644 src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java rename src/main/java/starlight/adapter/{backoffice/image => shared}/webapi/validation/ValidImageFileName.java (91%) rename src/main/java/starlight/adapter/{backoffice/image => shared}/webapi/validation/ValidImageFileNameValidator.java (92%) create mode 100644 src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java create mode 100644 src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java rename src/main/java/starlight/application/{aireport => businessplan}/required/PresignedUrlProviderPort.java (81%) diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java index 5767513..0de224e 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -52,10 +52,19 @@ public SectionGradingResult gradeSection(String sectionContent) { .getQuestionAnswerAdvisor(0.6, 3, filter); SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - Exception lastException = null; - SectionGradingResult lastFailureResult = null; + String lastFailureMessage = null; for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 1) { + try { + long delay = (long) Math.pow(2, attempt - 1) * 1000L; // 2s, 4s + log.info("[{}] 재시도 대기: {}ms (시도 {}/{})", getSectionType(), delay, attempt, MAX_RETRIES); + Thread.sleep(delay); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } try { String llmResponse = chatClient .prompt(prompt) @@ -75,19 +84,17 @@ public SectionGradingResult gradeSection(String sectionContent) { return result; } - lastFailureResult = result; + lastFailureMessage = result.errorMessage(); log.warn("[{}] 채점 실패 (시도 {}/{}): 파싱 결과 유효하지 않음", getSectionType(), attempt, MAX_RETRIES); } catch (Exception e) { - lastException = e; + lastFailureMessage = "파싱 실패: " + e.getMessage(); log.warn("[{}] 채점 실패 (시도 {}/{}): {}", getSectionType(), attempt, MAX_RETRIES, e.getMessage()); } } circuitBreaker.recordFailure(getSectionType()); - String errorMessage = lastException != null - ? "파싱 실패: " + lastException.getMessage() - : (lastFailureResult != null ? lastFailureResult.errorMessage() : "모든 재시도 실패"); + String errorMessage = lastFailureMessage != null ? lastFailureMessage : "모든 재시도 실패"; log.error("[{}] 채점 최종 실패 ({}회 시도)", getSectionType(), MAX_RETRIES); return SectionGradingResult.failure(getSectionType(), errorMessage); } diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java index 854c658..6c96d5f 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -2,6 +2,7 @@ import jakarta.validation.constraints.NotBlank; import org.hibernate.validator.constraints.URL; +import starlight.adapter.shared.webapi.validation.ValidPdfUrl; public record AiReportCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") @@ -9,6 +10,7 @@ public record AiReportCreateWithPdfRequest( // TODO: 버킷 정책 등에 따라서 이후에 host 강제할 것 @NotBlank(message = "PDF URL은 필수입니다.") + @ValidPdfUrl @URL(protocol = "https", message = "https URL만 허용됩니다.") String pdfUrl ) {} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java index 59b9bca..31a450e 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -13,7 +13,7 @@ import org.springframework.web.bind.annotation.RestController; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; -import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; import starlight.application.backoffice.image.required.PresignedUrlProviderPort; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java index 32c220d..c064fef 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; -import starlight.adapter.backoffice.image.webapi.validation.ValidImageFileName; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java similarity index 80% rename from src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java rename to src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java index be5a2e3..24f37a5 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanBusinessPlanImageController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanImageController.java @@ -4,8 +4,9 @@ import org.springframework.http.MediaType; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import starlight.adapter.shared.webapi.validation.ValidImageFileName; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.application.businessplan.required.PresignedUrlProviderPort; import starlight.adapter.businessplan.webapi.swagger.BusinessPlanImageApiDoc; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @@ -13,14 +14,14 @@ @RestController @RequestMapping("/v1/business-plans/images") @RequiredArgsConstructor -public class BusinessPlanBusinessPlanImageController implements BusinessPlanImageApiDoc { +public class BusinessPlanImageController implements BusinessPlanImageApiDoc { private final PresignedUrlProviderPort presignedUrlReader; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( @AuthenticationPrincipal AuthenticatedMember authenticatedMember, - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ) { return ApiResponse.success(presignedUrlReader.getPreSignedUrl(authenticatedMember.getMemberId(), fileName)); } diff --git a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java index a2b8500..64d491f 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/dto/BusinessPlanCreateWithPdfRequest.java @@ -1,12 +1,14 @@ package starlight.adapter.businessplan.webapi.dto; import jakarta.validation.constraints.NotBlank; +import starlight.adapter.shared.webapi.validation.ValidPdfUrl; public record BusinessPlanCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") String title, @NotBlank(message = "PDF URL은 필수입니다.") + @ValidPdfUrl String pdfUrl ) {} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java index b30e6f4..1be8df5 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java @@ -5,8 +5,9 @@ import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; + import java.net.URI; @@ -32,7 +33,7 @@ public class PdfDownloadClient implements starlight.application.aireport.require * * @param url 다운로드할 PDF의 절대 URL(프리사인드/퍼센트 인코딩 포함 가능) * @return 다운로드한 PDF 바이트 배열 - * @throws OcrException 다음의 에러타입으로 발생 + * @throws PdfDownloadException 다음의 에러타입으로 발생 * - PDF_EMPTY_RESPONSE : 본문이 비어있음 * - PDF_TOO_LARGE : 허용 최대 크기 초과 * - PDF_DOWNLOAD_ERROR : 네트워크/HTTP/기타 예외 전반 @@ -47,17 +48,17 @@ public byte[] downloadFromUrl(String url) { byte[] data = entity.getBody(); if (data == null || data.length == 0) { - throw new OcrException(OcrErrorType.PDF_EMPTY_RESPONSE); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } if (data.length > MAX_PDF_BYTES) { - throw new OcrException(OcrErrorType.PDF_TOO_LARGE); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_TOO_LARGE); } return data; - } catch (OcrException e) { - throw e; // 이미 처리된 OcrException은 재던짐 + } catch (PdfDownloadException e) { + throw e; } catch (Exception e) { log.error("PDF 다운로드 실패: {}", e.getMessage()); - throw new OcrException(OcrErrorType.PDF_DOWNLOAD_ERROR); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } } } diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java new file mode 100644 index 0000000..5f44f05 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadErrorType.java @@ -0,0 +1,18 @@ +package starlight.adapter.shared.infrastructure.pdf.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum PdfDownloadErrorType implements ErrorType { + PDF_EMPTY_RESPONSE(HttpStatus.BAD_GATEWAY, "PDF 응답이 비어있음"), + PDF_TOO_LARGE(HttpStatus.INTERNAL_SERVER_ERROR, "PDF의 크기가 업로드 제한 크기를 넘습니다."), + PDF_DOWNLOAD_ERROR(HttpStatus.BAD_GATEWAY, "PDF 다운로드 실패"), + ; + + private final HttpStatus status; + private final String message; +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java new file mode 100644 index 0000000..77fb062 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/exception/PdfDownloadException.java @@ -0,0 +1,14 @@ +package starlight.adapter.shared.infrastructure.pdf.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class PdfDownloadException extends GlobalException { + public PdfDownloadException(ErrorType errorType) { + super(errorType); + } + + public PdfDownloadException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 7624786..26ce282 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -12,6 +12,7 @@ import software.amazon.awssdk.services.s3.presigner.S3Presigner; import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; +import starlight.application.businessplan.required.PresignedUrlProviderPort; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -23,7 +24,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements starlight.application.aireport.required.PresignedUrlProviderPort, +public class NcpPresignedUrlProvider implements PresignedUrlProviderPort, starlight.application.backoffice.image.required.PresignedUrlProviderPort { private final S3Client ncpS3Client; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java similarity index 91% rename from src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java rename to src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java index 9727ada..04738a3 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileName.java @@ -1,4 +1,4 @@ -package starlight.adapter.backoffice.image.webapi.validation; +package starlight.adapter.shared.webapi.validation; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java similarity index 92% rename from src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java rename to src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java index a43ef88..ffdc1ca 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidImageFileNameValidator.java @@ -1,4 +1,4 @@ -package starlight.adapter.backoffice.image.webapi.validation; +package starlight.adapter.shared.webapi.validation; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java new file mode 100644 index 0000000..82d0fd0 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrl.java @@ -0,0 +1,23 @@ +package starlight.adapter.shared.webapi.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Constraint(validatedBy = ValidPdfUrlValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPdfUrl { + + String message() default "PDF URL 형식이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java new file mode 100644 index 0000000..58c2002 --- /dev/null +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java @@ -0,0 +1,23 @@ +package starlight.adapter.shared.webapi.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.net.URI; + +public class ValidPdfUrlValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (!StringUtils.hasText(value)) { + return false; + } + try { + URI.create(value); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java b/src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java similarity index 81% rename from src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java rename to src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java index 4417c3e..37b548e 100644 --- a/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java +++ b/src/main/java/starlight/application/businessplan/required/PresignedUrlProviderPort.java @@ -1,4 +1,4 @@ -package starlight.application.aireport.required; +package starlight.application.businessplan.required; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; diff --git a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java index 2c228e6..b185513 100644 --- a/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java +++ b/src/main/java/starlight/domain/businessplan/exception/BusinessPlanErrorType.java @@ -17,6 +17,7 @@ public enum BusinessPlanErrorType implements ErrorType { UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), SECTIONAL_CONTENT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 해당 Section 내용이 존재합니다."), SECTIONAL_CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 Section 내용이 존재하지 않습니다."), + INVALID_PDF_URL(HttpStatus.BAD_REQUEST, "PDF URL에 접근할 수 없거나 유효하지 않습니다."), ; private final HttpStatus status; diff --git a/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java index 521fcc1..94e02e9 100644 --- a/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/businessplan/webapi/BusinessPlanImageControllerIntegrationTest.java @@ -13,7 +13,7 @@ import org.springframework.test.web.servlet.MockMvc; import starlight.adapter.member.auth.security.auth.AuthDetails; import starlight.adapter.member.auth.security.filter.JwtFilter; -import starlight.application.aireport.required.PresignedUrlProviderPort; +import starlight.application.businessplan.required.PresignedUrlProviderPort; import starlight.bootstrap.SecurityConfig; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -26,7 +26,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @WebMvcTest( - controllers = BusinessPlanBusinessPlanImageController.class, + controllers = BusinessPlanImageController.class, excludeFilters = { @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = { JwtFilter.class, From f81bd43145acb68a6d8686fc5ce72c576a6cd290 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 17 Feb 2026 15:53:20 -0600 Subject: [PATCH 05/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존의 @URL 방식의 검증을 ValidPdfUrlValidator에 통일 - @NotBlank와 중첩되지 않도록 함 - PdfDownloadClient의 에러 메시지를 넘기도록 수정 --- .../aireport/webapi/dto/AiReportCreateWithPdfRequest.java | 3 --- .../shared/infrastructure/pdf/PdfDownloadClient.java | 4 ++-- .../shared/webapi/validation/ValidPdfUrlValidator.java | 8 +++++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java index 6c96d5f..a5d3c11 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -1,16 +1,13 @@ package starlight.adapter.aireport.webapi.dto; import jakarta.validation.constraints.NotBlank; -import org.hibernate.validator.constraints.URL; import starlight.adapter.shared.webapi.validation.ValidPdfUrl; public record AiReportCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") String title, - // TODO: 버킷 정책 등에 따라서 이후에 host 강제할 것 @NotBlank(message = "PDF URL은 필수입니다.") @ValidPdfUrl - @URL(protocol = "https", message = "https URL만 허용됩니다.") String pdfUrl ) {} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java index 1be8df5..26042b7 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClient.java @@ -57,8 +57,8 @@ public byte[] downloadFromUrl(String url) { } catch (PdfDownloadException e) { throw e; } catch (Exception e) { - log.error("PDF 다운로드 실패: {}", e.getMessage()); - throw new PdfDownloadException(PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); + log.error("PDF 다운로드 실패", e); + throw new PdfDownloadException(PdfDownloadErrorType.PDF_DOWNLOAD_ERROR, e); } } } diff --git a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java index 58c2002..9b10589 100644 --- a/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java +++ b/src/main/java/starlight/adapter/shared/webapi/validation/ValidPdfUrlValidator.java @@ -11,11 +11,13 @@ public class ValidPdfUrlValidator implements ConstraintValidator Date: Tue, 17 Feb 2026 16:12:33 -0600 Subject: [PATCH 06/12] =?UTF-8?q?[SRLT-126]=20Fix:=20PDF=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD=EC=97=90=20?= =?UTF-8?q?=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pdf/PdfDownloadClientIntegrationTest.java | 16 +++++++-------- .../pdf/PdfDownloadClientTest.java | 20 +++++++++---------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java index b87b1c1..173eb5b 100644 --- a/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientIntegrationTest.java @@ -9,8 +9,8 @@ import org.junit.jupiter.api.Test; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; import java.io.IOException; import java.time.Duration; @@ -83,8 +83,8 @@ void downloadFromUrl_Returns404_ThrowsException() { // when & then assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test @@ -99,8 +99,8 @@ void downloadFromUrl_Returns500_ThrowsException() { // when & then assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test @@ -115,8 +115,8 @@ void downloadFromUrl_EmptyBody_ThrowsException() { // when & then assertThatThrownBy(() -> pdfDownloadClient.downloadFromUrl(url)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test diff --git a/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java index cc40f4c..8a67377 100644 --- a/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java +++ b/src/test/java/starlight/adapter/shared/infrastructure/pdf/PdfDownloadClientTest.java @@ -5,8 +5,8 @@ import org.junit.jupiter.api.Test; import org.springframework.http.ResponseEntity; import org.springframework.web.client.RestClient; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrErrorType; -import starlight.adapter.aireport.infrastructure.ocr.exception.OcrException; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadErrorType; +import starlight.adapter.shared.infrastructure.pdf.exception.PdfDownloadException; import java.net.URI; @@ -71,8 +71,8 @@ void downloadFromUrl_ThrowsException_WhenResponseIsEmpty() { // when & then assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test @@ -88,8 +88,8 @@ void downloadFromUrl_ThrowsException_WhenResponseBodyIsNull() { // when & then assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_EMPTY_RESPONSE); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_EMPTY_RESPONSE); } @Test @@ -106,8 +106,8 @@ void downloadFromUrl_ThrowsException_WhenPdfIsTooLarge() { // when & then assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_TOO_LARGE); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_TOO_LARGE); } @Test @@ -139,8 +139,8 @@ void downloadFromUrl_ThrowsException_WhenNetworkError() { // when & then assertThatThrownBy(() -> pdfDownloadClientInstance.downloadFromUrl(TEST_URL)) - .isInstanceOf(OcrException.class) - .hasFieldOrPropertyWithValue("errorType", OcrErrorType.PDF_DOWNLOAD_ERROR); + .isInstanceOf(PdfDownloadException.class) + .hasFieldOrPropertyWithValue("errorType", PdfDownloadErrorType.PDF_DOWNLOAD_ERROR); } @Test From eb2b773758203da0589002b00cb276c2dd116ffb Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 18 Feb 2026 18:44:38 -0600 Subject: [PATCH 07/12] =?UTF-8?q?[SRLT-126]=20Fix:=20test=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 6b9bea1..7ef7252 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,8 @@ jobs: - name: Test with Gradle run: ./gradlew --info test + env: + SPRING_PROFILES_ACTIVE: test - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 From 00a14d73c81e2d8abf17727d42e3f6c9890c85c0 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 18 Feb 2026 18:54:29 -0600 Subject: [PATCH 08/12] =?UTF-8?q?[SRLT-126]=20Fix:=20test=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=20=EC=84=A4=EC=A0=95=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 7ef7252..6b9bea1 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,8 +32,6 @@ jobs: - name: Test with Gradle run: ./gradlew --info test - env: - SPRING_PROFILES_ACTIVE: test - name: Publish Test Results uses: EnricoMi/publish-unit-test-result-action@v2 From 5305371ab0c7bbd61ece93db47a05a0959cb2af7 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 18 Feb 2026 18:56:27 -0600 Subject: [PATCH 09/12] =?UTF-8?q?[SRLT-124]=20Chore:=20config=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config b/config index 403ba83..eeb1c88 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 403ba8356845b8b8d9c0cab9ec8a47253d6bdf74 +Subproject commit eeb1c88cdd8dff82ae5cb6a0392d02ba517140cf From 019613c75088f9cfa14c71062fb597b2c82333e0 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 18 Feb 2026 23:13:33 -0600 Subject: [PATCH 10/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20=ED=97=A5?= =?UTF-8?q?=EC=82=AC=EA=B3=A0=EB=82=A0=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20AiReportResponseParser=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존의 AiReportResponseParser의 DTO/Entity 변환 로직은 AiReportResult로 로직 이동 - AiReportResponseParser를 adapter/aireport/report/parser로 패키지 이동 - BusinessPlanContentExtractor를 application/aireport/util로 이동 --- .../aireport/persistence/AiReportJpa.java | 5 +- .../aireport/report/SpringAiReportGrader.java | 4 - .../impl/SpringAiFullReportGradeAgent.java | 2 +- .../agent/impl/SpringAiSectionGradeAgent.java | 2 +- .../parser}/AiReportResponseParser.java | 257 +++--------------- .../supervisor/SpringAiReportSupervisor.java | 2 +- .../application/aireport/AiReportService.java | 17 +- .../aireport/provided/dto/AiReportResult.java | 170 +++++++++++- .../util/BusinessPlanContentExtractor.java | 2 +- .../AiReportSectionAdvisorConfig.java | 2 +- .../report/SpringAiReportGraderTest.java | 5 +- .../AiReportServiceIntegrationTest.java | 4 +- .../aireport/AiReportServiceUnitTest.java | 16 +- .../util/AiReportResponseParserTest.java | 2 +- 14 files changed, 225 insertions(+), 265 deletions(-) rename src/main/java/starlight/{application/aireport/util => adapter/aireport/report/parser}/AiReportResponseParser.java (54%) rename src/main/java/starlight/application/{businessplan => aireport}/util/BusinessPlanContentExtractor.java (99%) diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index 1627a73..3c86329 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.expert.required.AiReportSummaryLookupPort; @@ -19,7 +19,6 @@ public class AiReportJpa implements AiReportCommandPort, AiReportQueryPort, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; - private final AiReportResponseParser responseParser; @Override public AiReport save(AiReport aiReport) { @@ -41,7 +40,7 @@ public Map findTotalScoresByBusinessPlanIds(List businessPl Map totalScoreMap = new HashMap<>(); for (AiReport report : reports) { - Integer totalScore = responseParser.toResponse(report).totalScore(); + Integer totalScore = AiReportResult.from(report).totalScore(); totalScoreMap.put(report.getBusinessPlanId(), totalScore != null ? totalScore : 0); } diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index fa1fe7b..a5d4371 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -9,7 +9,6 @@ import starlight.adapter.aireport.report.supervisor.SpringAiReportSupervisor; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.ReportGraderPort; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.shared.enumerate.SectionType; @@ -31,14 +30,12 @@ public class SpringAiReportGrader implements ReportGraderPort { private final Map sectionGradeAgentMap; private final FullReportGradeAgent fullReportGradeAgent; private final SpringAiReportSupervisor supervisor; - private final BusinessPlanContentExtractor contentExtractor; private final Executor sectionGradingExecutor; public SpringAiReportGrader( List sectionGradeAgentList, FullReportGradeAgent fullReportGradeAgent, SpringAiReportSupervisor supervisor, - BusinessPlanContentExtractor contentExtractor, @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { try { this.sectionGradeAgentMap = sectionGradeAgentList.stream() @@ -51,7 +48,6 @@ public SpringAiReportGrader( } this.fullReportGradeAgent = fullReportGradeAgent; this.supervisor = supervisor; - this.contentExtractor = contentExtractor; this.sectionGradingExecutor = sectionGradingExecutor; } diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java index 01ca547..93a3626 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java @@ -11,7 +11,7 @@ import starlight.adapter.aireport.report.agent.FullReportGradeAgent; import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; diff --git a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java index 0de224e..e4793d1 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -12,7 +12,7 @@ import starlight.adapter.aireport.report.dto.SectionGradingResult; import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; diff --git a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java similarity index 54% rename from src/main/java/starlight/application/aireport/util/AiReportResponseParser.java rename to src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java index ff27dd9..ca71df6 100644 --- a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java +++ b/src/main/java/starlight/adapter/aireport/report/parser/AiReportResponseParser.java @@ -1,18 +1,14 @@ -package starlight.application.aireport.util; +package starlight.adapter.aireport.report.parser; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ArrayNode; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; -import java.util.ArrayList; import java.util.List; @Slf4j @@ -22,103 +18,6 @@ public class AiReportResponseParser { private final ObjectMapper objectMapper; - /** - * AiReportResponse를 JsonNode로 변환 (저장용) - * 또는 JsonNode에서 AiReportResponse로 변환 (조회용) - * 통합된 변환 메소드 - */ - public JsonNode convertToJsonNode(AiReportResult response) { - ObjectNode rootNode = objectMapper.createObjectNode(); - - // 점수 필드 - rootNode.put("problemRecognitionScore", - response.problemRecognitionScore() != null ? response.problemRecognitionScore() : 0); - rootNode.put("feasibilityScore", - response.feasibilityScore() != null ? response.feasibilityScore() : 0); - rootNode.put("growthStrategyScore", - response.growthStrategyScore() != null ? response.growthStrategyScore() : 0); - rootNode.put("teamCompetenceScore", - response.teamCompetenceScore() != null ? response.teamCompetenceScore() : 0); - - // 강점 배열 - ArrayNode strengthsArray = rootNode.putArray("strengths"); - if (response.strengths() != null) { - for (AiReportResult.StrengthWeakness strength : response.strengths()) { - ObjectNode strengthNode = strengthsArray.addObject(); - strengthNode.put("title", strength.title() != null ? strength.title() : ""); - strengthNode.put("content", strength.content() != null ? strength.content() : ""); - } - } - - // 약점 배열 - ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); - if (response.weaknesses() != null) { - for (AiReportResult.StrengthWeakness weakness : response.weaknesses()) { - ObjectNode weaknessNode = weaknessesArray.addObject(); - weaknessNode.put("title", weakness.title() != null ? weakness.title() : ""); - weaknessNode.put("content", weakness.content() != null ? weakness.content() : ""); - } - } - - // 섹션별 점수 배열: sectionType과 gradingListScores - ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); - if (response.sectionScores() != null) { - for (AiReportResult.SectionScoreDetailResponse sectionScore : response.sectionScores()) { - ObjectNode sectionScoreNode = sectionScoresArray.addObject(); - sectionScoreNode.put("sectionType", - sectionScore.sectionType() != null ? sectionScore.sectionType() : ""); - sectionScoreNode.put("gradingListScores", - sectionScore.gradingListScores() != null ? sectionScore.gradingListScores() : "[]"); - } - } - - return rootNode; - } - - /** - * AiReport에서 AiReportResponse로 변환 - * 파싱 로직은 AiReportResponseParser를 재사용하고, id와 businessPlanId만 추가 - */ - public AiReportResult toResponse(AiReport aiReport) { - JsonNode jsonNode = aiReport.getRawJson().asTree(); - - // 공통 파싱 로직 재사용 - AiReportResult baseResponse = parseFromJsonNode(jsonNode); - - // totalScore 계산 - Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() - : 0) + - (baseResponse.feasibilityScore() != null ? baseResponse.feasibilityScore() : 0) + - (baseResponse.growthStrategyScore() != null ? baseResponse.growthStrategyScore() : 0) + - (baseResponse.teamCompetenceScore() != null ? baseResponse.teamCompetenceScore() : 0); - - // id와 businessPlanId를 포함하여 새 인스턴스 생성 - return new AiReportResult( - aiReport.getId(), - aiReport.getBusinessPlanId(), - totalScore, - baseResponse.problemRecognitionScore(), - baseResponse.feasibilityScore(), - baseResponse.growthStrategyScore(), - baseResponse.teamCompetenceScore(), - baseResponse.sectionScores(), - baseResponse.strengths(), - baseResponse.weaknesses()); - } - - /** - * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 - */ - private boolean isDefaultResponse(AiReportResult response) { - return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) && - (response.feasibilityScore() == null || response.feasibilityScore() == 0) && - (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) && - (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) && - (response.strengths() == null || response.strengths().isEmpty()) && - (response.weaknesses() == null || response.weaknesses().isEmpty()) && - (response.sectionScores() == null || response.sectionScores().isEmpty()); - } - /** * LLM 응답 문자열을 AiReportResponse로 파싱 (전체 리포트용) * 4개의 전체 점수 필드를 모두 요구 @@ -147,7 +46,7 @@ public AiReportResult parse(String llmResponse) { } // 5. 파싱 시도 - AiReportResult response = parseFromJsonNode(jsonNode); + AiReportResult response = AiReportResult.fromJsonNode(jsonNode); // 6. 파싱된 값이 기본값인지 확인 if (isDefaultResponse(response)) { @@ -208,8 +107,7 @@ public AiReportResult parseSectionResponse(String llmResponse) { } // 5. 섹션별 응답 파싱 (없는 필드는 null로 설정) - List sectionScores = parseSectionScores( - jsonNode.path("sectionScores")); + List sectionScores = AiReportResult.fromJsonNode(jsonNode).sectionScores(); // strengths와 weaknesses는 섹션별 응답에는 없음 return AiReportResult.fromGradingResult( @@ -227,6 +125,35 @@ public AiReportResult parseSectionResponse(String llmResponse) { } } + /** + * 강점/약점 리스트 파싱 (슈퍼바이저용) + */ + public List parseStrengthWeakness(String llmResponse, String type) { + try { + String cleanedJson = cleanJsonResponse(llmResponse); + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + JsonNode targetNode = jsonNode.path(type); + return AiReportResult.StrengthWeakness.listFromJsonNode(targetNode); + } catch (Exception e) { + log.error("Failed to parse strength/weakness from supervisor response. Type: {}", type, e); + return List.of(); + } + } + + /** + * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 + */ + private boolean isDefaultResponse(AiReportResult response) { + return (response.problemRecognitionScore() == null || response.problemRecognitionScore() == 0) && + (response.feasibilityScore() == null || response.feasibilityScore() == 0) && + (response.growthStrategyScore() == null || response.growthStrategyScore() == 0) && + (response.teamCompetenceScore() == null || response.teamCompetenceScore() == 0) && + (response.strengths() == null || response.strengths().isEmpty()) && + (response.weaknesses() == null || response.weaknesses().isEmpty()) && + (response.sectionScores() == null || response.sectionScores().isEmpty()); + } + /** * JSON 응답 문자열 정리 및 복구 */ @@ -362,124 +289,4 @@ private String repairIncompleteJson(String json) { return repaired.toString(); } - - /** - * JsonNode를 파싱하여 AiReportResponse로 변환 - */ - private AiReportResult parseFromJsonNode(JsonNode jsonNode) { - Integer problemRecognitionScore = null; - Integer feasibilityScore = null; - Integer growthStrategyScore = null; - Integer teamCompetenceScore = null; - - if (jsonNode.has("problemRecognitionScore") && !jsonNode.path("problemRecognitionScore").isNull()) { - problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(); - } - if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { - feasibilityScore = jsonNode.path("feasibilityScore").asInt(); - } - if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { - growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(); - } - if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { - teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(); - } - - // 강점 파싱 - List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); - - // 약점 파싱 - List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); - - // sectionScores 파싱: sectionType과 gradingListScores만 포함 - List sectionScores = parseSectionScores( - jsonNode.path("sectionScores")); - - return AiReportResult.fromGradingResult( - problemRecognitionScore, - feasibilityScore, - growthStrategyScore, - teamCompetenceScore, - sectionScores, - strengths, - weaknesses); - } - - /** - * 강점/약점 리스트 파싱 (슈퍼바이저용) - */ - public List parseStrengthWeakness(String llmResponse, String type) { - try { - String cleanedJson = cleanJsonResponse(llmResponse); - JsonNode jsonNode = objectMapper.readTree(cleanedJson); - - JsonNode targetNode = jsonNode.path(type); - return parseStrengthWeaknessList(targetNode); - } catch (Exception e) { - log.error("Failed to parse strength/weakness from supervisor response. Type: {}", type, e); - return List.of(); - } - } - - /** - * 강점/약점 리스트 파싱 - */ - private List parseStrengthWeaknessList(JsonNode node) { - List list = new ArrayList<>(); - if (node.isArray()) { - for (JsonNode itemNode : node) { - list.add(new AiReportResult.StrengthWeakness( - itemNode.path("title").asText(""), - itemNode.path("content").asText(""))); - } - } - return list; - } - - /** - * 섹션 점수 리스트 파싱 - * 불완전한 항목은 건너뛰거나 기본값으로 대체 - */ - private List parseSectionScores(JsonNode node) { - List list = new ArrayList<>(); - if (node.isArray()) { - for (JsonNode sectionScoreNode : node) { - try { - String sectionType = sectionScoreNode.path("sectionType").asText(""); - JsonNode gradingListNode = sectionScoreNode.path("gradingListScores"); - String gradingListScores; - - // gradingListScores가 JSON 배열(ArrayNode)인 경우와 문자열(TextNode)인 경우 모두 처리 - if (gradingListNode.isArray()) { - gradingListScores = objectMapper.writeValueAsString(gradingListNode); - } else { - gradingListScores = gradingListNode.asText("[]"); - } - - // gradingListScores가 유효한 JSON 문자열인지 검증 - if (!gradingListScores.equals("[]") && !gradingListScores.isEmpty()) { - try { - // JSON 배열 형식인지 확인 - if (!gradingListScores.trim().startsWith("[")) { - log.warn("Invalid gradingListScores format for sectionType: {}, using default", - sectionType); - gradingListScores = "[]"; - } else { - // JSON 파싱 가능 여부 확인 - objectMapper.readTree(gradingListScores); - } - } catch (Exception e) { - gradingListScores = "[]"; - } - } - - list.add(new AiReportResult.SectionScoreDetailResponse(sectionType, gradingListScores)); - } catch (Exception e) { - log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); - } - } - } - return list; - } - } diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java index ca0178c..e2d37ad 100644 --- a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java @@ -11,7 +11,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import starlight.adapter.aireport.report.dto.SectionGradingResult; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import java.util.HashMap; diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 5de7c3b..1422a21 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -10,8 +10,7 @@ import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.*; -import starlight.application.aireport.util.AiReportResponseParser; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -32,10 +31,8 @@ public class AiReportService implements AiReportUseCase { private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; - private final ReportGraderPort reportGrader; + private final ReportGraderPort reportGraderPort; private final ObjectMapper objectMapper; - private final OcrProviderPort ocrProvider; - private final AiReportResponseParser responseParser; private final BusinessPlanContentExtractor contentExtractor; @Override @@ -57,7 +54,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); } - AiReportResult gradingResult = reportGrader.gradeWithSectionAgents(sectionContents, fullContent); + AiReportResult gradingResult = reportGraderPort.gradeWithSectionAgents(sectionContents, fullContent); // 채점 결과 검증 if (isInvalidGradingResult(gradingResult)) { @@ -71,7 +68,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } @Override @@ -117,11 +114,11 @@ public AiReportResult getAiReport(Long planId, Long memberId) { AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } - private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradingResult) { - JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + private String getRawJsonStrFromAiReportResult(AiReportResult gradingResult) { + JsonNode gradingJsonNode = gradingResult.toJsonNode(); String rawJsonString; try { rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java index a5135d4..bc5d9ce 100644 --- a/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java @@ -1,6 +1,13 @@ package starlight.application.aireport.provided.dto; import com.fasterxml.jackson.annotation.JsonRawValue; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import starlight.domain.aireport.entity.AiReport; + +import java.util.ArrayList; import java.util.List; /** @@ -22,12 +29,67 @@ public record AiReportResult( public record SectionScoreDetailResponse( String sectionType, @JsonRawValue String gradingListScores - ) {} - + ) { + public static SectionScoreDetailResponse fromJsonNode(JsonNode node) { + String sectionType = node.path("sectionType").asText(""); + JsonNode gradingListNode = node.path("gradingListScores"); + String gradingListScores; + if (gradingListNode != null && gradingListNode.isArray()) { + gradingListScores = gradingListNode.toString(); + } else { + gradingListScores = gradingListNode != null ? gradingListNode.asText("[]") : "[]"; + } + if (!gradingListScores.equals("[]") && !gradingListScores.isEmpty() && !gradingListScores.trim().startsWith("[")) { + gradingListScores = "[]"; + } + return new SectionScoreDetailResponse(sectionType, gradingListScores); + } + + /** + * JsonNode 배열에서 리스트 생성 + */ + public static List listFromJsonNode(JsonNode arrayNode) { + List list = new ArrayList<>(); + if (arrayNode == null || !arrayNode.isArray()) { + return list; + } + for (JsonNode node : arrayNode) { + try { + list.add(fromJsonNode(node)); + } catch (Exception e) { + // 항목 스킵 + } + } + return list; + } + } + public record StrengthWeakness( String title, String content - ) {} + ) { + /** + * 단일 JsonNode에서 인스턴스 생성 + */ + public static StrengthWeakness fromJsonNode(JsonNode node) { + return new StrengthWeakness( + node.path("title").asText(""), + node.path("content").asText("")); + } + + /** + * JsonNode 배열에서 리스트 생성 + */ + public static List listFromJsonNode(JsonNode arrayNode) { + List list = new ArrayList<>(); + if (arrayNode != null && arrayNode.isArray()) { + for (JsonNode node : arrayNode) { + list.add(fromJsonNode(node)); + } + } + return list; + } + } /** * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) @@ -63,5 +125,107 @@ private static Integer sumTotalScore(Integer problemRecognitionScore, Integer fe (growthStrategyScore != null ? growthStrategyScore : 0) + (teamCompetenceScore != null ? teamCompetenceScore : 0); } + + /** + * AiReport 엔티티에서 API 응답 DTO로 변환 (id, businessPlanId 포함) + */ + public static AiReportResult from(AiReport aiReport) { + JsonNode jsonNode = aiReport.getRawJson().asTree(); + + AiReportResult base = fromJsonNode(jsonNode); + + Integer totalScore = sumTotalScore( + base.problemRecognitionScore(), + base.feasibilityScore(), + base.growthStrategyScore(), + base.teamCompetenceScore()); + + return new AiReportResult( + aiReport.getId(), + aiReport.getBusinessPlanId(), + totalScore, + base.problemRecognitionScore(), + base.feasibilityScore(), + base.growthStrategyScore(), + base.teamCompetenceScore(), + base.sectionScores(), + base.strengths(), + base.weaknesses()); + } + + /** + * 저장된 JSON(JsonNode)에서 DTO로 변환 (id, businessPlanId는 null) + * 엔티티 변환 및 LLM 파싱 결과 조립 시 공통 사용 + */ + public static AiReportResult fromJsonNode(JsonNode jsonNode) { + Integer problemRecognitionScore = null; + Integer feasibilityScore = null; + Integer growthStrategyScore = null; + Integer teamCompetenceScore = null; + + if (jsonNode.has("problemRecognitionScore") && !jsonNode.path("problemRecognitionScore").isNull()) { + problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(); + } + if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { + feasibilityScore = jsonNode.path("feasibilityScore").asInt(); + } + if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { + growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(); + } + if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { + teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(); + } + + List strengths = StrengthWeakness.listFromJsonNode(jsonNode.path("strengths")); + List weaknesses = StrengthWeakness.listFromJsonNode(jsonNode.path("weaknesses")); + List sectionScores = SectionScoreDetailResponse.listFromJsonNode(jsonNode.path("sectionScores")); + + return fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); + } + + /** + * 저장용 JsonNode로 변환 (엔티티 raw_json 형식과 동일) + */ + public JsonNode toJsonNode() { + ObjectNode rootNode = JsonNodeFactory.instance.objectNode(); + rootNode.put("problemRecognitionScore", problemRecognitionScore() != null ? problemRecognitionScore() : 0); + rootNode.put("feasibilityScore", feasibilityScore() != null ? feasibilityScore() : 0); + rootNode.put("growthStrategyScore", growthStrategyScore() != null ? growthStrategyScore() : 0); + rootNode.put("teamCompetenceScore", teamCompetenceScore() != null ? teamCompetenceScore() : 0); + + ArrayNode strengthsArray = rootNode.putArray("strengths"); + if (strengths() != null) { + for (StrengthWeakness s : strengths()) { + ObjectNode n = strengthsArray.addObject(); + n.put("title", s.title() != null ? s.title() : ""); + n.put("content", s.content() != null ? s.content() : ""); + } + } + ArrayNode weaknessesArray = rootNode.putArray("weaknesses"); + if (weaknesses() != null) { + for (StrengthWeakness w : weaknesses()) { + ObjectNode n = weaknessesArray.addObject(); + n.put("title", w.title() != null ? w.title() : ""); + n.put("content", w.content() != null ? w.content() : ""); + } + } + ArrayNode sectionScoresArray = rootNode.putArray("sectionScores"); + if (sectionScores() != null) { + for (SectionScoreDetailResponse ss : sectionScores()) { + ObjectNode n = sectionScoresArray.addObject(); + n.put("sectionType", ss.sectionType() != null ? ss.sectionType() : ""); + n.put("gradingListScores", ss.gradingListScores() != null ? ss.gradingListScores() : "[]"); + } + } + return rootNode; + } + } diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java similarity index 99% rename from src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java rename to src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java index 5555c48..d3a2b08 100644 --- a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/aireport/util/BusinessPlanContentExtractor.java @@ -1,4 +1,4 @@ -package starlight.application.businessplan.util; +package starlight.application.aireport.util; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java index eacd22b..2c859dd 100644 --- a/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java +++ b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java @@ -9,7 +9,7 @@ import starlight.adapter.aireport.report.circuitbreaker.SectionGradingCircuitBreaker; import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.shared.enumerate.SectionType; import java.util.Arrays; diff --git a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java index 79f770e..73ca7b8 100644 --- a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java +++ b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java @@ -7,7 +7,6 @@ import starlight.adapter.aireport.report.dto.SectionGradingResult; import starlight.adapter.aireport.report.supervisor.SpringAiReportSupervisor; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.shared.enumerate.SectionType; import java.util.HashMap; @@ -44,7 +43,6 @@ void gradeWithFullPrompt_returnsAiReportResult() { List.of(), fullReportGradeAgent, mock(SpringAiReportSupervisor.class), - mock(BusinessPlanContentExtractor.class), mock(Executor.class) ); @@ -126,7 +124,7 @@ void gradeWithSectionAgents_returnsAiReportResult() { FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); SpringAiReportSupervisor supervisor = mock(SpringAiReportSupervisor.class); - BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); + // 실제 Executor 사용 (비동기 실행을 위해) Executor executor = Executors.newFixedThreadPool(4); @@ -134,7 +132,6 @@ void gradeWithSectionAgents_returnsAiReportResult() { sectionAgents, fullReportGradeAgent, supervisor, - contentExtractor, executor ); diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 5de51af..01a08c8 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -10,7 +10,7 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; @@ -24,7 +24,7 @@ import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; import starlight.application.aireport.required.BusinessPlanQueryLookupPort; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 1cc0c19..e164470 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; @@ -11,7 +11,7 @@ import starlight.application.aireport.required.OcrProviderPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; import starlight.application.aireport.required.BusinessPlanQueryLookupPort; -import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.application.aireport.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -94,7 +94,7 @@ void gradeBusinessPlan_createsNewReport() { when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -156,7 +156,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(aiReportCommand.save(existingReport)).thenReturn(existingReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -178,7 +178,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -198,7 +198,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -236,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -260,7 +260,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java index 80fff9e..4b67f16 100644 --- a/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java @@ -3,7 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.util.AiReportResponseParser; +import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; From b8a25487a38f5f8175db76c1cde66d3075c771b1 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Thu, 19 Feb 2026 00:54:48 -0600 Subject: [PATCH 11/12] =?UTF-8?q?[SRLT-126]=20Refactor:=20=ED=97=A5?= =?UTF-8?q?=EC=82=AC=EA=B3=A0=EB=82=A0=20=EC=9D=98=EC=A1=B4=EC=84=B1?= =?UTF-8?q?=EC=97=90=20=EB=A7=9E=EA=B2=8C=20AiReportResponseParser=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존의 AiReportResponseParser의 DTO/Entity 변환 로직은 AiReportResult로 로직 이동 --- .../application/aireport/AiReportService.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 1422a21..a5cebdc 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -32,6 +32,7 @@ public class AiReportService implements AiReportUseCase { private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGraderPort; + private final OcrProviderPort ocrProviderPort; private final ObjectMapper objectMapper; private final BusinessPlanContentExtractor contentExtractor; @@ -64,7 +65,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("채점 완료. 총점: {}, planId: {}", gradingResult.totalScore(), planId); - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + String rawJsonString = getRawJsonStrFromAiReportResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); @@ -79,7 +80,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); - String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + String pdfText = ocrProviderPort.ocrPdfTextByUrl(pdfUrl); log.debug("OCR 완료. 텍스트 길이: {}", pdfText != null ? pdfText.length() : 0); if (pdfText == null || pdfText.trim().isEmpty()) { @@ -88,7 +89,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, } // PDF의 경우 기존 한 번에 LLM에 돌리는 방식을 사용 - AiReportResult gradingResult = reportGrader.gradeWithFullPrompt(pdfText); + AiReportResult gradingResult = reportGraderPort.gradeWithFullPrompt(pdfText); // 채점 결과 검증 if (isInvalidGradingResult(gradingResult)) { @@ -98,11 +99,11 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, log.info("PDF 채점 완료. 총점: {}, businessPlanId: {}", gradingResult.totalScore(), businessPlanId); - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); + String rawJsonString = getRawJsonStrFromAiReportResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReport); + return AiReportResult.from(aiReport); } @Override From 9d43a8088dfbb455a2d4d34db153196c522c4f85 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Thu, 19 Feb 2026 00:58:38 -0600 Subject: [PATCH 12/12] =?UTF-8?q?[SRLT-126]=20Fix:=20ocrProvider=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/AiReportServiceUnitTest.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index e164470..9ba3371 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -94,7 +94,7 @@ void gradeBusinessPlan_createsNewReport() { when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -156,7 +156,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(aiReportCommand.save(existingReport)).thenReturn(existingReport); when(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -178,7 +178,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -198,7 +198,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -236,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -260,7 +260,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, ocrProvider, objectMapper, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId))